Compare commits

...

2 commits

Author SHA1 Message Date
c4edfb0a35 Merge pull request 'feat(balance): multi-security entry UI + SecurityPicker (#214)' (#223) from issue-214-ui-multi-titres into main 2026-06-10 01:07:53 +00:00
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
10 changed files with 794 additions and 71 deletions

View file

@ -4,6 +4,7 @@
### Ajouté ### 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 : 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 : **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). - 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).

View file

@ -4,6 +4,7 @@
### Added ### 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: 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: **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). - 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).

View file

@ -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" });
});
});

View file

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

View file

@ -11,8 +11,10 @@ import { useTranslation } from "react-i18next";
import type { import type {
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceCategory, BalanceCategory,
BalanceSecurity,
} from "../../shared/types"; } from "../../shared/types";
import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
import type { SecurityPick } from "./SecurityPicker";
import SnapshotLineRow from "./SnapshotLineRow"; import SnapshotLineRow from "./SnapshotLineRow";
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel"; import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
@ -23,6 +25,8 @@ interface Props {
values: Record<number, string>; values: Record<number, string>;
/** account_id → holdings basket (detailed accounts, #213). */ /** account_id → holdings basket (detailed accounts, #213). */
holdings: Record<number, HoldingDraft[]>; holdings: Record<number, HoldingDraft[]>;
/** Securities catalogue for the SecurityPicker autocomplete (#214). */
securities: BalanceSecurity[];
onValueChange: (accountId: number, next: string) => void; onValueChange: (accountId: number, next: string) => void;
onAddHolding: (accountId: number, assetType?: "stock" | "crypto") => void; onAddHolding: (accountId: number, assetType?: "stock" | "crypto") => void;
onRemoveHolding: (accountId: number, rowId: string) => void; onRemoveHolding: (accountId: number, rowId: string) => void;
@ -32,6 +36,12 @@ interface Props {
field: keyof Omit<HoldingDraft, "rowId">, field: keyof Omit<HoldingDraft, "rowId">,
value: string value: string
) => void; ) => void;
/** Apply a SecurityPicker selection to a holding row (#214). */
onHoldingSecurityPick: (
accountId: number,
rowId: string,
pick: SecurityPick
) => void;
disabled?: boolean; disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */ /** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
snapshotDate?: string; snapshotDate?: string;
@ -42,10 +52,12 @@ export default function SnapshotEditor({
categories, categories,
values, values,
holdings, holdings,
securities,
onValueChange, onValueChange,
onAddHolding, onAddHolding,
onRemoveHolding, onRemoveHolding,
onHoldingFieldChange, onHoldingFieldChange,
onHoldingSecurityPick,
disabled, disabled,
snapshotDate, snapshotDate,
}: Props) { }: Props) {
@ -100,6 +112,7 @@ export default function SnapshotEditor({
account={acc} account={acc}
value={values[acc.id] ?? ""} value={values[acc.id] ?? ""}
holdings={holdings[acc.id]} holdings={holdings[acc.id]}
securities={securities}
onChange={(next) => onValueChange(acc.id, next)} onChange={(next) => onValueChange(acc.id, next)}
onAddHolding={() => onAddHolding={() =>
onAddHolding(acc.id, acc.category_asset_type ?? "stock") onAddHolding(acc.id, acc.category_asset_type ?? "stock")
@ -108,6 +121,9 @@ export default function SnapshotEditor({
onHoldingFieldChange={(rowId, field, value) => onHoldingFieldChange={(rowId, field, value) =>
onHoldingFieldChange(acc.id, rowId, field, value) onHoldingFieldChange(acc.id, rowId, field, value)
} }
onHoldingSecurityPick={(rowId, pick) =>
onHoldingSecurityPick(acc.id, rowId, pick)
}
disabled={disabled} disabled={disabled}
snapshotDate={snapshotDate} snapshotDate={snapshotDate}
/> />

View file

@ -14,10 +14,11 @@
// converted every former-priced account into `kind='detailed'` with one // converted every former-priced account into `kind='detailed'` with one
// holding, so those accounts now flow through the detailed (holdings) path. // holding, so those accounts now flow through the detailed (holdings) path.
// //
// This PR (#213) keeps the detailed UI deliberately minimal — it must round- // #214 turns the detailed variant into the real per-title entry surface: each
// trip a converted 1-holding account and not crash; the full add/remove + // sub-row carries a SecurityPicker (autocomplete over `balance_securities` +
// SecurityPicker UX lands in #214. The symbol field here is a plain text input // inline creation), quantity, unit_price (+ price fetch), a read-only computed
// (the autocomplete picker is #214). // 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 // 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 // (the editor stores raw strings to preserve partial input) and emits new
@ -27,9 +28,13 @@
import { ChangeEvent, useMemo } from "react"; import { ChangeEvent, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react"; 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 type { HoldingDraft } from "../../hooks/useSnapshotEditor";
import PriceFetchControl from "./PriceFetchControl"; import PriceFetchControl from "./PriceFetchControl";
import SecurityPicker, { type SecurityPick } from "./SecurityPicker";
interface Props { interface Props {
account: BalanceAccountWithCategory; account: BalanceAccountWithCategory;
@ -41,6 +46,8 @@ interface Props {
onChange: (next: string) => void; onChange: (next: string) => void;
/** Detailed variant: the holdings basket + mutators (#213). */ /** Detailed variant: the holdings basket + mutators (#213). */
holdings?: HoldingDraft[]; holdings?: HoldingDraft[];
/** Securities catalogue for the SecurityPicker autocomplete (#214). */
securities?: BalanceSecurity[];
onAddHolding?: () => void; onAddHolding?: () => void;
onRemoveHolding?: (rowId: string) => void; onRemoveHolding?: (rowId: string) => void;
onHoldingFieldChange?: ( onHoldingFieldChange?: (
@ -48,6 +55,8 @@ interface Props {
field: keyof Omit<HoldingDraft, "rowId">, field: keyof Omit<HoldingDraft, "rowId">,
value: string value: string
) => void; ) => 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, disabled,
snapshotDate, snapshotDate,
holdings, holdings,
securities,
onAddHolding, onAddHolding,
onRemoveHolding, onRemoveHolding,
onHoldingFieldChange, onHoldingFieldChange,
onHoldingSecurityPick,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const isDetailed = account.kind === "detailed"; const isDetailed = account.kind === "detailed";
@ -126,11 +137,15 @@ export default function SnapshotLineRow({
holding={h} holding={h}
accountName={account.name} accountName={account.name}
accountCurrency={account.currency} accountCurrency={account.currency}
securities={securities ?? []}
snapshotDate={snapshotDate} snapshotDate={snapshotDate}
disabled={disabled} disabled={disabled}
onFieldChange={(field, v) => onFieldChange={(field, v) =>
onHoldingFieldChange?.(h.rowId, field, v) onHoldingFieldChange?.(h.rowId, field, v)
} }
onSecurityPick={(pick) =>
onHoldingSecurityPick?.(h.rowId, pick)
}
onRemove={() => onRemoveHolding?.(h.rowId)} 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 (
<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({ function HoldingSubRow({
holding, holding,
accountName, accountName,
accountCurrency, accountCurrency,
securities,
snapshotDate, snapshotDate,
disabled, disabled,
onFieldChange, onFieldChange,
onSecurityPick,
onRemove, onRemove,
}: { }: {
holding: HoldingDraft; holding: HoldingDraft;
accountName: string; accountName: string;
accountCurrency: string; accountCurrency: string;
securities: BalanceSecurity[];
snapshotDate?: string; snapshotDate?: string;
disabled?: boolean; disabled?: boolean;
onFieldChange: (field: keyof Omit<HoldingDraft, "rowId">, value: string) => void; onFieldChange: (field: keyof Omit<HoldingDraft, "rowId">, value: string) => void;
onSecurityPick: (pick: SecurityPick) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -215,20 +253,39 @@ function HoldingSubRow({
return qty * price; return qty * price;
}, [holding.quantity, holding.unit_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 label = holding.symbol || accountName;
const fmt2 = (n: number) =>
n.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return ( return (
<div className="flex flex-wrap items-center gap-2 pl-2 border-l-2 border-[var(--border)]"> <div className="flex flex-wrap items-end gap-2 pl-2 py-1 border-l-2 border-[var(--border)]">
<input {/* Titre — SecurityPicker (autocomplete + inline create) */}
type="text" <Field label={t("balance.snapshot.detailed.col.title")}>
<SecurityPicker
securities={securities}
value={holding.symbol} value={holding.symbol}
onChange={(e) => onFieldChange("symbol", e.target.value)} assetType={holding.asset_type}
onSelect={onSecurityPick}
disabled={disabled} disabled={disabled}
placeholder={t("balance.snapshot.detailed.symbolPlaceholder")} ariaLabel={t("balance.snapshot.detailed.symbolLabel")}
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"
/> />
</Field>
{/* Quantité */}
<Field label={t("balance.snapshot.detailed.col.quantity")}>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
@ -241,9 +298,10 @@ function HoldingSubRow({
account: label, account: label,
})} })}
/> />
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline"> </Field>
×
</span> {/* Cours (unit price) */}
<Field label={t("balance.snapshot.detailed.col.unitPrice")}>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
@ -256,9 +314,10 @@ function HoldingSubRow({
account: label, account: label,
})} })}
/> />
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline"> </Field>
=
</span> {/* Valeur (computed, read-only) */}
<Field label={t("balance.snapshot.detailed.col.value")}>
<input <input
type="text" type="text"
value={computedValue === null ? "" : computedValue.toFixed(2)} value={computedValue === null ? "" : computedValue.toFixed(2)}
@ -271,23 +330,67 @@ function HoldingSubRow({
})} })}
aria-readonly="true" aria-readonly="true"
/> />
<span className="text-xs text-[var(--muted-foreground)] w-10"> </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} {accountCurrency}
</span> </span>
{holding.symbol && ( {holding.symbol && (
<div className="pb-0.5">
<PriceFetchControl <PriceFetchControl
symbol={holding.symbol} symbol={holding.symbol}
date={snapshotDate ?? ""} date={snapshotDate ?? ""}
categoryKind={"priced"} categoryKind={"priced"}
assetType={holding.asset_type} assetType={holding.asset_type}
onPriceFetched={(price) => onFieldChange("unit_price", String(price))} onPriceFetched={(price) =>
onFieldChange("unit_price", String(price))
}
/> />
</div>
)} )}
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
disabled={disabled} disabled={disabled}
className="p-1 rounded text-[var(--muted-foreground)] hover:text-[var(--negative)] hover:bg-[var(--negative)]/10 disabled:opacity-50" 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")} title={t("balance.snapshot.detailed.removeTitle")}
aria-label={t("balance.snapshot.detailed.removeTitle")} aria-label={t("balance.snapshot.detailed.removeTitle")}
> >

View file

@ -35,6 +35,7 @@ import type {
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceAssetType, BalanceAssetType,
BalanceCategory, BalanceCategory,
BalanceSecurity,
BalanceSnapshot, BalanceSnapshot,
BalanceSnapshotLine, BalanceSnapshotLine,
BalanceSnapshotHoldingWithSecurity, BalanceSnapshotHoldingWithSecurity,
@ -42,6 +43,7 @@ import type {
import { import {
listBalanceAccounts, listBalanceAccounts,
listBalanceCategories, listBalanceCategories,
listSecurities,
getSnapshotByDate, getSnapshotByDate,
deleteSnapshot, deleteSnapshot,
listLinesBySnapshot, listLinesBySnapshot,
@ -159,6 +161,8 @@ interface State {
accounts: BalanceAccountWithCategory[]; accounts: BalanceAccountWithCategory[];
/** Used to group lines by category in the editor view. */ /** Used to group lines by category in the editor view. */
categories: BalanceCategory[]; categories: BalanceCategory[];
/** Securities catalogue powering the SecurityPicker autocomplete (#214). */
securities: BalanceSecurity[];
/** /**
* Map of account_id string-typed value (simple accounts only). We keep * Map of account_id string-typed value (simple accounts only). We keep
* strings to preserve empty / partial input; conversion to number happens * strings to preserve empty / partial input; conversion to number happens
@ -194,6 +198,7 @@ type Action =
snapshot: BalanceSnapshot | null; snapshot: BalanceSnapshot | null;
accounts: BalanceAccountWithCategory[]; accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[]; categories: BalanceCategory[];
securities: BalanceSecurity[];
values: Record<number, string>; values: Record<number, string>;
holdings: Record<number, HoldingDraft[]>; holdings: Record<number, HoldingDraft[]>;
previousSnapshot: BalanceSnapshot | null; previousSnapshot: BalanceSnapshot | null;
@ -216,6 +221,19 @@ type Action =
value: string; value: string;
}; };
} }
| {
// Apply a SecurityPicker selection: sets symbol + asset_type (+ name) in
// one dispatch and drops any stale fetched-price attribution, since the
// symbol — and thus the price's subject — has changed (#214).
type: "SET_HOLDING_SECURITY";
payload: {
accountId: number;
rowId: string;
symbol: string;
asset_type: BalanceAssetType;
security_name: string;
};
}
| { | {
type: "PREFILL"; type: "PREFILL";
payload: { payload: {
@ -233,6 +251,7 @@ export function initialState(initialDate: string): State {
snapshot: null, snapshot: null,
accounts: [], accounts: [],
categories: [], categories: [],
securities: [],
values: {}, values: {},
holdings: {}, holdings: {},
previousSnapshot: null, previousSnapshot: null,
@ -271,6 +290,7 @@ export function reducer(state: State, action: Action): State {
snapshot: action.payload.snapshot, snapshot: action.payload.snapshot,
accounts: action.payload.accounts, accounts: action.payload.accounts,
categories: action.payload.categories, categories: action.payload.categories,
securities: action.payload.securities,
values: action.payload.values, values: action.payload.values,
holdings: action.payload.holdings, holdings: action.payload.holdings,
previousSnapshot: action.payload.previousSnapshot, previousSnapshot: action.payload.previousSnapshot,
@ -333,6 +353,31 @@ export function reducer(state: State, action: Action): State {
isDirty: true, isDirty: true,
}; };
} }
case "SET_HOLDING_SECURITY": {
const existing = state.holdings[action.payload.accountId] ?? [];
const next = existing.map((h) =>
h.rowId === action.payload.rowId
? {
...h,
symbol: action.payload.symbol,
asset_type: action.payload.asset_type,
security_name: action.payload.security_name,
// The previously-fetched price (if any) was for the OLD symbol;
// drop its attribution so save doesn't mis-credit the source.
price_source: null,
price_fetched_at: null,
}
: h
);
return {
...state,
holdings: {
...state.holdings,
[action.payload.accountId]: next,
},
isDirty: true,
};
}
case "PREFILL": case "PREFILL":
return { return {
...state, ...state,
@ -518,9 +563,10 @@ export function useSnapshotEditor(options: Options = {}) {
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
const targetDate = date && date.length > 0 ? date : todayISO(); const targetDate = date && date.length > 0 ? date : todayISO();
try { try {
const [accounts, categories] = await Promise.all([ const [accounts, categories, securities] = await Promise.all([
listBalanceAccounts(), listBalanceAccounts(),
listBalanceCategories(), listBalanceCategories(),
listSecurities(),
]); ]);
const values: Record<number, string> = {}; const values: Record<number, string> = {};
const holdings: Record<number, HoldingDraft[]> = {}; const holdings: Record<number, HoldingDraft[]> = {};
@ -579,6 +625,7 @@ export function useSnapshotEditor(options: Options = {}) {
snapshot: existing, snapshot: existing,
accounts, accounts,
categories, categories,
securities,
values, values,
holdings, holdings,
previousSnapshot: previous, previousSnapshot: previous,
@ -636,6 +683,35 @@ export function useSnapshotEditor(options: Options = {}) {
[] []
); );
/**
* Apply a SecurityPicker selection to a holding row (#214): sets symbol +
* asset_type + name in one shot. `name` is empty for a freshly-created
* symbol; an existing security carries its catalogue name.
*/
const setHoldingSecurity = useCallback(
(
accountId: number,
rowId: string,
pick: {
symbol: string;
asset_type: BalanceAssetType;
name: string | null;
}
) => {
dispatch({
type: "SET_HOLDING_SECURITY",
payload: {
accountId,
rowId,
symbol: pick.symbol,
asset_type: pick.asset_type,
security_name: pick.name ?? "",
},
});
},
[]
);
const reset = useCallback(() => { const reset = useCallback(() => {
dispatch({ type: "RESET" }); dispatch({ type: "RESET" });
}, []); }, []);
@ -762,6 +838,7 @@ export function useSnapshotEditor(options: Options = {}) {
addHolding, addHolding,
removeHolding, removeHolding,
setHoldingField, setHoldingField,
setHoldingSecurity,
reset, reset,
prefillFromPrevious, prefillFromPrevious,
save, save,

View file

@ -1770,7 +1770,29 @@
"addTitle": "Add a title", "addTitle": "Add a title",
"removeTitle": "Remove this title", "removeTitle": "Remove this title",
"symbolLabel": "Security symbol", "symbolLabel": "Security symbol",
"symbolPlaceholder": "Symbol" "symbolPlaceholder": "Symbol",
"bookCostLabel": "Cost basis for {{account}}",
"bookCostPlaceholder": "0.00",
"latentGainLabel": "Unrealized gain for {{account}}",
"latentGainNA": "—",
"col": {
"title": "Security",
"quantity": "Quantity",
"unitPrice": "Price",
"value": "Value",
"bookCost": "Cost basis",
"latentGain": "Unrealized gain"
},
"picker": {
"placeholder": "Symbol (e.g. AAPL, BTC)",
"empty": "Type a symbol to create it.",
"create": "Create \"{{symbol}}\"",
"assetTypeLabel": "Type:",
"assetType": {
"stock": "Stock",
"crypto": "Crypto"
}
}
}, },
"delete": { "delete": {
"title": "Delete this snapshot?", "title": "Delete this snapshot?",

View file

@ -1770,7 +1770,29 @@
"addTitle": "Ajouter un titre", "addTitle": "Ajouter un titre",
"removeTitle": "Retirer ce titre", "removeTitle": "Retirer ce titre",
"symbolLabel": "Symbole du titre", "symbolLabel": "Symbole du titre",
"symbolPlaceholder": "Symbole" "symbolPlaceholder": "Symbole",
"bookCostLabel": "Coût d'acquisition pour {{account}}",
"bookCostPlaceholder": "0,00",
"latentGainLabel": "Gain latent pour {{account}}",
"latentGainNA": "—",
"col": {
"title": "Titre",
"quantity": "Quantité",
"unitPrice": "Cours",
"value": "Valeur",
"bookCost": "Coût d'acquisition",
"latentGain": "Gain latent"
},
"picker": {
"placeholder": "Symbole (ex. AAPL, BTC)",
"empty": "Tapez un symbole pour le créer.",
"create": "Créer « {{symbol}} »",
"assetTypeLabel": "Type :",
"assetType": {
"stock": "Action",
"crypto": "Crypto"
}
}
}, },
"delete": { "delete": {
"title": "Supprimer ce snapshot ?", "title": "Supprimer ce snapshot ?",

View file

@ -206,10 +206,12 @@ export default function SnapshotEditPage() {
categories={state.categories} categories={state.categories}
values={state.values} values={state.values}
holdings={state.holdings} holdings={state.holdings}
securities={state.securities}
onValueChange={editor.setLineValue} onValueChange={editor.setLineValue}
onAddHolding={editor.addHolding} onAddHolding={editor.addHolding}
onRemoveHolding={editor.removeHolding} onRemoveHolding={editor.removeHolding}
onHoldingFieldChange={editor.setHoldingField} onHoldingFieldChange={editor.setHoldingField}
onHoldingSecurityPick={editor.setHoldingSecurity}
disabled={state.isSaving} disabled={state.isSaving}
snapshotDate={state.snapshotDate} snapshotDate={state.snapshotDate}
/> />