d6855ef1a0
Deploy App / deploy (push) Successful in 1m1s
Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt, sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer, Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge nach Bestaetigung zusammengelegt. - neues reines Modul lib/retirement.ts + POST /api/scenarios/<id>/retirement - Pensionsalter als Tornado-Treiber und Live-Simulations-Regler - AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65 Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution). AHV wird dafuer jahresweise statt phasenweise gerechnet. - Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0 - Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt, sichtbar als "Aus Vorphase uebernehmen" - Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation, Anlage und Cash verteilbar - Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive Werte kamen deshalb nie in der Folgephase an SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||
import { InfoBubble } from "@/components/InfoBubble";
|
||
import { formatChf, parseChfInput } from "@/lib/format";
|
||
|
||
const baseInputClass =
|
||
"w-full rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
|
||
|
||
export function FieldLabel({ label, help }: { label: string; help?: string }) {
|
||
return (
|
||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||
{label}
|
||
{help && <InfoBubble text={help} />}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function NumberField({
|
||
label,
|
||
help,
|
||
value,
|
||
onChange,
|
||
step,
|
||
min,
|
||
max,
|
||
}: {
|
||
label: string;
|
||
help?: string;
|
||
value: number;
|
||
onChange: (value: number) => void;
|
||
step?: number;
|
||
min?: number;
|
||
max?: number;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<FieldLabel label={label} help={help} />
|
||
<input
|
||
type="number"
|
||
className={baseInputClass}
|
||
value={Number.isFinite(value) ? value : 0}
|
||
step={step ?? "any"}
|
||
min={min}
|
||
max={max}
|
||
onFocus={(e) => e.currentTarget.select()}
|
||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und hätte
|
||
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
|
||
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
|
||
// String geführt; die Validierung liegt beim Aufrufer.
|
||
export function RequiredNumberField({
|
||
label,
|
||
help,
|
||
value,
|
||
onChange,
|
||
step = 0.1,
|
||
suffix,
|
||
placeholder = "Pflicht",
|
||
}: {
|
||
label: string;
|
||
help?: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
step?: number;
|
||
suffix?: string;
|
||
placeholder?: string;
|
||
}) {
|
||
const empty = value.trim() === "";
|
||
return (
|
||
<div>
|
||
<FieldLabel label={label} help={help} />
|
||
<div className="relative">
|
||
<input
|
||
type="number"
|
||
step={step}
|
||
value={value}
|
||
placeholder={placeholder}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={`${baseInputClass} ${suffix ? "pr-8" : ""} ${
|
||
empty ? "border-danger focus:border-danger" : ""
|
||
}`}
|
||
/>
|
||
{suffix && (
|
||
<span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-xs text-faint">
|
||
{suffix}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld – bitte ausfüllen.</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
|
||
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
|
||
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
|
||
// Optionales `max` (und `min`) klammern die Eingabe hart (Live-Cap).
|
||
export function MoneyInput({
|
||
value,
|
||
onChange,
|
||
className,
|
||
min = 0,
|
||
max,
|
||
}: {
|
||
value: number;
|
||
onChange: (value: number) => void;
|
||
className?: string;
|
||
min?: number;
|
||
max?: number;
|
||
}) {
|
||
const [focused, setFocused] = useState(false);
|
||
const [text, setText] = useState(() => String(Math.round(value || 0)));
|
||
const valueRef = useRef(value);
|
||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||
useEffect(() => {
|
||
valueRef.current = value;
|
||
}, [value]);
|
||
const holdTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
function clamp(v: number): number {
|
||
let result = Math.round(v);
|
||
if (min != null) result = Math.max(min, result);
|
||
if (max != null) result = Math.min(result, Math.round(max));
|
||
return result;
|
||
}
|
||
|
||
function stepBy(delta: number) {
|
||
onChange(clamp(Math.round(valueRef.current) + delta));
|
||
}
|
||
|
||
function stopHold() {
|
||
if (holdTimeout.current) {
|
||
clearTimeout(holdTimeout.current);
|
||
holdTimeout.current = null;
|
||
}
|
||
if (holdInterval.current) {
|
||
clearInterval(holdInterval.current);
|
||
holdInterval.current = null;
|
||
}
|
||
}
|
||
|
||
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
|
||
// Schrittweite mit der Haltedauer wächst (immer schneller).
|
||
function startHold(sign: number) {
|
||
stepBy(sign);
|
||
const start = Date.now();
|
||
holdTimeout.current = setTimeout(() => {
|
||
holdInterval.current = setInterval(() => {
|
||
const s = (Date.now() - start) / 1000;
|
||
const mag = s < 1.5 ? 1 : s < 3 ? 10 : s < 4.5 ? 100 : s < 6 ? 1000 : 10000;
|
||
stepBy(sign * mag);
|
||
}, 70);
|
||
}, 400);
|
||
}
|
||
|
||
useEffect(() => stopHold, []);
|
||
|
||
const arrowBtn =
|
||
"flex flex-1 items-center justify-center text-faint hover:bg-accent-soft hover:text-accent-soft-fg";
|
||
|
||
return (
|
||
<div className={`relative ${className ?? "w-full"}`}>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
inputMode="numeric"
|
||
className={`${baseInputClass} w-full pr-6`}
|
||
value={focused ? text : formatChf(value)}
|
||
onFocus={() => {
|
||
setFocused(true);
|
||
const current = Math.round(value || 0);
|
||
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn überschreiben kann.
|
||
setText(current === 0 ? "" : String(current));
|
||
requestAnimationFrame(() => inputRef.current?.select());
|
||
}}
|
||
onChange={(e) => setText(e.target.value.replace(min < 0 ? /[^0-9-]/g : /[^0-9]/g, ""))}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
stepBy(e.shiftKey ? 100 : 1);
|
||
} else if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
stepBy(e.shiftKey ? -100 : -1);
|
||
}
|
||
}}
|
||
onBlur={() => {
|
||
setFocused(false);
|
||
onChange(clamp(parseChfInput(text)));
|
||
}}
|
||
/>
|
||
<div className="absolute inset-y-0 right-0 flex w-6 flex-col overflow-hidden rounded-r-lg border-l border-border">
|
||
<button
|
||
type="button"
|
||
tabIndex={-1}
|
||
aria-label="Erhöhen"
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
startHold(1);
|
||
}}
|
||
onMouseUp={stopHold}
|
||
onMouseLeave={stopHold}
|
||
onTouchStart={(e) => {
|
||
e.preventDefault();
|
||
startHold(1);
|
||
}}
|
||
onTouchEnd={stopHold}
|
||
className={arrowBtn}
|
||
>
|
||
<ChevronUp className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
tabIndex={-1}
|
||
aria-label="Verringern"
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
startHold(-1);
|
||
}}
|
||
onMouseUp={stopHold}
|
||
onMouseLeave={stopHold}
|
||
onTouchStart={(e) => {
|
||
e.preventDefault();
|
||
startHold(-1);
|
||
}}
|
||
onTouchEnd={stopHold}
|
||
className={`${arrowBtn} border-t border-border`}
|
||
>
|
||
<ChevronDown className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MoneyField({
|
||
label,
|
||
help,
|
||
value,
|
||
onChange,
|
||
min,
|
||
max,
|
||
}: {
|
||
label: string;
|
||
help?: string;
|
||
value: number;
|
||
onChange: (value: number) => void;
|
||
min?: number;
|
||
max?: number;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<FieldLabel label={label} help={help} />
|
||
<MoneyInput value={value} onChange={onChange} min={min} max={max} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TextField({
|
||
label,
|
||
help,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
}: {
|
||
label: string;
|
||
help?: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder?: string;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<FieldLabel label={label} help={help} />
|
||
<input
|
||
type="text"
|
||
className={baseInputClass}
|
||
value={value}
|
||
placeholder={placeholder}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SelectField<T extends string>({
|
||
label,
|
||
help,
|
||
value,
|
||
onChange,
|
||
options,
|
||
}: {
|
||
label: string;
|
||
help?: string;
|
||
value: T;
|
||
onChange: (value: T) => void;
|
||
options: { value: T; label: string }[];
|
||
}) {
|
||
return (
|
||
<div>
|
||
<FieldLabel label={label} help={help} />
|
||
<select
|
||
className={baseInputClass}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value as T)}
|
||
>
|
||
{options.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- "Aus Vorphase uebernehmen" (Roadmap Nr. 44, Punkt A) --------------------------------
|
||
//
|
||
// Wiederkehr-Parameter (Raten, Beitraege, Amortisation) gelten weiter, bis man sie bewusst
|
||
// aendert. Bisher war das unsichtbar: Beim Anlegen einer Phase wurde der Wert KOPIERT, und
|
||
// spaetere Aenderungen an der Vorphase kamen deshalb nie an. Jetzt entscheidet ein sichtbares
|
||
// Haekchen: angehakt = Feld leer lassen und live erben; abgehakt = eigener Wert fuer diese
|
||
// Phase. In der ersten Phase gibt es nichts zu erben -- dort erscheint das Haekchen nicht.
|
||
export function InheritableField({
|
||
label,
|
||
help,
|
||
canInherit,
|
||
isInheriting,
|
||
inheritedDisplay,
|
||
onToggle,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
help?: string;
|
||
canInherit: boolean;
|
||
isInheriting: boolean;
|
||
inheritedDisplay: string;
|
||
onToggle: (inherit: boolean) => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div>
|
||
{isInheriting ? (
|
||
<>
|
||
<FieldLabel label={label} help={help} />
|
||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||
{inheritedDisplay} <span className="text-xs text-faint">(aus der Vorphase)</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
children
|
||
)}
|
||
{canInherit && (
|
||
<label className="mt-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted">
|
||
<input
|
||
type="checkbox"
|
||
checked={isInheriting}
|
||
onChange={(e) => onToggle(e.target.checked)}
|
||
className="h-3.5 w-3.5 accent-accent"
|
||
/>
|
||
Aus Vorphase übernehmen
|
||
</label>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|