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>
This commit is contained in:
parent
110ad57e1b
commit
c8c765e2f0
12 changed files with 1248 additions and 11 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
|
||||
- Bilan : un compte détaillé (par titre) peut désormais **importer ses positions depuis un CSV** au lieu d'ajouter chaque titre un à un. Depuis un snapshot, un bouton « Importer un CSV » à côté de « Ajouter un titre » ouvre un sélecteur de fichier ; le séparateur, l'encodage et les colonnes (symbole, quantité et — au besoin — cours et coût d'acquisition) sont détectés automatiquement et ajustables avant l'import. Les lignes partageant un symbole sont fusionnées en une seule position, et un CSV sans colonne de cours importe tout de même les quantités pour récupérer ou saisir le prix ensuite (#245).
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Balance: a detailed (by-security) account can now **import its positions from a CSV** instead of adding each security one by one. From a snapshot, an "Import CSV" button next to "Add a title" opens a file picker; the delimiter, encoding, and columns (symbol, quantity, and — optionally — price and cost basis) are auto-detected and can be adjusted before importing. Rows that share a symbol are merged into one position, and a CSV without a price column still imports the quantities so the price can be fetched or typed afterwards (#245).
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238).
|
||||
|
|
|
|||
351
src/components/balance/HoldingsCsvImportModal.tsx
Normal file
351
src/components/balance/HoldingsCsvImportModal.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
// HoldingsCsvImportModal — bulk-import a detailed account's positions from a
|
||||
// CSV instead of typing them one by one (Issue #245).
|
||||
//
|
||||
// Flow (all frontend — the save path is unchanged):
|
||||
// 1. pick a CSV file (native dialog, reusing the existing `pick_import_file`
|
||||
// Rust command with a CSV filter);
|
||||
// 2. detect its encoding (`detect_encoding`, utf-8 fallback) + read it
|
||||
// (`read_file_content`);
|
||||
// 3. `analyzeHoldingsCsv` preprocesses, detects the delimiter/header and the
|
||||
// symbol / quantity / unit_price / book_cost columns (flexible price
|
||||
// detection: no price column ⇒ mapping stays null);
|
||||
// 4. the user reviews/adjusts the mapping in a small editor + preview;
|
||||
// 5. on confirm we hand the DATA rows + mapping up to the editor hook, which
|
||||
// builds `HoldingDraft`s (FR numbers, duplicate-symbol merge) and merges
|
||||
// them into the account's basket. The user then fetches/types any missing
|
||||
// price and saves through the normal atomic path.
|
||||
//
|
||||
// The component owns the file/parse lifecycle; the actual draft building and
|
||||
// basket merge live in `useSnapshotEditor` so they stay pure + unit-tested.
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Upload, X, AlertTriangle, FileSpreadsheet } from "lucide-react";
|
||||
import {
|
||||
analyzeHoldingsCsv,
|
||||
type HoldingColumnMapping,
|
||||
type HoldingCsvAnalysis,
|
||||
} from "../../utils/csvAutoDetect";
|
||||
import { normalizeSecuritySymbol } from "../../services/balance.service";
|
||||
|
||||
interface Props {
|
||||
accountName: string;
|
||||
/** Called with the parsed DATA rows + the confirmed column mapping. */
|
||||
onImport: (rows: string[][], mapping: HoldingColumnMapping) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Step = "select" | "loading" | "map" | "error";
|
||||
|
||||
const PREVIEW_ROWS = 5;
|
||||
|
||||
export default function HoldingsCsvImportModal({
|
||||
accountName,
|
||||
onImport,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>("select");
|
||||
const [analysis, setAnalysis] = useState<HoldingCsvAnalysis | null>(null);
|
||||
const [mapping, setMapping] = useState<HoldingColumnMapping | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||
|
||||
const pickAndAnalyze = useCallback(async () => {
|
||||
setStep("loading");
|
||||
setErrorMsg("");
|
||||
try {
|
||||
const filePath = await invoke<string | null>("pick_import_file", {
|
||||
filters: [["CSV", ["csv", "txt"]]],
|
||||
});
|
||||
if (!filePath) {
|
||||
// User cancelled the native dialog — return to the select step.
|
||||
setStep("select");
|
||||
return;
|
||||
}
|
||||
|
||||
let encoding = "utf-8";
|
||||
try {
|
||||
encoding = await invoke<string>("detect_encoding", {
|
||||
filePath,
|
||||
});
|
||||
} catch {
|
||||
// fall back to utf-8
|
||||
}
|
||||
|
||||
const content = await invoke<string>("read_file_content", {
|
||||
filePath,
|
||||
encoding,
|
||||
});
|
||||
|
||||
const result = analyzeHoldingsCsv(content);
|
||||
if (!result) {
|
||||
setErrorMsg(t("balance.snapshot.detailed.importCsv.detectError"));
|
||||
setStep("error");
|
||||
return;
|
||||
}
|
||||
setAnalysis(result);
|
||||
setMapping(result.mapping);
|
||||
setStep("map");
|
||||
} catch (e) {
|
||||
setErrorMsg(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("balance.snapshot.detailed.importCsv.readError")
|
||||
);
|
||||
setStep("error");
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const columnOptions = useMemo(() => {
|
||||
if (!analysis) return null;
|
||||
return analysis.headers.map((h, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i}: {h}
|
||||
</option>
|
||||
));
|
||||
}, [analysis]);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!analysis || !mapping) return [];
|
||||
return analysis.rows.slice(0, PREVIEW_ROWS).map((row) => {
|
||||
const symbol = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
|
||||
const qty = (row[mapping.quantity] ?? "").trim();
|
||||
const price =
|
||||
mapping.unit_price !== null
|
||||
? (row[mapping.unit_price] ?? "").trim()
|
||||
: "";
|
||||
const book =
|
||||
mapping.book_cost !== null ? (row[mapping.book_cost] ?? "").trim() : "";
|
||||
return { symbol, qty, price, book };
|
||||
});
|
||||
}, [analysis, mapping]);
|
||||
|
||||
// Count the distinct, non-blank symbols that would be imported (after merge).
|
||||
const importableCount = useMemo(() => {
|
||||
if (!analysis || !mapping) return 0;
|
||||
const seen = new Set<string>();
|
||||
for (const row of analysis.rows) {
|
||||
const s = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
|
||||
if (s) seen.add(s);
|
||||
}
|
||||
return seen.size;
|
||||
}, [analysis, mapping]);
|
||||
|
||||
const handleImport = () => {
|
||||
if (!analysis || !mapping) return;
|
||||
onImport(analysis.rows, mapping);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const selectClass =
|
||||
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]";
|
||||
|
||||
const setMappingField = (
|
||||
field: keyof HoldingColumnMapping,
|
||||
raw: string
|
||||
) => {
|
||||
if (!mapping) return;
|
||||
const parsed = parseInt(raw, 10);
|
||||
const next: number | null =
|
||||
Number.isNaN(parsed) || parsed < 0 ? null : parsed;
|
||||
setMapping({ ...mapping, [field]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileSpreadsheet size={20} className="text-[var(--primary)]" />
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{t("balance.snapshot.detailed.importCsv.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
{t("balance.snapshot.detailed.importCsv.intro", {
|
||||
account: accountName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{(step === "select" || step === "loading") && (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={pickAndAnalyze}
|
||||
disabled={step === "loading"}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{step === "loading"
|
||||
? t("balance.snapshot.detailed.importCsv.loading")
|
||||
: t("balance.snapshot.detailed.importCsv.selectFile")}
|
||||
</button>
|
||||
<p className="text-xs text-[var(--muted-foreground)] text-center">
|
||||
{t("balance.snapshot.detailed.importCsv.hint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "error" && (
|
||||
<div className="py-4">
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("select")}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
||||
>
|
||||
{t("balance.snapshot.detailed.importCsv.retry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "map" && analysis && mapping && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||
{t("balance.snapshot.detailed.importCsv.symbolColumn")}
|
||||
</label>
|
||||
<select
|
||||
value={mapping.symbol}
|
||||
onChange={(e) => setMappingField("symbol", e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{columnOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||
{t("balance.snapshot.detailed.importCsv.quantityColumn")}
|
||||
</label>
|
||||
<select
|
||||
value={mapping.quantity}
|
||||
onChange={(e) => setMappingField("quantity", e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{columnOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||
{t("balance.snapshot.detailed.importCsv.priceColumn")}
|
||||
</label>
|
||||
<select
|
||||
value={mapping.unit_price ?? -1}
|
||||
onChange={(e) => setMappingField("unit_price", e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value={-1}>
|
||||
{t("balance.snapshot.detailed.importCsv.noColumn")}
|
||||
</option>
|
||||
{columnOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
|
||||
{t("balance.snapshot.detailed.importCsv.bookCostColumn")}
|
||||
</label>
|
||||
<select
|
||||
value={mapping.book_cost ?? -1}
|
||||
onChange={(e) => setMappingField("book_cost", e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value={-1}>
|
||||
{t("balance.snapshot.detailed.importCsv.noColumn")}
|
||||
</option>
|
||||
{columnOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mapping.unit_price === null && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] italic">
|
||||
{t("balance.snapshot.detailed.importCsv.noPriceNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Preview of the first rows as they will be imported. */}
|
||||
<div className="rounded-lg border border-[var(--border)] overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-[var(--muted)] text-left">
|
||||
<th className="px-2 py-1.5 font-medium">
|
||||
{t("balance.snapshot.detailed.col.title")}
|
||||
</th>
|
||||
<th className="px-2 py-1.5 font-medium text-right">
|
||||
{t("balance.snapshot.detailed.col.quantity")}
|
||||
</th>
|
||||
<th className="px-2 py-1.5 font-medium text-right">
|
||||
{t("balance.snapshot.detailed.col.unitPrice")}
|
||||
</th>
|
||||
<th className="px-2 py-1.5 font-medium text-right">
|
||||
{t("balance.snapshot.detailed.col.bookCost")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((r, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-t border-[var(--border)] tabular-nums"
|
||||
>
|
||||
<td className="px-2 py-1.5 font-medium">
|
||||
{r.symbol || "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">{r.qty || "—"}</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{r.price || "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">{r.book || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{t("balance.snapshot.detailed.importCsv.summary", {
|
||||
count: importableCount,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={importableCount === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Upload size={14} />
|
||||
{t("balance.snapshot.detailed.importCsv.import")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
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";
|
||||
|
|
@ -42,6 +43,12 @@ interface Props {
|
|||
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;
|
||||
|
|
@ -58,6 +65,7 @@ export default function SnapshotEditor({
|
|||
onRemoveHolding,
|
||||
onHoldingFieldChange,
|
||||
onHoldingSecurityPick,
|
||||
onImportHoldings,
|
||||
disabled,
|
||||
snapshotDate,
|
||||
}: Props) {
|
||||
|
|
@ -124,6 +132,9 @@ export default function SnapshotEditor({
|
|||
onHoldingSecurityPick={(rowId, pick) =>
|
||||
onHoldingSecurityPick(acc.id, rowId, pick)
|
||||
}
|
||||
onImportHoldings={(rows, mapping) =>
|
||||
onImportHoldings(acc.id, rows, mapping)
|
||||
}
|
||||
disabled={disabled}
|
||||
snapshotDate={snapshotDate}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -25,16 +25,18 @@
|
|||
// strings on every change. Numeric validation happens at save time in
|
||||
// `useSnapshotEditor.save`.
|
||||
|
||||
import { ChangeEvent, useMemo } from "react";
|
||||
import { ChangeEvent, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Plus, Trash2, Upload } from "lucide-react";
|
||||
import type {
|
||||
BalanceAccountWithCategory,
|
||||
BalanceSecurity,
|
||||
} from "../../shared/types";
|
||||
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
||||
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
|
||||
import PriceFetchControl from "./PriceFetchControl";
|
||||
import SecurityPicker, { type SecurityPick } from "./SecurityPicker";
|
||||
import HoldingsCsvImportModal from "./HoldingsCsvImportModal";
|
||||
|
||||
interface Props {
|
||||
account: BalanceAccountWithCategory;
|
||||
|
|
@ -57,6 +59,8 @@ interface Props {
|
|||
) => void;
|
||||
/** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */
|
||||
onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void;
|
||||
/** Import a batch of holdings from a CSV into this detailed account (#245). */
|
||||
onImportHoldings?: (rows: string[][], mapping: HoldingColumnMapping) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,9 +88,11 @@ export default function SnapshotLineRow({
|
|||
onRemoveHolding,
|
||||
onHoldingFieldChange,
|
||||
onHoldingSecurityPick,
|
||||
onImportHoldings,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isDetailed = account.kind === "detailed";
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
// Account total across the basket (live as the user types).
|
||||
const detailedTotal = useMemo(() => {
|
||||
|
|
@ -152,15 +158,36 @@ export default function SnapshotLineRow({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddHolding?.()}
|
||||
disabled={disabled}
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
<Plus size={13} />
|
||||
{t("balance.snapshot.detailed.addTitle")}
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddHolding?.()}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
<Plus size={13} />
|
||||
{t("balance.snapshot.detailed.addTitle")}
|
||||
</button>
|
||||
{onImportHoldings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportOpen(true)}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
<Upload size={13} />
|
||||
{t("balance.snapshot.detailed.importCsv.button")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{importOpen && onImportHoldings && (
|
||||
<HoldingsCsvImportModal
|
||||
accountName={account.name}
|
||||
onImport={onImportHoldings}
|
||||
onClose={() => setImportOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,12 @@ import {
|
|||
initialState,
|
||||
makeEmptyHolding,
|
||||
holdingsFromServiceHoldings,
|
||||
holdingsFromCsvRows,
|
||||
buildSimpleLines,
|
||||
buildDetailedLines,
|
||||
type HoldingDraft,
|
||||
} from "./useSnapshotEditor";
|
||||
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
|
||||
import { BalanceServiceError } from "../services/balance.service";
|
||||
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";
|
||||
|
||||
|
|
@ -291,6 +293,222 @@ describe("buildDetailedLines — holdings save path (#213)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("holdingsFromCsvRows — CSV rows → drafts (#245)", () => {
|
||||
const withPrice: HoldingColumnMapping = {
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: 2,
|
||||
book_cost: 3,
|
||||
};
|
||||
|
||||
it("maps rows to drafts, normalizing the symbol and parsing FR numbers", () => {
|
||||
const drafts = holdingsFromCsvRows(
|
||||
[
|
||||
["aapl", "10", "150,25", "1 200,00"],
|
||||
["msft", "5", "300.50", "1400"],
|
||||
],
|
||||
withPrice
|
||||
);
|
||||
expect(drafts).toHaveLength(2);
|
||||
expect(drafts[0].symbol).toBe("AAPL"); // normalized UPPER/TRIM
|
||||
expect(drafts[0].quantity).toBe("10");
|
||||
expect(drafts[0].unit_price).toBe("150.25"); // FR comma decimal
|
||||
expect(drafts[0].book_cost).toBe("1200"); // FR thousands space stripped
|
||||
expect(drafts[1].symbol).toBe("MSFT");
|
||||
expect(drafts[1].unit_price).toBe("300.5");
|
||||
});
|
||||
|
||||
it("leaves unit_price empty when there is no price column (flexible price)", () => {
|
||||
const noPrice: HoldingColumnMapping = {
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: null,
|
||||
book_cost: null,
|
||||
};
|
||||
const [d] = holdingsFromCsvRows([["AAPL", "10"]], noPrice);
|
||||
expect(d.symbol).toBe("AAPL");
|
||||
expect(d.quantity).toBe("10");
|
||||
expect(d.unit_price).toBe(""); // → validated at save (fetch/type later)
|
||||
expect(d.book_cost).toBe("");
|
||||
});
|
||||
|
||||
it("merges duplicate symbols: SUM quantity + book_cost, keep first price", () => {
|
||||
const drafts = holdingsFromCsvRows(
|
||||
[
|
||||
["AAPL", "6", "150.00", "700"],
|
||||
["MSFT", "5", "300.00", "1400"],
|
||||
["AAPL", "4", "151.00", "500"], // second AAPL lot
|
||||
],
|
||||
withPrice
|
||||
);
|
||||
// Two distinct securities → two drafts, no UNIQUE(symbol) violation.
|
||||
expect(drafts).toHaveLength(2);
|
||||
const aapl = drafts.find((d) => d.symbol === "AAPL")!;
|
||||
expect(aapl.quantity).toBe("10"); // 6 + 4
|
||||
expect(aapl.book_cost).toBe("1200"); // 700 + 500
|
||||
expect(aapl.unit_price).toBe("150"); // FIRST non-empty price wins
|
||||
});
|
||||
|
||||
it("skips rows with a blank symbol and defaults asset_type", () => {
|
||||
const drafts = holdingsFromCsvRows(
|
||||
[
|
||||
["", "10", "1", "1"],
|
||||
[" ", "10", "1", "1"],
|
||||
["BTC", "0.5", "1000", "400"],
|
||||
],
|
||||
withPrice,
|
||||
{ defaultAssetType: "crypto" }
|
||||
);
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0].symbol).toBe("BTC");
|
||||
expect(drafts[0].asset_type).toBe("crypto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IMPORT_HOLDINGS reducer — bulk merge into a basket (#245)", () => {
|
||||
const mapping: HoldingColumnMapping = {
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: 2,
|
||||
book_cost: 3,
|
||||
};
|
||||
|
||||
it("appends imported drafts into an empty basket", () => {
|
||||
const drafts = holdingsFromCsvRows(
|
||||
[
|
||||
["AAPL", "10", "150", "1200"],
|
||||
["MSFT", "5", "300", "1400"],
|
||||
],
|
||||
mapping
|
||||
);
|
||||
const s = reducer(base, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: drafts },
|
||||
});
|
||||
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "MSFT"]);
|
||||
expect(s.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("updates an existing symbol in place (keeps its rowId) and appends new ones", () => {
|
||||
const first = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
|
||||
let s = reducer(base, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: first },
|
||||
});
|
||||
const aaplRowId = s.holdings[5][0].rowId;
|
||||
|
||||
const second = holdingsFromCsvRows(
|
||||
[
|
||||
["AAPL", "12", "160", "1300"],
|
||||
["GOOG", "2", "140", "260"],
|
||||
],
|
||||
mapping
|
||||
);
|
||||
s = reducer(s, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: second },
|
||||
});
|
||||
// No duplicate AAPL row — merged in place with a stable rowId.
|
||||
expect(s.holdings[5].filter((h) => h.symbol === "AAPL")).toHaveLength(1);
|
||||
const aapl = s.holdings[5].find((h) => h.symbol === "AAPL")!;
|
||||
expect(aapl.rowId).toBe(aaplRowId);
|
||||
expect(aapl.quantity).toBe("12"); // import wins for quantity
|
||||
expect(aapl.unit_price).toBe("160");
|
||||
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "GOOG"]);
|
||||
});
|
||||
|
||||
it("an import WITHOUT price does not wipe a previously-set price", () => {
|
||||
const priced = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
|
||||
let s = reducer(base, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: priced },
|
||||
});
|
||||
const noPriceMapping: HoldingColumnMapping = {
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: null,
|
||||
book_cost: null,
|
||||
};
|
||||
const repriced = holdingsFromCsvRows([["AAPL", "20"]], noPriceMapping);
|
||||
s = reducer(s, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: repriced },
|
||||
});
|
||||
const aapl = s.holdings[5][0];
|
||||
expect(aapl.quantity).toBe("20"); // qty updated
|
||||
expect(aapl.unit_price).toBe("150"); // price preserved
|
||||
});
|
||||
|
||||
it("produces no duplicate symbols when the batch itself repeats a symbol", () => {
|
||||
// holdingsFromCsvRows already merges within the batch; the reducer is a
|
||||
// second guard. Even if fed pre-merged drafts, the basket stays unique.
|
||||
const drafts = holdingsFromCsvRows(
|
||||
[
|
||||
["AAPL", "3", "150", "450"],
|
||||
["AAPL", "7", "150", "1050"],
|
||||
],
|
||||
mapping
|
||||
);
|
||||
const s = reducer(base, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: drafts },
|
||||
});
|
||||
expect(s.holdings[5]).toHaveLength(1);
|
||||
expect(s.holdings[5][0].quantity).toBe("10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IMPORT_HOLDINGS → buildDetailedLines end-to-end (#245)", () => {
|
||||
const mapping: HoldingColumnMapping = {
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: 2,
|
||||
book_cost: 3,
|
||||
};
|
||||
|
||||
it("imported priced drafts flow through buildDetailedLines to holdings", () => {
|
||||
const drafts = holdingsFromCsvRows(
|
||||
[
|
||||
["AAPL", "10", "150.25", "1200"],
|
||||
["MSFT", "5", "300.50", "1400"],
|
||||
],
|
||||
mapping
|
||||
);
|
||||
const s = reducer(base, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: drafts },
|
||||
});
|
||||
const [line] = buildDetailedLines(s.holdings, new Set([5]));
|
||||
expect(line.holdings).toHaveLength(2);
|
||||
expect(line.holdings![0].symbol).toBe("AAPL");
|
||||
expect(line.holdings![0].unit_price).toBe(150.25);
|
||||
expect(line.holdings![0].book_cost).toBe(1200);
|
||||
// 10 * 150.25 + 5 * 300.50 = 1502.50 + 1502.50 = 3005.
|
||||
expect(line.value).toBe(3005);
|
||||
});
|
||||
|
||||
it("imported drafts WITHOUT price fail save validation until fetched (GOTCHA)", () => {
|
||||
const noPriceMapping: HoldingColumnMapping = {
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: null,
|
||||
book_cost: null,
|
||||
};
|
||||
const drafts = holdingsFromCsvRows([["AAPL", "10"]], noPriceMapping);
|
||||
const s = reducer(base, {
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId: 5, holdings: drafts },
|
||||
});
|
||||
let code: string | null = null;
|
||||
try {
|
||||
buildDetailedLines(s.holdings, new Set([5]));
|
||||
} catch (e) {
|
||||
code = (e as BalanceServiceError).code;
|
||||
}
|
||||
expect(code).toBe("snapshot_priced_unit_price_required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => {
|
||||
it("routes a detailed account through holdings even if its category is simple", () => {
|
||||
// Regression target: the account's OWN kind decides the path. Account 5 is
|
||||
|
|
|
|||
|
|
@ -52,10 +52,13 @@ import {
|
|||
getPreviousSnapshot,
|
||||
getHoldingsForLatestSnapshot,
|
||||
listHoldingsBySnapshotLine,
|
||||
normalizeSecuritySymbol,
|
||||
BalanceServiceError,
|
||||
type SnapshotLineInput,
|
||||
type SnapshotHoldingInput,
|
||||
} from "../services/balance.service";
|
||||
import { parseFrenchAmount } from "../utils/amountParser";
|
||||
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
|
||||
|
||||
export type SnapshotEditorMode = "new" | "edit";
|
||||
|
||||
|
|
@ -152,6 +155,77 @@ export function holdingsFromServiceHoldings(
|
|||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map parsed CSV data rows into holding drafts for a detailed account (Issue
|
||||
* #245 — CSV import). `rows` are DATA rows only (no header); `mapping` gives the
|
||||
* column indices from `analyzeHoldingsCsv`. Behavior:
|
||||
* - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so
|
||||
* an imported title collapses onto the same `balance_securities` row.
|
||||
* - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`).
|
||||
* - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no
|
||||
* price/cost column) the field stays empty; the user fetches/types it later.
|
||||
* - Duplicate symbols WITHIN the CSV are merged into one draft to respect the
|
||||
* UNIQUE(snapshot_line_id, security_id) constraint: quantities and book_costs
|
||||
* are SUMMED (multiple lots of the same title) and the FIRST non-empty price
|
||||
* is kept (the current market price is shared across lots).
|
||||
* Rows with a blank symbol are skipped. Exported for unit tests.
|
||||
*/
|
||||
export function holdingsFromCsvRows(
|
||||
rows: string[][],
|
||||
mapping: HoldingColumnMapping,
|
||||
opts: { defaultAssetType?: BalanceAssetType } = {}
|
||||
): HoldingDraft[] {
|
||||
const defaultAssetType = opts.defaultAssetType ?? "stock";
|
||||
const order: string[] = [];
|
||||
const bySymbol = new Map<
|
||||
string,
|
||||
{ symbol: string; qty: number; book: number | null; price: string }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rawSymbol = (row[mapping.symbol] ?? "").trim();
|
||||
if (!rawSymbol) continue;
|
||||
const symbol = normalizeSecuritySymbol(rawSymbol);
|
||||
if (!symbol) continue;
|
||||
|
||||
const qtyParsed = parseFrenchAmount((row[mapping.quantity] ?? "").trim());
|
||||
const qty = isNaN(qtyParsed) ? 0 : qtyParsed;
|
||||
|
||||
let price = "";
|
||||
if (mapping.unit_price !== null) {
|
||||
const p = parseFrenchAmount((row[mapping.unit_price] ?? "").trim());
|
||||
if (!isNaN(p)) price = String(p);
|
||||
}
|
||||
|
||||
let book: number | null = null;
|
||||
if (mapping.book_cost !== null) {
|
||||
const b = parseFrenchAmount((row[mapping.book_cost] ?? "").trim());
|
||||
if (!isNaN(b)) book = b;
|
||||
}
|
||||
|
||||
const existing = bySymbol.get(symbol);
|
||||
if (existing) {
|
||||
existing.qty += qty;
|
||||
if (book !== null) existing.book = (existing.book ?? 0) + book;
|
||||
if (!existing.price && price) existing.price = price; // first non-empty
|
||||
} else {
|
||||
order.push(symbol);
|
||||
bySymbol.set(symbol, { symbol, qty, book, price });
|
||||
}
|
||||
}
|
||||
|
||||
return order.map((sym) => {
|
||||
const a = bySymbol.get(sym)!;
|
||||
return {
|
||||
...makeEmptyHolding(defaultAssetType),
|
||||
symbol: a.symbol,
|
||||
quantity: String(a.qty),
|
||||
unit_price: a.price,
|
||||
book_cost: a.book !== null ? String(a.book) : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface State {
|
||||
mode: SnapshotEditorMode;
|
||||
/** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
|
||||
|
|
@ -212,6 +286,14 @@ type Action =
|
|||
type: "ADD_HOLDING";
|
||||
payload: { accountId: number; holding: HoldingDraft };
|
||||
}
|
||||
| {
|
||||
// Bulk-merge a batch of imported drafts into a detailed account's basket
|
||||
// (Issue #245 — CSV import). Merges by normalized symbol so the batch
|
||||
// never introduces a duplicate that would violate
|
||||
// UNIQUE(snapshot_line_id, security_id) at save.
|
||||
type: "IMPORT_HOLDINGS";
|
||||
payload: { accountId: number; holdings: HoldingDraft[] };
|
||||
}
|
||||
| { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
|
||||
| {
|
||||
type: "SET_HOLDING_FIELD";
|
||||
|
|
@ -325,6 +407,56 @@ export function reducer(state: State, action: Action): State {
|
|||
isDirty: true,
|
||||
};
|
||||
}
|
||||
case "IMPORT_HOLDINGS": {
|
||||
// Merge the imported batch into the existing basket by NORMALIZED symbol.
|
||||
// A symbol already present is UPDATED in place (keeping its rowId for a
|
||||
// stable React key); a new symbol is appended. This guarantees the basket
|
||||
// never holds two rows for the same security (UNIQUE constraint at save).
|
||||
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||
const result = existing.slice();
|
||||
const indexBySymbol = new Map<string, number>();
|
||||
result.forEach((h, i) => {
|
||||
const key = normalizeSecuritySymbol(h.symbol);
|
||||
if (key) indexBySymbol.set(key, i);
|
||||
});
|
||||
for (const draft of action.payload.holdings) {
|
||||
const key = normalizeSecuritySymbol(draft.symbol);
|
||||
if (!key) continue; // imported rows always carry a symbol; guard anyway
|
||||
const idx = indexBySymbol.get(key);
|
||||
if (idx !== undefined) {
|
||||
const prev = result[idx];
|
||||
result[idx] = {
|
||||
...prev,
|
||||
symbol: draft.symbol,
|
||||
// Keep the existing asset_type: the CSV carries none (the draft only
|
||||
// has the account's default class), while the current row's class
|
||||
// was set deliberately via the picker or a prior entry.
|
||||
security_name: draft.security_name || prev.security_name,
|
||||
quantity: draft.quantity,
|
||||
// Import wins for price/book_cost ONLY when it carries a value, so an
|
||||
// import-without-price never wipes a previously fetched/typed price.
|
||||
unit_price: draft.unit_price !== "" ? draft.unit_price : prev.unit_price,
|
||||
book_cost: draft.book_cost !== "" ? draft.book_cost : prev.book_cost,
|
||||
// A fresh imported price is a manual value → drop stale attribution.
|
||||
price_source:
|
||||
draft.unit_price !== "" ? null : prev.price_source,
|
||||
price_fetched_at:
|
||||
draft.unit_price !== "" ? null : prev.price_fetched_at,
|
||||
};
|
||||
} else {
|
||||
indexBySymbol.set(key, result.length);
|
||||
result.push(draft);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
holdings: {
|
||||
...state.holdings,
|
||||
[action.payload.accountId]: result,
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
}
|
||||
case "REMOVE_HOLDING": {
|
||||
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||
return {
|
||||
|
|
@ -665,6 +797,35 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Import a batch of holdings from parsed CSV rows into a detailed account
|
||||
* (Issue #245). Builds drafts via `holdingsFromCsvRows` (FR numbers, optional
|
||||
* price, duplicate-symbol merge) then dispatches IMPORT_HOLDINGS, which merges
|
||||
* them into the account's basket by symbol. New securities default to the
|
||||
* account's category asset class (else 'stock').
|
||||
*/
|
||||
const importHoldings = useCallback(
|
||||
(accountId: number, rows: string[][], mapping: HoldingColumnMapping) => {
|
||||
const acc = state.accounts.find((a) => a.id === accountId);
|
||||
const drafts = holdingsFromCsvRows(rows, mapping, {
|
||||
defaultAssetType: acc?.category_asset_type ?? "stock",
|
||||
});
|
||||
if (drafts.length === 0) {
|
||||
logInfo("Balance: CSV import produced no holdings (no valid rows)");
|
||||
return 0;
|
||||
}
|
||||
dispatch({
|
||||
type: "IMPORT_HOLDINGS",
|
||||
payload: { accountId, holdings: drafts },
|
||||
});
|
||||
logInfo(
|
||||
`Balance: imported ${drafts.length} holding(s) from CSV into account ${accountId}`
|
||||
);
|
||||
return drafts.length;
|
||||
},
|
||||
[state.accounts]
|
||||
);
|
||||
|
||||
const removeHolding = useCallback((accountId: number, rowId: string) => {
|
||||
dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
|
||||
}, []);
|
||||
|
|
@ -843,6 +1004,7 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
setDate,
|
||||
setLineValue,
|
||||
addHolding,
|
||||
importHoldings,
|
||||
removeHolding,
|
||||
setHoldingField,
|
||||
setHoldingSecurity,
|
||||
|
|
|
|||
|
|
@ -1795,6 +1795,25 @@
|
|||
"stock": "Stock",
|
||||
"crypto": "Crypto"
|
||||
}
|
||||
},
|
||||
"importCsv": {
|
||||
"button": "Import CSV",
|
||||
"title": "Import securities from a CSV",
|
||||
"intro": "Import the positions of \"{{account}}\" from a CSV file (broker statement, export). Columns are detected automatically; adjust them if needed.",
|
||||
"selectFile": "Choose a CSV file",
|
||||
"loading": "Reading file…",
|
||||
"hint": "Accepted formats: .csv, .txt. Delimiter and encoding are detected automatically.",
|
||||
"detectError": "Could not detect columns in this file. Make sure it is a valid securities CSV.",
|
||||
"readError": "Could not read the file.",
|
||||
"retry": "Retry",
|
||||
"symbolColumn": "Symbol column",
|
||||
"quantityColumn": "Quantity column",
|
||||
"priceColumn": "Price column (optional)",
|
||||
"bookCostColumn": "Book cost column (optional)",
|
||||
"noColumn": "— None —",
|
||||
"noPriceNote": "No price column: securities will be imported without a price. You can fetch or type it afterwards.",
|
||||
"summary": "{{count}} security(ies) will be imported.",
|
||||
"import": "Import"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
|
|
|
|||
|
|
@ -1795,6 +1795,25 @@
|
|||
"stock": "Action",
|
||||
"crypto": "Crypto"
|
||||
}
|
||||
},
|
||||
"importCsv": {
|
||||
"button": "Importer un CSV",
|
||||
"title": "Importer des titres depuis un CSV",
|
||||
"intro": "Importez les positions de « {{account}} » depuis un fichier CSV (relevé de courtier, exportation). Les colonnes sont détectées automatiquement ; ajustez-les au besoin.",
|
||||
"selectFile": "Choisir un fichier CSV",
|
||||
"loading": "Lecture du fichier…",
|
||||
"hint": "Formats acceptés : .csv, .txt. Le séparateur et l'encodage sont détectés automatiquement.",
|
||||
"detectError": "Impossible de détecter les colonnes dans ce fichier. Vérifiez qu'il s'agit d'un CSV de titres valide.",
|
||||
"readError": "Impossible de lire le fichier.",
|
||||
"retry": "Réessayer",
|
||||
"symbolColumn": "Colonne du symbole",
|
||||
"quantityColumn": "Colonne de la quantité",
|
||||
"priceColumn": "Colonne du cours (facultatif)",
|
||||
"bookCostColumn": "Colonne du coût d'acquisition (facultatif)",
|
||||
"noColumn": "— Aucune —",
|
||||
"noPriceNote": "Aucune colonne de cours : les titres seront importés sans prix. Vous pourrez le récupérer ou le saisir ensuite.",
|
||||
"summary": "{{count}} titre(s) seront importé(s).",
|
||||
"import": "Importer"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ export default function SnapshotEditPage() {
|
|||
onRemoveHolding={editor.removeHolding}
|
||||
onHoldingFieldChange={editor.setHoldingField}
|
||||
onHoldingSecurityPick={editor.setHoldingSecurity}
|
||||
onImportHoldings={editor.importHoldings}
|
||||
disabled={state.isSaving}
|
||||
snapshotDate={state.snapshotDate}
|
||||
/>
|
||||
|
|
|
|||
123
src/utils/csvAutoDetect.test.ts
Normal file
123
src/utils/csvAutoDetect.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// csvAutoDetect — holdings-CSV detection tests (Issue #245).
|
||||
//
|
||||
// Covers `autoDetectHoldingColumns` (column detection) and `analyzeHoldingsCsv`
|
||||
// (delimiter/header + full analysis). These helpers are pure and DOM-free, in
|
||||
// line with the project's "test the extracted pure pieces" convention. The
|
||||
// numeric parsing + duplicate-merge path lives in `holdingsFromCsvRows`
|
||||
// (tested in useSnapshotEditor.test.ts).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
autoDetectHoldingColumns,
|
||||
analyzeHoldingsCsv,
|
||||
} from "./csvAutoDetect";
|
||||
|
||||
describe("autoDetectHoldingColumns (#245)", () => {
|
||||
it("detects symbol/quantity/price/book_cost from EN headers", () => {
|
||||
const data = [
|
||||
["Symbol", "Quantity", "Price", "Book Cost"],
|
||||
["AAPL", "10", "150.25", "1200.00"],
|
||||
["MSFT", "5", "300.50", "1400.00"],
|
||||
];
|
||||
expect(autoDetectHoldingColumns(data, true)).toEqual({
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: 2,
|
||||
book_cost: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unit_price + book_cost null when the CSV has no such columns", () => {
|
||||
const data = [
|
||||
["Symbol", "Shares"],
|
||||
["AAPL", "10"],
|
||||
["MSFT", "5"],
|
||||
];
|
||||
const m = autoDetectHoldingColumns(data, true)!;
|
||||
expect(m.symbol).toBe(0);
|
||||
expect(m.quantity).toBe(1);
|
||||
expect(m.unit_price).toBeNull();
|
||||
expect(m.book_cost).toBeNull();
|
||||
});
|
||||
|
||||
it("never maps a Value (qty×price) column to price or book_cost", () => {
|
||||
const data = [
|
||||
["Symbol", "Quantity", "Price", "Value"],
|
||||
["AAPL", "10", "150.25", "1502.50"],
|
||||
["MSFT", "4", "300.50", "1202.00"],
|
||||
];
|
||||
const m = autoDetectHoldingColumns(data, true)!;
|
||||
expect(m.symbol).toBe(0);
|
||||
expect(m.quantity).toBe(1);
|
||||
expect(m.unit_price).toBe(2);
|
||||
// The Value column (col 3) must NOT be picked up as the cost basis.
|
||||
expect(m.book_cost).toBeNull();
|
||||
});
|
||||
|
||||
it("detects headerless CSVs positionally (symbol / int qty / decimal price)", () => {
|
||||
const data = [
|
||||
["AAPL", "10", "150.25"],
|
||||
["MSFT", "5", "300.50"],
|
||||
["GOOG", "2", "140.10"],
|
||||
];
|
||||
const m = autoDetectHoldingColumns(data, false)!;
|
||||
expect(m.symbol).toBe(0);
|
||||
expect(m.quantity).toBe(1);
|
||||
expect(m.unit_price).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null when there are no data rows", () => {
|
||||
expect(autoDetectHoldingColumns([], true)).toBeNull();
|
||||
expect(autoDetectHoldingColumns([["Symbol", "Qty"]], true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeHoldingsCsv (#245)", () => {
|
||||
it("parses an EN comma CSV with a header row", () => {
|
||||
const csv = "Symbol,Quantity,Price\nAAPL,10,150.25\nMSFT,5,300.50\n";
|
||||
const a = analyzeHoldingsCsv(csv)!;
|
||||
expect(a.delimiter).toBe(",");
|
||||
expect(a.hasHeader).toBe(true);
|
||||
expect(a.headers).toEqual(["Symbol", "Quantity", "Price"]);
|
||||
expect(a.rows).toEqual([
|
||||
["AAPL", "10", "150.25"],
|
||||
["MSFT", "5", "300.50"],
|
||||
]);
|
||||
expect(a.mapping).toEqual({
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: 2,
|
||||
book_cost: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles FR headers (accents), a semicolon delimiter and FR numbers", () => {
|
||||
const csv =
|
||||
"Symbole;Quantité;Cours;Coût\nAAPL;10;150,25;1 200,00\nMSFT;5;300,50;1 400,00\n";
|
||||
const a = analyzeHoldingsCsv(csv)!;
|
||||
expect(a.delimiter).toBe(";");
|
||||
expect(a.hasHeader).toBe(true);
|
||||
expect(a.mapping).toEqual({
|
||||
symbol: 0,
|
||||
quantity: 1,
|
||||
unit_price: 2,
|
||||
book_cost: 3,
|
||||
});
|
||||
expect(a.rows[0]).toEqual(["AAPL", "10", "150,25", "1 200,00"]);
|
||||
});
|
||||
|
||||
it("returns generated headers + all rows for a headerless CSV", () => {
|
||||
const csv = "AAPL,10,150.25\nMSFT,5,300.50\nGOOG,2,140.10\n";
|
||||
const a = analyzeHoldingsCsv(csv)!;
|
||||
expect(a.hasHeader).toBe(false);
|
||||
expect(a.headers).toEqual(["Col 0", "Col 1", "Col 2"]);
|
||||
expect(a.rows).toHaveLength(3);
|
||||
expect(a.mapping.symbol).toBe(0);
|
||||
expect(a.mapping.quantity).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null on empty / whitespace-only content", () => {
|
||||
expect(analyzeHoldingsCsv("")).toBeNull();
|
||||
expect(analyzeHoldingsCsv(" \n \n")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -556,3 +556,301 @@ function isSparseComplementary(
|
|||
|
||||
return total > 0 && complementary / total >= 0.7;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Holdings CSV detection (Issue #245) — a detailed balance account can import a
|
||||
// CSV of positions instead of typing them one by one. This is a SEPARATE flow
|
||||
// from the transaction import above: `autoDetectConfig` is coupled to the
|
||||
// date/amount transaction model, whereas a holdings CSV maps to
|
||||
// symbol / quantity / unit_price / book_cost. Price + book_cost are OPTIONAL
|
||||
// (flexible price detection): when the CSV has no price column the mapping
|
||||
// leaves it `null` and the holdings import without a price (the user fetches or
|
||||
// types it afterwards). Detection is best-effort and the UI lets the user
|
||||
// adjust every column, so an imperfect guess is always recoverable.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface HoldingColumnMapping {
|
||||
/** Column index of the security symbol/ticker (required). */
|
||||
symbol: number;
|
||||
/** Column index of the quantity held (required). */
|
||||
quantity: number;
|
||||
/** Column index of the unit price, or null when the CSV has no price column. */
|
||||
unit_price: number | null;
|
||||
/** Column index of the acquisition cost basis, or null when absent. */
|
||||
book_cost: number | null;
|
||||
}
|
||||
|
||||
export interface HoldingCsvAnalysis {
|
||||
delimiter: string;
|
||||
hasHeader: boolean;
|
||||
/** Header labels (actual cells when `hasHeader`, else `Col 0`, `Col 1`, …). */
|
||||
headers: string[];
|
||||
/** DATA rows only (the header row, if any, is stripped). */
|
||||
rows: string[][];
|
||||
mapping: HoldingColumnMapping;
|
||||
}
|
||||
|
||||
// Header keyword sets, matched against accent-stripped, alphanumeric-only
|
||||
// header cells. Bilingual (FR default + EN). Order inside each list is priority
|
||||
// order for the primary-keyword pass.
|
||||
const SYMBOL_HEADER_KEYWORDS = ["symbol", "symbole", "ticker", "titre"];
|
||||
const QUANTITY_HEADER_KEYWORDS = [
|
||||
"quantity",
|
||||
"quantite",
|
||||
"qty",
|
||||
"qte",
|
||||
"shares",
|
||||
"actions",
|
||||
"parts",
|
||||
"unites",
|
||||
"units",
|
||||
"nombre",
|
||||
];
|
||||
const PRICE_HEADER_KEYWORDS = [
|
||||
"prixunitaire",
|
||||
"unitprice",
|
||||
"marketprice",
|
||||
"price",
|
||||
"prix",
|
||||
"cours",
|
||||
"cotation",
|
||||
"cloture",
|
||||
"close",
|
||||
];
|
||||
const BOOKCOST_HEADER_KEYWORDS = [
|
||||
"bookcost",
|
||||
"costbasis",
|
||||
"prixderevient",
|
||||
"acquisition",
|
||||
"revient",
|
||||
"cost",
|
||||
"cout",
|
||||
];
|
||||
// Value/market-value columns must never be auto-picked as price or book_cost.
|
||||
const VALUE_HEADER_KEYWORDS = ["value", "valeur", "montant", "marchande"];
|
||||
|
||||
/** Accent-strip + lowercase + keep alphanumerics only (for header matching). */
|
||||
function normalizeHeaderCell(s: string): string {
|
||||
return (s ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first unused column whose normalized header contains one of the
|
||||
* keywords (keywords tried in priority order). `exclude` skips columns whose
|
||||
* header contains any excluded token (e.g. a "value" column for price).
|
||||
*/
|
||||
function matchHeaderColumn(
|
||||
normalizedHeaders: string[],
|
||||
keywords: string[],
|
||||
used: Set<number>,
|
||||
exclude: string[] = []
|
||||
): number | null {
|
||||
for (const kw of keywords) {
|
||||
for (let i = 0; i < normalizedHeaders.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
const h = normalizedHeaders[i];
|
||||
if (!h) continue;
|
||||
if (exclude.some((ex) => h.includes(ex))) continue;
|
||||
if (h.includes(kw)) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pick the shortest-average-length non-numeric, unused text column (symbols
|
||||
* are short tokens; a name/description column is longer). */
|
||||
function pickSymbolColumn(
|
||||
rows: string[][],
|
||||
colCount: number,
|
||||
numericCols: Set<number>,
|
||||
used: Set<number>
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
let bestAvg = Infinity;
|
||||
for (let col = 0; col < colCount; col++) {
|
||||
if (used.has(col) || numericCols.has(col)) continue;
|
||||
let totalLen = 0;
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
const cell = row[col]?.trim();
|
||||
if (!cell) continue;
|
||||
totalLen += cell.length;
|
||||
count++;
|
||||
}
|
||||
if (count === 0) continue;
|
||||
const avg = totalLen / count;
|
||||
if (avg < bestAvg) {
|
||||
bestAvg = avg;
|
||||
best = col;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Pick an unused numeric column, optionally preferring integer-heavy (quantity)
|
||||
* or decimal-heavy (price) columns. Returns null when none remain. */
|
||||
function pickNumericColumn(
|
||||
rows: string[][],
|
||||
numericCols: number[],
|
||||
used: Set<number>,
|
||||
opts: { preferIntegers?: boolean; preferDecimals?: boolean }
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
let bestScore = -1;
|
||||
for (const col of numericCols) {
|
||||
if (used.has(col)) continue;
|
||||
let ints = 0;
|
||||
let decimals = 0;
|
||||
let nonEmpty = 0;
|
||||
for (const row of rows) {
|
||||
const cell = row[col]?.trim();
|
||||
if (!cell) continue;
|
||||
const v = parseFrenchAmount(cell);
|
||||
if (isNaN(v)) continue;
|
||||
nonEmpty++;
|
||||
if (Number.isInteger(v)) ints++;
|
||||
else decimals++;
|
||||
}
|
||||
if (nonEmpty === 0) continue;
|
||||
let score = 1;
|
||||
if (opts.preferIntegers) score = ints / nonEmpty;
|
||||
else if (opts.preferDecimals) score = decimals / nonEmpty;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = col;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the symbol / quantity / unit_price / book_cost columns of a holdings
|
||||
* CSV. `data` is the parsed 2-D array INCLUDING the header row when
|
||||
* `hasHeader`. Returns a best-effort mapping (symbol + quantity always set so
|
||||
* the editor can render), or null only when there is no usable data.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function autoDetectHoldingColumns(
|
||||
data: string[][],
|
||||
hasHeader: boolean
|
||||
): HoldingColumnMapping | null {
|
||||
if (data.length === 0) return null;
|
||||
const colCount = Math.max(...data.map((r) => r.length));
|
||||
if (colCount === 0) return null;
|
||||
const dataRows = hasHeader ? data.slice(1) : data;
|
||||
if (dataRows.length === 0) return null;
|
||||
|
||||
const used = new Set<number>();
|
||||
let symbol: number | null = null;
|
||||
let quantity: number | null = null;
|
||||
let unitPrice: number | null = null;
|
||||
let bookCost: number | null = null;
|
||||
|
||||
// Step 1 — header keyword matching (most reliable when present).
|
||||
if (hasHeader) {
|
||||
const normalized = data[0].map(normalizeHeaderCell);
|
||||
symbol = matchHeaderColumn(normalized, SYMBOL_HEADER_KEYWORDS, used);
|
||||
if (symbol !== null) used.add(symbol);
|
||||
quantity = matchHeaderColumn(normalized, QUANTITY_HEADER_KEYWORDS, used);
|
||||
if (quantity !== null) used.add(quantity);
|
||||
unitPrice = matchHeaderColumn(
|
||||
normalized,
|
||||
PRICE_HEADER_KEYWORDS,
|
||||
used,
|
||||
VALUE_HEADER_KEYWORDS
|
||||
);
|
||||
if (unitPrice !== null) used.add(unitPrice);
|
||||
bookCost = matchHeaderColumn(
|
||||
normalized,
|
||||
BOOKCOST_HEADER_KEYWORDS,
|
||||
used,
|
||||
VALUE_HEADER_KEYWORDS
|
||||
);
|
||||
if (bookCost !== null) used.add(bookCost);
|
||||
}
|
||||
|
||||
// A value / market-value column must never be auto-assigned to a numeric
|
||||
// target by the heuristics below (it is qty × price, not a source column).
|
||||
// Mark such columns used up-front so the fallback pickers skip them.
|
||||
if (hasHeader) {
|
||||
const normalized = data[0].map(normalizeHeaderCell);
|
||||
normalized.forEach((h, i) => {
|
||||
if (!used.has(i) && VALUE_HEADER_KEYWORDS.some((v) => h && h.includes(v))) {
|
||||
used.add(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2 — numeric/text heuristics fill any column the header didn't resolve.
|
||||
const sampleRows = dataRows.slice(0, 20);
|
||||
const numericCols = detectNumericColumns(sampleRows, colCount);
|
||||
const numericSet = new Set(numericCols);
|
||||
|
||||
if (symbol === null) {
|
||||
symbol = pickSymbolColumn(sampleRows, colCount, numericSet, used);
|
||||
if (symbol !== null) used.add(symbol);
|
||||
}
|
||||
if (quantity === null) {
|
||||
quantity = pickNumericColumn(sampleRows, numericCols, used, {
|
||||
preferIntegers: true,
|
||||
});
|
||||
if (quantity !== null) used.add(quantity);
|
||||
}
|
||||
if (unitPrice === null) {
|
||||
unitPrice = pickNumericColumn(sampleRows, numericCols, used, {
|
||||
preferDecimals: true,
|
||||
});
|
||||
if (unitPrice !== null) used.add(unitPrice);
|
||||
}
|
||||
if (bookCost === null) {
|
||||
bookCost = pickNumericColumn(sampleRows, numericCols, used, {});
|
||||
if (bookCost !== null) used.add(bookCost);
|
||||
}
|
||||
|
||||
// symbol + quantity are required; fall back to sane defaults so the editor
|
||||
// always has something to show (the user can correct it).
|
||||
if (symbol === null) symbol = 0;
|
||||
if (quantity === null) quantity = symbol === 0 && colCount > 1 ? 1 : 0;
|
||||
|
||||
return { symbol, quantity, unit_price: unitPrice, book_cost: bookCost };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full holdings-CSV analysis: preprocess quoted lines, detect the delimiter and
|
||||
* header, then the column mapping. Returns the header labels + DATA rows (header
|
||||
* stripped) so the caller can render a mapping editor + preview and feed the
|
||||
* rows to `holdingsFromCsvRows`. Null when the content has no usable rows.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function analyzeHoldingsCsv(
|
||||
rawContent: string
|
||||
): HoldingCsvAnalysis | null {
|
||||
const content = preprocessQuotedCSV(rawContent);
|
||||
const nonEmptyLines = content.split(/\r?\n/).filter((l) => l.trim());
|
||||
if (nonEmptyLines.length === 0) return null;
|
||||
|
||||
const delimiter = detectDelimiter(nonEmptyLines.slice(0, 10));
|
||||
if (!delimiter) return null;
|
||||
|
||||
const parsed = Papa.parse(content, { delimiter, skipEmptyLines: true });
|
||||
const data = (parsed.data as string[][]).filter((r) =>
|
||||
r.some((c) => (c ?? "").trim() !== "")
|
||||
);
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const hasHeader = detectHeader(data[0]);
|
||||
const mapping = autoDetectHoldingColumns(data, hasHeader);
|
||||
if (!mapping) return null;
|
||||
|
||||
const colCount = Math.max(...data.map((r) => r.length));
|
||||
const headers = hasHeader
|
||||
? data[0].map((h, i) => (h ?? "").trim() || `Col ${i}`)
|
||||
: Array.from({ length: colCount }, (_, i) => `Col ${i}`);
|
||||
const rows = hasHeader ? data.slice(1) : data;
|
||||
|
||||
return { delimiter, hasHeader, headers, rows, mapping };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue