All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
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
132 lines
4 KiB
TypeScript
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>
|
|
);
|
|
}
|