Files
certain/web/src/components/Dubletten.jsx
T
ScarriffleandClaude Opus 5 b867b272ba feat: Gleich aussehende Buchungen lassen sich behalten, statt loeschen zu muessen
Die Liste sagt ausdruecklich, dass sie nichts entscheidet: Drei
Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche Einkaeufe
koennen es auch sein – das weiss nur der Nutzer. Dann darf sie aber auch
nicht zu einer Entscheidung draengen, und genau das tat sie: Der einzige
Knopf war der Papierkorb. Wer die Meldung loswerden wollte, musste eine
richtige Buchung wegwerfen.

Jetzt steht neben jeder Gruppe "Alle behalten". An den Buchungen aendert
sich dabei nichts; es ist ein Haken an der Frage.

Der Haken gilt dem, was man gesehen hat – nicht allem, was noch kommt:
Eine Gruppe faellt erst weg, wenn ALLE ihre Zeilen ihn tragen. Kommt
spaeter eine dritte gleich aussehende Buchung dazu, steht die Frage
wieder da, und zwar fuer alle drei. Ein Test haelt das fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 10:33:44 +02:00

110 lines
3.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import Icon from "./Icon";
import { useToast } from "../toast";
import { useConfirm } from "../confirm";
import { formatDate, formatMoney } from "../format";
// Buchungen, die gleich aussehen: gleiches Konto, gleicher Tag, gleicher
// Betrag, gleicher Text.
//
// Entschieden wird hier NICHTS. Drei Aufrundungen über 1.00 am selben Tag sind
// echt, drei gleiche Einkäufe können es auch sein – das weiss nur der Nutzer.
// Deshalb steht zu jeder Zeile die Kennung der Bank: Dieselbe Kennung heisst
// sicher dieselbe Zahlung, verschiedene heissen, die Bank hat sie als
// verschieden gemeldet.
export default function Dubletten({ accountById, onChange }) {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const [gruppen, setGruppen] = useState(null);
async function laden() {
try {
setGruppen((await api.duplicateTransactions()).gruppen || []);
} catch {
setGruppen([]); // still ignorieren – nicht kritisch
}
}
useEffect(() => { laden(); }, []);
// Ohne diesen Weg blieb nur Loeschen, um die Meldung loszuwerden – die
// Liste draengte damit zu einer Entscheidung, die sie selbst ausdruecklich
// nicht treffen will.
async function behalten(gruppe) {
try {
await api.keepDuplicates(gruppe.zeilen.map((z) => z.id));
laden();
onChange?.();
} catch (e) {
toast(e.message, "error");
}
}
async function loeschen(zeile) {
if (!(await confirm({
title: t("dupes.delete"),
message: t("dupes.deleteConfirm"),
danger: true,
confirmLabel: t("dupes.delete"),
}))) return;
try {
await api.deleteTransaction(zeile.id);
await laden();
onChange?.();
} catch (e) {
toast(e.message, "error");
}
}
if (!gruppen?.length) return null;
return (
<div className="card">
<div className="card-head">
<Icon name="alert" size={18} />
<h2>{t("dupes.title", { n: gruppen.length })}</h2>
</div>
<p className="muted small">{t("dupes.intro")}</p>
<div className="account-list">
{gruppen.map((g) => (
<div className="dubletten-gruppe" key={`${g.account_id}-${g.datum}-${g.amount_cents}`}>
<div className="named-sub">
{formatDate(g.datum)} · {accountById[g.account_id]?.name || "—"} ·{" "}
<strong>
{formatMoney(g.typ === "income" ? g.amount_cents : -g.amount_cents,
accountById[g.account_id]?.currency)}
</strong>
{" · "}{t("dupes.count", { n: g.zeilen.length })}
{" "}
<button className="btn sm" onClick={() => behalten(g)}>
<Icon name="check" size={14} /> {t("dupes.keepAll")}
</button>
</div>
{g.zeilen.map((z) => (
<div className="dubletten-zeile" key={z.id}>
<span className="ellipsis selectable">
{z.payee || z.description || t("inbox.noText")}
</span>
<span className="badge">{t(`todo.from.${z.herkunft}`, {
defaultValue: z.herkunft })}</span>
{/* Die Kennung entscheidet die Frage – deshalb steht sie da,
auch wenn sie hässlich ist. */}
<span className="muted small selectable nowrap">
{z.kennung || t("dupes.noRef")}
</span>
<button className="btn sm danger" onClick={() => loeschen(z)}>
<Icon name="trash" size={14} />
</button>
</div>
))}
</div>
))}
</div>
</div>
);
}