From 6a5f94303484c7f74de739c724c14006212b5306 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:34:22 +0000 Subject: [PATCH] =?UTF-8?q?Rechnungsdialog:=20Zustand=20mit=20Reducer,=20A?= =?UTF-8?q?ppwrite-Lesezugriffe=20und=20Hooks=20f=C3=BCr=20Projektkonto,?= =?UTF-8?q?=20Vorschau=20und=20weitere=20Tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/hooks/useRechnungDaten.js | 189 ++++++++++++++++++++++++ src/lib/rechnungDialogApi.js | 21 +++ src/lib/rechnungDialogState.js | 93 ++++++++++++ tests/unit/rechnungDialogState.test.mjs | 115 ++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 src/hooks/useRechnungDaten.js create mode 100644 src/lib/rechnungDialogApi.js create mode 100644 src/lib/rechnungDialogState.js create mode 100644 tests/unit/rechnungDialogState.test.mjs diff --git a/src/hooks/useRechnungDaten.js b/src/hooks/useRechnungDaten.js new file mode 100644 index 0000000..c1cf410 --- /dev/null +++ b/src/hooks/useRechnungDaten.js @@ -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 +} diff --git a/src/lib/rechnungDialogApi.js b/src/lib/rechnungDialogApi.js new file mode 100644 index 0000000..61c007c --- /dev/null +++ b/src/lib/rechnungDialogApi.js @@ -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 +} diff --git a/src/lib/rechnungDialogState.js b/src/lib/rechnungDialogState.js new file mode 100644 index 0000000..5181ce8 --- /dev/null +++ b/src/lib/rechnungDialogState.js @@ -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 + } +} diff --git a/tests/unit/rechnungDialogState.test.mjs b/tests/unit/rechnungDialogState.test.mjs new file mode 100644 index 0000000..067b7e8 --- /dev/null +++ b/tests/unit/rechnungDialogState.test.mjs @@ -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) +})