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:
1 parent
d1a1046969
commit
3b26fcdf0b
2 files changed
+164
-181
No files matched your search
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user