Simpl-Resultat/src/pages/ImportPage.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
7.4 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { useImportWizard } from "../hooks/useImportWizard";
import ImportFolderConfig from "../components/import/ImportFolderConfig";
import SourceList from "../components/import/SourceList";
import SourceConfigPanel from "../components/import/SourceConfigPanel";
import FilePreviewTable from "../components/import/FilePreviewTable";
import FormatDriftPanel from "../components/import/FormatDriftPanel";
import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel";
import ImportConfirmation from "../components/import/ImportConfirmation";
import ImportProgress from "../components/import/ImportProgress";
import ImportReportPanel from "../components/import/ImportReportPanel";
import WizardNavigation from "../components/import/WizardNavigation";
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
import { AlertCircle } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
export default function ImportPage() {
const { t } = useTranslation();
const {
state,
browseFolder,
refreshFolder,
selectSource,
updateConfig,
toggleFile,
selectAllFiles,
parseAndPreview,
checkDuplicates,
flipSignConvention,
executeImport,
goToStep,
reset,
adoptDriftFormat,
keepCurrentFormat,
autoDetectConfig,
saveConfigAsTemplate,
applyConfigTemplate,
updateConfigTemplate,
deleteConfigTemplate,
toggleDuplicateRow,
setSkipAllDuplicates,
} = useImportWizard();
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
return (
<div>
<div className="relative flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("import.title")}</h1>
<PageHelp helpKey="import" />
</div>
{/* Error banner */}
{state.error && (
<div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--negative)] flex items-center gap-2">
<AlertCircle size={16} className="text-[var(--negative)] shrink-0" />
{/*
The wizard reports a translation key when it has one (a stored
format it cannot decode) and a raw message otherwise; `defaultValue`
renders the latter unchanged.
*/}
<p className="text-sm text-[var(--foreground)]">
{t(state.error, { defaultValue: state.error })}
</p>
</div>
)}
{/* Folder config - always visible */}
<ImportFolderConfig
folderPath={state.importFolder}
onBrowse={browseFolder}
onRefresh={refreshFolder}
isLoading={state.isLoading}
/>
{/* Wizard steps */}
{state.step === "source-list" && (
<>
<SourceList
sources={state.scannedSources}
configuredSourceNames={state.configuredSourceNames}
importedFileNames={state.importedFilesBySource}
onSelectSource={selectSource}
/>
<ImportHistoryPanel onChanged={refreshFolder} />
</>
)}
{state.step === "source-config" && state.selectedSource && (
<div className="space-y-6">
<SourceConfigPanel
source={state.selectedSource}
config={state.sourceConfig}
selectedFiles={state.selectedFiles}
importedFileNames={state.importedFilesBySource.get(state.selectedSource.folder_name)}
headers={state.previewHeaders}
configTemplates={state.configTemplates}
onConfigChange={updateConfig}
onFileToggle={toggleFile}
onSelectAllFiles={selectAllFiles}
onAutoDetect={autoDetectConfig}
onSaveAsTemplate={saveConfigAsTemplate}
onApplyTemplate={applyConfigTemplate}
onUpdateTemplate={updateConfigTemplate}
onDeleteTemplate={deleteConfigTemplate}
selectedTemplateId={state.selectedTemplateId}
detectionScore={state.detectionScore}
detectedBank={state.detectedBank}
isLoading={state.isLoading}
/>
{/*
One way forward, and it goes through the preview (#329). The pair of
buttons this replaces offered "Aperçu" as an optional detour and
"Vérifier les doublons" as the real path, so the totals were the one
screen an import never had to show.
*/}
<WizardNavigation
onBack={() => goToStep("source-list")}
onNext={parseAndPreview}
onCancel={reset}
nextLabel={t("import.wizard.preview")}
nextDisabled={nextDisabled || state.isLoading}
/>
</div>
)}
{state.step === "file-preview" && (
<div className="space-y-6">
{/*
Format drift (#330) — ABOVE the table, because it tells the user
whether the table below is worth reading. Present only when the
header row moved since the last successful import of this source.
*/}
{state.formatDrift && (
<FormatDriftPanel
entries={state.formatDrift}
canAdopt={state.driftConfig !== null}
onAdopt={adoptDriftFormat}
onKeep={keepCurrentFormat}
isBusy={state.isLoading}
/>
)}
<FilePreviewTable
rows={state.parsedPreview}
onFlipSigns={flipSignConvention}
isFlipping={state.isLoading}
/>
<WizardNavigation
onBack={() => goToStep("source-config")}
onNext={checkDuplicates}
onCancel={reset}
nextLabel={t("import.wizard.checkDuplicates")}
nextDisabled={state.isLoading}
/>
</div>
)}
{state.step === "duplicate-check" && state.duplicateResult && (
<div className="space-y-6">
<DuplicateCheckPanel
result={state.duplicateResult}
excludedIndices={state.excludedDuplicateIndices}
onToggleRow={toggleDuplicateRow}
onSkipAll={() => setSkipAllDuplicates(true)}
onIncludeAll={() => setSkipAllDuplicates(false)}
/>
<WizardNavigation
onBack={() => goToStep("file-preview")}
onNext={() => goToStep("confirm")}
onCancel={reset}
nextLabel={t("import.wizard.confirm")}
/>
</div>
)}
{state.step === "confirm" && state.duplicateResult && (
<div className="space-y-6">
<ImportConfirmation
sourceName={state.sourceConfig.name}
config={state.sourceConfig}
headers={state.previewHeaders}
selectedFiles={state.selectedFiles}
duplicateResult={state.duplicateResult}
excludedCount={state.excludedDuplicateIndices.size}
/>
<WizardNavigation
onBack={() => goToStep("duplicate-check")}
onNext={executeImport}
onCancel={reset}
nextLabel={t("import.wizard.import")}
showCancel={false}
/>
</div>
)}
{state.step === "importing" && (
<ImportProgress
currentFile={state.importProgress.file}
progress={state.importProgress.current}
total={state.importProgress.total}
/>
)}
{state.step === "report" && state.importReport && (
<ImportReportPanel report={state.importReport} onDone={reset} />
)}
</div>
);
}