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