diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index a105d6e..c3dd7fc 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -4,6 +4,7 @@ ### Ajouté +- Bilan : **saisie de snapshot par titre**. Un compte *détaillé* se saisit désormais titre par titre — chaque position a sa propre ligne avec un sélecteur de titre (autocomplétion sur vos titres existants, avec création inline d'un nouveau symbole), quantité, cours (avec la récupération automatique de prix optionnelle), coût d'acquisition, et un gain latent calculé en direct. La valeur du compte est la somme affichée de ses positions. Les comptes simples sont inchangés. Le sélecteur de titre accepte n'importe quel symbole normalisé (MAJUSCULES/sans espaces) — pas de validation en direct du symbole, puisque la récupération du prix est une étape séparée et au mieux ; vous choisissez la classe d'actif (Action / Crypto) à la création d'un nouveau symbole (#214). - Bilan : la date d'un snapshot existant peut maintenant être déplacée. Le champ date devient modifiable en mode édition — changez-la puis enregistrez, et le snapshot (avec toutes ses lignes) est déplacé à la nouvelle date dans une seule transaction atomique. Si un autre snapshot occupe déjà la date cible, le déplacement est refusé avec un message clair et rien n'est modifié (#200). - Bilan : **enveloppe fiscale sur les comptes**. Un compte peut désormais porter une enveloppe fiscale optionnelle (Non-enregistré, CELI, REER, FERR, CELIAPP, REEE — ou aucune), choisie via un menu déroulant dans le formulaire de compte. C'est un axe distinct du type du compte (la classe d'actif) : un compte d'actions logé dans un CELI est enfin exprimable — type *Actions* + enveloppe *CELI*. La migration v12 ajoute la colonne nullable `balance_accounts.vehicle_type` avec un CHECK sur l'enum et backfille les anciens comptes CELI/REER (#202, #203). - Bilan : **axe enveloppe du graphique empilé**. Le graphique d'évolution empilé gagne un sous-choix d'axe — **Par classe d'actif** (défaut, comportement inchangé) ou **Par enveloppe** (regroupe par enveloppe fiscale, avec un bucket « Aucune » pour les comptes sans enveloppe). Lisez votre patrimoine par ce que vous détenez *ou* par l'abri fiscal où il se trouve (#204). diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1f028..9e090ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Balance: **per-security snapshot entry**. A *detailed* account is now entered title by title — each holding has its own row with a security picker (autocomplete over your existing securities, with inline creation of a new ticker), quantity, price (with the optional automatic price fetch), cost basis, and a live unrealized-gain figure. The account's value is the displayed sum of its positions. Simple accounts are unchanged. The security picker accepts any normalized symbol (UPPER/TRIM) — there is no live ticker validation, since the price fetch is a separate, best-effort step; you choose the asset class (Stock / Crypto) when creating a new symbol (#214). - Balance: an existing snapshot's date can now be moved. The date field is editable in edit mode — change it and save, and the snapshot (with all its lines) is moved to the new date inside a single atomic transaction. If another snapshot already occupies the target date, the move is rejected with a clear message and nothing changes (#200). - Balance: **fiscal envelope on accounts**. An account can now carry an optional fiscal envelope (Non-registered, TFSA, RRSP, RRIF, FHSA, RESP — or none), set via a dropdown in the account form. This is a separate axis from the account's type (asset class), so an account holding stocks inside a TFSA is finally expressible — type *Stocks* + envelope *TFSA*. Migration v12 adds the nullable `balance_accounts.vehicle_type` column with a CHECK on the enum and backfills the former TFSA/RRSP accounts (#202, #203). - Balance: **stacked-chart envelope axis**. The stacked evolution chart gains an axis sub-toggle — **By asset class** (default, unchanged behaviour) or **By envelope** (groups by fiscal envelope, with a "None" bucket for accounts without one). Read your net worth by what you hold *or* by where it's sheltered (#204). diff --git a/src/components/balance/SecurityPicker.test.ts b/src/components/balance/SecurityPicker.test.ts new file mode 100644 index 0000000..4975ef8 --- /dev/null +++ b/src/components/balance/SecurityPicker.test.ts @@ -0,0 +1,91 @@ +// SecurityPicker — unit tests (Issue #214). +// +// NOTE: this project has no jsdom / @testing-library harness (logged across +// the balance UI work). We test the picker's pure decision logic — the +// filter and the create-vs-pick rule — directly, the same way +// CategoryCombobox.test.ts tests `sortHierarchical`. DOM rendering is not +// exercised here. + +import { describe, it, expect } from "vitest"; +import { filterSecurities, decideCreateOption } from "./SecurityPicker"; +import type { BalanceSecurity } from "../../shared/types"; + +function sec( + id: number, + symbol: string, + name: string | null, + asset_type: "stock" | "crypto" = "stock" +): BalanceSecurity { + return { + id, + symbol, + name, + currency: "CAD", + asset_type, + created_at: "", + updated_at: "", + }; +} + +const catalogue: BalanceSecurity[] = [ + sec(1, "AAPL", "Apple Inc."), + sec(2, "BTC", "Bitcoin", "crypto"), + sec(3, "ETH", "Ethereum", "crypto"), + sec(4, "MSFT", "Microsoft Corp."), +]; + +describe("filterSecurities", () => { + it("returns the whole catalogue for an empty / whitespace query", () => { + expect(filterSecurities(catalogue, "")).toHaveLength(4); + expect(filterSecurities(catalogue, " ")).toHaveLength(4); + }); + + it("matches on symbol, case-insensitively", () => { + const r = filterSecurities(catalogue, "aapl"); + expect(r.map((s) => s.symbol)).toEqual(["AAPL"]); + }); + + it("matches on name, case-insensitively", () => { + const r = filterSecurities(catalogue, "micro"); + expect(r.map((s) => s.symbol)).toEqual(["MSFT"]); + }); + + it("matches a partial substring across multiple rows", () => { + // "et" hits ETH's symbol and nothing else here. + const r = filterSecurities(catalogue, "eth"); + expect(r.map((s) => s.symbol)).toEqual(["ETH"]); + }); + + it("preserves the catalogue order of matches", () => { + const r = filterSecurities(catalogue, "t"); // BTC, ETH, MSFT all contain 't' + expect(r.map((s) => s.symbol)).toEqual(["BTC", "ETH", "MSFT"]); + }); + + it("returns [] when nothing matches", () => { + expect(filterSecurities(catalogue, "ZZZZ")).toEqual([]); + }); +}); + +describe("decideCreateOption", () => { + it("returns null for an empty / whitespace query (nothing to create)", () => { + expect(decideCreateOption(catalogue, "")).toBeNull(); + expect(decideCreateOption(catalogue, " ")).toBeNull(); + }); + + it("offers a normalized (UPPER/TRIM) create for a brand-new symbol", () => { + expect(decideCreateOption(catalogue, " tsla ")).toEqual({ symbol: "TSLA" }); + }); + + it("returns null when the normalized symbol already exists (exact match)", () => { + // Same symbol, different casing/whitespace — already in the catalogue, so + // there is nothing new to create. + expect(decideCreateOption(catalogue, "aapl")).toBeNull(); + expect(decideCreateOption(catalogue, " AAPL ")).toBeNull(); + }); + + it("still offers create when the query only PARTIALLY matches an existing symbol", () => { + // "AAP" is a prefix of AAPL but not an exact symbol — the user may want a + // distinct ticker, so the create option is offered. + expect(decideCreateOption(catalogue, "AAP")).toEqual({ symbol: "AAP" }); + }); +}); diff --git a/src/components/balance/SecurityPicker.tsx b/src/components/balance/SecurityPicker.tsx new file mode 100644 index 0000000..bb32e1a --- /dev/null +++ b/src/components/balance/SecurityPicker.tsx @@ -0,0 +1,388 @@ +// SecurityPicker — autocomplete over the existing `balance_securities` catalogue +// with inline creation (Issue #214 / Bilan détail par titre). +// +// Behavior (decisions logged in the autopilot run of 2026-06-04 / 2026-06-06): +// - The input accepts ANY normalized string (UPPER + TRIM). There is NO live +// symbol validation — the price fetch (PriceFetchControl) is a separate, +// best-effort step. A symbol the user types but that does not exist in the +// catalogue is offered as an inline "create" option. +// - Picking an existing security emits its stored `symbol` + `asset_type` +// (+ optional `name`). Creating a new symbol emits the typed (normalized) +// symbol + the asset_type chosen via the stock/crypto toggle (default +// 'stock', matching `makeEmptyHolding`). +// - The symbol format mirrors the price-fetching one: `normalizeSecuritySymbol` +// (UPPER(TRIM(...))), the exact function the service + migrations use, so a +// picker-created security collapses onto the same `balance_securities` row. +// +// The UI idiom follows `CategoryCombobox` (controlled input + listbox, keyboard +// nav, click-outside close) so the Bilan editor stays visually consistent. +// +// This component is presentation + selection only. It receives the catalogue +// (the parent loads it once via `listSecurities()`), the current row symbol, +// and emits a `SecurityPick` on choose/create. Persisting a brand-new security +// happens server-side at save time (`findOrCreateSecurity` inside the atomic +// save), so no DB write happens here. + +import { + useState, + useRef, + useEffect, + useCallback, + useId, + useMemo, +} from "react"; +import { useTranslation } from "react-i18next"; +import type { BalanceAssetType, BalanceSecurity } from "../../shared/types"; +import { normalizeSecuritySymbol } from "../../services/balance.service"; + +/** What the picker emits when the user selects or creates a security. */ +export interface SecurityPick { + /** Normalized (UPPER/TRIM) symbol. */ + symbol: string; + asset_type: BalanceAssetType; + /** Existing security's name, or null for a freshly-created symbol. */ + name: string | null; + /** True when the symbol is not (yet) in the catalogue — created inline. */ + isNew: boolean; +} + +interface SecurityPickerProps { + /** The full securities catalogue (loaded once by the parent). */ + securities: BalanceSecurity[]; + /** Currently selected symbol on this row (controlled). */ + value: string; + /** Asset type currently on the row — seeds the create toggle default. */ + assetType: BalanceAssetType; + onSelect: (pick: SecurityPick) => void; + disabled?: boolean; + ariaLabel?: string; + placeholder?: string; +} + +// --------------------------------------------------------------------------- +// Pure helpers (exported for unit tests — the project has no jsdom harness, so +// component logic is tested through these rather than via DOM rendering). +// --------------------------------------------------------------------------- + +/** + * Filter the catalogue by a raw query. Matching is case-insensitive over both + * the symbol and the (optional) name. An empty query returns the whole list. + * The catalogue is assumed already symbol-sorted (listSecurities orders by + * symbol); we preserve that order. + */ +export function filterSecurities( + securities: BalanceSecurity[], + query: string +): BalanceSecurity[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return securities; + return securities.filter((s) => { + if (s.symbol.toLowerCase().includes(q)) return true; + if (s.name && s.name.toLowerCase().includes(q)) return true; + return false; + }); +} + +/** + * Decide, for a typed query, whether an inline "create" option should be + * offered and what it would create. Returns null when the query is empty or + * when an EXACT (normalized) symbol match already exists in the catalogue — + * in that case there is nothing new to create. The create symbol is the + * normalized form so it round-trips to the same `balance_securities` row. + */ +export function decideCreateOption( + securities: BalanceSecurity[], + query: string +): { symbol: string } | null { + const normalized = normalizeSecuritySymbol(query); + if (normalized.length === 0) return null; + const exact = securities.some( + (s) => normalizeSecuritySymbol(s.symbol) === normalized + ); + if (exact) return null; + return { symbol: normalized }; +} + +export default function SecurityPicker({ + securities, + value, + assetType, + onSelect, + disabled, + ariaLabel, + placeholder, +}: SecurityPickerProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [highlightIndex, setHighlightIndex] = useState(0); + // Asset type to use when CREATING a new symbol. Seeded from the row's current + // asset type (default 'stock' via makeEmptyHolding); the user can flip it. + const [createAssetType, setCreateAssetType] = + useState(assetType); + + const inputRef = useRef(null); + const listRef = useRef(null); + const containerRef = useRef(null); + const baseId = useId(); + const listboxId = `${baseId}-listbox`; + const optionId = (i: number) => `${baseId}-option-${i}`; + + // Keep the create-toggle default in sync if the row's asset type changes + // externally (e.g. a different security was picked then cleared). + useEffect(() => { + setCreateAssetType(assetType); + }, [assetType]); + + const filtered = useMemo( + () => filterSecurities(securities, query), + [securities, query] + ); + const createOption = useMemo( + () => decideCreateOption(securities, query), + [securities, query] + ); + + // Layout: [existing matches...] then (optionally) the create row last. + const totalItems = filtered.length + (createOption ? 1 : 0); + const createIndex = createOption ? filtered.length : -1; + + // The text shown in the input when closed: the selected symbol verbatim. + const displayLabel = value; + + useEffect(() => { + if (open && listRef.current) { + const el = listRef.current.children[highlightIndex] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: "nearest" }); + } + }, [highlightIndex, open]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + setQuery(""); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const choosePick = useCallback( + (pick: SecurityPick) => { + onSelect(pick); + setOpen(false); + setQuery(""); + inputRef.current?.blur(); + }, + [onSelect] + ); + + const selectItem = useCallback( + (index: number) => { + if (index === createIndex && createOption) { + choosePick({ + symbol: createOption.symbol, + asset_type: createAssetType, + name: null, + isNew: true, + }); + return; + } + const sec = filtered[index]; + if (sec) { + choosePick({ + symbol: sec.symbol, + asset_type: sec.asset_type, + name: sec.name, + isNew: false, + }); + } + }, + [filtered, createIndex, createOption, createAssetType, choosePick] + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open) { + if (e.key === "ArrowDown" || e.key === "Enter") { + e.preventDefault(); + setOpen(true); + setHighlightIndex(0); + } + return; + } + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + if (totalItems > 0) + setHighlightIndex((i) => (i + 1) % totalItems); + break; + case "ArrowUp": + e.preventDefault(); + if (totalItems > 0) + setHighlightIndex((i) => (i - 1 + totalItems) % totalItems); + break; + case "Enter": + e.preventDefault(); + if (totalItems > 0) selectItem(highlightIndex); + break; + case "Escape": + e.preventDefault(); + setOpen(false); + setQuery(""); + inputRef.current?.blur(); + break; + } + }; + + const activeId = + open && totalItems > 0 ? optionId(highlightIndex) : undefined; + + return ( +
+ { + setQuery(e.target.value); + setHighlightIndex(0); + if (!open) setOpen(true); + }} + onFocus={() => { + setOpen(true); + setQuery(""); + setHighlightIndex(0); + }} + onKeyDown={handleKeyDown} + className="w-full px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50" + /> + {open && ( +
+ {/* Asset-type toggle for the create option (stock / crypto). Only + meaningful when a new symbol would be created; shown alongside it + so the user sets the class before committing the create. */} + {createOption && ( +
+ + {t("balance.snapshot.detailed.picker.assetTypeLabel")} + +
+ {(["stock", "crypto"] as const).map((at) => ( + + ))} +
+
+ )} + + {totalItems === 0 ? ( +

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

+ ) : ( +
    + {filtered.map((sec, i) => ( +
  • e.preventDefault()} + onClick={() => selectItem(i)} + onMouseEnter={() => setHighlightIndex(i)} + className={`flex items-center justify-between gap-2 px-3 py-1.5 text-sm cursor-pointer ${ + i === highlightIndex + ? "bg-[var(--primary)] text-white" + : "text-[var(--foreground)] hover:bg-[var(--muted)]" + }`} + > + + {sec.symbol} + {sec.name && ( + + {sec.name} + + )} + + + {t( + `balance.snapshot.detailed.picker.assetType.${sec.asset_type}` + )} + +
  • + ))} + {createOption && ( +
  • e.preventDefault()} + onClick={() => selectItem(createIndex)} + onMouseEnter={() => setHighlightIndex(createIndex)} + className={`px-3 py-1.5 text-sm cursor-pointer border-t border-[var(--border)] ${ + createIndex === highlightIndex + ? "bg-[var(--primary)] text-white" + : "text-[var(--foreground)] hover:bg-[var(--muted)]" + }`} + > + {t("balance.snapshot.detailed.picker.create", { + symbol: createOption.symbol, + })} +
  • + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/balance/SnapshotEditor.tsx b/src/components/balance/SnapshotEditor.tsx index 43202f0..d07c8b5 100644 --- a/src/components/balance/SnapshotEditor.tsx +++ b/src/components/balance/SnapshotEditor.tsx @@ -11,8 +11,10 @@ import { useTranslation } from "react-i18next"; import type { BalanceAccountWithCategory, BalanceCategory, + BalanceSecurity, } from "../../shared/types"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; +import type { SecurityPick } from "./SecurityPicker"; import SnapshotLineRow from "./SnapshotLineRow"; import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel"; @@ -23,6 +25,8 @@ interface Props { values: Record; /** account_id → holdings basket (detailed accounts, #213). */ holdings: Record; + /** Securities catalogue for the SecurityPicker autocomplete (#214). */ + securities: BalanceSecurity[]; onValueChange: (accountId: number, next: string) => void; onAddHolding: (accountId: number, assetType?: "stock" | "crypto") => void; onRemoveHolding: (accountId: number, rowId: string) => void; @@ -32,6 +36,12 @@ interface Props { field: keyof Omit, value: string ) => void; + /** Apply a SecurityPicker selection to a holding row (#214). */ + onHoldingSecurityPick: ( + accountId: number, + rowId: string, + pick: SecurityPick + ) => void; disabled?: boolean; /** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */ snapshotDate?: string; @@ -42,10 +52,12 @@ export default function SnapshotEditor({ categories, values, holdings, + securities, onValueChange, onAddHolding, onRemoveHolding, onHoldingFieldChange, + onHoldingSecurityPick, disabled, snapshotDate, }: Props) { @@ -100,6 +112,7 @@ export default function SnapshotEditor({ account={acc} value={values[acc.id] ?? ""} holdings={holdings[acc.id]} + securities={securities} onChange={(next) => onValueChange(acc.id, next)} onAddHolding={() => onAddHolding(acc.id, acc.category_asset_type ?? "stock") @@ -108,6 +121,9 @@ export default function SnapshotEditor({ onHoldingFieldChange={(rowId, field, value) => onHoldingFieldChange(acc.id, rowId, field, value) } + onHoldingSecurityPick={(rowId, pick) => + onHoldingSecurityPick(acc.id, rowId, pick) + } disabled={disabled} snapshotDate={snapshotDate} /> diff --git a/src/components/balance/SnapshotLineRow.tsx b/src/components/balance/SnapshotLineRow.tsx index 3e609da..9a59c19 100644 --- a/src/components/balance/SnapshotLineRow.tsx +++ b/src/components/balance/SnapshotLineRow.tsx @@ -14,10 +14,11 @@ // converted every former-priced account into `kind='detailed'` with one // holding, so those accounts now flow through the detailed (holdings) path. // -// This PR (#213) keeps the detailed UI deliberately minimal — it must round- -// trip a converted 1-holding account and not crash; the full add/remove + -// SecurityPicker UX lands in #214. The symbol field here is a plain text input -// (the autocomplete picker is #214). +// #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 @@ -27,9 +28,13 @@ import { ChangeEvent, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2 } from "lucide-react"; -import type { BalanceAccountWithCategory } from "../../shared/types"; +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; @@ -41,6 +46,8 @@ interface Props { 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?: ( @@ -48,6 +55,8 @@ interface Props { field: keyof Omit, value: string ) => void; + /** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */ + onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void; } /** @@ -70,9 +79,11 @@ export default function SnapshotLineRow({ disabled, snapshotDate, holdings, + securities, onAddHolding, onRemoveHolding, onHoldingFieldChange, + onHoldingSecurityPick, }: Props) { const { t } = useTranslation(); const isDetailed = account.kind === "detailed"; @@ -126,11 +137,15 @@ export default function SnapshotLineRow({ 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)} /> ))} @@ -187,24 +202,47 @@ export default function SnapshotLineRow({ } // ----------------------------------------------------------------------------- -// Detailed sub-row — one security position (#213, minimal; full UX in #214). +// 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(); @@ -215,79 +253,144 @@ function HoldingSubRow({ 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 ( -
- onFieldChange("symbol", e.target.value)} - disabled={disabled} - placeholder={t("balance.snapshot.detailed.symbolPlaceholder")} - className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50" - aria-label={t("balance.snapshot.detailed.symbolLabel")} - autoComplete="off" - /> - 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, - })} - /> - - × - - 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, - })} - /> - - = - - - +
+ {/* 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))} - /> +
+ + onFieldChange("unit_price", String(price)) + } + /> +
)} +