Finanzen: Offen zeigt auch Ueberfaellige, Rechnungsliste laedt nach Zuordnen neu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1feeb18c08
commit
24a3cc41ef
6 files changed
+149
-29
No files matched your search
@@ -21,11 +21,12 @@ const NOTE_MAX = 500
|
|||||||
* weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung)
|
* weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung)
|
||||||
* lässt sich mit „Zuordnung lösen“ aufheben.
|
* lässt sich mit „Zuordnung lösen“ aufheben.
|
||||||
*
|
*
|
||||||
* invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand).
|
* invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand; invoicesLoaded =
|
||||||
|
* frisch und ohne Fehler geladen, dann sind Vorschläge zu nicht mehr offenen Rechnungen gesperrt).
|
||||||
* onDone(buchung) nach Erfolg (Aufrufer lädt neu und schließt); onClose({ refresh }) bei Abbruch, refresh = ein
|
* 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.
|
* Fehler trat auf, der Stand kann sich geändert haben.
|
||||||
*/
|
*/
|
||||||
export default function AssignDialog({ transaction, invoices = [], invoicesLoading = false, invoicesError = '', onClose, onDone }) {
|
export default function AssignDialog({ transaction, invoices = [], invoicesLoading = false, invoicesError = '', invoicesLoaded = false, onClose, onDone }) {
|
||||||
const tx = transaction || {}
|
const tx = transaction || {}
|
||||||
const row = useMemo(() => transactionRows([tx])[0], [tx])
|
const row = useMemo(() => transactionRows([tx])[0], [tx])
|
||||||
const [fixed, setFixed] = useState({ list: [], loading: false, error: '' })
|
const [fixed, setFixed] = useState({ list: [], loading: false, error: '' })
|
||||||
@@ -69,7 +70,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
}
|
}
|
||||||
}, [outgoing, unassigned])
|
}, [outgoing, unassigned])
|
||||||
|
|
||||||
const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list }), [tx, invoices, fixed.list])
|
const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list, invoicesLoaded }), [tx, invoices, fixed.list, invoicesLoaded])
|
||||||
const option = model.byKey[selected] || null
|
const option = model.byKey[selected] || null
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
ageGroupCells,
|
ageGroupCells,
|
||||||
finalizedFilter,
|
finalizedFilter,
|
||||||
invoiceConfirmText,
|
invoiceConfirmText,
|
||||||
|
invoiceFilterStatuses,
|
||||||
invoiceDoneMessage,
|
invoiceDoneMessage,
|
||||||
invoiceFailureMessage,
|
invoiceFailureMessage,
|
||||||
invoiceRows,
|
invoiceRows,
|
||||||
isFinalizedNotSent,
|
isFinalizedNotSent,
|
||||||
|
mergeInvoiceLists,
|
||||||
} from '../../lib/financeView'
|
} from '../../lib/financeView'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
@@ -31,8 +33,11 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del
|
|||||||
* customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest
|
* 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
|
* (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.
|
* Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen.
|
||||||
|
*
|
||||||
|
* „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich
|
||||||
|
* `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu.
|
||||||
*/
|
*/
|
||||||
export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, onChanged }) {
|
export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, refreshToken = 0, onChanged }) {
|
||||||
const filterCustomer = customerId || customer?.$id || null
|
const filterCustomer = customerId || customer?.$id || null
|
||||||
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
|
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
@@ -59,17 +64,17 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Nur die jüngste Anfrage zählt (Filterwechsel während des Ladens)
|
// Nur die jüngste Anfrage zählt (Filterwechsel während des Ladens)
|
||||||
const mine = ++seqRef.current
|
const mine = ++seqRef.current
|
||||||
getInvoices({ status, kunde: filterCustomer })
|
Promise.all(invoiceFilterStatuses(status).map((s) => getInvoices({ status: s, kunde: filterCustomer })))
|
||||||
.then((data) => {
|
.then((results) => {
|
||||||
if (!aliveRef.current || mine !== seqRef.current) return
|
if (!aliveRef.current || mine !== seqRef.current) return
|
||||||
setResult({ key, list: Array.isArray(data?.rechnungen) ? data.rechnungen : [] })
|
setResult({ key, list: mergeInvoiceLists(results.map((r) => r?.rechnungen)) })
|
||||||
setError(null)
|
setError(null)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!aliveRef.current || mine !== seqRef.current) return
|
if (!aliveRef.current || mine !== seqRef.current) return
|
||||||
setError({ key, message: err?.message || 'Die Rechnungen konnten nicht geladen werden.' })
|
setError({ key, message: err?.message || 'Die Rechnungen konnten nicht geladen werden.' })
|
||||||
})
|
})
|
||||||
}, [status, filterCustomer, key, reloadKey])
|
}, [status, filterCustomer, key, reloadKey, refreshToken])
|
||||||
|
|
||||||
const reload = () => setReloadKey((k) => k + 1)
|
const reload = () => setReloadKey((k) => k + 1)
|
||||||
const changed = () => {
|
const changed = () => {
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { getInvoices } from '../lib/financeApi'
|
import { getInvoices } from '../lib/financeApi'
|
||||||
|
import { invoiceFilterStatuses, mergeInvoiceLists } from '../lib/financeView'
|
||||||
|
|
||||||
const FALLBACK_ERROR = 'Die offenen Rechnungen konnten nicht geladen werden.'
|
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
|
* 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.
|
* (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).
|
* `loaded` ist true, sobald die Liste dieser Öffnung frisch und ohne Fehler da ist (erst dann darf der Dialog eine
|
||||||
|
* Rechnung als „nicht mehr offen“ erkennen). Scheitert Stripe, bleibt `invoices` leer und `error` nennt den Grund
|
||||||
|
* (Kategorien lassen sich trotzdem wählen).
|
||||||
*/
|
*/
|
||||||
export function useOpenInvoices(active) {
|
export function useOpenInvoices(active) {
|
||||||
const [state, setState] = useState({ invoices: [], loading: false, error: '' })
|
const [state, setState] = useState({ invoices: [], loading: false, error: '', loaded: false })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
setState({ invoices: [], loading: false, error: '' })
|
setState({ invoices: [], loading: false, error: '', loaded: false })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setState({ invoices: [], loading: true, error: '' })
|
setState({ invoices: [], loading: true, error: '', loaded: false })
|
||||||
Promise.all([getInvoices({ status: 'offen' }), getInvoices({ status: 'ueberfaellig' })])
|
Promise.all(invoiceFilterStatuses('offen').map((status) => getInvoices({ status })))
|
||||||
.then(([open, overdue]) => {
|
.then((results) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const all = [...(open?.rechnungen || []), ...(overdue?.rechnungen || [])]
|
setState({ invoices: mergeInvoiceLists(results.map((r) => r?.rechnungen)), loading: false, error: '', loaded: true })
|
||||||
setState({ invoices: all, loading: false, error: '' })
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR })
|
if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR, loaded: false })
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
|||||||
+54
-9
@@ -406,16 +406,53 @@ export function aboView(abos) {
|
|||||||
|
|
||||||
// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
|
// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Filter über der Rechnungsliste (Schlüssel = `status` der API); Standard ist „Offen“. */
|
/**
|
||||||
|
* Filter über der Rechnungsliste. `statuses` sind die API-Status, die der Filter abruft: „Offen“ zeigt offene UND
|
||||||
|
* überfällige Rechnungen (zwei Abrufe, zusammengeführt), alle anderen genau ihren Status. Standard ist „Offen“.
|
||||||
|
*/
|
||||||
export const INVOICE_FILTERS = [
|
export const INVOICE_FILTERS = [
|
||||||
{ key: 'offen', label: 'Offen' },
|
{ key: 'offen', label: 'Offen', statuses: ['offen', 'ueberfaellig'] },
|
||||||
{ key: 'ueberfaellig', label: 'Überfällig' },
|
{ key: 'ueberfaellig', label: 'Überfällig', statuses: ['ueberfaellig'] },
|
||||||
{ key: 'bezahlt', label: 'Bezahlt' },
|
{ key: 'bezahlt', label: 'Bezahlt', statuses: ['bezahlt'] },
|
||||||
{ key: 'entwurf', label: 'Entwürfe' },
|
{ key: 'entwurf', label: 'Entwürfe', statuses: ['entwurf'] },
|
||||||
{ key: 'alle', label: 'Alle' },
|
{ key: 'alle', label: 'Alle', statuses: ['alle'] },
|
||||||
]
|
]
|
||||||
export const DEFAULT_INVOICE_FILTER = 'offen'
|
export const DEFAULT_INVOICE_FILTER = 'offen'
|
||||||
|
|
||||||
|
/** API-Status, die ein Filter abruft (unbekannter Filter = alle). */
|
||||||
|
export function invoiceFilterStatuses(filterKey) {
|
||||||
|
const filter = INVOICE_FILTERS.find((f) => f.key === filterKey)
|
||||||
|
return filter ? [...filter.statuses] : ['alle']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrere Antwortlisten (je Status eine) zu einer Liste: neueste zuerst nach Rechnungsdatum, gleiche Tage behalten
|
||||||
|
* die Reihenfolge der Listen; eine Rechnung, die in mehreren vorkommt, nur einmal.
|
||||||
|
*/
|
||||||
|
export function mergeInvoiceLists(lists) {
|
||||||
|
const seen = new Set()
|
||||||
|
const all = []
|
||||||
|
for (const list of Array.isArray(lists) ? lists : []) {
|
||||||
|
for (const inv of Array.isArray(list) ? list : []) {
|
||||||
|
if (!inv || typeof inv !== 'object') continue
|
||||||
|
if (inv.id) {
|
||||||
|
if (seen.has(inv.id)) continue
|
||||||
|
seen.add(inv.id)
|
||||||
|
}
|
||||||
|
all.push(inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
.map((inv, index) => ({ inv, index }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = text(a.inv.createdDate)
|
||||||
|
const db = text(b.inv.createdDate)
|
||||||
|
if (da === db) return a.index - b.index
|
||||||
|
return da < db ? 1 : -1
|
||||||
|
})
|
||||||
|
.map((x) => x.inv)
|
||||||
|
}
|
||||||
|
|
||||||
const obj = (v) => (v && typeof v === 'object' ? v : {})
|
const obj = (v) => (v && typeof v === 'object' ? v : {})
|
||||||
const objects = (list) => (Array.isArray(list) ? list.filter((x) => x && typeof x === 'object') : [])
|
const objects = (list) => (Array.isArray(list) ? list.filter((x) => x && typeof x === 'object') : [])
|
||||||
const OPEN_INVOICE = new Set(['offen', 'ueberfaellig'])
|
const OPEN_INVOICE = new Set(['offen', 'ueberfaellig'])
|
||||||
@@ -790,6 +827,7 @@ export function transactionRows(buchungen) {
|
|||||||
|
|
||||||
// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
|
// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NOT_OPEN_ANYMORE = 'Diese Rechnung ist nicht mehr offen.'
|
||||||
export const PENDING_ASSIGN_HINT = 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.'
|
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). */
|
/** Bestätigungstext vor dem Bezahlt-Markieren (nicht umkehrbar). */
|
||||||
@@ -809,8 +847,10 @@ const fixedCostLabel = (f) => `${text(f.name) || 'Fixkosten'} · ${euro(f.amount
|
|||||||
* - Eingang: Vorschläge des Servers, andere offene Rechnungen, Kategorien (auch „Einnahme ohne Stripe-Rechnung“)
|
* - Eingang: Vorschläge des Servers, andere offene Rechnungen, Kategorien (auch „Einnahme ohne Stripe-Rechnung“)
|
||||||
* - Ausgang: Fixkosten (aktive), Privat/Umbuchung, Sonstiges
|
* - Ausgang: Fixkosten (aktive), Privat/Umbuchung, Sonstiges
|
||||||
* Rechnungen, deren offener Betrag vom Betrag der Buchung abweicht, sind nicht wählbar (Teilzahlungen in Stripe).
|
* Rechnungen, deren offener Betrag vom Betrag der Buchung abweicht, sind nicht wählbar (Teilzahlungen in Stripe).
|
||||||
|
* invoicesLoaded = die offenen Rechnungen wurden frisch und ohne Fehler geladen: ein Vorschlag, dessen Rechnung nicht
|
||||||
|
* (mehr) darin steht, ist dann nicht wählbar („Diese Rechnung ist nicht mehr offen.“).
|
||||||
*/
|
*/
|
||||||
export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) {
|
export function assignModel(tx, { invoices = [], fixedCosts = [], invoicesLoaded = false } = {}) {
|
||||||
const t = obj(tx)
|
const t = obj(tx)
|
||||||
const amount = t.amountCents
|
const amount = t.amountCents
|
||||||
const incoming = isNum(amount) && amount > 0
|
const incoming = isNum(amount) && amount > 0
|
||||||
@@ -868,6 +908,7 @@ export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) {
|
|||||||
} else if (s.kind === 'invoice' && text(s.id) && !seen.has(s.id)) {
|
} else if (s.kind === 'invoice' && text(s.id) && !seen.has(s.id)) {
|
||||||
seen.add(s.id)
|
seen.add(s.id)
|
||||||
const known = open.find((inv) => inv.id === s.id)
|
const known = open.find((inv) => inv.id === s.id)
|
||||||
|
const gone = invoicesLoaded && !known
|
||||||
const differs = s.amountDiffers === true || (known ? known.remainingCents !== amount : false)
|
const differs = s.amountDiffers === true || (known ? known.remainingCents !== amount : false)
|
||||||
const number = text(s.number) || text(known?.number)
|
const number = text(s.number) || text(known?.number)
|
||||||
model.suggestions.push({
|
model.suggestions.push({
|
||||||
@@ -876,8 +917,12 @@ export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) {
|
|||||||
label: `Rechnung ${number || s.id}${known ? ` · ${text(known.customerName) || text(known.customerEmail)}` : ''}`,
|
label: `Rechnung ${number || s.id}${known ? ` · ${text(known.customerName) || text(known.customerEmail)}` : ''}`,
|
||||||
detail: [text(s.reason), known ? `${euro(known.remainingCents)} offen` : ''].filter(Boolean).join(' · '),
|
detail: [text(s.reason), known ? `${euro(known.remainingCents)} offen` : ''].filter(Boolean).join(' · '),
|
||||||
confidence: s.confidence,
|
confidence: s.confidence,
|
||||||
disabled: differs,
|
disabled: gone || differs,
|
||||||
disabledReason: differs ? 'Betrag weicht ab, Teilzahlungen bitte direkt in Stripe erfassen' : '',
|
disabledReason: gone
|
||||||
|
? NOT_OPEN_ANYMORE
|
||||||
|
: differs
|
||||||
|
? 'Betrag weicht ab, Teilzahlungen bitte direkt in Stripe erfassen'
|
||||||
|
: '',
|
||||||
number,
|
number,
|
||||||
body: { invoiceId: s.id },
|
body: { invoiceId: s.id },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export default function FinancePage() {
|
|||||||
// Buchung, die gerade zugeordnet werden soll (Knopf in Block 7 oder Aufgabe „Zahlungseingang zuordnen“)
|
// Buchung, die gerade zugeordnet werden soll (Knopf in Block 7 oder Aufgabe „Zahlungseingang zuordnen“)
|
||||||
const [assignTxId, setAssignTxId] = useState(null)
|
const [assignTxId, setAssignTxId] = useState(null)
|
||||||
const [assignNotice, setAssignNotice] = useState('')
|
const [assignNotice, setAssignNotice] = useState('')
|
||||||
|
// Zähler, der die Rechnungsliste (Block 6) zum Neuladen bringt: sie hält ihre Liste selbst
|
||||||
|
const [invoicesRefresh, setInvoicesRefresh] = useState(0)
|
||||||
const refreshLock = useRef(false)
|
const refreshLock = useRef(false)
|
||||||
const retriedRef = useRef(null)
|
const retriedRef = useRef(null)
|
||||||
|
|
||||||
@@ -34,8 +36,12 @@ export default function FinancePage() {
|
|||||||
const needsInvoices = Boolean(assignTx && !assignTx.category && assignTx.status !== 'PDNG' && assignTx.amountCents > 0)
|
const needsInvoices = Boolean(assignTx && !assignTx.category && assignTx.status !== 'PDNG' && assignTx.amountCents > 0)
|
||||||
const openInvoices = useOpenInvoices(needsInvoices)
|
const openInvoices = useOpenInvoices(needsInvoices)
|
||||||
|
|
||||||
// Übersicht und Buchungen zusammen neu laden (nach Zuordnen, Rechnungsänderungen und Bankabruf)
|
// Übersicht, Buchungen und Rechnungsliste zusammen neu laden (nach Zuordnen, Rechnungsänderungen und Bankabruf):
|
||||||
const reloadAll = useCallback(() => Promise.all([reload(), reloadTransactions()]), [reload, reloadTransactions])
|
// eine eben bezahlte Rechnung soll sofort aus „Offen“ verschwinden
|
||||||
|
const reloadAll = useCallback(() => {
|
||||||
|
setInvoicesRefresh((n) => n + 1)
|
||||||
|
return Promise.all([reload(), reloadTransactions()])
|
||||||
|
}, [reload, reloadTransactions])
|
||||||
|
|
||||||
// Eine Aufgabe kann eine Buchung nennen, die in der geladenen Liste fehlt (neu abgerufen): einmal nachladen,
|
// Eine Aufgabe kann eine Buchung nennen, die in der geladenen Liste fehlt (neu abgerufen): einmal nachladen,
|
||||||
// dann melden statt still nichts zu tun
|
// dann melden statt still nichts zu tun
|
||||||
@@ -169,7 +175,7 @@ export default function FinancePage() {
|
|||||||
<div className="fin-cols fin-cols-half">
|
<div className="fin-cols fin-cols-half">
|
||||||
<AboCard abos={data.abos} />
|
<AboCard abos={data.abos} />
|
||||||
</div>
|
</div>
|
||||||
<InvoiceTable altersgruppen={data.rechnungen?.altersgruppen} onChanged={reloadAll} />
|
<InvoiceTable altersgruppen={data.rechnungen?.altersgruppen} refreshToken={invoicesRefresh} onChanged={reloadAll} />
|
||||||
<TransactionTable
|
<TransactionTable
|
||||||
transactions={txs.transactions}
|
transactions={txs.transactions}
|
||||||
stripeFehler={txs.stripeFehler}
|
stripeFehler={txs.stripeFehler}
|
||||||
@@ -200,6 +206,7 @@ export default function FinancePage() {
|
|||||||
invoices={openInvoices.invoices}
|
invoices={openInvoices.invoices}
|
||||||
invoicesLoading={openInvoices.loading}
|
invoicesLoading={openInvoices.loading}
|
||||||
invoicesError={openInvoices.error}
|
invoicesError={openInvoices.error}
|
||||||
|
invoicesLoaded={openInvoices.loaded}
|
||||||
onClose={handleAssignClose}
|
onClose={handleAssignClose}
|
||||||
onDone={handleAssignDone}
|
onDone={handleAssignDone}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const {
|
|||||||
aboStatus,
|
aboStatus,
|
||||||
INVOICE_FILTERS,
|
INVOICE_FILTERS,
|
||||||
DEFAULT_INVOICE_FILTER,
|
DEFAULT_INVOICE_FILTER,
|
||||||
|
invoiceFilterStatuses,
|
||||||
|
mergeInvoiceLists,
|
||||||
INVOICE_LIMITS,
|
INVOICE_LIMITS,
|
||||||
PENDING_ASSIGN_HINT,
|
PENDING_ASSIGN_HINT,
|
||||||
ageGroupCells,
|
ageGroupCells,
|
||||||
@@ -652,6 +654,40 @@ test('INVOICE_FILTERS: Reihenfolge Offen, Überfällig, Bezahlt, Entwürfe, Alle
|
|||||||
assert.equal(DEFAULT_INVOICE_FILTER, 'offen')
|
assert.equal(DEFAULT_INVOICE_FILTER, 'offen')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('invoiceFilterStatuses: „Offen“ ruft offen UND überfällig ab, die anderen genau ihren Status', () => {
|
||||||
|
assert.deepEqual(invoiceFilterStatuses('offen'), ['offen', 'ueberfaellig'])
|
||||||
|
assert.deepEqual(invoiceFilterStatuses('ueberfaellig'), ['ueberfaellig'])
|
||||||
|
assert.deepEqual(invoiceFilterStatuses('bezahlt'), ['bezahlt'])
|
||||||
|
assert.deepEqual(invoiceFilterStatuses('entwurf'), ['entwurf'])
|
||||||
|
assert.deepEqual(invoiceFilterStatuses('alle'), ['alle'])
|
||||||
|
assert.deepEqual(invoiceFilterStatuses('gibtsnicht'), ['alle'])
|
||||||
|
assert.deepEqual(invoiceFilterStatuses(undefined), ['alle'])
|
||||||
|
// die Liste des Filters lässt sich nicht von außen verändern
|
||||||
|
invoiceFilterStatuses('offen').push('x')
|
||||||
|
assert.deepEqual(invoiceFilterStatuses('offen'), ['offen', 'ueberfaellig'])
|
||||||
|
for (const f of INVOICE_FILTERS) assert.deepEqual(invoiceFilterStatuses(f.key), f.statuses)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mergeInvoiceLists: neueste zuerst, gleiche Tage in Listenreihenfolge, keine Doppelten', () => {
|
||||||
|
const open = [
|
||||||
|
{ id: 'a', createdDate: '2026-07-28', status: 'offen' },
|
||||||
|
{ id: 'b', createdDate: '2026-09-15', status: 'offen' },
|
||||||
|
]
|
||||||
|
const overdue = [
|
||||||
|
{ id: 'c', createdDate: '2026-09-01', status: 'ueberfaellig' },
|
||||||
|
{ id: 'd', createdDate: '2026-09-15', status: 'ueberfaellig' },
|
||||||
|
{ id: 'a', createdDate: '2026-07-28', status: 'offen' },
|
||||||
|
]
|
||||||
|
assert.deepEqual(mergeInvoiceLists([open, overdue]).map((i) => i.id), ['b', 'd', 'c', 'a'])
|
||||||
|
assert.deepEqual(mergeInvoiceLists([overdue, open]).map((i) => i.id), ['d', 'b', 'c', 'a'])
|
||||||
|
assert.deepEqual(mergeInvoiceLists([open]).map((i) => i.id), ['b', 'a'])
|
||||||
|
assert.deepEqual(mergeInvoiceLists([undefined, null, [null, 5, { id: 'x', createdDate: '2026-01-01' }]]).map((i) => i.id), ['x'])
|
||||||
|
assert.deepEqual(mergeInvoiceLists(undefined), [])
|
||||||
|
const input = [open, overdue]
|
||||||
|
mergeInvoiceLists(input)
|
||||||
|
assert.deepEqual(open.map((i) => i.id), ['a', 'b'], 'Eingabelisten bleiben unverändert')
|
||||||
|
})
|
||||||
|
|
||||||
test('ageGroupCells: vier Gruppen in fester Reihenfolge, fehlende zählen 0', () => {
|
test('ageGroupCells: vier Gruppen in fester Reihenfolge, fehlende zählen 0', () => {
|
||||||
const cells = ageGroupCells([
|
const cells = ageGroupCells([
|
||||||
{ key: '1-30', anzahl: 2, summeCents: 12345 },
|
{ key: '1-30', anzahl: 2, summeCents: 12345 },
|
||||||
@@ -1072,6 +1108,30 @@ test('assignModel: Vorschlag mit abweichendem Betrag ist nicht wählbar', () =>
|
|||||||
assert.equal(known.suggestions[0].disabled, true, 'auch wenn die Liste den Unterschied zeigt')
|
assert.equal(known.suggestions[0].disabled, true, 'auch wenn die Liste den Unterschied zeigt')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('assignModel: Vorschlag zu einer Rechnung, die in der frisch geladenen Liste fehlt, ist nicht wählbar', () => {
|
||||||
|
const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV(), SUG_INV({ id: 'in_9', number: 'WK-0009' })] })
|
||||||
|
const listed = [OPEN()] // in_1 steht darin, in_9 nicht mehr
|
||||||
|
const loaded = assignModel(tx, { invoices: listed, invoicesLoaded: true })
|
||||||
|
assert.equal(loaded.suggestions[0].disabled, false)
|
||||||
|
assert.equal(loaded.suggestions[1].disabled, true)
|
||||||
|
assert.equal(loaded.suggestions[1].disabledReason, 'Diese Rechnung ist nicht mehr offen.')
|
||||||
|
assert.equal(loaded.suggestions[1].number, 'WK-0009')
|
||||||
|
// Liste leer, aber frisch geladen: alle Rechnungsvorschläge sind weg
|
||||||
|
const none = assignModel(tx, { invoices: [], invoicesLoaded: true })
|
||||||
|
assert.deepEqual(none.suggestions.map((s) => s.disabled), [true, true])
|
||||||
|
assert.ok(none.suggestions.every((s) => s.disabledReason === 'Diese Rechnung ist nicht mehr offen.'))
|
||||||
|
// nicht (oder fehlerhaft) geladen: nichts sperren, der Server entscheidet
|
||||||
|
for (const opts of [{ invoices: [] }, { invoices: [], invoicesLoaded: false }]) {
|
||||||
|
assert.deepEqual(assignModel(tx, opts).suggestions.map((s) => s.disabled), [false, false])
|
||||||
|
}
|
||||||
|
// Auszahlungen hängen nicht an der Rechnungsliste
|
||||||
|
const withPayout = TX({ amountCents: 5000, vorschlaege: [{ kind: 'payout', id: 'po_1', number: '', confidence: 'hoch', reason: 'x', amountDiffers: false }] })
|
||||||
|
assert.equal(assignModel(withPayout, { invoices: [], invoicesLoaded: true }).suggestions[0].disabled, false)
|
||||||
|
// eine Rechnung, die in der Liste steht, aber einen anderen Betrag hat, nennt weiter den Betrag
|
||||||
|
const differs = assignModel(TX({ amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN({ remainingCents: 4000 })], invoicesLoaded: true })
|
||||||
|
assert.match(differs.suggestions[0].disabledReason, /Betrag weicht ab/)
|
||||||
|
})
|
||||||
|
|
||||||
test('assignModel: Ausgang bekommt Fixkosten (nur aktive) und Privat/Sonstiges, keine Rechnungen', () => {
|
test('assignModel: Ausgang bekommt Fixkosten (nur aktive) und Privat/Sonstiges, keine Rechnungen', () => {
|
||||||
const tx = TX({ amountCents: -1000, vorschlaege: [SUG_INV()] })
|
const tx = TX({ amountCents: -1000, vorschlaege: [SUG_INV()] })
|
||||||
const m = assignModel(tx, {
|
const m = assignModel(tx, {
|
||||||
|
|||||||
Reference in new issue
Block a user