// 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 { getInvoices, getProjektkonten, getProjektkonto, getSettings, listAuftraege, previewInvoice } from '../lib/financeApi' import { getAbgerechnet } from '../lib/teamApi' import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi' import { blattAusAppwrite, ohneProjektAbDaten } 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, } } /** Finanzeinstellungen, einmal geladen: undefined = lädt, null = nicht ladbar, sonst das Objekt `einstellungen`. */ export function useEinstellungen() { const [einstellungen, setEinstellungen] = useState(undefined) useEffect(() => { let aktuell = true getSettings().then( (d) => aktuell && setEinstellungen(d?.einstellungen ?? null), () => aktuell && setEinstellungen(null) ) return () => { aktuell = false } }, []) return einstellungen } /** 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.') } /** * ABs ohne Projekt und ihre Rechnungen für „Ohne Projekt“ im Rechnungsdialog: { auftraege, rechnungen }. Geladen wird nur, * wenn `aktiv` wahr ist (Schalter an und „Ohne Projekt“ gewählt). */ export function useOhneProjektAbs(kundeId, aktiv) { return useGeladen( async () => { const [abs, rechnungen] = await Promise.all([listAuftraege(kundeId), getInvoices({ status: 'alle', kunde: kundeId })]) return ohneProjektAbDaten(abs, rechnungen) }, aktiv ? kundeId : '', 'Die Aufträge konnten 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) const kontextRef = useRef('') useEffect(() => { if (!aktiv || !bodyRef.current || !schluessel) { datenRef.current = null kontextRef.current = '' return undefined } // Vorherige Zeilen bleiben nur innerhalb derselben Rechnung (Kunde, Art, Projekt, AB) abgeblendet stehen const { customerId, art, projektId, auftragId } = bodyRef.current const kontext = JSON.stringify([customerId, art, projektId, auftragId]) if (kontext !== kontextRef.current) datenRef.current = null kontextRef.current = kontext 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 }