Simpl-Resultat/src/components/balance/SnapshotEditor.tsx
le king fu c8c765e2f0
All checks were successful
PR Check / rust (pull_request) Successful in 22m45s
PR Check / frontend (pull_request) Successful in 2m25s
feat(balance): CSV import of holdings in detailed snapshot
A detailed (by-security) account can now import its positions from a CSV
instead of adding each security one by one. An "Import CSV" button next to
"Add a title" opens a native picker; the delimiter, encoding and columns
(symbol, quantity, optional price + book_cost) are auto-detected via new
csvAutoDetect helpers and adjustable in a small mapping editor. Rows sharing
a symbol are merged (SUM qty + book_cost, first price) and the batch is merged
into the basket by normalized symbol, so no UNIQUE(snapshot_line_id,
security_id) violation can occur at save. A CSV without a price column imports
quantities with an empty unit_price (fetch/type later) — the existing atomic
save path is unchanged (no SQL/Rust change).

- csvAutoDetect: autoDetectHoldingColumns + analyzeHoldingsCsv (+ tests)
- useSnapshotEditor: holdingsFromCsvRows + IMPORT_HOLDINGS action + importHoldings (+ tests)
- HoldingsCsvImportModal: file pick -> parse -> mapping editor + preview
- i18n FR/EN under balance.snapshot.detailed.importCsv.*
- CHANGELOG (Added / Ajouté)

Resolves #245

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:04:12 -04:00

147 lines
5 KiB
TypeScript

// SnapshotEditor — groups the active accounts by balance category and
// renders one `SnapshotLineRow` per account.
//
// Each row dispatches its variant on the account's OWN `account.kind` (#213)
// inside `SnapshotLineRow` (simple → scalar value; detailed → holdings basket).
// The editor itself only carries the values/holdings down and the change
// handlers up.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type {
BalanceAccountWithCategory,
BalanceCategory,
BalanceSecurity,
} from "../../shared/types";
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
import type { SecurityPick } from "./SecurityPicker";
import SnapshotLineRow from "./SnapshotLineRow";
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
interface Props {
accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[];
/** account_id → string-typed value (simple accounts). */
values: Record<number, string>;
/** account_id → holdings basket (detailed accounts, #213). */
holdings: Record<number, HoldingDraft[]>;
/** Securities catalogue for the SecurityPicker autocomplete (#214). */
securities: BalanceSecurity[];
onValueChange: (accountId: number, next: string) => void;
onAddHolding: (accountId: number, assetType?: "stock" | "crypto") => void;
onRemoveHolding: (accountId: number, rowId: string) => void;
onHoldingFieldChange: (
accountId: number,
rowId: string,
field: keyof Omit<HoldingDraft, "rowId">,
value: string
) => void;
/** Apply a SecurityPicker selection to a holding row (#214). */
onHoldingSecurityPick: (
accountId: number,
rowId: string,
pick: SecurityPick
) => void;
/** Import a batch of holdings from a CSV into a detailed account (#245). */
onImportHoldings: (
accountId: number,
rows: string[][],
mapping: HoldingColumnMapping
) => void;
disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
snapshotDate?: string;
}
export default function SnapshotEditor({
accounts,
categories,
values,
holdings,
securities,
onValueChange,
onAddHolding,
onRemoveHolding,
onHoldingFieldChange,
onHoldingSecurityPick,
onImportHoldings,
disabled,
snapshotDate,
}: Props) {
const { t } = useTranslation();
// Group accounts by their category, preserving the categories' sort_order
// first then the account name within each group.
const groups = useMemo(() => {
const byCategory = new Map<number, BalanceAccountWithCategory[]>();
for (const acc of accounts) {
const list = byCategory.get(acc.balance_category_id) ?? [];
list.push(acc);
byCategory.set(acc.balance_category_id, list);
}
const sortedCategories = [...categories].sort(
(a, b) => a.sort_order - b.sort_order || a.key.localeCompare(b.key)
);
return sortedCategories
.map((cat) => ({
category: cat,
accounts: (byCategory.get(cat.id) ?? []).sort((a, b) =>
a.name.localeCompare(b.name)
),
}))
.filter((group) => group.accounts.length > 0);
}, [accounts, categories]);
if (accounts.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
{t("balance.snapshot.editor.empty")}
</div>
);
}
return (
<div className="flex flex-col gap-4">
{groups.map(({ category, accounts: catAccounts }) => (
<div
key={category.id}
className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"
>
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
<h3 className="text-sm font-semibold">
{renderCategoryLabelFromCategory(category, t)}
</h3>
</div>
<div className="px-4">
{catAccounts.map((acc) => (
<SnapshotLineRow
key={acc.id}
account={acc}
value={values[acc.id] ?? ""}
holdings={holdings[acc.id]}
securities={securities}
onChange={(next) => onValueChange(acc.id, next)}
onAddHolding={() =>
onAddHolding(acc.id, acc.category_asset_type ?? "stock")
}
onRemoveHolding={(rowId) => onRemoveHolding(acc.id, rowId)}
onHoldingFieldChange={(rowId, field, value) =>
onHoldingFieldChange(acc.id, rowId, field, value)
}
onHoldingSecurityPick={(rowId, pick) =>
onHoldingSecurityPick(acc.id, rowId, pick)
}
onImportHoldings={(rows, mapping) =>
onImportHoldings(acc.id, rows, mapping)
}
disabled={disabled}
snapshotDate={snapshotDate}
/>
))}
</div>
</div>
))}
</div>
);
}