diff --git a/src/components/finance/AssignDialog.jsx b/src/components/finance/AssignDialog.jsx new file mode 100644 index 0000000..92e5e6f --- /dev/null +++ b/src/components/finance/AssignDialog.jsx @@ -0,0 +1,316 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { FaXmark } from 'react-icons/fa6' +import { Badge, Button } from '../ui' +import { useDialogFocus } from '../../hooks/useDialogFocus' +import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi' +import { + assignBody, + assignModel, + invoiceAssignConfirmText, + transactionRows, +} from '../../lib/financeView' + +const NOTE_MAX = 500 + +/** + * Dialog „Buchung zuordnen“. Auswahl je nach Buchung (siehe assignModel): Vorschläge, andere offene Rechnung, + * Stripe-Auszahlung (aus den Vorschlägen), Fixkosten (nur Ausgänge, aus der Fixkostenliste), Kategorie, dazu eine + * Notiz. Vorgemerkte Buchungen (PDNG) bekommen nur Kategorien und Fixkosten. Eine Rechnung wird in Stripe als + * bezahlt markiert und das lässt sich nicht rückgängig machen, deshalb zweistufig: erst „Zuordnen“, dann der + * Bestätigungstext mit „Ja, als bezahlt markieren“; vorher geht keine Anfrage raus. Fehler des Servers (409 Betrag + * 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). + * 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 }) { + const tx = transaction || {} + const row = useMemo(() => transactionRows([tx])[0], [tx]) + const [fixed, setFixed] = useState({ list: [], loading: false, error: '' }) + const [selected, setSelected] = useState('') + const [note, setNote] = useState('') + const [confirming, setConfirming] = useState(false) + const [busy, setBusy] = useState(null) // 'assign' | 'unassign' + const [error, setError] = useState('') + const lockRef = useRef(false) + const aliveRef = useRef(true) + const dirtyRef = useRef(false) + const dialogRef = useRef(null) + const confirmRef = useRef(null) + + const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0 + const unassigned = !tx.category + + const onOverlayKeyDown = useDialogFocus(dialogRef) + + useEffect(() => { + aliveRef.current = true + return () => { + aliveRef.current = false + } + }, []) + + // Fixkosten nur für Ausgänge ohne Zuordnung + useEffect(() => { + if (!outgoing || !unassigned) return undefined + let cancelled = false + setFixed({ list: [], loading: true, error: '' }) + getFixedCosts() + .then((data) => { + if (!cancelled) setFixed({ list: Array.isArray(data?.fixkosten) ? data.fixkosten : [], loading: false, error: '' }) + }) + .catch((err) => { + if (!cancelled) setFixed({ list: [], loading: false, error: err?.message || 'Die Fixkosten konnten nicht geladen werden.' }) + }) + return () => { + cancelled = true + } + }, [outgoing, unassigned]) + + const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list }), [tx, invoices, fixed.list]) + const option = model.byKey[selected] || null + + function close() { + if (lockRef.current) return + onClose({ refresh: dirtyRef.current }) + } + + useEffect(() => { + const onKey = (e) => { + // Escape schließt zuerst nur die offene Auswahl (z. B. ein Aufklappmenü): dann nicht den Dialog + if (e.key === 'Escape' && !e.defaultPrevented) close() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }) + + function choose(key) { + setSelected(key) + setConfirming(false) + if (error) setError('') + } + + useEffect(() => { + if (confirming) confirmRef.current?.focus() + }, [confirming]) + + async function doAssign() { + if (lockRef.current || !option || option.disabled) return + lockRef.current = true + setBusy('assign') + setError('') + try { + const data = await assignTransaction(tx.$id, assignBody(option, note)) + onDone(data?.buchung || null) + } catch (err) { + dirtyRef.current = true + if (aliveRef.current) { + setError(err?.message || 'Die Zuordnung ist fehlgeschlagen.') + setConfirming(false) + } + } finally { + lockRef.current = false + if (aliveRef.current) setBusy(null) + } + } + + // Erster Schritt: bei einer Rechnung nur den Bestätigungstext zeigen, noch keine Anfrage + function primary() { + if (!option || option.disabled || lockRef.current) return + if (option.type === 'invoice' && !confirming) { + setConfirming(true) + return + } + doAssign() + } + + async function doUnassign() { + if (lockRef.current) return + lockRef.current = true + setBusy('unassign') + setError('') + try { + const data = await unassignTransaction(tx.$id) + onDone(data?.buchung || null) + } catch (err) { + dirtyRef.current = true + if (aliveRef.current) setError(err?.message || 'Die Zuordnung konnte nicht gelöst werden.') + } finally { + lockRef.current = false + if (aliveRef.current) setBusy(null) + } + } + + const disabled = busy !== null + const showInvoiceState = model.incoming && !model.pending && !model.assigned + const invoiceSelectValue = model.invoices.some((o) => o.key === selected) ? selected : '' + const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : '' + + const radio = (o) => ( + + ) + + return ( +
+ +
+

Buchung zuordnen

+ +
+
Datum
{row?.dateText}{row?.pending && <> vorgemerkt}
+
Gegenpartei
{row?.counterparty}
+ {row?.remittance &&
Verwendungszweck
{row.remittance}
} +
Betrag
{row?.amountText}
+
+ + {error && ( +
+ {error} +
+ )} + + {model.assigned ? ( + <> +

+ Zugeordnet: {model.currentText} +

+ {!model.canUnassign && ( +

Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.

+ )} +
+ + {model.canUnassign && ( + + )} +
+ + ) : ( + <> + {model.hint &&

{model.hint}

} + + {showInvoiceState && invoicesLoading &&

Offene Rechnungen werden geladen …

} + {showInvoiceState && invoicesError && ( +

Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}

+ )} + {outgoing && fixed.loading &&

Fixkosten werden geladen …

} + {outgoing && fixed.error &&

Die Fixkosten konnten nicht geladen werden: {fixed.error}

} + + {model.suggestions.length > 0 && ( +
+ Vorschläge + {model.suggestions.map(radio)} +
+ )} + + {model.invoices.length > 0 && ( +
+ + +
+ )} + + {model.fixedCosts.length > 0 && ( +
+ + +
+ )} + +
+ Kategorie + {model.categories.map(radio)} +
+ +
+ +