feat(validation): complete validation editor and master data modules
This commit is contained in:
parent
2b5c765e41
commit
f73a24df13
73 changed files with 10194 additions and 0 deletions
47
validation-suite/frontend/atlas/app/(app)/contacts/page.tsx
Normal file
47
validation-suite/frontend/atlas/app/(app)/contacts/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { apiGet, Contact, Customer, Paginated } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
customer_id: z.string().min(1),
|
||||
full_name: z.string().min(2),
|
||||
function: z.string().optional().nullable(),
|
||||
email: z.union([z.string().email(), z.literal(""), z.null()]).optional(),
|
||||
phone: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function ContactsPage() {
|
||||
const { token } = useAuth();
|
||||
const customers = useQuery({
|
||||
queryKey: ["customers-options", token],
|
||||
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
return (
|
||||
<CrudPage<Contact>
|
||||
title="Ansprechpartner"
|
||||
subtitle="Kontaktpersonen, Funktionen und Kommunikationsdaten."
|
||||
endpoint="/contacts"
|
||||
columns={[
|
||||
{ key: "full_name", label: "Name" },
|
||||
{ key: "function", label: "Funktion" },
|
||||
{ key: "email", label: "Mail" },
|
||||
{ key: "phone", label: "Telefon" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
|
||||
{ name: "full_name", label: "Name" },
|
||||
{ name: "function", label: "Funktion" },
|
||||
{ name: "email", label: "Mail", type: "email" },
|
||||
{ name: "phone", label: "Telefon" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_id: "", full_name: "", function: "", email: "", phone: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
validation-suite/frontend/atlas/app/(app)/customers/page.tsx
Normal file
50
validation-suite/frontend/atlas/app/(app)/customers/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { Customer } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
customer_type: z.enum(["practice", "clinic"]),
|
||||
name: z.string().min(2),
|
||||
street: z.string().optional().nullable(),
|
||||
postal_code: z.string().optional().nullable(),
|
||||
city: z.string().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
email: z.union([z.string().email(), z.literal(""), z.null()]).optional(),
|
||||
hygiene_officer: z.string().optional().nullable(),
|
||||
quality_manager: z.string().optional().nullable(),
|
||||
notes: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<CrudPage<Customer>
|
||||
title="Kunden"
|
||||
subtitle="Praxen und Kliniken mit Kontakt- und Qualitaetsdaten."
|
||||
endpoint="/customers"
|
||||
columns={[
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "customer_type", label: "Typ" },
|
||||
{ key: "city", label: "Ort" },
|
||||
{ key: "phone", label: "Telefon" },
|
||||
{ key: "email", label: "Mail" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_type", label: "Typ", type: "select", options: [{ label: "Praxis", value: "practice" }, { label: "Klinik", value: "clinic" }] },
|
||||
{ name: "name", label: "Name", required: true },
|
||||
{ name: "street", label: "Adresse" },
|
||||
{ name: "postal_code", label: "PLZ" },
|
||||
{ name: "city", label: "Ort" },
|
||||
{ name: "phone", label: "Telefon" },
|
||||
{ name: "email", label: "Mail", type: "email" },
|
||||
{ name: "hygiene_officer", label: "Hygienebeauftragter" },
|
||||
{ name: "quality_manager", label: "QM" },
|
||||
{ name: "notes", label: "Bemerkungen", type: "textarea" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_type: "practice", name: "", street: "", postal_code: "", city: "", phone: "", email: "", hygiene_officer: "", quality_manager: "", notes: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
60
validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx
Normal file
60
validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Gauge, MapPin, Stethoscope, UserRound } 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;
|
||||
};
|
||||
|
||||
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 }
|
||||
] as const;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { token } = useAuth();
|
||||
const query = useQuery({
|
||||
queryKey: ["dashboard", token],
|
||||
queryFn: () => apiGet<DashboardData>("/dashboard", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
</header>
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{cards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:-translate-y-0.5 hover:border-primary/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-light">{item.label}</p>
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<p className="mt-6 text-4xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
validation-suite/frontend/atlas/app/(app)/devices/page.tsx
Normal file
70
validation-suite/frontend/atlas/app/(app)/devices/page.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { apiGet, Customer, Device, Location, Paginated } from "@/lib/api";
|
||||
|
||||
const optionalNumber = z.union([z.coerce.number().int().positive(), z.literal(""), z.null()]).optional();
|
||||
const schema = z.object({
|
||||
customer_id: z.string().min(1),
|
||||
location_id: z.string().optional().nullable(),
|
||||
manufacturer: z.string().min(2),
|
||||
model: z.string().min(1),
|
||||
device_type: z.string().optional().nullable(),
|
||||
serial_number: z.string().min(2),
|
||||
year_built: optionalNumber,
|
||||
commissioned_on: z.string().optional().nullable(),
|
||||
chamber_volume_liters: optionalNumber,
|
||||
steam_generation: z.string().optional().nullable(),
|
||||
water_treatment: z.string().optional().nullable(),
|
||||
documentation: z.string().optional().nullable(),
|
||||
supplier: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { token } = useAuth();
|
||||
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 customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
const locationOptions = (locations.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
return (
|
||||
<CrudPage<Device>
|
||||
title="Geraete"
|
||||
subtitle="Geraetestammdaten, technische Daten, Standort und Dokumentation."
|
||||
endpoint="/devices"
|
||||
columns={[
|
||||
{ key: "manufacturer", label: "Hersteller" },
|
||||
{ key: "model", label: "Modell" },
|
||||
{ key: "device_type", label: "Typ" },
|
||||
{ key: "serial_number", label: "Seriennummer" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
|
||||
{ name: "location_id", label: "Standort", type: "select", options: locationOptions },
|
||||
{ name: "manufacturer", label: "Hersteller" },
|
||||
{ name: "model", label: "Modell" },
|
||||
{ name: "device_type", label: "Typ" },
|
||||
{ name: "serial_number", label: "Seriennummer" },
|
||||
{ name: "year_built", label: "Baujahr", type: "number" },
|
||||
{ name: "commissioned_on", label: "Inbetriebnahme", type: "date" },
|
||||
{ name: "chamber_volume_liters", label: "Kammervolumen Liter", type: "number" },
|
||||
{ name: "steam_generation", label: "Dampferzeugung" },
|
||||
{ name: "water_treatment", label: "Wasseraufbereitung" },
|
||||
{ name: "supplier", label: "Lieferant" },
|
||||
{ name: "documentation", label: "Dokumentation", type: "textarea" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_id: "", location_id: "", manufacturer: "", model: "", device_type: "", serial_number: "", year_built: "", commissioned_on: "", chamber_volume_liters: "", steam_generation: "", water_treatment: "", documentation: "", supplier: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
validation-suite/frontend/atlas/app/(app)/documents/page.tsx
Normal file
20
validation-suite/frontend/atlas/app/(app)/documents/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Upload } from "lucide-react";
|
||||
|
||||
export default function DocumentsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold text-text">Dokumente</h1>
|
||||
<p className="mt-2 text-text-light">PDF, JPG, PNG, DOCX und XLSX mit Zuordnung zu Kunden, Geraeten, Validierungen und Pruefmitteln.</p>
|
||||
</header>
|
||||
<section className="flex min-h-80 items-center justify-center rounded-lg border border-dashed border-primary/40 bg-surface p-8 text-center shadow-soft">
|
||||
<div>
|
||||
<Upload className="mx-auto h-10 w-10 text-primary" />
|
||||
<h2 className="mt-4 text-xl font-semibold">Drag & Drop Upload</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-light">Die Dokumentenablage ist im Backend modelliert und fuer Upload-Flows vorbereitet.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
47
validation-suite/frontend/atlas/app/(app)/equipment/page.tsx
Normal file
47
validation-suite/frontend/atlas/app/(app)/equipment/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { Equipment } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
kind: z.enum(["temperature_logger", "pressure_logger", "sensor"]),
|
||||
manufacturer: z.string().optional().nullable(),
|
||||
model: z.string().optional().nullable(),
|
||||
serial_number: z.string().min(2),
|
||||
calibrated_on: z.string().optional().nullable(),
|
||||
calibration_due_on: z.string().optional().nullable(),
|
||||
certificate_document_id: z.string().optional().nullable(),
|
||||
status: z.enum(["green", "yellow", "red"])
|
||||
});
|
||||
|
||||
export default function EquipmentPage() {
|
||||
return (
|
||||
<CrudPage<Equipment>
|
||||
title="Pruefmittel"
|
||||
subtitle="Temperaturlogger, Drucklogger, Sensoren, Kalibrierstatus und Zertifikate."
|
||||
endpoint="/equipment"
|
||||
columns={[
|
||||
{ key: "kind", label: "Art" },
|
||||
{ key: "manufacturer", label: "Hersteller" },
|
||||
{ key: "model", label: "Modell" },
|
||||
{ key: "serial_number", label: "Seriennummer" },
|
||||
{ key: "calibration_due_on", label: "Gueltig bis" },
|
||||
{ key: "status", label: "Status" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "kind", label: "Art", type: "select", options: [{ label: "Temperaturlogger", value: "temperature_logger" }, { label: "Drucklogger", value: "pressure_logger" }, { label: "Sensor", value: "sensor" }] },
|
||||
{ name: "manufacturer", label: "Hersteller" },
|
||||
{ name: "model", label: "Modell" },
|
||||
{ name: "serial_number", label: "Seriennummer" },
|
||||
{ name: "calibrated_on", label: "Kalibrierung", type: "date" },
|
||||
{ name: "calibration_due_on", label: "Kalibrierung gueltig bis", type: "date" },
|
||||
{ name: "certificate_document_id", label: "Kalibrierzertifikat Dokument-ID" },
|
||||
{ name: "status", label: "Ampel", type: "select", options: [{ label: "Gruen", value: "green" }, { label: "Gelb", value: "yellow" }, { label: "Rot", value: "red" }] }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ kind: "temperature_logger", manufacturer: "", model: "", serial_number: "", calibrated_on: "", calibration_due_on: "", certificate_document_id: "", status: "green" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
14
validation-suite/frontend/atlas/app/(app)/layout.tsx
Normal file
14
validation-suite/frontend/atlas/app/(app)/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { AppShell } from "@/components/app-shell";
|
||||
import { AuthProvider } from "@/components/auth";
|
||||
import { QueryProvider } from "@/components/query-provider";
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
49
validation-suite/frontend/atlas/app/(app)/locations/page.tsx
Normal file
49
validation-suite/frontend/atlas/app/(app)/locations/page.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { CrudPage } from "@/components/crud-page";
|
||||
import { apiGet, Customer, Location, Paginated } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
customer_id: z.string().min(1),
|
||||
name: z.string().min(2),
|
||||
street: z.string().optional().nullable(),
|
||||
postal_code: z.string().optional().nullable(),
|
||||
city: z.string().optional().nullable(),
|
||||
room: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export default function LocationsPage() {
|
||||
const { token } = useAuth();
|
||||
const customers = useQuery({
|
||||
queryKey: ["customers-options", token],
|
||||
queryFn: () => apiGet<Paginated<Customer>>("/customers?page=1&page_size=100", token ?? ""),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
const customerOptions = (customers.data?.items ?? []).map((item) => ({ label: item.name, value: item.id }));
|
||||
return (
|
||||
<CrudPage<Location>
|
||||
title="Standorte"
|
||||
subtitle="Standorte und Raeume mit Zuordnung zum Kunden."
|
||||
endpoint="/locations"
|
||||
columns={[
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "city", label: "Ort" },
|
||||
{ key: "room", label: "Raum" },
|
||||
{ key: "customer_id", label: "Kunden-ID" }
|
||||
]}
|
||||
fields={[
|
||||
{ name: "customer_id", label: "Kunde", type: "select", options: customerOptions },
|
||||
{ name: "name", label: "Name" },
|
||||
{ name: "street", label: "Adresse" },
|
||||
{ name: "postal_code", label: "PLZ" },
|
||||
{ name: "city", label: "Ort" },
|
||||
{ name: "room", label: "Raum" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_id: "", name: "", street: "", postal_code: "", city: "", room: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
361
validation-suite/frontend/atlas/app/(app)/validations/page.tsx
Normal file
361
validation-suite/frontend/atlas/app/(app)/validations/page.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"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 { useAuth } from "@/components/auth";
|
||||
import { apiGet, apiSend, Contact, Customer, Device, Equipment, Location, 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 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";
|
||||
|
||||
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" });
|
||||
|
||||
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"] });
|
||||
}
|
||||
});
|
||||
|
||||
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 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 submit(values: FormValues) {
|
||||
saveMutation.mutate(values);
|
||||
}
|
||||
|
||||
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) }));
|
||||
}
|
||||
|
||||
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>
|
||||
</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>
|
||||
</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} />)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue