266 lines
11 KiB
Vue
266 lines
11 KiB
Vue
<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
|
||
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>
|
||
</UDashboardPanelContent>
|
||
</template>
|