V3: 3 Farbschemata, Grundprofil auf Plan-Ebene, Erstellungs-Popups, integer-Zahlenfelder mit Beschleunigungs-Spinner, Carry-Forward des Zielwerts, gefuehrter Uebergang
Deploy App / deploy (push) Successful in 1m51s

- Theming: semantische CSS-Tokens + 3 waehlbare Schemata (Hell/Dunkel/Warm/Sunset), Umschalter im Profil-Menue, FOUC-frei via Inline-Script, localStorage; Klassen-Sweep aller Komponenten, Recharts aus Tokens
- Datenmodell: Household entfaellt; Plan traegt Haushaltsform/Personen/Inflation selbst (Person -> planId, Plan -> userId); destruktive Migration (TRUNCATE); Onboarding/HouseholdSettings entfernt; Plan-Erstellung & -Einstellungen mit Profilfeldern
- Popups: Element-Erstellung mit Inline-Feldern (geteilte ElementPhaseFields/ElementTransitionFields), Phase- und Plan-Popups mit Direkteingabe
- Zahlenfelder: 1'000er-Runden entfernt (floorToThousand/roundToHundred weg), integer MoneyInput mit beschleunigendem Press-and-Hold-Spinner, 0-Bug-Fix, harte Live-Caps
- Quote: Amortisation + Tilgung neu quotenwirksam; Restquote sichtbar (sinkt beim Verteilen); Invest-Deckel = verfuegbares Kapital + fortgeschriebener Zielwert
- Carry-Forward: Startwert der Folgephase = Zielwert der Vorphase minus Uebergangs-Bezug (live abgeleitet); optionale Zusatzinvestition aus verfuegbarem Kapital
- Matrix: Zelle zeigt Start -> Ziel; Uebergangs-Spaltenkopf mit "n offen"-Badge + gefuehrtem Pruef-Panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:49:42 +02:00
parent b775ab77cb
commit 32adfb4476
32 changed files with 1706 additions and 1290 deletions
+57 -33
View File
@@ -3,14 +3,14 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { floorToThousand, formatChf, parseChfInput } from "@/lib/format";
import { formatChf, parseChfInput } from "@/lib/format";
const baseInputClass =
"w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-1.5 text-sm text-zinc-900 shadow-sm transition-colors focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100";
"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-zinc-600 dark:text-zinc-400">
<label className="mb-1 flex items-center text-xs font-medium text-muted">
{label}
{help && <InfoBubble text={help} />}
</label>
@@ -44,32 +44,34 @@ export function NumberField({
step={step ?? "any"}
min={min}
max={max}
onFocus={(e) => e.target.select()}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
/>
</div>
);
}
// 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, rundet
// beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts)
// und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten.
// Optionales `max` kappt Eingaben live auf das verfuegbare Budget (z. B. Sparquote).
// 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.floor(value || 0)));
const [text, setText] = useState(() => String(Math.round(value || 0)));
const valueRef = useRef(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
valueRef.current = value;
}, [value]);
@@ -77,13 +79,14 @@ export function MoneyInput({
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
function clamp(v: number): number {
let result = Math.max(0, v);
if (max != null) result = Math.min(result, Math.max(0, floorToThousand(max)));
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 step(delta: number) {
onChange(clamp(floorToThousand(valueRef.current) + delta));
function stepBy(delta: number) {
onChange(clamp(Math.round(valueRef.current) + delta));
}
function stopHold() {
@@ -97,72 +100,91 @@ export function MoneyInput({
}
}
// Klick-und-Halten: sofortiger erster Schritt, nach kurzer Verzoegerung
// fortlaufende Wiederholung, bis losgelassen wird.
function startHold(delta: number) {
step(delta);
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
// Schrittweite mit der Haltedauer waechst (immer schneller).
function startHold(sign: number) {
stepBy(sign);
const start = Date.now();
holdTimeout.current = setTimeout(() => {
holdInterval.current = setInterval(() => step(delta), 100);
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);
// Default-0 sofort leeren, damit man direkt lostippen kann.
const current = Math.floor(value || 0);
const current = Math.round(value || 0);
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben 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);
}
}}
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
onBlur={() => {
setFocused(false);
onChange(clamp(floorToThousand(parseChfInput(text))));
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-zinc-300 dark:border-zinc-700">
<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="Um 1'000 erhoehen"
aria-label="Erhoehen"
onMouseDown={(e) => {
e.preventDefault();
startHold(1000);
startHold(1);
}}
onMouseUp={stopHold}
onMouseLeave={stopHold}
onTouchStart={(e) => {
e.preventDefault();
startHold(1000);
startHold(1);
}}
onTouchEnd={stopHold}
className="flex flex-1 items-center justify-center text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400"
className={arrowBtn}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
tabIndex={-1}
aria-label="Um 1'000 verringern"
aria-label="Verringern"
onMouseDown={(e) => {
e.preventDefault();
startHold(-1000);
startHold(-1);
}}
onMouseUp={stopHold}
onMouseLeave={stopHold}
onTouchStart={(e) => {
e.preventDefault();
startHold(-1000);
startHold(-1);
}}
onTouchEnd={stopHold}
className="flex flex-1 items-center justify-center border-t border-zinc-300 text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400"
className={`${arrowBtn} border-t border-border`}
>
<ChevronDown className="h-3 w-3" />
</button>
@@ -176,18 +198,20 @@ export function MoneyField({
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} max={max} />
<MoneyInput value={value} onChange={onChange} min={min} max={max} />
</div>
);
}