Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
Deploy App / deploy (push) Successful in 57s
Deploy App / deploy (push) Successful in 57s
Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).
Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
Stand -- aus derselben Datei, aus der gerechnet wird
Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle
Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung
Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
Datei, dass alle Verweise eine existierende Ueberschrift treffen
SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Copy,
|
||||
Dices,
|
||||
FileText,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Menu,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
Tornado,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -21,6 +23,9 @@ import { Dashboard } from "@/components/Dashboard";
|
||||
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||
import { SpecView } from "@/components/SpecView";
|
||||
import { SystemParametersView } from "@/components/SystemParametersView";
|
||||
import { PlanTraceDialog } from "@/components/DetailView";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -47,6 +52,17 @@ export function AppShell({ username }: { username: string }) {
|
||||
const [showCharts, setShowCharts] = useState(false);
|
||||
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
|
||||
const [showSensitivity, setShowSensitivity] = useState(false);
|
||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
||||
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
|
||||
|
||||
function openSpecAt(anchor: string) {
|
||||
setSpecAnchor(anchor);
|
||||
setShowSpec(true);
|
||||
setShowSystemParams(false);
|
||||
setSelectedScenarioId(null);
|
||||
}
|
||||
|
||||
const loadPlans = useCallback(async () => {
|
||||
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
||||
@@ -86,6 +102,7 @@ export function AppShell({ username }: { username: string }) {
|
||||
function openScenario(id: string) {
|
||||
setSelectedScenarioId(id);
|
||||
setShowSpec(false);
|
||||
setShowSystemParams(false);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
@@ -127,10 +144,11 @@ export function AppShell({ username }: { username: string }) {
|
||||
onClick={() => {
|
||||
setSelectedScenarioId(null);
|
||||
setShowSpec(false);
|
||||
setShowSystemParams(false);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
||||
selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
@@ -176,11 +194,28 @@ export function AppShell({ username }: { username: string }) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSystemParams(true);
|
||||
setShowSpec(false);
|
||||
setSelectedScenarioId(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||||
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
||||
Systemparameter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSpec(true);
|
||||
setSpecAnchor(null);
|
||||
setShowSystemParams(false);
|
||||
setSelectedScenarioId(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
@@ -230,7 +265,9 @@ export function AppShell({ username }: { username: string }) {
|
||||
<Menu className="h-4 w-4" />
|
||||
</button>
|
||||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
||||
{showSpec
|
||||
{showSystemParams
|
||||
? "Systemparameter"
|
||||
: showSpec
|
||||
? "Spezifikation"
|
||||
: detail
|
||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||
@@ -240,11 +277,13 @@ export function AppShell({ username }: { username: string }) {
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-4 py-6 lg:px-8">
|
||||
{showSpec && <SpecView />}
|
||||
{showSystemParams && <SystemParametersView />}
|
||||
|
||||
{!showSpec && loading && <p className="text-sm text-muted">Laedt…</p>}
|
||||
{showSpec && <SpecView anchor={specAnchor} />}
|
||||
|
||||
{!showSpec && !loading && selectedScenarioId === null && (
|
||||
{!showSpec && !showSystemParams && loading && <p className="text-sm text-muted">Laedt…</p>}
|
||||
|
||||
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
||||
<DashboardHome
|
||||
username={username}
|
||||
plans={plans}
|
||||
@@ -254,7 +293,7 @@ export function AppShell({ username }: { username: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSpec && !loading && detail && selectedScenarioId && (
|
||||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
@@ -301,6 +340,15 @@ export function AppShell({ username }: { username: string }) {
|
||||
<Tornado className="h-4 w-4" />
|
||||
Einflussfaktoren berechnen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPlanTraces(true)}
|
||||
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Rechenwege
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{diff && detail.base && (
|
||||
@@ -313,7 +361,13 @@ export function AppShell({ username }: { username: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
|
||||
<PlanView
|
||||
plan={detail.plan}
|
||||
computed={detail.computed}
|
||||
diff={diff}
|
||||
onChanged={refreshCurrent}
|
||||
onOpenSpec={openSpecAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -358,6 +412,14 @@ export function AppShell({ username }: { username: string }) {
|
||||
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
|
||||
)}
|
||||
|
||||
{showPlanTraces && detail && (
|
||||
<PlanTraceDialog
|
||||
computed={computePlan(detail.plan, undefined, { explain: true })}
|
||||
onClose={() => setShowPlanTraces(false)}
|
||||
onOpenSpec={openSpecAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{copyFrom && (
|
||||
<CopyScenarioDialog
|
||||
source={copyFrom}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { BookOpen, X } from "lucide-react";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { CATEGORY_LABELS } from "@/lib/elements";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type {
|
||||
CashBridge,
|
||||
ElementYearPoint,
|
||||
PhaseComputed,
|
||||
PlanComputed,
|
||||
Trace,
|
||||
TraceStep,
|
||||
WealthBridge,
|
||||
} from "@/lib/calculations";
|
||||
|
||||
// --- Wasserfall ---------------------------------------------------------------------------
|
||||
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem
|
||||
// unsichtbaren Sockel und dem sichtbaren Delta darueber.
|
||||
|
||||
interface WaterfallItem {
|
||||
label: string;
|
||||
value: number;
|
||||
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo
|
||||
}
|
||||
|
||||
function waterfallData(items: WaterfallItem[]) {
|
||||
let running = 0;
|
||||
return items.map((it) => {
|
||||
if (it.total) {
|
||||
running = it.value;
|
||||
return { label: it.label, base: 0, delta: Math.abs(it.value), value: it.value, kind: "total" as const };
|
||||
}
|
||||
const start = running;
|
||||
running += it.value;
|
||||
return {
|
||||
label: it.label,
|
||||
base: Math.min(start, running),
|
||||
delta: Math.abs(it.value),
|
||||
value: it.value,
|
||||
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
|
||||
|
||||
function Waterfall({ items, height = 300 }: { items: WaterfallItem[]; height?: number }) {
|
||||
const data = useMemo(() => waterfallData(items), [items]);
|
||||
if (data.length === 0) return null;
|
||||
return (
|
||||
<div className="w-full" style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-32} textAnchor="end" height={70} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--surface-2)" }}
|
||||
formatter={(_v, _n, p) => [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]}
|
||||
labelFormatter={() => ""}
|
||||
/>
|
||||
<Bar dataKey="base" stackId="w" fill="transparent" isAnimationActive={false} />
|
||||
<Bar dataKey="delta" stackId="w" isAnimationActive={false} radius={[2, 2, 0, 0]}>
|
||||
{data.map((d, i) => (
|
||||
<Cell key={i} fill={WF_COLOR[d.kind]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
if (!isFirst) {
|
||||
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true });
|
||||
if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow });
|
||||
if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow });
|
||||
if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax });
|
||||
if (w.pensionConversion) items.push({ label: "PK verrentet", value: -w.pensionConversion });
|
||||
if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
|
||||
}
|
||||
items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true });
|
||||
if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal });
|
||||
if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn });
|
||||
if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation });
|
||||
if (w.pensionFundContribution) items.push({ label: "PK-Beiträge", value: w.pensionFundContribution });
|
||||
items.push({ label: "Vermögen Phasenende", value: w.endWealth, total: true });
|
||||
return items;
|
||||
}
|
||||
|
||||
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true });
|
||||
if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow });
|
||||
if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow });
|
||||
if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay });
|
||||
if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow });
|
||||
if (c.investments) items.push({ label: "Investitionen", value: -c.investments });
|
||||
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true });
|
||||
if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal });
|
||||
if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates });
|
||||
if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates });
|
||||
if (c.withdrawals) items.push({ label: "Bezugsraten", value: c.withdrawals });
|
||||
items.push({ label: "Cash Phasenende", value: c.cashEnd, total: true });
|
||||
return items;
|
||||
}
|
||||
|
||||
// --- Rechenweg ----------------------------------------------------------------------------
|
||||
|
||||
function TraceStepRow({ step }: { step: TraceStep }) {
|
||||
const unit = step.unit ?? "CHF";
|
||||
const value =
|
||||
unit === "" ? step.substituted ?? "" : unit === "%" ? `${step.result} %` : unit === "Jahre" ? `${step.result}` : formatChf(step.result);
|
||||
return (
|
||||
<tr className="border-t border-border align-top">
|
||||
<td className="px-3 py-1.5 text-fg">{step.label}</td>
|
||||
<td className="px-3 py-1.5 text-[11px] text-muted">
|
||||
{step.formula && <div className="font-mono">{step.formula}</div>}
|
||||
{step.substituted && unit !== "" && <div className="font-mono text-faint">{step.substituted}</div>}
|
||||
{step.note && <div className="mt-0.5 italic text-faint">{step.note}</div>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-1.5 text-right font-medium text-fg">{value}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface-2 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-fg">{trace.title}</span>
|
||||
{trace.specAnchor && onOpenSpec && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSpec(trace.specAnchor!)}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-muted hover:bg-surface"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" /> in der Spezifikation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<tbody>
|
||||
{trace.steps.map((s, i) => (
|
||||
<TraceStepRow key={i} step={s} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Gemeinsame Dialog-Huelle mit Reitern --------------------------------------------------
|
||||
|
||||
function DetailShell({
|
||||
title,
|
||||
subtitle,
|
||||
tabs,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tabs: { key: string; label: string; content: React.ReactNode }[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [active, setActive] = useState(tabs[0]?.key);
|
||||
const current = tabs.find((t) => t.key === active) ?? tabs[0];
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setActive(t.key)}
|
||||
className={`rounded-md px-3 py-1 font-medium ${
|
||||
current?.key === t.key ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">{current?.content}</div>
|
||||
|
||||
<p className="text-[11px] text-faint">Nur-Lese-Ansicht. Werte werden hier nicht verändert.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Element-Detailansicht ----------------------------------------------------------------
|
||||
|
||||
const MULTI_SERIES: ElementCategory[] = ["REAL_ESTATE"];
|
||||
|
||||
export function ElementDetailDialog({
|
||||
elementId,
|
||||
name,
|
||||
category,
|
||||
computed,
|
||||
onClose,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
elementId: string;
|
||||
name: string;
|
||||
category: ElementCategory;
|
||||
computed: PlanComputed;
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
// Verlauf ueber ALLE Phasen zusammensetzen.
|
||||
const points = useMemo(() => {
|
||||
const out: ElementYearPoint[] = [];
|
||||
for (const ph of computed.phases) {
|
||||
const ec = ph.elements.find((e) => e.elementId === elementId);
|
||||
if (ec) out.push(...ec.yearly);
|
||||
}
|
||||
return out;
|
||||
}, [computed, elementId]);
|
||||
|
||||
const perPhase = useMemo(
|
||||
() =>
|
||||
computed.phases
|
||||
.map((ph) => ({ phase: ph, ec: ph.elements.find((e) => e.elementId === elementId) }))
|
||||
.filter((x) => x.ec),
|
||||
[computed, elementId]
|
||||
);
|
||||
|
||||
const isFlow = category === "INCOME" || category === "EXPENSE";
|
||||
const valueLabel = isFlow
|
||||
? category === "INCOME"
|
||||
? "Einkommen (nominal)"
|
||||
: "Ausgaben (nominal)"
|
||||
: category === "OTHER_DEBT"
|
||||
? "Beitrag zum Vermögen"
|
||||
: category === "REAL_ESTATE"
|
||||
? "Eigenkapital"
|
||||
: "Wert";
|
||||
|
||||
const verlauf = (
|
||||
<>
|
||||
{points.length === 0 ? (
|
||||
<p className="text-sm text-muted">Für dieses Element gibt es in diesem Plan keinen Verlauf.</p>
|
||||
) : (
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
<Tooltip formatter={(v, n) => [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
{MULTI_SERIES.includes(category) && (
|
||||
<>
|
||||
<Line dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
<Line dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
|
||||
</>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Lebensphase</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Beginn</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Ende</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Hinweis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{perPhase.map(({ phase, ec }) => (
|
||||
<tr key={phase.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 text-fg">{phase.name}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.startValue)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.endValue)}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-faint">{ec!.note ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const rechenweg = (
|
||||
<>
|
||||
{perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && (
|
||||
<p className="text-sm text-muted">Für dieses Element gibt es keinen eigenen Rechenweg.</p>
|
||||
)}
|
||||
{perPhase.map(({ phase, ec }) => (
|
||||
<div key={phase.id} className="flex flex-col gap-2">
|
||||
{(ec!.trace || ec!.transitionTrace) && (
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{phase.name}</div>
|
||||
)}
|
||||
{ec!.trace && <TraceBlock trace={ec!.trace} onOpenSpec={onOpenSpec} />}
|
||||
{ec!.transitionTrace && <TraceBlock trace={ec!.transitionTrace} onOpenSpec={onOpenSpec} />}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
title={name}
|
||||
subtitle={CATEGORY_LABELS[category]}
|
||||
onClose={onClose}
|
||||
tabs={[
|
||||
{ key: "verlauf", label: "Verlauf", content: verlauf },
|
||||
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Phasen-Detailansicht ------------------------------------------------------------------
|
||||
|
||||
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||
|
||||
export function PhaseDetailDialog({
|
||||
phase,
|
||||
isFirst,
|
||||
onClose,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
isFirst: boolean;
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
const alloc = useMemo(() => {
|
||||
const rows = phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0));
|
||||
return [
|
||||
{ label: "Beginn", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.startValue)])) },
|
||||
{ label: "Ende", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.endValue)])) },
|
||||
];
|
||||
}, [phase]);
|
||||
const allocEls = useMemo(
|
||||
() => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)),
|
||||
[phase]
|
||||
);
|
||||
|
||||
const uebersicht = (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
|
||||
<Kpi label="Dauer" text={`${phase.durationYears} Jahre`} />
|
||||
<Kpi label="Vermögen Beginn" text={formatChf(phase.startWealthNominal)} />
|
||||
<Kpi label="Vermögen Ende" text={formatChf(phase.endWealthNominal)} />
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
|
||||
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.</p>
|
||||
{allocEls.length === 0 ? (
|
||||
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
|
||||
) : (
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={alloc} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{allocEls.map((el, i) => (
|
||||
<Bar key={el.elementId} dataKey={el.elementId} name={el.name} stackId="a" fill={ALLOC_PALETTE[i % ALLOC_PALETTE.length]} isAnimationActive={false} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
const wasserfall = (
|
||||
<>
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold text-fg">Woher kommt die Vermögensänderung?</h3>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
Nur <strong>echte</strong> Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier
|
||||
bewusst <strong>nicht</strong>: Sie verschieben Geld vom Cash in einen Vermögenswert, ohne das Vermögen zu
|
||||
verändern – als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt.
|
||||
</p>
|
||||
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
|
||||
{Math.abs(phase.wealthBridge.residual) > 2 && (
|
||||
<p className="text-[11px] text-faint">
|
||||
Rundungsdifferenz: {formatChf(phase.wealthBridge.residual)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold text-fg">Wohin ist das Cash geflossen?</h3>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch
|
||||
wenn sie das Vermögen nicht mindern.
|
||||
</p>
|
||||
<Waterfall items={cashItems(phase.cashBridge, isFirst)} />
|
||||
{Math.abs(phase.cashBridge.residual) > 2 && (
|
||||
<p className="text-[11px] text-faint">Rundungsdifferenz: {formatChf(phase.cashBridge.residual)}</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
const rechenweg = (
|
||||
<>
|
||||
{(phase.traces ?? []).length === 0 && <p className="text-sm text-muted">Kein Rechenweg verfügbar.</p>}
|
||||
{(phase.traces ?? []).map((t, i) => (
|
||||
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
title={phase.name}
|
||||
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
|
||||
onClose={onClose}
|
||||
tabs={[
|
||||
{ key: "uebersicht", label: "Übersicht", content: uebersicht },
|
||||
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
|
||||
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({ label, text }: { label: string; text: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
|
||||
<div className="text-[11px] text-muted">{label}</div>
|
||||
<div className="text-sm font-semibold text-fg">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) ---------------------------
|
||||
|
||||
export function PlanTraceDialog({
|
||||
computed,
|
||||
onClose,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
computed: PlanComputed;
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<DetailShell
|
||||
title="Rechenwege dieses Szenarios"
|
||||
subtitle="Plan-weite Grössen, die in alle Phasen hineinwirken"
|
||||
onClose={onClose}
|
||||
tabs={[
|
||||
{
|
||||
key: "plan",
|
||||
label: "Plan-Ebene",
|
||||
content: (
|
||||
<>
|
||||
{(computed.traces ?? []).map((t, i) => (
|
||||
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CreditCard,
|
||||
Home,
|
||||
Landmark,
|
||||
Maximize2,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Settings2,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||
import {
|
||||
CashTransitionFields,
|
||||
ElementDetail,
|
||||
@@ -46,7 +48,7 @@ import {
|
||||
type PhaseData,
|
||||
type TransitionData,
|
||||
} from "@/lib/elements";
|
||||
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
|
||||
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||
import type { ScenarioDiff } from "@/lib/diff";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
|
||||
@@ -92,12 +94,14 @@ export function PlanView({
|
||||
computed,
|
||||
diff,
|
||||
onChanged,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
||||
diff: ScenarioDiff | null;
|
||||
onChanged: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
|
||||
const cellDiff = (elementId: string, phaseId: string) =>
|
||||
@@ -124,6 +128,16 @@ export function PlanView({
|
||||
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
|
||||
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
|
||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Oeffnen erzeugt.
|
||||
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
|
||||
|
||||
// Erklaerte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein
|
||||
// und laeuft im Browser -- es braucht dafuer weder einen API-Aufruf noch eine groessere
|
||||
// Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis.
|
||||
const explained = useMemo(
|
||||
() => (detailFor ? computePlan(plan, undefined, { explain: true }) : null),
|
||||
[detailFor, plan]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
|
||||
@@ -405,6 +419,7 @@ export function PlanView({
|
||||
mode={valueMode}
|
||||
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
||||
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
|
||||
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
|
||||
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
||||
/>
|
||||
) : (
|
||||
@@ -499,12 +514,24 @@ export function PlanView({
|
||||
{!collapsed &&
|
||||
els.map((el) => (
|
||||
<tr key={el.id} className="hover:bg-surface-2">
|
||||
<td className={`sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
|
||||
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
|
||||
{el.name}
|
||||
<td className={`group/row sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-fg">
|
||||
<span className="min-w-0 flex-1 truncate">{el.name}</span>
|
||||
{diff?.elementRow.get(el.id) === "added" && (
|
||||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Detailansicht ${el.name}`}
|
||||
title="Detailansicht (nur lesen)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailFor({ kind: "element", id: el.id });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
|
||||
@@ -706,6 +733,36 @@ export function PlanView({
|
||||
);
|
||||
})()}
|
||||
|
||||
{detailFor?.kind === "element" && explained && (() => {
|
||||
const el = plan.elements.find((e) => e.id === detailFor.id);
|
||||
if (!el) return null;
|
||||
return (
|
||||
<ElementDetailDialog
|
||||
key={`det-${detailFor.id}`}
|
||||
elementId={el.id}
|
||||
name={el.name}
|
||||
category={el.category}
|
||||
computed={explained}
|
||||
onClose={() => setDetailFor(null)}
|
||||
onOpenSpec={onOpenSpec}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{detailFor?.kind === "phase" && explained && (() => {
|
||||
const ph = explained.phases.find((p) => p.id === detailFor.id);
|
||||
if (!ph) return null;
|
||||
return (
|
||||
<PhaseDetailDialog
|
||||
key={`detph-${detailFor.id}`}
|
||||
phase={ph}
|
||||
isFirst={ph.sequenceNumber === 1}
|
||||
onClose={() => setDetailFor(null)}
|
||||
onOpenSpec={onOpenSpec}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{editPhaseCell && (() => {
|
||||
const element = plan.elements.find((e) => e.id === editPhaseCell.elementId);
|
||||
const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId);
|
||||
@@ -854,6 +911,7 @@ function PhaseHeader({
|
||||
mode,
|
||||
diffKind,
|
||||
onClick,
|
||||
onExpand,
|
||||
active,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
@@ -861,6 +919,7 @@ function PhaseHeader({
|
||||
mode: ValueMode;
|
||||
diffKind: "changed" | "added" | "removed" | null;
|
||||
onClick: () => void;
|
||||
onExpand: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
|
||||
@@ -870,7 +929,7 @@ function PhaseHeader({
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
className={`group/ph min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
active
|
||||
? "bg-accent-soft"
|
||||
: diffKind === "added"
|
||||
@@ -881,7 +940,7 @@ function PhaseHeader({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||||
{diffKind === "added" && (
|
||||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||||
)}
|
||||
@@ -890,6 +949,20 @@ function PhaseHeader({
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
)}
|
||||
{/* Der Kopf oeffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event
|
||||
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Detailansicht ${phase.name}`}
|
||||
title="Detailansicht (nur lesen)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExpand();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { api } from "@/lib/api-client";
|
||||
|
||||
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
|
||||
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
|
||||
export function SpecView() {
|
||||
export function SpecView({ anchor }: { anchor?: string | null }) {
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -20,6 +20,14 @@ export function SpecView() {
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen."));
|
||||
}, []);
|
||||
|
||||
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
|
||||
// Ueberschrift mit der von rehype-slug erzeugten id.
|
||||
useEffect(() => {
|
||||
if (!anchor || markdown === null) return;
|
||||
const el = document.getElementById(anchor);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, [anchor, markdown]);
|
||||
|
||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { SYSTEM_PARAMETERS, type ParameterGroup, type SystemParameter } from "@/lib/constants";
|
||||
|
||||
const GROUPS: { key: ParameterGroup; title: string; intro: string }[] = [
|
||||
{
|
||||
key: "AHV",
|
||||
title: "AHV",
|
||||
intro:
|
||||
"Diese Werte legt der Bund fest. Sie ändern sich periodisch – der Stand ist je Parameter ausgewiesen.",
|
||||
},
|
||||
{
|
||||
key: "Vorsorge",
|
||||
title: "Berufliche und gebundene Vorsorge",
|
||||
intro: "Höchstbeträge und Umwandlung. Der Umwandlungssatz ist ein Vorschlag und pro Ereignis überschreibbar.",
|
||||
},
|
||||
{
|
||||
key: "Steuern",
|
||||
title: "Steuersätze",
|
||||
intro:
|
||||
"Bewusst pauschale Vorschlagswerte, keine Steuerberechnung: Die tatsächlichen Sätze sind kantonal, progressiv und vom Einzelfall abhängig. Sie lassen sich pro Ereignis überschreiben.",
|
||||
},
|
||||
];
|
||||
|
||||
function formatValue(p: SystemParameter): string {
|
||||
switch (p.unit) {
|
||||
case "CHF":
|
||||
return `${formatChf(p.value)} CHF`;
|
||||
case "%":
|
||||
return `${p.value} %`;
|
||||
case "Jahre":
|
||||
return `${p.value} Jahre`;
|
||||
default:
|
||||
return String(p.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
|
||||
// Die Eintraege stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
|
||||
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
|
||||
export function SystemParametersView() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-lg font-semibold text-fg">Systemparameter</h2>
|
||||
</div>
|
||||
|
||||
<p className="max-w-3xl text-sm text-muted">
|
||||
Alle fest hinterlegten Grössen, mit denen das Tool rechnet – inklusive Herleitung und Quelle. Sie stammen aus
|
||||
derselben Datei, aus der auch die Berechnung liest; was hier steht, ist also garantiert das, was gerechnet wird.
|
||||
</p>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const rows = SYSTEM_PARAMETERS.filter((p) => p.group === g.key);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section key={g.key} className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-fg">{g.title}</h3>
|
||||
<p className="mb-3 mt-0.5 text-xs text-muted">{g.intro}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((p) => (
|
||||
<div key={p.key} className="rounded-lg border border-border bg-surface-2 p-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium text-fg">{p.label}</span>
|
||||
<span className="text-sm font-semibold text-accent">{formatValue(p)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">{p.meaning}</p>
|
||||
{p.derivation && (
|
||||
<p className="mt-1 font-mono text-[11px] text-faint">Herleitung: {p.derivation}</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-faint">
|
||||
<span>Quelle: {p.source}</span>
|
||||
<span>Stand: {p.validFrom}</span>
|
||||
{p.editablePerEvent && <span className="text-accent">pro Ereignis überschreibbar</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="max-w-3xl text-xs text-faint">
|
||||
Die drei Steuersätze werden sowohl als Vorschlag im Eingabefeld als auch in der Berechnung als Rückfallwert
|
||||
verwendet – damit ein nicht angetippter Wert nicht fälschlich als 0 % gerechnet wird.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user