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

View file

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

View file

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

View file

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