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 { FaXmark } from 'react-icons/fa6'
|
||||||
import { Badge, Button } from '../ui'
|
import { Badge, Button } from '../ui'
|
||||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||||
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
|
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
|
||||||
import {
|
import {
|
||||||
assignBody,
|
assignBody,
|
||||||
@@ -40,11 +41,14 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
const dirtyRef = useRef(false)
|
const dirtyRef = useRef(false)
|
||||||
const dialogRef = useRef(null)
|
const dialogRef = useRef(null)
|
||||||
const confirmRef = useRef(null)
|
const confirmRef = useRef(null)
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
|
||||||
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
|
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
|
||||||
const unassigned = !tx.category
|
const unassigned = !tx.category
|
||||||
|
|
||||||
const onOverlayKeyDown = useDialogFocus(dialogRef)
|
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(() => {
|
useEffect(() => {
|
||||||
aliveRef.current = true
|
aliveRef.current = true
|
||||||
@@ -196,7 +200,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="fin-dialog-error" role="alert">
|
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Button, Card, EmptyState } from '../ui'
|
import { Button, Card, EmptyState } from '../ui'
|
||||||
|
import AddressWarning from './AddressWarning'
|
||||||
import InvoiceTable from './InvoiceTable'
|
import InvoiceTable from './InvoiceTable'
|
||||||
import { customerInvoicesGate } from '../../lib/financeView'
|
import { customerInvoicesGate } from '../../lib/financeView'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine
|
* 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
|
* 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 }) {
|
export default function CustomerInvoices({ customer, onShowOverview }) {
|
||||||
const gate = customerInvoicesGate(customer)
|
const gate = customerInvoicesGate(customer)
|
||||||
@@ -20,5 +22,10 @@ export default function CustomerInvoices({ customer, onShowOverview }) {
|
|||||||
</Card>
|
</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 { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
|
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
|
||||||
import { AutocompleteInput, Button } from '../ui'
|
import { AutocompleteInput, Button } from '../ui'
|
||||||
|
import AddressWarning from './AddressWarning'
|
||||||
import { useCustomers } from '../../hooks/useCustomers'
|
import { useCustomers } from '../../hooks/useCustomers'
|
||||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||||
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
import { createInvoice } from '../../lib/financeApi'
|
import { createInvoice } from '../../lib/financeApi'
|
||||||
import { canInvoice, euro } from '../../lib/financeFormat'
|
import { canInvoice, euro } from '../../lib/financeFormat'
|
||||||
import {
|
import {
|
||||||
|
ADDRESS_INCOMPLETE_TEXT,
|
||||||
INVOICE_LIMITS,
|
INVOICE_LIMITS,
|
||||||
customerName,
|
customerName,
|
||||||
customerPickerOptions,
|
customerPickerOptions,
|
||||||
customerSuggestions,
|
customerSuggestions,
|
||||||
finalizedFilter,
|
finalizedFilter,
|
||||||
invoiceFailureMessage,
|
invoiceFailureMessage,
|
||||||
|
invoiceSendConfirmText,
|
||||||
|
invoiceSendGate,
|
||||||
invoiceTotalCents,
|
invoiceTotalCents,
|
||||||
isFinalizedNotSent,
|
isFinalizedNotSent,
|
||||||
lineTotalCents,
|
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),
|
* 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
|
* 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;
|
* 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
|
* 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
|
* 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().
|
* (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
|
if (!open) return null
|
||||||
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
|
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
|
||||||
return fixedCustomer ? (
|
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} />
|
<PickerForm onClose={onClose} onCreated={onCreated} />
|
||||||
)
|
)
|
||||||
@@ -44,10 +53,10 @@ export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCust
|
|||||||
|
|
||||||
function PickerForm({ onClose, onCreated }) {
|
function PickerForm({ onClose, onCreated }) {
|
||||||
const { customers, loading, error } = useCustomers()
|
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 [customerText, setCustomerText] = useState('')
|
||||||
const [items, setItems] = useState(() => [newItem()])
|
const [items, setItems] = useState(() => [newItem()])
|
||||||
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
|
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
|
||||||
@@ -57,8 +66,11 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
|||||||
const lockRef = useRef(false)
|
const lockRef = useRef(false)
|
||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
const dialogRef = useRef(null)
|
const dialogRef = useRef(null)
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
|
||||||
const onOverlayKeyDown = useDialogFocus(dialogRef)
|
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(() => {
|
useEffect(() => {
|
||||||
aliveRef.current = true
|
aliveRef.current = true
|
||||||
@@ -83,6 +95,8 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
|||||||
const customer = fixedCustomer || resolved.option?.customer || null
|
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 customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
|
||||||
const total = invoiceTotalCents(items)
|
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) => {
|
const updateItem = (uid, field, value) => {
|
||||||
setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
|
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)
|
setError(check.error)
|
||||||
return
|
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
|
lockRef.current = true
|
||||||
setBusy(senden ? 'send' : 'draft')
|
setBusy(senden ? 'send' : 'draft')
|
||||||
setError('')
|
setError('')
|
||||||
@@ -135,7 +160,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
|||||||
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
|
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="fin-dialog-error" role="alert">
|
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -176,6 +201,21 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
|||||||
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
||||||
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<fieldset className="fin-items">
|
<fieldset className="fin-items">
|
||||||
@@ -289,7 +329,13 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
|||||||
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
|
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
|
||||||
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
||||||
</Button>
|
</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'}
|
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Pager from './Pager'
|
|||||||
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
||||||
import { pageOf } from '../../lib/financeFormat'
|
import { pageOf } from '../../lib/financeFormat'
|
||||||
import {
|
import {
|
||||||
|
ADDRESS_INCOMPLETE_TEXT,
|
||||||
DEFAULT_INVOICE_FILTER,
|
DEFAULT_INVOICE_FILTER,
|
||||||
INVOICES_ANCHOR,
|
INVOICES_ANCHOR,
|
||||||
INVOICE_FILTERS,
|
INVOICE_FILTERS,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
finalizedFilter,
|
finalizedFilter,
|
||||||
invoiceConfirmText,
|
invoiceConfirmText,
|
||||||
invoiceFilterStatuses,
|
invoiceFilterStatuses,
|
||||||
|
invoiceSendGate,
|
||||||
invoiceDoneMessage,
|
invoiceDoneMessage,
|
||||||
invoiceFailureMessage,
|
invoiceFailureMessage,
|
||||||
invoiceRows,
|
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
|
* 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
|
* (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
|
* „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.
|
* `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 filterCustomer = customerId || customer?.$id || null
|
||||||
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
|
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
@@ -76,6 +80,9 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
})
|
})
|
||||||
}, [status, filterCustomer, key, reloadKey, refreshToken])
|
}, [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 reload = () => setReloadKey((k) => k + 1)
|
||||||
const changed = () => {
|
const changed = () => {
|
||||||
reload()
|
reload()
|
||||||
@@ -101,6 +108,7 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
async function run(row, action) {
|
async function run(row, action) {
|
||||||
if (lockRef.current) return
|
if (lockRef.current) return
|
||||||
const invoice = byId[row.id]
|
const invoice = byId[row.id]
|
||||||
|
if (action.key === 'send' && sendBlocked(invoice)) return
|
||||||
const confirmText = invoiceConfirmText(action.key, invoice)
|
const confirmText = invoiceConfirmText(action.key, invoice)
|
||||||
if (confirmText && !window.confirm(confirmText)) return
|
if (confirmText && !window.confirm(confirmText)) return
|
||||||
lockRef.current = true
|
lockRef.current = true
|
||||||
@@ -253,7 +261,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
|
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
|
||||||
onClick={() => run(row, action)}
|
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}`}
|
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
|
||||||
>
|
>
|
||||||
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
|
{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)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onCreated={handleCreated}
|
onCreated={handleCreated}
|
||||||
fixedCustomer={customer}
|
fixedCustomer={customer}
|
||||||
|
onShowOverview={onShowOverview}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
+45
-1
@@ -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 <Kunde> über <Betrag> jetzt festschreiben und an <E-Mail> senden? Danach lässt sie sich nur noch
|
||||||
|
* stornieren.“ Fehlen Name oder E-Mail, steht dort „den Kunden“.
|
||||||
|
*/
|
||||||
|
export function invoiceSendConfirmText({ name, email, totalCents } = {}) {
|
||||||
|
const mail = text(email)
|
||||||
|
const who = text(name) || mail || 'den Kunden'
|
||||||
|
return `Rechnung für ${who} über ${euro(totalCents)} jetzt festschreiben und an ${mail || 'den Kunden'} senden? Danach lässt sie sich nur noch stornieren.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückfrage vor dem Senden eines Entwurfs, dem Löschen eines Entwurfs, dem Stornieren und der Erinnerung; '' für
|
||||||
|
* Aktionen ohne Rückfrage (auch „send“ bei einer Rechnung, die kein Entwurf mehr ist).
|
||||||
|
*/
|
||||||
export function invoiceConfirmText(actionKey, invoice) {
|
export function invoiceConfirmText(actionKey, invoice) {
|
||||||
const inv = obj(invoice)
|
const inv = obj(invoice)
|
||||||
const who = text(inv.customerName) || text(inv.customerEmail) || 'den Kunden'
|
const who = text(inv.customerName) || text(inv.customerEmail) || 'den Kunden'
|
||||||
const total = euro(inv.totalCents)
|
const total = euro(inv.totalCents)
|
||||||
const number = text(inv.number) || 'ohne Nummer'
|
const number = text(inv.number) || 'ohne Nummer'
|
||||||
switch (actionKey) {
|
switch (actionKey) {
|
||||||
|
case 'send':
|
||||||
|
return inv.status === 'entwurf'
|
||||||
|
? invoiceSendConfirmText({ name: inv.customerName, email: inv.customerEmail, totalCents: inv.totalCents })
|
||||||
|
: ''
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return `Den Entwurf für ${who} über ${total} wirklich löschen? Das lässt sich nicht rückgängig machen.`
|
return `Den Entwurf für ${who} über ${total} wirklich löschen? Das lässt sich nicht rückgängig machen.`
|
||||||
case 'void':
|
case 'void':
|
||||||
@@ -778,6 +796,32 @@ export function customerInvoicesGate(customer) {
|
|||||||
return { ok: false, title: check.reason, hint: '' }
|
return { ok: false, title: check.reason, hint: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Anschrift des Rechnungsempfängers (§ 14 Abs. 4 Nr. 1 UStG) --------------------------------------------
|
||||||
|
|
||||||
|
/** Kleinbetragsrechnung (§ 33 UStDV): bis 250 € brauchen Rechnungen keine Anschrift des Empfängers. */
|
||||||
|
export const SMALL_INVOICE_MAX_CENTS = 25_000
|
||||||
|
export const ADDRESS_INCOMPLETE_TEXT =
|
||||||
|
'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'
|
||||||
|
|
||||||
|
/** Hat der Kunde (Appwrite-Dokument) Straße, PLZ und Ort? Leere und nur aus Leerzeichen bestehende Felder zählen nicht. */
|
||||||
|
export function addressComplete(customer) {
|
||||||
|
const c = obj(customer)
|
||||||
|
return Boolean(text(c.street) && text(c.postalCode) && text(c.city))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf diese Rechnung gesendet werden? { warn, sendAllowed, text }. warn: die Anschrift des Kunden ist unvollständig
|
||||||
|
* (Hinweis mit `text` zeigen). sendAllowed ist nur dann false, wenn zugleich die Anschrift fehlt und die Summe über
|
||||||
|
* 250 € liegt (genau 250,00 € ist noch eine Kleinbetragsrechnung); Entwürfe sind nie betroffen (sie haben keinen
|
||||||
|
* Versand). Ohne Kunden gibt es keine Warnung, die Formularprüfung meldet das fehlende Feld.
|
||||||
|
*/
|
||||||
|
export function invoiceSendGate(customer, totalCents) {
|
||||||
|
if (!customer || typeof customer !== 'object') return { warn: false, sendAllowed: true, text: '' }
|
||||||
|
const complete = addressComplete(customer)
|
||||||
|
const small = isNum(totalCents) && totalCents <= SMALL_INVOICE_MAX_CENTS // unbekannte Summe gilt nicht als klein
|
||||||
|
return { warn: !complete, sendAllowed: complete || small, text: complete ? '' : ADDRESS_INCOMPLETE_TEXT }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
|
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
|
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
|
||||||
|
|||||||
@@ -1375,6 +1375,10 @@ textarea.form-control {
|
|||||||
.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: #fbbf24; overflow-wrap: anywhere; }
|
.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: #fbbf24; overflow-wrap: anywhere; }
|
||||||
.fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
|
.fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||||
.fin-fixed-customer { margin: 0; overflow-wrap: anywhere; }
|
.fin-fixed-customer { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px var(--sp-3); font-size: 13px; overflow-wrap: anywhere; }
|
||||||
|
.fin-address-warn-dialog { margin: 6px 0 0; color: #fbbf24; }
|
||||||
|
.fin-address-warn-page { margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); color: var(--warn); }
|
||||||
|
.fin-address-warn .fin-link { font-size: 13px; }
|
||||||
.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; }
|
.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; }
|
||||||
.fin-items legend { padding: 0; }
|
.fin-items legend { padding: 0; }
|
||||||
.fin-item {
|
.fin-item {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const {
|
|||||||
forecastRows,
|
forecastRows,
|
||||||
forecastNotes,
|
forecastNotes,
|
||||||
taxView,
|
taxView,
|
||||||
TAX_NOTE,
|
|
||||||
aboView,
|
aboView,
|
||||||
productLabel,
|
productLabel,
|
||||||
aboStatus,
|
aboStatus,
|
||||||
@@ -34,6 +33,7 @@ const {
|
|||||||
invoiceActions,
|
invoiceActions,
|
||||||
invoiceRows,
|
invoiceRows,
|
||||||
invoiceConfirmText,
|
invoiceConfirmText,
|
||||||
|
invoiceSendConfirmText,
|
||||||
invoiceDoneMessage,
|
invoiceDoneMessage,
|
||||||
isFinalizedNotSent,
|
isFinalizedNotSent,
|
||||||
finalizedFilter,
|
finalizedFilter,
|
||||||
@@ -48,6 +48,11 @@ const {
|
|||||||
customerSuggestions,
|
customerSuggestions,
|
||||||
resolveCustomerText,
|
resolveCustomerText,
|
||||||
customerInvoicesGate,
|
customerInvoicesGate,
|
||||||
|
SMALL_INVOICE_MAX_CENTS,
|
||||||
|
ADDRESS_INCOMPLETE_TEXT,
|
||||||
|
addressComplete,
|
||||||
|
invoiceSendGate,
|
||||||
|
TAX_NOTE,
|
||||||
sortTransactions,
|
sortTransactions,
|
||||||
strongestSuggestion,
|
strongestSuggestion,
|
||||||
suggestionText,
|
suggestionText,
|
||||||
@@ -790,10 +795,41 @@ test('invoiceConfirmText: Löschen, Stornieren, Erinnerung; sonst leer', () => {
|
|||||||
assert.equal(invoiceConfirmText('delete', inv), 'Den Entwurf für Kunde Zwei über 50,00 € wirklich löschen? Das lässt sich nicht rückgängig machen.')
|
assert.equal(invoiceConfirmText('delete', inv), 'Den Entwurf für Kunde Zwei über 50,00 € wirklich löschen? Das lässt sich nicht rückgängig machen.')
|
||||||
assert.equal(invoiceConfirmText('void', inv), 'Die Rechnung WK-0006 an Kunde Zwei über 50,00 € wirklich stornieren? Das lässt sich nicht rückgängig machen.')
|
assert.equal(invoiceConfirmText('void', inv), 'Die Rechnung WK-0006 an Kunde Zwei über 50,00 € wirklich stornieren? Das lässt sich nicht rückgängig machen.')
|
||||||
assert.equal(invoiceConfirmText('resend', inv), 'Die Rechnung WK-0006 jetzt noch einmal an k@example.invalid senden (Erinnerung)?')
|
assert.equal(invoiceConfirmText('resend', inv), 'Die Rechnung WK-0006 jetzt noch einmal an k@example.invalid senden (Erinnerung)?')
|
||||||
|
// „send“ gibt es nur für Entwürfe; bei jeder anderen Rechnung keine Rückfrage
|
||||||
assert.equal(invoiceConfirmText('send', inv), '')
|
assert.equal(invoiceConfirmText('send', inv), '')
|
||||||
assert.equal(invoiceConfirmText('pdf', inv), '')
|
assert.equal(invoiceConfirmText('pdf', inv), '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('invoiceConfirmText: Senden eines Entwurfs fragt vorher, mit Kunde, Betrag und E-Mail', () => {
|
||||||
|
const draft = INV({ status: 'entwurf', number: '', totalCents: 120050 })
|
||||||
|
assert.equal(
|
||||||
|
invoiceConfirmText('send', draft),
|
||||||
|
'Rechnung für Kunde Zwei über 1.200,50 € jetzt festschreiben und an k@example.invalid senden? Danach lässt sie sich nur noch stornieren.'
|
||||||
|
)
|
||||||
|
// ohne Namen steht die E-Mail als Kunde, ohne beides „den Kunden“
|
||||||
|
assert.equal(
|
||||||
|
invoiceConfirmText('send', INV({ status: 'entwurf', customerName: '', customerEmail: 'a@example.invalid' })),
|
||||||
|
'Rechnung für a@example.invalid über 50,00 € jetzt festschreiben und an a@example.invalid senden? Danach lässt sie sich nur noch stornieren.'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
invoiceConfirmText('send', INV({ status: 'entwurf', customerName: '', customerEmail: '' })),
|
||||||
|
'Rechnung für den Kunden über 50,00 € jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nur noch stornieren.'
|
||||||
|
)
|
||||||
|
for (const status of ['offen', 'ueberfaellig', 'bezahlt']) assert.equal(invoiceConfirmText('send', INV({ status })), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invoiceSendConfirmText: Text für den Dialog „Senden“ (Kunde, Summe, E-Mail des gewählten Kunden)', () => {
|
||||||
|
assert.equal(
|
||||||
|
invoiceSendConfirmText({ name: 'Müller Sanitär', email: 'm@example.invalid', totalCents: 15050 }),
|
||||||
|
'Rechnung für Müller Sanitär über 150,50 € jetzt festschreiben und an m@example.invalid senden? Danach lässt sie sich nur noch stornieren.'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
invoiceSendConfirmText({ name: ' ', email: ' m@example.invalid ', totalCents: 100 }),
|
||||||
|
'Rechnung für m@example.invalid über 1,00 € jetzt festschreiben und an m@example.invalid senden? Danach lässt sie sich nur noch stornieren.'
|
||||||
|
)
|
||||||
|
assert.match(invoiceSendConfirmText(), /^Rechnung für den Kunden über /)
|
||||||
|
})
|
||||||
|
|
||||||
test('invoiceDoneMessage', () => {
|
test('invoiceDoneMessage', () => {
|
||||||
const r = { number: 'WK-0007', customerEmail: 'k@example.invalid' }
|
const r = { number: 'WK-0007', customerEmail: 'k@example.invalid' }
|
||||||
assert.equal(invoiceDoneMessage('send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
|
assert.equal(invoiceDoneMessage('send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
|
||||||
@@ -1220,3 +1256,37 @@ test('customerInvoicesGate: fester Kunde mit E-Mail darf, alle anderen bekommen
|
|||||||
assert.equal(none.title, 'Kein Kunde ausgewählt')
|
assert.equal(none.title, 'Kein Kunde ausgewählt')
|
||||||
assert.equal(none.hint, '')
|
assert.equal(none.hint, '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('addressComplete: Straße, PLZ und Ort müssen alle gefüllt sein', () => {
|
||||||
|
const full = { street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin' }
|
||||||
|
assert.equal(addressComplete(full), true)
|
||||||
|
assert.equal(addressComplete({ ...full, street: '' }), false)
|
||||||
|
assert.equal(addressComplete({ ...full, postalCode: ' ' }), false)
|
||||||
|
assert.equal(addressComplete({ ...full, city: undefined }), false)
|
||||||
|
assert.equal(addressComplete({ ...full, city: null }), false)
|
||||||
|
assert.equal(addressComplete({}), false)
|
||||||
|
assert.equal(addressComplete(null), false)
|
||||||
|
// Land allein macht keine Anschrift
|
||||||
|
assert.equal(addressComplete({ country: 'Deutschland' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invoiceSendGate: Senden nur gesperrt bei fehlender Anschrift UND Summe über 250 €', () => {
|
||||||
|
assert.equal(SMALL_INVOICE_MAX_CENTS, 25000)
|
||||||
|
assert.equal(ADDRESS_INCOMPLETE_TEXT, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.')
|
||||||
|
const open = { street: '', postalCode: '10115', city: 'Berlin' }
|
||||||
|
const full = { street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin' }
|
||||||
|
|
||||||
|
// unvollständig + über 250 €: Warnung, Senden gesperrt
|
||||||
|
assert.deepEqual(invoiceSendGate(open, 25001), { warn: true, sendAllowed: false, text: ADDRESS_INCOMPLETE_TEXT })
|
||||||
|
assert.deepEqual(invoiceSendGate(open, 120050), { warn: true, sendAllowed: false, text: ADDRESS_INCOMPLETE_TEXT })
|
||||||
|
// genau 250,00 € und darunter: Kleinbetragsrechnung, Warnung ja, Senden erlaubt
|
||||||
|
assert.deepEqual(invoiceSendGate(open, 25000), { warn: true, sendAllowed: true, text: ADDRESS_INCOMPLETE_TEXT })
|
||||||
|
assert.deepEqual(invoiceSendGate(open, 0), { warn: true, sendAllowed: true, text: ADDRESS_INCOMPLETE_TEXT })
|
||||||
|
// vollständige Anschrift: nie eine Sperre
|
||||||
|
assert.deepEqual(invoiceSendGate(full, 99999999), { warn: false, sendAllowed: true, text: '' })
|
||||||
|
// unbekannte Summe gilt nicht als klein
|
||||||
|
assert.equal(invoiceSendGate(open, null).sendAllowed, false)
|
||||||
|
assert.equal(invoiceSendGate(open, undefined).sendAllowed, false)
|
||||||
|
// noch kein Kunde gewählt: weder Warnung noch Sperre (die Formularprüfung meldet das)
|
||||||
|
assert.deepEqual(invoiceSendGate(null, 99999), { warn: false, sendAllowed: true, text: '' })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user