diff --git a/src/components/finance/AddressWarning.jsx b/src/components/finance/AddressWarning.jsx
new file mode 100644
index 0000000..ba64a33
--- /dev/null
+++ b/src/components/finance/AddressWarning.jsx
@@ -0,0 +1,26 @@
+import { Link } from 'react-router-dom'
+import { ADDRESS_INCOMPLETE_TEXT, addressComplete } from '../../lib/financeView'
+
+/**
+ * Hinweis „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit Weg zur
+ * Übersicht des Kunden (dort stehen die Stammdaten). Rendert nichts, wenn die Anschrift vollständig ist. Mit
+ * `onShowOverview` (Reiter am Kunden) wechselt ein Knopf zum Reiter Übersicht, sonst führt ein Link zur Kundenseite.
+ * `variant`: 'dialog' (im Rechnungsdialog) oder 'page' (über der Rechnungsliste des Kunden).
+ */
+export default function AddressWarning({ customer, onShowOverview, variant = 'page' }) {
+ if (!customer || addressComplete(customer)) return null
+ return (
+
+
{error}
)}
diff --git a/src/components/finance/CustomerInvoices.jsx b/src/components/finance/CustomerInvoices.jsx
index 2101679..2c93100 100644
--- a/src/components/finance/CustomerInvoices.jsx
+++ b/src/components/finance/CustomerInvoices.jsx
@@ -1,11 +1,13 @@
import { Button, Card, EmptyState } from '../ui'
+import AddressWarning from './AddressWarning'
import InvoiceTable from './InvoiceTable'
import { customerInvoicesGate } from '../../lib/financeView'
/**
* Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine
* E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die
- * Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest).
+ * Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). Fehlt dem Kunden die Anschrift
+ * (Straße, PLZ, Ort), steht darüber eine Warnung mit Knopf zur Übersicht; „Senden“ ist dann über 250 € gesperrt.
*/
export default function CustomerInvoices({ customer, onShowOverview }) {
const gate = customerInvoicesGate(customer)
@@ -20,5 +22,10 @@ export default function CustomerInvoices({ customer, onShowOverview }) {
)
}
- return
+ return (
+ <>
+
+
+ >
+ )
}
diff --git a/src/components/finance/InvoiceCreateModal.jsx b/src/components/finance/InvoiceCreateModal.jsx
index 67662cd..9461021 100644
--- a/src/components/finance/InvoiceCreateModal.jsx
+++ b/src/components/finance/InvoiceCreateModal.jsx
@@ -1,17 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
import { AutocompleteInput, Button } from '../ui'
+import AddressWarning from './AddressWarning'
import { useCustomers } from '../../hooks/useCustomers'
import { useDialogFocus } from '../../hooks/useDialogFocus'
+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,
@@ -26,17 +31,21 @@ const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', pri
* 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.
+ * 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 }) {
+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 ? (
-
+
) : (
)
@@ -44,10 +53,10 @@ export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCust
function PickerForm({ onClose, onCreated }) {
const { customers, loading, error } = useCustomers()
- return
+ return
}
-function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
+function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview }) {
const [customerText, setCustomerText] = useState('')
const [items, setItems] = useState(() => [newItem()])
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
@@ -57,8 +66,11 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
const lockRef = useRef(false)
const aliveRef = useRef(true)
const dialogRef = useRef(null)
+ const errorRef = useRef(null)
const onOverlayKeyDown = useDialogFocus(dialogRef)
+ // 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
@@ -83,6 +95,8 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
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)))
@@ -96,6 +110,17 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
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('')
@@ -135,7 +160,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
Rechnung schreiben
{error && (
-
+
{error}
)}
@@ -176,6 +201,21 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
Die Rechnung geht an {customer.email}.
)}
+ {gate.warn && (
+
{
+ if (lockRef.current) return
+ onClose()
+ onShowOverview()
+ }
+ : undefined
+ }
+ />
+ )}
@@ -289,7 +329,13 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
submit(false)} disabled={disabled}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
- submit(true)} disabled={disabled}>
+ submit(true)}
+ disabled={disabled || !gate.sendAllowed}
+ title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
+ >
{busy === 'send' ? 'Sende …' : 'Senden'}
diff --git a/src/components/finance/InvoiceTable.jsx b/src/components/finance/InvoiceTable.jsx
index b941b1d..7797be3 100644
--- a/src/components/finance/InvoiceTable.jsx
+++ b/src/components/finance/InvoiceTable.jsx
@@ -6,6 +6,7 @@ import Pager from './Pager'
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
import { pageOf } from '../../lib/financeFormat'
import {
+ ADDRESS_INCOMPLETE_TEXT,
DEFAULT_INVOICE_FILTER,
INVOICES_ANCHOR,
INVOICE_FILTERS,
@@ -13,6 +14,7 @@ import {
finalizedFilter,
invoiceConfirmText,
invoiceFilterStatuses,
+ invoiceSendGate,
invoiceDoneMessage,
invoiceFailureMessage,
invoiceRows,
@@ -32,12 +34,14 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del
*
* customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede
- * Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen.
+ * Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
+ * fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
+ * Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
*
* „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich
* `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu.
*/
-export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, refreshToken = 0, onChanged }) {
+export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, refreshToken = 0, onChanged, onShowOverview = null }) {
const filterCustomer = customerId || customer?.$id || null
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
const [reloadKey, setReloadKey] = useState(0)
@@ -76,6 +80,9 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
})
}, [status, filterCustomer, key, reloadKey, refreshToken])
+ // Nur mit bekanntem Kunden (Kundenansicht) lässt sich die Anschrift prüfen
+ const sendBlocked = (invoice) => Boolean(customer) && !invoiceSendGate(customer, invoice?.totalCents).sendAllowed
+
const reload = () => setReloadKey((k) => k + 1)
const changed = () => {
reload()
@@ -101,6 +108,7 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
async function run(row, action) {
if (lockRef.current) return
const invoice = byId[row.id]
+ if (action.key === 'send' && sendBlocked(invoice)) return
const confirmText = invoiceConfirmText(action.key, invoice)
if (confirmText && !window.confirm(confirmText)) return
lockRef.current = true
@@ -253,7 +261,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
size="sm"
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
onClick={() => run(row, action)}
- disabled={busy !== null}
+ disabled={busy !== null || (action.key === 'send' && sendBlocked(byId[row.id]))}
+ title={action.key === 'send' && sendBlocked(byId[row.id]) ? ADDRESS_INCOMPLETE_TEXT : undefined}
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
>
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
@@ -276,6 +285,7 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
onClose={() => setCreateOpen(false)}
onCreated={handleCreated}
fixedCustomer={customer}
+ onShowOverview={onShowOverview}
/>
)
diff --git a/src/hooks/useScrollIntoView.js b/src/hooks/useScrollIntoView.js
new file mode 100644
index 0000000..714b5e8
--- /dev/null
+++ b/src/hooks/useScrollIntoView.js
@@ -0,0 +1,14 @@
+import { useEffect } from 'react'
+
+/**
+ * Holt das Element in `ref` in den sichtbaren Bereich, sobald sich `trigger` ändert und nicht leer ist (zum Beispiel eine
+ * Fehlermeldung ganz oben in einem langen Dialog, die sonst außerhalb des Fensters stehen würde). `block: 'nearest'`
+ * bewegt nur so weit wie nötig, ein schon sichtbares Element bleibt ruhig.
+ */
+export function useScrollIntoView(ref, trigger) {
+ useEffect(() => {
+ if (!trigger) return
+ const el = ref.current
+ if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' })
+ }, [ref, trigger])
+}
diff --git a/src/lib/financeView.js b/src/lib/financeView.js
index f1f08f3..3a1c811 100644
--- a/src/lib/financeView.js
+++ b/src/lib/financeView.js
@@ -542,13 +542,31 @@ export function invoiceRows(invoices) {
})
}
-/** Rückfrage vor dem Löschen eines Entwurfs, dem Stornieren und der Erinnerung; '' für Aktionen ohne Rückfrage. */
+/**
+ * Rückfrage vor dem Festschreiben und Senden einer Rechnung (Dialog „Senden“ und Entwurf in der Tabelle):
+ * „Rechnung für