Add tenant merge review page
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 39s
Build and Push Docker Images / build-frontend (push) Successful in 1m43s
Build and Push Docker Images / build-central-services-api (push) Successful in 23s
Build and Push Docker Images / build-docs (push) Successful in 24s
Build and Push Docker Images / build-website (push) Successful in 26s
Build and Push Docker Images / build-central-services-admin (push) Successful in 24s
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 39s
Build and Push Docker Images / build-frontend (push) Successful in 1m43s
Build and Push Docker Images / build-central-services-api (push) Successful in 23s
Build and Push Docker Images / build-docs (push) Successful in 24s
Build and Push Docker Images / build-website (push) Successful in 26s
Build and Push Docker Images / build-central-services-admin (push) Successful in 24s
This commit is contained in:
@@ -61,6 +61,35 @@ export type TenantImportResult = {
|
||||
filesDone?: number
|
||||
filesTotal?: number
|
||||
error?: string | null
|
||||
reviewUrl?: string
|
||||
}
|
||||
|
||||
export type TenantMergeKind = "import" | "existing" | "conflict" | "id_collision"
|
||||
export type TenantMergeDecision = "source" | "target"
|
||||
export type TenantMergeItem = {
|
||||
id: string
|
||||
table: string
|
||||
kind: TenantMergeKind
|
||||
label: string
|
||||
sourceKey: string
|
||||
targetKey: string | null
|
||||
defaultDecision: TenantMergeDecision
|
||||
allowedDecisions: TenantMergeDecision[]
|
||||
differences: string[]
|
||||
sourceRow: Record<string, any>
|
||||
targetRow: Record<string, any> | null
|
||||
}
|
||||
export type TenantMergeReview = {
|
||||
importId: string
|
||||
tenantId: number
|
||||
status: string
|
||||
filename: string
|
||||
summary: Record<TenantMergeKind, number>
|
||||
tables: string[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
items: TenantMergeItem[]
|
||||
}
|
||||
|
||||
export type TenantExportJob = {
|
||||
@@ -222,6 +251,20 @@ export const useAdmin = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getTenantImportReview = async (
|
||||
importId: string,
|
||||
query: { kind?: string; table?: string; offset?: number; limit?: number } = {},
|
||||
): Promise<TenantMergeReview> => {
|
||||
return await $api(`/api/admin/tenant-imports/${importId}/review`, { query })
|
||||
}
|
||||
|
||||
const executeTenantImportMerge = async (importId: string, decisions: Record<string, TenantMergeDecision>) => {
|
||||
return await $api(`/api/admin/tenant-imports/${importId}/execute`, {
|
||||
method: "POST",
|
||||
body: { decisions },
|
||||
})
|
||||
}
|
||||
|
||||
const getSystemStatus = async (): Promise<SystemStatus> => {
|
||||
return await $api("/api/admin/system-status")
|
||||
}
|
||||
@@ -262,5 +305,7 @@ export const useAdmin = () => {
|
||||
getTenantExport,
|
||||
downloadTenantExport,
|
||||
importTenant,
|
||||
getTenantImportReview,
|
||||
executeTenantImportMerge,
|
||||
}
|
||||
}
|
||||
|
||||
248
frontend/pages/administration/tenant-imports/[importId].vue
Normal file
248
frontend/pages/administration/tenant-imports/[importId].vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import type { TenantMergeDecision, TenantMergeItem, TenantMergeKind, TenantMergeReview } from "~/composables/useAdmin"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const admin = useAdmin()
|
||||
const importId = String(route.params.importId)
|
||||
|
||||
const loading = ref(true)
|
||||
const executing = ref(false)
|
||||
const executionResult = ref<any>(null)
|
||||
const review = ref<TenantMergeReview | null>(null)
|
||||
const tenantId = computed(() => review.value?.tenantId || 0)
|
||||
const decisions = reactive<Record<string, TenantMergeDecision>>({})
|
||||
const conflictItems = ref<TenantMergeItem[]>([])
|
||||
const kindFilter = ref<string>("")
|
||||
const tableFilter = ref<string>("")
|
||||
const offset = ref(0)
|
||||
const limit = 100
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "Alle Arten", value: "" },
|
||||
{ label: "Wird importiert", value: "import" },
|
||||
{ label: "Bereits vorhanden", value: "existing" },
|
||||
{ label: "Konflikt", value: "conflict" },
|
||||
{ label: "ID wird remappt", value: "id_collision" },
|
||||
]
|
||||
const kindLabels: Record<TenantMergeKind, string> = {
|
||||
import: "Wird importiert",
|
||||
existing: "Bereits vorhanden",
|
||||
conflict: "Konflikt",
|
||||
id_collision: "ID wird remappt",
|
||||
}
|
||||
const kindColors: Record<TenantMergeKind, string> = {
|
||||
import: "success",
|
||||
existing: "neutral",
|
||||
conflict: "warning",
|
||||
id_collision: "info",
|
||||
}
|
||||
|
||||
const unresolvedConflicts = computed(() => conflictItems.value.filter((item) => !decisions[item.id]).length)
|
||||
const hasNextPage = computed(() => Boolean(review.value && review.value.offset + review.value.items.length < review.value.total))
|
||||
|
||||
const loadConflictDecisions = async () => {
|
||||
conflictItems.value = []
|
||||
let currentOffset = 0
|
||||
while (true) {
|
||||
const page = await admin.getTenantImportReview(importId, { kind: "conflict", offset: currentOffset, limit: 500 })
|
||||
conflictItems.value.push(...page.items)
|
||||
currentOffset += page.items.length
|
||||
if (!page.items.length || currentOffset >= page.total) break
|
||||
}
|
||||
}
|
||||
|
||||
const loadReview = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
review.value = await admin.getTenantImportReview(importId, {
|
||||
kind: kindFilter.value || undefined,
|
||||
table: tableFilter.value || undefined,
|
||||
offset: offset.value,
|
||||
limit,
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Merge-Bericht konnte nicht geladen werden",
|
||||
description: err?.data?.error || err?.message,
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetAndLoad = async () => {
|
||||
offset.value = 0
|
||||
await loadReview()
|
||||
}
|
||||
|
||||
const setAllConflicts = (decision: TenantMergeDecision) => {
|
||||
for (const item of conflictItems.value) decisions[item.id] = decision
|
||||
}
|
||||
|
||||
const executeMerge = async () => {
|
||||
if (unresolvedConflicts.value || executing.value) return
|
||||
executing.value = true
|
||||
try {
|
||||
const result: any = await admin.executeTenantImportMerge(importId, decisions)
|
||||
executionResult.value = result
|
||||
toast.add({
|
||||
title: "Tenant-Merge abgeschlossen",
|
||||
description: `${result?.result?.imported || 0} importiert, ${result?.result?.updated || 0} aus der Quelle übernommen.`,
|
||||
color: "green",
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: err?.data?.reviewRequired ? "Dry-Run wurde aktualisiert" : "Tenant-Merge fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message,
|
||||
color: "red",
|
||||
})
|
||||
if (err?.data?.reviewRequired) {
|
||||
Object.keys(decisions).forEach((key) => delete decisions[key])
|
||||
await loadConflictDecisions()
|
||||
await loadReview()
|
||||
}
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadReview(), loadConflictDecisions()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Tenant-Import prüfen</h1>
|
||||
<p class="text-sm text-gray-500">Dry-Run {{ importId }}</p>
|
||||
</div>
|
||||
<UButton color="neutral" variant="soft" icon="i-heroicons-arrow-left" @click="router.push(`/administration/tenants/${tenantId}`)">
|
||||
Zurück zum Tenant
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
title="Noch keine Daten wurden verändert"
|
||||
description="Importierte und bereits vorhandene Datensätze werden automatisch behandelt. Für jeden Konflikt ist vor der Ausführung eine Entscheidung erforderlich."
|
||||
color="info"
|
||||
variant="soft"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="executionResult"
|
||||
title="Tenant-Merge abgeschlossen"
|
||||
:description="`${executionResult.result.imported} Datensätze importiert, ${executionResult.result.updated} aus der Quelle übernommen, ${executionResult.result.retained} im Ziel beibehalten und ${executionResult.result.remappedIds} IDs remappt. Dateien: ${executionResult.files.restored} wiederhergestellt, ${executionResult.files.skipped} übersprungen.`"
|
||||
color="success"
|
||||
variant="soft"
|
||||
/>
|
||||
|
||||
<UCard v-if="executionResult">
|
||||
<h2 class="mb-3 text-lg font-semibold">Ausführung nach Tabelle</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead><tr><th class="py-2">Tabelle</th><th>Importiert</th><th>Quelle übernommen</th><th>Ziel behalten</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(counts, table) in executionResult.result.tables" :key="table" class="border-t">
|
||||
<td class="py-2 font-medium">{{ table }}</td>
|
||||
<td>{{ counts.imported }}</td>
|
||||
<td>{{ counts.updated }}</td>
|
||||
<td>{{ counts.retained }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div v-if="review" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UCard><p class="text-sm text-gray-500">Wird importiert</p><p class="text-2xl font-semibold">{{ review.summary.import }}</p></UCard>
|
||||
<UCard><p class="text-sm text-gray-500">Vorhanden</p><p class="text-2xl font-semibold">{{ review.summary.existing }}</p></UCard>
|
||||
<UCard><p class="text-sm text-gray-500">ID-Remapping</p><p class="text-2xl font-semibold">{{ review.summary.id_collision }}</p></UCard>
|
||||
<UCard><p class="text-sm text-gray-500">Konflikte</p><p class="text-2xl font-semibold">{{ review.summary.conflict }}</p></UCard>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<UFormField label="Art" class="min-w-48">
|
||||
<USelect v-model="kindFilter" :items="kindOptions" value-key="value" label-key="label" @update:model-value="resetAndLoad" />
|
||||
</UFormField>
|
||||
<UFormField label="Tabelle" class="min-w-56">
|
||||
<USelect
|
||||
v-model="tableFilter"
|
||||
:items="[{ label: 'Alle Tabellen', value: '' }, ...(review?.tables || []).map((table) => ({ label: table, value: table }))]"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
@update:model-value="resetAndLoad"
|
||||
/>
|
||||
</UFormField>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<UButton color="neutral" variant="soft" @click="setAllConflicts('target')">Alle Konflikte: Ziel behalten</UButton>
|
||||
<UButton color="warning" variant="soft" @click="setAllConflicts('source')">Alle Konflikte: Quelle übernehmen</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div v-if="loading" class="py-8 text-center text-gray-500">Dry-Run wird geladen …</div>
|
||||
<div v-else-if="!review?.items.length" class="py-8 text-center text-gray-500">Keine Einträge für diesen Filter.</div>
|
||||
<div v-else class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<div v-for="item in review.items" :key="item.id" class="space-y-2 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UBadge :color="kindColors[item.kind] as any" variant="soft">{{ kindLabels[item.kind] }}</UBadge>
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
<span class="text-xs text-gray-500">{{ item.table }} · {{ item.sourceKey }}</span>
|
||||
</div>
|
||||
<p v-if="item.differences.length" class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Abweichende Felder: {{ item.differences.join(", ") }}
|
||||
</p>
|
||||
<div v-if="item.kind === 'conflict'" class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
size="sm"
|
||||
:color="decisions[item.id] === 'target' ? 'primary' : 'neutral'"
|
||||
:variant="decisions[item.id] === 'target' ? 'solid' : 'soft'"
|
||||
@click="decisions[item.id] = 'target'"
|
||||
>Ziel behalten</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
:color="decisions[item.id] === 'source' ? 'warning' : 'neutral'"
|
||||
:variant="decisions[item.id] === 'source' ? 'solid' : 'soft'"
|
||||
@click="decisions[item.id] = 'source'"
|
||||
>Quelle übernehmen</UButton>
|
||||
</div>
|
||||
<details v-if="item.kind === 'conflict'" class="text-xs">
|
||||
<summary class="cursor-pointer text-gray-500">Quelldaten und Zieldaten anzeigen</summary>
|
||||
<div class="mt-2 grid gap-2 lg:grid-cols-2">
|
||||
<pre class="overflow-auto rounded bg-gray-100 p-3 dark:bg-gray-900">{{ JSON.stringify(item.sourceRow, null, 2) }}</pre>
|
||||
<pre class="overflow-auto rounded bg-gray-100 p-3 dark:bg-gray-900">{{ JSON.stringify(item.targetRow, null, 2) }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="review" class="mt-4 flex items-center justify-between border-t pt-4">
|
||||
<UButton color="neutral" variant="soft" :disabled="offset === 0" @click="offset = Math.max(0, offset - limit); loadReview()">Zurück</UButton>
|
||||
<span class="text-sm text-gray-500">{{ offset + 1 }}–{{ Math.min(offset + review.items.length, review.total) }} von {{ review.total }}</span>
|
||||
<UButton color="neutral" variant="soft" :disabled="!hasNextPage" @click="offset += limit; loadReview()">Weiter</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p :class="unresolvedConflicts ? 'text-orange-600' : 'text-green-600'">
|
||||
{{ unresolvedConflicts ? `${unresolvedConflicts} Konflikte benötigen noch eine Entscheidung.` : "Alle Konflikte sind entschieden." }}
|
||||
</p>
|
||||
<UButton
|
||||
icon="i-heroicons-play"
|
||||
color="primary"
|
||||
:loading="executing"
|
||||
:disabled="loading || unresolvedConflicts > 0 || Boolean(executionResult)"
|
||||
@click="executeMerge"
|
||||
>Merge ausführen</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -206,7 +206,7 @@ const importTenantExport = async (event: Event) => {
|
||||
filename: job.filename || file.name,
|
||||
}
|
||||
|
||||
while (!["ready", "failed"].includes(job.status)) {
|
||||
while (!["ready", "review", "failed"].includes(job.status)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
job = await admin.getTenantExport(importJobId)
|
||||
tenantImportProgress.value = {
|
||||
@@ -221,6 +221,11 @@ const importTenantExport = async (event: Event) => {
|
||||
throw new Error(job.error || "Import konnte nicht abgeschlossen werden.")
|
||||
}
|
||||
|
||||
if (job.status === "review") {
|
||||
await router.push(`/administration/tenant-imports/${importJobId}`)
|
||||
return
|
||||
}
|
||||
|
||||
await fetchTenant()
|
||||
await auth.fetchMe()
|
||||
await auth.switchTenant(String(job.tenantId || targetTenantId))
|
||||
|
||||
Reference in New Issue
Block a user