style(orion): align report header footer and page layout

This commit is contained in:
Schubert Ferenc 2026-07-11 12:19:10 +02:00
parent 302e542fda
commit 503b343070
40 changed files with 2010 additions and 148 deletions

View file

@ -14,6 +14,7 @@ import {
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/components/auth";
import { BrandLogo } from "@/components/brand/brand-logo";
const navigation = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
@ -34,8 +35,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="min-h-screen bg-background text-text">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
<div className="mb-8">
<div className="text-xl font-semibold text-primary-dark">Validation Suite</div>
<div className="mt-1 text-sm text-text-light">Atlas Workspace</div>
<BrandLogo compact />
</div>
<nav className="space-y-1">
{navigation.map((item, index) => {

View file

@ -0,0 +1,9 @@
import Image from "next/image";
export function BrandLogo({ compact = false }: { compact?: boolean }) {
return (
<div className="flex items-center gap-3">
<Image src="/schubamed-logo.svg" alt="SCHUBAMED Validation Suite" width={compact ? 180 : 240} height={64} priority />
</div>
);
}

View file

@ -13,30 +13,6 @@ import { API_BASE, apiGet, apiSend, Contact, Customer, Device, Equipment, Locati
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",
@ -95,10 +71,7 @@ const schema = z.object({
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: "" }));
}
type ChecklistTemplate = { checklist_key: string; title: string; items: Record<string, unknown>[] };
function defaults(reportNumber = ""): FormValues {
return {
@ -131,8 +104,8 @@ function defaults(reportNumber = ""): FormValues {
{ text: "Medienversorgung verfuegbar", value: "na", comment: "" }
]
},
documentation_checklist: checklist(checklistTexts),
performance_checklist: checklist(performanceTexts),
documentation_checklist: [],
performance_checklist: [],
programs: [
{ name: "Vakuumtest", selected: false, custom: false },
{ name: "Bowie-Dick / Leerkammerprofil", selected: false, custom: false },
@ -191,6 +164,7 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
const [saveState, setSaveState] = useState<ActionState>("normal");
const [reviewState, setReviewState] = useState<ActionState>("normal");
const [pdfState, setPdfState] = useState<ActionState>("normal");
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null);
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) });
@ -199,6 +173,7 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
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 checklistTemplates = useQuery({ queryKey: ["report-checklists", token], queryFn: () => apiGet<ChecklistTemplate[]>("/report-templates/default/checklists", 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() });
@ -236,6 +211,15 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
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({
@ -327,9 +311,40 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
}
}
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 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() {
@ -352,7 +367,10 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
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.");
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");
@ -439,8 +457,8 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
</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}`} />)}
{form.watch("documentation_checklist").map((item, index) => <ChecklistRow key={`${String(item.template_key ?? "check")}-${index}`} form={form} path={`documentation_checklist.${index}`} />)}
{form.watch("performance_checklist").map((_, index) => <ChecklistRow key={`performance-${index}`} form={form} path={`performance_checklist.${index}`} />)}
</Accordion>
<Accordion title="7. Programme">
@ -469,8 +487,12 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
</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>
<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 transition hover:border-primary hover:bg-background"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}{uploadingCategory === category && <span className="ml-2 text-sm text-text-light">Upload...</span>}<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) => {
const fileUrl = String(form.watch(`attachments.${index}.url`) ?? "");
const imageUrl = fileUrl && /\.(png|jpe?g|webp|svg)$/i.test(fileUrl) ? `${API_BASE.replace("/api/v1", "")}${fileUrl}` : "";
return <div key={field.id} className="rounded-lg border border-border p-4">{imageUrl && <img src={imageUrl} alt={String(form.watch(`attachments.${index}.filename`) ?? "")} className="mb-3 max-h-56 w-full rounded-lg border border-border object-contain" />}<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>}