Simpl-Resultat/src/components/import/ImportReportPanel.tsx
le king fu 7b6f063094
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
fix(import): read amounts with an anchored parser and a real debit/credit rule
Three ways an imported amount could be silently wrong, all of them passing
validation, all of them fixed here.

The two-column rule was `isNaN(credit) ? -debit : credit`, so the credit always
won. Many banks write `0,00` in the unused column rather than leaving it empty,
and `isNaN(0)` is false -- every debit of such a file imported as 0,00 and the
expense simply vanished, with no error anywhere. The rule is `credit - debit` on
magnitudes now, which needs no special case for a `0,00` cell (zero is the
identity of the subtraction) and implements the documented convention even when
an export negates its debits. A row unreadable in BOTH columns is an error
instead of a free 0,00 transaction.

`parseFrenchAmount` ended on `parseFloat`, which returns the longest valid
PREFIX instead of rejecting. Measured before the fix: `"50,00-"` -> 5000,
`"1 234,56 CR"` -> 123456, `"100,00 CAD"` -> 10000. A factor-100 error, and it
passes `isNaN`, so those rows counted as VALID everywhere downstream -- which
would have defeated the signed preview (#329), the safety net of the whole
chantier. Validation is anchored over the whole normalized string now and any
residual character yields NaN.

NaN, not a rescued magnitude, for a trailing `CR`/`DB` or currency code. Two
reasons: `CR`/`DB` carry a DIRECTION, so returning a magnitude for both would
trade a loud failure for a silent SIGN error (the D/C-indicator shape is refused
upstream by design, #328); and `"100,00 CAD"` is structurally identical to
`"2025 Montant"`, so whitelisting a trailing word to rescue the first re-blinds
`detectHeader` on the second. Two accounting forms ARE legitimate and supported:
parentheses `(50,00)` and a trailing sign `50,00-`.

The `?? 0` fallbacks read column 0 -- usually the date -- when the mapping was
incomplete. An unmapped amount column is an explicit row error now, reported
ahead of any per-row problem since it is a format error affecting every row.

`1.234` is 1234 in a French column and 1.234 in an English one, and no rule
applied to that cell ALONE can tell. `detectDecimalSeparator` arbitrates from
the decisive siblings of the column and `parseFrenchAmount` takes the verdict as
an option. Detection deliberately stays out of it: it runs before a column is
known to be an amount column at all, so the verdict is applied where the value
actually becomes a transaction.

The rule itself moves out of the hook as a pure `mapRow(raw, format)` in
`importFormat.ts`. That is what lets the corpus tests run the REAL rule -- the
hand-written mirror in `csvAutoDetect.test.ts` and the static guard pinning five
`parseFilesInternal` expressions are both deleted -- and what stops the
detection score (#328) and the signed preview (#329) each re-implementing it.

Hardening is global: the parser is shared by 11 call sites, 8 in
`csvAutoDetect.ts` and 3 in the holdings CSV import (#245), where a price cell
`150,25 CAD` used to store 15025. It is refused now and `buildDetailedLines`
raises on the empty price. An unreadable QUANTITY was worse -- coerced to 0, so a
zero-value position saved in silence; the draft keeps the offending text instead
and the existing `snapshot_priced_quantity_required` fires.

Row errors become i18n keys (`import.rowErrors.*`) rather than the raw English
literals rendered straight into the preview table, since this adds a
user-visible string. The report table also carries raw exception messages, so
both render sites resolve through `isRowErrorKey` and never feed `t()` anything
that is not ours.

Test churn, per link 1's handoff (update the expectation, drop the marker, never
delete the test): the three `#325` KNOWN DEFECT blocks in `amountParser.test.ts`
flip, plus `unused-column-zero` in `csvAutoDetect.test.ts`. One block tagged
`#328` flips too -- `header-numeric-label`, whose own comment reads "#328 adds a
lexical signal to detectHeader, and #325 anchors the parser [...] either fix
closes this". The anchored parser landed first. The other `#328` blocks
(`debit-credit-reversed`, `absolute-indicator`) and the `#329` block are
verified unchanged. CHANGELOG and docs stay centralized in the last link of the
stack, as the plan specifies.

989 vitest (963 before), tsc + vite build clean, cargo check clean. No DB
migration.

Resolves #325
2026-08-13 13:37:06 -04:00

132 lines
4 KiB
TypeScript

import { useTranslation } from "react-i18next";
import {
CheckCircle,
XCircle,
AlertTriangle,
Tag,
FileText,
} from "lucide-react";
import type { ImportReport } from "../../shared/types";
import { isRowErrorKey } from "../../utils/importFormat";
interface ImportReportPanelProps {
report: ImportReport;
onDone: () => void;
}
export default function ImportReportPanel({
report,
onDone,
}: ImportReportPanelProps) {
const { t } = useTranslation();
const stats = [
{
icon: FileText,
label: t("import.report.totalRows"),
value: report.totalRows,
color: "text-[var(--foreground)]",
},
{
icon: CheckCircle,
label: t("import.report.imported"),
value: report.importedCount,
color: "text-[var(--positive)]",
},
{
icon: AlertTriangle,
label: t("import.report.skippedDuplicates"),
value: report.skippedDuplicates,
color: "text-[var(--accent)]",
},
{
icon: XCircle,
label: t("import.report.errors"),
value: report.errorCount,
color: "text-[var(--negative)]",
},
{
icon: Tag,
label: t("import.report.categorized"),
value: report.categorizedCount,
color: "text-[var(--primary)]",
},
{
icon: Tag,
label: t("import.report.uncategorized"),
value: report.uncategorizedCount,
color: "text-[var(--muted-foreground)]",
},
];
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">
{t("import.report.title")}
</h2>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{stats.map((stat) => (
<div
key={stat.label}
className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]"
>
<div className="flex items-center gap-2 mb-2">
<stat.icon size={16} className={stat.color} />
<span className="text-xs text-[var(--muted-foreground)]">
{stat.label}
</span>
</div>
<p className={`text-2xl font-bold ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
{/* Errors list */}
{report.errors.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2 text-[var(--negative)]">
{t("import.report.errorDetails")}
</h3>
<div className="max-h-48 overflow-y-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--muted)]">
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.report.row")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.report.errorMessage")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{report.errors.map((err, i) => (
<tr key={i}>
<td className="px-3 py-2">{err.rowIndex + 1}</td>
<td className="px-3 py-2 text-[var(--negative)]">
{/* Row errors are i18n keys (#325); an exception message
that reached this list is NOT one and stays verbatim. */}
{isRowErrorKey(err.message) ? t(err.message) : err.message}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Done button */}
<div className="flex justify-center pt-4">
<button
onClick={onDone}
className="px-6 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
{t("import.report.done")}
</button>
</div>
</div>
);
}