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>
228 lines
8.9 KiB
JavaScript
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
|
|
}
|