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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user