Finanzen: Buchung zuordnen auf Dialog-Baustein (Esc, Sperre, Vorschlaege als Zeilen)

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 d1a1046969
commit 3b26fcdf0b
2 files changed
+164 -181

No files matched your search

+146 -156
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaXmark } from 'react-icons/fa6'
import { Badge, Button } from '../ui'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { Badge, Button, Dialog, Field } from '../ui'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
import {
@@ -20,7 +18,8 @@ const NOTE_MAX = 500
* bezahlt markiert und das lässt sich nicht rückgängig machen, deshalb zweistufig: erst „Zuordnen“, dann der
* Bestätigungstext mit „Ja, als bezahlt markieren“; vorher geht keine Anfrage raus. Fehler des Servers (409 Betrag
* weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung)
* lässt sich mit „Zuordnung lösen“ aufheben.
* lässt sich mit „Zuordnung lösen“ aufheben. × und Esc kommen vom Baustein Dialog und wirken nicht, solange eine
* Anfrage läuft; lockRef sperrt zusätzlich den Augenblick, bevor der Zustand „busy“ gezeichnet ist.
*
* invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand; invoicesLoaded =
* frisch und ohne Fehler geladen, dann sind Vorschläge zu nicht mehr offenen Rechnungen gesperrt).
@@ -39,14 +38,12 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
const lockRef = useRef(false)
const aliveRef = useRef(true)
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)
@@ -82,15 +79,6 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
onClose({ refresh: dirtyRef.current })
}
useEffect(() => {
const onKey = (e) => {
// Escape schließt zuerst nur die offene Auswahl (z. B. ein Aufklappmenü): dann nicht den Dialog
if (e.key === 'Escape' && !e.defaultPrevented) close()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
})
function choose(key) {
setSelected(key)
setConfirming(false)
@@ -154,7 +142,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : ''
const radio = (o) => (
<label key={o.key} className={`fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
<label key={o.key} className={`check fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
<input
type="radio"
name="fin-assign-target"
@@ -166,156 +154,158 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
<span className="fin-choice-text">
<span className="fin-choice-label">{o.label}</span>
{(o.confidence || o.detail) && (
<span className="fin-choice-detail muted">
<span className="fin-choice-detail">
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
{o.confidence && o.detail ? ' · ' : ''}
{o.detail}
</span>
)}
{o.disabled && o.disabledReason && <span className="fin-choice-detail fin-hint-warn">{o.disabledReason}</span>}
{o.disabled && o.disabledReason && <span className="fin-choice-detail warn">{o.disabledReason}</span>}
</span>
</label>
)
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={close} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog fin-assign"
role="dialog"
aria-modal="true"
aria-labelledby="fin-assign-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-assign-title" className="mb-2">Buchung zuordnen</h2>
<dl className="fin-tx-facts">
<div><dt>Datum</dt><dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd></div>
<div><dt>Gegenpartei</dt><dd>{row?.counterparty}</dd></div>
{row?.remittance && <div><dt>Verwendungszweck</dt><dd className="fin-wrap">{row.remittance}</dd></div>}
<div><dt>Betrag</dt><dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd></div>
</dl>
{error && (
<div className="fin-dialog-error" role="alert" ref={errorRef}>
{error}
</div>
)}
{model.assigned ? (
<>
<p className="fin-assigned">
Zugeordnet: <strong>{model.currentText}</strong>
</p>
{!model.canUnassign && (
<p className="fin-dialog-sub">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
)}
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Schließen</Button>
{model.canUnassign && (
<Button type="button" onClick={doUnassign} disabled={disabled}>
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
</Button>
)}
</div>
</>
) : (
<>
{model.hint && <p className="fin-dialog-sub" role="note">{model.hint}</p>}
{showInvoiceState && invoicesLoading && <p className="fin-dialog-sub" role="status">Offene Rechnungen werden geladen …</p>}
{showInvoiceState && invoicesError && (
<p className="fin-dialog-warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
)}
{outgoing && fixed.loading && <p className="fin-dialog-sub" role="status">Fixkosten werden geladen …</p>}
{outgoing && fixed.error && <p className="fin-dialog-warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
{model.suggestions.length > 0 && (
<fieldset className="fin-choices">
<legend className="form-label">Vorschläge</legend>
{model.suggestions.map(radio)}
</fieldset>
)}
{model.invoices.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-invoice">Andere offene Rechnung</label>
<select
id="fin-assign-invoice"
className="form-control"
value={invoiceSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Rechnung wählen …</option>
{model.invoices.map((o) => (
<option key={o.key} value={o.key} disabled={o.disabled}>
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
</option>
))}
</select>
</div>
)}
{model.fixedCosts.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-fixed">Fixkosten</label>
<select
id="fin-assign-fixed"
className="form-control"
value={fixedSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Fixkosten wählen …</option>
{model.fixedCosts.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</div>
)}
<fieldset className="fin-choices">
<legend className="form-label">Kategorie</legend>
{model.categories.map(radio)}
</fieldset>
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-note">Notiz (optional)</label>
<textarea
id="fin-assign-note"
className="form-control"
rows={2}
maxLength={NOTE_MAX}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
{confirming && option ? (
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
</Button>
</div>
</div>
) : (
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
</Button>
</div>
)}
</>
let footer
if (model.assigned) {
footer = (
<div className="fin-foot">
<Button type="button" onClick={close} disabled={disabled}>Schließen</Button>
{model.canUnassign && (
<Button type="button" onClick={doUnassign} disabled={disabled}>
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
</Button>
)}
</div>
</div>
)
} else if (confirming && option) {
footer = (
<div className="fin-foot">
<Button type="button" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
</Button>
</div>
)
} else {
footer = (
<div className="fin-foot">
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
</Button>
</div>
)
}
return (
<Dialog title="Buchung zuordnen" onClose={close} busy={disabled} footer={footer}>
<dl className="kv">
<dt>Datum</dt>
<dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd>
<dt>Gegenpartei</dt>
<dd>{row?.counterparty}</dd>
{row?.remittance && (
<>
<dt>Verwendungszweck</dt>
<dd>{row.remittance}</dd>
</>
)}
<dt>Betrag</dt>
<dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd>
</dl>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{model.assigned ? (
<>
<p className="fin-assigned">
Zugeordnet: <strong>{model.currentText}</strong>
</p>
{!model.canUnassign && (
<p className="msg muted">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
)}
</>
) : (
<>
{model.hint && <p className="msg muted" role="note">{model.hint}</p>}
{showInvoiceState && invoicesLoading && <p className="msg muted" role="status">Offene Rechnungen werden geladen …</p>}
{showInvoiceState && invoicesError && (
<p className="msg warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
)}
{outgoing && fixed.loading && <p className="msg muted" role="status">Fixkosten werden geladen …</p>}
{outgoing && fixed.error && <p className="msg warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
{model.suggestions.length > 0 && (
<fieldset className="fin-choices">
<legend>Vorschläge</legend>
{model.suggestions.map(radio)}
</fieldset>
)}
{model.invoices.length > 0 && (
<Field label="Andere offene Rechnung" htmlFor="fin-assign-invoice">
<select
id="fin-assign-invoice"
className="select"
value={invoiceSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Rechnung wählen …</option>
{model.invoices.map((o) => (
<option key={o.key} value={o.key} disabled={o.disabled}>
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
</option>
))}
</select>
</Field>
)}
{model.fixedCosts.length > 0 && (
<Field label="Fixkosten" htmlFor="fin-assign-fixed">
<select
id="fin-assign-fixed"
className="select"
value={fixedSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Fixkosten wählen …</option>
{model.fixedCosts.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</Field>
)}
<fieldset className="fin-choices">
<legend>Kategorie</legend>
{model.categories.map(radio)}
</fieldset>
<Field label="Notiz (optional)" htmlFor="fin-assign-note">
<textarea
id="fin-assign-note"
className="textarea"
rows={2}
maxLength={NOTE_MAX}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</Field>
{confirming && option && (
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
</div>
)}
</>
)}
</Dialog>
)
}