feat: Buchen-Knopf auch unter der Prüfliste + Verlauf aufräumen
- Bei mehreren hundert Zeilen ist der Knopf oben längst aus dem Bild gescrollt; unter der Liste steht jetzt derselbe. - Rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen. Nur diese: solange ein Import steht, ist sein Eintrag die einzige Möglichkeit, seine Buchungen wieder loszuwerden – der Server lehnt das sonst ab. Test für beide Fälle des neuen Endpunkts; 92 grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
from ..deps import get_current_user, get_owned
|
||||||
from ..models import Account, AmountMode, ImportBatch, ImportTemplate, User
|
from ..models import Account, AmountMode, ImportBatch, ImportStatus, ImportTemplate, User
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
ImportBatchOut,
|
ImportBatchOut,
|
||||||
ImportCommitIn,
|
ImportCommitIn,
|
||||||
@@ -160,3 +160,21 @@ def undo(batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_
|
|||||||
batch = get_owned(db, ImportBatch, batch_id, user)
|
batch = get_owned(db, ImportBatch, batch_id, user)
|
||||||
deleted = csv_import.undo_batch(db, user, batch)
|
deleted = csv_import.undo_batch(db, user, batch)
|
||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/batches/{batch_id}/record", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_batch_record(
|
||||||
|
batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Entfernt einen rückgängig gemachten Eintrag aus dem Verlauf.
|
||||||
|
|
||||||
|
Nur für bereits zurückgenommene Importe: Solange ein Import steht, ist der
|
||||||
|
Eintrag die einzige Möglichkeit, seine Buchungen wieder loszuwerden."""
|
||||||
|
batch = get_owned(db, ImportBatch, batch_id, user)
|
||||||
|
if batch.status != ImportStatus.undone:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Nur rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen.",
|
||||||
|
)
|
||||||
|
db.delete(batch)
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -519,3 +519,23 @@ def test_reconciliation_flags_rows_waiting_for_target(admin):
|
|||||||
assert rec["difference_cents"] == 9200
|
assert rec["difference_cents"] == 9200
|
||||||
assert rec["first_mismatch"]["expected_cents"] == 9200
|
assert rec["first_mismatch"]["expected_cents"] == 9200
|
||||||
assert rec["first_mismatch"]["actual_cents"] == 0
|
assert rec["first_mismatch"]["actual_cents"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_undone_batch_from_history(admin):
|
||||||
|
"""Rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen –
|
||||||
|
laufende nicht, sonst verlöre man die Möglichkeit, sie zurückzunehmen."""
|
||||||
|
giro = _account(admin, name="Verlauf", currency="EUR")
|
||||||
|
_account(admin, name="Spar Verlauf", type="sparkonto", currency="EUR",
|
||||||
|
identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}])
|
||||||
|
rows = _preview(admin, giro["id"]).json()["rows"]
|
||||||
|
batch = admin.post("/api/imports/commit",
|
||||||
|
json={"account_id": giro["id"], "filename": "v.csv", "rows": rows}).json()
|
||||||
|
|
||||||
|
# Solange der Import steht, bleibt der Eintrag bestehen.
|
||||||
|
r = admin.delete(f"/api/imports/batches/{batch['id']}/record")
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert any(b["id"] == batch["id"] for b in admin.get("/api/imports/batches").json())
|
||||||
|
|
||||||
|
admin.delete(f"/api/imports/batches/{batch['id']}") # rückgängig machen
|
||||||
|
assert admin.delete(f"/api/imports/batches/{batch['id']}/record").status_code == 204
|
||||||
|
assert not any(b["id"] == batch["id"] for b in admin.get("/api/imports/batches").json())
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export const api = {
|
|||||||
importCommit: (body) => request("/imports/commit", { method: "POST", body }),
|
importCommit: (body) => request("/imports/commit", { method: "POST", body }),
|
||||||
importBatches: () => request("/imports/batches"),
|
importBatches: () => request("/imports/batches"),
|
||||||
undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }),
|
undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }),
|
||||||
|
removeImportBatch: (id) => request(`/imports/batches/${id}/record`, { method: "DELETE" }),
|
||||||
|
|
||||||
// PDF/Bild-Beleg
|
// PDF/Bild-Beleg
|
||||||
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
|
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
|
||||||
|
|||||||
@@ -1077,3 +1077,13 @@ tr.row-dup {
|
|||||||
.restore-summary tr:last-child td {
|
.restore-summary tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zweiter Buchen-Knopf unter der Prüfliste – bei vielen Zeilen ist der obere
|
||||||
|
längst aus dem Bild gescrollt. */
|
||||||
|
.commit-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|||||||
@@ -340,7 +340,9 @@
|
|||||||
"filterValue": "mit dem Wert",
|
"filterValue": "mit dem Wert",
|
||||||
"filterValuePlaceholder": "z. B. Giro",
|
"filterValuePlaceholder": "z. B. Giro",
|
||||||
"filterNone": "— kein Filter —",
|
"filterNone": "— kein Filter —",
|
||||||
"filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt."
|
"filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt.",
|
||||||
|
"removeEntry": "Eintrag entfernen",
|
||||||
|
"removeEntryConfirm": "Den Verlaufs-Eintrag „{{file}}“ entfernen? Die Buchungen sind bereits zurückgenommen."
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"show": "Passwort anzeigen",
|
"show": "Passwort anzeigen",
|
||||||
|
|||||||
@@ -340,7 +340,9 @@
|
|||||||
"filterValue": "with value",
|
"filterValue": "with value",
|
||||||
"filterValuePlaceholder": "e.g. Current",
|
"filterValuePlaceholder": "e.g. Current",
|
||||||
"filterNone": "— no filter —",
|
"filterNone": "— no filter —",
|
||||||
"filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice."
|
"filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice.",
|
||||||
|
"removeEntry": "Remove entry",
|
||||||
|
"removeEntryConfirm": "Remove the history entry “{{file}}”? Its transactions have already been undone."
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"show": "Show password",
|
"show": "Show password",
|
||||||
|
|||||||
@@ -196,6 +196,15 @@ export default function Import() {
|
|||||||
} catch (e) { toast(e.message, "error"); }
|
} catch (e) { toast(e.message, "error"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function entfernen(b) {
|
||||||
|
if (!(await confirm({
|
||||||
|
title: t("import.removeEntry"),
|
||||||
|
message: t("import.removeEntryConfirm", { file: b.filename || "—" }),
|
||||||
|
danger: true, confirmLabel: t("common.delete"),
|
||||||
|
}))) return;
|
||||||
|
try { await api.removeImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
async function undo(b) {
|
async function undo(b) {
|
||||||
if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return;
|
if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return;
|
||||||
try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); }
|
try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); }
|
||||||
@@ -325,7 +334,15 @@ export default function Import() {
|
|||||||
<button className="btn sm danger" onClick={() => undo(b)}>
|
<button className="btn sm danger" onClick={() => undo(b)}>
|
||||||
<Icon name="refresh-ccw" size={14} /> {t("import.undo")}
|
<Icon name="refresh-ccw" size={14} /> {t("import.undo")}
|
||||||
</button>
|
</button>
|
||||||
) : <span className="muted small">{t("import.undone")}</span>),
|
) : (
|
||||||
|
<span className="nowrap">
|
||||||
|
<span className="muted small">{t("import.undone")}</span>
|
||||||
|
<button className="btn sm ghost" title={t("import.removeEntry")}
|
||||||
|
style={{ marginLeft: 6 }} onClick={() => entfernen(b)}>
|
||||||
|
<Icon name="trash" size={14} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
], [t, formatDate, accountById]);
|
], [t, formatDate, accountById]);
|
||||||
@@ -614,6 +631,13 @@ export default function Import() {
|
|||||||
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")}
|
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")}
|
||||||
columns={reviewColumns}
|
columns={reviewColumns}
|
||||||
/>
|
/>
|
||||||
|
{/* Zweiter Knopf unter der Liste: bei vielen Zeilen muss man zum Buchen
|
||||||
|
sonst wieder ganz nach oben scrollen. */}
|
||||||
|
<div className="commit-footer">
|
||||||
|
<button className="btn primary" onClick={commit} disabled={busy || includeCount === 0}>
|
||||||
|
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user