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>
)
}
+18 -25
View File
@@ -663,9 +663,6 @@ button.link-danger { background: none; border: 0; padding: 0; font: inherit; fon
.fin-block { scroll-margin-top: 68px; } /* unter der 52 px hohen Mobil-Leiste 16 px Luft */
}
/* Uebergang: diese Regel faellt, sobald keine Datei die Klasse mehr benutzt (Hinweis im Zuordnen-Dialog) */
.fin-hint-warn { color: var(--warn); }
/* Block 0: Statuszeile (ohne Kasten) */
.fin-status-wrap > .fin-notice { margin: 8px 0 0; }
.fin-status { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 13px; color: var(--text-2); }
@@ -854,28 +851,25 @@ button.link-danger { background: none; border: 0; padding: 0; font: inherit; fon
.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); }
.fin-tx-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
.fin-tx-facts > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
.fin-tx-facts dt { color: var(--text-muted); font-size: 13px; }
.fin-tx-facts dd { margin: 0; overflow-wrap: anywhere; }
.fin-tx-facts .fin-tx-facts-amount { text-align: left; font-weight: 700; }
.fin-assigned { margin: 0 0 var(--sp-2); }
.fin-choices { border: 0; padding: 0; margin: 0 0 var(--sp-4); display: grid; gap: 8px; min-width: 0; }
.fin-choices legend { padding: 0; margin-bottom: 4px; }
.fin-choice {
display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; cursor: pointer;
border: 1px solid var(--border-strong); border-radius: var(--r-sm);
}
.fin-choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.fin-choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.fin-choice input { margin-top: 4px; accent-color: var(--accent); }
.fin-choice-off { opacity: 0.65; cursor: not-allowed; }
/* 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; }
.kv .fin-tx-facts-amount { text-align: left; font-weight: 600; }
.fin-assigned { margin: 0; }
.fin-choices { border: 0; padding: 0; margin: 0; display: grid; min-width: 0; }
.fin-choices legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
.fin-choice { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.fin-choice input { flex: none; margin: 3px 0 0; }
.fin-choice-off { opacity: 0.55; cursor: not-allowed; }
.fin-choice-text { display: grid; gap: 2px; min-width: 0; }
.fin-choice-label { font-weight: 600; overflow-wrap: anywhere; }
.fin-choice-detail { font-size: 13px; overflow-wrap: anywhere; }
.fin-confirm { margin-top: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); background: rgba(245, 158, 11, 0.1); }
.fin-confirm:focus { outline: 2px solid var(--warn); outline-offset: 2px; }
.fin-confirm p { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
.fin-choice-label { color: var(--text); overflow-wrap: anywhere; }
.fin-choice:has(input:checked) .fin-choice-label { font-weight: 600; }
.fin-choice-detail { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.fin-choice-detail.warn { color: var(--orange-fg); }
.fin-confirm { font-weight: 600; color: var(--orange-fg); overflow-wrap: anywhere; }
.fin-confirm p { margin: 0; }
@media (max-width: 860px) {
.fin-foot .btn { flex: 1 1 auto; }
}
@media (max-width: 640px) {
.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; }
@@ -885,7 +879,6 @@ button.link-danger { background: none; border: 0; padding: 0; font: inherit; fon
.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-dialog-row { grid-template-columns: minmax(0, 1fr); }
.fin-tx-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.fin-dialog-actions .ui-btn { flex: 1 1 auto; }
}