Rechnungsdialog: Zustand mit Reducer, Appwrite-Lesezugriffe und Hooks für Projektkonto, Vorschau und weitere Tickets

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:34:22 +00:00
1 parent 0f1dbabd98
commit 6a5f943034
4 files changed
+418

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
// Hooks des Rechnungsdialogs: Projektliste, Projektkonto, Vorschau, Tickets des Kunden und Arbeitsblätter weiterer Tickets.
// Überall zählt nur die jüngste Antwort; nach dem Ausbau eines Hooks zählt keine mehr.
import { useCallback, useEffect, useRef, useState } from 'react'
import { getProjektkonten, getProjektkonto, previewInvoice } from '../lib/financeApi'
import { getAbgerechnet } from '../lib/teamApi'
import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi'
import { blattAusAppwrite } from '../lib/rechnungDialogView'
import { createRequestGuard } from '../lib/requestGuard'
const VORSCHAU_WARTEZEIT_MS = 350
/**
* Lädt eine Ressource zu einem Schlüssel (Kunde oder Projekt). Ohne Schlüssel wird nichts geladen. Die Daten gehören zu
* ihrem Schlüssel: nach einem Wechsel ist sofort `data: null` und `loading: true`. `reload()` behält die Daten stehen und
* liefert ein Promise, das nie wirft.
*/
function useGeladen(laden, schluessel, fallbackError) {
const [state, setState] = useState({ key: null, data: null, loading: Boolean(schluessel), error: '' })
const guardRef = useRef(null)
if (!guardRef.current) guardRef.current = createRequestGuard()
const ladenRef = useRef(laden)
ladenRef.current = laden
const lebtRef = useRef(true)
const lade = useCallback(
async (key) => {
const guard = guardRef.current
const id = guard.next()
setState((s) => (s.key === key ? { ...s, loading: true, error: '' } : { key, data: null, loading: true, error: '' }))
const gilt = () => lebtRef.current && guard.isCurrent(id)
try {
const data = await ladenRef.current()
if (gilt()) setState({ key, data: data && typeof data === 'object' ? data : {}, loading: false, error: '' })
} catch (err) {
if (gilt()) setState((s) => ({ ...s, key, loading: false, error: err?.message || fallbackError }))
}
},
[fallbackError]
)
useEffect(() => {
lebtRef.current = true
if (!schluessel) {
guardRef.current.next()
setState({ key: null, data: null, loading: false, error: '' })
} else {
lade(schluessel)
}
return () => {
lebtRef.current = false
}
}, [schluessel, lade])
const reload = useCallback(() => (schluessel ? lade(schluessel) : Promise.resolve()), [schluessel, lade])
const passt = Boolean(schluessel) && state.key === schluessel
return {
data: passt ? state.data : null,
loading: Boolean(schluessel) && (!passt || state.loading),
error: passt ? state.error : '',
reload,
}
}
/** Projektkonten eines Kunden: { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }. */
export function useProjektkonten(kundeId) {
return useGeladen(() => getProjektkonten(kundeId), kundeId, 'Die Projekte konnten nicht geladen werden.')
}
/** Detail eines Projektkontos: { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }. */
export function useProjektkontoDetail(projektId) {
return useGeladen(() => getProjektkonto(projektId), projektId, 'Das Projektkonto konnte nicht geladen werden.')
}
/**
* Vorschau der Rechnung zum Körper der Anfrage. `vorschau = { key, status: 'laedt' | 'ok' | 'fehler', daten?, message?, httpStatus? }`.
* Der Körper wird über einen Ref gelesen, der Effekt hängt nur an [aktiv, schluessel]; nach 350 ms Ruhe geht die Anfrage raus.
*/
export function useRechnungVorschau({ anfrage, schluessel, aktiv }) {
const [vorschau, setVorschau] = useState(null)
const bodyRef = useRef(null)
bodyRef.current = anfrage?.ok ? anfrage.body : null
const datenRef = useRef(null)
useEffect(() => {
if (!aktiv || !bodyRef.current || !schluessel) {
datenRef.current = null
return undefined
}
let aktuell = true
setVorschau({ key: schluessel, status: 'laedt', daten: datenRef.current })
const timer = setTimeout(async () => {
try {
const daten = await previewInvoice(bodyRef.current)
if (!aktuell) return
datenRef.current = daten
setVorschau({ key: schluessel, status: 'ok', daten })
} catch (err) {
if (!aktuell) return
setVorschau({
key: schluessel,
status: 'fehler',
message: err?.message || 'Die Vorschau konnte nicht geladen werden.',
httpStatus: err?.status,
daten: datenRef.current,
})
}
}, VORSCHAU_WARTEZEIT_MS)
return () => {
aktuell = false
clearTimeout(timer)
}
}, [aktiv, schluessel])
return vorschau
}
/** Tickets des Kunden (einmal je Kunde, sobald `aktiv` wahr ist). */
export function useKundenTickets(kundeId, aktiv) {
const [state, setState] = useState({ key: null, tickets: [], loading: false, error: '' })
useEffect(() => {
if (!aktiv || !kundeId) return undefined
let aktuell = true
setState({ key: kundeId, tickets: [], loading: true, error: '' })
listKundenTickets(kundeId).then(
(tickets) => aktuell && setState({ key: kundeId, tickets, loading: false, error: '' }),
() => aktuell && setState({ key: kundeId, tickets: [], loading: false, error: 'Die Tickets konnten nicht geladen werden.' })
)
return () => {
aktuell = false
}
}, [kundeId, aktiv])
const passt = state.key === kundeId
return { tickets: passt ? state.tickets : [], loading: Boolean(aktiv && kundeId) && (!passt || state.loading), error: passt ? state.error : '' }
}
/**
* Arbeitsblätter weiterer Tickets: je Ticket einmal Blätter und Abgerechnet-Liste laden.
* Ergebnis: { [ticketId]: { status: 'laedt' | 'ok' | 'fehler', blaetter, message } }; entfernte Tickets verschwinden.
*/
export function useWeitereBlaetter(weitereTickets) {
const [ergebnis, setErgebnis] = useState({})
const angefragtRef = useRef(new Set())
const lebtRef = useRef(true)
useEffect(() => {
lebtRef.current = true
return () => {
lebtRef.current = false
}
}, [])
useEffect(() => {
const liste = Array.isArray(weitereTickets) ? weitereTickets : []
const ids = new Set(liste.map((t) => t.ticketId))
for (const id of [...angefragtRef.current]) if (!ids.has(id)) angefragtRef.current.delete(id)
for (const ticket of liste) {
if (angefragtRef.current.has(ticket.ticketId)) continue
angefragtRef.current.add(ticket.ticketId)
const gilt = () => lebtRef.current && angefragtRef.current.has(ticket.ticketId)
setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'laedt', blaetter: [], message: '' } }))
Promise.all([listTicketBlaetter(ticket.woid), getAbgerechnet(ticket.ticketId)]).then(
([dokumente, antwort]) => {
if (!gilt()) return
const blaetter = dokumente
.map((ws) => blattAusAppwrite(ws, antwort?.abgerechnet))
.filter(Boolean)
.sort((a, b) => String(a.datum).localeCompare(String(b.datum)) || String(a.id).localeCompare(String(b.id)))
setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'ok', blaetter, message: '' } }))
},
() => {
if (!gilt()) return
setErgebnis((r) => ({
...r,
[ticket.ticketId]: { status: 'fehler', blaetter: [], message: `Die Arbeitsblätter von #${ticket.woid} konnten nicht geladen werden.` },
}))
}
)
}
setErgebnis((r) => {
const keys = Object.keys(r).filter((k) => !ids.has(k))
if (keys.length === 0) return r
const rest = { ...r }
for (const k of keys) delete rest[k]
return rest
})
}, [weitereTickets])
return ergebnis
}