Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz, nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine. Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan, Rechenkern oder Layout nicht garantieren. Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen -- sonst nennen beide verschiedene Zahlen. Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test). Technik: pdfkit in der Node-Runtime statt Headless-Browser; @react-pdf/renderer bricht mit React 19. Als externes Paket deklariert, weil pdfkit Font-Metriken ueber Dateipfade laedt. Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowLeft, Download, FileText, Plus, Trash2 } from "lucide-react";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { ANALYSIS_TYPE_LABEL, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
|
||||
import { MAX_REPORT_SCENARIOS, type ReportMetric, type ReportSource } from "@/lib/report";
|
||||
|
||||
interface StoredReport {
|
||||
id: string;
|
||||
title: string;
|
||||
config: { metric?: string; source?: string; scenarioIds?: string[]; analysisIds?: string[]; comment?: string | null };
|
||||
pdfBytes: number;
|
||||
createdAt: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(iso).toLocaleString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
export function ReportsView({
|
||||
planId,
|
||||
scenarios,
|
||||
hasActuals,
|
||||
}: {
|
||||
planId: string;
|
||||
scenarios: { id: string; name: string; isBase: boolean }[];
|
||||
hasActuals: boolean;
|
||||
}) {
|
||||
const [reports, setReports] = useState<StoredReport[] | null>(null);
|
||||
const [analyses, setAnalyses] = useState<SavedAnalysisMeta[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<"list" | "new">("list");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// Formular
|
||||
const [title, setTitle] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [metric, setMetric] = useState<ReportMetric>("nominal");
|
||||
const [source, setSource] = useState<ReportSource>("PLAN");
|
||||
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
|
||||
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [r, a] = await Promise.all([
|
||||
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
|
||||
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
|
||||
]);
|
||||
setReports(r.reports);
|
||||
setAnalyses(a.analyses);
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [r, a] = await Promise.all([
|
||||
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
|
||||
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setReports(r.reports);
|
||||
setAnalyses(a.analyses);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
function startNew() {
|
||||
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
|
||||
setTitle(`Finanzplanung ${new Date().getFullYear()}`);
|
||||
setComment("");
|
||||
setMetric("nominal");
|
||||
setSource("PLAN");
|
||||
setScenarioIds(base ? [base.id] : []);
|
||||
setAnalysisIds([]);
|
||||
setMode("new");
|
||||
}
|
||||
|
||||
function toggleScenario(id: string) {
|
||||
setScenarioIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= MAX_REPORT_SCENARIOS ? prev : [...prev, id]
|
||||
);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post(`/api/plans/${planId}/reports`, {
|
||||
title: title.trim() || "Finanzplanung",
|
||||
metric,
|
||||
source,
|
||||
scenarioIds,
|
||||
analysisIds,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
await load();
|
||||
setMode("list");
|
||||
toast("success", "Bericht erstellt.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Bericht konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: StoredReport) {
|
||||
const ok = await confirm({
|
||||
title: "Bericht löschen?",
|
||||
message: `«${r.title}» wird endgültig entfernt. Die Datei lässt sich danach nicht mehr herunterladen.`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/plans/${planId}/reports/${r.id}`);
|
||||
await load();
|
||||
toast("success", "Bericht gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "new") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fg">Neuer Bericht</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Der Bericht wird als PDF erzeugt und abgelegt. Er hält den Stand von heute fest – spätere Änderungen am
|
||||
Plan verändern ihn nicht mehr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Titel</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Notiz auf dem Deckblatt (optional)</label>
|
||||
<input
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="z. B. «Stand nach Beratungsgespräch»"
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Werte</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{(["nominal", "real"] as ReportMetric[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={`rounded-md px-2 py-0.5 font-medium ${metric === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"}`}
|
||||
>
|
||||
{m === "real" ? "Real" : "Nominal"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InfoBubble text="Der Bericht führt durchgängig EINE Leitgrösse. Beides nebeneinander würde jede Tabellenspalte verdoppeln." />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Grundlage</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{(["PLAN", "ACTUAL"] as ReportSource[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={s === "ACTUAL" && !hasActuals}
|
||||
onClick={() => setSource(s)}
|
||||
title={s === "ACTUAL" && !hasActuals ? "Für diesen Plan sind keine effektiven Werte erfasst." : undefined}
|
||||
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
|
||||
source === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{s === "ACTUAL" ? "Effektiv" : "Plan"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Szenarien (max. {MAX_REPORT_SCENARIOS})
|
||||
<InfoBubble text="Mehr als drei Szenarien machen die Vergleichstabelle unlesbar. Das Basisszenario führt den Bericht an und liefert die Zusammenfassung." />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{scenarios.map((s) => {
|
||||
const checked = scenarioIds.includes(s.id);
|
||||
const full = !checked && scenarioIds.length >= MAX_REPORT_SCENARIOS;
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={`flex items-center gap-2 rounded-xl border p-3 text-sm ${
|
||||
checked ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||
} ${full ? "opacity-40" : ""}`}
|
||||
>
|
||||
<input type="checkbox" checked={checked} disabled={full} onChange={() => toggleScenario(s.id)} />
|
||||
<span className="font-medium text-fg">{s.name}</span>
|
||||
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Gespeicherte Analysen einbinden
|
||||
<InfoBubble text="Übernommen werden die beim Speichern eingefrorenen Zahlen – es wird nichts neu gerechnet." />
|
||||
</div>
|
||||
{analyses.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
|
||||
Noch keine Analysen gespeichert. Unter «Analysen» kannst du Ergebnisse festhalten.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{analyses.map((a) => (
|
||||
<label key={a.id} className="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={analysisIds.includes(a.id)}
|
||||
onChange={() =>
|
||||
setAnalysisIds((prev) => (prev.includes(a.id) ? prev.filter((x) => x !== a.id) : [...prev, a.id]))
|
||||
}
|
||||
/>
|
||||
<span className="font-medium text-fg">{a.name}</span>
|
||||
<span className="text-faint">{ANALYSIS_TYPE_LABEL[a.type as AnalysisType]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setMode("list")}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
<Button disabled={busy || scenarioIds.length === 0} onClick={create}>
|
||||
{busy ? "Wird erstellt…" : "Bericht erstellen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-fg">Berichte</h2>
|
||||
<Button onClick={startNew} disabled={scenarios.length === 0}>
|
||||
<Plus className="h-4 w-4" /> Neuer Bericht
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Ein Bericht ist ein <strong className="text-fg">festes Dokument</strong>: Die erzeugte PDF-Datei wird abgelegt
|
||||
und lässt sich jederzeit unverändert wieder herunterladen – auch wenn du den Plan danach weiterentwickelst.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!reports && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||
|
||||
{reports && reports.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Noch kein Bericht erstellt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reports && reports.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reports.map((r) => (
|
||||
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||
<FileText className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-fg">{r.title}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{dt(r.createdAt)} · {r.author} · {r.config.metric === "real" ? "real" : "nominal"} ·{" "}
|
||||
{r.config.source === "ACTUAL" ? "effektiv" : "Plan"} · {(r.config.scenarioIds ?? []).length} Szenario
|
||||
{(r.config.scenarioIds ?? []).length === 1 ? "" : "s"} · {Math.round(r.pdfBytes / 1024)} KB
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/plans/${planId}/reports/${r.id}`}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface hover:text-fg"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> PDF
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(r)}
|
||||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user