KI-AGENT: Statusanzeige für Tenant-Import ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 1m21s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 1m21s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s
This commit is contained in:
4
backend/db/migrations/0064_tenant_import_job_status.sql
Normal file
4
backend/db/migrations/0064_tenant_import_job_status.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "tenant_export_jobs"
|
||||||
|
ALTER COLUMN "tenant_id" DROP NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "status_message" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "import_result" jsonb;
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
text,
|
text,
|
||||||
integer,
|
integer,
|
||||||
|
jsonb,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
@@ -22,7 +23,6 @@ export const tenantExportJobs = pgTable("tenant_export_jobs", {
|
|||||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
|
||||||
tenantId: bigint("tenant_id", { mode: "number" })
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
.notNull()
|
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
createdBy: uuid("created_by").references(() => authUsers.id),
|
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||||
@@ -35,6 +35,8 @@ export const tenantExportJobs = pgTable("tenant_export_jobs", {
|
|||||||
contentType: text("content_type").notNull().default("application/zip"),
|
contentType: text("content_type").notNull().default("application/zip"),
|
||||||
fileSize: bigint("file_size", { mode: "number" }),
|
fileSize: bigint("file_size", { mode: "number" }),
|
||||||
error: text("error"),
|
error: text("error"),
|
||||||
|
statusMessage: text("status_message"),
|
||||||
|
importResult: jsonb("import_result"),
|
||||||
|
|
||||||
filesTotal: integer("files_total").notNull().default(0),
|
filesTotal: integer("files_total").notNull().default(0),
|
||||||
filesDone: integer("files_done").notNull().default(0),
|
filesDone: integer("files_done").notNull().default(0),
|
||||||
|
|||||||
@@ -439,6 +439,66 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startTenantImportJob = async (
|
||||||
|
jobId: string,
|
||||||
|
currentUser: { id: string; email: string },
|
||||||
|
source: { archiveBuffer: Buffer } | { exportData: TenantFullExport }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
status: "running",
|
||||||
|
statusMessage: "Mandantenimport wird vorbereitet",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
|
||||||
|
const onProgress = async ({ done, total, message }: { done: number; total: number; message?: string }) => {
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
filesDone: done,
|
||||||
|
filesTotal: total,
|
||||||
|
statusMessage: message || "Mandant wird importiert",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = "archiveBuffer" in source
|
||||||
|
? await importTenantFullExportArchive(server, source.archiveBuffer, { onProgress })
|
||||||
|
: await importTenantFullExport(server, source.exportData, { onProgress });
|
||||||
|
const access = await completeImportedTenantAccess(currentUser, result);
|
||||||
|
const importResult = { success: true, ...access, ...result };
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
tenantId: result.tenantId,
|
||||||
|
status: "completed",
|
||||||
|
statusMessage: "Mandantenimport abgeschlossen",
|
||||||
|
importResult,
|
||||||
|
error: null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("ERROR tenant import job:", err);
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
statusMessage: "Mandantenimport fehlgeschlagen",
|
||||||
|
error: err?.message || String(err),
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createPreparedTenantExport = async (tenantId: number, currentUserId: string) => {
|
const createPreparedTenantExport = async (tenantId: number, currentUserId: string) => {
|
||||||
const [tenant] = await server.db
|
const [tenant] = await server.db
|
||||||
.select({
|
.select({
|
||||||
@@ -1347,7 +1407,9 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
const isMultipart = req.headers["content-type"]?.includes("multipart/form-data");
|
const isMultipart = req.headers["content-type"]?.includes("multipart/form-data");
|
||||||
let result;
|
let source!: { archiveBuffer: Buffer } | { exportData: TenantFullExport };
|
||||||
|
let filename = "tenant-export.json";
|
||||||
|
let fileSize: number | null = null;
|
||||||
let targetTenantId: number | null = null;
|
let targetTenantId: number | null = null;
|
||||||
|
|
||||||
if (isMultipart) {
|
if (isMultipart) {
|
||||||
@@ -1355,6 +1417,8 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
if (!data?.file) return reply.code(400).send({ error: "export file required" });
|
if (!data?.file) return reply.code(400).send({ error: "export file required" });
|
||||||
|
|
||||||
const archiveBuffer = await data.toBuffer();
|
const archiveBuffer = await data.toBuffer();
|
||||||
|
filename = data.filename || "tenant-export.fedeo-export.zip";
|
||||||
|
fileSize = archiveBuffer.length;
|
||||||
targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
|
targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
|
||||||
if (targetTenantId) {
|
if (targetTenantId) {
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
@@ -1362,7 +1426,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await importTenantFullExportArchive(server, archiveBuffer);
|
source = { archiveBuffer };
|
||||||
} else {
|
} else {
|
||||||
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
|
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
|
||||||
const exportData = "format" in body ? body : body.exportData;
|
const exportData = "format" in body ? body : body.exportData;
|
||||||
@@ -1378,17 +1442,32 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await importTenantFullExport(server, exportData);
|
source = { exportData };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { matrixProvisioned, matrixProvisioningError } = await completeImportedTenantAccess(currentUser, result);
|
const [job] = await server.db
|
||||||
|
.insert(tenantExportJobs)
|
||||||
|
.values({
|
||||||
|
tenantId: null,
|
||||||
|
createdBy: currentUser.id,
|
||||||
|
operation: "import",
|
||||||
|
status: "queued",
|
||||||
|
statusMessage: "Upload abgeschlossen, Import wird gestartet",
|
||||||
|
filename,
|
||||||
|
fileSize,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
return {
|
void startTenantImportJob(job.id, currentUser, source);
|
||||||
success: true,
|
|
||||||
matrixProvisioned,
|
return reply.code(202).send({
|
||||||
matrixProvisioningError,
|
importId: job.id,
|
||||||
...result,
|
status: job.status,
|
||||||
};
|
statusMessage: job.statusMessage,
|
||||||
|
filename: job.filename,
|
||||||
|
statusUrl: `/api/admin/tenant-imports/${job.id}`,
|
||||||
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("ERROR /admin/tenant-imports:", err);
|
console.error("ERROR /admin/tenant-imports:", err);
|
||||||
const message = err?.message || "Internal Server Error";
|
const message = err?.message || "Internal Server Error";
|
||||||
@@ -1397,6 +1476,48 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GET /admin/tenant-imports/:import_id
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/admin/tenant-imports/:import_id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { import_id } = req.params as { import_id: string };
|
||||||
|
const [job] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(tenantExportJobs)
|
||||||
|
.where(and(
|
||||||
|
eq(tenantExportJobs.id, import_id),
|
||||||
|
eq(tenantExportJobs.operation, "import")
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!job) return reply.code(404).send({ error: "Import nicht gefunden" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
importId: job.id,
|
||||||
|
tenantId: job.tenantId,
|
||||||
|
operation: job.operation,
|
||||||
|
status: job.status,
|
||||||
|
statusMessage: job.statusMessage,
|
||||||
|
filename: job.filename,
|
||||||
|
fileSize: job.fileSize,
|
||||||
|
filesDone: job.filesDone,
|
||||||
|
filesTotal: job.filesTotal,
|
||||||
|
error: job.error,
|
||||||
|
result: job.importResult,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
updatedAt: job.updatedAt,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/tenant-imports/:import_id:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// PUT /admin/users/:user_id/access
|
// PUT /admin/users/:user_id/access
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
|
|||||||
@@ -565,16 +565,21 @@ const readZipTextEntry = async (entriesByName: Map<string, any>, name: string) =
|
|||||||
const restoreArchiveFiles = async (
|
const restoreArchiveFiles = async (
|
||||||
entriesByName: Map<string, any>,
|
entriesByName: Map<string, any>,
|
||||||
exportData: TenantFullExport,
|
exportData: TenantFullExport,
|
||||||
manifest: TenantArchiveManifest
|
manifest: TenantArchiveManifest,
|
||||||
|
onProgress?: (progress: { done: number; total: number; message?: string }) => Promise<void> | void
|
||||||
) => {
|
) => {
|
||||||
let restored = 0
|
let restored = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file]))
|
const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file]))
|
||||||
|
const filesTotal = exportData.files?.length || 0
|
||||||
|
let filesDone = 0
|
||||||
|
|
||||||
for (const fileRow of exportData.files || []) {
|
for (const fileRow of exportData.files || []) {
|
||||||
const originalPath = fileRow.path
|
const originalPath = fileRow.path
|
||||||
if (!originalPath) {
|
if (!originalPath) {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
filesDone += 1
|
||||||
|
await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,12 +587,16 @@ const restoreArchiveFiles = async (
|
|||||||
const archivePath = manifestFile?.archivePath
|
const archivePath = manifestFile?.archivePath
|
||||||
if (!archivePath || manifestFile?.missing) {
|
if (!archivePath || manifestFile?.missing) {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
filesDone += 1
|
||||||
|
await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = entriesByName.get(archivePath)
|
const entry = entriesByName.get(archivePath)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
filesDone += 1
|
||||||
|
await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +609,8 @@ const restoreArchiveFiles = async (
|
|||||||
ContentLength: content.length,
|
ContentLength: content.length,
|
||||||
}))
|
}))
|
||||||
restored += 1
|
restored += 1
|
||||||
|
filesDone += 1
|
||||||
|
await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { restored, skipped }
|
return { restored, skipped }
|
||||||
@@ -1008,8 +1019,8 @@ export const importTenantFullExportArchive = async (
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await importTenantFullExport(server, rawExportData)
|
const result = await importTenantFullExport(server, rawExportData, options)
|
||||||
const files = await restoreArchiveFiles(entriesByName, rawExportData, manifest)
|
const files = await restoreArchiveFiles(entriesByName, rawExportData, manifest, options.onProgress)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ export type TenantImportResult = {
|
|||||||
error?: string | null
|
error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TenantImportJob = {
|
||||||
|
importId: string
|
||||||
|
tenantId?: number | null
|
||||||
|
operation?: "import" | string
|
||||||
|
status: "queued" | "running" | "completed" | "failed" | string
|
||||||
|
statusMessage?: string | null
|
||||||
|
filename: string
|
||||||
|
fileSize?: number | null
|
||||||
|
filesDone?: number
|
||||||
|
filesTotal?: number
|
||||||
|
error?: string | null
|
||||||
|
result?: TenantImportResult | null
|
||||||
|
statusUrl?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string | null
|
||||||
|
completedAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type TenantExportJob = {
|
export type TenantExportJob = {
|
||||||
exportId: string
|
exportId: string
|
||||||
importId?: string
|
importId?: string
|
||||||
@@ -215,13 +233,17 @@ export const useAdmin = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const importTenant = async (body: Record<string, any> | FormData): Promise<TenantImportResult> => {
|
const importTenant = async (body: Record<string, any> | FormData): Promise<TenantImportJob> => {
|
||||||
return await $api("/api/admin/tenant-imports", {
|
return await $api("/api/admin/tenant-imports", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTenantImport = async (importId: string): Promise<TenantImportJob> => {
|
||||||
|
return await $api(`/api/admin/tenant-imports/${importId}`)
|
||||||
|
}
|
||||||
|
|
||||||
const getSystemStatus = async (): Promise<SystemStatus> => {
|
const getSystemStatus = async (): Promise<SystemStatus> => {
|
||||||
return await $api("/api/admin/system-status")
|
return await $api("/api/admin/system-status")
|
||||||
}
|
}
|
||||||
@@ -262,5 +284,6 @@ export const useAdmin = () => {
|
|||||||
getTenantExport,
|
getTenantExport,
|
||||||
downloadTenantExport,
|
downloadTenantExport,
|
||||||
importTenant,
|
importTenant,
|
||||||
|
getTenantImport,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminTenant } from "~/composables/useAdmin"
|
import type { AdminTenant, TenantImportJob } from "~/composables/useAdmin"
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -9,6 +9,7 @@ const admin = useAdmin()
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const creatingTenant = ref(false)
|
const creatingTenant = ref(false)
|
||||||
const importingTenant = ref(false)
|
const importingTenant = ref(false)
|
||||||
|
const tenantImportProgress = ref<TenantImportJob | null>(null)
|
||||||
const createTenantModalOpen = ref(false)
|
const createTenantModalOpen = ref(false)
|
||||||
const importFileInput = ref<HTMLInputElement | null>(null)
|
const importFileInput = ref<HTMLInputElement | null>(null)
|
||||||
const tenants = ref<AdminTenant[]>([])
|
const tenants = ref<AdminTenant[]>([])
|
||||||
@@ -101,19 +102,42 @@ const importTenantExport = async (event: Event) => {
|
|||||||
if (!file || importingTenant.value) return
|
if (!file || importingTenant.value) return
|
||||||
|
|
||||||
importingTenant.value = true
|
importingTenant.value = true
|
||||||
|
tenantImportProgress.value = {
|
||||||
|
importId: "",
|
||||||
|
status: "uploading",
|
||||||
|
statusMessage: "Exportdatei wird hochgeladen",
|
||||||
|
filename: file.name,
|
||||||
|
filesDone: 0,
|
||||||
|
filesTotal: 0,
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isZipExport = file.name.endsWith(".zip") || file.name.endsWith(".fedeo-export.zip") || file.type === "application/zip"
|
const isZipExport = file.name.endsWith(".zip") || file.name.endsWith(".fedeo-export.zip") || file.type === "application/zip"
|
||||||
let result
|
let job
|
||||||
|
|
||||||
if (isZipExport) {
|
if (isZipExport) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
result = await admin.importTenant(formData)
|
job = await admin.importTenant(formData)
|
||||||
} else {
|
} else {
|
||||||
result = await admin.importTenant(JSON.parse(await file.text()))
|
job = await admin.importTenant(JSON.parse(await file.text()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tenantImportProgress.value = job
|
||||||
|
|
||||||
|
while (!["completed", "failed"].includes(job.status)) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
|
job = await admin.getTenantImport(job.importId)
|
||||||
|
tenantImportProgress.value = job
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === "failed") {
|
||||||
|
throw new Error(job.error || "Mandantenimport fehlgeschlagen")
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = job.result
|
||||||
|
if (!result) throw new Error("Importergebnis fehlt")
|
||||||
|
|
||||||
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()
|
||||||
@@ -194,6 +218,36 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
|
|
||||||
|
<UCard v-if="tenantImportProgress" class="mt-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Mandantenimport</div>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ tenantImportProgress.statusMessage || 'Import wird verarbeitet' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UBadge
|
||||||
|
:color="tenantImportProgress.status === 'failed' ? 'error' : tenantImportProgress.status === 'completed' ? 'success' : 'warning'"
|
||||||
|
variant="soft"
|
||||||
|
>
|
||||||
|
{{ tenantImportProgress.status === 'uploading' ? 'Upload' : tenantImportProgress.status === 'queued' ? 'Wartet' : tenantImportProgress.status === 'running' ? 'Läuft' : tenantImportProgress.status === 'completed' ? 'Abgeschlossen' : 'Fehlgeschlagen' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<UProgress
|
||||||
|
:model-value="tenantImportProgress.filesTotal ? Math.round(((tenantImportProgress.filesDone || 0) / tenantImportProgress.filesTotal) * 100) : undefined"
|
||||||
|
:color="tenantImportProgress.status === 'failed' ? 'error' : tenantImportProgress.status === 'completed' ? 'success' : 'warning'"
|
||||||
|
:animation="tenantImportProgress.filesTotal ? undefined : 'carousel'"
|
||||||
|
/>
|
||||||
|
<p v-if="tenantImportProgress.filesTotal" class="text-xs text-gray-500">
|
||||||
|
{{ tenantImportProgress.filesDone || 0 }} / {{ tenantImportProgress.filesTotal }} Schritte
|
||||||
|
</p>
|
||||||
|
<p v-if="tenantImportProgress.error" class="text-sm text-red-600">
|
||||||
|
{{ tenantImportProgress.error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UModal v-model:open="createTenantModalOpen">
|
<UModal v-model:open="createTenantModalOpen">
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard>
|
<UCard>
|
||||||
|
|||||||
Reference in New Issue
Block a user