diff --git a/src/components/WorksheetList.jsx b/src/components/WorksheetList.jsx index b9daa22..a4a9c8f 100644 --- a/src/components/WorksheetList.jsx +++ b/src/components/WorksheetList.jsx @@ -1,6 +1,8 @@ import { EmptyState } from './ui' import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm' import { parseSupporters } from '../lib/support' +import { useAbgerechnet } from '../hooks/useAbgerechnet' +import { abgerechnetText, ticketIdsOf } from '../lib/projektkontoView' import { showAboToggle, statusAfterLabel, @@ -23,6 +25,8 @@ export default function WorksheetList({ onOwnSupport, currentUserId, }) { + // Hooks laufen immer, auch bei den frühen Rückgaben unten; die Abzeichen sehen alle Mitarbeiter und zeigen nie Beträge + const abgerechnet = useAbgerechnet(ticketIdsOf(worksheets)) if (loading) return
Wird geladen …
if (!worksheets || worksheets.length === 0) { @@ -35,6 +39,7 @@ export default function WorksheetList({ const when = worksheetWhen(ws) const kind = worksheetKindLabel(ws) const badge = worksheetBadge(ws) + const billed = abgerechnetText(abgerechnet, ws.$id) const lines = supportLines(ws) const status = statusAfterLabel(ws) const time = timeLabel(ws) @@ -58,6 +63,18 @@ export default function WorksheetList({ {kind && {kind}} WSID {ws.wsid} {badge && {badge.text}} + {billed && ( + + {billed} + + )} {showAbo ? ( + + ) + })} + + + ) +} diff --git a/src/components/auftraege/KontoZusammenfassung.jsx b/src/components/auftraege/KontoZusammenfassung.jsx new file mode 100644 index 0000000..782eec9 --- /dev/null +++ b/src/components/auftraege/KontoZusammenfassung.jsx @@ -0,0 +1,36 @@ +/** + * Zusammenfassung des Projektkontos im Rechnungsdialog: Vereinbart, Berechnet (darunter bezahlt), Noch offen, Zeit erfasst. + * Rein darstellend. Props: zeile (aus kontoZeile) und hinweis (aus stripeHinweis, leer = kein Hinweis). + */ +export default function KontoZusammenfassung({ zeile, hinweis }) { + return ( + <> +
+
+
Vereinbart
+
{zeile.vereinbart}
+
+
+
Berechnet
+
{zeile.berechnet}
+ {zeile.bezahlt &&
{zeile.bezahlt}
} +
+
+
Noch offen
+
{zeile.offen}
+
+
+
Zeit erfasst
+
{zeile.zeit.stunden}
+
{zeile.zeit.wert}
+ {zeile.zeit.abo &&
{zeile.zeit.abo}
} +
+
+ {hinweis && ( +

+ {hinweis} +

+ )} + + ) +} diff --git a/src/components/auftraege/ProjektkontoListe.jsx b/src/components/auftraege/ProjektkontoListe.jsx index 4b4bd06..73da2f5 100644 --- a/src/components/auftraege/ProjektkontoListe.jsx +++ b/src/components/auftraege/ProjektkontoListe.jsx @@ -1,11 +1,81 @@ +import './projektkonto.css' +import { Card } from '../ui' +import { kontoZeile, ohneProjektZeile, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView' + /** - * Platzhalter von W0: Strang W1 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). - * Die fertige Komponente rendert ihren Abschnitt selbst: (ein
; den Abstand zu den - * Nachbarn liefert `.page section + section`). - * - * Props (V8): konten (Projektkonten), ohneProjekt ({ berechnetCents, bezahltCents, anzahl }), auftraegeAktiv, stripeOk, - * onRechnung(projektId) und onAuftrag(projektId); die Kennung ist die des Projekts, `null` die Zeile „Ohne Projekt“. + * Abschnitt „Projekte“ im Kundenreiter „Aufträge und Rechnungen“: je Projekt eine Zeile mit Vereinbart, Berechnet + * (darunter bezahlt), Noch offen und erfasster Zeit, am Ende die feste Zeile „Ohne Projekt“. + * Props: konten (Projektkonten, darf fehlen), ohneProjekt ({ berechnetCents, bezahltCents, anzahl }), auftraegeAktiv, + * stripeOk, onRechnung(projektId) und onAuftrag(projektId). Die Kennung ist die des Projekts, `null` die Zeile „Ohne Projekt“. + * Die Überschrift „Projekte“ rendert die Komponente selbst; die Anzeigelogik steht in lib/projektkontoView.js. */ export default function ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag }) { - return null + const liste = Array.isArray(konten) ? konten : [] + const zeilen = [...liste.map((k) => kontoZeile(k, { stripeOk })), ohneProjektZeile(ohneProjekt, { stripeOk })] + const hinweis = stripeHinweis(stripeOk) + + return ( + +
+ {hinweis && ( +

+ {hinweis} +

+ )} + {liste.length === 0 &&

{KEINE_PROJEKTE_HINWEIS}

} + + + + + + + + + + + + + + {zeilen.map((z) => { + const id = z.projektId || null + const fuer = id === null ? 'ohne Projekt' : `für ${z.name}` + return ( + + + + + + + + + ) + })} + +
Projektkonten
ProjektVereinbartBerechnetNoch offenZeit erfasstAktionen
+
{z.name}
+ {z.meta &&
{z.meta}
} +
{z.vereinbart} +
{z.berechnet}
+ {z.bezahlt &&
{z.bezahlt}
} +
+ {z.offen} + +
{z.zeit.stunden}
+ {z.zeit.wert &&
{z.zeit.wert}
} + {z.zeit.abo &&
{z.zeit.abo}
} +
+
+ {auftraegeAktiv && ( + + )} + +
+
+
+
+ ) } diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx index 1daa360..dc8d40c 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“. 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 })} + /> + + +