From 2aa4abeadbd51e1482c44018641665808ba11ebf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:43:17 +0000 Subject: [PATCH] Rechnung schreiben: neuer Dialog mit Projektwahl, Zusammenfassung, Art, Aufwand mit Vorschau, Anzahlung und Schlussrechnung Co-Authored-By: Claude Sonnet 5.5 --- src/components/auftraege/RechnungDialog.jsx | 604 ++++++++++++++++++- src/components/auftraege/rechnung-dialog.css | 3 +- src/hooks/useRechnungDaten.js | 7 + 3 files changed, 604 insertions(+), 10 deletions(-) diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx index 1daa360..cfdd282 100644 --- a/src/components/auftraege/RechnungDialog.jsx +++ b/src/components/auftraege/RechnungDialog.jsx @@ -1,13 +1,599 @@ +import './rechnung-dialog.css' +import { useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { AutocompleteInput, Button, Dialog, Field } from '../ui' +import AddressWarning from '../finance/AddressWarning' +import KontoZusammenfassung from './KontoZusammenfassung' +import RechnungPositionen from './RechnungPositionen' +import RechnungVorschau from './RechnungVorschau' +import AufwandAuswahl from './AufwandAuswahl' +import { useCustomers } from '../../hooks/useCustomers' +import { useScrollIntoView } from '../../hooks/useScrollIntoView' +import { + useKundenTickets, + useProjektkontoDetail, + useProjektkonten, + useRechnungVorschau, + useWeitereBlaetter, +} from '../../hooks/useRechnungDaten' +import { createInvoice } from '../../lib/financeApi' +import { canInvoice, euro } from '../../lib/financeFormat' +import { + ADDRESS_INCOMPLETE_TEXT, + INVOICE_LIMITS, + customerName, + customerPickerOptions, + customerSuggestions, + invoiceSendGate, + invoiceTotalCents, + resolveCustomerText, +} from '../../lib/financeView' +import { kontoZeile, satzText, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView' +import { + abLabel, + anzahlungStandard, + artOptionen, + aufwandAuftrag, + berechnetFuerAuftrag, + gueltigeFestpreisAbs, + projektOptionen, + standardArt, + standardAuftrag, + startProjekt, + ticketOptionen, + wirksamesProjekt, +} from '../../lib/rechnungDialogView' +import { + BEZEICHNUNG_MAX, + VORSCHAU_ARTEN, + createSubmitter, + darfSpeichern, + rechnungAnfrage, + vorschauAnfrage, + vorschauSchluessel, + vorschauStatus, +} from '../../lib/rechnungAnfrage' +import { anfangszustand, reduce } from '../../lib/rechnungDialogState' + /** - * Platzhalter von W0: Strang W1 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). + * Dialog „Rechnung schreiben“ (ersetzt InvoiceCreateModal). Ablauf von oben nach unten: Kunde (fest oder per Suche), Projekt, + * Projektkonto (Vereinbart, Berechnet, Noch offen, Zeit), Art der Rechnung (Anzahlung, Schlussrechnung, nach Aufwand, Festbetrag, + * ohne Projekt nur freie Positionen), Inhalt je Art mit Vorschau vom Server, Leistungszeitraum, Zahlungsziel und Notiz. + * „Als Entwurf speichern“ und „Senden“ bleiben gesperrt, solange die Vorschau nicht zur aktuellen Eingabe gehört. * - * Props (V8): open, onClose, onCreated(rechnung, info), kunde (Kundendokument oder null = Kundenwahl im Dialog), - * projektId (undefined = keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert, - * danach gilt getProjektkonten, das der Dialog selbst lädt), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung). - * `info` von onCreated wird unverändert weitergereicht wie im bisherigen InvoiceCreateModal ({ senden }, bei festgeschriebener, - * nicht versendeter Rechnung { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose(). - * Der Dialog sitzt zusätzlich in InvoiceTable.jsx (dort ersetzt W1 das Modal), FinancePage.jsx bleibt unverändert. + * Props: open, onClose, onCreated(rechnung, info), kunde (Kundendokument; null = Kundenwahl im Dialog), projektId (undefined = + * keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert, danach gilt + * getProjektkonten), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung). + * onCreated bekommt wie im bisherigen Dialog ({ senden }) bzw. bei festgeschriebener, nicht versendeter Rechnung + * (502 mit invoiceId) (null, { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose(). + * Enter sendet nie (das Formular hat keinen Absenden-Knopf); „Senden“ fragt vorher nach. */ -export default function RechnungDialog({ open, onClose, onCreated, kunde, projektId, auftraegeAktiv, onShowOverview }) { - return null +export default function RechnungDialog({ open, onClose, onCreated, kunde = null, projektId, auftraegeAktiv, onShowOverview = null }) { + if (!open) return null + const gemeinsam = { onClose, onCreated, projektId, auftraegeAktiv, onShowOverview } + // Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht + return kunde ? : +} + +function KundenWahl(props) { + const { customers, loading, error } = useCustomers() + return +} + +const ZEITRAUM_PLATZHALTER = 'z. B. 01.10.–20.10.2026' + +function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onShowOverview, festerKunde, quelle }) { + const [state, dispatch] = useReducer(reduce, startProjekt(projektId), (p) => anfangszustand({ projektId: p })) + const [customerText, setCustomerText] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(null) // null | 'draft' | 'send' + const submitter = useMemo(() => createSubmitter({ create: createInvoice, confirm: (t) => window.confirm(t) }), []) + const errorRef = useRef(null) + const aliveRef = useRef(true) + const kundeIdRef = useRef('') + const neuGeladenRef = useRef(null) + useScrollIntoView(errorRef, error) + useEffect(() => { + aliveRef.current = true + return () => { + aliveRef.current = false + } + }, []) + + const act = (aktion) => { + if (error) setError('') + dispatch(aktion) + } + + // --- Abgeleitete Werte ------------------------------------------------------------------------------------ + const options = useMemo(() => customerPickerOptions(quelle?.customers), [quelle?.customers]) + const resolved = festerKunde + ? { state: canInvoice(festerKunde).ok ? 'ok' : 'blocked', option: null } + : resolveCustomerText(options, customerText) + const kundeDoc = festerKunde || resolved.option?.customer || null + const kunde = kundeDoc && canInvoice(kundeDoc).ok ? kundeDoc : null + const blockiert = Boolean(kundeDoc) && !kunde + + const konten = useProjektkonten(kunde?.$id) + const projekt = wirksamesProjekt(state.projektId, konten.data?.konten) + const aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv) + + const detail = useProjektkontoDetail(projekt || '') + const auftraege = detail.data?.auftraege || [] + const rechnungen = detail.data?.rechnungen || [] + + let optionen = [] + if (projekt === '') optionen = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: aktiv }) + else if (projekt && detail.data) optionen = artOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv }) + + const art = optionen.some((o) => o.key === state.art && o.enabled) + ? state.art + : standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0 }) + + const festpreisAbs = gueltigeFestpreisAbs(auftraege) + const auftragId = + (art === 'anzahlung' || art === 'schluss') && festpreisAbs.some((a) => a.id === state.auftragId) + ? state.auftragId + : standardAuftrag(art, auftraege, rechnungen) + const ab = auftraege.find((a) => a.id === auftragId) + + const weitereTickets = state.weitereTickets + const weitere = useWeitereBlaetter(weitereTickets) + const blaetter = [...(detail.data?.offeneArbeitsblaetter || []), ...weitereTickets.flatMap((t) => weitere[t.ticketId]?.blaetter || [])] + const kundenTickets = useKundenTickets(kunde?.$id, art === 'aufwand') + + const e = { + kunde, + art, + projektId: projekt ?? '', + auftragId, + items: state.items, + zusatz: state.zusatz, + blaetter, + abgewaehlt: state.abgewaehlt, + anzahlung: { ...anzahlungStandard(ab), ...state.anzahlung }, + zeilentexte: state.zeilentexte, + zeitraum: state.zeitraum, + zeitraumGeaendert: state.zeitraumGeaendert, + days: state.days, + memo: state.memo, + } + const vorschauReq = vorschauAnfrage(e) + const schluessel = vorschauReq.ok ? vorschauSchluessel(vorschauReq.body) : '' + const vorschau = useRechnungVorschau({ anfrage: vorschauReq, schluessel, aktiv: VORSCHAU_ARTEN.includes(art) }) + const status = vorschauStatus({ art, anfrage: vorschauReq, vorschau, schluessel }) + const anfrage = rechnungAnfrage(e) + + let totalCents = null + if (art === 'frei' || art === 'festbetrag') totalCents = invoiceTotalCents(state.items) + else if (status.state === 'bereit') totalCents = vorschau.daten.summeCents + const gate = invoiceSendGate(kunde, totalCents) + const darf = darfSpeichern(status) && busy === null && Boolean(kunde) && projekt !== null && art !== '' + + // --- Effekte auf die Vorschau ----------------------------------------------------------------------------- + const vorschauZeitraum = status.state === 'bereit' ? vorschau.daten.leistungszeitraum || '' : null + useEffect(() => { + // Der Vorschlag überschreibt nie eine Eingabe des Nutzers (der Reducer prüft das) + if (art === 'aufwand' && vorschauZeitraum !== null) dispatch({ type: 'zeitraum-vorschlag', text: vorschauZeitraum }) + }, [art, vorschauZeitraum]) + + const detailReloadRef = useRef(detail.reload) + detailReloadRef.current = detail.reload + useEffect(() => { + // 409 der Vorschau: ein Arbeitsblatt ist inzwischen abgerechnet, das Projektkonto neu laden (einmal je Fehlerobjekt) + if (vorschau?.status === 'fehler' && vorschau.httpStatus === 409 && neuGeladenRef.current !== vorschau) { + neuGeladenRef.current = vorschau + detailReloadRef.current() + } + }, [vorschau]) + + // --- Aktionen --------------------------------------------------------------------------------------------- + async function abschicken(senden) { + setBusy(senden ? 'send' : 'draft') + setError('') + const r = await submitter({ anfrage, senden, kunde, totalCents }) + if (aliveRef.current) setBusy(null) + if (r.status === 'fehler') { + if (aliveRef.current) setError(r.message) + if (r.httpStatus === 409) detail.reload() + } else if (r.status === 'festgeschrieben') { + if (onCreated) onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter }) + onClose() + } else if (r.status === 'ok') { + if (onCreated) onCreated(r.rechnung, { senden: r.senden }) + onClose() + } + } + + const close = () => { + if (busy !== null) return + onClose() + } + + function kundeGeaendert(text) { + setCustomerText(text) + if (error) setError('') + const neu = resolveCustomerText(options, text).option?.customer?.$id || '' + if (neu !== kundeIdRef.current) { + kundeIdRef.current = neu + dispatch({ type: 'projekt', projektId: null }) + } + } + + const gesperrt = busy !== null + const footer = ( +
+ + + +
+ ) + + // --- Abschnitte ------------------------------------------------------------------------------------------- + const mitPositionen = (liste, legende, labelPrefix, idPrefix, minZeilen, addLabel, zeigeSumme) => ( + act({ type: 'position', liste, uid, feld, wert })} + onAdd={() => act({ type: 'position+', liste })} + onRemove={(uid) => act({ type: 'position-', liste, uid })} + addLabel={addLabel} + zeigeSumme={zeigeSumme} + /> + ) + + const abBlock = () => { + if (!ab) return null + const berechnet = berechnetFuerAuftrag(rechnungen, ab.id) + return ( +
+ {festpreisAbs.length > 1 ? ( + + + + ) : ( +

{abLabel(ab)}

+ )} +

+ Auftragssumme {euro(ab.summeCents)} · bereits berechnet {euro(berechnet)} · noch nicht berechnet {euro(Math.max(0, ab.summeCents - berechnet))} +

+
+ ) + } + + const anzahlungFelder = () => { + const a = e.anzahlung + const prozent = a.modus !== 'betrag' + return ( +
+
+ + +
+
+ + act({ type: 'anzahlung', patch: { wert: ev.target.value } })} + /> + + + act({ type: 'anzahlung', patch: { bezeichnung: ev.target.value } })} + /> + +
+
+ ) + } + + const statusBereich = () => ( + <> + {status.state === 'laedt' && ( +

+ Vorschau wird aktualisiert … +

+ )} + {status.state === 'ungueltig' && status.message &&

{status.message}

} + {status.state === 'fehler' && ( +

+ {status.message} +

+ )} + + ) + + const vorschauZeilen = () => { + const daten = vorschau?.daten + if ((status.state !== 'bereit' && status.state !== 'laedt') || !daten) return null + return ( + act({ type: 'zeilentext', schluessel: schl, text, original })} + abgeblendet={status.state === 'laedt'} + /> + ) + } + + const aufwandBlock = () => { + const konto = detail.data.konto + const laut = aufwandAuftrag(auftraege) + const satzInfo = `Stundensatz ${satzText(konto.zeit?.satzCents)}${laut ? `, laut ${laut.nummer}` : ''}` + const ausgeschlossen = [...(konto.zeitquelle || []), ...(konto.tickets || []).map((t) => t.id), ...weitereTickets.map((t) => t.ticketId)] + return ( + <> + act({ type: 'blatt', id })} + satzInfo={satzInfo} + ticketOptionen={ticketOptionen(kundenTickets.tickets, { ausgeschlossen })} + ticketsLaden={kundenTickets.loading} + ticketsFehler={kundenTickets.error} + weitereTickets={weitereTickets} + weitereZustand={weitere} + onAddTicket={(o) => + act({ type: 'ticket+', ticket: { ticketId: o.ticketId, woid: o.woid, titel: o.label.replace(/^#\S+ · /, '') } }) + } + onRemoveTicket={(id) => act({ type: 'ticket-', ticketId: id, blattIds: (weitere[id]?.blaetter || []).map((b) => b.id) })} + /> + {statusBereich()} + {vorschauZeilen()} + {mitPositionen('zusatz', 'Zusätzliche Positionen', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)} + + ) + } + + const inhalt = () => { + if (art === 'frei' || art === 'festbetrag') return mitPositionen('items', 'Positionen', 'Position', 'rd-pos', 1, '+ Position', true) + if (art === 'aufwand') return aufwandBlock() + if (art === 'anzahlung') { + return ( + <> + {abBlock()} + {anzahlungFelder()} + {statusBereich()} + {vorschauZeilen()} + + ) + } + if (art === 'schluss') { + return ( + <> + {abBlock()} + {mitPositionen('zusatz', 'Zusätzliche Positionen (Nachträge)', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)} + {statusBereich()} + {vorschauZeilen()} + + ) + } + return null + } + + const projektBereit = projekt !== null + const kontoBereit = projekt === '' || Boolean(detail.data) + const hinweise = [...new Set(optionen.filter((o) => !o.enabled && o.hint).map((o) => o.hint))] + + // Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button" + return ( + ev.preventDefault()} footer={footer}> + {error && ( +
+ {error} +
+ )} + +
+ + {festerKunde ? ( +

+ {customerName(festerKunde) || 'Kunde'} + {festerKunde.email ? · {festerKunde.email} : null} +

+ ) : quelle?.loading ? ( +

+ Kunden werden geladen … +

+ ) : quelle?.error ? ( +

+ Die Kunden konnten nicht geladen werden: {quelle.error} +

+ ) : ( + customerSuggestions(options, t)} + placeholder="Kunde suchen …" + /> + )} +
+ {blockiert && ( +

+ {customerName(kundeDoc)}: {festerKunde ? canInvoice(festerKunde).reason : resolved.option?.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden. +

+ )} + {!festerKunde && resolved.state === 'unknown' && ( +

Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.

+ )} + {!festerKunde && kunde && kunde.email &&

Die Rechnung geht an {kunde.email}.

} + {kunde && gate.warn && ( + { + if (busy !== null) return + onClose() + onShowOverview() + } + : undefined + } + /> + )} + + {kunde && ( + <> + + + + {konten.loading && ( +

+ Projekte werden geladen … +

+ )} + {konten.error && ( +

+ Die Projekte konnten nicht geladen werden: {konten.error}{' '} + +

+ )} + {konten.data && konten.data.konten.length === 0 &&

{KEINE_PROJEKTE_HINWEIS}

} + + )} + + {kunde && projektBereit && projekt !== '' && ( + <> + {detail.loading && !detail.data && ( +

+ Projektkonto wird geladen … +

+ )} + {detail.error && !detail.data && ( +

+ Das Projektkonto konnte nicht geladen werden: {detail.error}{' '} + +

+ )} + {detail.data && ( + + )} + + )} + + {kunde && projektBereit && kontoBereit && optionen.length > 1 && ( +
+ Art der Rechnung +
+
+ {optionen.map((o) => ( + + ))} +
+ {hinweise.map((h) => ( +

+ {h} +

+ ))} +
+
+ )} + + {kunde && projektBereit && kontoBereit && art !== '' && inhalt()} + + {kunde && projektBereit && kontoBereit && art !== '' && ( + <> + + act({ type: 'zeitraum', text: ev.target.value })} + /> + +
+ + act({ type: 'tage', wert: ev.target.value })} + /> + + +