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, useEinstellungen, useOhneProjektAbs, useProjektkontoDetail, useProjektkonten, useRechnungVorschau, useWeitereBlaetter, } from '../../hooks/useRechnungDaten' import { createInvoice } from '../../lib/financeApi' import { canInvoice } 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, abSummenText, aufwandAuftrag, gueltigeFestpreisAbs, projektOptionen, standardArt, standardAuftrag, startProjekt, steuernummerHinweis, ticketOptionen, wirksamesProjekt, zahlungszielVorschlag, } from '../../lib/rechnungDialogView' import { BEZEICHNUNG_MAX, VORSCHAU_ARTEN, createSubmitter, darfSpeichern, rechnungAnfrage, vorschauAnfrage, vorschauSchluessel, vorschauStatus, } from '../../lib/rechnungAnfrage' import { anfangszustand, reduce } from '../../lib/rechnungDialogState' /** * Dialog „Rechnung schreiben“. 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: 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 = 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 einstellungen = useEinstellungen() 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 || '') // „Ohne Projekt“: gültige ABs ohne Projekt (der Server nimmt Anzahlung und Schlussrechnung dazu ohne Projekt an) const ohne = useOhneProjektAbs(kunde?.$id, projekt === '' && aktiv) const quelleDaten = projekt === '' ? ohne.data : detail.data const auftraege = quelleDaten?.auftraege || [] const rechnungen = quelleDaten?.rechnungen || [] const datenNeuLaden = projekt === '' ? ohne.reload : detail.reload 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 stripeOk = detail.data?.stripeOk const art = optionen.some((o) => o.key === state.art && o.enabled) ? state.art : standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0, stripeOk }) 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 !== '' // Das Zahlungsziel der gewählten AB (bzw. 14); der Reducer lässt eine Eingabe des Nutzers unberührt const tageVorschlag = zahlungszielVorschlag({ art, ab, auftraege }) useEffect(() => { dispatch({ type: 'tage-vorschlag', wert: tageVorschlag }) }, [tageVorschlag]) // --- 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(datenNeuLaden) detailReloadRef.current = datenNeuLaden 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) datenNeuLaden() } 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 return (
{festpreisAbs.length > 1 ? ( ) : (

{abLabel(ab)}

)}

{abSummenText({ ab, rechnungen, stripeOk })}

) } 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}.

} {steuernummerHinweis(einstellungen) && (

{steuernummerHinweis(einstellungen)}

)} {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 && projekt === '' && ohne.loading && (

Aufträge werden geladen …

)} {kunde && projekt === '' && ohne.error && (

Die Aufträge ohne Projekt konnten nicht geladen werden: {ohne.error}{' '}

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