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

+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect } from 'react'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* Fokus für ein Dialog-Overlay: beim Öffnen springt der Fokus in den Dialog (`dialogRef`), beim Schließen zurück zum
* Element, das ihn vorher hatte (falls es noch da ist). Der zurückgegebene onKeyDown gehört an das Overlay: Tab und
* Umschalt+Tab bleiben im Dialog (sonst landet man hinter dem Overlay in der Seite).
*/
export function useDialogFocus(dialogRef) {
useEffect(() => {
const opener = document.activeElement
dialogRef.current?.focus()
return () => {
if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus()
}
}, [dialogRef])
return useCallback((e) => {
if (e.key !== 'Tab') return
const overlay = e.currentTarget
const items = [...overlay.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
if (items.length === 0) {
e.preventDefault()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
const onDialogBox = active === dialogRef.current // der Dialog selbst hat den Fokus (tabIndex -1)
if (e.shiftKey && (active === first || onDialogBox || !overlay.contains(active))) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || !overlay.contains(active))) {
e.preventDefault()
first.focus()
}
}, [dialogRef])
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getTransactions } from '../lib/financeApi'
const FALLBACK_ERROR = 'Die Buchungen konnten nicht geladen werden.'
/**
* Lädt die gespeicherten Buchungen samt Zuordnungsvorschlägen (GET /buchungen, höchstens 1000, neueste zuerst).
* `transactions` bleibt beim erneuten Laden stehen; `loaded` ist true, sobald einmal geladen wurde (auch leer);
* `stripeFehler` ist der Hinweis des Servers, wenn Stripe die Vorschläge nicht liefern konnte. reload() wirft nie.
*/
export function useFinanceTransactions() {
const [state, setState] = useState({ transactions: [], stripeFehler: '', loaded: false })
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const aliveRef = useRef(true)
const seqRef = useRef(0)
const reload = useCallback(async () => {
// Nur die jüngste Anfrage zählt: eine langsame ältere darf neuere Daten nicht überschreiben
const mine = ++seqRef.current
const current = () => aliveRef.current && mine === seqRef.current
setLoading(true)
setError('')
try {
const data = await getTransactions()
if (current()) {
setState({
transactions: Array.isArray(data?.buchungen) ? data.buchungen : [],
stripeFehler: typeof data?.stripeFehler === 'string' ? data.stripeFehler : '',
loaded: true,
})
}
} catch (err) {
if (current()) setError(err?.message || FALLBACK_ERROR)
} finally {
if (current()) setLoading(false)
}
}, [])
useEffect(() => {
aliveRef.current = true
reload()
return () => {
aliveRef.current = false
}
}, [reload])
return { ...state, loading, error, reload }
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react'
import { getInvoices } from '../lib/financeApi'
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).
*/
export function useOpenInvoices(active) {
const [state, setState] = useState({ invoices: [], loading: false, error: '' })
useEffect(() => {
if (!active) {
setState({ invoices: [], loading: false, error: '' })
return undefined
}
let cancelled = false
setState({ invoices: [], loading: true, error: '' })
Promise.all([getInvoices({ status: 'offen' }), getInvoices({ status: 'ueberfaellig' })])
.then(([open, overdue]) => {
if (cancelled) return
const all = [...(open?.rechnungen || []), ...(overdue?.rechnungen || [])]
setState({ invoices: all, loading: false, error: '' })
})
.catch((err) => {
if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR })
})
return () => {
cancelled = true
}
}, [active])
return state
}