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:
@@ -9,6 +9,7 @@ import {
|
||||
Copy,
|
||||
Dices,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
FolderKanban,
|
||||
CalendarClock,
|
||||
GitBranch,
|
||||
@@ -33,6 +34,7 @@ import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||||
import { ActualsDialog } from "@/components/ActualsDialog";
|
||||
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
|
||||
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
|
||||
import { ReportsView } from "@/components/ReportsView";
|
||||
import { buildViews } from "@/lib/dataview";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||||
@@ -105,7 +107,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showActuals, setShowActuals] = useState(false);
|
||||
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
|
||||
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" } | null>(null);
|
||||
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null);
|
||||
// Welche Szenario-Bäume in der Seitenleiste aufgeklappt sind. Standard: eingeklappt.
|
||||
const [expandedTrees, setExpandedTrees] = useState<Record<string, boolean>>({});
|
||||
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
|
||||
@@ -182,7 +184,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
// Plan-Ebene öffnen (Dashboard / Szenarien / Analysen). Räumt die Szenario- und Wissens-
|
||||
// Ansichten weg -- es kann immer nur eine Hauptansicht aktiv sein.
|
||||
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses") {
|
||||
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") {
|
||||
setPlanNav({ planId, tab });
|
||||
setSelectedScenarioId(null);
|
||||
setShowSpec(false);
|
||||
@@ -347,7 +349,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
{plans.map((p) => {
|
||||
const navHere = planNav?.planId === p.id;
|
||||
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses", label: string, Icon: typeof FolderKanban) => (
|
||||
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPlanTab(p.id, tab)}
|
||||
@@ -427,6 +429,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
Effektive Werte
|
||||
</button>
|
||||
{subItem("analyses", "Analysen", BarChart3)}
|
||||
{subItem("reports", "Berichte", FileSpreadsheet)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -510,7 +513,11 @@ function AppShellInner({ username }: { username: string }) {
|
||||
: showSpec
|
||||
? "So rechnet FPT"
|
||||
: planNav
|
||||
? `${activePlan?.name ?? "Plan"} · ${planNav.tab === "dashboard" ? "Dashboard" : planNav.tab === "scenarios" ? "Szenarien" : "Analysen"}`
|
||||
? `${activePlan?.name ?? "Plan"} · ${
|
||||
{ dashboard: "Dashboard", scenarios: "Szenarien", actuals: "Effektive Werte", analyses: "Analysen", reports: "Berichte" }[
|
||||
planNav.tab
|
||||
]
|
||||
}`
|
||||
: selectedScenarioId && detail
|
||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||
: "Übersicht"}
|
||||
@@ -567,6 +574,14 @@ function AppShellInner({ username }: { username: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSpec && !showSystemParams && planNav?.tab === "reports" && activePlan && (
|
||||
<ReportsView
|
||||
planId={planNav.planId}
|
||||
scenarios={activePlan.scenarios.map((s) => ({ id: s.id, name: s.name, isBase: s.isBase }))}
|
||||
hasActuals={(activePlan._count?.actuals ?? 0) > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && (
|
||||
<DashboardHome
|
||||
username={username}
|
||||
|
||||
+12
-40
@@ -27,6 +27,12 @@ import { Timeline } from "@/components/Timeline";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
|
||||
import { capitalPot } from "@/lib/distribution";
|
||||
import {
|
||||
isRetirementTransition,
|
||||
openTransitionCount,
|
||||
transitionInactive as inactiveAtTransition,
|
||||
TRANSITION_CATEGORIES,
|
||||
} from "@/lib/decisions";
|
||||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||
@@ -75,15 +81,6 @@ const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||
|
||||
// Kategorien mit einem Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
|
||||
// Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive).
|
||||
const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||
"AHV",
|
||||
"PENSION_FUND",
|
||||
"PILLAR_3A",
|
||||
"REAL_ESTATE",
|
||||
"OTHER_ASSET",
|
||||
"OTHER_DEBT",
|
||||
];
|
||||
|
||||
const VALUE_CATEGORIES: ElementCategory[] = [
|
||||
"PENSION_FUND",
|
||||
"PILLAR_3A",
|
||||
@@ -244,12 +241,8 @@ export function PlanView({
|
||||
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
|
||||
}
|
||||
|
||||
function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||||
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
|
||||
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
return !!before?.working && !!after && !after.working;
|
||||
}
|
||||
// isRetirementTransition/transitionInactive/openTransitionCount kommen aus lib/decisions --
|
||||
// der Bericht zaehlt mit derselben Regel (sonst meldet er andere Zahlen als die Matrix).
|
||||
|
||||
// Beitragskarriere des Element-Besitzers (nur für AHV relevant).
|
||||
function careerFor(element: ElementInput) {
|
||||
@@ -315,20 +308,8 @@ export function PlanView({
|
||||
// Am Übergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
|
||||
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
|
||||
// ODER AHV ausserhalb des Pensions-Übergangs.
|
||||
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean {
|
||||
const ce = computedElement(fromPhase.id, el.id);
|
||||
if (ce && ce.status !== "ACTIVE") return true;
|
||||
if (el.category === "AHV") {
|
||||
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
|
||||
}
|
||||
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
|
||||
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
|
||||
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
|
||||
if (owner && !owner.working) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const transitionInactive = (el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed) =>
|
||||
inactiveAtTransition(computed, el, fromPhase, toPhase);
|
||||
|
||||
function cashTransitionFor(phaseId: string): CashTransitionData {
|
||||
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
|
||||
@@ -336,17 +317,8 @@ export function PlanView({
|
||||
|
||||
// Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze.
|
||||
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit.
|
||||
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
|
||||
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
|
||||
for (const el of plan.elements) {
|
||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||
if (transitionInactive(el, fromPhase, toPhase)) continue;
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
|
||||
if (!isTransitionAnswered(el.category, retire, td)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
const transitionOpenCount = (fromPhase: PhaseComputed, toPhase: PhaseComputed) =>
|
||||
openTransitionCount(plan, computed, fromPhase, toPhase);
|
||||
|
||||
// Ist der Übergangs-Entscheid dieses Elements noch offen?
|
||||
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||||
|
||||
@@ -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