Simpl-Resultat/src/components/balance/SnapshotLineRow.tsx
le king fu 4846120b0f feat(balance): multi-security snapshot entry UI + SecurityPicker (#214)
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>
2026-06-06 13:39:42 -04:00

401 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}