feat: Format wird beim Dateiwaehlen automatisch erkannt

Waehlt man eine Datei (oder uebernimmt eine vom Telefon), erkennt Certain
Trennzeichen, Dezimalzeichen, Datumsformat und Spaltenzuordnung selbst und
traegt sie ein. Darueber steht, WAS erkannt wurde – "Format erkannt:
Revolut" oder die Spaltenzuordnung im Klartext –, damit man es pruefen kann,
statt ihm blind zu vertrauen.

War etwas nicht eindeutig, steht das ausdruecklich da mit dem Hinweis, die
Zuordnung unten anzusehen. Das Formular bleibt also erreichbar, muss aber im
Normalfall nicht mehr angefasst werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 21:19:46 +02:00
co-authored by Claude Opus 5
parent e6801b0474
commit 3b034dc001
4 changed files with 66 additions and 3 deletions
+1
View File
@@ -174,6 +174,7 @@ export const api = {
request(`/inbox/${id}/category/${categoryId}`, { method: "POST" }),
bookInboxItem: (id) => request(`/inbox/${id}/book`, { method: "POST" }),
bookAllInbox: () => request("/inbox/book-all", { method: "POST" }),
detectFormat: (formData) => request("/imports/detect", { method: "POST", formData }),
listUploads: () => request("/imports/uploads"),
deleteUpload: (id) => request(`/imports/uploads/${id}`, { method: "DELETE" }),
+5 -1
View File
@@ -374,7 +374,11 @@
"usePhoneFile": "Übernehmen",
"phoneFileLoaded": "„{{name}}“ geladen – jetzt Format prüfen und Vorschau erzeugen.",
"discardPhoneFile": "Datei verwerfen",
"discardPhoneFileConfirm": "„{{name}}“ verwerfen? Die Datei wird gelöscht, gebucht war ohnehin nichts."
"discardPhoneFileConfirm": "„{{name}}“ verwerfen? Die Datei wird gelöscht, gebucht war ohnehin nichts.",
"detectedBank": "Format erkannt: {{name}}",
"detectedGeneric": "Format aus der Datei abgeleitet",
"detectFailed": "Format nicht erkannt",
"detectUncertain": "Nicht alles war eindeutig – wirf einen Blick auf die Spaltenzuordnung unten, bevor du die Vorschau erzeugst."
},
"password": {
"show": "Passwort anzeigen",
+5 -1
View File
@@ -374,7 +374,11 @@
"usePhoneFile": "Take over",
"phoneFileLoaded": "“{{name}}” loaded – check the format and build the preview.",
"discardPhoneFile": "Discard file",
"discardPhoneFileConfirm": "Discard “{{name}}”? The file is deleted; nothing was booked anyway."
"discardPhoneFileConfirm": "Discard “{{name}}”? The file is deleted; nothing was booked anyway.",
"detectedBank": "Format detected: {{name}}",
"detectedGeneric": "Format derived from the file",
"detectFailed": "Format not recognised",
"detectUncertain": "Not everything was clear – check the column mapping below before building the preview."
},
"password": {
"show": "Show password",
+55 -1
View File
@@ -60,6 +60,8 @@ export default function Import() {
const [raw, setRaw] = useState(null);
// Dateien, die per Kurzbefehl vom Telefon hereinkamen.
const [uploads, setUploads] = useState([]);
// Ergebnis der automatischen Formaterkennung.
const [erkannt, setErkannt] = useState(null);
const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | ""
const [rows, setRows] = useState(null);
const [recon, setRecon] = useState(null);
@@ -248,6 +250,31 @@ export default function Import() {
// Die abgelieferte Datei so behandeln, als haette man sie hier ausgewaehlt –
// damit gilt der ganze normale Weg samt Vorschau und Saldo-Abgleich.
// Format aus der Datei ableiten, damit niemand Spalten von Hand zuordnet.
// Das war die haeufigste Fehlerquelle: falsches Datumsformat = keine Zeile
// gueltig, falscher Dezimaltrenner = Betraege um Faktor 100 daneben.
async function formatErkennen(f) {
setErkannt(null);
try {
const daten = new FormData();
daten.append("file", f);
const r = await api.detectFormat(daten);
setErkannt(r);
if (r.ok && r.config) {
const c = r.config;
setCfg({
delimiter: c.delimiter, decimal_sep: c.decimal_sep, date_format: c.date_format,
encoding: c.encoding, has_header: c.has_header, skip_rows: c.skip_rows,
amount_mode: c.amount_mode,
column_map: Object.fromEntries(
Object.entries(c.column_map).filter(([, v]) => v !== null && v !== undefined)),
});
}
} catch (e) {
setErkannt({ ok: false, grund: e.message });
}
}
async function uploadUebernehmen(u) {
try {
const antwort = await fetch(`/api/imports/uploads/${u.id}/content`, { credentials: "include" });
@@ -258,6 +285,8 @@ export default function Import() {
setRaw(null);
setRows(null);
setMode("csv");
const datei = new File([blob], u.filename, { type: "text/csv" });
formatErkennen(datei);
toast(t("import.phoneFileLoaded", { name: u.filename }));
} catch (e) {
toast(e.message, "error");
@@ -450,7 +479,11 @@ export default function Import() {
<span>{t("import.file")}</span>
<input ref={fileRef} type="file"
accept={mode === "doc" ? ".pdf,application/pdf,image/*" : ".csv,text/csv,text/plain"}
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); setDocText(""); setDocPages([]); }} />
onChange={(e) => {
const f = e.target.files?.[0] || null;
setFile(f); setRaw(null); setRows(null); setDocText(""); setDocPages([]);
if (f) formatErkennen(f);
}} />
</label>
</div>
{mode === "doc" && (
@@ -656,6 +689,27 @@ export default function Import() {
<p className="muted small">{t("import.extraColsHint")}</p>
</details>
{erkannt && (
<div className={erkannt.ok ? "alert" : "alert error"}
style={{ display: "block", marginBottom: "var(--sp-3)" }}>
<strong>
{erkannt.ok
? (erkannt.preset
? t("import.detectedBank", { name: erkannt.preset })
: t("import.detectedGeneric"))
: t("import.detectFailed")}
</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)" }}>
<button className="btn primary" onClick={loadPreview} disabled={busy}>
<Icon name="chevron-right" size={15} /> {t("import.makePreview")}