Files
tickte-system/src/hooks/useRechnungDaten.js
T
ClaudeandClaude Sonnet 5.5 4b184f744f Kontrolle: AB ohne Projekt im Rechnungsdialog für Anzahlung und Schluss wählbar
Der Server (rechnungsbau.js, baueAbRechnung) nimmt Anzahlung und Schlussrechnung ohne
Projekt an. Unter 'Ohne Projekt' lädt der Dialog die ABs des Kunden ohne Projekt und
ihre Rechnungen; frei bleibt die Vorgabe.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:35 +00:00

228 lines
8.9 KiB
JavaScript

// 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
}