Finanzen: Rechnung schreiben auf Dialog-Baustein (breit, Enter sendet nie, Esc schliesst Liste zuerst)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b26fcdf0b
commit
625efc7f18
2 files changed
+206
-248
No files matched your search
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
|
||||
import { AutocompleteInput, Button } from '../ui'
|
||||
import { AutocompleteInput, Button, Dialog, Field } 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'
|
||||
@@ -65,10 +63,8 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
||||
const [busy, setBusy] = useState(null) // 'draft' | 'send'
|
||||
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)
|
||||
|
||||
@@ -79,14 +75,12 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
|
||||
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
// × 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
|
||||
@@ -144,203 +138,183 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
||||
|
||||
const disabled = busy !== null
|
||||
|
||||
return (
|
||||
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
|
||||
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
|
||||
<FaXmark aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
className="overlay-content fin-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="fin-invoice-title"
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
const footer = (
|
||||
<div className="fin-foot">
|
||||
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
|
||||
<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 || !gate.sendAllowed}
|
||||
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
||||
>
|
||||
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
|
||||
|
||||
{error && (
|
||||
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
|
||||
{fixedCustomer ? (
|
||||
<p className="fin-fixed-customer">
|
||||
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
|
||||
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
|
||||
</p>
|
||||
) : source?.loading ? (
|
||||
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
|
||||
) : source?.error ? (
|
||||
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
||||
) : (
|
||||
<AutocompleteInput
|
||||
id="fin-invoice-customer"
|
||||
value={customerText}
|
||||
onChange={(v) => {
|
||||
setCustomerText(v)
|
||||
if (error) setError('')
|
||||
}}
|
||||
getSuggestions={(t) => customerSuggestions(options, t)}
|
||||
placeholder="Kunde suchen …"
|
||||
/>
|
||||
)}
|
||||
{!customerBlocked.ok && (
|
||||
<p className="fin-dialog-warn" role="alert">
|
||||
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
||||
</p>
|
||||
)}
|
||||
{!fixedCustomer && resolved.state === 'unknown' && (
|
||||
<p className="fin-dialog-sub">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
|
||||
)}
|
||||
{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">
|
||||
<legend className="form-label">Positionen</legend>
|
||||
{items.map((item, index) => {
|
||||
const line = lineTotalCents(item)
|
||||
const n = index + 1
|
||||
return (
|
||||
<div key={item.uid} className="fin-item">
|
||||
<div className="fin-item-field fin-item-desc">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
||||
<input
|
||||
id={`fin-item-desc-${item.uid}`}
|
||||
className="form-control"
|
||||
maxLength={INVOICE_LIMITS.maxDescription}
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
||||
aria-label={`Position ${n}: Beschreibung`}
|
||||
placeholder="z. B. Webdesign Startseite"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-qty">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
||||
<input
|
||||
id={`fin-item-qty-${item.uid}`}
|
||||
className="form-control fin-amount"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
||||
aria-label={`Position ${n}: Menge`}
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-price">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
||||
<input
|
||||
id={`fin-item-price-${item.uid}`}
|
||||
className="form-control fin-amount"
|
||||
inputMode="decimal"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-sum">
|
||||
<span className="fin-item-label">Summe</span>
|
||||
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-remove">
|
||||
<button
|
||||
type="button"
|
||||
className="fin-icon-btn"
|
||||
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
||||
disabled={items.length <= 1}
|
||||
aria-label={`Position ${n} entfernen`}
|
||||
title="Position entfernen"
|
||||
>
|
||||
<FaTrashCan aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="fin-items-foot">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
||||
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
||||
>
|
||||
<FaPlus aria-hidden="true" /> Position
|
||||
</Button>
|
||||
<div className="fin-total" aria-live="polite">
|
||||
<span>Summe</span>
|
||||
<span className="fin-amount fin-total-value">{euro(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="fin-dialog-row">
|
||||
<div className="form-group fin-days">
|
||||
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
|
||||
<input
|
||||
id="fin-invoice-days"
|
||||
className="form-control fin-amount"
|
||||
inputMode="numeric"
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
setDays(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group fin-memo">
|
||||
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
|
||||
<textarea
|
||||
id="fin-invoice-memo"
|
||||
className="form-control"
|
||||
rows={2}
|
||||
maxLength={INVOICE_LIMITS.maxMemo}
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fin-dialog-actions">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={disabled}>Abbrechen</Button>
|
||||
<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 || !gate.sendAllowed}
|
||||
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
||||
>
|
||||
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
|
||||
return (
|
||||
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={disabled} onSubmit={(e) => e.preventDefault()} footer={footer}>
|
||||
{error && (
|
||||
<div className="msg err" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Kunde" htmlFor="fin-invoice-customer">
|
||||
{fixedCustomer ? (
|
||||
<p className="fin-fixed-customer">
|
||||
<strong className="strong">{customerName(fixedCustomer) || 'Kunde'}</strong>
|
||||
{fixedCustomer.email ? <span className="msg muted"> · {fixedCustomer.email}</span> : null}
|
||||
</p>
|
||||
) : source?.loading ? (
|
||||
<p className="msg muted" role="status">Kunden werden geladen …</p>
|
||||
) : source?.error ? (
|
||||
<p className="msg warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
||||
) : (
|
||||
<AutocompleteInput
|
||||
id="fin-invoice-customer"
|
||||
value={customerText}
|
||||
onChange={(v) => {
|
||||
setCustomerText(v)
|
||||
if (error) setError('')
|
||||
}}
|
||||
getSuggestions={(t) => customerSuggestions(options, t)}
|
||||
placeholder="Kunde suchen …"
|
||||
/>
|
||||
)}
|
||||
{!customerBlocked.ok && (
|
||||
<p className="msg warn" role="alert">
|
||||
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
||||
</p>
|
||||
)}
|
||||
{!fixedCustomer && resolved.state === 'unknown' && (
|
||||
<p className="msg muted">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
|
||||
)}
|
||||
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
||||
<p className="msg muted">Die Rechnung geht an {customer.email}.</p>
|
||||
)}
|
||||
{gate.warn && (
|
||||
<AddressWarning
|
||||
customer={customer}
|
||||
variant="dialog"
|
||||
onShowOverview={
|
||||
onShowOverview
|
||||
? () => {
|
||||
if (lockRef.current) return
|
||||
onClose()
|
||||
onShowOverview()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<fieldset className="fin-items">
|
||||
<legend>Positionen</legend>
|
||||
{items.map((item, index) => {
|
||||
const line = lineTotalCents(item)
|
||||
const n = index + 1
|
||||
return (
|
||||
<div key={item.uid} className="fin-item">
|
||||
<div className="fin-item-field fin-item-desc">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
||||
<input
|
||||
id={`fin-item-desc-${item.uid}`}
|
||||
className="input"
|
||||
maxLength={INVOICE_LIMITS.maxDescription}
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
||||
aria-label={`Position ${n}: Beschreibung`}
|
||||
placeholder="z. B. Webdesign Startseite"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-qty">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
||||
<input
|
||||
id={`fin-item-qty-${item.uid}`}
|
||||
className="input fin-amount"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
||||
aria-label={`Position ${n}: Menge`}
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-price">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
||||
<input
|
||||
id={`fin-item-price-${item.uid}`}
|
||||
className="input fin-amount"
|
||||
inputMode="decimal"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-sum">
|
||||
<span className="fin-item-label">Summe</span>
|
||||
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-remove">
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
||||
disabled={items.length <= 1}
|
||||
aria-label={`Position ${n} entfernen`}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="fin-items-foot">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
||||
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
||||
>
|
||||
+ Position
|
||||
</Button>
|
||||
<div className="fin-total" aria-live="polite">
|
||||
<span>Summe</span>
|
||||
<span className="fin-amount">{euro(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="msg muted">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="fin-dialog-row">
|
||||
<Field label="Zahlungsziel in Tagen" htmlFor="fin-invoice-days">
|
||||
<input
|
||||
id="fin-invoice-days"
|
||||
className="input fin-amount"
|
||||
inputMode="numeric"
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
setDays(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="fin-invoice-memo">
|
||||
<textarea
|
||||
id="fin-invoice-memo"
|
||||
className="textarea"
|
||||
rows={2}
|
||||
maxLength={INVOICE_LIMITS.maxMemo}
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user