Projektstart

This commit is contained in:
2026-01-22 16:26:57 +01:00
parent bc7fbf8ce6
commit 43c83e96bb
21 changed files with 264 additions and 70 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { apiFetch, createEventSourceFor } from "./api";
import { downloadFile } from "./export";
import { downloadExport } from "./exportHistory";
import { useTranslation } from "react-i18next";
type Tenant = {
@@ -51,6 +52,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const [exportTenantId, setExportTenantId] = useState<string | null>(null);
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "done" | "failed">("idle");
const [exportJobId, setExportJobId] = useState<string | null>(null);
const [exportHistory, setExportHistory] = useState<{ id: string; status: string; expiresAt?: string | null; createdAt?: string }[]>([]);
const [exportScope, setExportScope] = useState<"all" | "users" | "accounts" | "jobs" | "rules">("all");
const [exportFormat, setExportFormat] = useState<"json" | "csv" | "zip">("json");
const [tenantSort, setTenantSort] = useState<"recent" | "oldest" | "name">("recent");
@@ -70,6 +72,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const jobsData = await apiFetch("/admin/jobs", {}, token);
setJobs(jobsData.jobs ?? []);
const exportsData = await apiFetch("/admin/exports", {}, token);
setExportHistory(exportsData.exports ?? []);
};
useEffect(() => {
@@ -98,14 +102,15 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
} else {
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
setExportJobId(result.jobId);
setExportHistory((prev) => [{ id: result.jobId, status: "QUEUED" }, ...prev]);
const source = createEventSourceFor(`exports/${result.jobId}`, token);
source.onmessage = async (event) => {
const data = JSON.parse(event.data);
setExportHistory((prev) =>
prev.map((item) => (item.id === data.id ? { ...item, status: data.status, expiresAt: data.expiresAt } : item))
);
if (data.status === "DONE") {
const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
const response = await fetch(`${base}/admin/exports/${result.jobId}/download`, {
headers: { Authorization: `Bearer ${token}` }
});
const response = await downloadExport(token, result.jobId);
const blob = await response.blob();
downloadFile(blob, `tenant-${tenant.id}.zip`);
setExportStatus("done");
@@ -309,6 +314,48 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
: ""}
</p>
)}
{exportHistory.length > 0 && (
<div className="export-history">
<h4>{t("exportHistory")}</h4>
{exportHistory.map((item) => (
<div key={item.id} className="list-item">
<div>
<strong>{item.id.slice(0, 6)}</strong>
<p>
{item.expiresAt && new Date(item.expiresAt) < new Date()
? t("exportStatusExpired")
: item.status === "QUEUED"
? t("exportStatusQueued")
: item.status === "RUNNING"
? t("exportStatusRunning")
: item.status === "DONE"
? t("exportStatusDone")
: t("exportStatusFailed")}
</p>
</div>
<div className="inline-actions">
<span>
{item.createdAt ? new Date(item.createdAt).toLocaleString() : "-"} ·{" "}
{t("exportExpires")}: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : "-"}
</span>
<button
className="ghost"
disabled={item.status !== "DONE" || (item.expiresAt ? new Date(item.expiresAt) < new Date() : false)}
onClick={async () => {
const response = await downloadExport(token, item.id);
if (response.ok) {
const blob = await response.blob();
downloadFile(blob, `export-${item.id}.zip`);
}
}}
>
{t("exportDownload")}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}

View File

@@ -0,0 +1,13 @@
import { apiFetch } from "./api";
export const downloadExport = async (token: string, exportId: string) => {
const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
const response = await fetch(`${base}/admin/exports/${exportId}/download`, {
headers: { Authorization: `Bearer ${token}` }
});
return response;
};
export const fetchExportJob = async (token: string, exportId: string) => {
return apiFetch(`/admin/exports/${exportId}`, {}, token);
};

View File

@@ -143,5 +143,13 @@
"adminSortOldest": "Älteste",
"adminSortName": "Name",
"adminSortEmail": "Email",
"adminSortStatus": "Status"
"adminSortStatus": "Status",
"exportHistory": "Export Historie",
"exportDownload": "Download",
"exportExpires": "Läuft ab",
"exportStatusQueued": "In Warteschlange",
"exportStatusRunning": "Läuft",
"exportStatusDone": "Fertig",
"exportStatusFailed": "Fehlgeschlagen",
"exportStatusExpired": "Abgelaufen"
}

View File

@@ -143,5 +143,13 @@
"adminSortOldest": "Oldest",
"adminSortName": "Name",
"adminSortEmail": "Email",
"adminSortStatus": "Status"
"adminSortStatus": "Status",
"exportHistory": "Export history",
"exportDownload": "Download",
"exportExpires": "Expires",
"exportStatusQueued": "Queued",
"exportStatusRunning": "Running",
"exportStatusDone": "Done",
"exportStatusFailed": "Failed",
"exportStatusExpired": "Expired"
}

View File

@@ -274,6 +274,11 @@ h1 {
border-color: var(--secondary);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hero {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));