diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 3aeab5a..33e0d84 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { WealthChart, type TimelineSeries } from "@/components/WealthChart"; import { api } from "@/lib/api-client"; +import { formatChf } from "@/lib/format"; import type { PlanComputed } from "@/lib/calculations"; import type { PlanInput } from "@/lib/types"; @@ -120,11 +121,7 @@ export function Dashboard({ tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> - - typeof v === "number" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : v - } - /> + (typeof v === "number" ? formatChf(v) : v)} /> {barKeys.map((key, i) => ( @@ -144,7 +141,7 @@ function StatCard({ label, value, help }: { label: string; value: number; help?: {label}
- {value.toLocaleString("de-CH", { maximumFractionDigits: 0 })} CHF + {formatChf(value)} CHF
); diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 056dcf6..699981f 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -1,6 +1,8 @@ "use client"; +import { useState } from "react"; import { InfoBubble } from "@/components/InfoBubble"; +import { formatChf, parseChfInput, roundToThousand } from "@/lib/format"; 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"; @@ -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 ( + { + 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 ( +
+ + +
+ ); +} + export function TextField({ label, help, diff --git a/src/components/PhaseCard.tsx b/src/components/PhaseCard.tsx index ba0cdab..acfc904 100644 --- a/src/components/PhaseCard.tsx +++ b/src/components/PhaseCard.tsx @@ -4,13 +4,10 @@ import { useState } from "react"; import { LineChart, Line, ResponsiveContainer } from "recharts"; import { PhaseForm } from "@/components/PhaseForm"; import { api } from "@/lib/api-client"; +import { formatChf } from "@/lib/format"; import type { HouseholdInput, PhaseInput } from "@/lib/types"; import type { PhaseComputed } from "@/lib/calculations"; -function formatChf(value: number) { - return value.toLocaleString("de-CH", { maximumFractionDigits: 0 }); -} - export function PhaseCard({ household, phase, diff --git a/src/components/PhaseForm.tsx b/src/components/PhaseForm.tsx index 39ee1c0..1bd25ba 100644 --- a/src/components/PhaseForm.tsx +++ b/src/components/PhaseForm.tsx @@ -1,8 +1,9 @@ "use client"; 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 { formatChf } from "@/lib/format"; import type { ExpenseEntryInput, HouseholdInput, @@ -159,7 +160,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { } /> )} - {expenseEntries.map((entry, i) => (
- setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])} />
- Verfuegbare Sparquote (CHF/Jahr): {savingsQuota.toLocaleString("de-CH")} + Verfuegbare Sparquote (CHF/Jahr): {formatChf(savingsQuota)} {" "}(Details und Verteilung siehe Wertschriften weiter unten)
@@ -216,7 +217,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { value={s.name} onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))} /> -
- Verfuegbares Startkapital (aus Verkaeufen der Vorphase): {phase.incomingCapital.toLocaleString("de-CH")} CHF - {" "}— zugewiesen: {allocatedStartCapital.toLocaleString("de-CH")} + Verfuegbares Startkapital (aus Verkaeufen der Vorphase): {formatChf(phase.incomingCapital)} CHF + {" "}— zugewiesen: {formatChf(allocatedStartCapital)}
- Verfuegbare Sparquote (CHF/Jahr): {savingsQuota.toLocaleString("de-CH")} - {" "}— zugewiesen: {allocated.toLocaleString("de-CH")} + Verfuegbare Sparquote (CHF/Jahr): {formatChf(savingsQuota)} + {" "}— zugewiesen: {formatChf(allocated)}
{overAllocated && (
@@ -300,13 +301,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { value={re.name} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, marketValue: v } : x)))} /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, valueGrowth: v } : x)))} /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))} /> - - 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)))} options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))} /> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))} /> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))} /> - {it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? ( - - setItems((prev) => - prev.map((x, idx) => (idx === i ? { ...x, salePrice: e.target.valueAsNumber || 0 } : x)) - ) + onChange={(v) => + setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x))) } /> ) : ( - - {it.decision === "CARRY_OVER" ? it.carryOverValue.toLocaleString("de-CH") : it.carryOverValue.toLocaleString("de-CH")} CHF - + {formatChf(it.carryOverValue)} CHF )} @@ -199,7 +196,7 @@ export function TransitionPanel({
- Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): {totalAvailableCapital.toLocaleString("de-CH")} CHF + Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): {formatChf(totalAvailableCapital)} CHF

Wird beim Speichern automatisch in "{nextPhaseName}" als verfuegbares Startkapital hinterlegt. Uebernommene Wertschriften erscheinen dort automatisch mit ihrem Endwert als neuer Startwert. diff --git a/src/components/WealthChart.tsx b/src/components/WealthChart.tsx index 6c7fc92..52a956f 100644 --- a/src/components/WealthChart.tsx +++ b/src/components/WealthChart.tsx @@ -11,6 +11,7 @@ import { XAxis, YAxis, } from "recharts"; +import { formatChf } from "@/lib/format"; import type { PlanComputed } from "@/lib/calculations"; export interface TimelineSeries { @@ -67,11 +68,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) { tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> - - typeof v === "number" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : v - } - /> + (typeof v === "number" ? formatChf(v) : v)} /> {primary.boundaries.slice(1).map((b) => ( diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..ee67b9a --- /dev/null +++ b/src/lib/format.ts @@ -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; +}