Stammdaten gewinnen in Phase 1, Bestaetigung je Zeile statt pauschal
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -545,12 +545,15 @@ export function ElementPhaseFields({
|
||||
// wird er in der Spalte «Start». Bis 0.39 stand hier ein Eingabefeld, das
|
||||
// `phaseValues[phase1]` schrieb und die Stammdaten still überschattete (der Rechenkern
|
||||
// legt sie als `{...baseData, ...phaseValues}` übereinander): dieselbe Zahl an zwei Orten.
|
||||
// Dieselbe Vorrangregel wie im Rechenkern (`firstPhaseValues`): Wo die Stammdaten einen Wert
|
||||
// tragen, gewinnen SIE -- ein Phasenwert darf sie nicht überschatten. Der Rückfall auf den
|
||||
// Phasenwert bleibt nur, solange die Stammdaten leer sind.
|
||||
const bd = context.baseData;
|
||||
const start = (key: "amount" | "currentValue" | "startValue" | "purchasePrice" | "mortgage", label: string, help?: string) => (
|
||||
<LinkedField
|
||||
label={label}
|
||||
help={help}
|
||||
value={Math.round(num(pd[key], num(bd[key])))}
|
||||
value={Math.round(typeof bd[key] === "number" ? (bd[key] as number) : num(pd[key]))}
|
||||
actionLabel="Startwert bearbeiten"
|
||||
onAction={context.onEditBase}
|
||||
/>
|
||||
@@ -589,8 +592,13 @@ export function ElementPhaseFields({
|
||||
const isIncome = element.category === "INCOME";
|
||||
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
||||
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
||||
const baseValue =
|
||||
typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : num(bd.amount);
|
||||
const baseValue = carried
|
||||
? typeof pd.amount === "number"
|
||||
? pd.amount
|
||||
: context.derivedStart
|
||||
: typeof bd.amount === "number"
|
||||
? bd.amount
|
||||
: num(pd.amount);
|
||||
const d = context.deflatorStart || 1;
|
||||
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
|
||||
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
|
||||
@@ -815,7 +823,11 @@ export function ElementPhaseFields({
|
||||
case "REAL_ESTATE": {
|
||||
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
|
||||
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
|
||||
const hypStart = carried ? context.derivedMortgage : num(pd.mortgage, num(bd.mortgage));
|
||||
const hypStart = carried
|
||||
? context.derivedMortgage
|
||||
: typeof bd.mortgage === "number"
|
||||
? bd.mortgage
|
||||
: num(pd.mortgage);
|
||||
const amortEff = num(pd.amortization, num(context.inheritedValues.amortization));
|
||||
const hypEnde = Math.max(0, hypStart - amortEff * context.durationYears);
|
||||
const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100);
|
||||
|
||||
+77
-24
@@ -1000,9 +1000,14 @@ Erste Lebensphase anlegen
|
||||
setDistribute({ kind: "rates", phaseId: phase.id });
|
||||
}}
|
||||
onClose={() => setReviewPhaseId(null)}
|
||||
onSaved={() => {
|
||||
onSaved={(n) => {
|
||||
setReviewPhaseId(null);
|
||||
toast("success", "Annahmen bestätigt.");
|
||||
toast(
|
||||
"success",
|
||||
n === 0
|
||||
? "Gespeichert. Bestätigt wurde nichts – hake an, was du angeschaut hast."
|
||||
: `Gespeichert, ${n} ${n === 1 ? "Annahme" : "Annahmen"} bestätigt.`
|
||||
);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
@@ -2100,14 +2105,25 @@ function PhaseReviewDialog({
|
||||
ratesOpen: boolean;
|
||||
onDistributeRates: () => void;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onSaved: (confirmedCount: number) => void;
|
||||
}) {
|
||||
const [pds, setPds] = useState<Record<string, PhaseData>>(() =>
|
||||
Object.fromEntries(elements.map((e) => [e.id, { ...(e.phaseValues[phase.id] ?? {}) }]))
|
||||
);
|
||||
// Je Element ein Haken «Angeschaut», anfangs leer. Ein Sammel-Knopf, der ALLES bestätigt,
|
||||
// liesse sich drücken, ohne je gescrollt zu haben -- also genau das Durchwinken, das der
|
||||
// Mechanismus verhindern soll. Bestätigt wird deshalb nur, was hier angehakt ist.
|
||||
const [seen, setSeen] = useState<Record<string, boolean>>({});
|
||||
const seenCount = elements.filter((e) => seen[e.id]).length;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Wer ein Feld ändert, hat hingeschaut -- der Haken folgt der Bearbeitung von selbst.
|
||||
function patch(elementId: string, p: Partial<PhaseData>) {
|
||||
setPds((prev) => ({ ...prev, [elementId]: { ...prev[elementId], ...p } }));
|
||||
setSeen((prev) => ({ ...prev, [elementId]: true }));
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -2115,9 +2131,12 @@ function PhaseReviewDialog({
|
||||
for (const e of elements) {
|
||||
// `confirmed` steht NEBEN den Werten: Der Haken hält fest, dass jemand hingeschaut
|
||||
// hat. Er kopiert nichts -- was leer bleibt, erbt weiterhin aus der Vorphase.
|
||||
await api.put(`/api/elements/${e.id}/phase/${phase.id}`, { ...(pds[e.id] ?? {}), confirmed: true });
|
||||
// Geschrieben werden die Werte aller Zeilen; bestätigt nur die angehakten.
|
||||
const body: PhaseData = { ...(pds[e.id] ?? {}) };
|
||||
if (seen[e.id]) body.confirmed = true;
|
||||
await api.put(`/api/elements/${e.id}/phase/${phase.id}`, body);
|
||||
}
|
||||
onSaved();
|
||||
onSaved(seenCount);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
@@ -2129,8 +2148,8 @@ function PhaseReviewDialog({
|
||||
<DialogShell title={`Annahmen prüfen: ${phase.name}`} onClose={onClose} wide>
|
||||
<p className="text-sm text-muted">
|
||||
Diese Werte gelten für die {phase.durationYears} Jahre dieser Lebensphase. Sie sind aus der Vorphase
|
||||
übernommen – geh sie durch und passe an, was hier anders ist. Mit «Alle bestätigen» hältst du fest,
|
||||
dass du sie angeschaut hast; die Werte selbst bleiben unverändert und erben weiterhin.
|
||||
übernommen – geh sie durch und passe an, was hier anders ist. Hake an, was du angeschaut hast; die
|
||||
Werte selbst bleiben unverändert und erben weiterhin aus der Vorphase.
|
||||
</p>
|
||||
|
||||
{/* Die Verteilung zählt eigens: Man kann jede Zelle angeschaut und die Quote trotzdem nie
|
||||
@@ -2156,28 +2175,62 @@ function PhaseReviewDialog({
|
||||
In dieser Lebensphase gibt es keine offenen Annahmen mehr.
|
||||
</p>
|
||||
) : (
|
||||
elements.map((el) => (
|
||||
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
|
||||
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||||
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<ElementPhaseFields
|
||||
element={el}
|
||||
context={buildContext(el)}
|
||||
pd={pds[el.id] ?? {}}
|
||||
setP={(patch) => setPds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Der Sammel-Weg bleibt EIN Klick -- aber er ist ein sichtbarer Akt und keine
|
||||
Nebenwirkung des Speicherns. */}
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted">
|
||||
<span>
|
||||
{seenCount} von {elements.length} angeschaut
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeen(Object.fromEntries(elements.map((e) => [e.id, seenCount < elements.length])))}
|
||||
className="rounded border border-accent px-2 py-0.5 font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||||
>
|
||||
{seenCount < elements.length ? "Alle als angeschaut markieren" : "Alle Haken entfernen"}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
{elements.map((el) => (
|
||||
<div
|
||||
key={el.id}
|
||||
className={`rounded-xl border p-3 ${
|
||||
seen[el.id] ? "border-border bg-surface-2" : "border-attention bg-attention-soft/30"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
|
||||
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||||
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||||
<label className="ml-auto flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!seen[el.id]}
|
||||
onChange={(e) => setSeen((prev) => ({ ...prev, [el.id]: e.target.checked }))}
|
||||
/>
|
||||
Angeschaut
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<ElementPhaseFields
|
||||
element={el}
|
||||
context={buildContext(el)}
|
||||
pd={pds[el.id] ?? {}}
|
||||
setP={(p) => patch(el.id, p)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle bestätigen" />
|
||||
<DialogActions
|
||||
saving={saving}
|
||||
onConfirm={saveAll}
|
||||
onClose={onClose}
|
||||
confirmLabel={seenCount > 0 ? `Speichern · ${seenCount} bestätigen` : "Speichern"}
|
||||
/>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user