Wire tenant archive export in frontend

This commit is contained in:
2026-07-05 12:31:59 +02:00
parent 0a7b0448ad
commit 3c4a75a858
3 changed files with 108 additions and 24 deletions

View File

@@ -55,6 +55,22 @@ export type TenantImportResult = {
files: { restored: number; skipped: number } files: { restored: number; skipped: number }
} }
export type TenantExportJob = {
exportId: string
tenantId?: number
status: "queued" | "running" | "ready" | "failed" | string
filename: string
fileSize?: number | null
filesDone?: number
filesTotal?: number
error?: string | null
statusUrl?: string
downloadUrl?: string | null
createdAt?: string
updatedAt?: string | null
completedAt?: string | null
}
export type SystemStatus = { export type SystemStatus = {
checkedAt: string checkedAt: string
backend: { backend: {
@@ -173,13 +189,23 @@ export const useAdmin = () => {
}) })
} }
const exportTenant = async (id: number): Promise<Blob> => { const startTenantExport = async (id: number): Promise<TenantExportJob> => {
return await $api(`/api/admin/tenants/${id}/export`, { return await $api(`/api/admin/tenants/${id}/exports`, {
method: "POST",
})
}
const getTenantExport = async (exportId: string): Promise<TenantExportJob> => {
return await $api(`/api/admin/tenant-exports/${exportId}`)
}
const downloadTenantExport = async (exportId: string): Promise<Blob> => {
return await $api(`/api/admin/tenant-exports/${exportId}/download`, {
responseType: "blob", responseType: "blob",
}) })
} }
const importTenant = async (body: Record<string, any>): Promise<TenantImportResult> => { const importTenant = async (body: Record<string, any> | FormData): Promise<TenantImportResult> => {
return await $api("/api/admin/tenant-imports", { return await $api("/api/admin/tenant-imports", {
method: "POST", method: "POST",
body, body,
@@ -222,7 +248,9 @@ export const useAdmin = () => {
createTenant, createTenant,
invitePortalUser, invitePortalUser,
updateTenant, updateTenant,
exportTenant, startTenantExport,
getTenantExport,
downloadTenantExport,
importTenant, importTenant,
} }
} }

View File

@@ -12,6 +12,12 @@ const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const exportingTenant = ref(false) const exportingTenant = ref(false)
const importingTenant = ref(false) const importingTenant = ref(false)
const tenantExportProgress = ref<null | {
status: string
filesDone: number
filesTotal: number
filename: string
}>(null)
const creatingUser = ref(false) const creatingUser = ref(false)
const createUserModalOpen = ref(false) const createUserModalOpen = ref(false)
const createdUserPassword = ref("") const createdUserPassword = ref("")
@@ -93,26 +99,46 @@ const downloadTenantExport = async () => {
if (!tenantForm.value || exportingTenant.value) return if (!tenantForm.value || exportingTenant.value) return
exportingTenant.value = true exportingTenant.value = true
tenantExportProgress.value = null
try { try {
const blob = await admin.exportTenant(tenantForm.value.id) const started = await admin.startTenantExport(tenantForm.value.id)
let job = started
tenantExportProgress.value = {
status: job.status,
filesDone: job.filesDone || 0,
filesTotal: job.filesTotal || 0,
filename: job.filename,
}
while (!["ready", "failed"].includes(job.status)) {
await new Promise((resolve) => setTimeout(resolve, 1500))
job = await admin.getTenantExport(started.exportId)
tenantExportProgress.value = {
status: job.status,
filesDone: job.filesDone || 0,
filesTotal: job.filesTotal || 0,
filename: job.filename,
}
}
if (job.status === "failed") {
throw new Error(job.error || "Export konnte nicht erstellt werden.")
}
const blob = await admin.downloadTenantExport(started.exportId)
const objectUrl = URL.createObjectURL(blob) const objectUrl = URL.createObjectURL(blob)
const link = document.createElement("a") const link = document.createElement("a")
const safeName = (tenantForm.value.short || tenantForm.value.name || tenantForm.value.id)
.toString()
.trim()
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase()
link.href = objectUrl link.href = objectUrl
link.download = `fedeo-tenant-${safeName || tenantForm.value.id}-${new Date().toISOString().slice(0, 10)}.json` link.download = job.filename || `fedeo-tenant-${tenantForm.value.id}.fedeo-export.zip`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
URL.revokeObjectURL(objectUrl) URL.revokeObjectURL(objectUrl)
toast.add({ title: "Mandantenexport erstellt", color: "green" }) toast.add({ title: "Mandantenexport erstellt", description: job.filename, color: "green" })
} catch (err: any) { } catch (err: any) {
console.error("[administration/tenants/export]", err) console.error("[administration/tenants/export]", err)
toast.add({ toast.add({
@@ -138,12 +164,22 @@ const importTenantExport = async (event: Event) => {
lastImportResult.value = null lastImportResult.value = null
try { try {
const rawContent = await file.text() const targetTenantId = tenantForm.value?.id || tenantId
const exportData = JSON.parse(rawContent) const isZipExport = file.name.endsWith(".zip") || file.name.endsWith(".fedeo-export.zip") || file.type === "application/zip"
const result = await admin.importTenant({ let result
exportData,
targetTenantId: tenantForm.value?.id || tenantId, if (isZipExport) {
}) const formData = new FormData()
formData.append("file", file)
formData.append("targetTenantId", String(targetTenantId))
result = await admin.importTenant(formData)
} else {
result = await admin.importTenant({
exportData: JSON.parse(await file.text()),
targetTenantId,
})
}
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0) const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
lastImportResult.value = { lastImportResult.value = {
@@ -290,8 +326,19 @@ onMounted(async () => {
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-4"> <div class="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
<div class="font-medium">Full Export</div> <div class="font-medium">Full Export</div>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
Erstellt eine portable JSON-Datei mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateiinhalten. Erstellt ein portables ZIP-Archiv mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateien.
</p> </p>
<div v-if="tenantExportProgress" class="mt-4 space-y-2">
<UProgress
:model-value="tenantExportProgress.filesTotal ? Math.round((tenantExportProgress.filesDone / tenantExportProgress.filesTotal) * 100) : undefined"
/>
<p class="text-xs text-gray-500">
{{ tenantExportProgress.status === 'ready' ? 'Download wird vorbereitet' : 'Export wird erstellt' }}
<span v-if="tenantExportProgress.filesTotal">
· {{ tenantExportProgress.filesDone }} / {{ tenantExportProgress.filesTotal }} Dateien
</span>
</p>
</div>
<UButton <UButton
class="mt-4" class="mt-4"
icon="i-heroicons-arrow-down-tray" icon="i-heroicons-arrow-down-tray"
@@ -310,7 +357,7 @@ onMounted(async () => {
<input <input
ref="importFileInput" ref="importFileInput"
type="file" type="file"
accept="application/json,.json" accept="application/json,application/zip,.json,.zip,.fedeo-export.zip"
class="hidden" class="hidden"
@change="importTenantExport" @change="importTenantExport"
> >

View File

@@ -103,8 +103,17 @@ const importTenantExport = async (event: Event) => {
importingTenant.value = true importingTenant.value = true
try { try {
const exportData = JSON.parse(await file.text()) const isZipExport = file.name.endsWith(".zip") || file.name.endsWith(".fedeo-export.zip") || file.type === "application/zip"
const result = await admin.importTenant(exportData) let result
if (isZipExport) {
const formData = new FormData()
formData.append("file", file)
result = await admin.importTenant(formData)
} else {
result = await admin.importTenant(JSON.parse(await file.text()))
}
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0) const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
await fetchTenants() await fetchTenants()
@@ -158,7 +167,7 @@ onMounted(async () => {
<input <input
ref="importFileInput" ref="importFileInput"
type="file" type="file"
accept="application/json,.json" accept="application/json,application/zip,.json,.zip,.fedeo-export.zip"
class="hidden" class="hidden"
@change="importTenantExport" @change="importTenantExport"
> >