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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:38:00 +00:00
1 parent 3b26fcdf0b
commit 625efc7f18
2 files changed
+74 -116

No files matched your search

+51 -77
View File
@@ -1,9 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6' import { AutocompleteInput, Button, Dialog, Field } from '../ui'
import { AutocompleteInput, Button } from '../ui'
import AddressWarning from './AddressWarning' import AddressWarning from './AddressWarning'
import { useCustomers } from '../../hooks/useCustomers' import { useCustomers } from '../../hooks/useCustomers'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { useScrollIntoView } from '../../hooks/useScrollIntoView' 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'
@@ -65,10 +63,8 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
const [busy, setBusy] = useState(null) // 'draft' | 'send' const [busy, setBusy] = useState(null) // 'draft' | 'send'
const lockRef = useRef(false) const lockRef = useRef(false)
const aliveRef = useRef(true) const aliveRef = useRef(true)
const dialogRef = useRef(null)
const errorRef = 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 // Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
useScrollIntoView(errorRef, error) useScrollIntoView(errorRef, error)
@@ -79,14 +75,12 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
} }
}, []) }, [])
useEffect(() => { // × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt).
const onKey = (e) => { // Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab.
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog const close = () => {
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose() if (lockRef.current) return
onClose()
} }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers]) const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
const resolved = fixedCustomer const resolved = fixedCustomer
@@ -144,40 +138,43 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
const disabled = busy !== null const disabled = busy !== null
return ( const footer = (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}> <div className="fin-foot">
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen"> <Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
<FaXmark aria-hidden="true" /> <Button type="button" onClick={() => submit(false)} disabled={disabled}>
</button> {busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
<div </Button>
className="overlay-content fin-dialog" <Button
role="dialog" type="button"
aria-modal="true" variant="primary"
aria-labelledby="fin-invoice-title" onClick={() => submit(true)}
tabIndex={-1} disabled={disabled || !gate.sendAllowed}
ref={dialogRef} title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
> >
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2> {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 && ( {error && (
<div className="fin-dialog-error" role="alert" ref={errorRef}> <div className="msg err" role="alert" ref={errorRef}>
{error} {error}
</div> </div>
)} )}
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */} <Field label="Kunde" htmlFor="fin-invoice-customer">
<form onSubmit={(e) => e.preventDefault()}>
<div className="form-group">
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
{fixedCustomer ? ( {fixedCustomer ? (
<p className="fin-fixed-customer"> <p className="fin-fixed-customer">
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong> <strong className="strong">{customerName(fixedCustomer) || 'Kunde'}</strong>
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null} {fixedCustomer.email ? <span className="msg muted"> · {fixedCustomer.email}</span> : null}
</p> </p>
) : source?.loading ? ( ) : source?.loading ? (
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p> <p className="msg muted" role="status">Kunden werden geladen …</p>
) : source?.error ? ( ) : source?.error ? (
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p> <p className="msg warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
) : ( ) : (
<AutocompleteInput <AutocompleteInput
id="fin-invoice-customer" id="fin-invoice-customer"
@@ -191,15 +188,15 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
/> />
)} )}
{!customerBlocked.ok && ( {!customerBlocked.ok && (
<p className="fin-dialog-warn" role="alert"> <p className="msg warn" role="alert">
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden. {customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p> </p>
)} )}
{!fixedCustomer && resolved.state === 'unknown' && ( {!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> <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 && ( {customer && customerBlocked.ok && customer.email && !fixedCustomer && (
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p> <p className="msg muted">Die Rechnung geht an {customer.email}.</p>
)} )}
{gate.warn && ( {gate.warn && (
<AddressWarning <AddressWarning
@@ -216,10 +213,10 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
} }
/> />
)} )}
</div> </Field>
<fieldset className="fin-items"> <fieldset className="fin-items">
<legend className="form-label">Positionen</legend> <legend>Positionen</legend>
{items.map((item, index) => { {items.map((item, index) => {
const line = lineTotalCents(item) const line = lineTotalCents(item)
const n = index + 1 const n = index + 1
@@ -229,7 +226,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label> <label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
<input <input
id={`fin-item-desc-${item.uid}`} id={`fin-item-desc-${item.uid}`}
className="form-control" className="input"
maxLength={INVOICE_LIMITS.maxDescription} maxLength={INVOICE_LIMITS.maxDescription}
value={item.description} value={item.description}
onChange={(e) => updateItem(item.uid, 'description', e.target.value)} onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
@@ -241,7 +238,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label> <label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
<input <input
id={`fin-item-qty-${item.uid}`} id={`fin-item-qty-${item.uid}`}
className="form-control fin-amount" className="input fin-amount"
inputMode="numeric" inputMode="numeric"
value={item.quantity} value={item.quantity}
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)} onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
@@ -252,7 +249,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label> <label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
<input <input
id={`fin-item-price-${item.uid}`} id={`fin-item-price-${item.uid}`}
className="form-control fin-amount" className="input fin-amount"
inputMode="decimal" inputMode="decimal"
value={item.price} value={item.price}
onChange={(e) => updateItem(item.uid, 'price', e.target.value)} onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
@@ -267,13 +264,12 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
<div className="fin-item-field fin-item-remove"> <div className="fin-item-field fin-item-remove">
<button <button
type="button" type="button"
className="fin-icon-btn" className="link"
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))} onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
disabled={items.length <= 1} disabled={items.length <= 1}
aria-label={`Position ${n} entfernen`} aria-label={`Position ${n} entfernen`}
title="Position entfernen"
> >
<FaTrashCan aria-hidden="true" /> Entfernen
</button> </button>
</div> </div>
</div> </div>
@@ -282,27 +278,24 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
<div className="fin-items-foot"> <div className="fin-items-foot">
<Button <Button
type="button" type="button"
size="sm"
variant="ghost"
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))} onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
disabled={items.length >= INVOICE_LIMITS.maxItems} disabled={items.length >= INVOICE_LIMITS.maxItems}
> >
<FaPlus aria-hidden="true" /> Position + Position
</Button> </Button>
<div className="fin-total" aria-live="polite"> <div className="fin-total" aria-live="polite">
<span>Summe</span> <span>Summe</span>
<span className="fin-amount fin-total-value">{euro(total)}</span> <span className="fin-amount">{euro(total)}</span>
</div> </div>
</div> </div>
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p> <p className="msg muted">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
</fieldset> </fieldset>
<div className="fin-dialog-row"> <div className="fin-dialog-row">
<div className="form-group fin-days"> <Field label="Zahlungsziel in Tagen" htmlFor="fin-invoice-days">
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
<input <input
id="fin-invoice-days" id="fin-invoice-days"
className="form-control fin-amount" className="input fin-amount"
inputMode="numeric" inputMode="numeric"
value={days} value={days}
onChange={(e) => { onChange={(e) => {
@@ -310,37 +303,18 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
if (error) setError('') if (error) setError('')
}} }}
/> />
</div> </Field>
<div className="form-group fin-memo"> <Field label="Notiz (erscheint auf der Rechnung)" htmlFor="fin-invoice-memo">
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
<textarea <textarea
id="fin-invoice-memo" id="fin-invoice-memo"
className="form-control" className="textarea"
rows={2} rows={2}
maxLength={INVOICE_LIMITS.maxMemo} maxLength={INVOICE_LIMITS.maxMemo}
value={memo} value={memo}
onChange={(e) => setMemo(e.target.value)} onChange={(e) => setMemo(e.target.value)}
/> />
</Field>
</div> </div>
</div> </Dialog>
<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>
</div>
) )
} }
+23 -39
View File
@@ -814,43 +814,29 @@ button.link-danger { background: none; border: 0; padding: 0; font: inherit; fon
.fin-tx-action { grid-area: action; align-self: start; } .fin-tx-action { grid-area: action; align-self: start; }
} }
/* Dialoge: Rechnung schreiben, Buchung zuordnen. Overlay, Box und Schliessknopf kommen aus KOMPAT (.overlay:has(> .overlay-content), */ /* Dialoge: Rechnung schreiben, Buchung zuordnen. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
/* .overlay-content, .overlay-close); hier nur, was dort fehlt. */ .fin-fixed-customer { overflow-wrap: anywhere; }
.fin-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
.fin-close:disabled { opacity: 0.4; cursor: not-allowed; }
.fin-dialog { max-width: 760px; }
.fin-dialog:focus { outline: none; }
.fin-dialog-error {
margin-bottom: var(--sp-4); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm);
border: 1px solid var(--danger); background: var(--red-bg); color: var(--red-fg); overflow-wrap: anywhere;
}
.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: var(--orange-fg); 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-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; overflow-wrap: anywhere; } .fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; overflow-wrap: anywhere; }
.fin-address-warn-page { margin: 0 0 16px; } .fin-address-warn-page { margin: 0 0 16px; }
.fin-address-warn-dialog { margin: 0; } .fin-address-warn-dialog { margin: 0; }
.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; } /* Rechnung schreiben: Positionen, Summe, Zahlungsziel und Notiz */
.fin-items legend { padding: 0; } .fin-items { border: 0; padding: 0; margin: 0; min-width: 0; }
.fin-item { .fin-items legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
display: grid; grid-template-columns: minmax(0, 1fr) 90px 130px 120px 38px; gap: 8px; align-items: end; margin-bottom: 8px; .fin-items .msg { margin-top: 8px; overflow-wrap: anywhere; }
} .fin-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
.fin-item + .fin-item { margin-top: 8px; }
.fin-item-field { min-width: 0; } .fin-item-field { min-width: 0; }
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-muted); } .fin-item .input { width: 100%; }
.fin-item ~ .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .fin-item .fin-amount { text-align: right; }
.fin-item .form-control.fin-amount { text-align: right; } .fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-2); }
.fin-item-total { display: block; padding: 10px 0; font-weight: 600; } .fin-item + .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-icon-btn { .fin-item-sum { text-align: right; }
width: 38px; height: 40px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; .fin-item-total { display: block; line-height: 32px; font-weight: 600; }
background: transparent; border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text-muted); .fin-item-remove { display: flex; align-items: center; height: 32px; }
} .fin-item-remove button:disabled { cursor: not-allowed; }
.fin-icon-btn:hover:not(:disabled) { color: var(--red-fg); border-color: var(--red-fg); } .fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.fin-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; } .fin-total { display: flex; align-items: baseline; gap: 16px; font-size: 18px; font-weight: 600; }
.fin-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 12px; }
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-3); }
.fin-total { display: flex; align-items: baseline; gap: var(--sp-4); font-size: 18px; font-weight: 700; }
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-4); }
.fin-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-3); margin-top: var(--sp-4); }
/* Zuordnen (Dialog): Fuss, Fakten, Auswahlzeilen, Bestaetigung. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */ /* Zuordnen (Dialog): Fuss, Fakten, Auswahlzeilen, Bestaetigung. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
.fin-foot { flex: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } .fin-foot { flex: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.kv .fin-tx-facts-amount { text-align: left; font-weight: 600; } .kv .fin-tx-facts-amount { text-align: left; font-weight: 600; }
@@ -869,17 +855,15 @@ button.link-danger { background: none; border: 0; padding: 0; font: inherit; fon
.fin-confirm p { margin: 0; } .fin-confirm p { margin: 0; }
@media (max-width: 860px) { @media (max-width: 860px) {
.fin-foot .btn { flex: 1 1 auto; } .fin-foot .btn { flex: 1 1 auto; }
} .fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
@media (max-width: 640px) { .fin-item + .fin-item { margin-top: 16px; }
.fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) 38px; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
.fin-item-desc { grid-area: desc; } .fin-item-desc { grid-area: desc; }
.fin-item-qty { grid-area: qty; } .fin-item-qty { grid-area: qty; }
.fin-item-price { grid-area: price; } .fin-item-price { grid-area: price; }
.fin-item-sum { grid-area: sum; text-align: right; } .fin-item-sum { grid-area: sum; }
.fin-item-remove { grid-area: remove; } .fin-item-remove { grid-area: remove; }
.fin-item ~ .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; } .fin-item + .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; }
.fin-dialog-row { grid-template-columns: minmax(0, 1fr); } .fin-dialog-row { grid-template-columns: minmax(0, 1fr); }
.fin-dialog-actions .ui-btn { flex: 1 1 auto; }
} }
/* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */ /* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */