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