diff --git a/src/components/finance/AssignDialog.jsx b/src/components/finance/AssignDialog.jsx index 92e5e6f..85d39e7 100644 --- a/src/components/finance/AssignDialog.jsx +++ b/src/components/finance/AssignDialog.jsx @@ -21,11 +21,12 @@ const NOTE_MAX = 500 * weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung) * lässt sich mit „Zuordnung lösen“ aufheben. * - * invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand). + * invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand; invoicesLoaded = + * frisch und ohne Fehler geladen, dann sind Vorschläge zu nicht mehr offenen Rechnungen gesperrt). * onDone(buchung) nach Erfolg (Aufrufer lädt neu und schließt); onClose({ refresh }) bei Abbruch, refresh = ein * Fehler trat auf, der Stand kann sich geändert haben. */ -export default function AssignDialog({ transaction, invoices = [], invoicesLoading = false, invoicesError = '', onClose, onDone }) { +export default function AssignDialog({ transaction, invoices = [], invoicesLoading = false, invoicesError = '', invoicesLoaded = false, onClose, onDone }) { const tx = transaction || {} const row = useMemo(() => transactionRows([tx])[0], [tx]) const [fixed, setFixed] = useState({ list: [], loading: false, error: '' }) @@ -69,7 +70,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi } }, [outgoing, unassigned]) - const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list }), [tx, invoices, fixed.list]) + const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list, invoicesLoaded }), [tx, invoices, fixed.list, invoicesLoaded]) const option = model.byKey[selected] || null function close() { diff --git a/src/components/finance/InvoiceTable.jsx b/src/components/finance/InvoiceTable.jsx index 3032822..b941b1d 100644 --- a/src/components/finance/InvoiceTable.jsx +++ b/src/components/finance/InvoiceTable.jsx @@ -12,10 +12,12 @@ import { ageGroupCells, finalizedFilter, invoiceConfirmText, + invoiceFilterStatuses, invoiceDoneMessage, invoiceFailureMessage, invoiceRows, isFinalizedNotSent, + mergeInvoiceLists, } from '../../lib/financeView' const PAGE_SIZE = 50 @@ -31,8 +33,11 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del * customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest * (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede * Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. + * + * „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich + * `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu. */ -export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, onChanged }) { +export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, refreshToken = 0, onChanged }) { const filterCustomer = customerId || customer?.$id || null const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER) const [reloadKey, setReloadKey] = useState(0) @@ -59,17 +64,17 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null, useEffect(() => { // Nur die jüngste Anfrage zählt (Filterwechsel während des Ladens) const mine = ++seqRef.current - getInvoices({ status, kunde: filterCustomer }) - .then((data) => { + Promise.all(invoiceFilterStatuses(status).map((s) => getInvoices({ status: s, kunde: filterCustomer }))) + .then((results) => { if (!aliveRef.current || mine !== seqRef.current) return - setResult({ key, list: Array.isArray(data?.rechnungen) ? data.rechnungen : [] }) + setResult({ key, list: mergeInvoiceLists(results.map((r) => r?.rechnungen)) }) setError(null) }) .catch((err) => { if (!aliveRef.current || mine !== seqRef.current) return setError({ key, message: err?.message || 'Die Rechnungen konnten nicht geladen werden.' }) }) - }, [status, filterCustomer, key, reloadKey]) + }, [status, filterCustomer, key, reloadKey, refreshToken]) const reload = () => setReloadKey((k) => k + 1) const changed = () => { diff --git a/src/hooks/useOpenInvoices.js b/src/hooks/useOpenInvoices.js index 7917db1..bd7ea3c 100644 --- a/src/hooks/useOpenInvoices.js +++ b/src/hooks/useOpenInvoices.js @@ -1,31 +1,33 @@ import { useEffect, useState } from 'react' import { getInvoices } from '../lib/financeApi' +import { invoiceFilterStatuses, mergeInvoiceLists } from '../lib/financeView' const FALLBACK_ERROR = 'Die offenen Rechnungen konnten nicht geladen werden.' /** * Offene und überfällige Rechnungen für den Zuordnen-Dialog. Lädt jedes Mal frisch, wenn `active` true wird * (damit der Dialog nie auf einem alten Stand arbeitet), und leert die Liste, wenn es wieder false ist. - * Scheitert Stripe, bleibt `invoices` leer und `error` nennt den Grund (Kategorien lassen sich trotzdem wählen). + * `loaded` ist true, sobald die Liste dieser Öffnung frisch und ohne Fehler da ist (erst dann darf der Dialog eine + * Rechnung als „nicht mehr offen“ erkennen). Scheitert Stripe, bleibt `invoices` leer und `error` nennt den Grund + * (Kategorien lassen sich trotzdem wählen). */ export function useOpenInvoices(active) { - const [state, setState] = useState({ invoices: [], loading: false, error: '' }) + const [state, setState] = useState({ invoices: [], loading: false, error: '', loaded: false }) useEffect(() => { if (!active) { - setState({ invoices: [], loading: false, error: '' }) + setState({ invoices: [], loading: false, error: '', loaded: false }) return undefined } let cancelled = false - setState({ invoices: [], loading: true, error: '' }) - Promise.all([getInvoices({ status: 'offen' }), getInvoices({ status: 'ueberfaellig' })]) - .then(([open, overdue]) => { + setState({ invoices: [], loading: true, error: '', loaded: false }) + Promise.all(invoiceFilterStatuses('offen').map((status) => getInvoices({ status }))) + .then((results) => { if (cancelled) return - const all = [...(open?.rechnungen || []), ...(overdue?.rechnungen || [])] - setState({ invoices: all, loading: false, error: '' }) + setState({ invoices: mergeInvoiceLists(results.map((r) => r?.rechnungen)), loading: false, error: '', loaded: true }) }) .catch((err) => { - if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR }) + if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR, loaded: false }) }) return () => { cancelled = true diff --git a/src/lib/financeView.js b/src/lib/financeView.js index 6d01b65..346ecdb 100644 --- a/src/lib/financeView.js +++ b/src/lib/financeView.js @@ -406,16 +406,53 @@ export function aboView(abos) { // --- Rechnungen (Block 6) ---------------------------------------------------------------------------------- -/** Filter über der Rechnungsliste (Schlüssel = `status` der API); Standard ist „Offen“. */ +/** + * Filter über der Rechnungsliste. `statuses` sind die API-Status, die der Filter abruft: „Offen“ zeigt offene UND + * überfällige Rechnungen (zwei Abrufe, zusammengeführt), alle anderen genau ihren Status. Standard ist „Offen“. + */ export const INVOICE_FILTERS = [ - { key: 'offen', label: 'Offen' }, - { key: 'ueberfaellig', label: 'Überfällig' }, - { key: 'bezahlt', label: 'Bezahlt' }, - { key: 'entwurf', label: 'Entwürfe' }, - { key: 'alle', label: 'Alle' }, + { key: 'offen', label: 'Offen', statuses: ['offen', 'ueberfaellig'] }, + { key: 'ueberfaellig', label: 'Überfällig', statuses: ['ueberfaellig'] }, + { key: 'bezahlt', label: 'Bezahlt', statuses: ['bezahlt'] }, + { key: 'entwurf', label: 'Entwürfe', statuses: ['entwurf'] }, + { key: 'alle', label: 'Alle', statuses: ['alle'] }, ] export const DEFAULT_INVOICE_FILTER = 'offen' +/** API-Status, die ein Filter abruft (unbekannter Filter = alle). */ +export function invoiceFilterStatuses(filterKey) { + const filter = INVOICE_FILTERS.find((f) => f.key === filterKey) + return filter ? [...filter.statuses] : ['alle'] +} + +/** + * Mehrere Antwortlisten (je Status eine) zu einer Liste: neueste zuerst nach Rechnungsdatum, gleiche Tage behalten + * die Reihenfolge der Listen; eine Rechnung, die in mehreren vorkommt, nur einmal. + */ +export function mergeInvoiceLists(lists) { + const seen = new Set() + const all = [] + for (const list of Array.isArray(lists) ? lists : []) { + for (const inv of Array.isArray(list) ? list : []) { + if (!inv || typeof inv !== 'object') continue + if (inv.id) { + if (seen.has(inv.id)) continue + seen.add(inv.id) + } + all.push(inv) + } + } + return all + .map((inv, index) => ({ inv, index })) + .sort((a, b) => { + const da = text(a.inv.createdDate) + const db = text(b.inv.createdDate) + if (da === db) return a.index - b.index + return da < db ? 1 : -1 + }) + .map((x) => x.inv) +} + const obj = (v) => (v && typeof v === 'object' ? v : {}) const objects = (list) => (Array.isArray(list) ? list.filter((x) => x && typeof x === 'object') : []) const OPEN_INVOICE = new Set(['offen', 'ueberfaellig']) @@ -790,6 +827,7 @@ export function transactionRows(buchungen) { // --- Zuordnen (Dialog) ------------------------------------------------------------------------------------- +const NOT_OPEN_ANYMORE = 'Diese Rechnung ist nicht mehr offen.' export const PENDING_ASSIGN_HINT = 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.' /** Bestätigungstext vor dem Bezahlt-Markieren (nicht umkehrbar). */ @@ -809,8 +847,10 @@ const fixedCostLabel = (f) => `${text(f.name) || 'Fixkosten'} · ${euro(f.amount * - Eingang: Vorschläge des Servers, andere offene Rechnungen, Kategorien (auch „Einnahme ohne Stripe-Rechnung“) * - Ausgang: Fixkosten (aktive), Privat/Umbuchung, Sonstiges * Rechnungen, deren offener Betrag vom Betrag der Buchung abweicht, sind nicht wählbar (Teilzahlungen in Stripe). + * invoicesLoaded = die offenen Rechnungen wurden frisch und ohne Fehler geladen: ein Vorschlag, dessen Rechnung nicht + * (mehr) darin steht, ist dann nicht wählbar („Diese Rechnung ist nicht mehr offen.“). */ -export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) { +export function assignModel(tx, { invoices = [], fixedCosts = [], invoicesLoaded = false } = {}) { const t = obj(tx) const amount = t.amountCents const incoming = isNum(amount) && amount > 0 @@ -868,6 +908,7 @@ export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) { } else if (s.kind === 'invoice' && text(s.id) && !seen.has(s.id)) { seen.add(s.id) const known = open.find((inv) => inv.id === s.id) + const gone = invoicesLoaded && !known const differs = s.amountDiffers === true || (known ? known.remainingCents !== amount : false) const number = text(s.number) || text(known?.number) model.suggestions.push({ @@ -876,8 +917,12 @@ export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) { label: `Rechnung ${number || s.id}${known ? ` · ${text(known.customerName) || text(known.customerEmail)}` : ''}`, detail: [text(s.reason), known ? `${euro(known.remainingCents)} offen` : ''].filter(Boolean).join(' · '), confidence: s.confidence, - disabled: differs, - disabledReason: differs ? 'Betrag weicht ab, Teilzahlungen bitte direkt in Stripe erfassen' : '', + disabled: gone || differs, + disabledReason: gone + ? NOT_OPEN_ANYMORE + : differs + ? 'Betrag weicht ab, Teilzahlungen bitte direkt in Stripe erfassen' + : '', number, body: { invoiceId: s.id }, }) diff --git a/src/pages/FinancePage.jsx b/src/pages/FinancePage.jsx index e5534b4..687407f 100644 --- a/src/pages/FinancePage.jsx +++ b/src/pages/FinancePage.jsx @@ -26,6 +26,8 @@ export default function FinancePage() { // Buchung, die gerade zugeordnet werden soll (Knopf in Block 7 oder Aufgabe „Zahlungseingang zuordnen“) const [assignTxId, setAssignTxId] = useState(null) const [assignNotice, setAssignNotice] = useState('') + // Zähler, der die Rechnungsliste (Block 6) zum Neuladen bringt: sie hält ihre Liste selbst + const [invoicesRefresh, setInvoicesRefresh] = useState(0) const refreshLock = useRef(false) const retriedRef = useRef(null) @@ -34,8 +36,12 @@ export default function FinancePage() { const needsInvoices = Boolean(assignTx && !assignTx.category && assignTx.status !== 'PDNG' && assignTx.amountCents > 0) const openInvoices = useOpenInvoices(needsInvoices) - // Übersicht und Buchungen zusammen neu laden (nach Zuordnen, Rechnungsänderungen und Bankabruf) - const reloadAll = useCallback(() => Promise.all([reload(), reloadTransactions()]), [reload, reloadTransactions]) + // Übersicht, Buchungen und Rechnungsliste zusammen neu laden (nach Zuordnen, Rechnungsänderungen und Bankabruf): + // eine eben bezahlte Rechnung soll sofort aus „Offen“ verschwinden + const reloadAll = useCallback(() => { + setInvoicesRefresh((n) => n + 1) + return Promise.all([reload(), reloadTransactions()]) + }, [reload, reloadTransactions]) // Eine Aufgabe kann eine Buchung nennen, die in der geladenen Liste fehlt (neu abgerufen): einmal nachladen, // dann melden statt still nichts zu tun @@ -169,7 +175,7 @@ export default function FinancePage() {
- + diff --git a/tests/unit/financeView.test.mjs b/tests/unit/financeView.test.mjs index ef4ef64..57d26b0 100644 --- a/tests/unit/financeView.test.mjs +++ b/tests/unit/financeView.test.mjs @@ -25,6 +25,8 @@ const { aboStatus, INVOICE_FILTERS, DEFAULT_INVOICE_FILTER, + invoiceFilterStatuses, + mergeInvoiceLists, INVOICE_LIMITS, PENDING_ASSIGN_HINT, ageGroupCells, @@ -652,6 +654,40 @@ test('INVOICE_FILTERS: Reihenfolge Offen, Überfällig, Bezahlt, Entwürfe, Alle assert.equal(DEFAULT_INVOICE_FILTER, 'offen') }) +test('invoiceFilterStatuses: „Offen“ ruft offen UND überfällig ab, die anderen genau ihren Status', () => { + assert.deepEqual(invoiceFilterStatuses('offen'), ['offen', 'ueberfaellig']) + assert.deepEqual(invoiceFilterStatuses('ueberfaellig'), ['ueberfaellig']) + assert.deepEqual(invoiceFilterStatuses('bezahlt'), ['bezahlt']) + assert.deepEqual(invoiceFilterStatuses('entwurf'), ['entwurf']) + assert.deepEqual(invoiceFilterStatuses('alle'), ['alle']) + assert.deepEqual(invoiceFilterStatuses('gibtsnicht'), ['alle']) + assert.deepEqual(invoiceFilterStatuses(undefined), ['alle']) + // die Liste des Filters lässt sich nicht von außen verändern + invoiceFilterStatuses('offen').push('x') + assert.deepEqual(invoiceFilterStatuses('offen'), ['offen', 'ueberfaellig']) + for (const f of INVOICE_FILTERS) assert.deepEqual(invoiceFilterStatuses(f.key), f.statuses) +}) + +test('mergeInvoiceLists: neueste zuerst, gleiche Tage in Listenreihenfolge, keine Doppelten', () => { + const open = [ + { id: 'a', createdDate: '2026-07-28', status: 'offen' }, + { id: 'b', createdDate: '2026-09-15', status: 'offen' }, + ] + const overdue = [ + { id: 'c', createdDate: '2026-09-01', status: 'ueberfaellig' }, + { id: 'd', createdDate: '2026-09-15', status: 'ueberfaellig' }, + { id: 'a', createdDate: '2026-07-28', status: 'offen' }, + ] + assert.deepEqual(mergeInvoiceLists([open, overdue]).map((i) => i.id), ['b', 'd', 'c', 'a']) + assert.deepEqual(mergeInvoiceLists([overdue, open]).map((i) => i.id), ['d', 'b', 'c', 'a']) + assert.deepEqual(mergeInvoiceLists([open]).map((i) => i.id), ['b', 'a']) + assert.deepEqual(mergeInvoiceLists([undefined, null, [null, 5, { id: 'x', createdDate: '2026-01-01' }]]).map((i) => i.id), ['x']) + assert.deepEqual(mergeInvoiceLists(undefined), []) + const input = [open, overdue] + mergeInvoiceLists(input) + assert.deepEqual(open.map((i) => i.id), ['a', 'b'], 'Eingabelisten bleiben unverändert') +}) + test('ageGroupCells: vier Gruppen in fester Reihenfolge, fehlende zählen 0', () => { const cells = ageGroupCells([ { key: '1-30', anzahl: 2, summeCents: 12345 }, @@ -1072,6 +1108,30 @@ test('assignModel: Vorschlag mit abweichendem Betrag ist nicht wählbar', () => assert.equal(known.suggestions[0].disabled, true, 'auch wenn die Liste den Unterschied zeigt') }) +test('assignModel: Vorschlag zu einer Rechnung, die in der frisch geladenen Liste fehlt, ist nicht wählbar', () => { + const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV(), SUG_INV({ id: 'in_9', number: 'WK-0009' })] }) + const listed = [OPEN()] // in_1 steht darin, in_9 nicht mehr + const loaded = assignModel(tx, { invoices: listed, invoicesLoaded: true }) + assert.equal(loaded.suggestions[0].disabled, false) + assert.equal(loaded.suggestions[1].disabled, true) + assert.equal(loaded.suggestions[1].disabledReason, 'Diese Rechnung ist nicht mehr offen.') + assert.equal(loaded.suggestions[1].number, 'WK-0009') + // Liste leer, aber frisch geladen: alle Rechnungsvorschläge sind weg + const none = assignModel(tx, { invoices: [], invoicesLoaded: true }) + assert.deepEqual(none.suggestions.map((s) => s.disabled), [true, true]) + assert.ok(none.suggestions.every((s) => s.disabledReason === 'Diese Rechnung ist nicht mehr offen.')) + // nicht (oder fehlerhaft) geladen: nichts sperren, der Server entscheidet + for (const opts of [{ invoices: [] }, { invoices: [], invoicesLoaded: false }]) { + assert.deepEqual(assignModel(tx, opts).suggestions.map((s) => s.disabled), [false, false]) + } + // Auszahlungen hängen nicht an der Rechnungsliste + const withPayout = TX({ amountCents: 5000, vorschlaege: [{ kind: 'payout', id: 'po_1', number: '', confidence: 'hoch', reason: 'x', amountDiffers: false }] }) + assert.equal(assignModel(withPayout, { invoices: [], invoicesLoaded: true }).suggestions[0].disabled, false) + // eine Rechnung, die in der Liste steht, aber einen anderen Betrag hat, nennt weiter den Betrag + const differs = assignModel(TX({ amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN({ remainingCents: 4000 })], invoicesLoaded: true }) + assert.match(differs.suggestions[0].disabledReason, /Betrag weicht ab/) +}) + test('assignModel: Ausgang bekommt Fixkosten (nur aktive) und Privat/Sonstiges, keine Rechnungen', () => { const tx = TX({ amountCents: -1000, vorschlaege: [SUG_INV()] }) const m = assignModel(tx, {