Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen
Block 6 (Rechnungen: Filter, Altersgruppen, Aktionen, Dialog "Rechnung schreiben") und Block 7 (Buchungen mit Paging, Zuordnen-Dialog, bei Rechnungen zweistufig). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
18bc3012e0
commit
1feeb18c08
12 files changed
+2436
-8
No files matched your search
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaXmark } from 'react-icons/fa6'
|
||||
import { Badge, Button } from '../ui'
|
||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
|
||||
import {
|
||||
assignBody,
|
||||
assignModel,
|
||||
invoiceAssignConfirmText,
|
||||
transactionRows,
|
||||
} from '../../lib/financeView'
|
||||
|
||||
const NOTE_MAX = 500
|
||||
|
||||
/**
|
||||
* Dialog „Buchung zuordnen“. Auswahl je nach Buchung (siehe assignModel): Vorschläge, andere offene Rechnung,
|
||||
* Stripe-Auszahlung (aus den Vorschlägen), Fixkosten (nur Ausgänge, aus der Fixkostenliste), Kategorie, dazu eine
|
||||
* Notiz. Vorgemerkte Buchungen (PDNG) bekommen nur Kategorien und Fixkosten. Eine Rechnung wird in Stripe als
|
||||
* 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.
|
||||
*
|
||||
* invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand).
|
||||
* onDone(buchung) nach Erfolg (Aufrufer lädt neu und schließt); onClose({ refresh }) bei Abbruch, refresh = ein
|
||||
* Fehler trat auf, der Stand kann sich geändert haben.
|
||||
*/
|
||||
export default function AssignDialog({ transaction, invoices = [], invoicesLoading = false, invoicesError = '', onClose, onDone }) {
|
||||
const tx = transaction || {}
|
||||
const row = useMemo(() => transactionRows([tx])[0], [tx])
|
||||
const [fixed, setFixed] = useState({ list: [], loading: false, error: '' })
|
||||
const [selected, setSelected] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [busy, setBusy] = useState(null) // 'assign' | 'unassign'
|
||||
const [error, setError] = useState('')
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
const dirtyRef = useRef(false)
|
||||
const dialogRef = useRef(null)
|
||||
const confirmRef = useRef(null)
|
||||
|
||||
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
|
||||
const unassigned = !tx.category
|
||||
|
||||
const onOverlayKeyDown = useDialogFocus(dialogRef)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fixkosten nur für Ausgänge ohne Zuordnung
|
||||
useEffect(() => {
|
||||
if (!outgoing || !unassigned) return undefined
|
||||
let cancelled = false
|
||||
setFixed({ list: [], loading: true, error: '' })
|
||||
getFixedCosts()
|
||||
.then((data) => {
|
||||
if (!cancelled) setFixed({ list: Array.isArray(data?.fixkosten) ? data.fixkosten : [], loading: false, error: '' })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setFixed({ list: [], loading: false, error: err?.message || 'Die Fixkosten konnten nicht geladen werden.' })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [outgoing, unassigned])
|
||||
|
||||
const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list }), [tx, invoices, fixed.list])
|
||||
const option = model.byKey[selected] || null
|
||||
|
||||
function close() {
|
||||
if (lockRef.current) return
|
||||
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)
|
||||
if (error) setError('')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (confirming) confirmRef.current?.focus()
|
||||
}, [confirming])
|
||||
|
||||
async function doAssign() {
|
||||
if (lockRef.current || !option || option.disabled) return
|
||||
lockRef.current = true
|
||||
setBusy('assign')
|
||||
setError('')
|
||||
try {
|
||||
const data = await assignTransaction(tx.$id, assignBody(option, note))
|
||||
onDone(data?.buchung || null)
|
||||
} catch (err) {
|
||||
dirtyRef.current = true
|
||||
if (aliveRef.current) {
|
||||
setError(err?.message || 'Die Zuordnung ist fehlgeschlagen.')
|
||||
setConfirming(false)
|
||||
}
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Erster Schritt: bei einer Rechnung nur den Bestätigungstext zeigen, noch keine Anfrage
|
||||
function primary() {
|
||||
if (!option || option.disabled || lockRef.current) return
|
||||
if (option.type === 'invoice' && !confirming) {
|
||||
setConfirming(true)
|
||||
return
|
||||
}
|
||||
doAssign()
|
||||
}
|
||||
|
||||
async function doUnassign() {
|
||||
if (lockRef.current) return
|
||||
lockRef.current = true
|
||||
setBusy('unassign')
|
||||
setError('')
|
||||
try {
|
||||
const data = await unassignTransaction(tx.$id)
|
||||
onDone(data?.buchung || null)
|
||||
} catch (err) {
|
||||
dirtyRef.current = true
|
||||
if (aliveRef.current) setError(err?.message || 'Die Zuordnung konnte nicht gelöst werden.')
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = busy !== null
|
||||
const showInvoiceState = model.incoming && !model.pending && !model.assigned
|
||||
const invoiceSelectValue = model.invoices.some((o) => o.key === selected) ? selected : ''
|
||||
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' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="fin-assign-target"
|
||||
value={o.key}
|
||||
checked={selected === o.key}
|
||||
disabled={o.disabled || disabled}
|
||||
onChange={() => choose(o.key)}
|
||||
/>
|
||||
<span className="fin-choice-text">
|
||||
<span className="fin-choice-label">{o.label}</span>
|
||||
{(o.confidence || o.detail) && (
|
||||
<span className="fin-choice-detail muted">
|
||||
{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>}
|
||||
</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">
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
|
||||
import { AutocompleteInput, Button } from '../ui'
|
||||
import { useCustomers } from '../../hooks/useCustomers'
|
||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||
import { createInvoice } from '../../lib/financeApi'
|
||||
import { canInvoice, euro } from '../../lib/financeFormat'
|
||||
import {
|
||||
INVOICE_LIMITS,
|
||||
customerName,
|
||||
customerPickerOptions,
|
||||
customerSuggestions,
|
||||
finalizedFilter,
|
||||
invoiceFailureMessage,
|
||||
invoiceTotalCents,
|
||||
isFinalizedNotSent,
|
||||
lineTotalCents,
|
||||
resolveCustomerText,
|
||||
validateInvoiceForm,
|
||||
} from '../../lib/financeView'
|
||||
|
||||
let itemCounter = 0
|
||||
const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' })
|
||||
|
||||
/**
|
||||
* Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest),
|
||||
* Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der
|
||||
* Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft;
|
||||
* Fehler des Servers stehen im Dialog.
|
||||
*
|
||||
* onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit
|
||||
* invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt
|
||||
* (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose().
|
||||
*/
|
||||
export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null }) {
|
||||
if (!open) return null
|
||||
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
|
||||
return fixedCustomer ? (
|
||||
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} />
|
||||
) : (
|
||||
<PickerForm onClose={onClose} onCreated={onCreated} />
|
||||
)
|
||||
}
|
||||
|
||||
function PickerForm({ onClose, onCreated }) {
|
||||
const { customers, loading, error } = useCustomers()
|
||||
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} />
|
||||
}
|
||||
|
||||
function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
|
||||
const [customerText, setCustomerText] = useState('')
|
||||
const [items, setItems] = useState(() => [newItem()])
|
||||
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
|
||||
const [memo, setMemo] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(null) // 'draft' | 'send'
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
const dialogRef = useRef(null)
|
||||
|
||||
const onOverlayKeyDown = useDialogFocus(dialogRef)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
|
||||
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
|
||||
const resolved = fixedCustomer
|
||||
? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null }
|
||||
: resolveCustomerText(options, customerText)
|
||||
const customer = fixedCustomer || resolved.option?.customer || null
|
||||
const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
|
||||
const total = invoiceTotalCents(items)
|
||||
|
||||
const updateItem = (uid, field, value) => {
|
||||
setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
|
||||
if (error) setError('')
|
||||
}
|
||||
|
||||
async function submit(senden) {
|
||||
if (lockRef.current) return
|
||||
const check = validateInvoiceForm({ customer, items, days, memo })
|
||||
if (!check.ok) {
|
||||
setError(check.error)
|
||||
return
|
||||
}
|
||||
lockRef.current = true
|
||||
setBusy(senden ? 'send' : 'draft')
|
||||
setError('')
|
||||
try {
|
||||
const data = await createInvoice({ ...check.payload, senden })
|
||||
if (onCreated) onCreated(data?.rechnung || null, { senden })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (isFinalizedNotSent(err)) {
|
||||
// Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie
|
||||
if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) })
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (aliveRef.current) setError(invoiceFailureMessage(err))
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = busy !== null
|
||||
|
||||
return (
|
||||
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
|
||||
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
|
||||
<FaXmark aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
className="overlay-content fin-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="fin-invoice-title"
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
|
||||
|
||||
{error && (
|
||||
<div className="fin-dialog-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
|
||||
{fixedCustomer ? (
|
||||
<p className="fin-fixed-customer">
|
||||
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
|
||||
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
|
||||
</p>
|
||||
) : source?.loading ? (
|
||||
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
|
||||
) : source?.error ? (
|
||||
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
||||
) : (
|
||||
<AutocompleteInput
|
||||
id="fin-invoice-customer"
|
||||
value={customerText}
|
||||
onChange={(v) => {
|
||||
setCustomerText(v)
|
||||
if (error) setError('')
|
||||
}}
|
||||
getSuggestions={(t) => customerSuggestions(options, t)}
|
||||
placeholder="Kunde suchen …"
|
||||
/>
|
||||
)}
|
||||
{!customerBlocked.ok && (
|
||||
<p className="fin-dialog-warn" role="alert">
|
||||
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
||||
</p>
|
||||
)}
|
||||
{!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>
|
||||
)}
|
||||
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
||||
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset className="fin-items">
|
||||
<legend className="form-label">Positionen</legend>
|
||||
{items.map((item, index) => {
|
||||
const line = lineTotalCents(item)
|
||||
const n = index + 1
|
||||
return (
|
||||
<div key={item.uid} className="fin-item">
|
||||
<div className="fin-item-field fin-item-desc">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
||||
<input
|
||||
id={`fin-item-desc-${item.uid}`}
|
||||
className="form-control"
|
||||
maxLength={INVOICE_LIMITS.maxDescription}
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
||||
aria-label={`Position ${n}: Beschreibung`}
|
||||
placeholder="z. B. Webdesign Startseite"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-qty">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
||||
<input
|
||||
id={`fin-item-qty-${item.uid}`}
|
||||
className="form-control fin-amount"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
||||
aria-label={`Position ${n}: Menge`}
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-price">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
||||
<input
|
||||
id={`fin-item-price-${item.uid}`}
|
||||
className="form-control fin-amount"
|
||||
inputMode="decimal"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-sum">
|
||||
<span className="fin-item-label">Summe</span>
|
||||
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-remove">
|
||||
<button
|
||||
type="button"
|
||||
className="fin-icon-btn"
|
||||
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
||||
disabled={items.length <= 1}
|
||||
aria-label={`Position ${n} entfernen`}
|
||||
title="Position entfernen"
|
||||
>
|
||||
<FaTrashCan aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="fin-items-foot">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
||||
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
||||
>
|
||||
<FaPlus aria-hidden="true" /> Position
|
||||
</Button>
|
||||
<div className="fin-total" aria-live="polite">
|
||||
<span>Summe</span>
|
||||
<span className="fin-amount fin-total-value">{euro(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="fin-dialog-row">
|
||||
<div className="form-group fin-days">
|
||||
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
|
||||
<input
|
||||
id="fin-invoice-days"
|
||||
className="form-control fin-amount"
|
||||
inputMode="numeric"
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
setDays(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group fin-memo">
|
||||
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
|
||||
<textarea
|
||||
id="fin-invoice-memo"
|
||||
className="form-control"
|
||||
rows={2}
|
||||
maxLength={INVOICE_LIMITS.maxMemo}
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaSpinner } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import InvoiceCreateModal from './InvoiceCreateModal'
|
||||
import Pager from './Pager'
|
||||
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
||||
import { pageOf } from '../../lib/financeFormat'
|
||||
import {
|
||||
DEFAULT_INVOICE_FILTER,
|
||||
INVOICES_ANCHOR,
|
||||
INVOICE_FILTERS,
|
||||
ageGroupCells,
|
||||
finalizedFilter,
|
||||
invoiceConfirmText,
|
||||
invoiceDoneMessage,
|
||||
invoiceFailureMessage,
|
||||
invoiceRows,
|
||||
isFinalizedNotSent,
|
||||
} from '../../lib/financeView'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const RUNNING_LABEL = { send: 'Sende …', resend: 'Sende …', void: 'Storniere …', delete: 'Lösche …' }
|
||||
const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, delete: deleteInvoice }
|
||||
|
||||
/**
|
||||
* Block 6: Rechnungen. Filter Offen / Überfällig / Bezahlt / Entwürfe / Alle (Standard Offen), Altersgruppen der
|
||||
* offenen Rechnungen (nur mit `altersgruppen`, also nicht in der Kundenansicht), Tabelle mit 50 Zeilen je Seite
|
||||
* und die Aktionen je Status (siehe invoiceActions). Löschen, Stornieren und Erinnerung fragen vorher nach; solange
|
||||
* eine Anfrage läuft, sind alle Aktionen gesperrt. „Rechnung schreiben“ öffnet den Dialog.
|
||||
*
|
||||
* customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest
|
||||
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede
|
||||
* Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen.
|
||||
*/
|
||||
export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, onChanged }) {
|
||||
const filterCustomer = customerId || customer?.$id || null
|
||||
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [result, setResult] = useState({ key: '', list: [] }) // Antwort samt Filter, zu dem sie gehört
|
||||
const [error, setError] = useState(null) // { key, message }
|
||||
const [page, setPage] = useState(1)
|
||||
const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
|
||||
const [message, setMessage] = useState(null) // { tone: 'ok' | 'warn', text }
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
const seqRef = useRef(0)
|
||||
const onChangedRef = useRef(onChanged)
|
||||
onChangedRef.current = onChanged
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const key = `${status}|${filterCustomer || ''}`
|
||||
useEffect(() => {
|
||||
// Nur die jüngste Anfrage zählt (Filterwechsel während des Ladens)
|
||||
const mine = ++seqRef.current
|
||||
getInvoices({ status, kunde: filterCustomer })
|
||||
.then((data) => {
|
||||
if (!aliveRef.current || mine !== seqRef.current) return
|
||||
setResult({ key, list: Array.isArray(data?.rechnungen) ? data.rechnungen : [] })
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!aliveRef.current || mine !== seqRef.current) return
|
||||
setError({ key, message: err?.message || 'Die Rechnungen konnten nicht geladen werden.' })
|
||||
})
|
||||
}, [status, filterCustomer, key, reloadKey])
|
||||
|
||||
const reload = () => setReloadKey((k) => k + 1)
|
||||
const changed = () => {
|
||||
reload()
|
||||
if (onChangedRef.current) onChangedRef.current()
|
||||
}
|
||||
|
||||
const current = result.key === key
|
||||
const invoices = current ? result.list : []
|
||||
const rows = useMemo(() => invoiceRows(invoices), [invoices])
|
||||
const byId = useMemo(() => Object.fromEntries(invoices.map((inv) => [inv.id, inv])), [invoices])
|
||||
const paged = pageOf(rows, page, PAGE_SIZE)
|
||||
const loadError = error && error.key === key ? error.message : ''
|
||||
const loading = !current && !loadError
|
||||
const ages = ageGroupCells(filterCustomer ? null : altersgruppen)
|
||||
|
||||
function pickFilter(next) {
|
||||
if (next === status) return
|
||||
setStatus(next)
|
||||
setPage(1)
|
||||
setMessage(null)
|
||||
}
|
||||
|
||||
async function run(row, action) {
|
||||
if (lockRef.current) return
|
||||
const invoice = byId[row.id]
|
||||
const confirmText = invoiceConfirmText(action.key, invoice)
|
||||
if (confirmText && !window.confirm(confirmText)) return
|
||||
lockRef.current = true
|
||||
setBusy(`${row.id}:${action.key}`)
|
||||
setMessage(null)
|
||||
try {
|
||||
const data = await CALLS[action.key](row.id)
|
||||
if (aliveRef.current) setMessage({ tone: 'ok', text: invoiceDoneMessage(action.key, data?.rechnung) })
|
||||
if (aliveRef.current) changed()
|
||||
} catch (err) {
|
||||
if (!aliveRef.current) return
|
||||
setMessage({ tone: 'warn', text: invoiceFailureMessage(err) })
|
||||
if (isFinalizedNotSent(err)) {
|
||||
// Festgeschrieben: die Rechnung ist jetzt offen und steht im Filter „Offen“
|
||||
setStatus(finalizedFilter(err))
|
||||
setPage(1)
|
||||
}
|
||||
// Der Stand bei Stripe kann ein anderer sein (schon gesendet, schon bezahlt, gelöscht): neu laden
|
||||
changed()
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreated(rechnung, info = {}) {
|
||||
if (info.notice) {
|
||||
setMessage({ tone: info.tone || 'ok', text: info.notice })
|
||||
if (info.finalizedFilter) setStatus(info.finalizedFilter)
|
||||
} else {
|
||||
setMessage({ tone: 'ok', text: invoiceDoneMessage(info.senden ? 'create-send' : 'create-draft', rechnung) })
|
||||
// Der neue Eintrag soll sichtbar sein: Entwurf unter „Entwürfe“, gesendete Rechnung unter „Offen“
|
||||
setStatus(rechnung?.status === 'entwurf' ? 'entwurf' : 'offen')
|
||||
}
|
||||
setPage(1)
|
||||
changed()
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
|
||||
<Card
|
||||
title="Rechnungen"
|
||||
className="fin-invoices-card"
|
||||
bodyStyle={{ padding: 0 }}
|
||||
actions={
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<FaPlus aria-hidden="true" /> Rechnung schreiben
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{ages.length > 0 && (
|
||||
<ul className="fin-ages" aria-label="Offene Rechnungen nach Alter">
|
||||
{ages.map((cell) => (
|
||||
<li key={cell.key} className="fin-age">
|
||||
<span className="fin-age-label">{cell.label}</span>
|
||||
<span className="fin-age-count">{cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}</span>
|
||||
<span className="fin-amount fin-age-sum">{cell.sumText}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="fin-toolbar">
|
||||
<div className="seg" role="group" aria-label="Rechnungen filtern">
|
||||
{INVOICE_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={status === f.key ? 'active' : ''}
|
||||
aria-pressed={status === f.key}
|
||||
onClick={() => pickFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className={`fin-hint fin-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Rechnungen werden geladen …</p>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="fin-load-error">
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{loadError}</p>
|
||||
<Button size="sm" onClick={reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
|
||||
) : (
|
||||
<div className="fin-table-wrap">
|
||||
<table className="ui-table fin-invoices" role="table">
|
||||
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Nr.</th>
|
||||
<th role="columnheader" scope="col">Kunde</th>
|
||||
<th role="columnheader" scope="col">Datum</th>
|
||||
<th role="columnheader" scope="col">Fällig</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
|
||||
<th role="columnheader" scope="col">Status</th>
|
||||
<th role="columnheader" scope="col">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{paged.items.map((row) => (
|
||||
<tr key={row.key} role="row">
|
||||
<td role="cell" className="fin-inv-number">
|
||||
{row.numberText}
|
||||
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
|
||||
</td>
|
||||
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
|
||||
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
|
||||
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
|
||||
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
|
||||
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
|
||||
<td role="cell" className="fin-inv-status">
|
||||
<div className="fin-inv-statusbox">
|
||||
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
|
||||
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell" className="fin-inv-actions">
|
||||
<div className="fin-actions">
|
||||
{row.actions.map((action) =>
|
||||
action.url ? (
|
||||
<Button
|
||||
key={action.key}
|
||||
as="a"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
href={action.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={action.key}
|
||||
size="sm"
|
||||
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
|
||||
onClick={() => run(row, action)}
|
||||
disabled={busy !== null}
|
||||
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
|
||||
>
|
||||
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
|
||||
</Card>
|
||||
|
||||
<InvoiceCreateModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={handleCreated}
|
||||
fixedCustomer={customer}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from '../ui'
|
||||
|
||||
/**
|
||||
* Blätterleiste „Zurück · Seite 2 von 5 · 230 Buchungen · Weiter“; bei nur einer Seite erscheint sie nicht.
|
||||
* `noun` ist die Mehrzahl, z. B. „Buchungen“.
|
||||
*/
|
||||
export default function Pager({ page, pages, total, noun = 'Einträge', onPage }) {
|
||||
if (pages <= 1) return null
|
||||
return (
|
||||
<nav className="fin-pager" aria-label={`Seiten der ${noun}`}>
|
||||
<Button size="sm" variant="ghost" onClick={() => onPage(page - 1)} disabled={page <= 1}>
|
||||
Zurück
|
||||
</Button>
|
||||
<span className="fin-pager-info" aria-live="polite">
|
||||
Seite {page} von {pages}
|
||||
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => onPage(page + 1)} disabled={page >= pages}>
|
||||
Weiter
|
||||
</Button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FaSpinner, FaTriangleExclamation } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import Pager from './Pager'
|
||||
import { pageOf } from '../../lib/financeFormat'
|
||||
import { transactionRows } from '../../lib/financeView'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
export const TRANSACTIONS_ANCHOR = 'finanzen-buchungen'
|
||||
|
||||
/**
|
||||
* Block 7: Buchungen. Alle gespeicherten Buchungen, neueste zuerst, 50 je Seite: Datum, Gegenpartei mit
|
||||
* Verwendungszweck (zweizeilig), Betrag mit Vorzeichen, Zuordnung (bei offenen Buchungen der stärkste Vorschlag als
|
||||
* Text) und der Knopf „Zuordnen“ bzw. „Ändern“. Vorgemerkte Buchungen (PDNG) tragen „vorgemerkt“. Die Daten kommen
|
||||
* vom Aufrufer (useFinanceTransactions), damit Seite und Zuordnen-Dialog dieselbe Liste benutzen.
|
||||
* onAssign(buchung) öffnet den Dialog. Schmal (Karte bis 640 px) wird jede Zeile ein Raster: Datum und Betrag /
|
||||
* Gegenpartei und Verwendungszweck / Zuordnung und Knopf.
|
||||
*/
|
||||
export default function TransactionTable({ transactions, stripeFehler = '', loaded = false, loading = false, error = '', onReload, onAssign }) {
|
||||
const [page, setPage] = useState(1)
|
||||
const rows = useMemo(() => transactionRows(transactions), [transactions])
|
||||
const paged = pageOf(rows, page, PAGE_SIZE)
|
||||
|
||||
let body
|
||||
if (!loaded && loading) {
|
||||
body = (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Buchungen werden geladen …</p>
|
||||
</div>
|
||||
)
|
||||
} else if (!loaded) {
|
||||
body = (
|
||||
<div className="fin-load-error">
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
|
||||
<Button size="sm" onClick={onReload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)
|
||||
} else if (rows.length === 0) {
|
||||
body = <EmptyState title="Noch keine Buchungen gespeichert." hint="Sobald die Bank verbunden und abgerufen ist, erscheinen sie hier." />
|
||||
} else {
|
||||
body = (
|
||||
<div className="fin-table-wrap">
|
||||
<table className="ui-table fin-tx" role="table">
|
||||
<caption className="fin-sr">Buchungen, neueste zuerst</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Datum</th>
|
||||
<th role="columnheader" scope="col">Gegenpartei und Verwendungszweck</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||
<th role="columnheader" scope="col">Zuordnung</th>
|
||||
<th role="columnheader" scope="col"><span className="fin-sr">Aktion</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{paged.items.map((row) => (
|
||||
<tr key={row.key} role="row">
|
||||
<td role="cell" className="fin-tx-date">{row.dateText}</td>
|
||||
<td role="cell" className="fin-tx-what">
|
||||
<div className="fin-tx-counterparty">{row.counterparty}</div>
|
||||
{row.remittance && (
|
||||
<div className="fin-tx-remittance muted" title={row.remittance}>{row.remittance}</div>
|
||||
)}
|
||||
</td>
|
||||
<td role="cell" className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}`}>{row.amountText}</td>
|
||||
<td role="cell" className="fin-tx-assign">
|
||||
<div className="fin-tx-badges">
|
||||
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
|
||||
{row.pending && <Badge tone="muted">vorgemerkt</Badge>}
|
||||
</div>
|
||||
{row.suggestionText && <div className="fin-tx-suggestion muted">{row.suggestionText}</div>}
|
||||
{row.note && <div className="fin-tx-note muted">Notiz: {row.note}</div>}
|
||||
</td>
|
||||
<td role="cell" className="fin-tx-action">
|
||||
{row.action && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={row.action === 'assign' ? 'default' : 'ghost'}
|
||||
onClick={() => onAssign(row.tx)}
|
||||
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
|
||||
>
|
||||
{row.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
|
||||
<Card title="Buchungen" className="fin-tx-card" bodyStyle={{ padding: 0 }}>
|
||||
{loaded && error && (
|
||||
<p className="fin-hint fin-hint-warn fin-notice" role="alert">
|
||||
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={onReload} disabled={loading}>Erneut versuchen</Button>
|
||||
</p>
|
||||
)}
|
||||
{stripeFehler && (
|
||||
<p className="fin-hint fin-hint-warn fin-notice" role="status">
|
||||
<FaTriangleExclamation aria-hidden="true" /> {stripeFehler}
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user