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

This commit is contained in:
2026-08-06 12:35:31 +02:00
parent e2fdfe9c0a
commit 291f278d99
3 changed files with 299 additions and 1 deletions

View File

@@ -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,
}
}

View 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>

View File

@@ -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))