Validation_Suite/validation-suite/frontend/atlas/components/validations/validation-editor.tsx

515 lines
32 KiB
TypeScript

"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 { ActionButton, ActionState } from "@/components/action-button";
import { useAuth } from "@/components/auth";
import { API_BASE, apiGet, apiSend, Contact, Customer, Device, Equipment, Location, normalizeValidationPayload, Paginated, ValidationItem } from "@/lib/api";
const triState = ["yes", "no", "na"] as const;
const today = new Date().toISOString().slice(0, 10);
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 requiredLabels: Record<string, { label: string; section: string }> = {
report_number: { label: "Berichtsnummer", section: "Allgemeine Angaben" },
validation_type: { label: "Validierungsart", section: "Allgemeine Angaben" },
performed_on: { label: "Pruefdatum", section: "Allgemeine Angaben" },
customer_id: { label: "Kunde", section: "Kunde und Standort" },
location_id: { label: "Standort", section: "Kunde und Standort" },
device_id: { label: "Geraet", section: "Geraet" },
examiner_name: { label: "Pruefer", section: "Allgemeine Angaben" }
};
const schema = z.object({
report_number: z.string().min(3),
validation_type: z.string().min(1),
project: z.string().optional(),
performed_on: z.string().min(1),
next_validation_on: z.string().optional().nullable(),
revalidation_interval_months: z.coerce.number().int().min(1),
next_validation_manually_overridden: z.boolean(),
test_location: z.string().optional(),
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().min(1),
contact_id: z.string().optional().nullable(),
examiner_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>;
type ReviewIssue = { field: string; message: string; section: string };
type ReviewResult = { status: string; errors: ReviewIssue[]; warnings: ReviewIssue[]; complete_sections: string[] };
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,
next_validation_on: "",
revalidation_interval_months: 24,
next_validation_manually_overridden: false,
test_location: "",
examiner_name: "",
participants: "",
status: "ENTWURF",
result: "offen",
customer_id: "",
location_id: "",
contact_id: "",
examiner_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 hover:bg-background">
{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";
export function ValidationEditor({ validationId }: { validationId?: string }) {
const { token } = useAuth();
const client = useQueryClient();
const [draftId, setDraftId] = useState<string | null>(validationId ?? null);
const [lastSaved, setLastSaved] = useState<string>("");
const [formMessage, setFormMessage] = useState("");
const [reviewResult, setReviewResult] = useState<ReviewResult | null>(null);
const [saveState, setSaveState] = useState<ActionState>("normal");
const [reviewState, setReviewState] = useState<ActionState>("normal");
const [pdfState, setPdfState] = useState<ActionState>("normal");
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 existingValidation = useQuery({ queryKey: ["validation", validationId, token], queryFn: () => apiGet<ValidationItem>(`/validations/${validationId}`, token ?? ""), enabled: Boolean(token && validationId) });
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" });
useEffect(() => {
if (nextNumber.data?.report_number && !form.getValues("report_number")) {
form.setValue("report_number", nextNumber.data.report_number);
}
}, [form, nextNumber.data]);
useEffect(() => {
if (!existingValidation.data) return;
const data = existingValidation.data;
form.reset({
...defaults(),
...data,
result: data.result ?? "offen",
project: data.project ?? "",
test_location: data.test_location ?? "",
examiner_name: data.examiner_name ?? "",
participants: data.participants ?? "",
operator_name: data.operator_name ?? "",
performed_on: data.performed_on ?? "",
next_validation_on: data.next_validation_on ?? "",
contact_id: data.contact_id ?? "",
examiner_id: "",
location_id: data.location_id ?? "",
device_id: data.device_id ?? ""
});
setDraftId(data.id);
}, [existingValidation.data, form]);
const readonly = existingValidation.data?.status === "FREIGEGEBEN" || existingValidation.data?.status === "ABGESCHLOSSEN";
const saveMutation = useMutation({
mutationFn: (values: FormValues) => apiSend<ValidationItem>(draftId ? `/validations/${draftId}` : "/validations", token ?? "", draftId ? "PUT" : "POST", normalizeValidationPayload(values)),
onSuccess: (item) => {
setDraftId(item.id);
setLastSaved(new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
setFormMessage("");
setSaveState("success");
window.setTimeout(() => setSaveState("normal"), 1500);
client.invalidateQueries({ queryKey: ["dashboard"] });
},
onError: (error) => {
setSaveState("error");
setFormMessage(error instanceof Error ? error.message : "Speichern fehlgeschlagen.");
}
});
useEffect(() => {
if (readonly || !token || !watched.report_number || !watched.validation_type || !watched.customer_id || !watched.location_id || !watched.device_id || !watched.examiner_name || !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 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 calibrationWarnings = useMemo(() => selectedEquipment.filter((item) => item.calibration_due_on && item.calibration_due_on < today), [selectedEquipment]);
function missingRequired(values = form.getValues()) {
return Object.entries(requiredLabels).filter(([field]) => !values[field as keyof FormValues]);
}
function scrollToFirstMissing() {
const first = missingRequired()[0];
if (!first) return false;
setFormMessage(`${first[1].label} fehlt in ${first[1].section}.`);
document.querySelector(`[name="${first[0]}"]`)?.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
function submit(values: FormValues) {
if (readonly) {
setFormMessage("Diese Validierung ist schreibgeschuetzt.");
return;
}
if (missingRequired(values).length) {
setSaveState("error");
scrollToFirstMissing();
return;
}
setSaveState("loading");
saveMutation.mutate(values);
}
async function reviewValidation() {
if (readonly) {
setFormMessage("Diese Validierung ist schreibgeschuetzt.");
return;
}
if (missingRequired().length) {
setReviewState("error");
scrollToFirstMissing();
return;
}
try {
setReviewState("loading");
const saved = draftId ? null : await saveMutation.mutateAsync(form.getValues());
const id = draftId ?? saved?.id;
if (!id || !token) return;
const response = await fetch(`${API_BASE}/validations/${id}/review`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
const result = (await response.json()) as ReviewResult;
setReviewResult(result);
setReviewState("success");
window.setTimeout(() => setReviewState("normal"), 1500);
setFormMessage(result.errors.length ? "Pruefung abgeschlossen: Fehler blockieren die Freigabe." : "Pruefung abgeschlossen: keine blockierenden Fehler.");
} catch (error) {
setReviewState("error");
setFormMessage(error instanceof Error ? error.message : "Pruefung fehlgeschlagen.");
}
}
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) }));
}
async function openReportPreview() {
if (!draftId || !token) return;
const response = await fetch(`${API_BASE}/validations/${draftId}/report.html`, {
headers: { Authorization: `Bearer ${token}` }
});
const html = await response.text();
const preview = window.open("", "_blank");
if (preview) {
preview.document.write(html);
preview.document.close();
}
}
async function downloadReport() {
if (!draftId || !token) return;
try {
setPdfState("loading");
const response = await fetch(`${API_BASE}/validations/${draftId}/report.pdf`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error("PDF konnte nicht erzeugt werden.");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${form.getValues("report_number")}.pdf`;
link.click();
URL.revokeObjectURL(url);
setPdfState("success");
window.setTimeout(() => setPdfState("normal"), 1500);
} catch (error) {
setPdfState("error");
setFormMessage(error instanceof Error ? error.message : "PDF-Download fehlgeschlagen.");
}
}
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">{readonly ? "Schreibgeschuetzte freigegebene oder abgeschlossene Validierung." : "Eine responsive Seite fuer Erfassung, Pruefung und Berichtserstellung."}</p>
</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="Revalidierungsintervall Monate"><input type="number" className={inputClass} {...form.register("revalidation_interval_months")} /></Field>
<Field label="Naechste Validierung"><input type="date" className={inputClass} {...form.register("next_validation_on")} onChange={(event) => { form.setValue("next_validation_manually_overridden", true); form.setValue("next_validation_on", event.target.value); }} /></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="ENTWURF">Entwurf</option><option value="BEREIT_ZUR_PRUEFUNG">Bereit zur Pruefung</option><option value="IN_PRUEFUNG">In Pruefung</option><option value="FREIGEGEBEN">Freigegeben</option><option value="ABGESCHLOSSEN">Abgeschlossen</option><option value="STORNIERT">Storniert</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>
</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>
</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} />)}
<ActionButton type="button" icon={<Plus className="h-4 w-4" />} onClick={() => form.setValue("programs", [...form.getValues("programs"), { name: "", selected: true, custom: true }])} className="mt-4">Eigenes Programm</ActionButton>
</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>)}
<ActionButton type="button" icon={<Plus className="h-4 w-4" />} onClick={() => recommendations.append({ number: recommendations.fields.length + 1, text: "", deadline: "", status: "offen" })}>Zeile hinzufuegen</ActionButton>
</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>
{reviewResult && <section className="rounded-lg border border-border bg-surface p-5 shadow-soft"><h2 className="text-xl font-semibold">Pruefergebnis</h2><div className="mt-4 grid gap-4 md:grid-cols-3"><IssueList title="Fehler" items={reviewResult.errors} tone="danger" /><IssueList title="Warnungen" items={reviewResult.warnings} tone="warning" /><div><h3 className="font-semibold text-success">Vollstaendige Bereiche</h3>{reviewResult.complete_sections.map((item) => <p key={item} className="mt-2 text-sm">{item}</p>)}</div></div></section>}
{saveMutation.isError && formMessage && <div className="fixed right-4 top-4 z-50 max-w-md rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{formMessage}</div>}
<fieldset disabled={readonly} className={readonly ? "pointer-events-none contents opacity-80" : "contents"}></fieldset>
<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">
<div className="text-sm text-text-light"><p>{lastSaved ? `Automatisch gespeichert um ${lastSaved}` : "Autosave startet nach Ausfuellen der Pflichtfelder."}</p>{missingRequired().length > 0 && <p className="text-danger">Fehlt: {missingRequired().map(([, value]) => value.label).join(", ")}</p>}{formMessage && <p className="text-primary-dark">{formMessage}</p>}</div>
<div className="flex flex-wrap gap-2">
<ActionButton type="submit" disabled={readonly} disabledReason="Freigegebene und abgeschlossene Validierungen sind schreibgeschuetzt" icon={<Save className="h-4 w-4" />} state={saveState} loadingText="Speichern..." successText="Gespeichert">Entwurf speichern</ActionButton>
<ActionButton type="button" disabled={readonly} disabledReason="Freigegebene und abgeschlossene Validierungen sind schreibgeschuetzt" icon={<ShieldCheck className="h-4 w-4" />} state={reviewState} loadingText="Pruefung laeuft..." successText="Geprueft" onClick={reviewValidation}>Bericht pruefen</ActionButton>
<ActionButton type="button" variant="primary" icon={<FileText className="h-4 w-4" />} onClick={() => draftId ? window.location.assign(`/validations/${draftId}/preview`) : scrollToFirstMissing()}>HTML-Vorschau</ActionButton>
<ActionButton type="button" icon={<Download className="h-4 w-4" />} state={pdfState} loadingText="PDF wird erzeugt..." successText="PDF erzeugt" onClick={() => draftId ? downloadReport() : scrollToFirstMissing()}>PDF herunterladen</ActionButton>
</div>
</div>
</div>
</form>
);
}
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>;
}
function IssueList({ title, items, tone }: { title: string; items: ReviewIssue[]; tone: "danger" | "warning" }) {
return <div><h3 className={`font-semibold ${tone === "danger" ? "text-danger" : "text-warning"}`}>{title}</h3>{items.length === 0 ? <p className="mt-2 text-sm text-text-light">Keine Eintraege.</p> : items.map((item) => <p key={`${item.field}-${item.message}`} className="mt-2 text-sm">{item.section}: {item.message}</p>)}</div>;
}
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 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>;
}
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>;
}
export default ValidationEditor;