Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0069a62a09
commit
88a98b858f
10 files changed
+1001
-111
No files matched your search
@@ -0,0 +1,91 @@
|
|||||||
|
import { useId, useState } from 'react'
|
||||||
|
import { FaChevronDown, FaChevronUp } from 'react-icons/fa6'
|
||||||
|
import { Kpi } from '../ui'
|
||||||
|
import { euroWhole } from '../../lib/financeFormat'
|
||||||
|
import { breakdownRows, lowPointLines } from '../../lib/financeView'
|
||||||
|
|
||||||
|
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||||
|
|
||||||
|
/** Block 1: Kontostand, „Verfügbar nach Rücklagen“ (mit Aufschlüsselung) und „Tiefster Stand in 30 Tagen“. */
|
||||||
|
export default function LiquidityHead({ kopf }) {
|
||||||
|
const k = kopf && typeof kopf === 'object' ? kopf : {}
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const listId = useId()
|
||||||
|
|
||||||
|
const hasBalance = isNum(k.kontostandCents)
|
||||||
|
const shortfall = isNum(k.verfuegbarCents) && k.verfuegbarCents < 0
|
||||||
|
const lows = lowPointLines(k)
|
||||||
|
const anyLowNegative = lows.some((l) => l && l.negative)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kpi-grid fin-head">
|
||||||
|
<Kpi
|
||||||
|
label="Kontostand"
|
||||||
|
value={euroWhole(k.kontostandCents)}
|
||||||
|
sub={hasBalance ? 'gebucht' : 'Noch kein Kontostand abgerufen'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Kpi
|
||||||
|
label="Verfügbar nach Rücklagen"
|
||||||
|
value={euroWhole(k.verfuegbarCents)}
|
||||||
|
accent={shortfall ? 'var(--danger)' : 'var(--accent)'}
|
||||||
|
sub={
|
||||||
|
<>
|
||||||
|
{shortfall && <div className="fin-flag">Rücklagen nicht gedeckt</div>}
|
||||||
|
{isNum(k.verfuegbarCents) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fin-link"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listId}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
Aufschlüsselung {open ? <FaChevronUp aria-hidden="true" /> : <FaChevronDown aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul id={listId} className="fin-breakdown">
|
||||||
|
{breakdownRows(k.aufschluesselung, k.verfuegbarCents).map((row) => (
|
||||||
|
<li key={row.key} className={row.total ? 'fin-breakdown-total' : undefined}>
|
||||||
|
<span>{row.label}</span>
|
||||||
|
<span className="fin-amount">{row.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isNum(k.verfuegbarCents) && <div>Ohne Kontostand nicht berechenbar</div>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Kpi
|
||||||
|
label="Tiefster Stand in 30 Tagen"
|
||||||
|
accent={anyLowNegative ? 'var(--danger)' : 'var(--info)'}
|
||||||
|
value={
|
||||||
|
lows.some(Boolean) ? (
|
||||||
|
<div className="fin-lows">
|
||||||
|
{lows.map(
|
||||||
|
(low) =>
|
||||||
|
low && (
|
||||||
|
<div key={low.key} className="fin-low">
|
||||||
|
<span className="fin-low-label">{low.label}:</span>
|
||||||
|
<span className="fin-amount">{low.amountText}</span>
|
||||||
|
<span className="fin-low-when">
|
||||||
|
am {low.dateText}
|
||||||
|
{low.negative && <span className="fin-flag"> · im Minus</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'–'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sub={lows.some(Boolean) ? '„erwartet“ zählt offene Rechnungen und Abos dazu' : 'Ohne Kontostand nicht berechenbar'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { FaRotate, FaTriangleExclamation } from 'react-icons/fa6'
|
||||||
|
import { Button } from '../ui'
|
||||||
|
import { statusItems } from '../../lib/financeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block 0: Stand von Bank und Stripe in einer Zeile und der Knopf „Aktualisieren“.
|
||||||
|
* onRefresh() ruft den Bankabruf und lädt die Übersicht neu; wirft er (z. B. 429 „Bitte in N Minuten erneut
|
||||||
|
* versuchen.“), erscheint die Meldung des Servers als Hinweis unter der Zeile.
|
||||||
|
*/
|
||||||
|
export default function StatusLine({ stand, onRefresh, refreshing = false }) {
|
||||||
|
const [hint, setHint] = useState(null) // { text, limited }
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const items = statusItems(stand)
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
setHint(null)
|
||||||
|
try {
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err) {
|
||||||
|
if (aliveRef.current) {
|
||||||
|
setHint({ text: err?.message || 'Der Abruf ist fehlgeschlagen.', limited: err?.status === 429 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fin-status-wrap">
|
||||||
|
<div className="fin-status">
|
||||||
|
<div className="fin-status-items">
|
||||||
|
{items.map((item) => (
|
||||||
|
<span key={item.key} className={`fin-status-item${item.ok ? '' : ' fin-status-warn'}`}>
|
||||||
|
{!item.ok && <FaTriangleExclamation aria-hidden="true" />}
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleRefresh} disabled={refreshing}>
|
||||||
|
<FaRotate className={refreshing ? 'fin-spin' : undefined} aria-hidden="true" />{' '}
|
||||||
|
{refreshing ? 'Aktualisiere …' : 'Aktualisieren'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{hint && (
|
||||||
|
<p className={`fin-hint${hint.limited ? '' : ' fin-hint-warn'}`} role={hint.limited ? 'status' : 'alert'}>
|
||||||
|
{hint.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||||
|
import { TODO_LIMIT, todoButtonLabel, todoKey, urgencyOf, visibleTodos } from '../../lib/financeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block 2: „Zu erledigen“, höchstens 7 Zeilen, danach „Alle N anzeigen“. Je Zeile Dringlichkeit (Text), Titel,
|
||||||
|
* Text und genau ein Knopf. onAction(aktion, aufgabe) darf ein Promise liefern; wirft es, steht die Meldung
|
||||||
|
* unter der Zeile. Solange eine Aktion läuft, sind alle Knöpfe gesperrt (kein Doppelklick).
|
||||||
|
*/
|
||||||
|
export default function TodoList({ aufgaben, onAction }) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [running, setRunning] = useState(null) // Schlüssel der laufenden Aufgabe
|
||||||
|
const [failure, setFailure] = useState(null) // { key, message }
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { shown, total } = visibleTodos(aufgaben, expanded)
|
||||||
|
|
||||||
|
async function run(todo, key) {
|
||||||
|
if (running) return
|
||||||
|
setFailure(null)
|
||||||
|
setRunning(key)
|
||||||
|
try {
|
||||||
|
await onAction(todo.aktion, todo)
|
||||||
|
} catch (err) {
|
||||||
|
if (aliveRef.current) setFailure({ key, message: err?.message || 'Die Aktion ist fehlgeschlagen.' })
|
||||||
|
} finally {
|
||||||
|
if (aliveRef.current) setRunning(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="Zu erledigen"
|
||||||
|
actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}
|
||||||
|
className="fin-todo-card"
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
{total === 0 ? (
|
||||||
|
<EmptyState title="Nichts offen" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="fin-todos">
|
||||||
|
{shown.map((todo, index) => {
|
||||||
|
const key = todoKey(todo, index)
|
||||||
|
const urgency = urgencyOf(todo)
|
||||||
|
const label = todoButtonLabel(todo)
|
||||||
|
return (
|
||||||
|
<li key={key} className={`fin-todo fin-todo-${todo.dringlichkeit || 'info'}`}>
|
||||||
|
<Badge tone={urgency.tone}>{urgency.label}</Badge>
|
||||||
|
<div className="fin-todo-main">
|
||||||
|
<div className="fin-todo-title">{todo.titel}</div>
|
||||||
|
{todo.text && <div className="fin-todo-text muted">{todo.text}</div>}
|
||||||
|
{failure?.key === key && (
|
||||||
|
<div className="fin-hint fin-hint-warn" role="alert">
|
||||||
|
{failure.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<Button size="sm" onClick={() => run(todo, key)} disabled={running !== null}>
|
||||||
|
{running === key ? 'Moment …' : label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{total > TODO_LIMIT && (
|
||||||
|
<div className="fin-todos-more">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
||||||
|
{expanded ? 'Weniger anzeigen' : `Alle ${total} anzeigen`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { getOverview } from '../lib/financeApi'
|
||||||
|
|
||||||
|
const FALLBACK_ERROR = 'Die Übersicht konnte nicht geladen werden.'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Finanzübersicht (GET /uebersicht).
|
||||||
|
* data bleibt beim erneuten Laden stehen, damit die Seite nicht aufblitzt; error ist die Meldung des letzten
|
||||||
|
* fehlgeschlagenen Versuchs (leer, solange alles in Ordnung ist). reload() liefert ein Promise, das nie wirft.
|
||||||
|
*/
|
||||||
|
export function useFinanceOverview() {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
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 next = await getOverview()
|
||||||
|
if (current()) setData(next && typeof next === 'object' ? next : {})
|
||||||
|
} 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 { data, loading, error, reload }
|
||||||
|
}
|
||||||
@@ -84,6 +84,15 @@ export function sinceLabel(iso, now = Date.now()) {
|
|||||||
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
|
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Berliner Kalendertag ('YYYY-MM-DD') eines ISO-Zeitpunkts, Date oder Millisekundenwerts; ungueltig -> null. */
|
||||||
|
export function ymdBerlin(value) {
|
||||||
|
const t =
|
||||||
|
value instanceof Date ? value.getTime() : typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN
|
||||||
|
if (!Number.isFinite(t)) return null
|
||||||
|
const parts = Object.fromEntries(berlinDate.formatToParts(new Date(t)).map((p) => [p.type, p.value]))
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`
|
||||||
|
}
|
||||||
|
|
||||||
// Ganze Euro mit Tausenderpunkten (1.234.567) oder ohne Punkte, optional ,1-2 Nachkommastellen.
|
// Ganze Euro mit Tausenderpunkten (1.234.567) oder ohne Punkte, optional ,1-2 Nachkommastellen.
|
||||||
// Eine fuehrende 0 ist nur ohne Tausenderpunkte erlaubt, '12.50' (englisch) ist absichtlich ungueltig.
|
// Eine fuehrende 0 ist nur ohne Tausenderpunkte erlaubt, '12.50' (englisch) ist absichtlich ungueltig.
|
||||||
const AMOUNT = /^([1-9][0-9]{0,2}(?:\.[0-9]{3})+|[0-9]+)(?:,([0-9]{1,2}))?$/
|
const AMOUNT = /^([1-9][0-9]{0,2}(?:\.[0-9]{3})+|[0-9]+)(?:,([0-9]{1,2}))?$/
|
||||||
@@ -140,6 +149,38 @@ export const AGE_GROUP_LABEL = {
|
|||||||
'ueber-60': 'Über 60 Tage',
|
'ueber-60': 'Über 60 Tage',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const INTERVAL_LABEL = {
|
||||||
|
monat: 'monatlich',
|
||||||
|
quartal: 'vierteljährlich',
|
||||||
|
jahr: 'jährlich',
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_MONTHS = { monat: 1, quartal: 3, jahr: 12 }
|
||||||
|
|
||||||
|
function daysInMonth(year, month) {
|
||||||
|
if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
|
||||||
|
return [4, 6, 9, 11].includes(month) ? 30 : 31
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naechster Termin einer Folge: '2026-01-31' + 'monat' -> '2026-02-28' (Monatsende wird begrenzt, nie in den
|
||||||
|
* Folgemonat uebergelaufen). Rhythmen: monat, quartal, jahr. Ungueltiges Datum oder unbekannter Rhythmus -> null.
|
||||||
|
*/
|
||||||
|
export function nextIntervalDate(ymd, interval) {
|
||||||
|
const months = Object.prototype.hasOwnProperty.call(INTERVAL_MONTHS, interval) ? INTERVAL_MONTHS[interval] : 0
|
||||||
|
const m = typeof ymd === 'string' ? DATE_ONLY.exec(ymd.trim()) : null
|
||||||
|
if (!months || !m) return null
|
||||||
|
const year = Number(m[1])
|
||||||
|
const month = Number(m[2])
|
||||||
|
const day = Number(m[3])
|
||||||
|
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null
|
||||||
|
const total = year * 12 + (month - 1) + months
|
||||||
|
const nextYear = Math.floor(total / 12)
|
||||||
|
const nextMonth = (total % 12) + 1
|
||||||
|
const nextDay = Math.min(day, daysInMonth(nextYear, nextMonth))
|
||||||
|
return `${String(nextYear).padStart(4, '0')}-${String(nextMonth).padStart(2, '0')}-${String(nextDay).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
/** Seite einer Liste (1-basiert); die Seitenzahl wird auf 1..pages begrenzt. */
|
/** Seite einer Liste (1-basiert); die Seitenzahl wird auf 1..pages begrenzt. */
|
||||||
export function pageOf(list, page, size = 50) {
|
export function pageOf(list, page, size = 50) {
|
||||||
const items = Array.isArray(list) ? list : []
|
const items = Array.isArray(list) ? list : []
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Reine Darstellungs-Helfer fuer Statuszeile, Liquiditaetskopf und Aufgabenliste der Finanzen-Seite
|
||||||
|
// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
|
||||||
|
import {
|
||||||
|
INTERVAL_LABEL,
|
||||||
|
dateDe,
|
||||||
|
euro,
|
||||||
|
euroWhole,
|
||||||
|
nextIntervalDate,
|
||||||
|
signedEuro,
|
||||||
|
sinceLabel,
|
||||||
|
ymdBerlin,
|
||||||
|
URGENCY,
|
||||||
|
} from './financeFormat'
|
||||||
|
|
||||||
|
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||||
|
const text = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||||
|
|
||||||
|
/** Id des Rechnungsblocks (Task 5): Ziel für „Zu den Rechnungen“. */
|
||||||
|
export const INVOICES_ANCHOR = 'finanzen-rechnungen'
|
||||||
|
/** Wie viele Aufgaben ohne „Alle anzeigen“ sichtbar sind. */
|
||||||
|
export const TODO_LIMIT = 7
|
||||||
|
|
||||||
|
// --- Statuszeile (Block 0) ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank-Teil der Statuszeile: { ok, text }. ok=false zeigt eine Warnung (Text steht immer dabei).
|
||||||
|
* Normal: „Bank: Stand vor 3 Std. · jüngste Buchung 29.09.2026 · Zugang bis 29.03.2027“.
|
||||||
|
*/
|
||||||
|
export function bankStatus(bank, now = Date.now()) {
|
||||||
|
if (!bank || typeof bank !== 'object') return { ok: false, text: 'Bank: nicht verbunden' }
|
||||||
|
|
||||||
|
const until = dateDe(bank.validUntil)
|
||||||
|
const untilKnown = until !== '–'
|
||||||
|
const validDay = ymdBerlin(bank.validUntil)
|
||||||
|
const today = ymdBerlin(now)
|
||||||
|
const expired = bank.status === 'abgelaufen' || (validDay !== null && today !== null && validDay < today)
|
||||||
|
if (expired) return { ok: false, text: `Bank: Zugang abgelaufen${untilKnown ? ` am ${until}` : ''}` }
|
||||||
|
|
||||||
|
const parts = []
|
||||||
|
if (bank.lastSyncOk === false || bank.status === 'fehler') {
|
||||||
|
const error = text(bank.lastError)
|
||||||
|
parts.push(`Abruf fehlgeschlagen${error ? `: ${error}` : ''}`)
|
||||||
|
if (untilKnown) parts.push(`Zugang bis ${until}`)
|
||||||
|
return { ok: false, text: `Bank: ${parts.join(' · ')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(text(bank.lastSyncAt) ? `Stand ${sinceLabel(bank.lastSyncAt, now)}` : 'noch kein Abruf')
|
||||||
|
if (text(bank.latestBookingDate)) parts.push(`jüngste Buchung ${dateDe(bank.latestBookingDate)}`)
|
||||||
|
if (untilKnown) parts.push(`Zugang bis ${until}`)
|
||||||
|
return { ok: true, text: `Bank: ${parts.join(' · ')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stripe-Teil der Statuszeile: { ok, text }; bei Ausfall mit dem Text des Servers. */
|
||||||
|
export function stripeStatus(stripe, now = Date.now()) {
|
||||||
|
if (!stripe || typeof stripe !== 'object') return { ok: true, text: 'Stripe: Stand unbekannt' }
|
||||||
|
if (stripe.ok === false) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
text: `Stripe: Abruf fehlgeschlagen: ${text(stripe.fehler) || 'Stripe ist gerade nicht erreichbar.'}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const since = text(stripe.abgerufenUm) ? sinceLabel(stripe.abgerufenUm, now) : '–'
|
||||||
|
return { ok: true, text: `Stripe: Stand ${since === '–' ? 'unbekannt' : since}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [{ key, ok, text }] für Bank und Stripe. */
|
||||||
|
export function statusItems(stand, now = Date.now()) {
|
||||||
|
const s = stand && typeof stand === 'object' ? stand : {}
|
||||||
|
return [
|
||||||
|
{ key: 'bank', ...bankStatus(s.bank, now) },
|
||||||
|
{ key: 'stripe', ...stripeStatus(s.stripe, now) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Liquiditätskopf (Block 1) -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufschlüsselung „Verfügbar nach Rücklagen“ als Zeilen { key, label, text, total }:
|
||||||
|
* Kontostand − Steuer-Rücklage − Fixkosten 30 Tage − Mindestpuffer = Verfügbar (Beträge auf den Cent genau).
|
||||||
|
*/
|
||||||
|
export function breakdownRows(aufschluesselung, verfuegbarCents) {
|
||||||
|
const a = aufschluesselung && typeof aufschluesselung === 'object' ? aufschluesselung : {}
|
||||||
|
const minus = (cents) => (isNum(cents) ? signedEuro(-cents) : '–')
|
||||||
|
return [
|
||||||
|
{ key: 'kontostand', label: 'Kontostand (gebucht)', text: euro(a.kontostandCents), total: false },
|
||||||
|
{ key: 'steuer', label: 'Steuer-Rücklage', text: minus(a.steuerRuecklageCents), total: false },
|
||||||
|
{ key: 'fixkosten', label: 'Fixkosten der nächsten 30 Tage', text: minus(a.fixkosten30Cents), total: false },
|
||||||
|
{ key: 'puffer', label: 'Mindestpuffer', text: minus(a.mindestpufferCents), total: false },
|
||||||
|
{ key: 'verfuegbar', label: 'Verfügbar nach Rücklagen', text: euro(verfuegbarCents), total: true },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei Zeilen „Tiefster Stand in 30 Tagen“: sicher und erwartet, je { key, label, amountText, dateText, negative };
|
||||||
|
* ein Eintrag ist null, wenn der Server keinen Wert liefert (kein Kontostand).
|
||||||
|
*/
|
||||||
|
export function lowPointLines(kopf) {
|
||||||
|
const k = kopf && typeof kopf === 'object' ? kopf : {}
|
||||||
|
const line = (key, label, point) =>
|
||||||
|
point && isNum(point.amountCents)
|
||||||
|
? { key, label, amountText: euroWhole(point.amountCents), dateText: dateDe(point.date), negative: point.amountCents < 0 }
|
||||||
|
: null
|
||||||
|
return [line('sicher', 'sicher', k.tiefsterSicher), line('erwartet', 'erwartet', k.tiefsterErwartet)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zu erledigen (Block 2) --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Nur Aufgaben (Objekte) in Serverreihenfolge; bei eingeklappter Liste höchstens `max` Stück. */
|
||||||
|
export function visibleTodos(aufgaben, expanded = false, max = TODO_LIMIT) {
|
||||||
|
const items = Array.isArray(aufgaben) ? aufgaben.filter((t) => t && typeof t === 'object') : []
|
||||||
|
if (expanded || items.length <= max) return { shown: items, total: items.length, hidden: 0 }
|
||||||
|
return { shown: items.slice(0, max), total: items.length, hidden: items.length - max }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urgencyOf(todo) {
|
||||||
|
return URGENCY[todo?.dringlichkeit] || URGENCY.info
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stabiler Schlüssel einer Aufgabe (für React und den Zustand „läuft gerade“). */
|
||||||
|
export function todoKey(todo, index = 0) {
|
||||||
|
const a = todo?.aktion || {}
|
||||||
|
const ref = a.transactionId || a.invoiceId || a.customerId || a.vorschlag?.key || ''
|
||||||
|
return [index, todo?.art || '', todo?.datum || '', ref].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** http(s)-Adresse oder ''; schützt vor `javascript:` und Ähnlichem aus Serverdaten. */
|
||||||
|
export function safeUrl(value) {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) return ''
|
||||||
|
try {
|
||||||
|
const url = new URL(value.trim())
|
||||||
|
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschriftung des einen Knopfs einer Aufgabe; '' (kein Knopf) bei unbekannter Aktion. */
|
||||||
|
export function todoButtonLabel(todo) {
|
||||||
|
const a = todo?.aktion
|
||||||
|
switch (a?.typ) {
|
||||||
|
case 'bank-verbinden':
|
||||||
|
return 'Bank verbinden'
|
||||||
|
case 'bank-aktualisieren':
|
||||||
|
return 'Aktualisieren'
|
||||||
|
case 'rechnung-oeffnen':
|
||||||
|
return safeUrl(a.hostedUrl) ? 'Zahlungsseite öffnen' : 'Zu den Rechnungen'
|
||||||
|
case 'zuordnen':
|
||||||
|
return 'Zuordnen'
|
||||||
|
case 'kunde-oeffnen':
|
||||||
|
return 'Kunde öffnen'
|
||||||
|
case 'fixkosten-uebernehmen':
|
||||||
|
return 'Übernehmen'
|
||||||
|
case 'einstellungen':
|
||||||
|
return 'Einstellungen'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixkosten-Vorschlag des Servers ({ key, name, amountCents, interval, lastDate }) -> Anfrage für createFixedCost
|
||||||
|
* samt Rückfrage-Text; null, wenn der Vorschlag unvollständig ist. nextDate = lastDate + ein Rhythmus.
|
||||||
|
*/
|
||||||
|
export function fixedCostFromSuggestion(vorschlag) {
|
||||||
|
const v = vorschlag && typeof vorschlag === 'object' ? vorschlag : {}
|
||||||
|
const name = text(v.name)
|
||||||
|
const nextDate = nextIntervalDate(v.lastDate, v.interval)
|
||||||
|
if (!name || !Number.isSafeInteger(v.amountCents) || v.amountCents <= 0 || !nextDate) return null
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
name,
|
||||||
|
amountCents: v.amountCents,
|
||||||
|
interval: v.interval,
|
||||||
|
nextDate,
|
||||||
|
counterpartyMatch: typeof v.key === 'string' ? v.key : '',
|
||||||
|
},
|
||||||
|
confirmText: `Als Fixkosten übernehmen: ${name}, ${euro(v.amountCents)} ${INTERVAL_LABEL[v.interval]}?`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const planError = (message) => ({ kind: 'error', message })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was ein Klick auf den Aufgaben-Knopf bewirkt (die Seite führt es aus):
|
||||||
|
* bank-connect | bank-refresh | open-url {url} | scroll {id} | assign {transactionId} | navigate {to} |
|
||||||
|
* fixed-cost {payload, confirmText} | error {message}.
|
||||||
|
*/
|
||||||
|
export function planTodoAction(aktion) {
|
||||||
|
const a = aktion && typeof aktion === 'object' ? aktion : {}
|
||||||
|
switch (a.typ) {
|
||||||
|
case 'bank-verbinden':
|
||||||
|
return { kind: 'bank-connect' }
|
||||||
|
case 'bank-aktualisieren':
|
||||||
|
return { kind: 'bank-refresh' }
|
||||||
|
case 'rechnung-oeffnen': {
|
||||||
|
const url = safeUrl(a.hostedUrl)
|
||||||
|
return url ? { kind: 'open-url', url } : { kind: 'scroll', id: INVOICES_ANCHOR }
|
||||||
|
}
|
||||||
|
case 'zuordnen':
|
||||||
|
return text(a.transactionId)
|
||||||
|
? { kind: 'assign', transactionId: a.transactionId }
|
||||||
|
: planError('Die Buchung ist nicht bekannt.')
|
||||||
|
case 'kunde-oeffnen':
|
||||||
|
return text(a.customerId)
|
||||||
|
? { kind: 'navigate', to: `/customers/${encodeURIComponent(a.customerId)}` }
|
||||||
|
: planError('Zu diesem Eintrag ist kein Kunde hinterlegt.')
|
||||||
|
case 'einstellungen':
|
||||||
|
return { kind: 'navigate', to: '/finance/einstellungen' }
|
||||||
|
case 'fixkosten-uebernehmen': {
|
||||||
|
const suggestion = fixedCostFromSuggestion(a.vorschlag)
|
||||||
|
return suggestion
|
||||||
|
? { kind: 'fixed-cost', ...suggestion }
|
||||||
|
: planError('Der Vorschlag ist unvollständig und kann nicht übernommen werden.')
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return planError('Diese Aktion ist nicht bekannt.')
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
-111
@@ -1,127 +1,113 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate } from 'react-icons/fa6'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
|
import { FaSpinner } from 'react-icons/fa6'
|
||||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
import { PageHeader, Card, Button } from '../components/ui'
|
||||||
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
import StatusLine from '../components/finance/StatusLine'
|
||||||
|
import LiquidityHead from '../components/finance/LiquidityHead'
|
||||||
|
import TodoList from '../components/finance/TodoList'
|
||||||
|
import { useFinanceOverview } from '../hooks/useFinanceOverview'
|
||||||
|
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
|
||||||
|
import { planTodoAction, safeUrl } from '../lib/financeView'
|
||||||
|
|
||||||
export default function FinancePage() {
|
export default function FinancePage() {
|
||||||
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
|
const navigate = useNavigate()
|
||||||
const [invoices, setInvoices] = useState([])
|
const { data, loading, error, reload } = useFinanceOverview()
|
||||||
const [loading, setLoading] = useState(true)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
// Buchung, die gerade zugeordnet werden soll; den Dialog dazu zeigt Block 7 (Task 5)
|
||||||
const [filter, setFilter] = useState('all') // all | open | overdue | paid
|
const [assignTxId, setAssignTxId] = useState(null)
|
||||||
const [search, setSearch] = useState('')
|
const refreshLock = useRef(false)
|
||||||
const [openingId, setOpeningId] = useState(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
// Bankabruf anstoßen, danach die Übersicht neu laden. Fehler (z. B. 429) gehen an den Aufrufer.
|
||||||
setLoading(true)
|
// Ein Abruf, der bei der Bank scheitert, antwortet mit ok:false; das steht danach in der Statuszeile.
|
||||||
setError(null)
|
const refresh = useCallback(async () => {
|
||||||
|
if (refreshLock.current) return
|
||||||
|
refreshLock.current = true
|
||||||
|
setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const data = await integrationsApi.listInvoices(undefined, 400)
|
await refreshBank()
|
||||||
setInvoices(data.invoices || [])
|
await reload()
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
refreshLock.current = false
|
||||||
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [reload])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
const handleAction = useCallback(
|
||||||
|
async (aktion) => {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const plan = planTodoAction(aktion)
|
||||||
const filtered = useMemo(() => {
|
switch (plan.kind) {
|
||||||
return invoices.filter((inv) => {
|
case 'bank-connect': {
|
||||||
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
const { url } = await startBankAuth()
|
||||||
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
|
const target = safeUrl(url)
|
||||||
if (filter === 'overdue' && !overdue) return false
|
if (!target) throw new Error('Die Bank hat keine Weiterleitung geliefert.')
|
||||||
// InvoiceNinja liefert status_id als Text ("4" = bezahlt)
|
window.location.assign(target)
|
||||||
if (filter === 'paid' && String(inv.status_id) !== '4') return false
|
return
|
||||||
if (search) {
|
}
|
||||||
const s = search.toLowerCase()
|
case 'bank-refresh':
|
||||||
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
|
return refresh()
|
||||||
|
case 'open-url':
|
||||||
|
window.open(plan.url, '_blank', 'noopener,noreferrer')
|
||||||
|
return
|
||||||
|
case 'scroll':
|
||||||
|
// Der Rechnungsblock (Task 5) trägt diese Id; ohne ihn passiert nichts. Erst nach dem Neuzeichnen
|
||||||
|
// starten: ein Scroll mitten im Klick wird sonst vom Neuzeichnen der Aufgabenzeile abgebrochen.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.getElementById(plan.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case 'assign':
|
||||||
|
setAssignTxId(plan.transactionId)
|
||||||
|
return
|
||||||
|
case 'navigate':
|
||||||
|
navigate(plan.to)
|
||||||
|
return
|
||||||
|
case 'fixed-cost':
|
||||||
|
if (!window.confirm(plan.confirmText)) return
|
||||||
|
await createFixedCost(plan.payload)
|
||||||
|
await reload()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
throw new Error(plan.message)
|
||||||
}
|
}
|
||||||
return true
|
},
|
||||||
})
|
[navigate, refresh, reload]
|
||||||
}, [invoices, filter, search, today])
|
)
|
||||||
|
|
||||||
const openPdf = async (id) => {
|
let body
|
||||||
setOpeningId(id)
|
if (!data && loading) {
|
||||||
try { await integrationsApi.openInvoicePdf(id) }
|
body = (
|
||||||
catch (err) { alert('PDF-Fehler: ' + err.message) }
|
<div className="empty" role="status">
|
||||||
finally { setOpeningId(null) }
|
<FaSpinner className="spinner" aria-hidden="true" />
|
||||||
|
<p className="muted">Finanzen werden geladen …</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (!data) {
|
||||||
|
body = (
|
||||||
|
<Card>
|
||||||
|
<p role="alert" style={{ fontWeight: 600, marginTop: 0 }}>{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
|
||||||
|
<Button variant="primary" onClick={reload}>Erneut versuchen</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<div className="fin-stack">
|
||||||
|
{error && (
|
||||||
|
<p className="fin-hint fin-hint-warn" role="alert">
|
||||||
|
Die Übersicht konnte nicht neu geladen werden: {error}{' '}
|
||||||
|
<Button size="sm" variant="ghost" onClick={reload} disabled={loading}>Erneut versuchen</Button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
|
||||||
|
<LiquidityHead kopf={data.kopf} />
|
||||||
|
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader title="Finanzen" subtitle="Kontostand, Aufgaben und Zahlungen im Blick" />
|
||||||
title="Finanzen"
|
{body}
|
||||||
subtitle="Rechnungen aus InvoiceNinja: Übersicht und Status. Neue Rechnungen direkt in InvoiceNinja schreiben."
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
|
|
||||||
<FaArrowUpRightFromSquare /> InvoiceNinja
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="kpi-grid">
|
|
||||||
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
|
||||||
<Kpi label="Überfällig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} überfällig`} accent="var(--danger)" />
|
|
||||||
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
|
||||||
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card pad={false}>
|
|
||||||
<div className="ui-card-head">
|
|
||||||
<div className="seg">
|
|
||||||
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Überfällig'], ['paid', 'Bezahlt']].map(([id, label]) => (
|
|
||||||
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
|
|
||||||
</div>
|
|
||||||
<div className="ui-card-body" style={{ padding: 0 }}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="empty text-red">{error}</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen für diesen Filter." />
|
|
||||||
) : (
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table className="ui-table">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map((inv) => {
|
|
||||||
const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: 'var(--text-muted)' }
|
|
||||||
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
|
||||||
return (
|
|
||||||
<tr key={inv.id}>
|
|
||||||
<td className="mono">{inv.number || inv.id?.slice(-6)}</td>
|
|
||||||
<td>{inv.client_name || '-'}</td>
|
|
||||||
<td className="muted">{inv.date || '-'}</td>
|
|
||||||
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
|
|
||||||
<td>{formatMoney(inv.amount)}</td>
|
|
||||||
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
|
|
||||||
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
|
|
||||||
<td>
|
|
||||||
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
|
|
||||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1147,3 +1147,61 @@ textarea.form-control {
|
|||||||
.legal-form-row { grid-template-columns: 1fr; }
|
.legal-form-row { grid-template-columns: 1fr; }
|
||||||
.legal-contracts { display: block; overflow-x: auto; }
|
.legal-contracts { display: block; overflow-x: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Finanzen --- */
|
||||||
|
.fin-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.fin-stack { display: flex; flex-direction: column; gap: var(--sp-5); }
|
||||||
|
.fin-stack .kpi-grid { margin-bottom: 0; }
|
||||||
|
.fin-spin { animation: spin 1s linear infinite; }
|
||||||
|
.fin-hint { margin: 0; font-size: 13px; color: var(--text-muted); }
|
||||||
|
.fin-hint-warn { color: var(--warn); }
|
||||||
|
.fin-flag { color: var(--danger); font-weight: 700; }
|
||||||
|
.fin-link {
|
||||||
|
background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
|
||||||
|
color: var(--accent); display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
text-decoration: underline; text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.fin-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* Block 0: Statuszeile */
|
||||||
|
.fin-status-wrap { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.fin-status {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-3);
|
||||||
|
padding: 10px 14px; font-size: 13px; color: var(--text-muted);
|
||||||
|
background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
.fin-status-items { display: flex; flex-wrap: wrap; gap: 4px var(--sp-4); min-width: 0; }
|
||||||
|
.fin-status-item { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.fin-status-warn { color: var(--warn); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Block 1: Liquiditaetskopf */
|
||||||
|
.fin-head .kpi-value { font-variant-numeric: tabular-nums; }
|
||||||
|
.fin-breakdown { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; gap: 4px; color: var(--text-muted); }
|
||||||
|
.fin-breakdown li { display: flex; justify-content: space-between; gap: var(--sp-3); }
|
||||||
|
.fin-breakdown .fin-amount { color: var(--text); }
|
||||||
|
.fin-breakdown-total { border-top: 1px solid var(--border-strong); padding-top: 4px; font-weight: 700; color: var(--text); }
|
||||||
|
.fin-lows { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 6px 10px; align-items: baseline; font-size: 14px; font-weight: 600; }
|
||||||
|
.fin-low { display: contents; }
|
||||||
|
.fin-low-label { color: var(--text-muted); }
|
||||||
|
.fin-low .fin-amount { font-size: 20px; font-weight: 800; }
|
||||||
|
.fin-low-when { color: var(--text-muted); font-size: 13px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Block 2: Zu erledigen */
|
||||||
|
.fin-todo-card { overflow: hidden; }
|
||||||
|
.fin-todos { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.fin-todo {
|
||||||
|
display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: var(--sp-3); align-items: start;
|
||||||
|
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border); border-left: 3px solid var(--border);
|
||||||
|
}
|
||||||
|
.fin-todo:last-child { border-bottom: none; }
|
||||||
|
.fin-todo-hoch { border-left-color: var(--danger); }
|
||||||
|
.fin-todo-mittel { border-left-color: var(--warn); }
|
||||||
|
.fin-todo .badge { justify-self: start; }
|
||||||
|
.fin-todo-title { font-weight: 700; }
|
||||||
|
.fin-todo-text { font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; }
|
||||||
|
.fin-todo .fin-hint { margin-top: 6px; }
|
||||||
|
.fin-todos-more { padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
|
||||||
|
.fin-todo .ui-btn { justify-self: start; }
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ const {
|
|||||||
AGE_GROUP_LABEL,
|
AGE_GROUP_LABEL,
|
||||||
pageOf,
|
pageOf,
|
||||||
canInvoice,
|
canInvoice,
|
||||||
|
nextIntervalDate,
|
||||||
|
ymdBerlin,
|
||||||
|
INTERVAL_LABEL,
|
||||||
} = await loadModule('src/lib/financeFormat.js')
|
} = await loadModule('src/lib/financeFormat.js')
|
||||||
|
|
||||||
const MINUS = '−'
|
const MINUS = '−'
|
||||||
@@ -245,3 +248,53 @@ test('canInvoice: kein Kunde', () => {
|
|||||||
assert.equal(canInvoice(undefined).ok, false)
|
assert.equal(canInvoice(undefined).ok, false)
|
||||||
assert.equal(typeof canInvoice(null).reason, 'string')
|
assert.equal(typeof canInvoice(null).reason, 'string')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('nextIntervalDate: ein Rhythmus nach dem Datum', () => {
|
||||||
|
assert.equal(nextIntervalDate('2026-09-27', 'monat'), '2026-10-27')
|
||||||
|
assert.equal(nextIntervalDate('2026-09-27', 'quartal'), '2026-12-27')
|
||||||
|
assert.equal(nextIntervalDate('2026-09-27', 'jahr'), '2027-09-27')
|
||||||
|
assert.equal(nextIntervalDate('2026-12-15', 'monat'), '2027-01-15')
|
||||||
|
assert.equal(nextIntervalDate('2026-11-15', 'quartal'), '2027-02-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nextIntervalDate: Monatsende wird begrenzt, nicht in den Folgemonat übergelaufen', () => {
|
||||||
|
assert.equal(nextIntervalDate('2026-01-31', 'monat'), '2026-02-28')
|
||||||
|
assert.equal(nextIntervalDate('2024-01-31', 'monat'), '2024-02-29')
|
||||||
|
assert.equal(nextIntervalDate('2026-03-31', 'monat'), '2026-04-30')
|
||||||
|
assert.equal(nextIntervalDate('2026-11-30', 'quartal'), '2027-02-28')
|
||||||
|
assert.equal(nextIntervalDate('2026-08-31', 'quartal'), '2026-11-30')
|
||||||
|
assert.equal(nextIntervalDate('2024-02-29', 'jahr'), '2025-02-28')
|
||||||
|
assert.equal(nextIntervalDate('2023-02-28', 'jahr'), '2024-02-28')
|
||||||
|
assert.equal(nextIntervalDate('1900-01-31', 'monat'), '1900-02-28') // 1900 ist kein Schaltjahr
|
||||||
|
assert.equal(nextIntervalDate('2000-01-31', 'monat'), '2000-02-29')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nextIntervalDate: ungültige Eingaben ergeben null', () => {
|
||||||
|
assert.equal(nextIntervalDate('2026-02-30', 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate('2026-13-01', 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate('2026-00-10', 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate('2026-04-31', 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate('27.09.2026', 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate('', 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate(null, 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate(undefined, 'monat'), null)
|
||||||
|
assert.equal(nextIntervalDate('2026-09-27', 'woche'), null)
|
||||||
|
assert.equal(nextIntervalDate('2026-09-27', undefined), null)
|
||||||
|
assert.equal(nextIntervalDate('2026-09-27', 'constructor'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('INTERVAL_LABEL: deutsche Rhythmus-Wörter wie im Portal', () => {
|
||||||
|
assert.deepEqual(INTERVAL_LABEL, { monat: 'monatlich', quartal: 'vierteljährlich', jahr: 'jährlich' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ymdBerlin: Berliner Kalendertag eines Zeitpunkts', () => {
|
||||||
|
assert.equal(ymdBerlin('2026-10-01T10:55:21.691Z'), '2026-10-01')
|
||||||
|
assert.equal(ymdBerlin('2026-07-01T23:30:00.000Z'), '2026-07-02') // Sommerzeit: schon der nächste Tag
|
||||||
|
assert.equal(ymdBerlin('2099-03-29T00:00:00.000Z'), '2099-03-29')
|
||||||
|
assert.equal(ymdBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01')
|
||||||
|
assert.equal(ymdBerlin(Date.UTC(2026, 9, 1, 12)), '2026-10-01')
|
||||||
|
assert.equal(ymdBerlin(''), null)
|
||||||
|
assert.equal(ymdBerlin('kein Datum'), null)
|
||||||
|
assert.equal(ymdBerlin(null), null)
|
||||||
|
assert.equal(ymdBerlin(NaN), null)
|
||||||
|
})
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const {
|
||||||
|
INVOICES_ANCHOR,
|
||||||
|
TODO_LIMIT,
|
||||||
|
bankStatus,
|
||||||
|
stripeStatus,
|
||||||
|
statusItems,
|
||||||
|
breakdownRows,
|
||||||
|
lowPointLines,
|
||||||
|
visibleTodos,
|
||||||
|
urgencyOf,
|
||||||
|
todoKey,
|
||||||
|
safeUrl,
|
||||||
|
todoButtonLabel,
|
||||||
|
fixedCostFromSuggestion,
|
||||||
|
planTodoAction,
|
||||||
|
} = await loadModule('src/lib/financeView.js')
|
||||||
|
|
||||||
|
const MINUS = '−'
|
||||||
|
const NOW = Date.parse('2026-10-01T13:55:21.691Z') // 15:55 Berliner Zeit
|
||||||
|
|
||||||
|
const bank = (extra = {}) => ({
|
||||||
|
status: 'aktiv',
|
||||||
|
ibanMasked: 'DE00 •••• 9313',
|
||||||
|
validUntil: '2027-03-29T00:00:00.000Z',
|
||||||
|
lastSyncAt: '2026-10-01T10:55:21.691Z',
|
||||||
|
lastSyncOk: true,
|
||||||
|
lastError: '',
|
||||||
|
balanceBookedCents: 10893,
|
||||||
|
balanceAvailableCents: 9999,
|
||||||
|
balanceDate: '2026-10-01',
|
||||||
|
latestBookingDate: '2026-09-29',
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Statuszeile ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('bankStatus: Stand, jüngste Buchung und Zugang bis (Spec Block 0)', () => {
|
||||||
|
assert.deepEqual(bankStatus(bank(), NOW), {
|
||||||
|
ok: true,
|
||||||
|
text: 'Bank: Stand vor 3 Std. · jüngste Buchung 29.09.2026 · Zugang bis 29.03.2027',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bankStatus: ohne Verbindung', () => {
|
||||||
|
for (const none of [null, undefined, 'x']) {
|
||||||
|
assert.deepEqual(bankStatus(none, NOW), { ok: false, text: 'Bank: nicht verbunden' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bankStatus: Abruf fehlgeschlagen zeigt den Fehler und weiter den Zugang', () => {
|
||||||
|
const r = bankStatus(bank({ lastSyncOk: false, lastError: 'Bank antwortet nicht' }), NOW)
|
||||||
|
assert.equal(r.ok, false)
|
||||||
|
assert.equal(r.text, 'Bank: Abruf fehlgeschlagen: Bank antwortet nicht · Zugang bis 29.03.2027')
|
||||||
|
assert.equal(bankStatus(bank({ lastSyncOk: false, lastError: '' }), NOW).text, 'Bank: Abruf fehlgeschlagen · Zugang bis 29.03.2027')
|
||||||
|
assert.equal(bankStatus(bank({ status: 'fehler', lastError: 'x' }), NOW).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bankStatus: abgelaufener Zugang (Status oder Datum)', () => {
|
||||||
|
assert.deepEqual(bankStatus(bank({ status: 'abgelaufen' }), NOW), { ok: false, text: 'Bank: Zugang abgelaufen am 29.03.2027' })
|
||||||
|
const past = bankStatus(bank({ validUntil: '2026-09-30T00:00:00.000Z' }), NOW)
|
||||||
|
assert.deepEqual(past, { ok: false, text: 'Bank: Zugang abgelaufen am 30.09.2026' })
|
||||||
|
// am letzten Tag ist der Zugang noch gültig
|
||||||
|
assert.equal(bankStatus(bank({ validUntil: '2026-10-01T00:00:00.000Z' }), NOW).ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bankStatus: noch kein Abruf, keine Buchung, kein Datum', () => {
|
||||||
|
const r = bankStatus(bank({ lastSyncAt: null, latestBookingDate: null, validUntil: null }), NOW)
|
||||||
|
assert.deepEqual(r, { ok: true, text: 'Bank: noch kein Abruf' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stripeStatus: Stand und Ausfall mit dem Text des Servers', () => {
|
||||||
|
assert.deepEqual(stripeStatus({ ok: true, abgerufenUm: '2026-10-01T13:55:00.000Z' }, NOW), { ok: true, text: 'Stripe: Stand gerade eben' })
|
||||||
|
assert.deepEqual(stripeStatus({ ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null }, NOW), {
|
||||||
|
ok: false,
|
||||||
|
text: 'Stripe: Abruf fehlgeschlagen: Stripe ist gerade nicht erreichbar.',
|
||||||
|
})
|
||||||
|
assert.equal(stripeStatus({ ok: false }, NOW).text, 'Stripe: Abruf fehlgeschlagen: Stripe ist gerade nicht erreichbar.')
|
||||||
|
assert.deepEqual(stripeStatus({ ok: true, abgerufenUm: null }, NOW), { ok: true, text: 'Stripe: Stand unbekannt' })
|
||||||
|
assert.deepEqual(stripeStatus(null, NOW), { ok: true, text: 'Stripe: Stand unbekannt' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('statusItems: Bank und Stripe, fehlender Stand ist kein Fehler im Code', () => {
|
||||||
|
const items = statusItems({ bank: null, stripe: { ok: true, abgerufenUm: '2026-10-01T13:00:00.000Z' } }, NOW)
|
||||||
|
assert.deepEqual(items.map((i) => i.key), ['bank', 'stripe'])
|
||||||
|
assert.equal(items[0].text, 'Bank: nicht verbunden')
|
||||||
|
assert.equal(items[1].text, 'Stripe: Stand vor 55 Min.')
|
||||||
|
assert.equal(statusItems(undefined, NOW).length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Liquiditätskopf -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('breakdownRows: Kontostand minus Rücklage, Fixkosten und Puffer ergibt Verfügbar', () => {
|
||||||
|
const rows = breakdownRows({ kontostandCents: 10893, steuerRuecklageCents: 3268, fixkosten30Cents: 1449, mindestpufferCents: 2000 }, 4176)
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => [r.label, r.text]),
|
||||||
|
[
|
||||||
|
['Kontostand (gebucht)', '108,93 €'],
|
||||||
|
['Steuer-Rücklage', `${MINUS}32,68 €`],
|
||||||
|
['Fixkosten der nächsten 30 Tage', `${MINUS}14,49 €`],
|
||||||
|
['Mindestpuffer', `${MINUS}20,00 €`],
|
||||||
|
['Verfügbar nach Rücklagen', '41,76 €'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert.deepEqual(rows.map((r) => r.total), [false, false, false, false, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('breakdownRows: Puffer 0 ohne Vorzeichen, negatives Ergebnis, fehlende Werte', () => {
|
||||||
|
const rows = breakdownRows({ kontostandCents: 1000, steuerRuecklageCents: 0, fixkosten30Cents: 5000, mindestpufferCents: 0 }, -4000)
|
||||||
|
assert.equal(rows[1].text, '0,00 €')
|
||||||
|
assert.equal(rows[4].text, `${MINUS}40,00 €`)
|
||||||
|
const none = breakdownRows(null, null)
|
||||||
|
assert.deepEqual(none.map((r) => r.text), ['–', '–', '–', '–', '–'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lowPointLines: sicher und erwartet mit ganzen Euro und Datum', () => {
|
||||||
|
const [sicher, erwartet] = lowPointLines({
|
||||||
|
tiefsterSicher: { amountCents: 9444, date: '2026-10-11' },
|
||||||
|
tiefsterErwartet: { amountCents: 10893, date: '2026-10-01' },
|
||||||
|
})
|
||||||
|
assert.deepEqual(sicher, { key: 'sicher', label: 'sicher', amountText: '94 €', dateText: '11.10.2026', negative: false })
|
||||||
|
assert.deepEqual(erwartet, { key: 'erwartet', label: 'erwartet', amountText: '109 €', dateText: '01.10.2026', negative: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lowPointLines: negativ mit Minus U+2212, ohne Kontostand null', () => {
|
||||||
|
const [sicher] = lowPointLines({ tiefsterSicher: { amountCents: -12000, date: '2026-10-20' }, tiefsterErwartet: null })
|
||||||
|
assert.equal(sicher.amountText, `${MINUS}120 €`)
|
||||||
|
assert.equal(sicher.negative, true)
|
||||||
|
assert.deepEqual(lowPointLines({ tiefsterSicher: null, tiefsterErwartet: null }), [null, null])
|
||||||
|
assert.deepEqual(lowPointLines(undefined), [null, null])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Zu erledigen --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const todos = (n) => Array.from({ length: n }, (_, i) => ({ art: 'abo-zahlung', dringlichkeit: 'hoch', titel: `T${i}`, text: '', datum: '2026-10-01', aktion: { typ: 'kunde-oeffnen', customerId: `c${i}` } }))
|
||||||
|
|
||||||
|
test('visibleTodos: höchstens 7, Rest hinter „Alle N anzeigen“', () => {
|
||||||
|
assert.equal(TODO_LIMIT, 7)
|
||||||
|
const few = visibleTodos(todos(7))
|
||||||
|
assert.equal(few.shown.length, 7)
|
||||||
|
assert.equal(few.hidden, 0)
|
||||||
|
const many = visibleTodos(todos(12))
|
||||||
|
assert.equal(many.shown.length, 7)
|
||||||
|
assert.equal(many.total, 12)
|
||||||
|
assert.equal(many.hidden, 5)
|
||||||
|
assert.deepEqual(many.shown.map((t) => t.titel), ['T0', 'T1', 'T2', 'T3', 'T4', 'T5', 'T6'])
|
||||||
|
const all = visibleTodos(todos(12), true)
|
||||||
|
assert.equal(all.shown.length, 12)
|
||||||
|
assert.equal(all.hidden, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('visibleTodos: leer, ungültig, Nicht-Objekte werden ausgelassen', () => {
|
||||||
|
assert.deepEqual(visibleTodos([]), { shown: [], total: 0, hidden: 0 })
|
||||||
|
assert.deepEqual(visibleTodos(null), { shown: [], total: 0, hidden: 0 })
|
||||||
|
assert.deepEqual(visibleTodos(undefined), { shown: [], total: 0, hidden: 0 })
|
||||||
|
assert.equal(visibleTodos([null, 'x', 3, todos(1)[0]]).total, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('urgencyOf: Badge-Text und Ton, unbekannt gilt als Hinweis', () => {
|
||||||
|
assert.deepEqual(urgencyOf({ dringlichkeit: 'hoch' }), { label: 'Dringend', tone: 'danger' })
|
||||||
|
assert.deepEqual(urgencyOf({ dringlichkeit: 'mittel' }), { label: 'Bald', tone: 'warn' })
|
||||||
|
assert.deepEqual(urgencyOf({ dringlichkeit: 'info' }), { label: 'Hinweis', tone: 'muted' })
|
||||||
|
assert.deepEqual(urgencyOf({ dringlichkeit: 'x' }), { label: 'Hinweis', tone: 'muted' })
|
||||||
|
assert.deepEqual(urgencyOf(null), { label: 'Hinweis', tone: 'muted' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('todoKey: unterscheidet Aufgaben auch bei gleichem Titel', () => {
|
||||||
|
const [a, b] = todos(2)
|
||||||
|
assert.notEqual(todoKey(a, 0), todoKey(b, 1))
|
||||||
|
assert.notEqual(todoKey({ ...a }, 0), todoKey({ ...a }, 1))
|
||||||
|
assert.equal(typeof todoKey(null), 'string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('safeUrl: nur http(s)', () => {
|
||||||
|
assert.equal(safeUrl('https://invoice.stripe.com/i/acct_1/test_x'), 'https://invoice.stripe.com/i/acct_1/test_x')
|
||||||
|
assert.equal(safeUrl(' http://example.test/a '), 'http://example.test/a')
|
||||||
|
for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'ftp://x.test', 'invoice.stripe.com/i', '', ' ', null, undefined, 5]) {
|
||||||
|
assert.equal(safeUrl(bad), '', String(bad))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('todoButtonLabel: ein Knopf je Aktion', () => {
|
||||||
|
const label = (aktion) => todoButtonLabel({ aktion })
|
||||||
|
assert.equal(label({ typ: 'bank-verbinden' }), 'Bank verbinden')
|
||||||
|
assert.equal(label({ typ: 'bank-aktualisieren' }), 'Aktualisieren')
|
||||||
|
assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: 'https://x.test/i' }), 'Zahlungsseite öffnen')
|
||||||
|
assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: null }), 'Zu den Rechnungen')
|
||||||
|
assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: 'javascript:alert(1)' }), 'Zu den Rechnungen')
|
||||||
|
assert.equal(label({ typ: 'zuordnen' }), 'Zuordnen')
|
||||||
|
assert.equal(label({ typ: 'kunde-oeffnen' }), 'Kunde öffnen')
|
||||||
|
assert.equal(label({ typ: 'fixkosten-uebernehmen' }), 'Übernehmen')
|
||||||
|
assert.equal(label({ typ: 'einstellungen' }), 'Einstellungen')
|
||||||
|
assert.equal(label({ typ: 'neu' }), '')
|
||||||
|
assert.equal(todoButtonLabel({}), '')
|
||||||
|
assert.equal(todoButtonLabel(null), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestion = (extra = {}) => ({ key: 'hetzner', name: 'Hetzner', amountCents: 449, interval: 'monat', lastDate: '2026-09-27', count: 4, ...extra })
|
||||||
|
|
||||||
|
test('fixedCostFromSuggestion: Anfrage mit nächstem Termin und Rückfrage-Text', () => {
|
||||||
|
assert.deepEqual(fixedCostFromSuggestion(suggestion()), {
|
||||||
|
payload: { name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-10-27', counterpartyMatch: 'hetzner' },
|
||||||
|
confirmText: 'Als Fixkosten übernehmen: Hetzner, 4,49 € monatlich?',
|
||||||
|
})
|
||||||
|
const q = fixedCostFromSuggestion(suggestion({ interval: 'quartal', lastDate: '2026-11-30', amountCents: 123456 }))
|
||||||
|
assert.equal(q.payload.nextDate, '2027-02-28')
|
||||||
|
assert.equal(q.confirmText, 'Als Fixkosten übernehmen: Hetzner, 1.234,56 € vierteljährlich?')
|
||||||
|
assert.equal(fixedCostFromSuggestion(suggestion({ interval: 'jahr' })).confirmText, 'Als Fixkosten übernehmen: Hetzner, 4,49 € jährlich?')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fixedCostFromSuggestion: unvollständig ergibt null', () => {
|
||||||
|
assert.equal(fixedCostFromSuggestion(null), null)
|
||||||
|
assert.equal(fixedCostFromSuggestion({}), null)
|
||||||
|
assert.equal(fixedCostFromSuggestion(suggestion({ name: ' ' })), null)
|
||||||
|
assert.equal(fixedCostFromSuggestion(suggestion({ amountCents: 0 })), null)
|
||||||
|
assert.equal(fixedCostFromSuggestion(suggestion({ amountCents: 4.5 })), null)
|
||||||
|
assert.equal(fixedCostFromSuggestion(suggestion({ interval: 'woche' })), null)
|
||||||
|
assert.equal(fixedCostFromSuggestion(suggestion({ lastDate: 'Mai' })), null)
|
||||||
|
assert.equal(fixedCostFromSuggestion(suggestion({ key: undefined })).payload.counterpartyMatch, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('planTodoAction: jede Aktionsart des Portals', () => {
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'bank-verbinden' }), { kind: 'bank-connect' })
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'bank-aktualisieren' }), { kind: 'bank-refresh' })
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: 'https://x.test/i' }), { kind: 'open-url', url: 'https://x.test/i' })
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: null }), { kind: 'scroll', id: 'finanzen-rechnungen' })
|
||||||
|
assert.equal(INVOICES_ANCHOR, 'finanzen-rechnungen')
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', hostedUrl: 'javascript:alert(1)' }), { kind: 'scroll', id: 'finanzen-rechnungen' })
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'zuordnen', transactionId: 'b1' }), { kind: 'assign', transactionId: 'b1' })
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'kunde-oeffnen', customerId: 'c2' }), { kind: 'navigate', to: '/customers/c2' })
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'kunde-oeffnen', customerId: 'a/b c' }), { kind: 'navigate', to: '/customers/a%2Fb%20c' })
|
||||||
|
assert.deepEqual(planTodoAction({ typ: 'einstellungen' }), { kind: 'navigate', to: '/finance/einstellungen' })
|
||||||
|
const fixed = planTodoAction({ typ: 'fixkosten-uebernehmen', vorschlag: suggestion() })
|
||||||
|
assert.equal(fixed.kind, 'fixed-cost')
|
||||||
|
assert.equal(fixed.payload.nextDate, '2026-10-27')
|
||||||
|
assert.equal(fixed.confirmText, 'Als Fixkosten übernehmen: Hetzner, 4,49 € monatlich?')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('planTodoAction: fehlende Angaben und unbekannte Aktionen geben eine Meldung', () => {
|
||||||
|
for (const aktion of [
|
||||||
|
{ typ: 'zuordnen' },
|
||||||
|
{ typ: 'zuordnen', transactionId: '' },
|
||||||
|
{ typ: 'kunde-oeffnen' },
|
||||||
|
{ typ: 'kunde-oeffnen', customerId: ' ' },
|
||||||
|
{ typ: 'fixkosten-uebernehmen' },
|
||||||
|
{ typ: 'fixkosten-uebernehmen', vorschlag: suggestion({ amountCents: -1 }) },
|
||||||
|
{ typ: 'unbekannt' },
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
]) {
|
||||||
|
const plan = planTodoAction(aktion)
|
||||||
|
assert.equal(plan.kind, 'error', JSON.stringify(aktion))
|
||||||
|
assert.ok(plan.message.length > 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user