Simpl-Resultat/src/components/balance/SecurityPicker.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

388 lines
14 KiB
TypeScript

// 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<BalanceAssetType>(assetType);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative w-40">
<input
ref={inputRef}
type="text"
role="combobox"
aria-label={ariaLabel ?? t("balance.snapshot.detailed.symbolLabel")}
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeId}
autoComplete="off"
spellCheck={false}
disabled={disabled}
value={open ? query : displayLabel}
placeholder={
placeholder ?? t("balance.snapshot.detailed.picker.placeholder")
}
onChange={(e) => {
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 && (
<div className="absolute z-50 mt-1 w-72 rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg overflow-hidden">
{/* 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 && (
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-[var(--muted)]/40">
<span className="text-[11px] text-[var(--muted-foreground)]">
{t("balance.snapshot.detailed.picker.assetTypeLabel")}
</span>
<div className="flex rounded-md overflow-hidden border border-[var(--border)]">
{(["stock", "crypto"] as const).map((at) => (
<button
key={at}
type="button"
// Prevent the input blur (mousedown) from closing the list.
onMouseDown={(e) => e.preventDefault()}
onClick={() => setCreateAssetType(at)}
className={`px-2 py-0.5 text-[11px] ${
createAssetType === at
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
aria-pressed={createAssetType === at}
>
{t(`balance.snapshot.detailed.picker.assetType.${at}`)}
</button>
))}
</div>
</div>
)}
{totalItems === 0 ? (
<p className="px-3 py-2 text-xs text-[var(--muted-foreground)]">
{t("balance.snapshot.detailed.picker.empty")}
</p>
) : (
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="max-h-56 overflow-auto"
>
{filtered.map((sec, i) => (
<li
key={sec.id}
id={optionId(i)}
role="option"
aria-selected={i === highlightIndex}
onMouseDown={(e) => 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)]"
}`}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className="font-medium">{sec.symbol}</span>
{sec.name && (
<span
className={`truncate text-xs ${
i === highlightIndex
? "text-white/80"
: "text-[var(--muted-foreground)]"
}`}
>
{sec.name}
</span>
)}
</span>
<span
className={`shrink-0 text-[10px] uppercase tracking-wide ${
i === highlightIndex
? "text-white/80"
: "text-[var(--muted-foreground)]"
}`}
>
{t(
`balance.snapshot.detailed.picker.assetType.${sec.asset_type}`
)}
</span>
</li>
))}
{createOption && (
<li
id={optionId(createIndex)}
role="option"
aria-selected={createIndex === highlightIndex}
onMouseDown={(e) => 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,
})}
</li>
)}
</ul>
)}
</div>
)}
</div>
);
}