"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"; import { getValidationResultPresentation, normalizeValidationResult, validationResultValues } from "@/lib/validation-result"; const triState = ["yes", "no", "na"] as const; const today = new Date().toISOString().slice(0, 10); const attachmentCategories = [ "Aufbereitungsraum", "reiner Bereich", "unreiner Bereich", "Sterilisator", "Beladung", "Sensorposition", "Chargenprotokoll", "Indikator", "Zertifikat", "Kalibrierschein", "Winlog-Auswertung" ]; const requiredLabels: Record = { 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; type ReviewIssue = { field: string; message: string; section: string }; type ReviewResult = { status: string; errors: ReviewIssue[]; warnings: ReviewIssue[]; complete_sections: string[] }; type ChecklistTemplate = { checklist_key: string; title: string; items: Record[] }; 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: [], performance_checklist: [], 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 (
{open &&
{children}
}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return ; } 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(validationId ?? null); const [lastSaved, setLastSaved] = useState(""); const [formMessage, setFormMessage] = useState(""); const [reviewResult, setReviewResult] = useState(null); const [saveState, setSaveState] = useState("normal"); const [reviewState, setReviewState] = useState("normal"); const [pdfState, setPdfState] = useState("normal"); const [uploadingCategory, setUploadingCategory] = useState(null); const autosaveTimer = useRef | 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>("/customers?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const locations = useQuery({ queryKey: ["locations-options", token], queryFn: () => apiGet>("/locations?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) }); const checklistTemplates = useQuery({ queryKey: ["report-checklists", token], queryFn: () => apiGet("/report-templates/default/checklists", token ?? ""), enabled: Boolean(token) }); const existingValidation = useQuery({ queryKey: ["validation", validationId, token], queryFn: () => apiGet(`/validations/${validationId}`, token ?? ""), enabled: Boolean(token && validationId) }); const form = useForm({ 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 selectedResult = getValidationResultPresentation(form.watch("result")); 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: normalizeValidationResult(data.result), 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]); useEffect(() => { if (!checklistTemplates.data || existingValidation.data) return; if (form.getValues("documentation_checklist").length > 0) return; const items = checklistTemplates.data.flatMap((template) => template.items.map((item) => ({ ...item, template_key: template.checklist_key, template_title: template.title })) ); form.setValue("documentation_checklist", items); }, [checklistTemplates.data, existingValidation.data, form]); const readonly = existingValidation.data?.status === "FREIGEGEBEN" || existingValidation.data?.status === "ABGESCHLOSSEN"; const saveMutation = useMutation({ mutationFn: (values: FormValues) => apiSend(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."); } } async function addFiles(files: FileList | null, category: string) { if (!files || files.length === 0) return; if (!draftId || !token) { setFormMessage("Bitte den Entwurf speichern, bevor Bilder oder Anlagen hochgeladen werden."); return; } try { setUploadingCategory(category); for (const [index, file] of Array.from(files).entries()) { const order = attachments.fields.length + index + 1; const body = new FormData(); body.append("category", category); body.append("description", ""); body.append("order", String(order)); body.append("file", file); const response = await fetch(`${API_BASE}/validations/${draftId}/attachments`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body }); if (!response.ok) { const errorBody = await response.json().catch(() => null); throw new Error(errorBody?.detail ?? "Upload fehlgeschlagen."); } const uploaded = await response.json(); attachments.append(uploaded); } client.invalidateQueries({ queryKey: ["validation", draftId, token] }); setFormMessage("Upload abgeschlossen."); } catch (error) { setFormMessage(error instanceof Error ? error.message : "Upload fehlgeschlagen."); } finally { setUploadingCategory(null); } } 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) { const body = await response.json().catch(() => null); throw new Error(body?.detail ?? "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 (

Validierungsbericht

{readonly ? "Schreibgeschuetzte freigegebene oder abgeschlossene Validierung." : "Eine responsive Seite fuer Erfassung, Pruefung und Berichtserstellung."}

{ form.setValue("next_validation_manually_overridden", true); form.setValue("next_validation_on", event.target.value); }} />