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
}
+21
View File
@@ -0,0 +1,21 @@
import { databases, DATABASE_ID, COLLECTIONS, Query } from './appwrite'
/** Tickets eines Kunden, neueste zuerst (für „Weiteres Ticket hinzunehmen“). */
export async function listKundenTickets(customerId) {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(100),
])
return res.documents
}
/** Alle Arbeitsblätter eines Tickets (nach Ticketnummer `woid`). */
export async function listTicketBlaetter(woid) {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
Query.equal('woid', woid),
Query.orderAsc('$createdAt'),
Query.limit(5000),
])
return res.documents
}
+93
View File
@@ -0,0 +1,93 @@
// Zustand des Rechnungsdialogs „Rechnung schreiben“ (rein, ohne React; unter Node testbar).
// `anfangszustand` liefert den leeren Zustand, `reduce` verarbeitet die Aktionen des Dialogs. Unbekannte Aktionen,
// unbekannte Listen und Felder sowie Aktionen ohne Wirkung geben denselben Zustand (dieselbe Referenz) zurück.
import { INVOICE_LIMITS } from './financeView'
const LISTEN = ['items', 'zusatz']
const FELDER = ['description', 'quantity', 'price']
const MINDESTENS = { items: 1, zusatz: 0 }
const leereZeile = (uid) => ({ uid, description: '', quantity: '1', price: '' })
/** Startzustand. projektId: null = noch zu wählen, '' = Ohne Projekt, Text = dieses Projekt. */
export function anfangszustand({ projektId = null } = {}) {
return {
projektId,
art: '',
auftragId: '',
items: [leereZeile(1)],
zusatz: [],
abgewaehlt: [],
weitereTickets: [],
anzahlung: {},
zeilentexte: {},
zeitraum: '',
zeitraumGeaendert: false,
days: String(INVOICE_LIMITS.defaultDays),
memo: '',
}
}
function neueUid(s) {
return 1 + Math.max(0, ...s.items.map((z) => z.uid), ...s.zusatz.map((z) => z.uid))
}
export function reduce(s, a) {
switch (a?.type) {
case 'projekt':
return { ...anfangszustand({ projektId: a.projektId }), days: s.days, memo: s.memo }
case 'art':
return {
...s,
art: a.art,
auftragId: '',
zeilentexte: {},
zeitraum: s.zeitraumGeaendert ? s.zeitraum : '',
}
case 'auftrag':
return { ...s, auftragId: a.auftragId, anzahlung: {} }
case 'position': {
if (!LISTEN.includes(a.liste) || !FELDER.includes(a.feld)) return s
return { ...s, [a.liste]: s[a.liste].map((z) => (z.uid === a.uid ? { ...z, [a.feld]: a.wert } : z)) }
}
case 'position+': {
if (!LISTEN.includes(a.liste) || s[a.liste].length >= INVOICE_LIMITS.maxItems) return s
return { ...s, [a.liste]: [...s[a.liste], leereZeile(neueUid(s))] }
}
case 'position-': {
if (!LISTEN.includes(a.liste) || s[a.liste].length <= MINDESTENS[a.liste]) return s
return { ...s, [a.liste]: s[a.liste].filter((z) => z.uid !== a.uid) }
}
case 'blatt':
return { ...s, abgewaehlt: s.abgewaehlt.includes(a.id) ? s.abgewaehlt.filter((id) => id !== a.id) : [...s.abgewaehlt, a.id] }
case 'ticket+':
return s.weitereTickets.some((t) => t.ticketId === a.ticket.ticketId) ? s : { ...s, weitereTickets: [...s.weitereTickets, a.ticket] }
case 'ticket-': {
const weg = Array.isArray(a.blattIds) ? a.blattIds : []
return {
...s,
weitereTickets: s.weitereTickets.filter((t) => t.ticketId !== a.ticketId),
abgewaehlt: s.abgewaehlt.filter((id) => !weg.includes(id)),
}
}
case 'anzahlung':
return { ...s, anzahlung: { ...s.anzahlung, ...a.patch } }
case 'zeilentext': {
const zeilentexte = { ...s.zeilentexte }
if (!String(a.text ?? '').trim() || a.text === a.original) delete zeilentexte[a.schluessel]
else zeilentexte[a.schluessel] = a.text
return { ...s, zeilentexte }
}
case 'zeitraum':
return { ...s, zeitraum: a.text, zeitraumGeaendert: true }
case 'zeitraum-vorschlag':
if (s.zeitraumGeaendert || s.zeitraum === a.text) return s
return { ...s, zeitraum: a.text }
case 'tage':
return { ...s, days: a.wert }
case 'notiz':
return { ...s, memo: a.wert }
default:
return s
}
}
+115
View File
@@ -0,0 +1,115 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { anfangszustand, reduce } = await loadModule('src/lib/rechnungDialogState.js')
test('anfangszustand: nichts gewählt, eine leere Position, Zahlungsziel 14', () => {
assert.deepEqual(anfangszustand({ projektId: 'p1' }), {
projektId: 'p1', art: '', auftragId: '',
items: [{ uid: 1, description: '', quantity: '1', price: '' }], zusatz: [],
abgewaehlt: [], weitereTickets: [], anzahlung: {}, zeilentexte: {},
zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '',
})
assert.equal(anfangszustand().projektId, null)
assert.equal(anfangszustand({ projektId: '' }).projektId, '')
})
test('projekt: setzt alles zurück, behält aber Zahlungsziel und Notiz', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'tage', wert: '30' })
s = reduce(s, { type: 'notiz', wert: 'Danke' })
s = reduce(s, { type: 'art', art: 'aufwand' })
s = reduce(s, { type: 'blatt', id: 'b1' })
s = reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text: 'Neu', original: 'Alt' })
s = reduce(s, { type: 'projekt', projektId: 'p2' })
assert.equal(s.projektId, 'p2')
assert.equal(s.art, '')
assert.deepEqual(s.abgewaehlt, [])
assert.deepEqual(s.zeilentexte, {})
assert.equal(s.days, '30')
assert.equal(s.memo, 'Danke')
assert.equal(reduce(s, { type: 'projekt', projektId: null }).projektId, null)
})
test('art: Positionen für Festbetrag und Zusatzpositionen bleiben getrennt und erhalten', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'description', wert: 'Pauschale' })
s = reduce(s, { type: 'position+', liste: 'zusatz' })
s = reduce(s, { type: 'position', liste: 'zusatz', uid: 2, feld: 'price', wert: '12,00' })
s = reduce(s, { type: 'art', art: 'aufwand' })
assert.equal(s.art, 'aufwand')
assert.equal(s.items[0].description, 'Pauschale')
assert.deepEqual(s.zusatz, [{ uid: 2, description: '', quantity: '1', price: '12,00' }])
// eine unbekannte Liste oder ein unbekanntes Feld ändert nichts
assert.equal(reduce(s, { type: 'position', liste: 'x', uid: 1, feld: 'description', wert: 'y' }), s)
assert.equal(reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'uid', wert: 9 }), s)
})
test('position+ und position-: Positionen mindestens 1, Zusatzpositionen mindestens 0, höchstens 20', () => {
let s = anfangszustand({ projektId: 'p1' })
assert.equal(reduce(s, { type: 'position-', liste: 'items', uid: 1 }), s)
s = reduce(s, { type: 'position+', liste: 'zusatz' })
assert.equal(s.zusatz[0].uid, 2)
s = reduce(s, { type: 'position-', liste: 'zusatz', uid: 2 })
assert.deepEqual(s.zusatz, [])
for (let i = 0; i < 25; i += 1) s = reduce(s, { type: 'position+', liste: 'zusatz' })
assert.equal(s.zusatz.length, 20)
})
test('blatt: Abwählen und Wiederanwählen', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'blatt', id: 'b2' })
assert.deepEqual(s.abgewaehlt, ['b2'])
s = reduce(s, { type: 'blatt', id: 'b2' })
assert.deepEqual(s.abgewaehlt, [])
})
test('ticket+ und ticket-: ein Ticket nur einmal, beim Entfernen verschwindet die Abwahl seiner Blätter', () => {
const ticket = { ticketId: 't2', woid: '100140', titel: 'Kontaktformular' }
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'ticket+', ticket })
s = reduce(s, { type: 'ticket+', ticket })
assert.deepEqual(s.weitereTickets, [ticket])
s = reduce(s, { type: 'blatt', id: 'w1' })
s = reduce(s, { type: 'blatt', id: 'b9' })
s = reduce(s, { type: 'ticket-', ticketId: 't2', blattIds: ['w1'] })
assert.deepEqual(s.weitereTickets, [])
assert.deepEqual(s.abgewaehlt, ['b9'])
})
test('zeitraum-vorschlag gilt nur, solange der Nutzer das Feld nicht selbst geändert hat', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['01.10.–20.10.2026', false])
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' }), s) // gleicher Text: kein neuer Zustand
s = reduce(s, { type: 'zeitraum', text: 'Oktober 2026' })
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['Oktober 2026', true])
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '02.10.–21.10.2026' }).zeitraum, 'Oktober 2026')
// ein Wechsel der Art verwirft einen Vorschlag, nicht aber die Eingabe des Nutzers
const vorschlag = reduce(anfangszustand({ projektId: 'p1' }), { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
assert.equal(reduce(vorschlag, { type: 'art', art: 'festbetrag' }).zeitraum, '')
assert.equal(reduce(s, { type: 'art', art: 'festbetrag' }).zeitraum, 'Oktober 2026')
})
test('zeilentext: ein unveränderter oder leerer Text entfernt den Eintrag', () => {
let s = anfangszustand({ projektId: 'p1' })
const setze = (text) => reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text, original: 'Alt' })
s = setze('Neu')
assert.deepEqual(s.zeilentexte, { '2026-10-06': 'Neu' })
s = setze('Alt')
assert.deepEqual(s.zeilentexte, {})
s = setze('Neu')
s = setze(' ')
assert.deepEqual(s.zeilentexte, {})
})
test('anzahlung und auftrag: Eingaben ergänzen sich, eine andere AB setzt sie zurück', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'anzahlung', patch: { modus: 'betrag' } })
s = reduce(s, { type: 'anzahlung', patch: { wert: '400,00' } })
assert.deepEqual(s.anzahlung, { modus: 'betrag', wert: '400,00' })
s = reduce(s, { type: 'auftrag', auftragId: 'a2' })
assert.deepEqual([s.auftragId, s.anzahlung], ['a2', {}])
assert.equal(reduce(s, { type: 'gibtsnicht' }), s)
})