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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/**
|
||||
* Fokus für ein Dialog-Overlay: beim Öffnen springt der Fokus in den Dialog (`dialogRef`), beim Schließen zurück zum
|
||||
* Element, das ihn vorher hatte (falls es noch da ist). Der zurückgegebene onKeyDown gehört an das Overlay: Tab und
|
||||
* Umschalt+Tab bleiben im Dialog (sonst landet man hinter dem Overlay in der Seite).
|
||||
*/
|
||||
export function useDialogFocus(dialogRef) {
|
||||
useEffect(() => {
|
||||
const opener = document.activeElement
|
||||
dialogRef.current?.focus()
|
||||
return () => {
|
||||
if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus()
|
||||
}
|
||||
}, [dialogRef])
|
||||
|
||||
return useCallback((e) => {
|
||||
if (e.key !== 'Tab') return
|
||||
const overlay = e.currentTarget
|
||||
const items = [...overlay.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
const onDialogBox = active === dialogRef.current // der Dialog selbst hat den Fokus (tabIndex -1)
|
||||
if (e.shiftKey && (active === first || onDialogBox || !overlay.contains(active))) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && (active === last || !overlay.contains(active))) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}, [dialogRef])
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getTransactions } from '../lib/financeApi'
|
||||
|
||||
const FALLBACK_ERROR = 'Die Buchungen konnten nicht geladen werden.'
|
||||
|
||||
/**
|
||||
* Lädt die gespeicherten Buchungen samt Zuordnungsvorschlägen (GET /buchungen, höchstens 1000, neueste zuerst).
|
||||
* `transactions` bleibt beim erneuten Laden stehen; `loaded` ist true, sobald einmal geladen wurde (auch leer);
|
||||
* `stripeFehler` ist der Hinweis des Servers, wenn Stripe die Vorschläge nicht liefern konnte. reload() wirft nie.
|
||||
*/
|
||||
export function useFinanceTransactions() {
|
||||
const [state, setState] = useState({ transactions: [], stripeFehler: '', loaded: false })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const aliveRef = useRef(true)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
// Nur die jüngste Anfrage zählt: eine langsame ältere darf neuere Daten nicht überschreiben
|
||||
const mine = ++seqRef.current
|
||||
const current = () => aliveRef.current && mine === seqRef.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await getTransactions()
|
||||
if (current()) {
|
||||
setState({
|
||||
transactions: Array.isArray(data?.buchungen) ? data.buchungen : [],
|
||||
stripeFehler: typeof data?.stripeFehler === 'string' ? data.stripeFehler : '',
|
||||
loaded: true,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (current()) setError(err?.message || FALLBACK_ERROR)
|
||||
} finally {
|
||||
if (current()) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
reload()
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [reload])
|
||||
|
||||
return { ...state, loading, error, reload }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getInvoices } from '../lib/financeApi'
|
||||
|
||||
const FALLBACK_ERROR = 'Die offenen Rechnungen konnten nicht geladen werden.'
|
||||
|
||||
/**
|
||||
* Offene und überfällige Rechnungen für den Zuordnen-Dialog. Lädt jedes Mal frisch, wenn `active` true wird
|
||||
* (damit der Dialog nie auf einem alten Stand arbeitet), und leert die Liste, wenn es wieder false ist.
|
||||
* Scheitert Stripe, bleibt `invoices` leer und `error` nennt den Grund (Kategorien lassen sich trotzdem wählen).
|
||||
*/
|
||||
export function useOpenInvoices(active) {
|
||||
const [state, setState] = useState({ invoices: [], loading: false, error: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setState({ invoices: [], loading: false, error: '' })
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setState({ invoices: [], loading: true, error: '' })
|
||||
Promise.all([getInvoices({ status: 'offen' }), getInvoices({ status: 'ueberfaellig' })])
|
||||
.then(([open, overdue]) => {
|
||||
if (cancelled) return
|
||||
const all = [...(open?.rechnungen || []), ...(overdue?.rechnungen || [])]
|
||||
setState({ invoices: all, loading: false, error: '' })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [active])
|
||||
|
||||
return state
|
||||
}
|
||||
+530
-2
@@ -1,11 +1,16 @@
|
||||
// Reine Darstellungs-Helfer fuer die Bloecke der Finanzen-Seite (Status, Liquiditaet, Aufgaben, Vorschau, Steuer, Abos)
|
||||
// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
|
||||
// Reine Darstellungs-Helfer fuer die Bloecke der Finanzen-Seite (Status, Liquiditaet, Aufgaben, Vorschau, Steuer, Abos,
|
||||
// Rechnungen, Buchungen, Zuordnen) (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
|
||||
import {
|
||||
AGE_GROUP_LABEL,
|
||||
CATEGORY_LABEL,
|
||||
INTERVAL_LABEL,
|
||||
INVOICE_STATUS,
|
||||
canInvoice,
|
||||
dateDe,
|
||||
euro,
|
||||
euroWhole,
|
||||
nextIntervalDate,
|
||||
parseEuro,
|
||||
percentDe,
|
||||
signedEuro,
|
||||
sinceLabel,
|
||||
@@ -398,3 +403,526 @@ export function aboView(abos) {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
|
||||
|
||||
/** Filter über der Rechnungsliste (Schlüssel = `status` der API); Standard ist „Offen“. */
|
||||
export const INVOICE_FILTERS = [
|
||||
{ key: 'offen', label: 'Offen' },
|
||||
{ key: 'ueberfaellig', label: 'Überfällig' },
|
||||
{ key: 'bezahlt', label: 'Bezahlt' },
|
||||
{ key: 'entwurf', label: 'Entwürfe' },
|
||||
{ key: 'alle', label: 'Alle' },
|
||||
]
|
||||
export const DEFAULT_INVOICE_FILTER = 'offen'
|
||||
|
||||
const obj = (v) => (v && typeof v === 'object' ? v : {})
|
||||
const objects = (list) => (Array.isArray(list) ? list.filter((x) => x && typeof x === 'object') : [])
|
||||
const OPEN_INVOICE = new Set(['offen', 'ueberfaellig'])
|
||||
|
||||
/**
|
||||
* Altersgruppen der offenen Rechnungen als [{ key, label, count, sumText }] in der Reihenfolge nicht fällig, 1–30,
|
||||
* 31–60, über 60 Tage (fehlende Gruppen zählen 0). Ohne Liste (kein Array) gibt es nichts anzuzeigen: [].
|
||||
*/
|
||||
export function ageGroupCells(altersgruppen) {
|
||||
if (!Array.isArray(altersgruppen)) return []
|
||||
return Object.keys(AGE_GROUP_LABEL).map((key) => {
|
||||
const group = obj(altersgruppen.find((g) => g && g.key === key))
|
||||
return {
|
||||
key,
|
||||
label: AGE_GROUP_LABEL[key],
|
||||
count: isNum(group.anzahl) ? group.anzahl : 0,
|
||||
sumText: euro(isNum(group.summeCents) ? group.summeCents : 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktionen einer Rechnung als [{ key, label, url? }] (key: hosted | pdf | send | resend | void | delete):
|
||||
* Entwurf: Senden, Löschen; offen/überfällig: Zahlungsseite, PDF, Erinnerung senden, Stornieren; bezahlt: PDF.
|
||||
* Abo-Rechnungen (source 'abo') verwaltet Stripe: höchstens Zahlungsseite und PDF. Adressen nur, wenn sie
|
||||
* http(s) sind.
|
||||
*/
|
||||
export function invoiceActions(invoice) {
|
||||
const inv = obj(invoice)
|
||||
const abo = inv.source === 'abo'
|
||||
const hosted = safeUrl(inv.hostedUrl)
|
||||
const pdf = safeUrl(inv.pdfUrl)
|
||||
const pdfAction = pdf ? [{ key: 'pdf', label: 'PDF', url: pdf }] : []
|
||||
switch (inv.status) {
|
||||
case 'entwurf':
|
||||
return abo ? [] : [{ key: 'send', label: 'Senden' }, { key: 'delete', label: 'Löschen' }]
|
||||
case 'offen':
|
||||
case 'ueberfaellig':
|
||||
return [
|
||||
...(hosted ? [{ key: 'hosted', label: 'Zahlungsseite', url: hosted }] : []),
|
||||
...pdfAction,
|
||||
...(abo ? [] : [{ key: 'resend', label: 'Erinnerung senden' }, { key: 'void', label: 'Stornieren' }]),
|
||||
]
|
||||
default:
|
||||
return pdfAction
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeilen der Rechnungstabelle (Serverreihenfolge, neueste zuerst):
|
||||
* { key, id, numberText, customerText, isAbo, createdText, dueText, totalText, remainingText, statusLabel,
|
||||
* statusTone, statusNote, actions }. „Offen“ nur bei offen/überfällig; statusNote: „12 Tage überfällig“ bzw.
|
||||
* „am 05.08.2026 · per Überweisung“ (außerhalb von Stripe bezahlt).
|
||||
*/
|
||||
export function invoiceRows(invoices) {
|
||||
return objects(invoices).map((inv, index) => {
|
||||
const status = has(INVOICE_STATUS, inv.status)
|
||||
? INVOICE_STATUS[inv.status]
|
||||
: { label: text(inv.status) || 'unbekannt', tone: 'muted' }
|
||||
let note = ''
|
||||
if (inv.status === 'ueberfaellig' && isNum(inv.overdueDays) && inv.overdueDays > 0) {
|
||||
note = `${inv.overdueDays} ${inv.overdueDays === 1 ? 'Tag' : 'Tage'} überfällig`
|
||||
} else if (inv.status === 'bezahlt') {
|
||||
note = [text(inv.paidDate) ? `am ${dateDe(inv.paidDate)}` : '', inv.outOfBand ? 'per Überweisung' : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
return {
|
||||
key: text(inv.id) || `row-${index}`,
|
||||
id: text(inv.id),
|
||||
numberText: text(inv.number) || 'ohne Nr.',
|
||||
customerText: text(inv.customerName) || text(inv.customerEmail) || 'Unbekannter Kunde',
|
||||
isAbo: inv.source === 'abo',
|
||||
createdText: dateDe(inv.createdDate),
|
||||
dueText: dateDe(inv.dueDate),
|
||||
totalText: euro(inv.totalCents),
|
||||
remainingText: OPEN_INVOICE.has(inv.status) ? euro(inv.remainingCents) : '–',
|
||||
statusLabel: status.label,
|
||||
statusTone: status.tone,
|
||||
statusNote: note,
|
||||
actions: invoiceActions(inv),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Rückfrage vor dem Löschen eines Entwurfs, dem Stornieren und der Erinnerung; '' für Aktionen ohne Rückfrage. */
|
||||
export function invoiceConfirmText(actionKey, invoice) {
|
||||
const inv = obj(invoice)
|
||||
const who = text(inv.customerName) || text(inv.customerEmail) || 'den Kunden'
|
||||
const total = euro(inv.totalCents)
|
||||
const number = text(inv.number) || 'ohne Nummer'
|
||||
switch (actionKey) {
|
||||
case 'delete':
|
||||
return `Den Entwurf für ${who} über ${total} wirklich löschen? Das lässt sich nicht rückgängig machen.`
|
||||
case 'void':
|
||||
return `Die Rechnung ${number} an ${who} über ${total} wirklich stornieren? Das lässt sich nicht rückgängig machen.`
|
||||
case 'resend':
|
||||
return `Die Rechnung ${number} jetzt noch einmal an ${text(inv.customerEmail) || who} senden (Erinnerung)?`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Erfolgsmeldung nach einer Rechnungsaktion (send | resend | void | delete | create-draft | create-send). */
|
||||
export function invoiceDoneMessage(actionKey, rechnung) {
|
||||
const inv = obj(rechnung)
|
||||
const number = text(inv.number)
|
||||
const mail = text(inv.customerEmail)
|
||||
const subject = number ? `Rechnung ${number}` : 'Die Rechnung'
|
||||
const to = mail ? ` an ${mail}` : ''
|
||||
switch (actionKey) {
|
||||
case 'send':
|
||||
case 'create-send':
|
||||
return `${subject} wurde${to} gesendet.`
|
||||
case 'resend':
|
||||
return `Die Erinnerung zu ${number ? `Rechnung ${number}` : 'der Rechnung'} wurde${to} gesendet.`
|
||||
case 'void':
|
||||
return `${subject} wurde storniert.`
|
||||
case 'delete':
|
||||
return 'Der Entwurf wurde gelöscht.'
|
||||
case 'create-draft':
|
||||
return 'Der Entwurf wurde gespeichert.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Rechnung besteht schon bei Stripe, der Aufruf scheiterte danach (502 mit invoiceId). */
|
||||
export function isFinalizedNotSent(err) {
|
||||
return Boolean(err) && err.status === 502 && Boolean(err.invoiceId)
|
||||
}
|
||||
|
||||
// Der Server meldet auch „gesendet, konnte aber nicht gelesen werden“ (Rechnung ist raus, nur das Nachlesen scheiterte)
|
||||
const UNREADABLE = /nicht gelesen/i
|
||||
|
||||
/**
|
||||
* Meldung zu einem Fehler bei einer Rechnungsaktion. Bei 502 mit invoiceId (festgeschrieben, nicht versendet) der
|
||||
* feststehende Text mit der Nummer; scheiterte nur das Nachlesen, gilt die genauere Meldung des Servers.
|
||||
*/
|
||||
export function invoiceFailureMessage(err) {
|
||||
const message = text(err?.message) || 'Die Aktion ist fehlgeschlagen.'
|
||||
if (isFinalizedNotSent(err) && !UNREADABLE.test(message)) {
|
||||
const number = text(err.invoiceNumber)
|
||||
return `${number ? `Rechnung ${number}` : 'Die Rechnung'} wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.`
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
/** Welcher Filter die bei Stripe entstandene Rechnung zeigt: mit Nummer „Offen“, sonst (Entwurf) „Entwürfe“. */
|
||||
export function finalizedFilter(err) {
|
||||
return text(err?.invoiceNumber) ? 'offen' : 'entwurf'
|
||||
}
|
||||
|
||||
// --- Rechnung schreiben (Dialog) ---------------------------------------------------------------------------
|
||||
|
||||
/** Grenzen der Rechnungs-API (Portal, stripeFinance.js); die Oberfläche prüft vorab, der Server entscheidet. */
|
||||
export const INVOICE_LIMITS = {
|
||||
maxItems: 20,
|
||||
maxDescription: 200,
|
||||
maxQuantity: 999,
|
||||
maxUnitCents: 10_000_000,
|
||||
maxTotalCents: 99_999_999,
|
||||
maxDays: 60,
|
||||
defaultDays: 14,
|
||||
maxMemo: 1000,
|
||||
}
|
||||
|
||||
/** Menge als Text -> ganze Zahl 1..999, sonst null. */
|
||||
export function parseQuantity(value) {
|
||||
const s = text(typeof value === 'number' ? String(value) : value)
|
||||
if (!/^[0-9]{1,4}$/.test(s)) return null
|
||||
const n = Number(s)
|
||||
return n >= 1 && n <= INVOICE_LIMITS.maxQuantity ? n : null
|
||||
}
|
||||
|
||||
/** Einzelpreis als Text („1.234,50“) -> Cent (1..100.000,00 €), sonst null. */
|
||||
export function parseUnitPrice(value) {
|
||||
try {
|
||||
const cents = parseEuro(typeof value === 'string' ? value : '')
|
||||
return cents >= 1 && cents <= INVOICE_LIMITS.maxUnitCents ? cents : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Zeilensumme einer Position { quantity, price } (Texte) in Cent; null, wenn Menge oder Preis nicht gültig sind. */
|
||||
export function lineTotalCents(item) {
|
||||
const quantity = parseQuantity(item?.quantity)
|
||||
const unit = parseUnitPrice(item?.price)
|
||||
return quantity === null || unit === null ? null : quantity * unit
|
||||
}
|
||||
|
||||
/** Summe aller gültigen Positionen in Cent (ungültige zählen 0). */
|
||||
export function invoiceTotalCents(items) {
|
||||
return objects(items).reduce((sum, item) => sum + (lineTotalCents(item) ?? 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular -> Anfrage. form = { customer, items: [{ description, quantity, price }], days, memo } (Texte).
|
||||
* { ok: true, payload: { customerId, items: [{ description, quantity, unitAmountCents }], daysUntilDue, memo? },
|
||||
* totalCents } oder { ok: false, error } mit der ersten Meldung. `senden` setzt der Aufrufer.
|
||||
*/
|
||||
export function validateInvoiceForm(form) {
|
||||
const f = obj(form)
|
||||
const fail = (error) => ({ ok: false, error })
|
||||
|
||||
if (!f.customer) return fail('Bitte einen Kunden wählen.')
|
||||
const check = canInvoice(f.customer)
|
||||
if (!check.ok) return fail(`Für ${customerName(f.customer) || 'diesen Kunden'} kann keine Rechnung geschrieben werden: ${check.reason}.`)
|
||||
|
||||
const items = objects(f.items)
|
||||
if (items.length < 1 || items.length > INVOICE_LIMITS.maxItems) {
|
||||
return fail(`Eine Rechnung braucht 1 bis ${INVOICE_LIMITS.maxItems} Positionen.`)
|
||||
}
|
||||
const lines = []
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const label = `Position ${i + 1}`
|
||||
const description = text(items[i].description)
|
||||
if (!description) return fail(`${label}: Bitte eine Beschreibung eingeben.`)
|
||||
if (description.length > INVOICE_LIMITS.maxDescription) {
|
||||
return fail(`${label}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.`)
|
||||
}
|
||||
const quantity = parseQuantity(items[i].quantity)
|
||||
if (quantity === null) return fail(`${label}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.`)
|
||||
const unit = parseUnitPrice(items[i].price)
|
||||
if (unit === null) return fail(`${label}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).`)
|
||||
lines.push({ description, quantity, unitAmountCents: unit })
|
||||
}
|
||||
const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
|
||||
if (totalCents > INVOICE_LIMITS.maxTotalCents) return fail('Die Rechnungssumme ist zu hoch (höchstens 999.999,99 €).')
|
||||
|
||||
const daysText = text(typeof f.days === 'number' ? String(f.days) : f.days)
|
||||
const days = /^[0-9]{1,3}$/.test(daysText) ? Number(daysText) : null
|
||||
if (days === null || days > INVOICE_LIMITS.maxDays) {
|
||||
return fail(`Das Zahlungsziel muss eine ganze Zahl von 0 bis ${INVOICE_LIMITS.maxDays} Tagen sein.`)
|
||||
}
|
||||
const memo = text(f.memo)
|
||||
if (memo.length > INVOICE_LIMITS.maxMemo) return fail(`Die Notiz darf höchstens ${INVOICE_LIMITS.maxMemo} Zeichen lang sein.`)
|
||||
|
||||
const payload = { customerId: f.customer.$id, items: lines, daysUntilDue: days }
|
||||
if (memo) payload.memo = memo
|
||||
return { ok: true, payload, totalCents }
|
||||
}
|
||||
|
||||
/** Anzeigename eines Kunden (Appwrite-Dokument): name, sonst companyName. */
|
||||
export function customerName(customer) {
|
||||
return text(customer?.name) || text(customer?.companyName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kundenauswahl: alle Kunden mit Namen als [{ id, name, label, email, ok, reason }] nach Namen sortiert (de).
|
||||
* ok/reason nach canInvoice. label ist der Text im Feld und eindeutig (bei gleichem Namen mit E-Mail ergänzt).
|
||||
*/
|
||||
export function customerPickerOptions(customers) {
|
||||
const named = objects(customers)
|
||||
.map((c) => ({ c, name: customerName(c) }))
|
||||
.filter((x) => x.name && text(x.c.$id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }))
|
||||
const count = new Map()
|
||||
for (const { name } of named) count.set(name.toLowerCase(), (count.get(name.toLowerCase()) || 0) + 1)
|
||||
const used = new Set()
|
||||
return named.map(({ c, name }) => {
|
||||
const email = text(c.email)
|
||||
let label = count.get(name.toLowerCase()) > 1 ? `${name} (${email || c.$id})` : name
|
||||
if (used.has(label.toLowerCase())) label = `${label} · ${String(c.$id).slice(-4)}`
|
||||
used.add(label.toLowerCase())
|
||||
const check = canInvoice(c)
|
||||
return { id: c.$id, customer: c, name, label, email, ok: check.ok, reason: check.ok ? '' : check.reason }
|
||||
})
|
||||
}
|
||||
|
||||
/** Vorschläge für das Kundenfeld: nur Kunden mit ok, die den Text enthalten (Namensanfang zuerst), höchstens `max`. */
|
||||
export function customerSuggestions(options, input, max = 12) {
|
||||
const needle = text(input).toLowerCase()
|
||||
const list = objects(options).filter((o) => o.ok)
|
||||
const hit = (o) => !needle || o.label.toLowerCase().includes(needle) || o.email.toLowerCase().includes(needle)
|
||||
const starts = list.filter((o) => hit(o) && o.label.toLowerCase().startsWith(needle))
|
||||
const rest = list.filter((o) => hit(o) && !o.label.toLowerCase().startsWith(needle))
|
||||
return [...starts, ...rest].slice(0, max).map((o) => ({ value: o.label, hint: o.email }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Was im Kundenfeld steht: { state: 'empty' | 'ok' | 'blocked' | 'unknown', option }. blocked = Kunde gefunden, aber
|
||||
* ohne Rechnung möglich (option.reason nennt den Grund); unknown = Text passt zu keinem Kunden.
|
||||
*/
|
||||
export function resolveCustomerText(options, input) {
|
||||
const value = text(input).toLowerCase()
|
||||
if (!value) return { state: 'empty', option: null }
|
||||
const option = objects(options).find((o) => o.label.toLowerCase() === value) || null
|
||||
if (!option) return { state: 'unknown', option: null }
|
||||
return { state: option.ok ? 'ok' : 'blocked', option }
|
||||
}
|
||||
|
||||
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
|
||||
|
||||
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
|
||||
const dayOf = (tx) => text(tx.bookingDate) || text(tx.valueDate)
|
||||
|
||||
/** Buchungen neueste zuerst (Buchungsdatum, sonst Wertstellung); gleiche Tage behalten die Reihenfolge des Servers. */
|
||||
export function sortTransactions(list) {
|
||||
return objects(list)
|
||||
.map((tx, index) => ({ tx, index }))
|
||||
.sort((a, b) => {
|
||||
const da = dayOf(a.tx)
|
||||
const db = dayOf(b.tx)
|
||||
if (da === db) return a.index - b.index
|
||||
return da < db ? 1 : -1
|
||||
})
|
||||
.map((x) => x.tx)
|
||||
}
|
||||
|
||||
/** Stärkster Vorschlag (hoch vor mittel vor niedrig, sonst Serverreihenfolge) oder null. */
|
||||
export function strongestSuggestion(vorschlaege) {
|
||||
const list = objects(vorschlaege)
|
||||
let best = null
|
||||
for (const s of list) {
|
||||
if (!best || (RANK[s.confidence] ?? 9) < (RANK[best.confidence] ?? 9)) best = s
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** „Vorschlag: Rechnung WK-0005 (hoch)“ / „Vorschlag: Stripe-Auszahlung (hoch) · Betrag weicht ab“; '' ohne Vorschlag. */
|
||||
export function suggestionText(suggestion) {
|
||||
if (!suggestion || typeof suggestion !== 'object') return ''
|
||||
const what = suggestion.kind === 'payout' ? 'Stripe-Auszahlung' : `Rechnung ${text(suggestion.number) || text(suggestion.id)}`
|
||||
const level = has(RANK, suggestion.confidence) ? ` (${suggestion.confidence})` : ''
|
||||
return `Vorschlag: ${what}${level}${suggestion.amountDiffers ? ' · Betrag weicht ab' : ''}`
|
||||
}
|
||||
|
||||
/** Text der Zuordnung: „Offen“, „Rechnung WK-0005“, „Stripe-Auszahlung“, „Fixkosten“, „Einnahme ohne Stripe-Rechnung“ … */
|
||||
export function assignmentText(tx) {
|
||||
const t = obj(tx)
|
||||
const category = text(t.category)
|
||||
if (category === 'rechnung') return `Rechnung ${text(t.matchedInvoiceNumber) || text(t.matchedInvoiceId) || ''}`.trim()
|
||||
if (has(CATEGORY_LABEL, category)) return CATEGORY_LABEL[category]
|
||||
return category || CATEGORY_LABEL['']
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeilen der Buchungstabelle in der Reihenfolge von sortTransactions:
|
||||
* { key, id, tx, dateText, counterparty, remittance, amountText, incoming, pending, assignmentText, assigned, note,
|
||||
* suggestionText (leer bei zugeordneten und vorgemerkten Buchungen), action: 'assign' | 'change' | null, actionLabel }.
|
||||
* Zuordnen für jede offene Buchung, „Ändern“ für zugeordnete außer Rechnungen (sie lassen sich nicht lösen).
|
||||
*/
|
||||
export function transactionRows(buchungen) {
|
||||
return sortTransactions(buchungen).map((tx, index) => {
|
||||
const category = text(tx.category)
|
||||
const assigned = Boolean(category)
|
||||
let action = null
|
||||
if (!assigned) action = 'assign'
|
||||
else if (category !== 'rechnung') action = 'change'
|
||||
return {
|
||||
key: text(tx.$id) || `tx-${index}`,
|
||||
id: text(tx.$id),
|
||||
tx,
|
||||
dateText: dateDe(dayOf(tx)),
|
||||
counterparty: text(tx.counterpartyName) || text(tx.counterpartyIban) || 'Unbekannt',
|
||||
remittance: text(tx.remittance),
|
||||
amountText: signedEuro(tx.amountCents),
|
||||
incoming: isNum(tx.amountCents) && tx.amountCents > 0,
|
||||
pending: tx.status === 'PDNG',
|
||||
assignmentText: assignmentText(tx),
|
||||
assigned,
|
||||
note: text(tx.note),
|
||||
// Vorgemerkte Buchungen lassen sich keiner Rechnung zuordnen: kein Vorschlag
|
||||
suggestionText: assigned || tx.status === 'PDNG' ? '' : suggestionText(strongestSuggestion(tx.vorschlaege)),
|
||||
action,
|
||||
actionLabel: action === 'assign' ? 'Zuordnen' : action === 'change' ? 'Ändern' : '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
|
||||
|
||||
export const PENDING_ASSIGN_HINT = 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.'
|
||||
|
||||
/** Bestätigungstext vor dem Bezahlt-Markieren (nicht umkehrbar). */
|
||||
export function invoiceAssignConfirmText(number) {
|
||||
return `Die Rechnung ${number} wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.`
|
||||
}
|
||||
|
||||
const fixedCostLabel = (f) => `${text(f.name) || 'Fixkosten'} · ${euro(f.amountCents)} ${INTERVAL_LABEL[f.interval] || ''}`.trim()
|
||||
|
||||
/**
|
||||
* Auswahl im Zuordnen-Dialog zu einer Buchung:
|
||||
* { incoming, outgoing, pending, assigned, currentText, canUnassign, hint, suggestions, invoices, fixedCosts,
|
||||
* categories, byKey }. Jede Option: { key, type ('invoice'|'payout'|'fixedCost'|'category'), label, detail,
|
||||
* disabled, disabledReason, number, body } mit body = Anfrage an die API (ohne Notiz).
|
||||
* - zugeordnete Buchung: keine Optionen, nur currentText (und canUnassign außer bei Rechnungen)
|
||||
* - vorgemerkt (PDNG): nur Fixkosten (Ausgang) und Kategorien, mit Hinweis
|
||||
* - Eingang: Vorschläge des Servers, andere offene Rechnungen, Kategorien (auch „Einnahme ohne Stripe-Rechnung“)
|
||||
* - Ausgang: Fixkosten (aktive), Privat/Umbuchung, Sonstiges
|
||||
* Rechnungen, deren offener Betrag vom Betrag der Buchung abweicht, sind nicht wählbar (Teilzahlungen in Stripe).
|
||||
*/
|
||||
export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) {
|
||||
const t = obj(tx)
|
||||
const amount = t.amountCents
|
||||
const incoming = isNum(amount) && amount > 0
|
||||
const outgoing = isNum(amount) && amount < 0
|
||||
const pending = t.status === 'PDNG'
|
||||
const category = text(t.category)
|
||||
const assigned = Boolean(category)
|
||||
const model = {
|
||||
incoming,
|
||||
outgoing,
|
||||
pending,
|
||||
assigned,
|
||||
currentText: assigned ? assignmentText(t) : '',
|
||||
canUnassign: assigned && category !== 'rechnung',
|
||||
hint: pending && !assigned ? PENDING_ASSIGN_HINT : '',
|
||||
suggestions: [],
|
||||
invoices: [],
|
||||
fixedCosts: [],
|
||||
categories: [],
|
||||
byKey: {},
|
||||
}
|
||||
if (assigned) return model
|
||||
|
||||
const open = objects(invoices).filter((inv) => OPEN_INVOICE.has(inv.status) && text(inv.id))
|
||||
const invoiceOption = (inv, extra = {}) => {
|
||||
const differs = isNum(amount) && inv.remainingCents !== amount
|
||||
return {
|
||||
key: `invoice:${inv.id}`,
|
||||
type: 'invoice',
|
||||
label: `${text(inv.number) || inv.id} · ${text(inv.customerName) || text(inv.customerEmail) || 'Unbekannter Kunde'} · ${euro(inv.remainingCents)} offen · fällig ${dateDe(inv.dueDate)}`,
|
||||
detail: '',
|
||||
disabled: differs,
|
||||
disabledReason: differs ? `Betrag weicht ab (offen: ${euro(inv.remainingCents)})` : '',
|
||||
number: text(inv.number),
|
||||
body: { invoiceId: inv.id },
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
if (incoming && !pending) {
|
||||
const seen = new Set()
|
||||
for (const s of objects(t.vorschlaege)) {
|
||||
if (s.kind === 'payout' && text(s.id)) {
|
||||
model.suggestions.push({
|
||||
key: `payout:${s.id}`,
|
||||
type: 'payout',
|
||||
label: 'Stripe-Auszahlung',
|
||||
detail: text(s.reason),
|
||||
confidence: s.confidence,
|
||||
disabled: false,
|
||||
disabledReason: '',
|
||||
number: '',
|
||||
body: { payoutId: s.id },
|
||||
})
|
||||
} else if (s.kind === 'invoice' && text(s.id) && !seen.has(s.id)) {
|
||||
seen.add(s.id)
|
||||
const known = open.find((inv) => inv.id === s.id)
|
||||
const differs = s.amountDiffers === true || (known ? known.remainingCents !== amount : false)
|
||||
const number = text(s.number) || text(known?.number)
|
||||
model.suggestions.push({
|
||||
key: `invoice:${s.id}`,
|
||||
type: 'invoice',
|
||||
label: `Rechnung ${number || s.id}${known ? ` · ${text(known.customerName) || text(known.customerEmail)}` : ''}`,
|
||||
detail: [text(s.reason), known ? `${euro(known.remainingCents)} offen` : ''].filter(Boolean).join(' · '),
|
||||
confidence: s.confidence,
|
||||
disabled: differs,
|
||||
disabledReason: differs ? 'Betrag weicht ab, Teilzahlungen bitte direkt in Stripe erfassen' : '',
|
||||
number,
|
||||
body: { invoiceId: s.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
model.invoices = open.filter((inv) => !seen.has(inv.id)).map((inv) => invoiceOption(inv))
|
||||
}
|
||||
|
||||
if (outgoing) {
|
||||
model.fixedCosts = objects(fixedCosts)
|
||||
.filter((f) => f.active !== false && text(f.$id))
|
||||
.map((f) => ({
|
||||
key: `fixedCost:${f.$id}`,
|
||||
type: 'fixedCost',
|
||||
label: fixedCostLabel(f),
|
||||
detail: '',
|
||||
disabled: false,
|
||||
disabledReason: '',
|
||||
number: '',
|
||||
body: { fixedCostId: f.$id },
|
||||
}))
|
||||
}
|
||||
|
||||
const categories = [...(incoming ? ['rechnung-extern'] : []), 'privat', 'sonstiges']
|
||||
model.categories = categories.map((value) => ({
|
||||
key: `category:${value}`,
|
||||
type: 'category',
|
||||
label: CATEGORY_LABEL[value],
|
||||
detail: '',
|
||||
disabled: false,
|
||||
disabledReason: '',
|
||||
number: '',
|
||||
body: { category: value },
|
||||
}))
|
||||
|
||||
for (const option of [...model.suggestions, ...model.invoices, ...model.fixedCosts, ...model.categories]) {
|
||||
model.byKey[option.key] = option
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
/** Anfrage-Body für assignTransaction: Ziel der Option plus Notiz (nur wenn nicht leer; höchstens 500 Zeichen). */
|
||||
export function assignBody(option, note) {
|
||||
const body = { ...obj(option).body }
|
||||
const n = text(note)
|
||||
if (n) body.note = n
|
||||
return body
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button } from '../components/ui'
|
||||
@@ -8,17 +8,71 @@ import TodoList from '../components/finance/TodoList'
|
||||
import ForecastTable from '../components/finance/ForecastTable'
|
||||
import TaxCard from '../components/finance/TaxCard'
|
||||
import AboCard from '../components/finance/AboCard'
|
||||
import InvoiceTable from '../components/finance/InvoiceTable'
|
||||
import TransactionTable from '../components/finance/TransactionTable'
|
||||
import AssignDialog from '../components/finance/AssignDialog'
|
||||
import { useFinanceOverview } from '../hooks/useFinanceOverview'
|
||||
import { useFinanceTransactions } from '../hooks/useFinanceTransactions'
|
||||
import { useOpenInvoices } from '../hooks/useOpenInvoices'
|
||||
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
|
||||
import { planTodoAction, safeUrl } from '../lib/financeView'
|
||||
|
||||
export default function FinancePage() {
|
||||
const navigate = useNavigate()
|
||||
const { data, loading, error, reload } = useFinanceOverview()
|
||||
const txs = useFinanceTransactions()
|
||||
const reloadTransactions = txs.reload
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
// Buchung, die gerade zugeordnet werden soll; den Dialog dazu zeigt Block 7 (Task 5)
|
||||
// Buchung, die gerade zugeordnet werden soll (Knopf in Block 7 oder Aufgabe „Zahlungseingang zuordnen“)
|
||||
const [assignTxId, setAssignTxId] = useState(null)
|
||||
const [assignNotice, setAssignNotice] = useState('')
|
||||
const refreshLock = useRef(false)
|
||||
const retriedRef = useRef(null)
|
||||
|
||||
const assignTx = assignTxId ? txs.transactions.find((t) => t.$id === assignTxId) || null : null
|
||||
// Offene Rechnungen braucht nur ein gebuchter, noch nicht zugeordneter Eingang
|
||||
const needsInvoices = Boolean(assignTx && !assignTx.category && assignTx.status !== 'PDNG' && assignTx.amountCents > 0)
|
||||
const openInvoices = useOpenInvoices(needsInvoices)
|
||||
|
||||
// Übersicht und Buchungen zusammen neu laden (nach Zuordnen, Rechnungsänderungen und Bankabruf)
|
||||
const reloadAll = useCallback(() => Promise.all([reload(), reloadTransactions()]), [reload, reloadTransactions])
|
||||
|
||||
// Eine Aufgabe kann eine Buchung nennen, die in der geladenen Liste fehlt (neu abgerufen): einmal nachladen,
|
||||
// dann melden statt still nichts zu tun
|
||||
useEffect(() => {
|
||||
if (!assignTxId || assignTx || txs.loading) return
|
||||
if (retriedRef.current !== assignTxId) {
|
||||
retriedRef.current = assignTxId
|
||||
reloadTransactions()
|
||||
return
|
||||
}
|
||||
setAssignNotice(
|
||||
txs.error
|
||||
? `Die Buchungen konnten nicht geladen werden: ${txs.error}`
|
||||
: 'Die Buchung wurde nicht gefunden. Möglicherweise ist sie schon zugeordnet.'
|
||||
)
|
||||
setAssignTxId(null)
|
||||
}, [assignTxId, assignTx, txs.loading, txs.error, reloadTransactions])
|
||||
|
||||
const openAssign = useCallback((id) => {
|
||||
setAssignNotice('')
|
||||
retriedRef.current = null
|
||||
setAssignTxId(id)
|
||||
}, [])
|
||||
|
||||
const handleAssignDone = useCallback(() => {
|
||||
setAssignTxId(null)
|
||||
return reloadAll()
|
||||
}, [reloadAll])
|
||||
|
||||
const handleAssignClose = useCallback(
|
||||
(info) => {
|
||||
setAssignTxId(null)
|
||||
// Nach einem Fehler kann sich der Stand geändert haben (z. B. „Bereits zugeordnet“)
|
||||
if (info?.refresh) reloadAll()
|
||||
},
|
||||
[reloadAll]
|
||||
)
|
||||
|
||||
// Bankabruf anstoßen, danach die Übersicht neu laden. Fehler (z. B. 429) gehen an den Aufrufer.
|
||||
// Ein Abruf, der bei der Bank scheitert, antwortet mit ok:false; das steht danach in der Statuszeile.
|
||||
@@ -28,12 +82,12 @@ export default function FinancePage() {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await refreshBank()
|
||||
await reload()
|
||||
await reloadAll()
|
||||
} finally {
|
||||
refreshLock.current = false
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [reload])
|
||||
}, [reloadAll])
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (aktion) => {
|
||||
@@ -59,7 +113,7 @@ export default function FinancePage() {
|
||||
})
|
||||
return
|
||||
case 'assign':
|
||||
setAssignTxId(plan.transactionId)
|
||||
openAssign(plan.transactionId)
|
||||
return
|
||||
case 'navigate':
|
||||
navigate(plan.to)
|
||||
@@ -73,7 +127,7 @@ export default function FinancePage() {
|
||||
throw new Error(plan.message)
|
||||
}
|
||||
},
|
||||
[navigate, refresh, reload]
|
||||
[navigate, openAssign, refresh, reload]
|
||||
)
|
||||
|
||||
let body
|
||||
@@ -115,6 +169,16 @@ export default function FinancePage() {
|
||||
<div className="fin-cols fin-cols-half">
|
||||
<AboCard abos={data.abos} />
|
||||
</div>
|
||||
<InvoiceTable altersgruppen={data.rechnungen?.altersgruppen} onChanged={reloadAll} />
|
||||
<TransactionTable
|
||||
transactions={txs.transactions}
|
||||
stripeFehler={txs.stripeFehler}
|
||||
loaded={txs.loaded}
|
||||
loading={txs.loading}
|
||||
error={txs.error}
|
||||
onReload={reloadTransactions}
|
||||
onAssign={(tx) => openAssign(tx.$id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -122,7 +186,24 @@ export default function FinancePage() {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Finanzen" subtitle="Kontostand, Aufgaben und Zahlungen im Blick" />
|
||||
{assignNotice && (
|
||||
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
|
||||
{assignNotice}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={() => setAssignNotice('')}>Schließen</Button>
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
{assignTx && (
|
||||
<AssignDialog
|
||||
key={assignTx.$id}
|
||||
transaction={assignTx}
|
||||
invoices={openInvoices.invoices}
|
||||
invoicesLoading={openInvoices.loading}
|
||||
invoicesError={openInvoices.error}
|
||||
onClose={handleAssignClose}
|
||||
onDone={handleAssignDone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1282,3 +1282,152 @@ textarea.form-control {
|
||||
.fin-abo:last-child { border-bottom: none; }
|
||||
.fin-abo-customer { font-weight: 700; overflow-wrap: anywhere; }
|
||||
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); font-size: 13px; }
|
||||
|
||||
/* Block 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */
|
||||
.fin-block { scroll-margin-top: var(--sp-4); }
|
||||
.fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; }
|
||||
.fin-toolbar { padding: var(--sp-3) var(--sp-4); }
|
||||
.fin-toolbar .seg { flex-wrap: wrap; max-width: 100%; }
|
||||
.fin-notice { padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
||||
.fin-load-error { display: grid; gap: var(--sp-3); justify-items: start; padding: var(--sp-4); }
|
||||
.fin-page-notice { margin-bottom: var(--sp-4); }
|
||||
.fin-ages {
|
||||
list-style: none; margin: 0; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3);
|
||||
}
|
||||
.fin-age { display: grid; gap: 2px; min-width: 0; }
|
||||
.fin-age-label { color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.fin-age-count { font-size: 13px; color: var(--text-muted); }
|
||||
.fin-age-sum { text-align: left; font-size: 18px; font-weight: 800; }
|
||||
.fin-pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
|
||||
.fin-pager-info { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.fin-invoices th, .fin-invoices td, .fin-tx th, .fin-tx td { padding: 9px 10px; vertical-align: top; }
|
||||
.fin-invoices th.fin-amount, .fin-tx th.fin-amount { text-align: right; }
|
||||
.fin-invoices th:first-child, .fin-invoices td:first-child, .fin-tx th:first-child, .fin-tx td:first-child { padding-left: var(--sp-4); }
|
||||
.fin-invoices th:last-child, .fin-invoices td:last-child, .fin-tx th:last-child, .fin-tx td:last-child { padding-right: var(--sp-4); }
|
||||
.fin-invoices tbody tr:last-child td, .fin-tx tbody tr:last-child td { border-bottom: none; }
|
||||
.fin-inv-number { font-weight: 700; white-space: nowrap; }
|
||||
.fin-inv-customer { overflow-wrap: anywhere; }
|
||||
.fin-inv-created, .fin-inv-due, .fin-tx-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.fin-inv-statusbox { display: grid; gap: 3px; justify-items: start; }
|
||||
.fin-inv-note { font-size: 12px; }
|
||||
.fin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fin-tx-counterparty { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.fin-tx-remittance {
|
||||
font-size: 13px; overflow-wrap: anywhere;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.fin-tx-amount { font-weight: 700; }
|
||||
.fin-tx-badges { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.fin-tx-assign .badge { white-space: normal; }
|
||||
.fin-tx-suggestion, .fin-tx-note { font-size: 12px; margin-top: 4px; overflow-wrap: anywhere; }
|
||||
.fin-tx-action { white-space: nowrap; text-align: right; }
|
||||
@container (max-width: 860px) {
|
||||
.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fin-invoices thead, .fin-tx thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-invoices, .fin-invoices tbody, .fin-tx, .fin-tx tbody { display: block; }
|
||||
.fin-invoices tr {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px var(--sp-4); align-items: baseline;
|
||||
grid-template-areas: "number status" "customer customer" "created due" "total remaining" "actions actions";
|
||||
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.fin-invoices tbody tr:last-child { border-bottom: none; }
|
||||
.fin-invoices td, .fin-invoices td:first-child, .fin-invoices td:last-child, .fin-tx td, .fin-tx td:first-child, .fin-tx td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||
.fin-invoices tbody tr:hover td, .fin-tx tbody tr:hover td { background: transparent; }
|
||||
.fin-inv-number { grid-area: number; }
|
||||
.fin-inv-status { grid-area: status; }
|
||||
.fin-inv-statusbox { justify-items: end; text-align: right; }
|
||||
.fin-inv-customer { grid-area: customer; }
|
||||
.fin-inv-created { grid-area: created; }
|
||||
.fin-inv-due { grid-area: due; }
|
||||
.fin-inv-total { grid-area: total; }
|
||||
.fin-inv-remaining { grid-area: remaining; }
|
||||
.fin-inv-actions { grid-area: actions; padding-top: 4px; }
|
||||
.fin-invoices td[data-label] { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 var(--sp-2); font-size: 13px; }
|
||||
.fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-weight: 500; }
|
||||
}
|
||||
@container (max-width: 640px) {
|
||||
.fin-tx tr {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
|
||||
grid-template-areas: "date amount" "what what" "assign action";
|
||||
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.fin-tx tbody tr:last-child { border-bottom: none; }
|
||||
.fin-tx-date { grid-area: date; }
|
||||
.fin-tx-amount { grid-area: amount; }
|
||||
.fin-tx-what { grid-area: what; }
|
||||
.fin-tx-assign { grid-area: assign; }
|
||||
.fin-tx-action { grid-area: action; align-self: start; }
|
||||
}
|
||||
|
||||
/* Dialoge: Rechnung schreiben, Buchung zuordnen (auf dem dunklen Overlay) */
|
||||
.fin-close { background: none; border: 0; padding: 0; line-height: 1; font-size: 32px; top: 16px; right: 24px; z-index: 1; }
|
||||
.fin-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
|
||||
.fin-close:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.fin-overlay { background: rgba(6, 9, 14, 0.98); }
|
||||
.fin-dialog { max-width: 760px; }
|
||||
.fin-dialog:focus { outline: none; }
|
||||
.fin-dialog-error {
|
||||
margin-bottom: var(--sp-4); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm);
|
||||
border: 1px solid var(--danger); background: rgba(239, 68, 68, 0.16); color: #fecaca; overflow-wrap: anywhere;
|
||||
}
|
||||
.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: #fbbf24; overflow-wrap: anywhere; }
|
||||
.fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
.fin-fixed-customer { margin: 0; overflow-wrap: anywhere; }
|
||||
.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; }
|
||||
.fin-items legend { padding: 0; }
|
||||
.fin-item {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) 90px 130px 120px 38px; gap: 8px; align-items: end; margin-bottom: 8px;
|
||||
}
|
||||
.fin-item-field { min-width: 0; }
|
||||
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-muted); }
|
||||
.fin-item ~ .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-item .form-control.fin-amount { text-align: right; }
|
||||
.fin-item-total { display: block; padding: 10px 0; font-weight: 600; }
|
||||
.fin-icon-btn {
|
||||
width: 38px; height: 40px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
background: transparent; border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text-muted);
|
||||
}
|
||||
.fin-icon-btn:hover:not(:disabled) { color: #f87171; border-color: #f87171; }
|
||||
.fin-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.fin-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||
.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; }
|
||||
.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; }
|
||||
@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; }
|
||||
.fin-item-qty { grid-area: qty; }
|
||||
.fin-item-price { grid-area: price; }
|
||||
.fin-item-sum { grid-area: sum; text-align: right; }
|
||||
.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; }
|
||||
.fin-toolbar .seg button { padding: 6px 9px; }
|
||||
}
|
||||
@@ -23,6 +23,35 @@ const {
|
||||
aboView,
|
||||
productLabel,
|
||||
aboStatus,
|
||||
INVOICE_FILTERS,
|
||||
DEFAULT_INVOICE_FILTER,
|
||||
INVOICE_LIMITS,
|
||||
PENDING_ASSIGN_HINT,
|
||||
ageGroupCells,
|
||||
invoiceActions,
|
||||
invoiceRows,
|
||||
invoiceConfirmText,
|
||||
invoiceDoneMessage,
|
||||
isFinalizedNotSent,
|
||||
finalizedFilter,
|
||||
invoiceFailureMessage,
|
||||
parseQuantity,
|
||||
parseUnitPrice,
|
||||
lineTotalCents,
|
||||
invoiceTotalCents,
|
||||
validateInvoiceForm,
|
||||
customerName,
|
||||
customerPickerOptions,
|
||||
customerSuggestions,
|
||||
resolveCustomerText,
|
||||
sortTransactions,
|
||||
strongestSuggestion,
|
||||
suggestionText,
|
||||
assignmentText,
|
||||
transactionRows,
|
||||
invoiceAssignConfirmText,
|
||||
assignModel,
|
||||
assignBody,
|
||||
} = await loadModule('src/lib/financeView.js')
|
||||
|
||||
const MINUS = '−'
|
||||
@@ -607,3 +636,491 @@ test('productLabel und aboStatus', () => {
|
||||
assert.deepEqual(aboStatus('weird'), { label: 'weird', tone: 'muted' })
|
||||
assert.deepEqual(aboStatus(''), { label: 'unbekannt', tone: 'muted' })
|
||||
})
|
||||
|
||||
// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
|
||||
|
||||
const INV = (over = {}) => ({
|
||||
id: 'in_1', number: 'WK-0006', status: 'ueberfaellig', customerName: 'Kunde Zwei', customerEmail: 'k@example.invalid',
|
||||
webklarCustomerId: 'c2', createdDate: '2026-09-01', dueDate: '2026-09-21', totalCents: 5000, remainingCents: 5000,
|
||||
paidCents: 0, paidDate: null, overdueDays: 10, source: 'projekt', hostedUrl: 'https://pay.stripe.test/i/1',
|
||||
pdfUrl: 'https://pay.stripe.test/i/1/pdf', outOfBand: false, bankTransactionId: '', ...over,
|
||||
})
|
||||
|
||||
test('INVOICE_FILTERS: Reihenfolge Offen, Überfällig, Bezahlt, Entwürfe, Alle; Standard Offen', () => {
|
||||
assert.deepEqual(INVOICE_FILTERS.map((f) => f.label), ['Offen', 'Überfällig', 'Bezahlt', 'Entwürfe', 'Alle'])
|
||||
assert.deepEqual(INVOICE_FILTERS.map((f) => f.key), ['offen', 'ueberfaellig', 'bezahlt', 'entwurf', 'alle'])
|
||||
assert.equal(DEFAULT_INVOICE_FILTER, 'offen')
|
||||
})
|
||||
|
||||
test('ageGroupCells: vier Gruppen in fester Reihenfolge, fehlende zählen 0', () => {
|
||||
const cells = ageGroupCells([
|
||||
{ key: '1-30', anzahl: 2, summeCents: 12345 },
|
||||
{ key: 'nicht-faellig', anzahl: 1, summeCents: 16879 },
|
||||
])
|
||||
assert.deepEqual(cells.map((c) => c.key), ['nicht-faellig', '1-30', '31-60', 'ueber-60'])
|
||||
assert.deepEqual(cells.map((c) => c.label), ['Nicht fällig', '1–30 Tage', '31–60 Tage', 'Über 60 Tage'])
|
||||
assert.deepEqual(cells.map((c) => c.count), [1, 2, 0, 0])
|
||||
assert.deepEqual(cells.map((c) => c.sumText), ['168,79 €', '123,45 €', '0,00 €', '0,00 €'])
|
||||
assert.deepEqual(ageGroupCells(null), [])
|
||||
assert.deepEqual(ageGroupCells(undefined), [])
|
||||
assert.equal(ageGroupCells([]).length, 4)
|
||||
})
|
||||
|
||||
test('invoiceActions: Entwurf bekommt Senden und Löschen, keine Adressen', () => {
|
||||
const a = invoiceActions(INV({ status: 'entwurf', number: '', hostedUrl: null, pdfUrl: null }))
|
||||
assert.deepEqual(a.map((x) => x.key), ['send', 'delete'])
|
||||
assert.deepEqual(a.map((x) => x.label), ['Senden', 'Löschen'])
|
||||
})
|
||||
|
||||
test('invoiceActions: offen und überfällig (Projekt): Zahlungsseite, PDF, Erinnerung senden, Stornieren', () => {
|
||||
for (const status of ['offen', 'ueberfaellig']) {
|
||||
const a = invoiceActions(INV({ status }))
|
||||
assert.deepEqual(a.map((x) => x.label), ['Zahlungsseite', 'PDF', 'Erinnerung senden', 'Stornieren'])
|
||||
assert.equal(a[0].url, 'https://pay.stripe.test/i/1')
|
||||
assert.equal(a[1].url, 'https://pay.stripe.test/i/1/pdf')
|
||||
}
|
||||
})
|
||||
|
||||
test('invoiceActions: Abo-Rechnungen nur Zahlungsseite und PDF, nie Senden/Stornieren/Löschen', () => {
|
||||
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'offen' })).map((x) => x.key), ['hosted', 'pdf'])
|
||||
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'ueberfaellig' })).map((x) => x.key), ['hosted', 'pdf'])
|
||||
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'entwurf' })), [])
|
||||
assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'bezahlt' })).map((x) => x.key), ['pdf'])
|
||||
})
|
||||
|
||||
test('invoiceActions: bezahlt nur PDF; unsichere Adressen fallen weg', () => {
|
||||
assert.deepEqual(invoiceActions(INV({ status: 'bezahlt' })).map((x) => x.key), ['pdf'])
|
||||
assert.deepEqual(invoiceActions(INV({ status: 'storniert' })).map((x) => x.key), ['pdf'])
|
||||
const unsafe = invoiceActions(INV({ status: 'offen', hostedUrl: 'javascript:alert(1)', pdfUrl: '' }))
|
||||
assert.deepEqual(unsafe.map((x) => x.key), ['resend', 'void'])
|
||||
assert.deepEqual(invoiceActions(null), [])
|
||||
})
|
||||
|
||||
test('invoiceRows: Spalten und Texte', () => {
|
||||
const [r] = invoiceRows([INV()])
|
||||
assert.equal(r.numberText, 'WK-0006')
|
||||
assert.equal(r.customerText, 'Kunde Zwei')
|
||||
assert.equal(r.createdText, '01.09.2026')
|
||||
assert.equal(r.dueText, '21.09.2026')
|
||||
assert.equal(r.totalText, '50,00 €')
|
||||
assert.equal(r.remainingText, '50,00 €')
|
||||
assert.equal(r.statusLabel, 'Überfällig')
|
||||
assert.equal(r.statusTone, 'warn')
|
||||
assert.equal(r.statusNote, '10 Tage überfällig')
|
||||
assert.equal(r.isAbo, false)
|
||||
})
|
||||
|
||||
test('invoiceRows: Entwurf ohne Nummer, Bezahlt per Überweisung, Offen nur bei offen/überfällig', () => {
|
||||
const [draft, paid, paidStripe, open, one] = invoiceRows([
|
||||
INV({ status: 'entwurf', number: '', dueDate: null, hostedUrl: null, pdfUrl: null, overdueDays: 0, remainingCents: 3000, totalCents: 3000 }),
|
||||
INV({ status: 'bezahlt', paidDate: '2026-08-05', outOfBand: true, remainingCents: 0, totalCents: 16879 }),
|
||||
INV({ status: 'bezahlt', paidDate: '2026-08-06', outOfBand: false, remainingCents: 0 }),
|
||||
INV({ status: 'offen', overdueDays: 0, remainingCents: 1234 }),
|
||||
INV({ overdueDays: 1 }),
|
||||
])
|
||||
assert.equal(draft.numberText, 'ohne Nr.')
|
||||
assert.equal(draft.dueText, '–')
|
||||
assert.equal(draft.remainingText, '–')
|
||||
assert.equal(draft.statusLabel, 'Entwurf')
|
||||
assert.equal(paid.statusNote, 'am 05.08.2026 · per Überweisung')
|
||||
assert.equal(paid.remainingText, '–')
|
||||
assert.equal(paidStripe.statusNote, 'am 06.08.2026')
|
||||
assert.equal(open.statusNote, '')
|
||||
assert.equal(open.remainingText, '12,34 €')
|
||||
assert.equal(one.statusNote, '1 Tag überfällig')
|
||||
})
|
||||
|
||||
test('invoiceRows: fehlende Namen, Abo-Kennzeichen, kaputte Einträge', () => {
|
||||
const rows = invoiceRows([INV({ customerName: '', customerEmail: 'a@b.invalid', source: 'abo' }), INV({ customerName: '', customerEmail: '' }), null, 5])
|
||||
assert.equal(rows.length, 2)
|
||||
assert.equal(rows[0].customerText, 'a@b.invalid')
|
||||
assert.equal(rows[0].isAbo, true)
|
||||
assert.equal(rows[1].customerText, 'Unbekannter Kunde')
|
||||
assert.deepEqual(invoiceRows(undefined), [])
|
||||
})
|
||||
|
||||
test('invoiceConfirmText: Löschen, Stornieren, Erinnerung; sonst leer', () => {
|
||||
const inv = INV({ status: 'offen' })
|
||||
assert.equal(invoiceConfirmText('delete', inv), 'Den Entwurf für Kunde Zwei über 50,00 € wirklich löschen? Das lässt sich nicht rückgängig machen.')
|
||||
assert.equal(invoiceConfirmText('void', inv), 'Die Rechnung WK-0006 an Kunde Zwei über 50,00 € wirklich stornieren? Das lässt sich nicht rückgängig machen.')
|
||||
assert.equal(invoiceConfirmText('resend', inv), 'Die Rechnung WK-0006 jetzt noch einmal an k@example.invalid senden (Erinnerung)?')
|
||||
assert.equal(invoiceConfirmText('send', inv), '')
|
||||
assert.equal(invoiceConfirmText('pdf', inv), '')
|
||||
})
|
||||
|
||||
test('invoiceDoneMessage', () => {
|
||||
const r = { number: 'WK-0007', customerEmail: 'k@example.invalid' }
|
||||
assert.equal(invoiceDoneMessage('send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
|
||||
assert.equal(invoiceDoneMessage('create-send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
|
||||
assert.equal(invoiceDoneMessage('resend', r), 'Die Erinnerung zu Rechnung WK-0007 wurde an k@example.invalid gesendet.')
|
||||
assert.equal(invoiceDoneMessage('void', r), 'Rechnung WK-0007 wurde storniert.')
|
||||
assert.equal(invoiceDoneMessage('delete'), 'Der Entwurf wurde gelöscht.')
|
||||
assert.equal(invoiceDoneMessage('create-draft', { number: '' }), 'Der Entwurf wurde gespeichert.')
|
||||
assert.equal(invoiceDoneMessage('send', {}), 'Die Rechnung wurde gesendet.')
|
||||
assert.equal(invoiceDoneMessage('xyz', r), '')
|
||||
})
|
||||
|
||||
test('invoiceFailureMessage: 502 mit invoiceId nennt die Nummer, sonst die Meldung des Servers', () => {
|
||||
const e502 = Object.assign(new Error('Stripe ist gerade nicht erreichbar.'), { status: 502, invoiceId: 'in_9', invoiceNumber: 'WK-0009' })
|
||||
assert.equal(isFinalizedNotSent(e502), true)
|
||||
assert.equal(invoiceFailureMessage(e502), 'Rechnung WK-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
|
||||
const noNumber = Object.assign(new Error('x'), { status: 502, invoiceId: 'in_9' })
|
||||
assert.equal(invoiceFailureMessage(noNumber), 'Die Rechnung wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
|
||||
const plain502 = Object.assign(new Error('Stripe ist gerade nicht erreichbar.'), { status: 502 })
|
||||
assert.equal(isFinalizedNotSent(plain502), false)
|
||||
assert.equal(invoiceFailureMessage(plain502), 'Stripe ist gerade nicht erreichbar.')
|
||||
assert.equal(invoiceFailureMessage(Object.assign(new Error('Diese Rechnung wird gerade schon erstellt.'), { status: 409 })), 'Diese Rechnung wird gerade schon erstellt.')
|
||||
assert.equal(invoiceFailureMessage(null), 'Die Aktion ist fehlgeschlagen.')
|
||||
assert.equal(isFinalizedNotSent(null), false)
|
||||
})
|
||||
|
||||
test('invoiceFailureMessage: scheiterte nur das Nachlesen, gilt die Meldung des Servers (die Rechnung ist raus)', () => {
|
||||
const sentButUnreadable = Object.assign(
|
||||
new Error('Rechnung WK-0010 wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.'),
|
||||
{ status: 502, invoiceId: 'in_10', invoiceNumber: 'WK-0010' }
|
||||
)
|
||||
assert.equal(invoiceFailureMessage(sentButUnreadable), 'Rechnung WK-0010 wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.')
|
||||
const draftUnreadable = Object.assign(new Error('Der Entwurf wurde angelegt, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.'), { status: 502, invoiceId: 'in_11', invoiceNumber: '' })
|
||||
assert.match(invoiceFailureMessage(draftUnreadable), /^Der Entwurf wurde angelegt/)
|
||||
const notSent = Object.assign(new Error('Rechnung WK-0010 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), { status: 502, invoiceId: 'in_10', invoiceNumber: 'WK-0010' })
|
||||
assert.equal(invoiceFailureMessage(notSent), 'Rechnung WK-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
|
||||
})
|
||||
|
||||
test('finalizedFilter: mit Nummer Offen, ohne (Entwurf) Entwürfe', () => {
|
||||
assert.equal(finalizedFilter({ invoiceNumber: 'WK-0010' }), 'offen')
|
||||
assert.equal(finalizedFilter({ invoiceNumber: '' }), 'entwurf')
|
||||
assert.equal(finalizedFilter({}), 'entwurf')
|
||||
assert.equal(finalizedFilter(null), 'entwurf')
|
||||
})
|
||||
|
||||
// --- Rechnung schreiben (Dialog) ---------------------------------------------------------------------------
|
||||
|
||||
const CUST = { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', customerStatus: 'customer' }
|
||||
const FORM = (over = {}) => ({
|
||||
customer: CUST,
|
||||
items: [{ description: 'Webdesign', quantity: '2', price: '1.234,50' }],
|
||||
days: '14',
|
||||
memo: '',
|
||||
...over,
|
||||
})
|
||||
|
||||
test('parseQuantity: ganze Zahlen 1 bis 999', () => {
|
||||
assert.equal(parseQuantity('1'), 1)
|
||||
assert.equal(parseQuantity(' 12 '), 12)
|
||||
assert.equal(parseQuantity('999'), 999)
|
||||
assert.equal(parseQuantity('007'), 7)
|
||||
assert.equal(parseQuantity(3), 3)
|
||||
for (const bad of ['0', '1000', '', '1,5', '-1', 'a', null, undefined, '1.0']) assert.equal(parseQuantity(bad), null, String(bad))
|
||||
})
|
||||
|
||||
test('parseUnitPrice: Betrag in Euro, 0,01 € bis 100.000,00 €', () => {
|
||||
assert.equal(parseUnitPrice('1.234,50'), 123450)
|
||||
assert.equal(parseUnitPrice('120'), 12000)
|
||||
assert.equal(parseUnitPrice('0,01'), 1)
|
||||
assert.equal(parseUnitPrice('100.000,00'), INVOICE_LIMITS.maxUnitCents)
|
||||
for (const bad of ['0', '0,00', '100.000,01', '-5', 'abc', '', '12.50', null, undefined]) assert.equal(parseUnitPrice(bad), null, String(bad))
|
||||
})
|
||||
|
||||
test('lineTotalCents und invoiceTotalCents: ungültige Positionen zählen 0', () => {
|
||||
assert.equal(lineTotalCents({ quantity: '2', price: '1.234,50' }), 246900)
|
||||
assert.equal(lineTotalCents({ quantity: '0', price: '5' }), null)
|
||||
assert.equal(lineTotalCents({ quantity: '1', price: '' }), null)
|
||||
assert.equal(lineTotalCents(null), null)
|
||||
assert.equal(invoiceTotalCents([{ quantity: '2', price: '10' }, { quantity: 'x', price: '10' }, { quantity: '1', price: '0,50' }]), 2050)
|
||||
assert.equal(invoiceTotalCents(undefined), 0)
|
||||
})
|
||||
|
||||
test('validateInvoiceForm: gültiges Formular -> Anfrage in Cent (ohne leere Notiz)', () => {
|
||||
const r = validateInvoiceForm(FORM())
|
||||
assert.equal(r.ok, true)
|
||||
assert.deepEqual(r.payload, {
|
||||
customerId: 'c1',
|
||||
items: [{ description: 'Webdesign', quantity: 2, unitAmountCents: 123450 }],
|
||||
daysUntilDue: 14,
|
||||
})
|
||||
assert.equal(r.totalCents, 246900)
|
||||
const withMemo = validateInvoiceForm(FORM({ memo: ' Danke! ', days: '0', items: [{ description: ' A ', quantity: '1', price: '0,01' }] }))
|
||||
assert.equal(withMemo.ok, true)
|
||||
assert.equal(withMemo.payload.memo, 'Danke!')
|
||||
assert.equal(withMemo.payload.daysUntilDue, 0)
|
||||
assert.equal(withMemo.payload.items[0].description, 'A')
|
||||
})
|
||||
|
||||
test('validateInvoiceForm: Kunde fehlt, Lead, ohne E-Mail', () => {
|
||||
assert.deepEqual(validateInvoiceForm(FORM({ customer: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
|
||||
const lead = validateInvoiceForm(FORM({ customer: { ...CUST, customerStatus: 'lead' } }))
|
||||
assert.equal(lead.ok, false)
|
||||
assert.equal(lead.error, 'Für Kunde Eins kann keine Rechnung geschrieben werden: Noch kein fester Kunde.')
|
||||
const noMail = validateInvoiceForm(FORM({ customer: { ...CUST, email: '' } }))
|
||||
assert.equal(noMail.error, 'Für Kunde Eins kann keine Rechnung geschrieben werden: E-Mail fehlt.')
|
||||
})
|
||||
|
||||
test('validateInvoiceForm: Positionen werden mit Nummer gemeldet', () => {
|
||||
const good = { description: 'A', quantity: '1', price: '5' }
|
||||
assert.equal(validateInvoiceForm(FORM({ items: [] })).error, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(validateInvoiceForm(FORM({ items: Array(21).fill(good) })).error, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(validateInvoiceForm(FORM({ items: [good, { ...good, description: ' ' }] })).error, 'Position 2: Bitte eine Beschreibung eingeben.')
|
||||
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, description: 'x'.repeat(201) }] })).error, 'Position 1: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
|
||||
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, quantity: '0' }] })).error, 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, price: '0' }] })).error, 'Position 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).')
|
||||
assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, price: 'abc' }] })).error, 'Position 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).')
|
||||
assert.equal(validateInvoiceForm(FORM({ items: Array(20).fill(good) })).ok, true)
|
||||
})
|
||||
|
||||
test('validateInvoiceForm: Summe, Zahlungsziel und Notiz', () => {
|
||||
const big = { description: 'A', quantity: '999', price: '100.000,00' }
|
||||
assert.equal(validateInvoiceForm(FORM({ items: [big] })).error, 'Die Rechnungssumme ist zu hoch (höchstens 999.999,99 €).')
|
||||
for (const days of ['', '-1', '61', '1,5', 'x']) {
|
||||
assert.equal(validateInvoiceForm(FORM({ days })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', days)
|
||||
}
|
||||
assert.equal(validateInvoiceForm(FORM({ days: '60' })).ok, true)
|
||||
assert.equal(validateInvoiceForm(FORM({ memo: 'x'.repeat(1001) })).error, 'Die Notiz darf höchstens 1000 Zeichen lang sein.')
|
||||
assert.equal(validateInvoiceForm(FORM({ memo: 'x'.repeat(1000) })).ok, true)
|
||||
assert.equal(validateInvoiceForm(undefined).ok, false)
|
||||
})
|
||||
|
||||
const CUSTOMERS = [
|
||||
{ $id: 'c3', name: 'Zeta GmbH', email: 'zeta@example.invalid', customerStatus: 'customer' },
|
||||
{ $id: 'c1', name: 'Alpha AG', email: 'alpha@example.invalid' }, // ohne Status = Kunde
|
||||
{ $id: 'c2', name: 'Beta Lead', email: 'beta@example.invalid', customerStatus: 'lead' },
|
||||
{ $id: 'c4', name: 'Gamma', email: '', customerStatus: 'customer' },
|
||||
{ $id: 'c5', companyName: 'Delta Firma', email: 'delta@example.invalid', customerStatus: 'customer' },
|
||||
{ $id: 'c6', name: 'Alpha AG', email: 'alpha2@example.invalid', customerStatus: 'customer' },
|
||||
{ $id: 'c7', name: '', email: 'x@example.invalid' },
|
||||
null,
|
||||
]
|
||||
|
||||
test('customerName: name, sonst companyName', () => {
|
||||
assert.equal(customerName({ name: ' A ', companyName: 'B' }), 'A')
|
||||
assert.equal(customerName({ companyName: 'B' }), 'B')
|
||||
assert.equal(customerName({}), '')
|
||||
assert.equal(customerName(null), '')
|
||||
})
|
||||
|
||||
test('customerPickerOptions: nach Namen sortiert, Grund bei gesperrten, gleiche Namen mit E-Mail unterscheidbar', () => {
|
||||
const o = customerPickerOptions(CUSTOMERS)
|
||||
assert.deepEqual(o.map((x) => x.label), [
|
||||
'Alpha AG (alpha@example.invalid)',
|
||||
'Alpha AG (alpha2@example.invalid)',
|
||||
'Beta Lead',
|
||||
'Delta Firma',
|
||||
'Gamma',
|
||||
'Zeta GmbH',
|
||||
])
|
||||
const byId = Object.fromEntries(o.map((x) => [x.id, x]))
|
||||
assert.equal(byId.c1.ok, true)
|
||||
assert.equal(byId.c2.ok, false)
|
||||
assert.equal(byId.c2.reason, 'Noch kein fester Kunde')
|
||||
assert.equal(byId.c4.ok, false)
|
||||
assert.equal(byId.c4.reason, 'E-Mail fehlt')
|
||||
assert.equal(byId.c5.name, 'Delta Firma')
|
||||
assert.equal(new Set(o.map((x) => x.label.toLowerCase())).size, o.length)
|
||||
assert.deepEqual(customerPickerOptions(undefined), [])
|
||||
})
|
||||
|
||||
test('customerSuggestions: nur Kunden, für die eine Rechnung möglich ist; Namensanfang zuerst; Teiltreffer auch per E-Mail', () => {
|
||||
const o = customerPickerOptions(CUSTOMERS)
|
||||
const all = customerSuggestions(o, '')
|
||||
assert.deepEqual(all.map((s) => s.value), ['Alpha AG (alpha@example.invalid)', 'Alpha AG (alpha2@example.invalid)', 'Delta Firma', 'Zeta GmbH'])
|
||||
assert.equal(all[2].hint, 'delta@example.invalid')
|
||||
assert.ok(!all.some((s) => s.value.includes('Beta') || s.value === 'Gamma'), 'Lead und Kunde ohne E-Mail fehlen')
|
||||
assert.deepEqual(customerSuggestions(o, 'ta').map((s) => s.value), ['Delta Firma', 'Zeta GmbH'])
|
||||
assert.deepEqual(customerSuggestions(o, 'zeta@').map((s) => s.value), ['Zeta GmbH'])
|
||||
assert.deepEqual(customerSuggestions(o, 'gamma'), [])
|
||||
assert.equal(customerSuggestions(o, '', 2).length, 2)
|
||||
})
|
||||
|
||||
test('resolveCustomerText: ok, gesperrt mit Grund, unbekannt, leer', () => {
|
||||
const o = customerPickerOptions(CUSTOMERS)
|
||||
assert.equal(resolveCustomerText(o, '').state, 'empty')
|
||||
assert.equal(resolveCustomerText(o, ' ').state, 'empty')
|
||||
const ok = resolveCustomerText(o, 'zeta gmbh')
|
||||
assert.equal(ok.state, 'ok')
|
||||
assert.equal(ok.option.id, 'c3')
|
||||
const blocked = resolveCustomerText(o, 'Beta Lead')
|
||||
assert.equal(blocked.state, 'blocked')
|
||||
assert.equal(blocked.option.reason, 'Noch kein fester Kunde')
|
||||
assert.equal(resolveCustomerText(o, 'Gamma').option.reason, 'E-Mail fehlt')
|
||||
assert.equal(resolveCustomerText(o, 'Zeta').state, 'unknown', 'nur der ganze Name zählt, ein Teil nicht')
|
||||
assert.equal(resolveCustomerText(o, 'Nie gehört').state, 'unknown')
|
||||
assert.equal(resolveCustomerText(o, 'Alpha AG').state, 'unknown', 'mehrdeutiger Name braucht die E-Mail im Feld')
|
||||
})
|
||||
|
||||
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
|
||||
|
||||
const TX = (over = {}) => ({
|
||||
$id: 'b1', status: 'BOOK', bookingDate: '2026-01-05', valueDate: '2026-01-05', amountCents: 16879, currency: 'EUR',
|
||||
counterpartyName: 'KUNDE EINS GMBH', counterpartyIban: 'DE00', remittance: 'Rechnung WK-0005 vom 28.07.2026', mcc: '',
|
||||
category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '', note: '',
|
||||
vorschlaege: [], ...over,
|
||||
})
|
||||
|
||||
test('sortTransactions: neueste zuerst, gleiche Tage behalten die Serverreihenfolge, Vormerkung nach Wertstellung', () => {
|
||||
const list = [
|
||||
TX({ $id: 'a', bookingDate: '2026-01-05' }),
|
||||
TX({ $id: 'b', bookingDate: '2026-03-01' }),
|
||||
TX({ $id: 'c', bookingDate: '2026-01-05' }),
|
||||
TX({ $id: 'd', bookingDate: '', valueDate: '2026-02-10', status: 'PDNG' }),
|
||||
null,
|
||||
]
|
||||
assert.deepEqual(sortTransactions(list).map((t) => t.$id), ['b', 'd', 'a', 'c'])
|
||||
assert.deepEqual(sortTransactions(undefined), [])
|
||||
const before = list.map((t) => t && t.$id)
|
||||
sortTransactions(list)
|
||||
assert.deepEqual(list.map((t) => t && t.$id), before, 'Eingabe bleibt unverändert')
|
||||
})
|
||||
|
||||
test('strongestSuggestion und suggestionText', () => {
|
||||
const low = { kind: 'invoice', id: 'in_l', number: 'WK-0001', confidence: 'niedrig', reason: '', amountDiffers: false }
|
||||
const mid = { kind: 'invoice', id: 'in_m', number: 'WK-0002', confidence: 'mittel', reason: '', amountDiffers: true }
|
||||
const hi = { kind: 'payout', id: 'po_h', number: '', confidence: 'hoch', reason: '', amountDiffers: false }
|
||||
const hi2 = { kind: 'invoice', id: 'in_h2', number: 'WK-0003', confidence: 'hoch', reason: '', amountDiffers: false }
|
||||
assert.equal(strongestSuggestion([low, mid, hi, hi2]), hi)
|
||||
assert.equal(strongestSuggestion([low, mid]), mid)
|
||||
assert.equal(strongestSuggestion([]), null)
|
||||
assert.equal(strongestSuggestion(undefined), null)
|
||||
assert.equal(suggestionText(hi2), 'Vorschlag: Rechnung WK-0003 (hoch)')
|
||||
assert.equal(suggestionText(hi), 'Vorschlag: Stripe-Auszahlung (hoch)')
|
||||
assert.equal(suggestionText(mid), 'Vorschlag: Rechnung WK-0002 (mittel) · Betrag weicht ab')
|
||||
assert.equal(suggestionText({ kind: 'invoice', id: 'in_x', number: '' }), 'Vorschlag: Rechnung in_x')
|
||||
assert.equal(suggestionText(null), '')
|
||||
})
|
||||
|
||||
test('assignmentText: Rechnung mit Nummer, Kategorien, Offen', () => {
|
||||
assert.equal(assignmentText(TX({ category: 'rechnung', matchedInvoiceNumber: 'WK-0005' })), 'Rechnung WK-0005')
|
||||
assert.equal(assignmentText(TX({ category: 'rechnung', matchedInvoiceNumber: '', matchedInvoiceId: 'in_1' })), 'Rechnung in_1')
|
||||
assert.equal(assignmentText(TX({ category: 'stripe-auszahlung' })), 'Stripe-Auszahlung')
|
||||
assert.equal(assignmentText(TX({ category: 'fixkosten' })), 'Fixkosten')
|
||||
assert.equal(assignmentText(TX({ category: 'rechnung-extern' })), 'Einnahme ohne Stripe-Rechnung')
|
||||
assert.equal(assignmentText(TX({ category: 'privat' })), 'Privat/Umbuchung')
|
||||
assert.equal(assignmentText(TX({ category: 'sonstiges' })), 'Sonstiges')
|
||||
assert.equal(assignmentText(TX({ category: '' })), 'Offen')
|
||||
assert.equal(assignmentText(TX({ category: 'neu' })), 'neu')
|
||||
})
|
||||
|
||||
test('transactionRows: Texte, Vorzeichen, Vorschlag, Aktionen', () => {
|
||||
const rows = transactionRows([
|
||||
TX({ $id: 'in', vorschlaege: [{ kind: 'invoice', id: 'in_5', number: 'WK-0005', confidence: 'hoch', reason: 'x', amountDiffers: false }] }),
|
||||
TX({ $id: 'out', bookingDate: '2026-01-04', amountCents: -1000, counterpartyName: 'Hetzner', remittance: 'Server' }),
|
||||
TX({ $id: 'rg', bookingDate: '2026-01-03', category: 'rechnung', matchedInvoiceNumber: 'WK-0004', note: 'bar' }),
|
||||
TX({ $id: 'pv', bookingDate: '2026-01-02', category: 'privat' }),
|
||||
TX({ $id: 'pd', bookingDate: '', valueDate: '2026-01-01', status: 'PDNG', counterpartyName: '', counterpartyIban: 'DE99', vorschlaege: [{ kind: 'invoice', id: 'in_5', number: 'WK-0005', confidence: 'hoch', reason: 'x', amountDiffers: false }] }),
|
||||
])
|
||||
assert.deepEqual(rows.map((r) => r.id), ['in', 'out', 'rg', 'pv', 'pd'])
|
||||
const [inn, out, rg, pv, pd] = rows
|
||||
assert.equal(inn.dateText, '05.01.2026')
|
||||
assert.equal(inn.counterparty, 'KUNDE EINS GMBH')
|
||||
assert.equal(inn.remittance, 'Rechnung WK-0005 vom 28.07.2026')
|
||||
assert.equal(inn.amountText, '+168,79 €')
|
||||
assert.equal(inn.incoming, true)
|
||||
assert.equal(inn.assignmentText, 'Offen')
|
||||
assert.equal(inn.suggestionText, 'Vorschlag: Rechnung WK-0005 (hoch)')
|
||||
assert.equal(inn.action, 'assign')
|
||||
assert.equal(inn.actionLabel, 'Zuordnen')
|
||||
assert.equal(out.amountText, `${MINUS}10,00 €`)
|
||||
assert.equal(out.incoming, false)
|
||||
assert.equal(out.action, 'assign')
|
||||
assert.equal(out.suggestionText, '')
|
||||
assert.equal(rg.assignmentText, 'Rechnung WK-0004')
|
||||
assert.equal(rg.action, null, 'eine zugeordnete Rechnung lässt sich nicht lösen')
|
||||
assert.equal(rg.actionLabel, '')
|
||||
assert.equal(rg.note, 'bar')
|
||||
assert.equal(pv.action, 'change')
|
||||
assert.equal(pv.actionLabel, 'Ändern')
|
||||
assert.equal(pd.pending, true)
|
||||
assert.equal(pd.suggestionText, '', 'vorgemerkt: kein Rechnungsvorschlag')
|
||||
assert.equal(pd.dateText, '01.01.2026')
|
||||
assert.equal(pd.counterparty, 'DE99')
|
||||
assert.equal(transactionRows(null).length, 0)
|
||||
})
|
||||
|
||||
// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
|
||||
|
||||
const OPEN = (over = {}) => INV({ status: 'offen', overdueDays: 0, ...over })
|
||||
const SUG_INV = (over = {}) => ({ kind: 'invoice', id: 'in_1', number: 'WK-0006', confidence: 'hoch', reason: 'Rechnungsnummer im Verwendungszweck', amountDiffers: false, ...over })
|
||||
|
||||
test('assignModel: Eingang mit Vorschlägen, anderen offenen Rechnungen und Kategorien', () => {
|
||||
const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV(), { kind: 'payout', id: 'po_1', number: '', confidence: 'hoch', reason: 'Stripe-Auszahlung gleicher Betrag', amountDiffers: false }] })
|
||||
const m = assignModel(tx, {
|
||||
invoices: [OPEN(), OPEN({ id: 'in_2', number: 'WK-0007', remainingCents: 5000 }), OPEN({ id: 'in_3', number: 'WK-0008', remainingCents: 999 }), INV({ id: 'in_4', status: 'bezahlt' }), INV({ id: 'in_5', status: 'entwurf' })],
|
||||
fixedCosts: [{ $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'monat', active: true }],
|
||||
})
|
||||
assert.equal(m.incoming, true)
|
||||
assert.equal(m.pending, false)
|
||||
assert.equal(m.assigned, false)
|
||||
assert.equal(m.hint, '')
|
||||
assert.deepEqual(m.suggestions.map((s) => s.key), ['invoice:in_1', 'payout:po_1'])
|
||||
assert.equal(m.suggestions[0].number, 'WK-0006')
|
||||
assert.deepEqual(m.suggestions[0].body, { invoiceId: 'in_1' })
|
||||
assert.deepEqual(m.suggestions[1].body, { payoutId: 'po_1' })
|
||||
assert.deepEqual(m.invoices.map((o) => o.key), ['invoice:in_2', 'invoice:in_3'], 'Vorschlag nicht doppelt, nur offene/überfällige')
|
||||
assert.equal(m.invoices[0].disabled, false)
|
||||
assert.equal(m.invoices[1].disabled, true)
|
||||
assert.equal(m.invoices[1].disabledReason, 'Betrag weicht ab (offen: 9,99 €)')
|
||||
assert.deepEqual(m.fixedCosts, [], 'keine Fixkosten für Eingänge')
|
||||
assert.deepEqual(m.categories.map((c) => c.label), ['Einnahme ohne Stripe-Rechnung', 'Privat/Umbuchung', 'Sonstiges'])
|
||||
assert.deepEqual(m.categories.map((c) => c.body), [{ category: 'rechnung-extern' }, { category: 'privat' }, { category: 'sonstiges' }])
|
||||
assert.ok(m.byKey['invoice:in_2'] && m.byKey['category:privat'] && m.byKey['payout:po_1'])
|
||||
})
|
||||
|
||||
test('assignModel: Vorschlag mit abweichendem Betrag ist nicht wählbar', () => {
|
||||
const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV({ amountDiffers: true, confidence: 'mittel' })] })
|
||||
const m = assignModel(tx, { invoices: [] })
|
||||
assert.equal(m.suggestions[0].disabled, true)
|
||||
assert.match(m.suggestions[0].disabledReason, /Betrag weicht ab/)
|
||||
const known = assignModel(TX({ amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN({ remainingCents: 4000 })] })
|
||||
assert.equal(known.suggestions[0].disabled, true, 'auch wenn die Liste den Unterschied zeigt')
|
||||
})
|
||||
|
||||
test('assignModel: Ausgang bekommt Fixkosten (nur aktive) und Privat/Sonstiges, keine Rechnungen', () => {
|
||||
const tx = TX({ amountCents: -1000, vorschlaege: [SUG_INV()] })
|
||||
const m = assignModel(tx, {
|
||||
invoices: [OPEN()],
|
||||
fixedCosts: [
|
||||
{ $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'monat', active: true },
|
||||
{ $id: 'f2', name: 'Alt', amountCents: 500, interval: 'jahr', active: false },
|
||||
{ $id: 'f3', name: 'Domain', amountCents: 1999, interval: 'jahr' },
|
||||
],
|
||||
})
|
||||
assert.equal(m.outgoing, true)
|
||||
assert.deepEqual(m.suggestions, [])
|
||||
assert.deepEqual(m.invoices, [])
|
||||
assert.deepEqual(m.fixedCosts.map((f) => f.key), ['fixedCost:f1', 'fixedCost:f3'])
|
||||
assert.equal(m.fixedCosts[0].label, 'Hetzner · 10,00 € monatlich')
|
||||
assert.deepEqual(m.fixedCosts[0].body, { fixedCostId: 'f1' })
|
||||
assert.deepEqual(m.categories.map((c) => c.body.category), ['privat', 'sonstiges'])
|
||||
})
|
||||
|
||||
test('assignModel: Vorgemerkt (PDNG) nur Kategorien und Fixkosten, mit Hinweis', () => {
|
||||
assert.equal(PENDING_ASSIGN_HINT, 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.')
|
||||
const incoming = assignModel(TX({ status: 'PDNG', amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN()] })
|
||||
assert.equal(incoming.pending, true)
|
||||
assert.equal(incoming.hint, PENDING_ASSIGN_HINT)
|
||||
assert.deepEqual(incoming.suggestions, [])
|
||||
assert.deepEqual(incoming.invoices, [])
|
||||
assert.ok(incoming.categories.length > 0)
|
||||
assert.ok(!Object.keys(incoming.byKey).some((k) => k.startsWith('invoice:') || k.startsWith('payout:')))
|
||||
const outgoing = assignModel(TX({ status: 'PDNG', amountCents: -300 }), { fixedCosts: [{ $id: 'f1', name: 'X', amountCents: 300, interval: 'monat' }] })
|
||||
assert.deepEqual(outgoing.fixedCosts.map((f) => f.key), ['fixedCost:f1'])
|
||||
})
|
||||
|
||||
test('assignModel: zugeordnete Buchung zeigt nur die Zuordnung; Rechnung nicht lösbar', () => {
|
||||
const privat = assignModel(TX({ category: 'privat' }), { invoices: [OPEN()] })
|
||||
assert.equal(privat.assigned, true)
|
||||
assert.equal(privat.currentText, 'Privat/Umbuchung')
|
||||
assert.equal(privat.canUnassign, true)
|
||||
assert.deepEqual(privat.byKey, {})
|
||||
assert.equal(privat.categories.length, 0)
|
||||
const rg = assignModel(TX({ category: 'rechnung', matchedInvoiceNumber: 'WK-0005' }))
|
||||
assert.equal(rg.currentText, 'Rechnung WK-0005')
|
||||
assert.equal(rg.canUnassign, false)
|
||||
const none = assignModel(null)
|
||||
assert.equal(none.assigned, false)
|
||||
})
|
||||
|
||||
test('assignBody und invoiceAssignConfirmText', () => {
|
||||
assert.deepEqual(assignBody({ body: { invoiceId: 'in_1' } }, ''), { invoiceId: 'in_1' })
|
||||
assert.deepEqual(assignBody({ body: { category: 'privat' } }, ' Miete privat '), { category: 'privat', note: 'Miete privat' })
|
||||
assert.deepEqual(assignBody(null, 'x'), { note: 'x' })
|
||||
assert.equal(invoiceAssignConfirmText('WK-0005'), 'Die Rechnung WK-0005 wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.')
|
||||
})
|
||||
Reference in new issue
Block a user