feat(orion): extend report layout settings to version 1.1

This commit is contained in:
Schubert Ferenc 2026-07-12 14:56:15 +02:00
parent 12e1761a5b
commit 613ffdc8b7
591 changed files with 3601 additions and 999 deletions

View file

@ -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>
);
}