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>
388 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|