From af43c702883de79a133ad23f8acce65f2e6ad475 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:46:02 +0000 Subject: [PATCH] =?UTF-8?q?Rechnungsdialog:=20alter=20InvoiceCreateModal?= =?UTF-8?q?=20entf=C3=A4llt,=20RechnungDialog=20ersetzt=20ihn=20=C3=BCbera?= =?UTF-8?q?ll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/auftraege/RechnungDialog.jsx | 2 +- src/components/finance/InvoiceCreateModal.jsx | 320 ------------------ 2 files changed, 1 insertion(+), 321 deletions(-) delete mode 100644 src/components/finance/InvoiceCreateModal.jsx diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx index cfdd282..dc8d40c 100644 --- a/src/components/auftraege/RechnungDialog.jsx +++ b/src/components/auftraege/RechnungDialog.jsx @@ -55,7 +55,7 @@ import { import { anfangszustand, reduce } from '../../lib/rechnungDialogState' /** - * Dialog „Rechnung schreiben“ (ersetzt InvoiceCreateModal). Ablauf von oben nach unten: Kunde (fest oder per Suche), Projekt, + * 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. diff --git a/src/components/finance/InvoiceCreateModal.jsx b/src/components/finance/InvoiceCreateModal.jsx deleted file mode 100644 index 44ecf91..0000000 --- a/src/components/finance/InvoiceCreateModal.jsx +++ /dev/null @@ -1,320 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { AutocompleteInput, Button, Dialog, Field } from '../ui' -import AddressWarning from './AddressWarning' -import { useCustomers } from '../../hooks/useCustomers' -import { useScrollIntoView } from '../../hooks/useScrollIntoView' -import { createInvoice } from '../../lib/financeApi' -import { canInvoice, euro } from '../../lib/financeFormat' -import { - ADDRESS_INCOMPLETE_TEXT, - INVOICE_LIMITS, - customerName, - customerPickerOptions, - customerSuggestions, - finalizedFilter, - invoiceFailureMessage, - invoiceSendConfirmText, - invoiceSendGate, - invoiceTotalCents, - isFinalizedNotSent, - lineTotalCents, - resolveCustomerText, - validateInvoiceForm, -} from '../../lib/financeView' - -let itemCounter = 0 -const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' }) - -/** - * Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest), - * Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der - * Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft; - * Fehler des Servers stehen im Dialog (und werden in den sichtbaren Bereich geholt). „Senden“ fragt vorher nach - * (die Rechnung wird festgeschrieben und per Mail an den Kunden geschickt). Fehlt dem Kunden die Anschrift (Straße, - * PLZ, Ort), steht eine Warnung mit Weg zur Übersicht da, und „Senden“ ist über 250 € gesperrt (§ 14 Abs. 4 Nr. 1 - * UStG; Entwürfe bleiben möglich). `onShowOverview` (Reiter am Kunden) wechselt zur Übersicht, sonst führt ein Link - * zur Kundenseite. - * - * onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit - * invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt - * (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose(). - */ -export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null, onShowOverview = null }) { - if (!open) return null - // Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht - return fixedCustomer ? ( - - ) : ( - - ) -} - -function PickerForm({ onClose, onCreated }) { - const { customers, loading, error } = useCustomers() - return -} - -function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview }) { - const [customerText, setCustomerText] = useState('') - const [items, setItems] = useState(() => [newItem()]) - const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays)) - const [memo, setMemo] = useState('') - const [error, setError] = useState('') - const [busy, setBusy] = useState(null) // 'draft' | 'send' - const lockRef = useRef(false) - const aliveRef = useRef(true) - const errorRef = useRef(null) - - // Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen - useScrollIntoView(errorRef, error) - - useEffect(() => { - aliveRef.current = true - return () => { - aliveRef.current = false - } - }, []) - - // × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt). - // Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab. - const close = () => { - if (lockRef.current) return - onClose() - } - - const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers]) - const resolved = fixedCustomer - ? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null } - : resolveCustomerText(options, customerText) - const customer = fixedCustomer || resolved.option?.customer || null - const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true } - const total = invoiceTotalCents(items) - // Anschrift des Empfängers (§ 14 Abs. 4 Nr. 1 UStG): ohne sie ist „Senden“ nur bis 250 € möglich - const gate = invoiceSendGate(customerBlocked.ok ? customer : null, total) - - const updateItem = (uid, field, value) => { - setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it))) - if (error) setError('') - } - - async function submit(senden) { - if (lockRef.current) return - const check = validateInvoiceForm({ customer, items, days, memo }) - if (!check.ok) { - setError(check.error) - return - } - if (senden) { - if (!invoiceSendGate(customer, check.totalCents).sendAllowed) { - setError(ADDRESS_INCOMPLETE_TEXT) - return - } - // Senden schreibt die Rechnung fest und schickt sie an den Kunden: erst nach Rückfrage - const sure = window.confirm( - invoiceSendConfirmText({ name: customerName(customer), email: customer.email, totalCents: check.totalCents }) - ) - if (!sure) return - } - lockRef.current = true - setBusy(senden ? 'send' : 'draft') - setError('') - try { - const data = await createInvoice({ ...check.payload, senden }) - if (onCreated) onCreated(data?.rechnung || null, { senden }) - onClose() - } catch (err) { - if (isFinalizedNotSent(err)) { - // Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie - if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) }) - onClose() - return - } - if (aliveRef.current) setError(invoiceFailureMessage(err)) - } finally { - lockRef.current = false - if (aliveRef.current) setBusy(null) - } - } - - const disabled = busy !== null - - const footer = ( -
- - - -
- ) - - // Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button" - return ( - e.preventDefault()} footer={footer}> - {error && ( -
- {error} -
- )} - - - {fixedCustomer ? ( -

- {customerName(fixedCustomer) || 'Kunde'} - {fixedCustomer.email ? · {fixedCustomer.email} : null} -

- ) : source?.loading ? ( -

Kunden werden geladen …

- ) : source?.error ? ( -

Die Kunden konnten nicht geladen werden: {source.error}

- ) : ( - { - setCustomerText(v) - if (error) setError('') - }} - getSuggestions={(t) => customerSuggestions(options, t)} - placeholder="Kunde suchen …" - /> - )} - {!customerBlocked.ok && ( -

- {customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden. -

- )} - {!fixedCustomer && resolved.state === 'unknown' && ( -

Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.

- )} - {customer && customerBlocked.ok && customer.email && !fixedCustomer && ( -

Die Rechnung geht an {customer.email}.

- )} - {gate.warn && ( - { - if (lockRef.current) return - onClose() - onShowOverview() - } - : undefined - } - /> - )} -
- -
- Positionen - {items.map((item, index) => { - const line = lineTotalCents(item) - const n = index + 1 - return ( -
-
- - updateItem(item.uid, 'description', e.target.value)} - aria-label={`Position ${n}: Beschreibung`} - placeholder="z. B. Webdesign Startseite" - /> -
-
- - updateItem(item.uid, 'quantity', e.target.value)} - aria-label={`Position ${n}: Menge`} - /> -
-
- - updateItem(item.uid, 'price', e.target.value)} - aria-label={`Position ${n}: Einzelpreis in Euro`} - placeholder="0,00" - /> -
-
- Summe - {line === null ? '–' : euro(line)} -
-
- -
-
- ) - })} -
- -
- Summe - {euro(total)} -
-
-

Gemäß § 19 UStG ohne Umsatzsteuer.

-
- -
- - { - setDays(e.target.value) - if (error) setError('') - }} - /> - - -