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