205 lines
16 KiB
TypeScript
205 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable } from "@tanstack/react-table";
|
|
import { Copy, Download, Edit2, Eye, FilePlus2, GitBranchPlus, Search, Trash2, XCircle } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useAuth } from "@/components/auth";
|
|
import { API_BASE, apiDelete, apiGet, apiSend, Customer, Device, Paginated, ValidationItem } from "@/lib/api";
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
ENTWURF: "Entwurf",
|
|
BEREIT_ZUR_PRUEFUNG: "Bereit zur Pruefung",
|
|
IN_PRUEFUNG: "In Pruefung",
|
|
FREIGEGEBEN: "Freigegeben",
|
|
ABGESCHLOSSEN: "Abgeschlossen",
|
|
STORNIERT: "Storniert"
|
|
};
|
|
|
|
const statusClass: Record<string, string> = {
|
|
ENTWURF: "bg-border text-text-light",
|
|
BEREIT_ZUR_PRUEFUNG: "bg-accent/35 text-primary-dark",
|
|
IN_PRUEFUNG: "bg-warning/15 text-warning",
|
|
FREIGEGEBEN: "bg-success/15 text-success",
|
|
ABGESCHLOSSEN: "bg-primary-dark/15 text-primary-dark",
|
|
STORNIERT: "bg-danger/15 text-danger"
|
|
};
|
|
|
|
export default function ValidationsPage() {
|
|
const { token } = useAuth();
|
|
const client = useQueryClient();
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState("");
|
|
const [status, setStatus] = useState("");
|
|
const [customerId, setCustomerId] = useState("");
|
|
const [deviceId, setDeviceId] = useState("");
|
|
const [validationType, setValidationType] = useState("");
|
|
const [result, setResult] = useState("");
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [overdueOnly, setOverdueOnly] = useState(false);
|
|
const [sorting, setSorting] = useState<SortingState>([{ id: "updated_at", desc: true }]);
|
|
const pageSize = 10;
|
|
const sort = sorting[0] ?? { id: "updated_at", desc: true };
|
|
|
|
useEffect(() => {
|
|
const initial = new URLSearchParams(window.location.search);
|
|
setStatus(initial.get("status") ?? "");
|
|
setOverdueOnly(initial.get("overdue_only") === "true");
|
|
}, []);
|
|
const params = new URLSearchParams({
|
|
page: String(page),
|
|
page_size: String(pageSize),
|
|
sort_by: sort.id,
|
|
sort_order: sort.desc ? "desc" : "asc"
|
|
});
|
|
if (search) params.set("search", search);
|
|
if (status) params.set("status", status);
|
|
if (customerId) params.set("customer_id", customerId);
|
|
if (deviceId) params.set("device_id", deviceId);
|
|
if (validationType) params.set("validation_type", validationType);
|
|
if (result) params.set("result", result);
|
|
if (dateFrom) params.set("date_from", dateFrom);
|
|
if (dateTo) params.set("date_to", dateTo);
|
|
if (overdueOnly) params.set("overdue_only", "true");
|
|
|
|
const validations = useQuery({
|
|
queryKey: ["validations", params.toString(), token],
|
|
queryFn: () => apiGet<Paginated<ValidationItem>>(`/validations?${params.toString()}`, token ?? ""),
|
|
enabled: Boolean(token)
|
|
});
|
|
const customers = useQuery({ queryKey: ["customers-options", token], queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
|
const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
|
|
|
const duplicateMutation = useMutation({ mutationFn: (id: string) => apiSend<ValidationItem>(`/validations/${id}/duplicate`, token ?? "", "POST", {}), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
|
|
const versionMutation = useMutation({ mutationFn: (id: string) => apiSend<ValidationItem>(`/validations/${id}/new-version`, token ?? "", "POST", {}), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
|
|
const cancelMutation = useMutation({ mutationFn: (id: string) => apiSend<ValidationItem>(`/validations/${id}/cancel`, token ?? "", "POST", {}), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
|
|
const deleteMutation = useMutation({ mutationFn: (id: string) => apiDelete(`/validations/${id}`, token ?? ""), onSuccess: () => client.invalidateQueries({ queryKey: ["validations"] }) });
|
|
|
|
async function downloadPdf(id: string, reportNumber: string) {
|
|
const response = await fetch(`${API_BASE}/validations/${id}/report.pdf`, { headers: { Authorization: `Bearer ${token}` } });
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${reportNumber}.pdf`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const columns = useMemo<ColumnDef<ValidationItem>[]>(() => [
|
|
{ accessorKey: "report_number", header: "Berichtsnummer" },
|
|
{ accessorKey: "validation_type", header: "Art" },
|
|
{ accessorKey: "performed_on", header: "Pruefdatum" },
|
|
{ accessorKey: "next_validation_on", header: "Naechste Validierung" },
|
|
{ accessorKey: "examiner_name", header: "Pruefer" },
|
|
{ accessorKey: "result", header: "Ergebnis" },
|
|
{ accessorKey: "updated_at", header: "Zuletzt geaendert" },
|
|
{ accessorKey: "status", header: "Status", cell: ({ row }) => <span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusClass[row.original.status] ?? statusClass.ENTWURF}`}>{statusLabels[row.original.status] ?? row.original.status}</span> },
|
|
{ id: "actions", header: "Aktionen", enableSorting: false, cell: ({ row }) => {
|
|
const editable = ["ENTWURF", "BEREIT_ZUR_PRUEFUNG", "IN_PRUEFUNG"].includes(row.original.status);
|
|
const versionable = ["FREIGEGEBEN", "ABGESCHLOSSEN"].includes(row.original.status);
|
|
return <div className="flex justify-end gap-2">{editable && <Link className="btn btn-secondary h-10 px-3" title="Weiterbearbeiten" href={`/validations/${row.original.id}/edit`}><Edit2 className="h-4 w-4" /> Weiterbearbeiten</Link>}{versionable && <button className="btn btn-secondary h-10 px-3" title="Neue Version erstellen" onClick={() => versionMutation.mutate(row.original.id)}><GitBranchPlus className="h-4 w-4" /> Neue Version</button>}<Link className="icon-btn" title="HTML-Vorschau" href={`/validations/${row.original.id}/preview`}><Eye className="h-4 w-4" /></Link><button className="icon-btn" title="PDF herunterladen" onClick={() => downloadPdf(row.original.id, row.original.report_number)}><Download className="h-4 w-4" /></button><button className="icon-btn" title={duplicateMutation.isPending ? "Duplizieren laeuft..." : "Duplizieren"} disabled={duplicateMutation.isPending} onClick={() => duplicateMutation.mutate(row.original.id)}>{duplicateMutation.isPending ? <span className="spinner" /> : <Copy className="h-4 w-4" />}</button><button className="icon-btn text-danger" title={cancelMutation.isPending ? "Stornieren laeuft..." : "Stornieren"} disabled={cancelMutation.isPending} onClick={() => cancelMutation.mutate(row.original.id)}>{cancelMutation.isPending ? <span className="spinner" /> : <XCircle className="h-4 w-4" />}</button>{row.original.status === "ENTWURF" && <button className="icon-btn text-danger" title={deleteMutation.isPending ? "Loeschen laeuft..." : "Loeschen"} disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(row.original.id)}>{deleteMutation.isPending ? <span className="spinner" /> : <Trash2 className="h-4 w-4" />}</button>}</div>;
|
|
} }
|
|
], [cancelMutation, deleteMutation, duplicateMutation, token]);
|
|
|
|
const table = useReactTable({
|
|
data: validations.data?.items ?? [],
|
|
columns,
|
|
state: { sorting },
|
|
manualSorting: true,
|
|
onSortingChange: setSorting,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel()
|
|
});
|
|
const totalPages = Math.max(1, Math.ceil((validations.data?.total ?? 0) / pageSize));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<header className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
|
<div><h1 className="text-3xl font-semibold">Validierungen</h1><p className="mt-2 text-text-light">Verwaltung, Vorschau, Export und Workflow.</p></div>
|
|
<Link href="/validations/new" className="btn btn-primary h-12"><FilePlus2 className="h-5 w-5" /> Neue Validierung</Link>
|
|
</header>
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-soft">
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
<label className="flex h-12 items-center gap-2 rounded-lg border border-border px-3 md:col-span-2"><Search className="h-4 w-4 text-primary" /><input value={search} onChange={(event) => { setPage(1); setSearch(event.target.value); }} className="flex-1 outline-none" placeholder="Volltextsuche" /></label>
|
|
<select value={status} onChange={(event) => setStatus(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Status</option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
|
|
<select value={validationType} onChange={(event) => setValidationType(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Arten</option><option>Erstvalidierung</option><option>Revalidierung</option><option>Leistungsbeurteilung</option><option>Sonderpruefung</option></select>
|
|
<select value={customerId} onChange={(event) => setCustomerId(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Kunden</option>{(customers.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select>
|
|
<select value={deviceId} onChange={(event) => setDeviceId(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Geraete</option>{(devices.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.manufacturer} {item.model}</option>)}</select>
|
|
<select value={result} onChange={(event) => setResult(event.target.value)} className="h-12 rounded-lg border border-border px-3"><option value="">Alle Ergebnisse</option><option value="offen">Offen</option><option value="bestanden">Bestanden</option><option value="nicht_bestanden">Nicht bestanden</option><option value="mit_auflagen">Mit Auflagen</option></select>
|
|
<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} className="h-12 rounded-lg border border-border px-3" />
|
|
<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} className="h-12 rounded-lg border border-border px-3" />
|
|
<label className="flex h-12 items-center gap-3 rounded-lg border border-border px-3"><input type="checkbox" checked={overdueOnly} onChange={(event) => setOverdueOnly(event.target.checked)} /> ueberfaellige Revalidierungen</label>
|
|
</div>
|
|
</section>
|
|
<ImportPanel />
|
|
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-left text-sm">
|
|
<thead className="bg-background text-xs uppercase text-text-light">{table.getHeaderGroups().map((group) => <tr key={group.id}>{group.headers.map((header) => <th key={header.id} onClick={header.column.getToggleSortingHandler()} className="cursor-pointer px-5 py-4 hover:text-primary-dark">{flexRender(header.column.columnDef.header, header.getContext())}</th>)}</tr>)}</thead>
|
|
<tbody className="divide-y divide-border">{table.getRowModel().rows.map((row) => <tr key={row.id} className="hover:bg-accent/10">{row.getVisibleCells().map((cell) => <td key={cell.id} className="whitespace-nowrap px-5 py-4">{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>)}</tr>)}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<footer className="flex items-center justify-between text-sm text-text-light"><span>{validations.data?.total ?? 0} Datensaetze</span><div className="flex items-center gap-3"><button disabled={page <= 1} title={page <= 1 ? "Erste Seite erreicht" : undefined} onClick={() => setPage((value) => value - 1)} className="btn btn-secondary">Zurueck</button><span>Seite {page} von {totalPages}</span><button disabled={page >= totalPages} title={page >= totalPages ? "Letzte Seite erreicht" : undefined} onClick={() => setPage((value) => value + 1)} className="btn btn-secondary">Weiter</button></div></footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ImportPreview = {
|
|
rows: { row_number: number; data: Record<string, string>; errors: string[]; duplicate: boolean }[];
|
|
valid_rows: number;
|
|
invalid_rows: number;
|
|
duplicates: number;
|
|
};
|
|
|
|
function ImportPanel() {
|
|
const { token } = useAuth();
|
|
const client = useQueryClient();
|
|
const [preview, setPreview] = useState<ImportPreview | null>(null);
|
|
const [summary, setSummary] = useState("");
|
|
const [strategy, setStrategy] = useState("skip");
|
|
|
|
async function previewCsv(file: File | null) {
|
|
if (!file || !token) return;
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const response = await fetch(`${API_BASE}/validations/import/csv-preview`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: formData
|
|
});
|
|
setPreview(await response.json());
|
|
}
|
|
|
|
async function importJson(file: File | null) {
|
|
if (!file || !token) return;
|
|
const parsed = JSON.parse(await file.text());
|
|
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
const response = await fetch(`${API_BASE}/validations/import/json`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rows, duplicate_strategy: strategy })
|
|
});
|
|
const result = await response.json();
|
|
setSummary(`Erfolgreich: ${result.successful}, uebersprungen: ${result.skipped}, fehlerhaft: ${result.failed}`);
|
|
client.invalidateQueries({ queryKey: ["validations"] });
|
|
}
|
|
|
|
return (
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-soft">
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div><h2 className="font-semibold">Import</h2><p className="mt-1 text-sm text-text-light">CSV-Vorschau und JSON-Import mit Duplikatstrategie.</p></div>
|
|
<select value={strategy} onChange={(event) => setStrategy(event.target.value)} className="h-11 rounded-lg border border-border px-3"><option value="skip">Duplikate ueberspringen</option><option value="update">Duplikate aktualisieren</option><option value="copy">Als Kopie importieren</option></select>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
<label className="rounded-lg border border-dashed border-primary/40 p-4 transition hover:border-primary hover:bg-accent/10 hover:shadow-soft">CSV-Vorschau<input type="file" accept=".csv" className="mt-3 block w-full cursor-pointer" onChange={(event) => previewCsv(event.target.files?.[0] ?? null)} /></label>
|
|
<label className="rounded-lg border border-dashed border-primary/40 p-4 transition hover:border-primary hover:bg-accent/10 hover:shadow-soft">JSON importieren<input type="file" accept=".json" className="mt-3 block w-full cursor-pointer" onChange={(event) => importJson(event.target.files?.[0] ?? null)} /></label>
|
|
</div>
|
|
{summary && <p className="mt-3 text-sm text-primary-dark">{summary}</p>}
|
|
{preview && <div className="mt-4 overflow-x-auto"><p className="mb-2 text-sm text-text-light">Gueltig: {preview.valid_rows}, fehlerhaft: {preview.invalid_rows}, Duplikate: {preview.duplicates}</p><table className="min-w-full text-left text-sm"><thead className="bg-background text-xs uppercase text-text-light"><tr><th className="px-3 py-2">Zeile</th><th className="px-3 py-2">Berichtsnummer</th><th className="px-3 py-2">Status</th><th className="px-3 py-2">Fehler</th></tr></thead><tbody>{preview.rows.map((row) => <tr key={row.row_number} className="border-t border-border"><td className="px-3 py-2">{row.row_number}</td><td className="px-3 py-2">{row.data.report_number}</td><td className="px-3 py-2">{row.duplicate ? "Duplikat" : "Neu"}</td><td className="px-3 py-2 text-danger">{row.errors.join(", ")}</td></tr>)}</tbody></table></div>}
|
|
</section>
|
|
);
|
|
}
|