Simpl-Resultat/src/components/import/FilePreviewTable.tsx
le king fu c9872fc36b
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
feat(import): recognise known bank layouts and report format drift
Two failure modes, both anchored on the header row.

A KNOWN BANK IS NOW READ BY NAME. `bankSignatures.ts` declares the
documented export layout of Desjardins, RBC, Banque Nationale and
Tangerine as a set of normalized header labels plus a delimiter and a
preamble quirk. The table is evaluated BEFORE the generic dictionary and
an unknown file falls straight through to it, unchanged.

The signatures are not decorative. The generic dictionary matches
keywords as substrings, one role at a time, which reads two of these
four layouts wrong — both frozen as counterfactual test pairs, same
rows, header renamed:

  - Tangerine writes `Date,Transaction,Name,Memo,Amount`. `Transaction`
    is a description keyword, so every row of the file was labelled with
    its direction word instead of the merchant.
  - RBC writes its amount column `CAD$`, which no amount keyword
    matches, next to a nearly empty `Cheque Number`. Those two are
    sparse-complementary, so the shape scan paired them as debit/credit
    and the one row carrying a cheque number imported as -247.95 instead
    of -6.95.

A signature stays a set of PREFERENCES all the same: they are written
from documented layouts, without real statements, so every hint is
dropped the moment the data contradicts it. The single exception is the
amount mode, which outranks the sparse-complementary scan — nothing
inside an RBC file can tell that pair from a genuine one — and even that
is refused unless the declared columns are candidates the shape scan
proposed. Failing degrades to the generic path; it never breaks.

FORMAT DRIFT IS NOW REPORTED INSTEAD OF IMPORTED. Every successful
import records the normalized labels of its header row in
`import_sources.header_signature`, as a JSON array and not a hash: the
panel has to be able to name the columns that moved. On the next import,
a header that normalizes differently opens a `FormatDriftPanel` above
the preview — column by column, `Montant : 3 -> 4` — with the two
outcomes that exist: adopt the re-detected format, or keep the stored
one. A cosmetic rename (`Montant` -> `MONTANT ($)`) normalizes
identically and says nothing.

A source whose file has no header row keeps `header_signature` NULL and
drift detection is inoperative on it. Documented, not worked around:
a signature invented from the data would fire on every import.

THE REPAIR PATH IS NOW IN THE INTERFACE, in the drift panel and beside
the preview's sign flip. `findDuplicates` matches on date AND
description AND amount, so re-importing a file "now that it reads right"
does not correct the rows already written — it doubles them, and a
flipped sign produces mirror pairs that net to zero in every report. The
only safe path is deleting the faulty import from the history first.

The drift re-detection reuses `detectFormatForFile`, so there is still
exactly one detector; the static guard on its caller count moves from
two to three deliberately. Its score and bank badge are dropped straight
after: they measure the format the panel offers, not the one in use.

Resolves #330
2026-08-13 14:54:46 -04:00

209 lines
8 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { AlertCircle, ArrowDownLeft, ArrowUpRight, RefreshCw } from "lucide-react";
import type { ParsedRow } from "../../shared/types";
import { isRowErrorKey, summarizeParsedRows } from "../../utils/importFormat";
import RepairPathNotice from "./RepairPathNotice";
/** Rows rendered in the table. The recap above it always covers the whole file. */
const DISPLAYED_ROWS = 20;
interface FilePreviewTableProps {
/** ALL parsed rows — the recap is meaningless on a truncated sample. */
rows: ParsedRow[];
onFlipSigns?: () => void;
isFlipping?: boolean;
}
export default function FilePreviewTable({
rows,
onFlipSigns,
isFlipping = false,
}: FilePreviewTableProps) {
const { t, i18n } = useTranslation();
if (rows.length === 0) {
return (
<div className="text-center py-8 text-[var(--muted-foreground)]">
{t("import.preview.noData")}
</div>
);
}
const totals = summarizeParsedRows(rows);
const displayedRows = rows.slice(0, DISPLAYED_ROWS);
const currency = new Intl.NumberFormat(
i18n.language === "fr" ? "fr-CA" : "en-CA",
{ style: "currency", currency: "CAD" }
);
// Row errors are i18n keys (#325); anything else reaches us verbatim.
const errorText = (error: string) =>
isRowErrorKey(error) ? t(error) : error;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{t("import.preview.title")}
</h2>
<div className="flex items-center gap-4 text-sm">
<span className="text-[var(--muted-foreground)]">
{t("import.preview.rowCount", { count: rows.length })}
</span>
{totals.errorCount > 0 && (
<span className="flex items-center gap-1 text-[var(--negative)]">
<AlertCircle size={14} />
{t("import.preview.errorCount", { count: totals.errorCount })}
</span>
)}
</div>
</div>
{/*
The signed recap (#329) — the last control before the database, and the
only one that looks at what the amounts MEAN. A statement showing zero
inflows next to a payroll line is wrong on its face, whatever produced
it. Computed over every row, never over the twenty displayed below.
*/}
<div className="mb-4 p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 flex-1 min-w-[16rem]">
<div>
<p className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<ArrowDownLeft size={14} className="text-[var(--negative)]" />
{t("import.preview.outflowCount", { count: totals.outflowCount })}
</p>
<p className="font-mono text-sm text-[var(--negative)]">
{currency.format(totals.outflowTotal)}
</p>
</div>
<div>
<p className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<ArrowUpRight size={14} className="text-[var(--positive)]" />
{t("import.preview.inflowCount", { count: totals.inflowCount })}
</p>
<p className="font-mono text-sm text-[var(--positive)]">
{currency.format(totals.inflowTotal)}
</p>
</div>
<div>
<p className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<AlertCircle size={14} />
{t("import.preview.errorRows")}
</p>
<p
className={`font-mono text-sm ${
totals.errorCount > 0
? "text-[var(--negative)]"
: "text-[var(--muted-foreground)]"
}`}
>
{totals.errorCount}
</p>
</div>
</div>
{onFlipSigns && (
<div className="text-right max-w-xs">
<button
onClick={onFlipSigns}
disabled={isFlipping}
className="flex items-center gap-1 px-3 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw size={14} />
{t("import.preview.flipSigns")}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("import.preview.flipSignsHint")}
</p>
</div>
)}
</div>
{/*
The repair path (#330), stated where the correction is offered. A user
who flips the signs here has just learned that earlier imports of the
same source read backwards; re-importing those files is the natural
next move and it double-books every row instead of fixing them.
*/}
<div className="mt-4">
<RepairPathNotice />
</div>
</div>
<div className="overflow-x-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)]">
#
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.date")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.description")}
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.amount")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.raw")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{displayedRows.map((row) => (
<tr
key={row.rowIndex}
className={
row.error
? "bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))]"
: "hover:bg-[var(--muted)]"
}
>
<td className="px-3 py-2 text-[var(--muted-foreground)]">
{row.rowIndex + 1}
</td>
<td className="px-3 py-2">
{row.parsed?.date || (
<span className="text-[var(--negative)] text-xs">
{row.error ? errorText(row.error) : "—"}
</span>
)}
</td>
<td className="px-3 py-2 max-w-xs truncate">
{row.parsed?.description || "—"}
</td>
<td className="px-3 py-2 text-right font-mono">
{row.parsed?.amount != null
? row.parsed.amount.toFixed(2)
: "—"}
</td>
<td className="px-3 py-2 text-xs text-[var(--muted-foreground)] max-w-xs truncate">
<span className="inline-flex gap-0">
{row.raw.map((cell, i) => (
<span key={i}>
{i > 0 && <span className="text-[var(--border)] mx-0.5">{'·'}</span>}
{cell}
</span>
))}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length > displayedRows.length && (
<p className="text-sm text-[var(--muted-foreground)] text-center mt-4">
{t("import.preview.moreRows", {
count: rows.length - displayedRows.length,
})}
</p>
)}
</div>
);
}