Turn the detailed-account snapshot variant into the real per-title entry surface (building on the minimal sub-rows from #213): - New SecurityPicker (src/components/balance/SecurityPicker.tsx): an autocomplete combobox over the existing balance_securities catalogue (loaded via listSecurities()) with inline creation. Accepts any normalized symbol (UPPER/TRIM) with NO live ticker validation — the price fetch is best-effort and separate. On pick/create it emits a SecurityPick {symbol, asset_type, name, isNew}; a stock/crypto toggle lets the user set the asset class when creating a new symbol (default 'stock'). Built on the CategoryCombobox UI idiom (ARIA listbox, keyboard nav, click-outside). Pure helpers filterSecurities / decideCreateOption are exported and unit-tested (no jsdom harness). - SnapshotLineRow detailed sub-rows: labeled columns [title (SecurityPicker), quantity, price (+ existing PriceFetchControl), value (qty x price, read-only), book_cost, live unrealized gain]. Account value = displayed SUM of positions. Simple accounts unchanged. - useSnapshotEditor: new SET_HOLDING_SECURITY action + setHoldingSecurity callback (atomically sets symbol + asset_type + name and drops the stale fetched-price attribution since the symbol changed). The securities catalogue is loaded in loadForDate and exposed as state.securities, so it refreshes after a save that creates a security. - i18n: extended balance.snapshot.detailed.* (col.*, picker.*, book cost, unrealized gain) in FR + EN — no hardcoded UI text. - CHANGELOG (EN + FR) under [Unreleased]: first user-visible surface of the per-title detail chain (#210-#213 were schema/service/reducer). Build (tsc + vite) green; npm test green (613 tests, +10 SecurityPicker). Generated autonomously by /autopilot run of 2026-06-06 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
// 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<HoldingDraft, "rowId">,
|
||
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 (
|
||
<div className="py-2 border-b border-[var(--border)] last:border-b-0">
|
||
<div className="flex items-center justify-between gap-2 mb-1">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="text-sm font-medium truncate">{account.name}</span>
|
||
<span
|
||
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||
title={t("balance.snapshot.detailed.badgeHint")}
|
||
>
|
||
{t("balance.snapshot.detailed.badge")}
|
||
</span>
|
||
</div>
|
||
{detailedTotal !== null && (
|
||
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
|
||
{detailedTotal.toLocaleString(undefined, {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})}{" "}
|
||
{account.currency}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{rows.length === 0 ? (
|
||
<p className="text-xs text-[var(--muted-foreground)] py-1">
|
||
{t("balance.snapshot.detailed.empty")}
|
||
</p>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
{rows.map((h) => (
|
||
<HoldingSubRow
|
||
key={h.rowId}
|
||
holding={h}
|
||
accountName={account.name}
|
||
accountCurrency={account.currency}
|
||
securities={securities ?? []}
|
||
snapshotDate={snapshotDate}
|
||
disabled={disabled}
|
||
onFieldChange={(field, v) =>
|
||
onHoldingFieldChange?.(h.rowId, field, v)
|
||
}
|
||
onSecurityPick={(pick) =>
|
||
onHoldingSecurityPick?.(h.rowId, pick)
|
||
}
|
||
onRemove={() => onRemoveHolding?.(h.rowId)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => onAddHolding?.()}
|
||
disabled={disabled}
|
||
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
||
>
|
||
<Plus size={13} />
|
||
{t("balance.snapshot.detailed.addTitle")}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Simple variant — unchanged from #146.
|
||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
onChange(e.target.value);
|
||
};
|
||
|
||
return (
|
||
<div className="flex items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium truncate">{account.name}</div>
|
||
{account.symbol && (
|
||
<div className="text-xs text-[var(--muted-foreground)]">
|
||
{account.symbol}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={value}
|
||
onChange={handleChange}
|
||
disabled={disabled}
|
||
placeholder={t("balance.snapshot.line.valuePlaceholder")}
|
||
className="w-32 px-3 py-2 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.line.valueLabel", {
|
||
account: account.name,
|
||
})}
|
||
/>
|
||
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
||
{account.currency}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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 (
|
||
<label className="flex flex-col gap-0.5">
|
||
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||
{label}
|
||
</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
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<HoldingDraft, "rowId">, 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 (
|
||
<div className="flex flex-wrap items-end gap-2 pl-2 py-1 border-l-2 border-[var(--border)]">
|
||
{/* Titre — SecurityPicker (autocomplete + inline create) */}
|
||
<Field label={t("balance.snapshot.detailed.col.title")}>
|
||
<SecurityPicker
|
||
securities={securities}
|
||
value={holding.symbol}
|
||
assetType={holding.asset_type}
|
||
onSelect={onSecurityPick}
|
||
disabled={disabled}
|
||
ariaLabel={t("balance.snapshot.detailed.symbolLabel")}
|
||
/>
|
||
</Field>
|
||
|
||
{/* Quantité */}
|
||
<Field label={t("balance.snapshot.detailed.col.quantity")}>
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={holding.quantity}
|
||
onChange={(e) => 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,
|
||
})}
|
||
/>
|
||
</Field>
|
||
|
||
{/* Cours (unit price) */}
|
||
<Field label={t("balance.snapshot.detailed.col.unitPrice")}>
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={holding.unit_price}
|
||
onChange={(e) => 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,
|
||
})}
|
||
/>
|
||
</Field>
|
||
|
||
{/* Valeur (computed, read-only) */}
|
||
<Field label={t("balance.snapshot.detailed.col.value")}>
|
||
<input
|
||
type="text"
|
||
value={computedValue === null ? "" : computedValue.toFixed(2)}
|
||
readOnly
|
||
disabled
|
||
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
||
className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] cursor-not-allowed"
|
||
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
||
account: label,
|
||
})}
|
||
aria-readonly="true"
|
||
/>
|
||
</Field>
|
||
|
||
{/* Book cost (cost basis) */}
|
||
<Field label={t("balance.snapshot.detailed.col.bookCost")}>
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={holding.book_cost}
|
||
onChange={(e) => 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,
|
||
})}
|
||
/>
|
||
</Field>
|
||
|
||
{/* Gain latent (value − book_cost), live, read-only */}
|
||
<Field label={t("balance.snapshot.detailed.col.latentGain")}>
|
||
<span
|
||
className={`inline-flex items-center justify-end w-24 px-2 py-1.5 text-sm text-right tabular-nums ${
|
||
latentGain === null
|
||
? "text-[var(--muted-foreground)]"
|
||
: latentGain >= 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)}`}
|
||
</span>
|
||
</Field>
|
||
|
||
<span className="text-xs text-[var(--muted-foreground)] pb-2">
|
||
{accountCurrency}
|
||
</span>
|
||
|
||
{holding.symbol && (
|
||
<div className="pb-0.5">
|
||
<PriceFetchControl
|
||
symbol={holding.symbol}
|
||
date={snapshotDate ?? ""}
|
||
categoryKind={"priced"}
|
||
assetType={holding.asset_type}
|
||
onPriceFetched={(price) =>
|
||
onFieldChange("unit_price", String(price))
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onRemove}
|
||
disabled={disabled}
|
||
className="p-1 mb-1 rounded text-[var(--muted-foreground)] hover:text-[var(--negative)] hover:bg-[var(--negative)]/10 disabled:opacity-50"
|
||
title={t("balance.snapshot.detailed.removeTitle")}
|
||
aria-label={t("balance.snapshot.detailed.removeTitle")}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|