feat: Der Import fragt nur noch, was er nicht selbst weiss

Bisher standen alle Einstellungen offen nebeneinander – auch die, die
Certain laengst erkannt hatte. Rund zwanzig Bedienelemente fuer einen
Vorgang, bei dem im Normalfall keine einzige Entscheidung ansteht.

Jetzt: Datei waehlen, Pruefansicht. Dazwischen nichts.

- Format und Spaltenzuordnung klappen ein, solange die Erkennung sicher
  ist. Ein Klick auf "Alle Einstellungen anzeigen" holt sie zurueck –
  samt der Begruendung, WAS erkannt wurde und warum die Vorschau so
  aussieht, wie sie aussieht. Wer nachsehen will, kann; muessen soll
  niemand.
- Die Erkennungs-Meldung steht jetzt unter der Datei, auf die sie sich
  bezieht, statt weit unten zwischen den Spaltenfeldern.
- "Vorschau erstellen" entfaellt. Der Knopf tat genau das, was ohnehin
  als naechstes kommen musste; die Vorschau laeuft jetzt von selbst,
  entprellt, sobald Datum und Betrag zugeordnet sind. Fehlt etwas, steht
  da, was fehlt.
- "Als Vorlage speichern" ist keine primaere Aktion mehr. Das ist Pflege
  und sah aus wie eine Entscheidung, die jetzt ansteht.

Der Zeilenfilter klappt ausdruecklich NICHT mit ein. Revolut legt alle
Pockets in eine Datei; ohne Filter zaehlen die Bewegungen doppelt. Das
ist kein Detail fuer Fortgeschrittene, sondern die Bedingung dafuer,
dass die Zahlen stimmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-30 20:58:38 +02:00
co-authored by Claude Opus 5
parent ea81cef45d
commit 2891270ac2
3 changed files with 103 additions and 39 deletions
+3 -3
View File
@@ -440,7 +440,6 @@
"modeDebitCredit": "Getrennte Soll-/Haben-Spalten", "modeDebitCredit": "Getrennte Soll-/Haben-Spalten",
"modeAbsExpense": "Ein Betrag (immer Ausgabe)", "modeAbsExpense": "Ein Betrag (immer Ausgabe)",
"readFile": "Datei einlesen", "readFile": "Datei einlesen",
"makePreview": "Vorschau erstellen",
"saveTemplate": "Als Vorlage speichern", "saveTemplate": "Als Vorlage speichern",
"templateName": "Vorlagenname", "templateName": "Vorlagenname",
"colDate": "Datum", "colDate": "Datum",
@@ -452,7 +451,6 @@
"colIban": "Gegen-IBAN", "colIban": "Gegen-IBAN",
"makePreviewHint": "Ordne mindestens Datum und Betrag zu.", "makePreviewHint": "Ordne mindestens Datum und Betrag zu.",
"rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.", "rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.",
"makePreviewNote": "„Vorschau erstellen“ zeigt danach ALLE Buchungen zum Prüfen und Buchen.",
"summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).", "summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).",
"setTypeForAll": "Alle gewählten Zeilen:", "setTypeForAll": "Alle gewählten Zeilen:",
"flipAll": "Richtung umdrehen", "flipAll": "Richtung umdrehen",
@@ -510,7 +508,9 @@
"detectedBank": "Format erkannt: {{name}}", "detectedBank": "Format erkannt: {{name}}",
"detectedGeneric": "Format aus der Datei abgeleitet", "detectedGeneric": "Format aus der Datei abgeleitet",
"detectFailed": "Format nicht erkannt", "detectFailed": "Format nicht erkannt",
"detectUncertain": "Nicht alles war eindeutig – wirf einen Blick auf die Spaltenzuordnung unten, bevor du die Vorschau erzeugst." "detectUncertain": "Nicht alles war eindeutig – wirf einen Blick auf die Spaltenzuordnung unten, bevor du die Vorschau erzeugst.",
"showAllSettings": "Alle Einstellungen anzeigen",
"hideAllSettings": "Einstellungen ausblenden"
}, },
"password": { "password": {
"show": "Passwort anzeigen", "show": "Passwort anzeigen",
+3 -3
View File
@@ -440,7 +440,6 @@
"modeDebitCredit": "Separate debit/credit columns", "modeDebitCredit": "Separate debit/credit columns",
"modeAbsExpense": "Single amount (always expense)", "modeAbsExpense": "Single amount (always expense)",
"readFile": "Read file", "readFile": "Read file",
"makePreview": "Build preview",
"saveTemplate": "Save as template", "saveTemplate": "Save as template",
"templateName": "Template name", "templateName": "Template name",
"colDate": "Date", "colDate": "Date",
@@ -452,7 +451,6 @@
"colIban": "Counterparty IBAN", "colIban": "Counterparty IBAN",
"makePreviewHint": "Map at least date and amount.", "makePreviewHint": "Map at least date and amount.",
"rawHint": "Only the first rows – just for mapping the columns.", "rawHint": "Only the first rows – just for mapping the columns.",
"makePreviewNote": "“Build preview” then shows ALL transactions to review and import.",
"summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).", "summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).",
"setTypeForAll": "All selected rows:", "setTypeForAll": "All selected rows:",
"flipAll": "Flip direction", "flipAll": "Flip direction",
@@ -510,7 +508,9 @@
"detectedBank": "Format detected: {{name}}", "detectedBank": "Format detected: {{name}}",
"detectedGeneric": "Format derived from the file", "detectedGeneric": "Format derived from the file",
"detectFailed": "Format not recognised", "detectFailed": "Format not recognised",
"detectUncertain": "Not everything was clear – check the column mapping below before building the preview." "detectUncertain": "Not everything was clear – check the column mapping below before building the preview.",
"showAllSettings": "Show all settings",
"hideAllSettings": "Hide settings"
}, },
"password": { "password": {
"show": "Show password", "show": "Show password",
+97 -33
View File
@@ -77,6 +77,8 @@ export default function Import() {
const [erkannt, setErkannt] = useState(null); const [erkannt, setErkannt] = useState(null);
// 0 = noch nichts gebucht, also auch nichts zu zeigen. // 0 = noch nichts gebucht, also auch nichts zu zeigen.
const [uebertragsLauf, setUebertragsLauf] = useState(0); const [uebertragsLauf, setUebertragsLauf] = useState(0);
// Hat der Nutzer die vollstaendige Ansicht ausdruecklich geoeffnet?
const [alleEinstellungen, setAlleEinstellungen] = useState(false);
const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | "" const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | ""
const [rows, setRows] = useState(null); const [rows, setRows] = useState(null);
const [recon, setRecon] = useState(null); const [recon, setRecon] = useState(null);
@@ -106,6 +108,27 @@ export default function Import() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [file, mode, config.delimiter, config.encoding, config.has_header, config.skip_rows]); }, [file, mode, config.delimiter, config.encoding, config.has_header, config.skip_rows]);
// Auch die Pruefansicht laeuft von selbst, sobald Datum und Betrag zugeordnet
// sind. Der Knopf „Vorschau erstellen" war eine Pflichtuebung: Er tat genau
// das, was ohnehin als naechstes kommen musste.
//
// Entprellt, weil jede Aenderung an „Zeilen ueberspringen" sonst eine
// Anfrage samt Dateiuebertragung ausloest.
const vorschauBereit = mode === "csv" && !!file && !!accountId && !!raw
&& config.column_map.date != null
&& (config.amount_mode === "debit_credit"
? (config.column_map.debit != null || config.column_map.credit != null)
: config.column_map.amount != null);
useEffect(() => {
if (!vorschauBereit) return;
const id = setTimeout(() => { loadPreview(); }, 500);
return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vorschauBereit, raw, accountId, config.decimal_sep, config.date_format,
config.amount_mode, config.filter_column, config.filter_value,
JSON.stringify(config.column_map)]);
function setCfg(patch) { setConfig((c) => ({ ...c, ...patch })); } function setCfg(patch) { setConfig((c) => ({ ...c, ...patch })); }
function setCol(field, value) { function setCol(field, value) {
setConfig((c) => ({ ...c, column_map: { ...c.column_map, [field]: value === "" ? null : Number(value) } })); setConfig((c) => ({ ...c, column_map: { ...c.column_map, [field]: value === "" ? null : Number(value) } }));
@@ -219,6 +242,9 @@ export default function Import() {
n: batch.imported_count, u: batch.uebertraege_verknuepft }) n: batch.imported_count, u: batch.uebertraege_verknuepft })
: t("import.imported", { n: batch.imported_count })); : t("import.imported", { n: batch.imported_count }));
setRows(null); setRecon(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]); setRows(null); setRecon(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]);
// Auch die Erkennungs-Meldung: Sie bezoege sich sonst auf eine Datei,
// die nicht mehr gewaehlt ist.
setErkannt(null); setAlleEinstellungen(false);
if (fileRef.current) fileRef.current.value = ""; if (fileRef.current) fileRef.current.value = "";
loadStamm(); loadStamm();
// Uebertraege sind nach einem Import am wahrscheinlichsten – eine // Uebertraege sind nach einem Import am wahrscheinlichsten – eine
@@ -406,6 +432,11 @@ export default function Import() {
const acc = accountById[Number(accountId)]; const acc = accountById[Number(accountId)];
// Sicher erkannt heisst: Der Nutzer muss nichts einstellen. Dann stehen
// Format und Spaltenzuordnung nicht offen im Weg – erreichbar bleiben sie.
const sicherErkannt = !!erkannt?.ok && erkannt.sicherheit === "hoch";
const zeigeEinstellungen = !sicherErkannt || alleEinstellungen;
const reviewColumns = useMemo(() => [ const reviewColumns = useMemo(() => [
{ {
key: "include", header: "", width: 42, fixed: true, key: "include", header: "", width: 42, fixed: true,
@@ -592,6 +623,7 @@ export default function Import() {
onChange={(e) => { onChange={(e) => {
const f = e.target.files?.[0] || null; const f = e.target.files?.[0] || null;
setFile(f); setRaw(null); setRows(null); setDocText(""); setDocPages([]); setFile(f); setRaw(null); setRows(null); setDocText(""); setDocPages([]);
setAlleEinstellungen(false);
if (f) formatErkennen(f); if (f) formatErkennen(f);
}} /> }} />
</label> </label>
@@ -604,6 +636,43 @@ export default function Import() {
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.docHint")}</p> <p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.docHint")}</p>
</> </>
)} )}
{/* Was erkannt wurde, gehoert hierher – direkt unter die Datei, auf
die es sich bezieht. Vorher stand es weit unten zwischen den
Spaltenfeldern, wo es niemand suchte. */}
{mode === "csv" && erkannt && (
<div className={erkannt.ok ? "alert" : "alert error"}
style={{ display: "block", marginTop: "var(--sp-3)" }}>
<strong>
{erkannt.ok
? (erkannt.preset
? t("import.detectedBank", { name: erkannt.preset })
: t("import.detectedGeneric"))
: t("import.detectFailed")}
</strong>
{/* Im Normalfall genuegt „erkannt". Wer wissen will, WAS erkannt
wurde und warum es so aussieht, wie es aussieht, klappt auf –
und findet dort auch jedes Feld zum Aendern. */}
{(!sicherErkannt || alleEinstellungen) && (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{erkannt.begruendung || erkannt.grund}
</p>
)}
{erkannt.ok && erkannt.sicherheit !== "hoch" && (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{t("import.detectUncertain")}
</p>
)}
{sicherErkannt && (
<button type="button" className="btn sm ghost"
style={{ marginTop: "var(--sp-2)" }}
onClick={() => setAlleEinstellungen((v) => !v)}>
<Icon name={alleEinstellungen ? "chevron-up" : "chevron-down"} size={14} />
{" "}
{alleEinstellungen ? t("import.hideAllSettings") : t("import.showAllSettings")}
</button>
)}
</div>
)}
<div className="row" style={{ display: mode === "csv" ? undefined : "none" }}> <div className="row" style={{ display: mode === "csv" ? undefined : "none" }}>
<label className="grow"> <label className="grow">
<span>{t("import.templateAny")}</span> <span>{t("import.templateAny")}</span>
@@ -624,8 +693,9 @@ export default function Import() {
</div> </div>
</div> </div>
{/* Schritt 2: Format (nur CSV) */} {/* Schritt 2: Format (nur CSV) – eingeklappt, solange nichts einzustellen
{mode === "csv" && ( ist. Erreichbar ueber „Alle Einstellungen anzeigen" in Schritt 1. */}
{mode === "csv" && zeigeEinstellungen && (
<div className="card"> <div className="card">
<h2>{t("import.step2")}</h2> <h2>{t("import.step2")}</h2>
<div className="row"> <div className="row">
@@ -704,9 +774,13 @@ export default function Import() {
</div> </div>
)} )}
{/* Schritt 3: Spaltenzuordnung */} {/* Schritt 3: Spaltenzuordnung.
{raw && ( Die Karte bleibt stehen, wenn ein Zeilenfilter noetig sein koennte –
der darf NIE mit eingeklappt werden: Revolut legt alle Pockets in
eine Datei, und ohne Filter zaehlen die Bewegungen doppelt. */}
{raw && (zeigeEinstellungen || filterSpalten.length > 0) && (
<div className="card"> <div className="card">
{zeigeEinstellungen && (<>
<h2>{t("import.step3")}</h2> <h2>{t("import.step3")}</h2>
<p className="muted small">{t("import.rawHint")}</p> <p className="muted small">{t("import.rawHint")}</p>
<div className="table-scroll"> <div className="table-scroll">
@@ -741,6 +815,8 @@ export default function Import() {
))} ))}
</div> </div>
</>)}
{/* Revolut legt alle Produkte in EINE Datei. Ohne Filter zaehlen die {/* Revolut legt alle Produkte in EINE Datei. Ohne Filter zaehlen die
Bewegungen doppelt – deshalb steht der Filter offen, sobald die Bewegungen doppelt – deshalb steht der Filter offen, sobald die
Datei eine Spalte mit wenigen wiederkehrenden Werten hat. */} Datei eine Spalte mit wenigen wiederkehrenden Werten hat. */}
@@ -777,6 +853,7 @@ export default function Import() {
</div> </div>
)} )}
{zeigeEinstellungen && (<>
<details className="import-more"> <details className="import-more">
<summary className="muted small">{t("import.moreColumns")}</summary> <summary className="muted small">{t("import.moreColumns")}</summary>
<p className="muted small">{t("import.ibanHint")}</p> <p className="muted small">{t("import.ibanHint")}</p>
@@ -800,37 +877,24 @@ export default function Import() {
<p className="muted small">{t("import.extraColsHint")}</p> <p className="muted small">{t("import.extraColsHint")}</p>
</details> </details>
{erkannt && ( {/* Eine Vorlage zu speichern ist Pflege, nicht der naechste Schritt.
<div className={erkannt.ok ? "alert" : "alert error"} Als primaere Aktion stand sie neben „Vorschau erstellen" und sah
style={{ display: "block", marginBottom: "var(--sp-3)" }}> aus wie eine Entscheidung, die jetzt ansteht. */}
<strong> {/* Ohne den Knopf „Vorschau erstellen" muss gesagt werden, warum
{erkannt.ok noch nichts kommt. Sonst wartet man auf etwas, das nie laeuft. */}
? (erkannt.preset {!vorschauBereit && (
? t("import.detectedBank", { name: erkannt.preset }) <p className="muted small" style={{ marginTop: "var(--sp-3)" }}>
: t("import.detectedGeneric")) {t("import.makePreviewHint")}
: t("import.detectFailed")} </p>
</strong> )}
<p className="muted small" style={{ margin: "4px 0 0" }}>
{erkannt.begruendung || erkannt.grund}
</p>
{erkannt.ok && erkannt.sicherheit !== "hoch" && (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{t("import.detectUncertain")}
</p>
)}
</div>
)}
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}> <div className="input-with-button"
<button className="btn primary" onClick={loadPreview} disabled={busy}> style={{ maxWidth: 320, marginTop: "var(--sp-3)" }}>
<Icon name="chevron-right" size={15} /> {t("import.makePreview")} <input placeholder={t("import.templateName")} value={saveName}
</button> onChange={(e) => setSaveName(e.target.value)} />
<div className="input-with-button" style={{ maxWidth: 320 }}> <button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
<input placeholder={t("import.templateName")} value={saveName} onChange={(e) => setSaveName(e.target.value)} />
<button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
</div>
</div> </div>
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.makePreviewNote")}</p> </>)}
</div> </div>
)} )}