Files
FEDEO/frontend/pages/administration/tenant-imports/[importId].vue
flfeders c66d04f0b5
All checks were successful
Build and Push Docker Images / build-central-services-api (push) Successful in 24s
Build and Push Docker Images / build-central-services-admin (push) Successful in 24s
Build and Push Docker Images / build-docs (push) Successful in 24s
Build and Push Docker Images / build-backend (push) Successful in 38s
Build and Push Docker Images / build-frontend (push) Successful in 1m42s
Build and Push Docker Images / build-website (push) Successful in 27s
Handle missing tables in tenant merge
2026-08-06 14:52:38 +02:00

268 lines
11 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>
<UDashboardNavbar title="Tenant-Import prüfen">
<template #left>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-chevron-left"
@click="router.push(`/administration/tenants/${tenantId}`)"
>
Tenant
</UButton>
</template>
<template #right>
<UBadge color="neutral" variant="soft" class="hidden sm:inline-flex">
Dry-Run {{ importId.slice(0, 8) }}
</UBadge>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="mx-auto w-full max-w-screen-2xl space-y-6 pb-8">
<div>
<h1 class="text-2xl font-semibold text-highlighted">Import prüfen und zusammenführen</h1>
<p class="mt-1 text-sm text-muted">
Prüfe neue, vorhandene und abweichende Datensätze, bevor Änderungen am Tenant ausgeführt werden.
</p>
</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
v-if="item.allowedDecisions.includes('target')"
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
v-if="item.allowedDecisions.includes('source')"
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>
</UDashboardPanelContent>
</template>