Files
FPT/src/components/FormField.tsx
T
admGitAICDS d6855ef1a0
Deploy App / deploy (push) Successful in 1m1s
Pensionsalter anpassen (Roadmap 44)
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>
2026-07-21 14:24:40 +02:00

374 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}