Finanzen: Rueckfrage vor dem Senden, Anschrift des Kunden pruefen, Fehlermeldungen im Bild
Senden einer Rechnung (Dialog und Entwurf in der Tabelle) fragt vorher: "Rechnung fuer
<Kunde> ueber <Betrag> jetzt festschreiben und an <E-Mail> senden? Danach laesst sie sich
nur noch stornieren."
Fehlt dem Kunden Strasse, PLZ oder Ort, zeigen Rechnungsdialog und Kundenansicht die
Warnung "Adresse des Kunden unvollstaendig ..." mit Weg zur Uebersicht, und Senden ist
ueber 250 € gesperrt (§ 14 Abs. 4 Nr. 1 UStG, Kleinbetragsrechnung); Entwuerfe bleiben
moeglich. Reine Helfer addressComplete und invoiceSendGate samt Tests.
Fehlermeldungen in Rechnungs- und Zuordnen-Dialog werden per scrollIntoView
({ block: 'nearest' }) in den sichtbaren Bereich geholt.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e72faf97c
commit
72f07410fe
9 files changed
+240
-15
No files matched your search
@@ -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 (
|
||||
<div className={`fin-address-warn fin-address-warn-${variant}`} role="alert">
|
||||
<span>{ADDRESS_INCOMPLETE_TEXT}</span>
|
||||
{onShowOverview ? (
|
||||
<button type="button" className="fin-link" onClick={onShowOverview}>
|
||||
Zur Übersicht
|
||||
</button>
|
||||
) : customer.$id ? (
|
||||
<Link className="fin-link" to={`/customers/${customer.$id}`}>
|
||||
Zur Übersicht
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaXmark } from 'react-icons/fa6'
|
||||
import { Badge, Button } from '../ui'
|
||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
|
||||
import {
|
||||
assignBody,
|
||||
@@ -40,11 +41,14 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
||||
const dirtyRef = useRef(false)
|
||||
const dialogRef = useRef(null)
|
||||
const confirmRef = useRef(null)
|
||||
const errorRef = useRef(null)
|
||||
|
||||
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
|
||||
const unassigned = !tx.category
|
||||
|
||||
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
|
||||
@@ -196,7 +200,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
||||
</dl>
|
||||
|
||||
{error && (
|
||||
<div className="fin-dialog-error" role="alert">
|
||||
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 }) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return <InvoiceTable customer={customer} />
|
||||
return (
|
||||
<>
|
||||
<AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />
|
||||
<InvoiceTable customer={customer} onShowOverview={onShowOverview} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} />
|
||||
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} onShowOverview={onShowOverview} />
|
||||
) : (
|
||||
<PickerForm onClose={onClose} onCreated={onCreated} />
|
||||
)
|
||||
@@ -44,10 +53,10 @@ export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCust
|
||||
|
||||
function PickerForm({ onClose, onCreated }) {
|
||||
const { customers, loading, error } = useCustomers()
|
||||
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} />
|
||||
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} onShowOverview={null} />
|
||||
}
|
||||
|
||||
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 }) {
|
||||
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
|
||||
|
||||
{error && (
|
||||
<div className="fin-dialog-error" role="alert">
|
||||
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -176,6 +201,21 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
||||
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
||||
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
|
||||
)}
|
||||
{gate.warn && (
|
||||
<AddressWarning
|
||||
customer={customer}
|
||||
variant="dialog"
|
||||
onShowOverview={
|
||||
onShowOverview
|
||||
? () => {
|
||||
if (lockRef.current) return
|
||||
onClose()
|
||||
onShowOverview()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset className="fin-items">
|
||||
@@ -289,7 +329,13 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
||||
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
|
||||
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={() => submit(true)} disabled={disabled}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => submit(true)}
|
||||
disabled={disabled || !gate.sendAllowed}
|
||||
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
||||
>
|
||||
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user