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 (
+
+ )
+ }
+
+ 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 (
+
+ )
}
diff --git a/src/components/auftraege/rechnung-dialog.css b/src/components/auftraege/rechnung-dialog.css
index 40118bf..47277f4 100644
--- a/src/components/auftraege/rechnung-dialog.css
+++ b/src/components/auftraege/rechnung-dialog.css
@@ -2,7 +2,7 @@
müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */
.rd-group { border: 0; margin: 0; padding: 0; min-width: 0; }
.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
-.rd-stack { display: grid; gap: 8px; }
+.rd-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
.rd-facts.facts { margin: 0; }
.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; }
.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
@@ -36,6 +36,7 @@
.rd-lines { table-layout: fixed; }
.rd-lines .rd-line-text { overflow-wrap: anywhere; }
.rd-lines .fin-amount { width: 7.5rem; }
+.rd-lines th.fin-amount { text-align: right; }
.rd-line-input { width: 100%; min-width: 0; }
.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.rd-dim { opacity: 0.5; }
diff --git a/src/hooks/useRechnungDaten.js b/src/hooks/useRechnungDaten.js
index c1cf410..f7f97a4 100644
--- a/src/hooks/useRechnungDaten.js
+++ b/src/hooks/useRechnungDaten.js
@@ -80,12 +80,19 @@ export function useRechnungVorschau({ anfrage, schluessel, aktiv }) {
const bodyRef = useRef(null)
bodyRef.current = anfrage?.ok ? anfrage.body : null
const datenRef = useRef(null)
+ const kontextRef = useRef('')
useEffect(() => {
if (!aktiv || !bodyRef.current || !schluessel) {
datenRef.current = null
+ kontextRef.current = ''
return undefined
}
+ // Vorherige Zeilen bleiben nur innerhalb derselben Rechnung (Kunde, Art, Projekt, AB) abgeblendet stehen
+ const { customerId, art, projektId, auftragId } = bodyRef.current
+ const kontext = JSON.stringify([customerId, art, projektId, auftragId])
+ if (kontext !== kontextRef.current) datenRef.current = null
+ kontextRef.current = kontext
let aktuell = true
setVorschau({ key: schluessel, status: 'laedt', daten: datenRef.current })
const timer = setTimeout(async () => {