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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue