Files
FEDEO/frontend/pages/administration/tenant-imports/[importId].vue
flfeders 291f278d99
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
Add tenant merge review page
2026-08-06 12:35:31 +02:00

249 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>