// SnapshotLineRow — single account line inside the snapshot editor. // // Two variants are dispatched by the account's OWN `account.kind` (#213), // NOT by `category_kind`: // // - `simple` (Issue #146): a single value input keyed by `account_id`. // - `detailed` (Issue #213): N sub-rows, one per security held — each with // `quantity`, `unit_price` (both required), a read-only live // `value`, and the existing PriceFetchControl. The account's // value is the sum across its holdings. // // The OLD "priced scalar" variant (one security via account.symbol + scalar // quantity/unit_price on the line) is SUPERSEDED: migration v16 (#211) // converted every former-priced account into `kind='detailed'` with one // holding, so those accounts now flow through the detailed (holdings) path. // // #214 turns the detailed variant into the real per-title entry surface: each // sub-row carries a SecurityPicker (autocomplete over `balance_securities` + // inline creation), quantity, unit_price (+ price fetch), a read-only computed // value, a book_cost input, and a live latent-gain figure. The account's value // is the SUM across its holdings. // // 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`. import { ChangeEvent, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2 } from "lucide-react"; import type { BalanceAccountWithCategory, BalanceSecurity, } from "../../shared/types"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; import PriceFetchControl from "./PriceFetchControl"; import SecurityPicker, { type SecurityPick } from "./SecurityPicker"; interface Props { account: BalanceAccountWithCategory; disabled?: boolean; /** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */ snapshotDate?: string; /** Simple variant: the scalar value string + its change handler. */ value: string; onChange: (next: string) => void; /** Detailed variant: the holdings basket + mutators (#213). */ holdings?: HoldingDraft[]; /** Securities catalogue for the SecurityPicker autocomplete (#214). */ securities?: BalanceSecurity[]; onAddHolding?: () => void; onRemoveHolding?: (rowId: string) => void; onHoldingFieldChange?: ( rowId: string, field: keyof Omit, value: string ) => void; /** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */ onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void; } /** * Parse a string like "12.34" or "12,34" into a finite number, or null * if invalid / empty. Used by the detailed sub-rows 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, snapshotDate, holdings, securities, onAddHolding, onRemoveHolding, onHoldingFieldChange, onHoldingSecurityPick, }: Props) { const { t } = useTranslation(); const isDetailed = account.kind === "detailed"; // Account total across the basket (live as the user types). const detailedTotal = useMemo(() => { if (!isDetailed || !holdings) return null; let total = 0; for (const h of holdings) { const qty = parseDecimal(h.quantity); const price = parseDecimal(h.unit_price); if (qty !== null && price !== null) total += qty * price; } return total; }, [isDetailed, holdings]); if (isDetailed) { const rows = holdings ?? []; return (
{account.name} {t("balance.snapshot.detailed.badge")}
{detailedTotal !== null && ( {detailedTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{" "} {account.currency} )}
{rows.length === 0 ? (

{t("balance.snapshot.detailed.empty")}

) : (
{rows.map((h) => ( onHoldingFieldChange?.(h.rowId, field, v) } onSecurityPick={(pick) => onHoldingSecurityPick?.(h.rowId, pick) } onRemove={() => onRemoveHolding?.(h.rowId)} /> ))}
)}
); } // Simple variant — unchanged from #146. const handleChange = (e: ChangeEvent) => { onChange(e.target.value); }; return (
{account.name}
{account.symbol && (
{account.symbol}
)}
{account.currency}
); } // ----------------------------------------------------------------------------- // Detailed sub-row — one security position (#214: SecurityPicker + polished // columns [titre, quantité, cours (+ fetch), valeur, book_cost, gain latent]). // ----------------------------------------------------------------------------- /** A small labeled field wrapper so each column reads clearly on its own. */ function Field({ label, children, }: { label: string; children: React.ReactNode; }) { return ( ); } function HoldingSubRow({ holding, accountName, accountCurrency, securities, snapshotDate, disabled, onFieldChange, onSecurityPick, onRemove, }: { holding: HoldingDraft; accountName: string; accountCurrency: string; securities: BalanceSecurity[]; snapshotDate?: string; disabled?: boolean; onFieldChange: (field: keyof Omit, value: string) => void; onSecurityPick: (pick: SecurityPick) => void; onRemove: () => void; }) { const { t } = useTranslation(); const computedValue = useMemo(() => { const qty = parseDecimal(holding.quantity); const price = parseDecimal(holding.unit_price); if (qty === null || price === null) return null; return qty * price; }, [holding.quantity, holding.unit_price]); // Live latent gain = value − book_cost. N/A when value can't be computed or // book_cost is empty / zero (consistent with computeUnrealizedGain's guard, // which treats a 0 book_cost as "no meaningful gain figure" for display). const latentGain = useMemo(() => { if (computedValue === null) return null; const bookCost = parseDecimal(holding.book_cost); if (bookCost === null || bookCost === 0) return null; return computedValue - bookCost; }, [computedValue, holding.book_cost]); const label = holding.symbol || accountName; const fmt2 = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, }); return (
{/* Titre — SecurityPicker (autocomplete + inline create) */} {/* Quantité */} onFieldChange("quantity", e.target.value)} disabled={disabled} placeholder={t("balance.snapshot.priced.quantityPlaceholder")} className="w-20 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50" aria-label={t("balance.snapshot.priced.quantityLabel", { account: label, })} /> {/* Cours (unit price) */} onFieldChange("unit_price", e.target.value)} disabled={disabled} placeholder={t("balance.snapshot.priced.unitPricePlaceholder")} className="w-24 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50" aria-label={t("balance.snapshot.priced.unitPriceLabel", { account: label, })} /> {/* Valeur (computed, read-only) */} {/* Book cost (cost basis) */} onFieldChange("book_cost", e.target.value)} disabled={disabled} placeholder={t("balance.snapshot.detailed.bookCostPlaceholder")} className="w-24 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50" aria-label={t("balance.snapshot.detailed.bookCostLabel", { account: label, })} /> {/* Gain latent (value − book_cost), live, read-only */} = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" }`} aria-label={t("balance.snapshot.detailed.latentGainLabel", { account: label, })} > {latentGain === null ? t("balance.snapshot.detailed.latentGainNA") : `${latentGain >= 0 ? "+" : ""}${fmt2(latentGain)}`} {accountCurrency} {holding.symbol && (
onFieldChange("unit_price", String(price)) } />
)}
); }