Compare commits
No commits in common. "c4edfb0a35b7b772486a7a3bb74c650127e06e4a" and "cbaa9cb6d095c8814817869d6e4bfff4ea7ad264" have entirely different histories.
c4edfb0a35
...
cbaa9cb6d0
10 changed files with 71 additions and 794 deletions
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
### 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).
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
### 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).
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
// 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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -11,10 +11,8 @@ 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";
|
||||||
|
|
||||||
|
|
@ -25,8 +23,6 @@ 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;
|
||||||
|
|
@ -36,12 +32,6 @@ 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;
|
||||||
|
|
@ -52,12 +42,10 @@ 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) {
|
||||||
|
|
@ -112,7 +100,6 @@ 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")
|
||||||
|
|
@ -121,9 +108,6 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,10 @@
|
||||||
// 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.
|
||||||
//
|
//
|
||||||
// #214 turns the detailed variant into the real per-title entry surface: each
|
// This PR (#213) keeps the detailed UI deliberately minimal — it must round-
|
||||||
// sub-row carries a SecurityPicker (autocomplete over `balance_securities` +
|
// trip a converted 1-holding account and not crash; the full add/remove +
|
||||||
// inline creation), quantity, unit_price (+ price fetch), a read-only computed
|
// SecurityPicker UX lands in #214. The symbol field here is a plain text input
|
||||||
// value, a book_cost input, and a live latent-gain figure. The account's value
|
// (the autocomplete picker is #214).
|
||||||
// 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
|
||||||
|
|
@ -28,13 +27,9 @@
|
||||||
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 {
|
import type { BalanceAccountWithCategory } from "../../shared/types";
|
||||||
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;
|
||||||
|
|
@ -46,8 +41,6 @@ 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?: (
|
||||||
|
|
@ -55,8 +48,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -79,11 +70,9 @@ 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";
|
||||||
|
|
@ -137,15 +126,11 @@ 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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -202,47 +187,24 @@ export default function SnapshotLineRow({
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Detailed sub-row — one security position (#214: SecurityPicker + polished
|
// Detailed sub-row — one security position (#213, minimal; full UX in #214).
|
||||||
// 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();
|
||||||
|
|
@ -253,144 +215,79 @@ 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-end gap-2 pl-2 py-1 border-l-2 border-[var(--border)]">
|
<div className="flex flex-wrap items-center gap-2 pl-2 border-l-2 border-[var(--border)]">
|
||||||
{/* Titre — SecurityPicker (autocomplete + inline create) */}
|
<input
|
||||||
<Field label={t("balance.snapshot.detailed.col.title")}>
|
type="text"
|
||||||
<SecurityPicker
|
value={holding.symbol}
|
||||||
securities={securities}
|
onChange={(e) => onFieldChange("symbol", e.target.value)}
|
||||||
value={holding.symbol}
|
disabled={disabled}
|
||||||
assetType={holding.asset_type}
|
placeholder={t("balance.snapshot.detailed.symbolPlaceholder")}
|
||||||
onSelect={onSecurityPick}
|
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"
|
||||||
disabled={disabled}
|
aria-label={t("balance.snapshot.detailed.symbolLabel")}
|
||||||
ariaLabel={t("balance.snapshot.detailed.symbolLabel")}
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</Field>
|
<input
|
||||||
|
type="text"
|
||||||
{/* Quantité */}
|
inputMode="decimal"
|
||||||
<Field label={t("balance.snapshot.detailed.col.quantity")}>
|
value={holding.quantity}
|
||||||
<input
|
onChange={(e) => onFieldChange("quantity", e.target.value)}
|
||||||
type="text"
|
disabled={disabled}
|
||||||
inputMode="decimal"
|
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
|
||||||
value={holding.quantity}
|
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"
|
||||||
onChange={(e) => onFieldChange("quantity", e.target.value)}
|
aria-label={t("balance.snapshot.priced.quantityLabel", {
|
||||||
disabled={disabled}
|
account: label,
|
||||||
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", {
|
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||||||
account: label,
|
×
|
||||||
})}
|
</span>
|
||||||
/>
|
<input
|
||||||
</Field>
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
{/* Cours (unit price) */}
|
value={holding.unit_price}
|
||||||
<Field label={t("balance.snapshot.detailed.col.unitPrice")}>
|
onChange={(e) => onFieldChange("unit_price", e.target.value)}
|
||||||
<input
|
disabled={disabled}
|
||||||
type="text"
|
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
|
||||||
inputMode="decimal"
|
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"
|
||||||
value={holding.unit_price}
|
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
|
||||||
onChange={(e) => onFieldChange("unit_price", e.target.value)}
|
account: label,
|
||||||
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"
|
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||||||
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
|
=
|
||||||
account: label,
|
</span>
|
||||||
})}
|
<input
|
||||||
/>
|
type="text"
|
||||||
</Field>
|
value={computedValue === null ? "" : computedValue.toFixed(2)}
|
||||||
|
readOnly
|
||||||
{/* Valeur (computed, read-only) */}
|
disabled
|
||||||
<Field label={t("balance.snapshot.detailed.col.value")}>
|
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
||||||
<input
|
className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
type="text"
|
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
||||||
value={computedValue === null ? "" : computedValue.toFixed(2)}
|
account: label,
|
||||||
readOnly
|
})}
|
||||||
disabled
|
aria-readonly="true"
|
||||||
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
/>
|
||||||
className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] cursor-not-allowed"
|
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
||||||
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
|
||||||
account: label,
|
|
||||||
})}
|
|
||||||
aria-readonly="true"
|
|
||||||
/>
|
|
||||||
</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 mb-1 rounded text-[var(--muted-foreground)] hover:text-[var(--negative)] hover:bg-[var(--negative)]/10 disabled:opacity-50"
|
className="p-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")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import type {
|
||||||
BalanceAccountWithCategory,
|
BalanceAccountWithCategory,
|
||||||
BalanceAssetType,
|
BalanceAssetType,
|
||||||
BalanceCategory,
|
BalanceCategory,
|
||||||
BalanceSecurity,
|
|
||||||
BalanceSnapshot,
|
BalanceSnapshot,
|
||||||
BalanceSnapshotLine,
|
BalanceSnapshotLine,
|
||||||
BalanceSnapshotHoldingWithSecurity,
|
BalanceSnapshotHoldingWithSecurity,
|
||||||
|
|
@ -43,7 +42,6 @@ import type {
|
||||||
import {
|
import {
|
||||||
listBalanceAccounts,
|
listBalanceAccounts,
|
||||||
listBalanceCategories,
|
listBalanceCategories,
|
||||||
listSecurities,
|
|
||||||
getSnapshotByDate,
|
getSnapshotByDate,
|
||||||
deleteSnapshot,
|
deleteSnapshot,
|
||||||
listLinesBySnapshot,
|
listLinesBySnapshot,
|
||||||
|
|
@ -161,8 +159,6 @@ 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
|
||||||
|
|
@ -198,7 +194,6 @@ 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;
|
||||||
|
|
@ -221,19 +216,6 @@ 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: {
|
||||||
|
|
@ -251,7 +233,6 @@ export function initialState(initialDate: string): State {
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
accounts: [],
|
accounts: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
securities: [],
|
|
||||||
values: {},
|
values: {},
|
||||||
holdings: {},
|
holdings: {},
|
||||||
previousSnapshot: null,
|
previousSnapshot: null,
|
||||||
|
|
@ -290,7 +271,6 @@ 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,
|
||||||
|
|
@ -353,31 +333,6 @@ 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,
|
||||||
|
|
@ -563,10 +518,9 @@ 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, securities] = await Promise.all([
|
const [accounts, categories] = 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[]> = {};
|
||||||
|
|
@ -625,7 +579,6 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
snapshot: existing,
|
snapshot: existing,
|
||||||
accounts,
|
accounts,
|
||||||
categories,
|
categories,
|
||||||
securities,
|
|
||||||
values,
|
values,
|
||||||
holdings,
|
holdings,
|
||||||
previousSnapshot: previous,
|
previousSnapshot: previous,
|
||||||
|
|
@ -683,35 +636,6 @@ 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" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -838,7 +762,6 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
addHolding,
|
addHolding,
|
||||||
removeHolding,
|
removeHolding,
|
||||||
setHoldingField,
|
setHoldingField,
|
||||||
setHoldingSecurity,
|
|
||||||
reset,
|
reset,
|
||||||
prefillFromPrevious,
|
prefillFromPrevious,
|
||||||
save,
|
save,
|
||||||
|
|
|
||||||
|
|
@ -1770,29 +1770,7 @@
|
||||||
"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?",
|
||||||
|
|
|
||||||
|
|
@ -1770,29 +1770,7 @@
|
||||||
"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 ?",
|
||||||
|
|
|
||||||
|
|
@ -206,12 +206,10 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue