From fb57ec5549bcce04981f2f9da450199195913b14 Mon Sep 17 00:00:00 2001 From: kelle Date: Wed, 8 Jul 2026 20:40:54 +0200 Subject: [PATCH] Add +-1000 spinner buttons to amount fields, floor (not round) to nearest 1000 in all allocation calculations --- .../api/phases/[phaseId]/transition/route.ts | 6 ++ src/components/FormField.tsx | 67 +++++++++++++------ src/components/TransitionPanel.tsx | 28 ++++---- src/lib/format.ts | 10 +-- 4 files changed, 75 insertions(+), 36 deletions(-) diff --git a/src/app/api/phases/[phaseId]/transition/route.ts b/src/app/api/phases/[phaseId]/transition/route.ts index 979c832..fda1bf0 100644 --- a/src/app/api/phases/[phaseId]/transition/route.ts +++ b/src/app/api/phases/[phaseId]/transition/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; import { computeSecurityYearlyValues } from "@/lib/calculations"; +import { floorToThousand } from "@/lib/format"; const transitionItemSchema = z.object({ positionType: z.enum(["SECURITY", "REAL_ESTATE"]), @@ -132,6 +133,11 @@ export async function PUT( } } + // Auf ein Vielfaches von 1'000 abrunden, damit der Betrag ueber Wertschriften + // (die nur in 1'000er-Schritten Sparbeitraege/Startwerte annehmen) vollstaendig + // verteilbar bleibt. + incomingCapital = floorToThousand(incomingCapital); + const transition = await prisma.$transaction(async (tx) => { await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } }); diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 699981f..1e8527d 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { InfoBubble } from "@/components/InfoBubble"; -import { formatChf, parseChfInput, roundToThousand } from "@/lib/format"; +import { floorToThousand, formatChf, parseChfInput } 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"; @@ -50,8 +50,9 @@ 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). +// 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. export function MoneyInput({ value, onChange, @@ -62,24 +63,52 @@ export function MoneyInput({ className?: string; }) { const [focused, setFocused] = useState(false); - const [text, setText] = useState(() => String(Math.round(value || 0))); + const [text, setText] = useState(() => String(Math.floor(value || 0))); + + function step(delta: number) { + onChange(floorToThousand(value) + delta); + } 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))); - }} - /> +
+ { + setFocused(true); + setText(String(Math.floor(value || 0))); + }} + onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))} + onBlur={() => { + setFocused(false); + onChange(floorToThousand(parseChfInput(text))); + }} + /> +
+ + +
+
); } diff --git a/src/components/TransitionPanel.tsx b/src/components/TransitionPanel.tsx index 87aaacd..eb974fa 100644 --- a/src/components/TransitionPanel.tsx +++ b/src/components/TransitionPanel.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { MoneyInput } from "@/components/FormField"; import { api } from "@/lib/api-client"; -import { formatChf } from "@/lib/format"; +import { floorToThousand, formatChf } from "@/lib/format"; import type { PhaseInput, TransitionDecision } from "@/lib/types"; import type { PhaseComputed } from "@/lib/calculations"; @@ -106,19 +106,21 @@ export function TransitionPanel({ ); } - const totalAvailableCapital = items.reduce((sum, it) => { - if (it.positionType === "SECURITY") { + const totalAvailableCapital = floorToThousand( + items.reduce((sum, it) => { + if (it.positionType === "SECURITY") { + if (it.decision === "CARRY_OVER") return sum; + const gain = Math.max(0, it.carryOverValue - it.originalValue); + const tax = gain * (it.saleTaxRate / 100); + return sum + (it.carryOverValue - tax); + } if (it.decision === "CARRY_OVER") return sum; - const gain = Math.max(0, it.carryOverValue - it.originalValue); + const salePrice = it.salePrice ?? 0; + const gain = Math.max(0, salePrice - it.originalValue); const tax = gain * (it.saleTaxRate / 100); - return sum + (it.carryOverValue - tax); - } - if (it.decision === "CARRY_OVER") return sum; - const salePrice = it.salePrice ?? 0; - const gain = Math.max(0, salePrice - it.originalValue); - const tax = gain * (it.saleTaxRate / 100); - return sum + (salePrice - tax); - }, 0); + return sum + (salePrice - tax); + }, 0) + ); async function handleSave() { setSaving(true); @@ -181,7 +183,7 @@ export function TransitionPanel({ {it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? ( setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x))) diff --git a/src/lib/format.ts b/src/lib/format.ts index ee67b9a..89e0b0f 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -10,10 +10,12 @@ export function formatChf(value: number): string { 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; +// Rundet ABwaerts auf ein Vielfaches von 1'000. Bewusst floor statt round: Betraege wie +// z. B. eine verfuegbare Sparquote von 1'450 CHF liessen sich sonst nicht vollstaendig +// auf Wertschriften verteilen (nur 1'000er-Schritte moeglich) -- durch Abrunden bleibt +// der angezeigte/verplanbare Betrag immer tatsaechlich erreichbar. +export function floorToThousand(value: number): number { + return Math.floor((value || 0) / 1000) * 1000; } export function parseChfInput(text: string): number {