Files
tickte-system/src/components/auftraege/RechnungDialog.jsx
T

632 lines
24 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ? <RechnungFormular {...gemeinsam} festerKunde={kunde} quelle={null} /> : <KundenWahl {...gemeinsam} />
}
function KundenWahl(props) {
const { customers, loading, error } = useCustomers()
return <RechnungFormular {...props} festerKunde={null} quelle={{ customers, loading, error }} />
}
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 = (
<div className="rd-foot">
<Button type="button" onClick={close} disabled={gesperrt}>
Abbrechen
</Button>
<Button type="button" disabled={!darf} onClick={() => abschicken(false)}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button
type="button"
variant="primary"
disabled={!darf || !gate.sendAllowed}
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
onClick={() => abschicken(true)}
>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
)
// --- Abschnitte -------------------------------------------------------------------------------------------
const mitPositionen = (liste, legende, labelPrefix, idPrefix, minZeilen, addLabel, zeigeSumme) => (
<RechnungPositionen
legende={legende}
labelPrefix={labelPrefix}
idPrefix={idPrefix}
zeilen={state[liste]}
minZeilen={minZeilen}
onChange={(uid, feld, wert) => 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 (
<div className="rd-stack">
{festpreisAbs.length > 1 ? (
<Field label="Auftragsbestätigung" htmlFor="rd-ab">
<select id="rd-ab" className="select" value={ab.id} onChange={(ev) => act({ type: 'auftrag', auftragId: ev.target.value })}>
{festpreisAbs.map((a) => (
<option key={a.id} value={a.id}>
{abLabel(a)}
</option>
))}
</select>
</Field>
) : (
<p className="rd-hint">{abLabel(ab)}</p>
)}
<p className="rd-hint">{abSummenText({ ab, rechnungen, stripeOk })}</p>
</div>
)
}
const anzahlungFelder = () => {
const a = e.anzahlung
const prozent = a.modus !== 'betrag'
return (
<div className="rd-stack">
<div className="seg rd-seg" role="group" aria-label="Anzahlung in">
<button type="button" aria-pressed={prozent} onClick={() => act({ type: 'anzahlung', patch: { modus: 'prozent' } })}>
Prozent
</button>
<button type="button" aria-pressed={!prozent} onClick={() => act({ type: 'anzahlung', patch: { modus: 'betrag' } })}>
Betrag
</button>
</div>
<div className="field-row">
<Field label={prozent ? 'Anzahlung in %' : 'Betrag in €'} htmlFor="rd-anzahlung-wert">
<input
id="rd-anzahlung-wert"
className="input"
inputMode="decimal"
value={a.wert}
onChange={(ev) => act({ type: 'anzahlung', patch: { wert: ev.target.value } })}
/>
</Field>
<Field label="Bezeichnung" htmlFor="rd-anzahlung-bez">
<input
id="rd-anzahlung-bez"
className="input"
maxLength={BEZEICHNUNG_MAX}
value={a.bezeichnung}
onChange={(ev) => act({ type: 'anzahlung', patch: { bezeichnung: ev.target.value } })}
/>
</Field>
</div>
</div>
)
}
const statusBereich = () => (
<>
{status.state === 'laedt' && (
<p className="msg muted" role="status" aria-live="polite">
Vorschau wird aktualisiert …
</p>
)}
{status.state === 'ungueltig' && status.message && <p className="msg muted">{status.message}</p>}
{status.state === 'fehler' && (
<p className="msg err" role="alert">
{status.message}
</p>
)}
</>
)
const vorschauZeilen = () => {
const daten = vorschau?.daten
if ((status.state !== 'bereit' && status.state !== 'laedt') || !daten) return null
return (
<RechnungVorschau
positionen={daten.positionen}
summeCents={daten.summeCents}
bearbeitbar={art === 'aufwand'}
zeilentexte={state.zeilentexte}
onZeilentext={(schl, text, original) => 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 (
<>
<AufwandAuswahl
blaetter={blaetter}
abgewaehlt={state.abgewaehlt}
onToggle={(id) => 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 (
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={gesperrt} onSubmit={(ev) => ev.preventDefault()} footer={footer}>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
<div className="rd-stack">
<Field label="Kunde" htmlFor={festerKunde ? undefined : 'rd-kunde'}>
{festerKunde ? (
<p>
<strong>{customerName(festerKunde) || 'Kunde'}</strong>
{festerKunde.email ? <span className="msg muted"> · {festerKunde.email}</span> : null}
</p>
) : quelle?.loading ? (
<p className="msg muted" role="status">
Kunden werden geladen …
</p>
) : quelle?.error ? (
<p className="msg warn" role="alert">
Die Kunden konnten nicht geladen werden: {quelle.error}
</p>
) : (
<AutocompleteInput
id="rd-kunde"
value={customerText}
onChange={kundeGeaendert}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
</Field>
{blockiert && (
<p className="msg warn" role="alert">
{customerName(kundeDoc)}: {festerKunde ? canInvoice(festerKunde).reason : resolved.option?.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!festerKunde && resolved.state === 'unknown' && (
<p className="rd-hint">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{!festerKunde && kunde && kunde.email && <p className="rd-hint">Die Rechnung geht an {kunde.email}.</p>}
{steuernummerHinweis(einstellungen) && (
<p className="msg warn" role="status">
{steuernummerHinweis(einstellungen)}
</p>
)}
{kunde && gate.warn && (
<AddressWarning
customer={kunde}
variant="dialog"
onShowOverview={
onShowOverview
? () => {
if (busy !== null) return
onClose()
onShowOverview()
}
: undefined
}
/>
)}
{kunde && (
<>
<Field label="Projekt" htmlFor="rd-projekt">
<select
id="rd-projekt"
className="select"
value={projekt === null ? '?' : projekt}
disabled={konten.loading}
onChange={(ev) => act({ type: 'projekt', projektId: ev.target.value })}
>
{projekt === null && (
<option value="?" disabled>
Projekt wählen …
</option>
)}
{projektOptionen(konten.data?.konten).map((o) => (
<option key={o.value || 'ohne'} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
{konten.loading && (
<p className="msg muted" role="status">
Projekte werden geladen …
</p>
)}
{konten.error && (
<p className="msg warn" role="alert">
Die Projekte konnten nicht geladen werden: {konten.error}{' '}
<button type="button" className="link" onClick={konten.reload}>
Erneut versuchen
</button>
</p>
)}
{konten.data && konten.data.konten.length === 0 && <p className="rd-hint">{KEINE_PROJEKTE_HINWEIS}</p>}
</>
)}
{kunde && projekt === '' && ohne.loading && (
<p className="msg muted" role="status">
Aufträge werden geladen …
</p>
)}
{kunde && projekt === '' && ohne.error && (
<p className="msg warn" role="alert">
Die Aufträge ohne Projekt konnten nicht geladen werden: {ohne.error}{' '}
<button type="button" className="link" onClick={ohne.reload}>
Erneut versuchen
</button>
</p>
)}
{kunde && projektBereit && projekt !== '' && (
<>
{detail.loading && !detail.data && (
<p className="msg muted" role="status">
Projektkonto wird geladen …
</p>
)}
{detail.error && !detail.data && (
<p className="msg warn" role="alert">
Das Projektkonto konnte nicht geladen werden: {detail.error}{' '}
<button type="button" className="link" onClick={detail.reload}>
Erneut versuchen
</button>
</p>
)}
{detail.data && (
<KontoZusammenfassung
zeile={kontoZeile(detail.data.konto, { stripeOk: detail.data.stripeOk })}
hinweis={stripeHinweis(detail.data.stripeOk)}
/>
)}
</>
)}
{kunde && projektBereit && kontoBereit && optionen.length > 1 && (
<fieldset className="rd-group">
<legend>Art der Rechnung</legend>
<div className="rd-stack">
<div className="seg rd-seg" role="group" aria-label="Art der Rechnung">
{optionen.map((o) => (
<button
key={o.key}
type="button"
aria-pressed={o.key === art}
disabled={!o.enabled}
title={o.hint || undefined}
onClick={() => act({ type: 'art', art: o.key })}
>
{o.label}
</button>
))}
</div>
{hinweise.map((h) => (
<p className="rd-hint" key={h}>
{h}
</p>
))}
</div>
</fieldset>
)}
{kunde && projektBereit && kontoBereit && art !== '' && inhalt()}
{kunde && projektBereit && kontoBereit && art !== '' && (
<>
<Field label="Leistungszeitraum (optional)" htmlFor="rd-zeitraum">
<input
id="rd-zeitraum"
className="input"
value={state.zeitraum}
placeholder={ZEITRAUM_PLATZHALTER}
onChange={(ev) => act({ type: 'zeitraum', text: ev.target.value })}
/>
</Field>
<div className="field-row">
<Field label="Zahlungsziel in Tagen" htmlFor="rd-tage">
<input
id="rd-tage"
className="input fin-amount"
inputMode="numeric"
value={state.days}
onChange={(ev) => act({ type: 'tage', wert: ev.target.value })}
/>
</Field>
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="rd-notiz">
<textarea
id="rd-notiz"
className="textarea"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={state.memo}
onChange={(ev) => act({ type: 'notiz', wert: ev.target.value })}
/>
</Field>
</div>
</>
)}
</div>
</Dialog>
)
}