feat(validation): add editing versioning revalidation and aligned reports

This commit is contained in:
Schubert Ferenc 2026-07-11 10:26:18 +02:00
parent f73a24df13
commit 302e542fda
28 changed files with 2691 additions and 406 deletions

View file

@ -1,26 +1,25 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Building2, Gauge, MapPin, Stethoscope, UserRound } from "lucide-react";
import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine } from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/components/auth";
import { apiGet } from "@/lib/api";
type DashboardData = {
customers: number;
locations: number;
contacts: number;
devices: number;
equipment: number;
validations: number;
validation_drafts: number;
validation_ready: number;
validation_in_review: number;
validation_approved: number;
validation_overdue: number;
};
const cards = [
{ label: "Kunden", key: "customers", href: "/customers", icon: Building2 },
{ label: "Standorte", key: "locations", href: "/locations", icon: MapPin },
{ label: "Ansprechpartner", key: "contacts", href: "/contacts", icon: UserRound },
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
{ label: "Pruefmittel", key: "equipment", href: "/equipment", icon: Gauge }
{ label: "Entwuerfe", key: "validation_drafts", href: "/validations?status=ENTWURF", icon: FilePenLine },
{ label: "Bereit zur Pruefung", key: "validation_ready", href: "/validations?status=BEREIT_ZUR_PRUEFUNG", icon: ClipboardList },
{ label: "In Pruefung", key: "validation_in_review", href: "/validations?status=IN_PRUEFUNG", icon: Clock },
{ label: "Freigegeben", key: "validation_approved", href: "/validations?status=FREIGEGEBEN", icon: CheckCircle2 },
{ label: "Ueberfaellige Revalidierungen", key: "validation_overdue", href: "/validations?overdue_only=true", icon: AlertTriangle }
] as const;
export default function DashboardPage() {
@ -35,7 +34,7 @@ export default function DashboardPage() {
<div className="space-y-8">
<header>
<h1 className="text-3xl font-semibold text-text">Dashboard</h1>
<p className="mt-2 text-text-light">Aktuelle Stammdaten aus PostgreSQL.</p>
<p className="mt-2 text-text-light">Validierungsworkflow und faellige Revalidierungen.</p>
</header>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map((item) => {
@ -52,8 +51,8 @@ export default function DashboardPage() {
})}
</section>
<section className="rounded-lg border border-border bg-surface p-6 shadow-soft">
<h2 className="text-xl font-semibold">Stammdaten</h2>
<p className="mt-2 text-sm leading-6 text-text-light">Kunden, Standorte, Ansprechpartner, Geraete und Pruefmittel koennen produktiv angelegt, bearbeitet, gesucht und geloescht werden.</p>
<h2 className="text-xl font-semibold">Zuletzt bearbeitete Validierungen</h2>
<p className="mt-2 text-sm leading-6 text-text-light">Die Validierungsverwaltung bietet Suche, Filter, Sortierung, Vorschau, Export und Workflow-Aktionen.</p>
</section>
</div>
);

View file

@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import ValidationEditor from "@/components/validations/validation-editor";
export default function EditValidationPage() {
const params = useParams<{ id: string }>();
return <ValidationEditor validationId={params.id} />;
}

View file

@ -0,0 +1,60 @@
"use client";
import { ArrowLeft, Download } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useAuth } from "@/components/auth";
import { API_BASE } from "@/lib/api";
export default function ValidationPreviewPage() {
const { token } = useAuth();
const params = useParams<{ id: string }>();
const [html, setHtml] = useState("");
const [message, setMessage] = useState("Vorschau wird geladen.");
useEffect(() => {
if (!token || !params.id) return;
fetch(`${API_BASE}/validations/${params.id}/report.html`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(async (response) => {
if (!response.ok) throw new Error(await response.text());
return response.text();
})
.then((content) => {
setHtml(content);
setMessage("");
})
.catch(() => setMessage("Vorschau konnte nicht geladen werden."));
}, [params.id, token]);
async function downloadPdf() {
const response = await fetch(`${API_BASE}/validations/${params.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 = "validierungsbericht.pdf";
link.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-5">
<header className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-semibold">HTML-Vorschau</h1>
<p className="mt-2 text-text-light">Authentifiziert gerenderter Orion-Bericht.</p>
</div>
<div className="flex gap-2">
<Link href="/validations" className="btn btn-secondary h-12"><ArrowLeft className="h-4 w-4" /> Zurueck</Link>
<button type="button" onClick={downloadPdf} className="btn btn-primary h-12"><Download className="h-4 w-4" /> PDF herunterladen</button>
</div>
</header>
{message ? <div className="rounded-lg border border-border bg-surface p-6 shadow-soft">{message}</div> : <iframe title="Validierungsbericht" srcDoc={html} className="h-[78vh] w-full rounded-lg border border-border bg-white shadow-soft" />}
</div>
);
}

View file

@ -0,0 +1,5 @@
import ValidationEditor from "@/components/validations/validation-editor";
export default function NewValidationPage() {
return <ValidationEditor />;
}

View file

@ -1,361 +1,205 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Download, FileText, Plus, Save, ShieldCheck, UploadCloud, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
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 { apiGet, apiSend, Contact, Customer, Device, Equipment, Location, Paginated, ValidationItem } from "@/lib/api";
import { API_BASE, apiDelete, apiGet, apiSend, Customer, Device, Paginated, ValidationItem } from "@/lib/api";
const triState = ["yes", "no", "na"] as const;
const today = new Date().toISOString().slice(0, 10);
const statusLabels: Record<string, string> = {
ENTWURF: "Entwurf",
BEREIT_ZUR_PRUEFUNG: "Bereit zur Pruefung",
IN_PRUEFUNG: "In Pruefung",
FREIGEGEBEN: "Freigegeben",
ABGESCHLOSSEN: "Abgeschlossen",
STORNIERT: "Storniert"
};
const checklistTexts = [
"Gebrauchsanweisung und Herstellerdokumentation vorhanden",
"Wartungsnachweise vollstaendig",
"Kalibrierzertifikate der Pruefmittel gueltig",
"Aufstellbedingungen dokumentiert",
"Wasserqualitaet dokumentiert",
"Chargendokumentation nachvollziehbar",
"Routinekontrollen definiert",
"Freigabeverfahren beschrieben",
"Personal eingewiesen",
"Abweichungen bewertet"
];
const performanceTexts = [
"Vakuumtest entspricht Vorgaben",
"Bowie-Dick / Leerkammerprofil entspricht Vorgaben",
"Temperaturband innerhalb Spezifikation",
"Haltezeit erreicht",
"Druckverlauf plausibel",
"Trocknungsergebnis akzeptabel",
"Beladungsmuster reproduzierbar",
"Sensorpositionen dokumentiert"
];
const attachmentCategories = [
"Aufbereitungsraum",
"reiner Bereich",
"unreiner Bereich",
"Sterilisator",
"Beladung",
"Sensorposition",
"Chargenprotokoll",
"Indikator",
"Zertifikat",
"Kalibrierschein",
"Winlog-Auswertung"
];
const schema = z.object({
report_number: z.string().min(3),
validation_type: z.string().min(1),
project: z.string().min(1),
performed_on: z.string().min(1),
test_location: z.string().min(1),
examiner_name: z.string().min(1),
participants: z.string().optional(),
status: z.string().min(1),
result: z.string().min(1),
customer_id: z.string().min(1),
location_id: z.string().optional().nullable(),
contact_id: z.string().optional().nullable(),
operator_name: z.string().optional(),
device_id: z.string().min(1),
equipment_ids: z.array(z.string()),
environment_conditions: z.record(z.unknown()),
documentation_checklist: z.array(z.record(z.unknown())),
performance_checklist: z.array(z.record(z.unknown())),
programs: z.array(z.record(z.unknown())),
loading_patterns: z.array(z.record(z.unknown())),
measurement_data: z.array(z.record(z.unknown())),
drying: z.record(z.unknown()),
recommendations: z.array(z.record(z.unknown())),
attachments: z.array(z.record(z.unknown()))
});
type FormValues = z.infer<typeof schema>;
function checklist(items: string[]) {
return items.map((text, index) => ({ number: index + 1, text, value: "na", comment: "" }));
}
function defaults(reportNumber = ""): FormValues {
return {
report_number: reportNumber,
validation_type: "Erstvalidierung",
project: "",
performed_on: today,
test_location: "",
examiner_name: "",
participants: "",
status: "draft",
result: "offen",
customer_id: "",
location_id: "",
contact_id: "",
operator_name: "",
device_id: "",
equipment_ids: [],
environment_conditions: {
room_temperature: "",
humidity: "",
test_time: "",
checks: [
{ text: "Raumbedingungen stabil", value: "na", comment: "" },
{ text: "Aufstellort frei zugaenglich", value: "na", comment: "" },
{ text: "Medienversorgung verfuegbar", value: "na", comment: "" }
]
},
documentation_checklist: checklist(checklistTexts),
performance_checklist: checklist(performanceTexts),
programs: [
{ name: "Vakuumtest", selected: false, custom: false },
{ name: "Bowie-Dick / Leerkammerprofil", selected: false, custom: false },
{ name: "134 C hohl verpackt", selected: false, custom: false }
],
loading_patterns: [1, 2, 3].map((run) => ({ run, pattern: "", description: "", images: [] })),
measurement_data: ["Vakuumtest", "Leerkammerprofil", "Testlauf 1", "Testlauf 2", "Testlauf 3"].map((name) => ({
name,
start_time: "",
end_time: "",
duration: "",
leak_rate: "",
min_temperature: "",
max_temperature: "",
temperature_band: "",
equilibration_time: "",
holding_time: "",
pressure: "",
result: "",
imports: []
})),
drying: { start_weight: "", end_weight: "", difference: "", rating: "", comment: "" },
recommendations: [],
attachments: []
};
}
function Accordion({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="rounded-lg border border-border bg-surface shadow-soft">
<button type="button" onClick={() => setOpen((value) => !value)} className="flex w-full items-center justify-between px-5 py-4 text-left text-lg font-semibold">
{title}
<ChevronDown className={`h-5 w-5 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="border-t border-border p-5">{children}</div>}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block"><span className="text-sm font-medium text-text">{label}</span><div className="mt-2">{children}</div></label>;
}
const inputClass = "h-12 w-full rounded-lg border border-border bg-white px-4 outline-none focus:border-primary";
const selectClass = inputClass;
const areaClass = "min-h-24 w-full rounded-lg border border-border bg-white px-4 py-3 outline-none focus:border-primary";
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 [draftId, setDraftId] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<string>("");
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const nextNumber = useQuery({ queryKey: ["next-report-number", token], queryFn: () => apiGet<{ report_number: string }>("/validations/next-report-number", 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 locations = useQuery({ queryKey: ["locations-options", token], queryFn: () => apiGet<Paginated<Location>>("/locations?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet<Paginated<Contact>>("/contacts?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 equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet<Paginated<Equipment>>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaults() });
const watched = useWatch({ control: form.control });
const selectedDevice = devices.data?.items.find((item) => item.id === form.watch("device_id"));
const selectedEquipment = equipment.data?.items.filter((item) => form.watch("equipment_ids").includes(item.id)) ?? [];
const recommendations = useFieldArray({ control: form.control, name: "recommendations" });
const attachments = useFieldArray({ control: form.control, name: "attachments" });
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(() => {
if (nextNumber.data?.report_number && !form.getValues("report_number")) {
form.setValue("report_number", nextNumber.data.report_number);
}
}, [form, nextNumber.data]);
const saveMutation = useMutation({
mutationFn: (values: FormValues) => apiSend<ValidationItem>(draftId ? `/validations/${draftId}` : "/validations", token ?? "", draftId ? "PUT" : "POST", values),
onSuccess: (item) => {
setDraftId(item.id);
setLastSaved(new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
client.invalidateQueries({ queryKey: ["dashboard"] });
}
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");
useEffect(() => {
if (!token || !watched.report_number || !watched.customer_id || !watched.device_id || !watched.project || !watched.performed_on) return;
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
autosaveTimer.current = setTimeout(() => {
const values = form.getValues();
saveMutation.mutate(values);
}, 2500);
return () => {
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
};
}, [form, saveMutation, token, watched]);
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 customerOptions = customers.data?.items ?? [];
const selectedCustomer = customerOptions.find((item) => item.id === form.watch("customer_id"));
const filteredLocations = (locations.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
const filteredContacts = (contacts.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
const filteredDevices = (devices.data?.items ?? []).filter((item) => !form.watch("customer_id") || item.customer_id === form.watch("customer_id"));
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"] }) });
const calibrationWarnings = useMemo(() => selectedEquipment.filter((item) => item.calibration_due_on && item.calibration_due_on < today), [selectedEquipment]);
function submit(values: FormValues) {
saveMutation.mutate(values);
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);
}
function addFiles(files: FileList | null, category: string) {
if (!files) return;
Array.from(files).forEach((file, index) => attachments.append({ category, filename: file.name, description: "", order: attachments.fields.length + index + 1, preview: URL.createObjectURL(file) }));
}
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 (
<form onSubmit={form.handleSubmit(submit)} className="space-y-5 pb-28">
<header>
<h1 className="text-3xl font-semibold text-text">Validierungsbericht</h1>
<p className="mt-2 text-text-light">Eine responsive Seite fuer Erfassung, Pruefung und Berichtserstellung.</p>
<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>
<Accordion title="1. Allgemeine Angaben" defaultOpen>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Berichtsnummer"><input className={inputClass} {...form.register("report_number")} /></Field>
<Field label="Validierungsart"><select className={selectClass} {...form.register("validation_type")}><option>Erstvalidierung</option><option>Revalidierung</option><option>Leistungsbeurteilung</option><option>Sonderpruefung</option></select></Field>
<Field label="Projekt"><input className={inputClass} {...form.register("project")} /></Field>
<Field label="Pruefdatum"><input type="date" className={inputClass} {...form.register("performed_on")} /></Field>
<Field label="Pruefungsort"><input className={inputClass} {...form.register("test_location")} /></Field>
<Field label="Pruefer"><input className={inputClass} {...form.register("examiner_name")} /></Field>
<Field label="Mitwirkende Personen"><textarea className={areaClass} {...form.register("participants")} /></Field>
<Field label="Status"><select className={selectClass} {...form.register("status")}><option value="draft">Entwurf</option><option value="in_progress">In Pruefung</option><option value="ready_for_report">Bericht bereit</option><option value="completed">Abgeschlossen</option></select></Field>
<Field label="Gesamtergebnis"><select className={selectClass} {...form.register("result")}><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></Field>
<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>
</Accordion>
<Accordion title="2. Kunde und Standort">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Kunde"><select className={selectClass} {...form.register("customer_id")}><option value="">Bitte waehlen</option>{customerOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>
<Field label="Standort"><select className={selectClass} {...form.register("location_id")}><option value="">Bitte waehlen</option>{filteredLocations.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>
<Field label="Ansprechpartner"><select className={selectClass} {...form.register("contact_id")}><option value="">Bitte waehlen</option>{filteredContacts.map((item) => <option key={item.id} value={item.id}>{item.full_name}</option>)}</select></Field>
<Field label="Betreiber"><input className={inputClass} {...form.register("operator_name")} /></Field>
<Field label="QM-/Hygienebeauftragter"><input className={inputClass} value={[selectedCustomer?.quality_manager, selectedCustomer?.hygiene_officer].filter(Boolean).join(" / ")} readOnly /></Field>
</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>
</Accordion>
<Accordion title="3. Geraet">
<Field label="Geraet"><select className={selectClass} {...form.register("device_id")}><option value="">Bitte waehlen</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.manufacturer} {item.model} - {item.serial_number}</option>)}</select></Field>
<div className="mt-4 grid gap-4 md:grid-cols-3">{[
["Hersteller", selectedDevice?.manufacturer],
["Modell", selectedDevice?.model],
["Seriennummer", selectedDevice?.serial_number],
["Baujahr", selectedDevice?.year_built],
["Inbetriebnahme", selectedDevice?.commissioned_on],
["Kammervolumen", selectedDevice?.chamber_volume_liters],
["Dampferzeugung", selectedDevice?.steam_generation],
["Wasseraufbereitung", selectedDevice?.water_treatment],
["Dokumentation", selectedDevice?.documentation],
["Lieferant", selectedDevice?.supplier]
].map(([label, value]) => <div key={label as string} className="rounded-lg border border-border bg-background p-4"><p className="text-xs text-text-light">{label}</p><p className="mt-1 font-medium">{String(value ?? "-")}</p></div>)}</div>
</Accordion>
<Accordion title="4. Pruefmittel">
<Controller control={form.control} name="equipment_ids" render={({ field }) => (
<div className="grid gap-3 md:grid-cols-2">
{(equipment.data?.items ?? []).map((item) => {
const expired = Boolean(item.calibration_due_on && item.calibration_due_on < today);
return <label key={item.id} className="flex items-start gap-3 rounded-lg border border-border p-4"><input type="checkbox" className="mt-1 h-5 w-5" checked={field.value.includes(item.id)} onChange={(event) => field.onChange(event.target.checked ? [...field.value, item.id] : field.value.filter((id: string) => id !== item.id))} /><span><strong>{item.kind}</strong><br />{item.serial_number} · {item.calibrated_on ?? "-"} · {item.status}{expired && <span className="ml-2 text-danger">Kalibrierung abgelaufen</span>}</span></label>;
})}
</div>
)} />
{calibrationWarnings.length > 0 && <p className="mt-4 rounded-lg border border-danger/30 bg-danger/5 p-4 text-danger">Mindestens ein ausgewaehltes Pruefmittel hat eine abgelaufene Kalibrierung.</p>}
</Accordion>
<Accordion title="5. Umgebungsbedingungen">
<div className="grid gap-4 md:grid-cols-3">
<Field label="Raumtemperatur"><input className={inputClass} {...form.register("environment_conditions.room_temperature")} /></Field>
<Field label="relative Luftfeuchtigkeit"><input className={inputClass} {...form.register("environment_conditions.humidity")} /></Field>
<Field label="Pruefzeit"><input className={inputClass} {...form.register("environment_conditions.test_time")} /></Field>
</div>
{[0, 1, 2].map((index) => <ChecklistRow key={index} form={form} path={`environment_conditions.checks.${index}`} />)}
</Accordion>
<Accordion title="6. Dokumentations- und Leistungschecklisten">
<h3 className="font-semibold">Dokumentation</h3>{checklistTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`documentation_checklist.${index}`} />)}
<h3 className="mt-6 font-semibold">Leistung</h3>{performanceTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`performance_checklist.${index}`} />)}
</Accordion>
<Accordion title="7. Programme">
{[0, 1, 2].map((index) => <ProgramRow key={index} form={form} index={index} />)}
<button type="button" onClick={() => form.setValue("programs", [...form.getValues("programs"), { name: "", selected: true, custom: true }])} className="mt-4 inline-flex items-center gap-2 rounded-lg border border-border px-4 py-3 font-semibold"><Plus className="h-4 w-4" /> Eigenes Programm</button>
</Accordion>
<Accordion title="8. Beladungsmuster">
{[0, 1, 2].map((index) => <LoadingRun key={index} form={form} index={index} />)}
</Accordion>
<Accordion title="9. Messdaten">
{[0, 1, 2, 3, 4].map((index) => <MeasurementBlock key={index} form={form} index={index} />)}
</Accordion>
<Accordion title="10. Trocknung">
<div className="grid gap-4 md:grid-cols-2">
{["start_weight", "end_weight", "difference", "rating"].map((name) => <Field key={name} label={name}><input className={inputClass} {...form.register(`drying.${name}`)} /></Field>)}
<Field label="Bemerkung"><textarea className={areaClass} {...form.register("drying.comment")} /></Field>
</div>
</Accordion>
<Accordion title="11. Empfehlungen und Auflagen">
{recommendations.fields.map((field, index) => <div key={field.id} className="mb-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-4"><input className={inputClass} placeholder="Nummer" {...form.register(`recommendations.${index}.number`)} /><input className={inputClass} placeholder="Text" {...form.register(`recommendations.${index}.text`)} /><input type="date" className={inputClass} {...form.register(`recommendations.${index}.deadline`)} /><input className={inputClass} placeholder="Status" {...form.register(`recommendations.${index}.status`)} /></div>)}
<button type="button" onClick={() => recommendations.append({ number: recommendations.fields.length + 1, text: "", deadline: "", status: "offen" })} className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-3 font-semibold"><Plus className="h-4 w-4" /> Zeile hinzufuegen</button>
</Accordion>
<Accordion title="12. Bilder und Anlagen">
<div className="grid gap-3 md:grid-cols-2">{attachmentCategories.map((category) => <label key={category} className="rounded-lg border border-dashed border-primary/40 p-4"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}<input type="file" multiple className="mt-3 block w-full text-sm" onChange={(event) => addFiles(event.target.files, category)} /></label>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">{attachments.fields.map((field, index) => <div key={field.id} className="rounded-lg border border-border p-4"><div className="flex justify-between gap-3"><strong>{String(form.watch(`attachments.${index}.filename`) ?? "")}</strong><button type="button" onClick={() => attachments.remove(index)}><X className="h-4 w-4 text-danger" /></button></div><input className={`${inputClass} mt-3`} placeholder="Beschreibung" {...form.register(`attachments.${index}.description`)} /><input className={`${inputClass} mt-3`} placeholder="Reihenfolge" {...form.register(`attachments.${index}.order`)} /></div>)}</div>
</Accordion>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface/95 px-4 py-3 shadow-soft backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3">
<span className="text-sm text-text-light">{lastSaved ? `Automatisch gespeichert um ${lastSaved}` : "Autosave aktiv, sobald Pflichtfelder ausgefuellt sind."}</span>
<div className="flex flex-wrap gap-2">
<button type="submit" className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><Save className="h-4 w-4" /> Entwurf speichern</button>
<button type="button" onClick={() => form.setValue("status", "ready_for_report")} className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><ShieldCheck className="h-4 w-4" /> Validierung pruefen</button>
<button type="button" className="inline-flex h-12 items-center gap-2 rounded-lg bg-primary px-4 font-semibold text-white shadow-soft"><FileText className="h-4 w-4" /> Bericht erzeugen</button>
<button type="button" className="inline-flex h-12 items-center gap-2 rounded-lg border border-border px-4 font-semibold"><Download className="h-4 w-4" /> Bericht herunterladen</button>
</div>
</div>
</div>
</form>
</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>
);
}
function ChecklistRow({ form, path }: { form: ReturnType<typeof useForm<FormValues>>; path: string }) {
return <div className="mt-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-[1fr_180px_1fr]"><input className={inputClass} readOnly {...form.register(`${path}.text` as never)} /><select className={selectClass} {...form.register(`${path}.value` as never)}>{triState.map((value) => <option key={value} value={value}>{value === "yes" ? "Ja" : value === "no" ? "Nein" : "Nicht zutreffend"}</option>)}</select><input className={inputClass} placeholder="Kommentar" {...form.register(`${path}.comment` as never)} /></div>;
}
type ImportPreview = {
rows: { row_number: number; data: Record<string, string>; errors: string[]; duplicate: boolean }[];
valid_rows: number;
invalid_rows: number;
duplicates: number;
};
function ProgramRow({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
return <div className="mb-3 grid gap-3 rounded-lg border border-border p-4 md:grid-cols-[80px_1fr]"><input type="checkbox" className="h-6 w-6" {...form.register(`programs.${index}.selected`)} /><input className={inputClass} {...form.register(`programs.${index}.name`)} /></div>;
}
function ImportPanel() {
const { token } = useAuth();
const client = useQueryClient();
const [preview, setPreview] = useState<ImportPreview | null>(null);
const [summary, setSummary] = useState("");
const [strategy, setStrategy] = useState("skip");
function LoadingRun({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
return <div className="mb-4 rounded-lg border border-border p-4"><h3 className="font-semibold">Testlauf {index + 1}</h3><div className="mt-3 grid gap-3 md:grid-cols-2"><input className={inputClass} placeholder="Beladungsmuster" {...form.register(`loading_patterns.${index}.pattern`)} /><input className={inputClass} placeholder="Beschreibung" {...form.register(`loading_patterns.${index}.description`)} /><input type="file" multiple className="rounded-lg border border-border p-3 md:col-span-2" /></div></div>;
}
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());
}
function MeasurementBlock({ form, index }: { form: ReturnType<typeof useForm<FormValues>>; index: number }) {
const fields = ["start_time", "end_time", "duration", "leak_rate", "min_temperature", "max_temperature", "temperature_band", "equilibration_time", "holding_time", "pressure", "result"];
return <div className="mb-4 rounded-lg border border-border p-4"><h3 className="font-semibold">{String(form.watch(`measurement_data.${index}.name`) ?? "")}</h3><div className="mt-3 grid gap-3 md:grid-cols-3">{fields.map((field) => <input key={field} className={inputClass} placeholder={field} {...form.register(`measurement_data.${index}.${field}`)} />)}<input type="file" multiple accept=".csv,.pdf" className="rounded-lg border border-border p-3 md:col-span-3" /></div></div>;
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>
);
}

View file

@ -25,3 +25,158 @@ textarea {
font: inherit;
}
button,
a {
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
color 180ms ease,
opacity 180ms ease,
transform 180ms ease;
}
button {
cursor: pointer;
}
button:hover:not(:disabled) {
box-shadow: 0 12px 28px rgba(46, 59, 64, 0.12);
transform: translateY(-1px);
}
button:hover:not(:disabled) svg,
a:hover svg,
label:hover svg {
transform: scale(1.08);
}
button:active:not(:disabled),
a:active {
transform: scale(0.98);
}
button:disabled,
[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.45;
}
a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
svg {
transition: transform 180ms ease, color 180ms ease;
}
input,
select,
textarea {
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
input:focus,
select:focus,
textarea:focus {
border-color: #6C8A96;
box-shadow: 0 0 0 3px rgba(108, 138, 150, 0.18);
outline: none;
}
tbody tr {
transition: background-color 180ms ease;
}
tbody tr:hover {
background: rgba(167, 199, 199, 0.14);
}
.btn {
align-items: center;
border: 1px solid #E6EAEA;
border-radius: 8px;
display: inline-flex;
font-weight: 700;
gap: 8px;
justify-content: center;
min-height: 44px;
padding: 10px 16px;
}
.btn-secondary {
background: #FFFFFF;
color: #2E3B40;
}
.btn-secondary:hover:not(:disabled) {
background: #F7F8F8;
border-color: #A7C7C7;
}
.btn-secondary:active:not(:disabled) {
background: #E6EAEA;
}
.btn-primary {
background: #6C8A96;
border-color: #6C8A96;
color: #FFFFFF;
}
.btn-primary:hover:not(:disabled) {
background: #4F6A74;
}
.btn-primary:active:not(:disabled) {
background: #415B64;
}
.btn-danger {
background: #FFFFFF;
border-color: rgba(201, 92, 84, 0.35);
color: #C95C54;
}
.btn-danger:hover:not(:disabled) {
background: rgba(201, 92, 84, 0.08);
border-color: #C95C54;
}
.btn-success {
background: rgba(102, 162, 107, 0.12);
border-color: rgba(102, 162, 107, 0.35);
color: #427646;
}
.icon-btn {
align-items: center;
background: #FFFFFF;
border: 1px solid #E6EAEA;
border-radius: 8px;
display: inline-flex;
height: 38px;
justify-content: center;
width: 38px;
}
.icon-btn:hover:not(:disabled) {
background: #F7F8F8;
border-color: #A7C7C7;
}
.spinner {
animation: spin 0.8s linear infinite;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
height: 16px;
width: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}