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