// SnapshotLineRow — single account line inside the snapshot editor. // // Two variants are dispatched by `account.category_kind`: // // - `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 is rendered by PriceFetchControl (Issue #158). // // 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, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { BalanceAccountWithCategory } from "../../shared/types"; import PriceFetchControl from "./PriceFetchControl"; interface BaseProps { account: BalanceAccountWithCategory; disabled?: boolean; /** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */ snapshotDate?: string; } interface SimpleProps extends BaseProps { value: string; onChange: (next: string) => void; /** 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({ account, value, onChange, disabled, quantityValue, unitPriceValue, onQuantityChange, onUnitPriceChange, snapshotDate, }: 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} {/* PriceFetchControl — wired next to the unit_price input (Issue #158). onPriceFetched updates unit_price only; quantity stays as-is. TODO: asset_type from category schema (see decisions-log.md MEDIUM) */} {account.symbol && ( onUnitPriceChange?.(String(price)) } /> )}
); } // Simple variant — unchanged from #146. const handleChange = (e: ChangeEvent) => { onChange(e.target.value); }; return (
{account.name}
{account.symbol && (
{account.symbol}
)}
{account.currency}
); }