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:
1 parent
0f1dbabd98
commit
6a5f943034
4 files changed
+418
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user