feat(orion): extend report layout settings to version 1.1
This commit is contained in:
parent
12e1761a5b
commit
613ffdc8b7
591 changed files with 3601 additions and 999 deletions
|
|
@ -0,0 +1,529 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, RotateCcw, Save } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/components/auth";
|
||||
|
||||
type LayoutProfile = "COMPACT" | "STANDARD";
|
||||
type SignatureMode = "TECHNICAL_ONLY" | "TECHNICAL_AND_CLIENT";
|
||||
type ImageSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
type PageMargin = "NARROW" | "STANDARD" | "WIDE";
|
||||
type Spacing = "COMPACT" | "STANDARD";
|
||||
type TableFontSize = "SMALL" | "STANDARD";
|
||||
type ImagePosition = "STACKED" | "SIDE_BY_SIDE";
|
||||
type LogoSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
|
||||
type ReportSettings = {
|
||||
id: string;
|
||||
scope: "GLOBAL";
|
||||
layout_profile: LayoutProfile;
|
||||
show_cover_result_text: boolean;
|
||||
signature_mode: SignatureMode;
|
||||
image_size: ImageSize;
|
||||
page_break_before_main_chapters: boolean;
|
||||
page_margin: PageMargin;
|
||||
section_spacing: Spacing;
|
||||
table_layout: Spacing;
|
||||
table_font_size: TableFontSize;
|
||||
image_position: ImagePosition;
|
||||
max_images_per_page: 1 | 2 | 4;
|
||||
show_image_captions: boolean;
|
||||
show_header: boolean;
|
||||
show_footer: boolean;
|
||||
logo_size: LogoSize;
|
||||
compact_cover: boolean;
|
||||
updated_by_user_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type ReportSettingsForm = Pick<
|
||||
ReportSettings,
|
||||
| "layout_profile"
|
||||
| "show_cover_result_text"
|
||||
| "signature_mode"
|
||||
| "image_size"
|
||||
| "page_break_before_main_chapters"
|
||||
| "page_margin"
|
||||
| "section_spacing"
|
||||
| "table_layout"
|
||||
| "table_font_size"
|
||||
| "image_position"
|
||||
| "max_images_per_page"
|
||||
| "show_image_captions"
|
||||
| "show_header"
|
||||
| "show_footer"
|
||||
| "logo_size"
|
||||
| "compact_cover"
|
||||
>;
|
||||
|
||||
const defaults: ReportSettingsForm = {
|
||||
layout_profile: "STANDARD",
|
||||
show_cover_result_text: true,
|
||||
signature_mode: "TECHNICAL_ONLY",
|
||||
image_size: "MEDIUM",
|
||||
page_break_before_main_chapters: true,
|
||||
page_margin: "STANDARD",
|
||||
section_spacing: "STANDARD",
|
||||
table_layout: "STANDARD",
|
||||
table_font_size: "STANDARD",
|
||||
image_position: "SIDE_BY_SIDE",
|
||||
max_images_per_page: 2,
|
||||
show_image_captions: true,
|
||||
show_header: true,
|
||||
show_footer: true,
|
||||
logo_size: "MEDIUM",
|
||||
compact_cover: false
|
||||
};
|
||||
|
||||
async function readError(response: Response, fallback: string) {
|
||||
try {
|
||||
const data = (await response.json()) as { detail?: string };
|
||||
return data.detail ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
const response = await fetch("/api/report-settings", { credentials: "include", cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "Berichtseinstellungen konnten nicht geladen werden."));
|
||||
}
|
||||
return (await response.json()) as ReportSettings;
|
||||
}
|
||||
|
||||
async function saveSettings(values: ReportSettingsForm) {
|
||||
const response = await fetch("/api/report-settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
credentials: "include",
|
||||
cache: "no-store"
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "Berichtseinstellungen konnten nicht gespeichert werden."));
|
||||
}
|
||||
return (await response.json()) as ReportSettings;
|
||||
}
|
||||
|
||||
async function previewSettings(values: ReportSettingsForm) {
|
||||
const response = await fetch("/api/report-settings/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
credentials: "include",
|
||||
cache: "no-store"
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, "PDF-Vorschau konnte nicht erzeugt werden."));
|
||||
}
|
||||
return URL.createObjectURL(await response.blob());
|
||||
}
|
||||
|
||||
function toForm(settings?: ReportSettings): ReportSettingsForm {
|
||||
return settings
|
||||
? {
|
||||
layout_profile: settings.layout_profile,
|
||||
show_cover_result_text: settings.show_cover_result_text,
|
||||
signature_mode: settings.signature_mode,
|
||||
image_size: settings.image_size,
|
||||
page_break_before_main_chapters: settings.page_break_before_main_chapters,
|
||||
page_margin: settings.page_margin,
|
||||
section_spacing: settings.section_spacing,
|
||||
table_layout: settings.table_layout,
|
||||
table_font_size: settings.table_font_size,
|
||||
image_position: settings.image_position,
|
||||
max_images_per_page: settings.max_images_per_page,
|
||||
show_image_captions: settings.show_image_captions,
|
||||
show_header: settings.show_header,
|
||||
show_footer: settings.show_footer,
|
||||
logo_size: settings.logo_size,
|
||||
compact_cover: settings.compact_cover
|
||||
}
|
||||
: defaults;
|
||||
}
|
||||
|
||||
function sameSettings(a: ReportSettingsForm, b: ReportSettingsForm) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export default function ReportLayoutSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<ReportSettingsForm>(defaults);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const settings = useQuery({
|
||||
queryKey: ["report-settings"],
|
||||
queryFn: getSettings,
|
||||
enabled: user?.role === "admin"
|
||||
});
|
||||
|
||||
const saved = useMemo(() => toForm(settings.data), [settings.data]);
|
||||
const dirty = !sameSettings(draft, saved);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.data) {
|
||||
setDraft(toForm(settings.data));
|
||||
}
|
||||
}, [settings.data]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
}, [previewUrl]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: saveSettings,
|
||||
onSuccess: async (data) => {
|
||||
setMessage("Berichtseinstellungen gespeichert");
|
||||
setError("");
|
||||
setDraft(toForm(data));
|
||||
await queryClient.invalidateQueries({ queryKey: ["report-settings"] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
setMessage("");
|
||||
}
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: previewSettings,
|
||||
onSuccess: (url) => {
|
||||
setPreviewUrl((previous) => {
|
||||
if (previous) {
|
||||
URL.revokeObjectURL(previous);
|
||||
}
|
||||
return url;
|
||||
});
|
||||
setMessage("Vorschau aktualisiert");
|
||||
setError("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
setMessage("");
|
||||
}
|
||||
});
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <div className="rounded-lg border border-border bg-surface p-6 text-text-light">Nur fuer Administratoren verfuegbar.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold text-text">Berichtseinstellungen</h1>
|
||||
<p className="mt-2 max-w-3xl text-text-light">
|
||||
Grundlegende Orion-Berichtsdarstellung mit sicheren Auswahloptionen und PDF-Vorschau.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{settings.isLoading ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-surface p-5 text-sm text-text-light">
|
||||
<span className="spinner" />
|
||||
Berichtseinstellungen werden geladen...
|
||||
</div>
|
||||
) : settings.isError ? (
|
||||
<div className="rounded-lg border border-danger/40 bg-danger/10 p-5 text-sm text-danger">
|
||||
Berichtseinstellungen konnten nicht geladen werden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,520px)]">
|
||||
<div className="grid gap-5 lg:grid-cols-2 xl:grid-cols-1">
|
||||
<SettingsCard title="Seitenlayout" description="Steuert Seitenränder und Abstände im Bericht.">
|
||||
<SegmentedControl
|
||||
label="Seitenränder"
|
||||
value={draft.page_margin}
|
||||
options={[
|
||||
{ value: "NARROW", label: "Schmal" },
|
||||
{ value: "STANDARD", label: "Standard" },
|
||||
{ value: "WIDE", label: "Breit" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, page_margin: value as PageMargin }))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Abschnittsabstände"
|
||||
value={draft.section_spacing}
|
||||
options={[
|
||||
{ value: "COMPACT", label: "Kompakt" },
|
||||
{ value: "STANDARD", label: "Standard" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, section_spacing: value as Spacing }))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Layoutprofil"
|
||||
value={draft.layout_profile}
|
||||
options={[
|
||||
{ value: "COMPACT", label: "Kompakt" },
|
||||
{ value: "STANDARD", label: "Standard" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, layout_profile: value as LayoutProfile }))}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Deckblatt" description="Sichtbarkeit und kompaktere Darstellung auf der Titelseite.">
|
||||
<ToggleRow
|
||||
label="Ergebnistext auf Deckblatt"
|
||||
checked={draft.show_cover_result_text}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, show_cover_result_text: value }))}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Deckblatt kompakt"
|
||||
checked={draft.compact_cover}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, compact_cover: value }))}
|
||||
/>
|
||||
<RadioCards
|
||||
label="Unterschriften"
|
||||
value={draft.signature_mode}
|
||||
options={[
|
||||
{ value: "TECHNICAL_ONLY", label: "Nur technische Validierung" },
|
||||
{ value: "TECHNICAL_AND_CLIENT", label: "Technische Validierung + Auftraggeber" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, signature_mode: value as SignatureMode }))}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Tabellen" description="Dichte und Schrift der Berichtstabellen.">
|
||||
<SegmentedControl
|
||||
label="Tabellenlayout"
|
||||
value={draft.table_layout}
|
||||
options={[
|
||||
{ value: "COMPACT", label: "Kompakt" },
|
||||
{ value: "STANDARD", label: "Standard" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, table_layout: value as Spacing }))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Tabellenschrift"
|
||||
value={draft.table_font_size}
|
||||
options={[
|
||||
{ value: "SMALL", label: "Klein" },
|
||||
{ value: "STANDARD", label: "Standard" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, table_font_size: value as TableFontSize }))}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Bilder" description="Größe, Anordnung und Beschriftungen von Bildern.">
|
||||
<SegmentedControl
|
||||
label="Standard-Bildgröße"
|
||||
value={draft.image_size}
|
||||
options={[
|
||||
{ value: "SMALL", label: "Klein" },
|
||||
{ value: "MEDIUM", label: "Mittel" },
|
||||
{ value: "LARGE", label: "Groß" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, image_size: value as ImageSize }))}
|
||||
/>
|
||||
<RadioCards
|
||||
label="Bildposition"
|
||||
value={draft.image_position}
|
||||
options={[
|
||||
{ value: "STACKED", label: "Untereinander" },
|
||||
{ value: "SIDE_BY_SIDE", label: "Nebeneinander wenn möglich" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, image_position: value as ImagePosition }))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Maximale Bilder pro Seite"
|
||||
value={String(draft.max_images_per_page)}
|
||||
options={[
|
||||
{ value: "1", label: "1" },
|
||||
{ value: "2", label: "2" },
|
||||
{ value: "4", label: "4" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, max_images_per_page: Number(value) as 1 | 2 | 4 }))}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Bildbeschriftungen"
|
||||
checked={draft.show_image_captions}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, show_image_captions: value }))}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Kopf- und Fußzeile" description="Sichtbarkeit und Logo-Größe im Berichtskopf.">
|
||||
<ToggleRow
|
||||
label="Kopfzeile anzeigen"
|
||||
checked={draft.show_header}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, show_header: value }))}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Fußzeile anzeigen"
|
||||
checked={draft.show_footer}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, show_footer: value }))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Firmenlogo"
|
||||
value={draft.logo_size}
|
||||
options={[
|
||||
{ value: "SMALL", label: "Klein" },
|
||||
{ value: "MEDIUM", label: "Mittel" },
|
||||
{ value: "LARGE", label: "Groß" }
|
||||
]}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, logo_size: value as LogoSize }))}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Kapitel" description="Legt fest, ob Hauptkapitel auf neuen Seiten beginnen.">
|
||||
<ToggleRow
|
||||
label="Neue Seite vor Hauptkapiteln"
|
||||
checked={draft.page_break_before_main_chapters}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, page_break_before_main_chapters: value }))}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-lg border border-border bg-surface p-4 shadow-soft xl:sticky xl:top-6 xl:h-[calc(100vh-3rem)]">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-stretch">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-text">Live-Vorschau</h2>
|
||||
<p className="mt-1 text-sm text-text-light">PDF-Vorschau mit aktuellen, noch nicht gespeicherten Einstellungen.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary h-12"
|
||||
disabled={previewMutation.isPending || settings.isLoading}
|
||||
onClick={() => previewMutation.mutate(draft)}
|
||||
>
|
||||
{previewMutation.isPending ? <span className="spinner" /> : <Download className="h-4 w-4" />}
|
||||
{previewMutation.isPending ? "PDF wird erzeugt..." : "Vorschau aktualisieren"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 h-[520px] overflow-hidden rounded-lg border border-border bg-background xl:h-[calc(100%-8rem)]">
|
||||
{previewUrl ? (
|
||||
<iframe title="Orion PDF-Vorschau" src={previewUrl} className="h-full w-full" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-text-light">
|
||||
Erzeuge eine Vorschau, um die Auswirkungen der Einstellungen direkt zu prüfen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-text-light">
|
||||
Falls der Browser keine eingebetteten PDFs anzeigt, öffnet er die Datei über seine Standard-PDF-Funktion.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(message || error || dirty) && (
|
||||
<div className={`rounded-lg border p-4 text-sm ${error ? "border-danger/40 bg-danger/10 text-danger" : "border-accent/40 bg-accent/10 text-primary-dark"}`}>
|
||||
{error || message || "Ungespeicherte Änderungen"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-surface/95 px-4 py-3 shadow-soft backdrop-blur lg:left-72">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary h-12"
|
||||
disabled={!dirty || saveMutation.isPending}
|
||||
onClick={() => {
|
||||
setDraft(saved);
|
||||
setError("");
|
||||
setMessage("Änderungen verworfen");
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Zurücksetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary h-12"
|
||||
disabled={previewMutation.isPending || settings.isLoading}
|
||||
onClick={() => previewMutation.mutate(draft)}
|
||||
>
|
||||
{previewMutation.isPending ? <span className="spinner" /> : <Download className="h-4 w-4" />}
|
||||
{previewMutation.isPending ? "PDF wird erzeugt..." : "Vorschau aktualisieren"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary h-12"
|
||||
disabled={!dirty || saveMutation.isPending || settings.isLoading}
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
>
|
||||
{saveMutation.isPending ? <span className="spinner" /> : <Save className="h-4 w-4" />}
|
||||
{saveMutation.isPending ? "Speichern..." : "Änderungen speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCard({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||
<h2 className="text-xl font-semibold text-text">{title}</h2>
|
||||
<p className="mt-1 text-sm text-text-light">{description}</p>
|
||||
<div className="mt-5 space-y-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedControl({ label, value, options, onChange }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-text">{label}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={value === option.value}
|
||||
className={`min-h-12 rounded-lg border px-4 text-sm font-semibold transition hover:shadow-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[0.98] ${
|
||||
value === option.value ? "border-primary bg-primary text-white" : "border-border bg-white text-text hover:bg-background"
|
||||
}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioCards({ label, value, options, onChange }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-text">{label}</p>
|
||||
<div className="grid gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={value === option.value}
|
||||
className={`flex min-h-12 items-center justify-between rounded-lg border px-4 text-left text-sm font-semibold transition hover:shadow-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[0.98] ${
|
||||
value === option.value ? "border-primary bg-accent/25 text-primary-dark" : "border-border bg-white text-text hover:bg-background"
|
||||
}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<span className={`h-3 w-3 rounded-full border ${value === option.value ? "border-primary bg-primary" : "border-border"}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
className="flex min-h-12 w-full items-center justify-between rounded-lg border border-border bg-white px-4 text-left text-sm font-semibold transition hover:bg-background hover:shadow-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[0.98]"
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className={`rounded-full px-3 py-1 text-xs ${checked ? "bg-success/15 text-success" : "bg-slate-100 text-text-light"}`}>
|
||||
{checked ? "Ein" : "Aus"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,28 +45,53 @@ export default function QuickStartValidationPage() {
|
|||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (data.locations.length === 1) {
|
||||
setSelectedLocationId(data.locations[0].id);
|
||||
}
|
||||
if (data.contacts.length === 1) {
|
||||
setSelectedContactId(data.contacts[0].id);
|
||||
}
|
||||
if (data.devices.length === 1) {
|
||||
setSelectedDeviceId(data.devices[0].id);
|
||||
}
|
||||
if (validationType === "Revalidierung" && data.devices.length > 0 && !selectedDeviceId) {
|
||||
const previousDevice = data.validations[0]?.device_id;
|
||||
if (previousDevice) {
|
||||
setSelectedDeviceId(previousDevice);
|
||||
setSelectedLocationId((current) => {
|
||||
if (data.locations.length === 1) {
|
||||
return data.locations[0].id;
|
||||
}
|
||||
return current && data.locations.some((item) => item.id === current) ? current : "";
|
||||
});
|
||||
setSelectedContactId((current) => {
|
||||
if (data.contacts.length === 1) {
|
||||
return data.contacts[0].id;
|
||||
}
|
||||
return current && data.contacts.some((item) => item.id === current) ? current : "";
|
||||
});
|
||||
setSelectedDeviceId((current) => {
|
||||
if (data.devices.length === 1) {
|
||||
return data.devices[0].id;
|
||||
}
|
||||
if (current && data.devices.some((item) => item.id === current)) {
|
||||
return current;
|
||||
}
|
||||
if (validationType === "Revalidierung" || validationType === "Leistungsbeurteilung") {
|
||||
const previousDevice = data.validations.find((validation) => data.devices.some((device) => device.id === validation.device_id))?.device_id;
|
||||
return previousDevice ?? "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}, [customerData.data, validationType]);
|
||||
|
||||
useEffect(() => {
|
||||
const data = customerData.data;
|
||||
if (!data || !selectedDeviceId || selectedLocationId) {
|
||||
return;
|
||||
}
|
||||
}, [customerData.data, selectedDeviceId, validationType]);
|
||||
const device = data.devices.find((item) => item.id === selectedDeviceId);
|
||||
if (device?.location_id && data.locations.some((item) => item.id === device.location_id)) {
|
||||
setSelectedLocationId(device.location_id);
|
||||
}
|
||||
}, [customerData.data, selectedDeviceId, selectedLocationId]);
|
||||
|
||||
const selectedCustomer = customers.data?.items.find((item) => item.id === selectedCustomerId) ?? customerData.data?.customer ?? null;
|
||||
const selectedDevice = customerData.data?.devices.find((item) => item.id === selectedDeviceId) ?? null;
|
||||
const selectedLocation = customerData.data?.locations.find((item) => item.id === selectedLocationId) ?? null;
|
||||
const selectedContact = customerData.data?.contacts.find((item) => item.id === selectedContactId) ?? null;
|
||||
const currentExaminer = user ? `${user.first_name} ${user.last_name}` : "nicht angemeldet";
|
||||
const locations = customerData.data?.locations ?? [];
|
||||
const contacts = customerData.data?.contacts ?? [];
|
||||
const devices = customerData.data?.devices ?? [];
|
||||
const hasValidLocation = Boolean(selectedLocationId && locations.some((item) => item.id === selectedLocationId));
|
||||
const hasValidContact = !selectedContactId || contacts.some((item) => item.id === selectedContactId);
|
||||
const hasValidDevice = Boolean(selectedDeviceId && devices.some((item) => item.id === selectedDeviceId));
|
||||
const latestValidation = useMemo(() => {
|
||||
if (!selectedDeviceId) {
|
||||
return null;
|
||||
|
|
@ -99,10 +124,25 @@ export default function QuickStartValidationPage() {
|
|||
const customerCards = customers.data?.items ?? [];
|
||||
const canCreate =
|
||||
Boolean(selectedCustomerId) &&
|
||||
Boolean(selectedDeviceId) &&
|
||||
(customerData.data?.locations.length ?? 0) > 0 &&
|
||||
((customerData.data?.locations.length ?? 0) === 1 ? Boolean(selectedLocationId) : Boolean(selectedLocationId)) &&
|
||||
Boolean(validationType) &&
|
||||
Boolean(user) &&
|
||||
hasValidDevice &&
|
||||
hasValidLocation &&
|
||||
hasValidContact &&
|
||||
!createMutation.isPending;
|
||||
const disabledReason = !selectedCustomerId
|
||||
? "Bitte Kunde auswählen"
|
||||
: !hasValidDevice
|
||||
? "Bitte Gerät auswählen"
|
||||
: locations.length === 0
|
||||
? "Für den Kunden ist kein Standort vorhanden"
|
||||
: !hasValidLocation
|
||||
? "Bitte Standort auswählen"
|
||||
: !hasValidContact
|
||||
? "Bitte gültigen Ansprechpartner auswählen"
|
||||
: !user
|
||||
? "Bitte anmelden"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -224,8 +264,26 @@ export default function QuickStartValidationPage() {
|
|||
<div className="rounded-lg border border-border bg-surface p-5 shadow-soft lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold">4. Übernahme vorhandener Daten</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<InfoCard label="Standort" value={selectedLocation ? selectedLocation.name : customerData.data?.locations.length === 1 ? "Automatisch übernommen" : "Bitte auswählen"} />
|
||||
<InfoCard label="Ansprechpartner" value={selectedContact ? selectedContact.full_name : customerData.data?.contacts.length === 1 ? "Automatisch übernommen" : "Bitte auswählen"} />
|
||||
<SelectionCard
|
||||
label="Standort"
|
||||
value={selectedLocationId}
|
||||
placeholder="Bitte auswählen"
|
||||
emptyText={selectedCustomerId ? "Kein Standort für diesen Kunden vorhanden." : "Bitte zuerst einen Kunden auswählen."}
|
||||
options={locations.map((item) => ({ value: item.id, label: [item.name, item.room, item.city].filter(Boolean).join(" · ") }))}
|
||||
onChange={(value) => {
|
||||
setSelectedLocationId(value);
|
||||
setSelectedContactId(contacts.length === 1 ? contacts[0].id : "");
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<SelectionCard
|
||||
label="Ansprechpartner"
|
||||
value={selectedContactId}
|
||||
placeholder="Optional auswählen"
|
||||
emptyText={selectedCustomerId ? "Kein Ansprechpartner für diesen Kunden vorhanden." : "Bitte zuerst einen Kunden auswählen."}
|
||||
options={contacts.map((item) => ({ value: item.id, label: [item.full_name, item.function].filter(Boolean).join(" · ") }))}
|
||||
onChange={setSelectedContactId}
|
||||
/>
|
||||
<InfoCard label="Prüfer" value={currentExaminer} />
|
||||
<InfoCard label="Vorherige Validierung" value={latestValidation ? latestValidation.report_number : "Keine vorhanden"} />
|
||||
</div>
|
||||
|
|
@ -261,7 +319,7 @@ export default function QuickStartValidationPage() {
|
|||
<button
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
title={!canCreate ? "Bitte Kunde und Gerät auswählen" : undefined}
|
||||
title={!canCreate ? disabledReason : undefined}
|
||||
onClick={() => createMutation.mutate()}
|
||||
className="btn btn-primary h-12 min-w-64"
|
||||
>
|
||||
|
|
@ -282,3 +340,38 @@ function InfoCard({ label, value }: { label: string; value: string }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionCard({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
emptyText,
|
||||
onChange,
|
||||
required = false
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder: string;
|
||||
emptyText: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const disabled = options.length === 0;
|
||||
return (
|
||||
<label className="block rounded-lg border border-border bg-white p-4 transition hover:border-primary/40 focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/15">
|
||||
<span className="text-xs uppercase tracking-wide text-text-light">{label}{required && " *"}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
className="mt-2 h-11 w-full rounded-lg border border-border bg-background px-3 text-sm font-medium text-text outline-none transition hover:bg-white focus:border-primary disabled:cursor-not-allowed disabled:bg-border/30 disabled:text-text-light"
|
||||
>
|
||||
<option value="">{disabled ? emptyText : placeholder}</option>
|
||||
{options.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
{disabled && <p className="mt-2 text-sm text-text-light">{emptyText}</p>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth";
|
||||
|
||||
const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ detail: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const response = await fetch(`${mercuryBase}/report-settings/preview`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: await request.text(),
|
||||
cache: "no-store"
|
||||
});
|
||||
const contentType = response.headers.get("content-type") ?? "application/pdf";
|
||||
if (!response.ok && contentType.includes("application/json")) {
|
||||
return NextResponse.json(await response.json(), { status: response.status });
|
||||
}
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
"content-disposition": response.headers.get("content-disposition") ?? 'attachment; filename="orion-layout-preview.pdf"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth";
|
||||
|
||||
const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`;
|
||||
|
||||
async function forwardJson(request: NextRequest, method: "GET" | "PUT") {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ detail: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const response = await fetch(`${mercuryBase}/report-settings`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: method === "PUT" ? await request.text() : undefined,
|
||||
cache: "no-store"
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return forwardJson(request, "GET");
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return forwardJson(request, "PUT");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue