Format all monetary amounts as 1'000-separated integers and round amount inputs to nearest 1'000
Deploy App / deploy (push) Successful in 43s

This commit is contained in:
2026-07-08 20:31:34 +02:00
parent a1e689167c
commit 3b68ff8319
7 changed files with 110 additions and 43 deletions
+3 -6
View File
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart"; import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations"; import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
@@ -120,11 +121,7 @@ export function Dashboard({
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/> />
<Tooltip <Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
formatter={(v) =>
typeof v === "number" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : v
}
/>
<Legend wrapperStyle={{ fontSize: 12 }} /> <Legend wrapperStyle={{ fontSize: 12 }} />
{barKeys.map((key, i) => ( {barKeys.map((key, i) => (
<Bar key={key} dataKey={key} stackId="a" fill={PALETTE[i % PALETTE.length]} /> <Bar key={key} dataKey={key} stackId="a" fill={PALETTE[i % PALETTE.length]} />
@@ -144,7 +141,7 @@ function StatCard({ label, value, help }: { label: string; value: number; help?:
{label} {label}
</div> </div>
<div className="mt-1 text-xl font-semibold text-zinc-900 dark:text-zinc-50"> <div className="mt-1 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
{value.toLocaleString("de-CH", { maximumFractionDigits: 0 })} CHF {formatChf(value)} CHF
</div> </div>
</div> </div>
); );
+55
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState } from "react";
import { InfoBubble } from "@/components/InfoBubble"; import { InfoBubble } from "@/components/InfoBubble";
import { formatChf, parseChfInput, roundToThousand } from "@/lib/format";
const baseInputClass = const baseInputClass =
"w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"; "w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100";
@@ -47,6 +49,59 @@ export function NumberField({
); );
} }
// Roher Betrags-Input ohne Label (fuer kompakte Tabellenzellen o.ae.). Zeigt den Wert
// formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, und
// rundet beim Verlassen des Feldes auf ein Vielfaches von 1'000 (siehe lib/format.ts).
export function MoneyInput({
value,
onChange,
className,
}: {
value: number;
onChange: (value: number) => void;
className?: string;
}) {
const [focused, setFocused] = useState(false);
const [text, setText] = useState(() => String(Math.round(value || 0)));
return (
<input
type="text"
inputMode="numeric"
className={className ?? baseInputClass}
value={focused ? text : formatChf(value)}
onFocus={() => {
setFocused(true);
setText(String(Math.round(value || 0)));
}}
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
onBlur={() => {
setFocused(false);
onChange(roundToThousand(parseChfInput(text)));
}}
/>
);
}
export function MoneyField({
label,
help,
value,
onChange,
}: {
label: string;
help?: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<MoneyInput value={value} onChange={onChange} />
</div>
);
}
export function TextField({ export function TextField({
label, label,
help, help,
+1 -4
View File
@@ -4,13 +4,10 @@ import { useState } from "react";
import { LineChart, Line, ResponsiveContainer } from "recharts"; import { LineChart, Line, ResponsiveContainer } from "recharts";
import { PhaseForm } from "@/components/PhaseForm"; import { PhaseForm } from "@/components/PhaseForm";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { HouseholdInput, PhaseInput } from "@/lib/types"; import type { HouseholdInput, PhaseInput } from "@/lib/types";
import type { PhaseComputed } from "@/lib/calculations"; import type { PhaseComputed } from "@/lib/calculations";
function formatChf(value: number) {
return value.toLocaleString("de-CH", { maximumFractionDigits: 0 });
}
export function PhaseCard({ export function PhaseCard({
household, household,
phase, phase,
+19 -18
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { NumberField, SelectField, TextField } from "@/components/FormField"; import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { import type {
ExpenseEntryInput, ExpenseEntryInput,
HouseholdInput, HouseholdInput,
@@ -159,7 +160,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
} }
/> />
)} )}
<NumberField <MoneyField
label="Geschaetztes Jahreseinkommen (CHF)" label="Geschaetztes Jahreseinkommen (CHF)"
help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase." help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase."
value={entry.amount} value={entry.amount}
@@ -185,7 +186,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
<Section title="Ausgaben"> <Section title="Ausgaben">
{expenseEntries.map((entry, i) => ( {expenseEntries.map((entry, i) => (
<div key={entry.id} className="grid grid-cols-[1fr_auto] items-end gap-2"> <div key={entry.id} className="grid grid-cols-[1fr_auto] items-end gap-2">
<NumberField <MoneyField
label="Geschaetzte Gesamtausgaben (CHF/Jahr)" label="Geschaetzte Gesamtausgaben (CHF/Jahr)"
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern. Keine separate Kategorisierung noetig." help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern. Keine separate Kategorisierung noetig."
value={entry.amount} value={entry.amount}
@@ -201,7 +202,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])} onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
/> />
<div className="rounded-md bg-zinc-50 px-3 py-2 text-sm text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"> <div className="rounded-md bg-zinc-50 px-3 py-2 text-sm text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong> Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
{" "}(Details und Verteilung siehe Wertschriften weiter unten) {" "}(Details und Verteilung siehe Wertschriften weiter unten)
</div> </div>
</Section> </Section>
@@ -216,7 +217,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
value={s.name} value={s.name}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/> />
<NumberField <MoneyField
label="Startwert (CHF)" label="Startwert (CHF)"
help="Wert dieser Position zu Beginn der Phase." help="Wert dieser Position zu Beginn der Phase."
value={s.startValue} value={s.startValue}
@@ -229,7 +230,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
value={s.expectedReturn} value={s.expectedReturn}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))} onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))}
/> />
<NumberField <MoneyField
label="Jaehrlicher Sparbeitrag (CHF)" label="Jaehrlicher Sparbeitrag (CHF)"
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren moechten." help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren moechten."
value={s.annualContribution} value={s.annualContribution}
@@ -274,13 +275,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
<div className="flex flex-col gap-1.5 rounded-md bg-zinc-50 px-3 py-2 text-sm dark:bg-zinc-800"> <div className="flex flex-col gap-1.5 rounded-md bg-zinc-50 px-3 py-2 text-sm dark:bg-zinc-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusDot ok={!startCapitalRemaining} /> <StatusDot ok={!startCapitalRemaining} />
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{phase.incomingCapital.toLocaleString("de-CH")}</strong> CHF Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{formatChf(phase.incomingCapital)}</strong> CHF
{" "} zugewiesen: {allocatedStartCapital.toLocaleString("de-CH")} {" "} zugewiesen: {formatChf(allocatedStartCapital)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusDot ok={!savingsRemaining} /> <StatusDot ok={!savingsRemaining} />
Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong> Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
{" "} zugewiesen: {allocated.toLocaleString("de-CH")} {" "} zugewiesen: {formatChf(allocated)}
</div> </div>
{overAllocated && ( {overAllocated && (
<div className="text-amber-700 dark:text-amber-400"> <div className="text-amber-700 dark:text-amber-400">
@@ -300,13 +301,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
value={re.name} value={re.name}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/> />
<NumberField <MoneyField
label="Aktueller Marktwert (CHF)" label="Aktueller Marktwert (CHF)"
help="Geschaetzter heutiger Verkehrswert der Liegenschaft." help="Geschaetzter heutiger Verkehrswert der Liegenschaft."
value={re.marketValue} value={re.marketValue}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, marketValue: v } : x)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, marketValue: v } : x)))}
/> />
<NumberField <MoneyField
label="Aktuelle Hypothek (CHF)" label="Aktuelle Hypothek (CHF)"
help="Ausstehender Hypothekarbetrag." help="Ausstehender Hypothekarbetrag."
value={re.mortgage} value={re.mortgage}
@@ -319,13 +320,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
value={re.valueGrowth} value={re.valueGrowth}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, valueGrowth: v } : x)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, valueGrowth: v } : x)))}
/> />
<NumberField <MoneyField
label="Jaehrliche Amortisation (CHF)" label="Jaehrliche Amortisation (CHF)"
help="Betrag, um den die Hypothek pro Jahr reduziert wird." help="Betrag, um den die Hypothek pro Jahr reduziert wird."
value={re.amortization} value={re.amortization}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
/> />
<NumberField <MoneyField
label="Geschaetzter Verkaufspreis (CHF)" label="Geschaetzter Verkaufspreis (CHF)"
help="Nur bei geplantem Verkauf am Ende der Phase auszufuellen." help="Nur bei geplantem Verkauf am Ende der Phase auszufuellen."
value={re.salePrice ?? 0} value={re.salePrice ?? 0}
@@ -367,7 +368,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
{ value: "EXPENSE", label: "Ausgabe" }, { value: "EXPENSE", label: "Ausgabe" },
]} ]}
/> />
<NumberField <MoneyField
label="Betrag (CHF)" label="Betrag (CHF)"
value={ev.amount} value={ev.amount}
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))} onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))}
@@ -396,19 +397,19 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))} onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))}
options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))} options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))}
/> />
<NumberField <MoneyField
label="AHV-Rente (CHF/Jahr)" label="AHV-Rente (CHF/Jahr)"
help="Zusammengesetzt mit der PK-Rente zur 'Erwarteten Rente'. Bei Ehepaaren max. 1.5x AHV-Maximalrente gemeinsam." help="Zusammengesetzt mit der PK-Rente zur 'Erwarteten Rente'. Bei Ehepaaren max. 1.5x AHV-Maximalrente gemeinsam."
value={r.ahvAmount} value={r.ahvAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))} onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))}
/> />
<NumberField <MoneyField
label="PK-Rente (CHF/Jahr)" label="PK-Rente (CHF/Jahr)"
help="Pensionskassenrente (2. Saeule)." help="Pensionskassenrente (2. Saeule)."
value={r.pkPensionAmount} value={r.pkPensionAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))} onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))}
/> />
<NumberField <MoneyField
label="Kapitalbezug brutto (CHF)" label="Kapitalbezug brutto (CHF)"
help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse." help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse."
value={r.lumpSumAmount} value={r.lumpSumAmount}
+7 -10
View File
@@ -1,7 +1,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MoneyInput } from "@/components/FormField";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { PhaseInput, TransitionDecision } from "@/lib/types"; import type { PhaseInput, TransitionDecision } from "@/lib/types";
import type { PhaseComputed } from "@/lib/calculations"; import type { PhaseComputed } from "@/lib/calculations";
@@ -178,20 +180,15 @@ export function TransitionPanel({
</td> </td>
<td className="py-2"> <td className="py-2">
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? ( {it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
<input <MoneyInput
type="number"
className="w-32 rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900" className="w-32 rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900"
value={it.salePrice ?? 0} value={it.salePrice ?? 0}
onChange={(e) => onChange={(v) =>
setItems((prev) => setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x)))
prev.map((x, idx) => (idx === i ? { ...x, salePrice: e.target.valueAsNumber || 0 } : x))
)
} }
/> />
) : ( ) : (
<span className="text-xs text-zinc-500"> <span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span>
{it.decision === "CARRY_OVER" ? it.carryOverValue.toLocaleString("de-CH") : it.carryOverValue.toLocaleString("de-CH")} CHF
</span>
)} )}
</td> </td>
</tr> </tr>
@@ -199,7 +196,7 @@ export function TransitionPanel({
</tbody> </tbody>
</table> </table>
<div className="rounded-md bg-white px-3 py-2 text-sm dark:bg-zinc-900"> <div className="rounded-md bg-white px-3 py-2 text-sm dark:bg-zinc-900">
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{totalAvailableCapital.toLocaleString("de-CH")} CHF</strong> Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{formatChf(totalAvailableCapital)} CHF</strong>
<p className="mt-1 text-xs text-zinc-500"> <p className="mt-1 text-xs text-zinc-500">
Wird beim Speichern automatisch in &quot;{nextPhaseName}&quot; als verfuegbares Startkapital hinterlegt. Wird beim Speichern automatisch in &quot;{nextPhaseName}&quot; als verfuegbares Startkapital hinterlegt.
Uebernommene Wertschriften erscheinen dort automatisch mit ihrem Endwert als neuer Startwert. Uebernommene Wertschriften erscheinen dort automatisch mit ihrem Endwert als neuer Startwert.
+2 -5
View File
@@ -11,6 +11,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations"; import type { PlanComputed } from "@/lib/calculations";
export interface TimelineSeries { export interface TimelineSeries {
@@ -67,11 +68,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/> />
<Tooltip <Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
formatter={(v) =>
typeof v === "number" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : v
}
/>
<Legend wrapperStyle={{ fontSize: 12 }} /> <Legend wrapperStyle={{ fontSize: 12 }} />
{primary.boundaries.slice(1).map((b) => ( {primary.boundaries.slice(1).map((b) => (
<ReferenceLine key={b.year} x={b.year} stroke="#a1a1aa" strokeDasharray="2 2" /> <ReferenceLine key={b.year} x={b.year} stroke="#a1a1aa" strokeDasharray="2 2" />
+23
View File
@@ -0,0 +1,23 @@
// Einheitliches Format fuer Geldbetraege im ganzen Tool: Tausendertrennzeichen mit
// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht ueber
// Intl/toLocaleString("de-CH"), da dessen Gruppentrennzeichen ( U+2019) nicht dem
// geraden Apostroph entspricht.
export function formatChf(value: number): string {
const rounded = Math.round(value || 0);
const sign = rounded < 0 ? "-" : "";
const digits = Math.abs(rounded).toString();
const withSeparators = digits.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
return sign + withSeparators;
}
// Rundet auf ein Vielfaches von 1'000 -- Betraege unter 1'000 CHF sind fuer dieses
// Planungstool nicht relevant.
export function roundToThousand(value: number): number {
return Math.round((value || 0) / 1000) * 1000;
}
export function parseChfInput(text: string): number {
const cleaned = text.replace(/[^0-9-]/g, "");
const parsed = parseInt(cleaned, 10);
return Number.isFinite(parsed) ? parsed : 0;
}