Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen

Block 6 (Rechnungen: Filter, Altersgruppen, Aktionen, Dialog "Rechnung schreiben")
und Block 7 (Buchungen mit Paging, Zuordnen-Dialog, bei Rechnungen zweistufig).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 18bc3012e0
commit 1feeb18c08
12 files changed
+2436 -8

No files matched your search

+316
View File
@@ -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) => (
<label key={o.key} className={`fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
<input
type="radio"
name="fin-assign-target"
value={o.key}
checked={selected === o.key}
disabled={o.disabled || disabled}
onChange={() => choose(o.key)}
/>
<span className="fin-choice-text">
<span className="fin-choice-label">{o.label}</span>
{(o.confidence || o.detail) && (
<span className="fin-choice-detail muted">
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
{o.confidence && o.detail ? ' · ' : ''}
{o.detail}
</span>
)}
{o.disabled && o.disabledReason && <span className="fin-choice-detail fin-hint-warn">{o.disabledReason}</span>}
</span>
</label>
)
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={close} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog fin-assign"
role="dialog"
aria-modal="true"
aria-labelledby="fin-assign-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-assign-title" className="mb-2">Buchung zuordnen</h2>
<dl className="fin-tx-facts">
<div><dt>Datum</dt><dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd></div>
<div><dt>Gegenpartei</dt><dd>{row?.counterparty}</dd></div>
{row?.remittance && <div><dt>Verwendungszweck</dt><dd className="fin-wrap">{row.remittance}</dd></div>}
<div><dt>Betrag</dt><dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd></div>
</dl>
{error && (
<div className="fin-dialog-error" role="alert">
{error}
</div>
)}
{model.assigned ? (
<>
<p className="fin-assigned">
Zugeordnet: <strong>{model.currentText}</strong>
</p>
{!model.canUnassign && (
<p className="fin-dialog-sub">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
)}
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Schließen</Button>
{model.canUnassign && (
<Button type="button" onClick={doUnassign} disabled={disabled}>
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
</Button>
)}
</div>
</>
) : (
<>
{model.hint && <p className="fin-dialog-sub" role="note">{model.hint}</p>}
{showInvoiceState && invoicesLoading && <p className="fin-dialog-sub" role="status">Offene Rechnungen werden geladen …</p>}
{showInvoiceState && invoicesError && (
<p className="fin-dialog-warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
)}
{outgoing && fixed.loading && <p className="fin-dialog-sub" role="status">Fixkosten werden geladen …</p>}
{outgoing && fixed.error && <p className="fin-dialog-warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
{model.suggestions.length > 0 && (
<fieldset className="fin-choices">
<legend className="form-label">Vorschläge</legend>
{model.suggestions.map(radio)}
</fieldset>
)}
{model.invoices.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-invoice">Andere offene Rechnung</label>
<select
id="fin-assign-invoice"
className="form-control"
value={invoiceSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Rechnung wählen …</option>
{model.invoices.map((o) => (
<option key={o.key} value={o.key} disabled={o.disabled}>
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
</option>
))}
</select>
</div>
)}
{model.fixedCosts.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-fixed">Fixkosten</label>
<select
id="fin-assign-fixed"
className="form-control"
value={fixedSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Fixkosten wählen …</option>
{model.fixedCosts.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</div>
)}
<fieldset className="fin-choices">
<legend className="form-label">Kategorie</legend>
{model.categories.map(radio)}
</fieldset>
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-note">Notiz (optional)</label>
<textarea
id="fin-assign-note"
className="form-control"
rows={2}
maxLength={NOTE_MAX}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
{confirming && option ? (
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
</Button>
</div>
</div>
) : (
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,300 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
import { AutocompleteInput, Button } from '../ui'
import { useCustomers } from '../../hooks/useCustomers'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { createInvoice } from '../../lib/financeApi'
import { canInvoice, euro } from '../../lib/financeFormat'
import {
INVOICE_LIMITS,
customerName,
customerPickerOptions,
customerSuggestions,
finalizedFilter,
invoiceFailureMessage,
invoiceTotalCents,
isFinalizedNotSent,
lineTotalCents,
resolveCustomerText,
validateInvoiceForm,
} from '../../lib/financeView'
let itemCounter = 0
const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' })
/**
* Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest),
* Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der
* Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft;
* Fehler des Servers stehen im Dialog.
*
* onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit
* invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt
* (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose().
*/
export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null }) {
if (!open) return null
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
return fixedCustomer ? (
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} />
) : (
<PickerForm onClose={onClose} onCreated={onCreated} />
)
}
function PickerForm({ onClose, onCreated }) {
const { customers, loading, error } = useCustomers()
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} />
}
function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
const [customerText, setCustomerText] = useState('')
const [items, setItems] = useState(() => [newItem()])
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
const [memo, setMemo] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(null) // 'draft' | 'send'
const lockRef = useRef(false)
const aliveRef = useRef(true)
const dialogRef = useRef(null)
const onOverlayKeyDown = useDialogFocus(dialogRef)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
useEffect(() => {
const onKey = (e) => {
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
const resolved = fixedCustomer
? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null }
: resolveCustomerText(options, customerText)
const customer = fixedCustomer || resolved.option?.customer || null
const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
const total = invoiceTotalCents(items)
const updateItem = (uid, field, value) => {
setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
if (error) setError('')
}
async function submit(senden) {
if (lockRef.current) return
const check = validateInvoiceForm({ customer, items, days, memo })
if (!check.ok) {
setError(check.error)
return
}
lockRef.current = true
setBusy(senden ? 'send' : 'draft')
setError('')
try {
const data = await createInvoice({ ...check.payload, senden })
if (onCreated) onCreated(data?.rechnung || null, { senden })
onClose()
} catch (err) {
if (isFinalizedNotSent(err)) {
// Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie
if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) })
onClose()
return
}
if (aliveRef.current) setError(invoiceFailureMessage(err))
} finally {
lockRef.current = false
if (aliveRef.current) setBusy(null)
}
}
const disabled = busy !== null
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="fin-invoice-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
{error && (
<div className="fin-dialog-error" role="alert">
{error}
</div>
)}
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
<form onSubmit={(e) => e.preventDefault()}>
<div className="form-group">
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
{fixedCustomer ? (
<p className="fin-fixed-customer">
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
</p>
) : source?.loading ? (
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
) : source?.error ? (
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
) : (
<AutocompleteInput
id="fin-invoice-customer"
value={customerText}
onChange={(v) => {
setCustomerText(v)
if (error) setError('')
}}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
{!customerBlocked.ok && (
<p className="fin-dialog-warn" role="alert">
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!fixedCustomer && resolved.state === 'unknown' && (
<p className="fin-dialog-sub">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
)}
</div>
<fieldset className="fin-items">
<legend className="form-label">Positionen</legend>
{items.map((item, index) => {
const line = lineTotalCents(item)
const n = index + 1
return (
<div key={item.uid} className="fin-item">
<div className="fin-item-field fin-item-desc">
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
<input
id={`fin-item-desc-${item.uid}`}
className="form-control"
maxLength={INVOICE_LIMITS.maxDescription}
value={item.description}
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
aria-label={`Position ${n}: Beschreibung`}
placeholder="z. B. Webdesign Startseite"
/>
</div>
<div className="fin-item-field fin-item-qty">
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
<input
id={`fin-item-qty-${item.uid}`}
className="form-control fin-amount"
inputMode="numeric"
value={item.quantity}
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
aria-label={`Position ${n}: Menge`}
/>
</div>
<div className="fin-item-field fin-item-price">
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
<input
id={`fin-item-price-${item.uid}`}
className="form-control fin-amount"
inputMode="decimal"
value={item.price}
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
aria-label={`Position ${n}: Einzelpreis in Euro`}
placeholder="0,00"
/>
</div>
<div className="fin-item-field fin-item-sum">
<span className="fin-item-label">Summe</span>
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
</div>
<div className="fin-item-field fin-item-remove">
<button
type="button"
className="fin-icon-btn"
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
disabled={items.length <= 1}
aria-label={`Position ${n} entfernen`}
title="Position entfernen"
>
<FaTrashCan aria-hidden="true" />
</button>
</div>
</div>
)
})}
<div className="fin-items-foot">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
disabled={items.length >= INVOICE_LIMITS.maxItems}
>
<FaPlus aria-hidden="true" /> Position
</Button>
<div className="fin-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount fin-total-value">{euro(total)}</span>
</div>
</div>
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
</fieldset>
<div className="fin-dialog-row">
<div className="form-group fin-days">
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
<input
id="fin-invoice-days"
className="form-control fin-amount"
inputMode="numeric"
value={days}
onChange={(e) => {
setDays(e.target.value)
if (error) setError('')
}}
/>
</div>
<div className="form-group fin-memo">
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
<textarea
id="fin-invoice-memo"
className="form-control"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</div>
</div>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={onClose} disabled={disabled}>Abbrechen</Button>
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button type="button" variant="primary" onClick={() => submit(true)} disabled={disabled}>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
</form>
</div>
</div>
)
}
+277
View File
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaSpinner } from 'react-icons/fa6'
import { Badge, Button, Card, EmptyState } from '../ui'
import InvoiceCreateModal from './InvoiceCreateModal'
import Pager from './Pager'
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
import { pageOf } from '../../lib/financeFormat'
import {
DEFAULT_INVOICE_FILTER,
INVOICES_ANCHOR,
INVOICE_FILTERS,
ageGroupCells,
finalizedFilter,
invoiceConfirmText,
invoiceDoneMessage,
invoiceFailureMessage,
invoiceRows,
isFinalizedNotSent,
} from '../../lib/financeView'
const PAGE_SIZE = 50
const RUNNING_LABEL = { send: 'Sende …', resend: 'Sende …', void: 'Storniere …', delete: 'Lösche …' }
const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, delete: deleteInvoice }
/**
* Block 6: Rechnungen. Filter Offen / Überfällig / Bezahlt / Entwürfe / Alle (Standard Offen), Altersgruppen der
* offenen Rechnungen (nur mit `altersgruppen`, also nicht in der Kundenansicht), Tabelle mit 50 Zeilen je Seite
* und die Aktionen je Status (siehe invoiceActions). Löschen, Stornieren und Erinnerung fragen vorher nach; solange
* eine Anfrage läuft, sind alle Aktionen gesperrt. „Rechnung schreiben“ öffnet den Dialog.
*
* 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.
*/
export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, onChanged }) {
const filterCustomer = customerId || customer?.$id || null
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
const [reloadKey, setReloadKey] = useState(0)
const [result, setResult] = useState({ key: '', list: [] }) // Antwort samt Filter, zu dem sie gehört
const [error, setError] = useState(null) // { key, message }
const [page, setPage] = useState(1)
const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
const [message, setMessage] = useState(null) // { tone: 'ok' | 'warn', text }
const [createOpen, setCreateOpen] = useState(false)
const lockRef = useRef(false)
const aliveRef = useRef(true)
const seqRef = useRef(0)
const onChangedRef = useRef(onChanged)
onChangedRef.current = onChanged
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
const key = `${status}|${filterCustomer || ''}`
useEffect(() => {
// Nur die jüngste Anfrage zählt (Filterwechsel während des Ladens)
const mine = ++seqRef.current
getInvoices({ status, kunde: filterCustomer })
.then((data) => {
if (!aliveRef.current || mine !== seqRef.current) return
setResult({ key, list: Array.isArray(data?.rechnungen) ? data.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])
const reload = () => setReloadKey((k) => k + 1)
const changed = () => {
reload()
if (onChangedRef.current) onChangedRef.current()
}
const current = result.key === key
const invoices = current ? result.list : []
const rows = useMemo(() => invoiceRows(invoices), [invoices])
const byId = useMemo(() => Object.fromEntries(invoices.map((inv) => [inv.id, inv])), [invoices])
const paged = pageOf(rows, page, PAGE_SIZE)
const loadError = error && error.key === key ? error.message : ''
const loading = !current && !loadError
const ages = ageGroupCells(filterCustomer ? null : altersgruppen)
function pickFilter(next) {
if (next === status) return
setStatus(next)
setPage(1)
setMessage(null)
}
async function run(row, action) {
if (lockRef.current) return
const invoice = byId[row.id]
const confirmText = invoiceConfirmText(action.key, invoice)
if (confirmText && !window.confirm(confirmText)) return
lockRef.current = true
setBusy(`${row.id}:${action.key}`)
setMessage(null)
try {
const data = await CALLS[action.key](row.id)
if (aliveRef.current) setMessage({ tone: 'ok', text: invoiceDoneMessage(action.key, data?.rechnung) })
if (aliveRef.current) changed()
} catch (err) {
if (!aliveRef.current) return
setMessage({ tone: 'warn', text: invoiceFailureMessage(err) })
if (isFinalizedNotSent(err)) {
// Festgeschrieben: die Rechnung ist jetzt offen und steht im Filter „Offen“
setStatus(finalizedFilter(err))
setPage(1)
}
// Der Stand bei Stripe kann ein anderer sein (schon gesendet, schon bezahlt, gelöscht): neu laden
changed()
} finally {
lockRef.current = false
if (aliveRef.current) setBusy(null)
}
}
function handleCreated(rechnung, info = {}) {
if (info.notice) {
setMessage({ tone: info.tone || 'ok', text: info.notice })
if (info.finalizedFilter) setStatus(info.finalizedFilter)
} else {
setMessage({ tone: 'ok', text: invoiceDoneMessage(info.senden ? 'create-send' : 'create-draft', rechnung) })
// Der neue Eintrag soll sichtbar sein: Entwurf unter „Entwürfe“, gesendete Rechnung unter „Offen“
setStatus(rechnung?.status === 'entwurf' ? 'entwurf' : 'offen')
}
setPage(1)
changed()
}
return (
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
<Card
title="Rechnungen"
className="fin-invoices-card"
bodyStyle={{ padding: 0 }}
actions={
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
<FaPlus aria-hidden="true" /> Rechnung schreiben
</Button>
}
>
{ages.length > 0 && (
<ul className="fin-ages" aria-label="Offene Rechnungen nach Alter">
{ages.map((cell) => (
<li key={cell.key} className="fin-age">
<span className="fin-age-label">{cell.label}</span>
<span className="fin-age-count">{cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}</span>
<span className="fin-amount fin-age-sum">{cell.sumText}</span>
</li>
))}
</ul>
)}
<div className="fin-toolbar">
<div className="seg" role="group" aria-label="Rechnungen filtern">
{INVOICE_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={status === f.key ? 'active' : ''}
aria-pressed={status === f.key}
onClick={() => pickFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
{message && (
<p className={`fin-hint fin-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
{loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Rechnungen werden geladen …</p>
</div>
) : loadError ? (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{loadError}</p>
<Button size="sm" onClick={reload}>Erneut versuchen</Button>
</div>
) : rows.length === 0 ? (
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
) : (
<div className="fin-table-wrap">
<table className="ui-table fin-invoices" role="table">
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Nr.</th>
<th role="columnheader" scope="col">Kunde</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Fällig</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-inv-number">
{row.numberText}
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
</td>
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
<td role="cell" className="fin-inv-status">
<div className="fin-inv-statusbox">
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
</div>
</td>
<td role="cell" className="fin-inv-actions">
<div className="fin-actions">
{row.actions.map((action) =>
action.url ? (
<Button
key={action.key}
as="a"
size="sm"
variant="ghost"
href={action.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
>
{action.label}
</Button>
) : (
<Button
key={action.key}
size="sm"
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
onClick={() => run(row, action)}
disabled={busy !== null}
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
>
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
</Button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
</Card>
<InvoiceCreateModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCreated}
fixedCustomer={customer}
/>
</section>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { Button } from '../ui'
/**
* Blätterleiste „Zurück · Seite 2 von 5 · 230 Buchungen · Weiter“; bei nur einer Seite erscheint sie nicht.
* `noun` ist die Mehrzahl, z. B. „Buchungen“.
*/
export default function Pager({ page, pages, total, noun = 'Einträge', onPage }) {
if (pages <= 1) return null
return (
<nav className="fin-pager" aria-label={`Seiten der ${noun}`}>
<Button size="sm" variant="ghost" onClick={() => onPage(page - 1)} disabled={page <= 1}>
Zurück
</Button>
<span className="fin-pager-info" aria-live="polite">
Seite {page} von {pages}
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
</span>
<Button size="sm" variant="ghost" onClick={() => onPage(page + 1)} disabled={page >= pages}>
Weiter
</Button>
</nav>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useMemo, useState } from 'react'
import { FaSpinner, FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Button, Card, EmptyState } from '../ui'
import Pager from './Pager'
import { pageOf } from '../../lib/financeFormat'
import { transactionRows } from '../../lib/financeView'
const PAGE_SIZE = 50
export const TRANSACTIONS_ANCHOR = 'finanzen-buchungen'
/**
* Block 7: Buchungen. Alle gespeicherten Buchungen, neueste zuerst, 50 je Seite: Datum, Gegenpartei mit
* Verwendungszweck (zweizeilig), Betrag mit Vorzeichen, Zuordnung (bei offenen Buchungen der stärkste Vorschlag als
* Text) und der Knopf „Zuordnen“ bzw. „Ändern“. Vorgemerkte Buchungen (PDNG) tragen „vorgemerkt“. Die Daten kommen
* vom Aufrufer (useFinanceTransactions), damit Seite und Zuordnen-Dialog dieselbe Liste benutzen.
* onAssign(buchung) öffnet den Dialog. Schmal (Karte bis 640 px) wird jede Zeile ein Raster: Datum und Betrag /
* Gegenpartei und Verwendungszweck / Zuordnung und Knopf.
*/
export default function TransactionTable({ transactions, stripeFehler = '', loaded = false, loading = false, error = '', onReload, onAssign }) {
const [page, setPage] = useState(1)
const rows = useMemo(() => transactionRows(transactions), [transactions])
const paged = pageOf(rows, page, PAGE_SIZE)
let body
if (!loaded && loading) {
body = (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Buchungen werden geladen …</p>
</div>
)
} else if (!loaded) {
body = (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
<Button size="sm" onClick={onReload}>Erneut versuchen</Button>
</div>
)
} else if (rows.length === 0) {
body = <EmptyState title="Noch keine Buchungen gespeichert." hint="Sobald die Bank verbunden und abgerufen ist, erscheinen sie hier." />
} else {
body = (
<div className="fin-table-wrap">
<table className="ui-table fin-tx" role="table">
<caption className="fin-sr">Buchungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Gegenpartei und Verwendungszweck</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col">Zuordnung</th>
<th role="columnheader" scope="col"><span className="fin-sr">Aktion</span></th>
</tr>
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-tx-date">{row.dateText}</td>
<td role="cell" className="fin-tx-what">
<div className="fin-tx-counterparty">{row.counterparty}</div>
{row.remittance && (
<div className="fin-tx-remittance muted" title={row.remittance}>{row.remittance}</div>
)}
</td>
<td role="cell" className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}`}>{row.amountText}</td>
<td role="cell" className="fin-tx-assign">
<div className="fin-tx-badges">
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
{row.pending && <Badge tone="muted">vorgemerkt</Badge>}
</div>
{row.suggestionText && <div className="fin-tx-suggestion muted">{row.suggestionText}</div>}
{row.note && <div className="fin-tx-note muted">Notiz: {row.note}</div>}
</td>
<td role="cell" className="fin-tx-action">
{row.action && (
<Button
size="sm"
variant={row.action === 'assign' ? 'default' : 'ghost'}
onClick={() => onAssign(row.tx)}
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
>
{row.actionLabel}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
return (
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
<Card title="Buchungen" className="fin-tx-card" bodyStyle={{ padding: 0 }}>
{loaded && error && (
<p className="fin-hint fin-hint-warn fin-notice" role="alert">
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
<Button size="sm" variant="ghost" onClick={onReload} disabled={loading}>Erneut versuchen</Button>
</p>
)}
{stripeFehler && (
<p className="fin-hint fin-hint-warn fin-notice" role="status">
<FaTriangleExclamation aria-hidden="true" /> {stripeFehler}
</p>
)}
{body}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
</Card>
</section>
)
}