Button in der Planansicht -> Dialog: Erklaerung, Eingaben, Lauf, Ergebnis + Faecher. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet und die Erfolgs-/Ruinwahrscheinlichkeit des Plans ausgewiesen. Kern (computePlan-Nahtstelle): - computePlan(plan, sample?) nimmt optional pro Jahr Inflation und pro Element/Jahr eine Rendite. Ohne sample bitgenau wie bisher (durch Golden Tests abgesichert). - Dazu Inflation auf ein kumulatives Deflator-Array umgestellt (statt (1+i)^t), damit sie pro Jahr variieren kann. Deterministisch identisch. - Reine Funktion ohne Server-Deps -> Simulation laeuft komplett im Browser, null Serverlast. ~10'000 Laeufe in ~1 s, Fortschritt alle 500 Laeufe (kein Freeze). Statistik (montecarlo.ts): - Fettschwaenzig (standardisierte Student-t, nu=5): Extremcrashs realistisch haeufig, eine Normalverteilung wuerde sie stark unterschaetzen. - Gemeinsamer Marktschock (rho=0.7): riskante Anlagen fallen im Crash zusammen, nicht gegeneinander. - Boeden: 0 % fuer PK/3a, -100 % sonst. Seedbar (reproduzierbar). - Zwei Renditezahlen: geplante (Zielbalken) vs. historische (Streu-Mittelpunkt) -- sonst waere P(>= geplantes Endvermoegen) immer ~50 %. UI: Erklaerung, pro Element/Inflation historischer Oe (Pflicht, kein Default) + Streuungsstufe (recherchierte sigma-Werte) + Anzahl Laeufe. Ergebnis: Ruin-/ Erfolgswahrscheinlichkeit, Faecher (10/50/90) + deterministische Linie. 7 MC-Tests inkl. deterministischer Aequivalenz, Vol-Drag, Reproduzierbarkeit, Boden, Ruin (41 -> 48). Keine DB-Aenderung. Spezifikation auf v0.7 (Kap. 4.12, 9.15). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Dices, X } from "lucide-react";
|
||||
import { NumberField, SelectField, MoneyField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
runMonteCarlo,
|
||||
defaultVolatilityLevel,
|
||||
RETURN_VOLATILITY_LEVELS,
|
||||
INFLATION_VOLATILITY_LEVELS,
|
||||
RETURN_BEARING,
|
||||
floorFor,
|
||||
type ReturnVolatilityLevel,
|
||||
type InflationVolatilityLevel,
|
||||
type MonteCarloResult,
|
||||
} from "@/lib/montecarlo";
|
||||
import { CATEGORY_LABELS } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
|
||||
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||
{ value: "niedrig", label: "Niedrig" },
|
||||
{ value: "moderat", label: "Moderat" },
|
||||
{ value: "hoch", label: "Hoch" },
|
||||
{ value: "sehr_hoch", label: "Sehr hoch" },
|
||||
{ value: "manuell", label: "Manuell" },
|
||||
];
|
||||
const INFLATION_LEVEL_OPTIONS: { value: InflationVolatilityLevel; label: string }[] = [
|
||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||
{ value: "niedrig", label: "Niedrig" },
|
||||
{ value: "manuell", label: "Manuell" },
|
||||
];
|
||||
|
||||
const RETURN_HELP =
|
||||
"Wie stark die Jahresrendite schwankt. Sehr niedrig ≈ Staatsanleihen/Geldmarkt · Niedrig ≈ Immobilien, defensive Mischportfolios · Moderat ≈ breit diversifizierte Aktien-ETFs/Fonds · Hoch ≈ Einzelaktien, Branchen-/Schwellenländerfonds · Sehr hoch ≈ Kryptowährungen, hochspekulative Anlagen.";
|
||||
const INFLATION_HELP =
|
||||
"Wie stark die Inflation schwankt. Für die Schweiz ist sie historisch sehr stabil (Sehr niedrig ≈ 1 %). Höhere Stufen wären Hyperinflations-Annahmen.";
|
||||
|
||||
// Pflicht-Zahlenfeld, das wirklich leer sein kann (NumberField erzwingt eine Zahl).
|
||||
function MeanField({
|
||||
label,
|
||||
help,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const empty = value.trim() === "";
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
{label}
|
||||
<InfoBubble text={help} />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={value}
|
||||
placeholder="Pflicht"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`w-full rounded-lg border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/25 ${
|
||||
empty ? "border-danger" : "border-border focus:border-accent"
|
||||
}`}
|
||||
/>
|
||||
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld – bitte ausfüllen.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ElementInput = { mean: string; level: ReturnVolatilityLevel; manualSigma: string };
|
||||
|
||||
function returnSigma(el: ElementInput): number {
|
||||
return el.level === "manuell"
|
||||
? Number(el.manualSigma) || 0
|
||||
: RETURN_VOLATILITY_LEVELS[el.level];
|
||||
}
|
||||
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
|
||||
return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level];
|
||||
}
|
||||
|
||||
export function MonteCarloDialog({
|
||||
plan,
|
||||
computed,
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const returnElements = useMemo(
|
||||
() => plan.elements.filter((e) => RETURN_BEARING.includes(e.category)),
|
||||
[plan.elements]
|
||||
);
|
||||
|
||||
const [runs, setRuns] = useState(1000);
|
||||
const [inflMean, setInflMean] = useState("");
|
||||
const [inflLevel, setInflLevel] = useState<InflationVolatilityLevel>("sehr_niedrig");
|
||||
const [inflManual, setInflManual] = useState("1");
|
||||
const [target, setTarget] = useState(Math.max(0, computed.nachlass));
|
||||
const [els, setEls] = useState<Record<string, ElementInput>>(() =>
|
||||
Object.fromEntries(
|
||||
returnElements.map((e) => [e.id, { mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" }])
|
||||
)
|
||||
);
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [result, setResult] = useState<MonteCarloResult | null>(null);
|
||||
|
||||
function setEl(id: string, patch: Partial<ElementInput>) {
|
||||
setEls((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
|
||||
}
|
||||
|
||||
// Pflichtfelder: historische Inflation + je Element die historische Rendite muessen gesetzt sein.
|
||||
const missing =
|
||||
inflMean.trim() === "" || returnElements.some((e) => (els[e.id]?.mean ?? "").trim() === "");
|
||||
|
||||
// Deterministische Endwert-Linie fuer den Vergleich im Faecher.
|
||||
const detPoints = useMemo(() => {
|
||||
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
|
||||
const pts: { age: number; det: number }[] = [];
|
||||
if (computed.phases.length > 0) {
|
||||
pts.push({ age: startAge, det: computed.phases[0].startWealthNominal });
|
||||
let acc = 0;
|
||||
for (const ph of computed.phases) {
|
||||
acc += ph.durationYears;
|
||||
pts.push({ age: startAge + acc, det: ph.endWealthNominal });
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}, [computed.phases, plan.persons]);
|
||||
|
||||
const estSeconds = Math.max(1, Math.round(runs / 6000));
|
||||
|
||||
async function run() {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
try {
|
||||
const elements = Object.fromEntries(
|
||||
returnElements.map((e) => {
|
||||
const ei = els[e.id];
|
||||
return [e.id, { mean: Number(ei.mean) || 0, sigma: returnSigma(ei), floor: floorFor(e.category) }];
|
||||
})
|
||||
);
|
||||
const res = await runMonteCarlo(
|
||||
plan,
|
||||
{
|
||||
runs,
|
||||
inflationMean: Number(inflMean) || 0,
|
||||
inflationSigma: inflationSigma(inflLevel, inflManual),
|
||||
elements,
|
||||
target,
|
||||
},
|
||||
(done, total) => setProgress(done / total)
|
||||
);
|
||||
setResult(res);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return result.bands.map((b) => ({
|
||||
age: b.age,
|
||||
// Range-Flaeche als [unten, oben]-Tupel -- robust auch bei negativem p10 (Ruin-Faelle).
|
||||
band: [b.p10, b.p90] as [number, number],
|
||||
median: b.p50,
|
||||
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
|
||||
}));
|
||||
}, [result, detPoints]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 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-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<Dices className="h-5 w-5 text-accent" /> Monte-Carlo-Simulation
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
{/* Erklaerung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
|
||||
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
|
||||
Verläufe</strong> und zeigt, wie oft dein Plan aufgeht.
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Eingabe:</strong> je Anlage die historische Durchschnittsrendite (der Mittelpunkt,
|
||||
um den gewürfelt wird) und wie stark sie schwankt; dazu dieselben Angaben für die Inflation.
|
||||
<strong className="text-fg"> Ergebnis:</strong> die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag
|
||||
erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
|
||||
häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem
|
||||
Marktschock gemeinsam. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen — sie beurteilt nicht,
|
||||
ob deine Durchschnittswerte realistisch sind.</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inflation */}
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<MeanField
|
||||
label="Ø Inflation letzte 20 J. (%)"
|
||||
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
||||
value={inflMean}
|
||||
onChange={setInflMean}
|
||||
/>
|
||||
<SelectField
|
||||
label="Streuung"
|
||||
help={INFLATION_HELP}
|
||||
value={inflLevel}
|
||||
onChange={(v: InflationVolatilityLevel) => setInflLevel(v)}
|
||||
options={INFLATION_LEVEL_OPTIONS}
|
||||
/>
|
||||
{inflLevel === "manuell" ? (
|
||||
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => setInflManual(String(v))} />
|
||||
) : (
|
||||
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Elemente */}
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Renditetragende Elemente</div>
|
||||
{returnElements.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
|
||||
Dieser Plan hat keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{returnElements.map((e) => {
|
||||
const ei = els[e.id];
|
||||
return (
|
||||
<div key={e.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-fg">{e.name}</span>
|
||||
<span className="text-xs text-faint">{CATEGORY_LABELS[e.category]}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<MeanField
|
||||
label="Ø Rendite letzte 20 J. (%)"
|
||||
help="Der Mittelpunkt, um den gewürfelt wird – die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
|
||||
value={ei?.mean ?? ""}
|
||||
onChange={(v) => setEl(e.id, { mean: v })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Streuung"
|
||||
help={RETURN_HELP}
|
||||
value={ei.level}
|
||||
onChange={(v: ReturnVolatilityLevel) => setEl(e.id, { level: v })}
|
||||
options={RETURN_LEVEL_OPTIONS}
|
||||
/>
|
||||
{ei.level === "manuell" ? (
|
||||
<NumberField label="Standardabw. (%)" step={0.5} value={Number(ei.manualSigma) || 0} onChange={(v) => setEl(e.id, { manualSigma: String(v) })} />
|
||||
) : (
|
||||
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[ei.level]} />
|
||||
)}
|
||||
</div>
|
||||
{(e.category === "PENSION_FUND" || e.category === "PILLAR_3A") && (
|
||||
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[e.category]} schreibt keine negative Rendite gut.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lauf-Parameter */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<SelectField
|
||||
label="Anzahl Simulationen"
|
||||
value={String(runs)}
|
||||
onChange={(v: string) => setRuns(Number(v))}
|
||||
options={[
|
||||
{ value: "1000", label: "1'000 (schnell)" },
|
||||
{ value: "5000", label: "5'000" },
|
||||
{ value: "10000", label: "10'000 (genau)" },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-faint">geschätzt ~{estSeconds} s</p>
|
||||
</div>
|
||||
<MoneyField
|
||||
label="Zielbetrag (Endvermögen)"
|
||||
help="Die Erfolgswahrscheinlichkeit misst, wie oft das Endvermögen mindestens diesen Betrag erreicht. Vorbelegt mit deinem geplanten Nachlass."
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{missing && <p className="text-xs text-danger">Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={missing || running || returnElements.length === 0}
|
||||
onClick={run}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{running ? `Berechne… ${Math.round(progress * 100)} %` : "Berechnung jetzt durchführen"}
|
||||
</button>
|
||||
{running && (
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div className="h-full bg-accent transition-all" style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && <MonteCarloResults result={result} target={target} chartData={chartData} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlySigma({ value }: { value: number }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Standardabweichung
|
||||
<InfoBubble text="Die hinter der gewählten Streuungsstufe hinterlegte Standardabweichung. Nur bei Manuell selbst eingebbar." />
|
||||
</label>
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{value} %
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonteCarloResults({
|
||||
result,
|
||||
target,
|
||||
chartData,
|
||||
}: {
|
||||
result: MonteCarloResult;
|
||||
target: number;
|
||||
chartData: { age: number; band: [number, number]; median: number; det: number | null }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Stat label="Erfolgswahrscheinlichkeit" value={`${Math.round(result.successProbability * 100)} %`} help={`Anteil der Läufe mit Endvermögen ≥ ${formatChf(target)}.`} good />
|
||||
<Stat label="Ruinwahrscheinlichkeit" value={`${Math.round(result.ruinProbability * 100)} %`} help="Anteil der Läufe, in denen das Vermögen vor Planende unter 0 fällt." danger />
|
||||
<Stat label="Läufe" value={result.runs.toLocaleString("de-CH")} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 text-sm">
|
||||
<Band label="Pessimistisch (10 %)" value={result.finalWealthP10} />
|
||||
<Band label="Median" value={result.finalWealthMedian} />
|
||||
<Band label="Optimistisch (90 %)" value={result.finalWealthP90} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-fg">Vermögensfächer nach Alter (nominal)</div>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median.
|
||||
Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median (Schwankung
|
||||
frisst Rendite).
|
||||
</p>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
<Tooltip
|
||||
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
|
||||
labelFormatter={(v) => `Alter ${v}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area dataKey="band" name="10–90 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
|
||||
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, help, good, danger }: { label: string; value: string; help?: string; good?: boolean; danger?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<div className="flex items-center text-xs text-muted">
|
||||
{label}
|
||||
{help && <InfoBubble text={help} />}
|
||||
</div>
|
||||
<div className={`mt-1 text-2xl font-semibold ${good ? "text-success" : danger ? "text-danger" : "text-fg"}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Band({ label, value }: { label: string; value: number }) {
|
||||
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="font-semibold text-fg">{formatChf(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user