Simpl-Resultat/src/components/import/ColumnMappingEditor.tsx
le king fu 7a604e0e0d
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m48s
fix(import): persist the import format and restore it faithfully
The root bug of this chantier. `import_sources` carried no `amount_mode` and no
`sign_convention` until v17, so restoring a configured source re-inferred the
mode from `mapping.debitAmount !== undefined` and wrote
`signConvention: "negative_expense"` outright (useImportWizard.ts:321-323). A
credit-card statement configured for positive expenses came back on the default
convention at its second import and `parseFilesInternal` negated every amount:
expenses landed as income, with no error shown anywhere. The format is a read
value now, not a guessed one.

Two types and a codec, not one composed type. The four carriers are
structurally incompatible -- `ImportSource.has_header` is declared boolean,
`ImportConfigTemplate.has_header` is a number, `SourceConfig` is camelCase on a
parsed mapping -- so the guarantee cannot come from a shared shape. It comes
from `src/utils/importFormat.ts` being the single conversion point between
`ImportFormatRow` (persisted: snake_case, mapping as JSON, `has_header`
normalized to 0/1) and `ImportFormat` (domain), and from its completeness test.

That test is enforced on two levels, and both were mutation-checked:
`FORMAT_FIELD_PAIRS` is typed `Record<keyof ImportFormat, keyof
ImportFormatRow>`, so a field added to the format fails to BUILD until it is
listed; the test then compares each codec's real output keys against that table,
so a field listed but not wired fails the TEST. Dropping `sign_convention` from
`formatToRow` -- the shape of the original bug -- fails 14 tests.

`formatFromRow` validates rather than falls back. The v17 CHECK admits
`absolute_indicator` so the third amount mode ships without another migration,
but the app cannot map one: falling through to the `single` branch would read
the wrong column for every row, and anything other than `positive_expense`
would silently mean `negative_expense`. It raises an `ImportFormatError`
carrying an i18n key, and the wizard opens on a fresh configuration so
"reconfigure this source" stays an action the user can actually take.

Also here:

- The config write moves from `checkDuplicatesInternal` to `executeImport`, so
  an import abandoned at the duplicate step leaves no configuration behind. It
  is the only write point in the hook and a guard test holds that.
- Switching amount mode prunes the abandoned mode's columns, so the mode owns
  the mapping rather than the reverse. The column the `<select>` merely displays
  is deliberately not materialized -- #325 turns an unmapped amount column into
  an explicit row error, and writing a 0 here would make it unreachable. The
  mode and the pruned mapping land in ONE state update: the panel's handlers
  each spread the same `config` prop, so two calls would see the same stale
  value.
- `template_id` is recorded and restored as provenance only, never re-read as
  format. `selectedTemplateId` is no longer blanked on every source selection.
  An acceptance test rewrites a template end to end and asserts the linked
  source reads identically, plus a non-vacuity check that the template really
  changed.
- Both template writers go through the codec too, so a new format field cannot
  reach one table and miss the other.

`parseFilesInternal` is untouched: link 1's static guard on its five pinned
expressions still passes. 39 new tests (963 vitest total, was 924), build clean,
`cargo check` clean, no migration.

Resolves #324
2026-08-13 13:16:52 -04:00

176 lines
5.5 KiB
TypeScript

import { useTranslation } from "react-i18next";
import type { ColumnMapping, AmountMode } from "../../shared/types";
import { clearMappingForMode } from "../../utils/importFormat";
interface ColumnMappingEditorProps {
headers: string[];
mapping: ColumnMapping;
amountMode: AmountMode;
onMappingChange: (mapping: ColumnMapping) => void;
/**
* The mode carries the pruned mapping with it. Both have to land in a single
* state update: the parent's handlers each spread the same `config` prop, so
* two consecutive calls would see the same stale value and the second would
* overwrite the first.
*/
onAmountModeChange: (mode: AmountMode, mapping: ColumnMapping) => void;
}
export default function ColumnMappingEditor({
headers,
mapping,
amountMode,
onMappingChange,
onAmountModeChange,
}: ColumnMappingEditorProps) {
const { t } = useTranslation();
// The mode is the source of truth for the mapping, not the reverse: leaving a
// mode drops its columns so a stale key cannot keep deciding how amounts are
// read (#324).
const selectMode = (mode: AmountMode) =>
onAmountModeChange(mode, clearMappingForMode(mapping, mode));
const columnOptions = headers.map((h, i) => (
<option key={i} value={i}>
{i}: {h}
</option>
));
const selectClass =
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]";
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{t("import.config.columnMapping")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.dateColumn")}
</label>
<select
value={mapping.date}
onChange={(e) =>
onMappingChange({ ...mapping, date: parseInt(e.target.value) })
}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.descriptionColumn")}
</label>
<select
value={mapping.description}
onChange={(e) =>
onMappingChange({
...mapping,
description: parseInt(e.target.value),
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.amountMode")}
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="amountMode"
value="single"
checked={amountMode === "single"}
onChange={() => selectMode("single")}
className="accent-[var(--primary)]"
/>
{t("import.config.singleAmount")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="amountMode"
value="debit_credit"
checked={amountMode === "debit_credit"}
onChange={() => selectMode("debit_credit")}
className="accent-[var(--primary)]"
/>
{t("import.config.debitCredit")}
</label>
</div>
</div>
{amountMode === "single" ? (
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.amountColumn")}
</label>
<select
value={mapping.amount ?? 0}
onChange={(e) =>
onMappingChange({
...mapping,
amount: parseInt(e.target.value),
debitAmount: undefined,
creditAmount: undefined,
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.debitColumn")}
</label>
<select
value={mapping.debitAmount ?? 0}
onChange={(e) =>
onMappingChange({
...mapping,
debitAmount: parseInt(e.target.value),
amount: undefined,
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.creditColumn")}
</label>
<select
value={mapping.creditAmount ?? 0}
onChange={(e) =>
onMappingChange({
...mapping,
creditAmount: parseInt(e.target.value),
amount: undefined,
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
</div>
)}
</div>
);
}