diff --git a/src/components/balance/AccountForm.tsx b/src/components/balance/AccountForm.tsx index a56efb0..f49c7e4 100644 --- a/src/components/balance/AccountForm.tsx +++ b/src/components/balance/AccountForm.tsx @@ -1,20 +1,31 @@ -// AccountForm — variant=account (Issue #138 / Bilan #1a). +// AccountForm — account or category variant. // -// The category variant lands in Issue #140 (Bilan #2) when the priced-kind -// switch becomes available. For now this component focuses on creating / -// editing a `balance_account` record bound to an existing category. +// Mode = 'account' (Issue #138 / Bilan #1a): create / edit a balance_account +// row bound to an existing category. +// Mode = 'category' (Issue #140 / Bilan #2): create a balance_category row +// with a kind selector (`simple | priced`). +// +// Both variants live in the same component because they share the surrounding +// wiring (form layout, save / cancel buttons, validation feedback) and only +// the input fields differ. import { FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import type { BalanceAccount, BalanceCategory, + BalanceCategoryKind, } from "../../shared/types"; import type { CreateBalanceAccountInput, + CreateBalanceCategoryInput, UpdateBalanceAccountInput, } from "../../services/balance.service"; +// ----------------------------------------------------------------------------- +// Account variant types +// ----------------------------------------------------------------------------- + export interface AccountFormValues { balance_category_id: number; name: string; @@ -22,7 +33,8 @@ export interface AccountFormValues { notes: string; } -interface Props { +interface AccountVariantProps { + mode: "account"; /** When provided, the form is in edit mode; otherwise creation. */ initialAccount?: BalanceAccount | null; categories: BalanceCategory[]; @@ -33,7 +45,26 @@ interface Props { onCancel: () => void; } -function defaultValues( +// ----------------------------------------------------------------------------- +// Category variant types (Issue #140) +// ----------------------------------------------------------------------------- + +export interface CategoryFormValues { + key: string; + i18n_key: string; + kind: BalanceCategoryKind; +} + +interface CategoryVariantProps { + mode: "category"; + isSaving: boolean; + onSubmit: (values: CreateBalanceCategoryInput) => Promise | void; + onCancel: () => void; +} + +type Props = AccountVariantProps | CategoryVariantProps; + +function defaultAccountValues( initial: BalanceAccount | null | undefined, categories: BalanceCategory[] ): AccountFormValues { @@ -55,22 +86,33 @@ function defaultValues( }; } -export default function AccountForm({ +export default function AccountForm(props: Props) { + if (props.mode === "category") { + return ; + } + return ; +} + +// ----------------------------------------------------------------------------- +// Account variant +// ----------------------------------------------------------------------------- + +function AccountVariant({ initialAccount, categories, isSaving, onSubmit, onCancel, -}: Props) { +}: AccountVariantProps) { const { t } = useTranslation(); const [values, setValues] = useState(() => - defaultValues(initialAccount, categories) + defaultAccountValues(initialAccount, categories) ); const [touched, setTouched] = useState(false); // Reset form when target account changes (edit different row). useEffect(() => { - setValues(defaultValues(initialAccount, categories)); + setValues(defaultAccountValues(initialAccount, categories)); setTouched(false); }, [initialAccount, categories]); @@ -80,17 +122,21 @@ export default function AccountForm({ ); const isPriced = selectedCategory?.kind === "priced"; const trimmedName = values.name.trim(); + const trimmedSymbol = values.symbol.trim(); const nameInvalid = touched && trimmedName.length === 0; + // Priced categories require a symbol — surfaced as a validation error. + const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setTouched(true); if (!trimmedName) return; + if (isPriced && !trimmedSymbol) return; const payload: CreateBalanceAccountInput = { balance_category_id: values.balance_category_id, name: trimmedName, - symbol: values.symbol.trim() || null, + symbol: trimmedSymbol || null, notes: values.notes.trim() || null, }; @@ -178,14 +224,24 @@ export default function AccountForm({ type="text" value={values.symbol} onChange={(e) => setValues({ ...values, symbol: e.target.value })} + onBlur={() => setTouched(true)} placeholder={ isPriced ? t("balance.account.form.symbolPlaceholderPriced") : t("balance.account.form.symbolPlaceholderSimple") } - className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${ + symbolMissingForPriced + ? "border-[var(--negative)]" + : "border-[var(--border)]" + }`} autoComplete="off" /> + {symbolMissingForPriced && ( +

+ {t("balance.account.form.symbolRequiredForPriced")} +

+ )}
@@ -216,7 +272,12 @@ export default function AccountForm({ + +
+ + ); +} diff --git a/src/components/balance/SnapshotEditor.tsx b/src/components/balance/SnapshotEditor.tsx index ca269cc..7435dc5 100644 --- a/src/components/balance/SnapshotEditor.tsx +++ b/src/components/balance/SnapshotEditor.tsx @@ -1,11 +1,9 @@ // SnapshotEditor — groups the active accounts by balance category and // renders one `SnapshotLineRow` per account. // -// Issue #146 / Bilan #1b: simple-kind editor only. The priced variant -// (quantity x unit_price + price fetch button) is rendered in #140. -// Until then, accounts whose category is `priced` still appear here so -// the user can enter a manual aggregate value — the storage layer accepts -// a simple-kind line for any account regardless of its category kind. +// Both `simple` and `priced` variants are dispatched by `account.category_kind` +// inside `SnapshotLineRow`. The editor itself only carries the values down +// and the change handlers up. import { useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -13,13 +11,19 @@ import type { BalanceAccountWithCategory, BalanceCategory, } from "../../shared/types"; +import type { PricedEntry } from "../../hooks/useSnapshotEditor"; import SnapshotLineRow from "./SnapshotLineRow"; interface Props { accounts: BalanceAccountWithCategory[]; categories: BalanceCategory[]; + /** account_id → string-typed value (simple kind). */ values: Record; + /** account_id → {quantity, unit_price} strings (priced kind). */ + pricedValues: Record; onValueChange: (accountId: number, next: string) => void; + onQuantityChange: (accountId: number, next: string) => void; + onUnitPriceChange: (accountId: number, next: string) => void; disabled?: boolean; } @@ -27,7 +31,10 @@ export default function SnapshotEditor({ accounts, categories, values, + pricedValues, onValueChange, + onQuantityChange, + onUnitPriceChange, disabled, }: Props) { const { t } = useTranslation(); @@ -75,15 +82,22 @@ export default function SnapshotEditor({
- {catAccounts.map((acc) => ( - onValueChange(acc.id, next)} - disabled={disabled} - /> - ))} + {catAccounts.map((acc) => { + const priced = pricedValues[acc.id]; + return ( + onValueChange(acc.id, next)} + onQuantityChange={(next) => onQuantityChange(acc.id, next)} + onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)} + disabled={disabled} + /> + ); + })}
))} diff --git a/src/components/balance/SnapshotLineRow.tsx b/src/components/balance/SnapshotLineRow.tsx index 2418f1a..82a79c3 100644 --- a/src/components/balance/SnapshotLineRow.tsx +++ b/src/components/balance/SnapshotLineRow.tsx @@ -1,23 +1,53 @@ // SnapshotLineRow — single account line inside the snapshot editor. // -// Issue #146 / Bilan #1b ships the *simple* variant only: a single value -// input keyed by `account_id`. The priced variant (quantity / unit_price / -// computed value + price-fetch button) lands in Issue #140 / Bilan #2. +// Two variants are dispatched by `account.category_kind`: // -// We intentionally keep this component dumb: it receives a string value -// from the parent (the editor stores raw strings to preserve partial input -// the user is typing) and emits the new string on every change. Numeric -// validation happens at save time in `useSnapshotEditor.save`. +// - `simple` (Issue #146): a single value input keyed by `account_id`. +// - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both +// required), and a read-only `value` field that +// renders `quantity * unit_price` live as the +// user types. An attribution tag `[Manuel]` +// appears next to the row; the `[via Maximus]` +// tag will land with Issue #143 (price-fetching). +// +// We keep this component dumb on purpose: it receives strings from the +// parent (the editor stores raw strings to preserve partial input) and +// emits new strings on every change. Numeric validation happens at save +// time in `useSnapshotEditor.save` against the service's +// `validateLineKindInvariants` helper. -import { ChangeEvent } from "react"; +import { ChangeEvent, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { BalanceAccountWithCategory } from "../../shared/types"; -interface Props { +interface BaseProps { account: BalanceAccountWithCategory; + disabled?: boolean; +} + +interface SimpleProps extends BaseProps { value: string; onChange: (next: string) => void; - disabled?: boolean; + /** Optional priced handlers for callers that wire both at once. */ + quantityValue?: string; + unitPriceValue?: string; + onQuantityChange?: (next: string) => void; + onUnitPriceChange?: (next: string) => void; +} + +type Props = SimpleProps; + +/** + * Parse a string like "12.34" or "12,34" into a finite number, or null + * if invalid / empty. Used by the priced variant to compute the live + * `value` preview. + */ +function parseDecimal(raw: string): number | null { + if (!raw) return null; + const trimmed = String(raw).trim().replace(",", "."); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; } export default function SnapshotLineRow({ @@ -25,9 +55,119 @@ export default function SnapshotLineRow({ value, onChange, disabled, + quantityValue, + unitPriceValue, + onQuantityChange, + onUnitPriceChange, }: Props) { const { t } = useTranslation(); + const isPriced = account.category_kind === "priced"; + // Compute the live value preview for priced rows. Returns null when + // either input cannot yet be parsed (so we display a placeholder). + const computedPricedValue = useMemo(() => { + if (!isPriced) return null; + const qty = parseDecimal(quantityValue ?? ""); + const price = parseDecimal(unitPriceValue ?? ""); + if (qty === null || price === null) return null; + return qty * price; + }, [isPriced, quantityValue, unitPriceValue]); + + if (isPriced) { + const handleQty = (e: ChangeEvent) => + onQuantityChange?.(e.target.value); + const handlePrice = (e: ChangeEvent) => + onUnitPriceChange?.(e.target.value); + + return ( +
+
+
+ {account.name} + + {t("balance.snapshot.priced.attributionManual")} + +
+ {account.symbol && ( +
+ {account.symbol} +
+ )} +
+
+
+ + + {t("balance.snapshot.priced.quantity")} + +
+ + × + +
+ + + {t("balance.snapshot.priced.unitPrice")} + +
+ + = + +
+ + + {t("balance.snapshot.priced.computedValue")} + +
+ + {account.currency} + +
+
+ ); + } + + // Simple variant — unchanged from #146. const handleChange = (e: ChangeEvent) => { onChange(e.target.value); }; diff --git a/src/hooks/useSnapshotEditor.ts b/src/hooks/useSnapshotEditor.ts index 2d835ef..adbf801 100644 --- a/src/hooks/useSnapshotEditor.ts +++ b/src/hooks/useSnapshotEditor.ts @@ -38,6 +38,12 @@ import { export type SnapshotEditorMode = "new" | "edit"; +/** String-typed entry for a priced-kind line being edited. */ +export interface PricedEntry { + quantity: string; + unit_price: string; +} + interface State { mode: SnapshotEditorMode; /** ISO YYYY-MM-DD; controlled in 'new' mode, frozen in 'edit'. */ @@ -49,11 +55,16 @@ interface State { /** Used to group lines by category in the editor view. */ categories: BalanceCategory[]; /** - * Map of account_id → string-typed value. We keep strings to preserve - * empty / partial input the user is typing; conversion to number happens - * at save time (and at validation when needed). + * Map of account_id → string-typed value (simple kind only). We keep + * strings to preserve empty / partial input; conversion to number + * happens at save time. */ values: Record; + /** + * Map of account_id → string-typed `{quantity, unit_price}` (priced + * kind only). Same partial-input guarantee as `values`. + */ + pricedValues: Record; /** Snapshot whose values would prefill if the user clicks "Prefill". */ previousSnapshot: BalanceSnapshot | null; /** Lines from `previousSnapshot` (loaded lazily when needed). */ @@ -78,13 +89,28 @@ type Action = accounts: BalanceAccountWithCategory[]; categories: BalanceCategory[]; values: Record; + pricedValues: Record; previousSnapshot: BalanceSnapshot | null; previousLines: BalanceSnapshotLine[] | null; }; } | { type: "SET_DATE"; payload: string } | { type: "SET_VALUE"; payload: { accountId: number; value: string } } - | { type: "PREFILL"; payload: Record } + | { + type: "SET_PRICED_FIELD"; + payload: { + accountId: number; + field: "quantity" | "unit_price"; + value: string; + }; + } + | { + type: "PREFILL"; + payload: { + values: Record; + pricedValues: Record; + }; + } | { type: "RESET" } | { type: "CLEAR_DIRTY" }; @@ -96,6 +122,7 @@ function initialState(initialDate: string): State { accounts: [], categories: [], values: {}, + pricedValues: {}, previousSnapshot: null, previousLines: null, isLoading: false, @@ -129,6 +156,7 @@ function reducer(state: State, action: Action): State { accounts: action.payload.accounts, categories: action.payload.categories, values: action.payload.values, + pricedValues: action.payload.pricedValues, previousSnapshot: action.payload.previousSnapshot, previousLines: action.payload.previousLines, isLoading: false, @@ -148,10 +176,33 @@ function reducer(state: State, action: Action): State { }, isDirty: true, }; + case "SET_PRICED_FIELD": { + const existing = + state.pricedValues[action.payload.accountId] ?? { + quantity: "", + unit_price: "", + }; + const next: PricedEntry = + action.payload.field === "quantity" + ? { ...existing, quantity: action.payload.value } + : { ...existing, unit_price: action.payload.value }; + return { + ...state, + pricedValues: { + ...state.pricedValues, + [action.payload.accountId]: next, + }, + isDirty: true, + }; + } case "PREFILL": return { ...state, - values: { ...state.values, ...action.payload }, + values: { ...state.values, ...action.payload.values }, + pricedValues: { + ...state.pricedValues, + ...action.payload.pricedValues, + }, isDirty: true, }; case "RESET": @@ -160,6 +211,7 @@ function reducer(state: State, action: Action): State { // Keep the loaded structure (accounts, categories, snapshot) but wipe // user input back to a clean slate sourced from the saved lines. values: {}, + pricedValues: {}, isDirty: true, }; case "CLEAR_DIRTY": @@ -222,11 +274,37 @@ export function useSnapshotEditor(options: Options = {}) { const existing = await getSnapshotByDate(targetDate); const isEdit = !!existing; let values: Record = {}; + let pricedValues: Record = {}; let previousLines: BalanceSnapshotLine[] | null = null; + // Index account kinds for quick line classification. + const kindByAccountId = new Map(); + for (const acc of accounts) { + kindByAccountId.set(acc.id, acc.category_kind); + } if (existing) { const lines = await listLinesBySnapshot(existing.id); for (const line of lines) { - values[line.account_id] = String(line.value); + // The line itself carries quantity / unit_price for priced kinds; + // we still cross-check against the account kind to decide which + // input map this row belongs to (it dictates what the user sees). + const kind = kindByAccountId.get(line.account_id); + if ( + kind === "priced" || + (line.quantity !== null && line.unit_price !== null) + ) { + pricedValues[line.account_id] = { + quantity: + line.quantity !== null && line.quantity !== undefined + ? String(line.quantity) + : "", + unit_price: + line.unit_price !== null && line.unit_price !== undefined + ? String(line.unit_price) + : "", + }; + } else { + values[line.account_id] = String(line.value); + } } } const previous = await getPreviousSnapshot(targetDate); @@ -243,6 +321,7 @@ export function useSnapshotEditor(options: Options = {}) { accounts, categories, values, + pricedValues, previousSnapshot: previous, previousLines, }, @@ -269,17 +348,36 @@ export function useSnapshotEditor(options: Options = {}) { }); }, []); + const setLineQuantity = useCallback( + (accountId: number, value: string) => { + dispatch({ + type: "SET_PRICED_FIELD", + payload: { accountId, field: "quantity", value }, + }); + }, + [] + ); + + const setLineUnitPrice = useCallback( + (accountId: number, value: string) => { + dispatch({ + type: "SET_PRICED_FIELD", + payload: { accountId, field: "unit_price", value }, + }); + }, + [] + ); + const reset = useCallback(() => { dispatch({ type: "RESET" }); }, []); /** * Build the prefill map from the previous snapshot. Per spec-decisions - * row "Bouton Pré-remplir" (Issue 1b decision): + * row "Bouton Pré-remplir": * - simple kind → copy value - * - priced kind → copy quantity, leave unit_price blank → effectively - * no-op at Issue #146 because priced UI ships in #140. - * We add a TODO so the priced branch is explicit. + * - priced kind → copy quantity, leave unit_price blank (the user + * must enter or fetch a fresh price each time). */ const prefillFromPrevious = useCallback(() => { const lines = state.previousLines; @@ -288,18 +386,29 @@ export function useSnapshotEditor(options: Options = {}) { for (const acc of state.accounts) { accountKindById.set(acc.id, acc.category_kind); } - const next: Record = {}; + const nextSimple: Record = {}; + const nextPriced: Record = {}; for (const line of lines) { const kind = accountKindById.get(line.account_id); if (!kind) continue; // archived account — skip if (kind === "simple") { - next[line.account_id] = String(line.value); + nextSimple[line.account_id] = String(line.value); } else { - // TODO Issue #140 — implement priced prefill (quantity copy, leave - // unit_price blank). For Issue #146 the priced UI does not exist yet. + // Priced: copy quantity, leave unit_price blank — quantities don't + // change unless the user buys / sells, prices always change. + nextPriced[line.account_id] = { + quantity: + line.quantity !== null && line.quantity !== undefined + ? String(line.quantity) + : "", + unit_price: "", + }; } } - dispatch({ type: "PREFILL", payload: next }); + dispatch({ + type: "PREFILL", + payload: { values: nextSimple, pricedValues: nextPriced }, + }); }, [state.previousLines, state.accounts]); /** @@ -326,7 +435,13 @@ export function useSnapshotEditor(options: Options = {}) { snapshot_date: state.snapshotDate, }); } - const lines = Object.entries(state.values) + // Index account kinds for line classification at save time. + const kindByAccountId = new Map(); + for (const acc of state.accounts) { + kindByAccountId.set(acc.id, acc.category_kind); + } + // Simple-kind lines: drop empty fields, accept any finite number. + const simpleLines = Object.entries(state.values) .filter(([, v]) => v !== undefined && String(v).trim().length > 0) .map(([accountIdStr, raw]) => { const accountId = Number(accountIdStr); @@ -338,9 +453,49 @@ export function useSnapshotEditor(options: Options = {}) { `Invalid value for account ${accountId}: "${raw}"` ); } - return { account_id: accountId, value: num }; + return { + account_id: accountId, + value: num, + account_kind: "simple" as const, + }; }); - await upsertSnapshotLines(snapshotId, lines); + // Priced-kind lines: both qty + price required, value computed. + const pricedLines = Object.entries(state.pricedValues) + .filter( + ([, entry]) => + entry && + String(entry.quantity ?? "").trim().length > 0 && + String(entry.unit_price ?? "").trim().length > 0 + ) + .map(([accountIdStr, entry]) => { + const accountId = Number(accountIdStr); + const qtyTrim = String(entry.quantity).trim().replace(",", "."); + const priceTrim = String(entry.unit_price).trim().replace(",", "."); + const qty = Number(qtyTrim); + const price = Number(priceTrim); + if (!Number.isFinite(qty)) { + throw new BalanceServiceError( + "snapshot_priced_quantity_required", + `Invalid quantity for account ${accountId}: "${entry.quantity}"` + ); + } + if (!Number.isFinite(price)) { + throw new BalanceServiceError( + "snapshot_priced_unit_price_required", + `Invalid unit_price for account ${accountId}: "${entry.unit_price}"` + ); + } + return { + account_id: accountId, + account_kind: "priced" as const, + quantity: qty, + unit_price: price, + // value = qty * price; the service re-validates the relation + // within PRICED_VALUE_TOLERANCE before persisting. + value: qty * price, + }; + }); + await upsertSnapshotLines(snapshotId, [...simpleLines, ...pricedLines]); dispatch({ type: "CLEAR_DIRTY" }); // Reload so 'new' mode flips to 'edit' and the snapshot row is in state. await loadForDate(state.snapshotDate); @@ -356,6 +511,8 @@ export function useSnapshotEditor(options: Options = {}) { state.snapshot, state.snapshotDate, state.values, + state.pricedValues, + state.accounts, loadForDate, ]); @@ -377,6 +534,8 @@ export function useSnapshotEditor(options: Options = {}) { state, setDate, setLineValue, + setLineQuantity, + setLineUnitPrice, reset, prefillFromPrevious, save, diff --git a/src/pages/SnapshotEditPage.tsx b/src/pages/SnapshotEditPage.tsx index 4a8062b..650ec0a 100644 --- a/src/pages/SnapshotEditPage.tsx +++ b/src/pages/SnapshotEditPage.tsx @@ -49,7 +49,8 @@ export default function SnapshotEditPage() { const isEditMode = state.mode === "edit"; const canPrefill = !!state.previousSnapshot; - // Aggregate value (simple kind only — sums all visible numeric inputs). + // Aggregate value across simple + priced lines (computed live as the + // user types). Priced contribution = quantity × unit_price. const totalValue = useMemo(() => { let total = 0; let hasAny = false; @@ -62,8 +63,19 @@ export default function SnapshotEditPage() { hasAny = true; } } + for (const entry of Object.values(state.pricedValues)) { + if (!entry) continue; + const qty = Number(String(entry.quantity ?? "").trim().replace(",", ".")); + const price = Number( + String(entry.unit_price ?? "").trim().replace(",", ".") + ); + if (Number.isFinite(qty) && Number.isFinite(price)) { + total += qty * price; + hasAny = true; + } + } return hasAny ? total : null; - }, [state.values]); + }, [state.values, state.pricedValues]); const handleSave = async () => { try { @@ -184,7 +196,10 @@ export default function SnapshotEditPage() { accounts={state.accounts} categories={state.categories} values={state.values} + pricedValues={state.pricedValues} onValueChange={editor.setLineValue} + onQuantityChange={editor.setLineQuantity} + onUnitPriceChange={editor.setLineUnitPrice} disabled={state.isSaving} /> )}