style(orion): align report header footer and page layout
This commit is contained in:
parent
302e542fda
commit
503b343070
40 changed files with 2010 additions and 148 deletions
|
|
@ -1,20 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine, Gauge, Plus, Stethoscope, TestTube2, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
|
||||
type DashboardData = {
|
||||
customers: number;
|
||||
devices: number;
|
||||
equipment: number;
|
||||
validations: number;
|
||||
validation_drafts: number;
|
||||
validation_ready: number;
|
||||
validation_in_review: number;
|
||||
validation_approved: number;
|
||||
validation_completed: number;
|
||||
validation_overdue: number;
|
||||
equipment_green: number;
|
||||
equipment_yellow: number;
|
||||
equipment_red: number;
|
||||
};
|
||||
|
||||
const cards = [
|
||||
const primaryCards = [
|
||||
{ label: "Neue Validierung", value: "Starten", href: "/validations/new", icon: Plus },
|
||||
{ label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList },
|
||||
{ label: "Kunden", key: "customers", href: "/customers", icon: Users },
|
||||
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
|
||||
{ label: "Pruefmittel", key: "equipment", href: "/equipment", icon: TestTube2 }
|
||||
] as const;
|
||||
|
||||
const workflowCards = [
|
||||
{ label: "Entwuerfe", key: "validation_drafts", href: "/validations?status=ENTWURF", icon: FilePenLine },
|
||||
{ label: "Bereit zur Pruefung", key: "validation_ready", href: "/validations?status=BEREIT_ZUR_PRUEFUNG", icon: ClipboardList },
|
||||
{ label: "In Pruefung", key: "validation_in_review", href: "/validations?status=IN_PRUEFUNG", icon: Clock },
|
||||
|
|
@ -32,27 +49,63 @@ export default function DashboardPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold text-text">Dashboard</h1>
|
||||
<header className="rounded-lg border border-border bg-surface p-6 shadow-soft">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<BrandLogo />
|
||||
<div className="text-sm text-text-light">
|
||||
<p>Benutzer: angemeldet</p>
|
||||
<p>Speicherstatus: synchronisiert</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mt-6 text-3xl font-semibold text-text">Dashboard</h1>
|
||||
<p className="mt-2 text-text-light">Validierungsworkflow und faellige Revalidierungen.</p>
|
||||
</header>
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{cards.map((item) => {
|
||||
{primaryCards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const value = "key" in item ? query.data?.[item.key] ?? 0 : item.value;
|
||||
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">
|
||||
<Link key={item.label} 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 hover:shadow-lg">
|
||||
<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>
|
||||
<p className="mt-6 text-4xl font-semibold text-text">{value}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<section className="rounded-lg border border-border bg-surface p-6 shadow-soft">
|
||||
<h2 className="text-xl font-semibold">Zuletzt bearbeitete Validierungen</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-text-light">Die Validierungsverwaltung bietet Suche, Filter, Sortierung, Vorschau, Export und Workflow-Aktionen.</p>
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{workflowCards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-5 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||
<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-4 text-3xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<section className="grid gap-4 xl:grid-cols-4">
|
||||
<Link href="/validations?sort_by=updated_at&sort_order=desc" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||
<h2 className="text-xl font-semibold">Zuletzt bearbeitet</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-text-light">Aktuelle Validierungen nach Bearbeitungsdatum oeffnen.</p>
|
||||
</Link>
|
||||
<Link href="/validations?overdue_only=true" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-danger/40 hover:bg-background">
|
||||
<div className="flex items-center justify-between"><h2 className="text-xl font-semibold">Ueberfaellige Revalidierungen</h2><AlertTriangle className="h-5 w-5 text-danger" /></div>
|
||||
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.validation_overdue ?? 0}</p>
|
||||
</Link>
|
||||
<Link href="/equipment" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||
<div className="flex items-center justify-between"><h2 className="text-xl font-semibold">Kalibrierstatus</h2><Gauge className="h-5 w-5 text-primary" /></div>
|
||||
<p className="mt-4 text-sm text-text-light">Gruen {query.data?.equipment_green ?? 0} · Gelb {query.data?.equipment_yellow ?? 0} · Rot {query.data?.equipment_red ?? 0}</p>
|
||||
</Link>
|
||||
<Link href="/validations?status=ABGESCHLOSSEN" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||
<h2 className="text-xl font-semibold">Letzte Berichte</h2>
|
||||
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.validation_completed ?? 0}</p>
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default function ValidationPreviewPage() {
|
|||
const params = useParams<{ id: string }>();
|
||||
const [html, setHtml] = useState("");
|
||||
const [message, setMessage] = useState("Vorschau wird geladen.");
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !params.id) return;
|
||||
|
|
@ -33,6 +34,11 @@ export default function ValidationPreviewPage() {
|
|||
const response = await fetch(`${API_BASE}/validations/${params.id}/report.pdf`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
setToast(body?.detail ?? "PDF konnte nicht erzeugt werden.");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
|
@ -54,6 +60,7 @@ export default function ValidationPreviewPage() {
|
|||
<button type="button" onClick={downloadPdf} className="btn btn-primary h-12"><Download className="h-4 w-4" /> PDF herunterladen</button>
|
||||
</div>
|
||||
</header>
|
||||
{toast && <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">{toast}</div>}
|
||||
{message ? <div className="rounded-lg border border-border bg-surface p-6 shadow-soft">{message}</div> : <iframe title="Validierungsbericht" srcDoc={html} className="h-[78vh] w-full rounded-lg border border-border bg-white shadow-soft" />}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { AuthProvider, useAuth } from "@/components/auth";
|
||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
import { login } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
|
|
@ -33,7 +34,7 @@ function LoginPanel() {
|
|||
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-10">
|
||||
<section className="w-full max-w-md rounded-lg border border-border bg-surface p-8 shadow-soft">
|
||||
<div className="mb-8">
|
||||
<p className="text-sm font-medium text-primary">Validation Suite</p>
|
||||
<BrandLogo />
|
||||
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
|
||||
</div>
|
||||
|
|
|
|||
9
validation-suite/frontend/atlas/app/icon.svg
Normal file
9
validation-suite/frontend/atlas/app/icon.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 480">
|
||||
<path d="M476,95 L483,94 L486,97 L486,99 L484,101 L477,102 Z" fill="#F60B2F"/>
|
||||
<path d="M473,92 L473,112 L476,112 L477,104 L480,104 L485,112 L489,112 L485,106 L489,101 L488,93 Z" fill="#F60B2F"/>
|
||||
<path d="M476,83 L487,84 L496,92 L498,97 L498,107 L496,111 L485,120 L475,120 L470,118 L464,112 L461,106 L461,98 L464,91 L469,86 Z" fill="#F60B2F"/>
|
||||
<path d="M329,84 L328,399 L329,401 L338,401 L354,399 L358,396 L358,119 L363,118 L385,127 L391,133 L392,386 L403,381 L417,372 L421,365 L422,158 L432,170 L444,193 L450,211 L454,230 L454,250 L448,281 L441,297 L440,303 L460,316 L466,315 L478,284 L483,255 L483,232 L480,210 L470,178 L454,150 L431,124 L407,106 L378,92 L350,85 Z" fill="#F60B2F"/>
|
||||
<path d="M308,83 L283,86 L256,94 L235,104 L211,121 L193,139 L178,160 L167,182 L159,208 L159,216 L162,219 L278,285 L281,289 L280,367 L269,364 L250,355 L229,340 L211,320 L203,308 L194,289 L190,285 L186,283 L163,283 L162,288 L168,305 L184,334 L195,348 L209,362 L236,381 L268,395 L298,401 L310,400 L310,271 L303,265 L201,207 L192,200 L207,170 L217,157 L229,145 L255,127 L280,117 L281,200 L284,202 L307,202 L310,200 L310,84 Z" fill="#F60B2F"/>
|
||||
<path d="M473,80 L467,83 L461,89 L458,95 L458,108 L463,117 L468,121 L476,124 L487,123 L493,120 L498,115 L501,109 L501,94 L497,87 L491,82 L486,80 Z" fill="#F60B2F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
15
validation-suite/frontend/atlas/app/loading.tsx
Normal file
15
validation-suite/frontend/atlas/app/loading.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="rounded-lg border border-border bg-surface p-8 shadow-soft">
|
||||
<BrandLogo />
|
||||
<div className="mt-6 flex items-center justify-center gap-3 text-text-light">
|
||||
<span className="spinner" />
|
||||
<span>Validation Suite wird geladen.</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
17
validation-suite/frontend/atlas/app/not-found.tsx
Normal file
17
validation-suite/frontend/atlas/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Link from "next/link";
|
||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<section className="w-full max-w-lg rounded-lg border border-border bg-surface p-8 text-center shadow-soft">
|
||||
<div className="flex justify-center">
|
||||
<BrandLogo />
|
||||
</div>
|
||||
<h1 className="mt-8 text-3xl font-semibold">Seite nicht gefunden</h1>
|
||||
<p className="mt-3 text-text-light">Die angeforderte Seite ist nicht vorhanden oder wurde verschoben.</p>
|
||||
<Link href="/dashboard" className="btn btn-primary mt-6">Zum Dashboard</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 480">
|
||||
<path d="M476,95 L483,94 L486,97 L486,99 L484,101 L477,102 Z" fill="#F60B2F"/>
|
||||
<path d="M473,92 L473,112 L476,112 L477,104 L480,104 L485,112 L489,112 L485,106 L489,101 L488,93 Z" fill="#F60B2F"/>
|
||||
<path d="M476,83 L487,84 L496,92 L498,97 L498,107 L496,111 L485,120 L475,120 L470,118 L464,112 L461,106 L461,98 L464,91 L469,86 Z" fill="#F60B2F"/>
|
||||
<path d="M329,84 L328,399 L329,401 L338,401 L354,399 L358,396 L358,119 L363,118 L385,127 L391,133 L392,386 L403,381 L417,372 L421,365 L422,158 L432,170 L444,193 L450,211 L454,230 L454,250 L448,281 L441,297 L440,303 L460,316 L466,315 L478,284 L483,255 L483,232 L480,210 L470,178 L454,150 L431,124 L407,106 L378,92 L350,85 Z" fill="#F60B2F"/>
|
||||
<path d="M308,83 L283,86 L256,94 L235,104 L211,121 L193,139 L178,160 L167,182 L159,208 L159,216 L162,219 L278,285 L281,289 L280,367 L269,364 L250,355 L229,340 L211,320 L203,308 L194,289 L190,285 L186,283 L163,283 L162,288 L168,305 L184,334 L195,348 L209,362 L236,381 L268,395 L298,401 L310,400 L310,271 L303,265 L201,207 L192,200 L207,170 L217,157 L229,145 L255,127 L280,117 L281,200 L284,202 L307,202 L310,200 L310,84 Z" fill="#F60B2F"/>
|
||||
<path d="M473,80 L467,83 L461,89 L458,95 L458,108 L463,117 L468,121 L476,124 L487,123 L493,120 L498,115 L501,109 L501,94 L497,87 L491,82 L486,80 Z" fill="#F60B2F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Loading…
Add table
Add a link
Reference in a new issue