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`
|
||||
}
|
||||
|
||||
/** 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.
|
||||
// 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}))?$/
|
||||
@@ -140,6 +149,38 @@ export const AGE_GROUP_LABEL = {
|
||||
'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. */
|
||||
export function pageOf(list, page, size = 50) {
|
||||
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 { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate } from 'react-icons/fa6'
|
||||
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
|
||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button } 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() {
|
||||
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
|
||||
const [invoices, setInvoices] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [filter, setFilter] = useState('all') // all | open | overdue | paid
|
||||
const [search, setSearch] = useState('')
|
||||
const [openingId, setOpeningId] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
const { data, loading, error, reload } = useFinanceOverview()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
// Buchung, die gerade zugeordnet werden soll; den Dialog dazu zeigt Block 7 (Task 5)
|
||||
const [assignTxId, setAssignTxId] = useState(null)
|
||||
const refreshLock = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
// 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.
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshLock.current) return
|
||||
refreshLock.current = true
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const data = await integrationsApi.listInvoices(undefined, 400)
|
||||
setInvoices(data.invoices || [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
await refreshBank()
|
||||
await reload()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
refreshLock.current = false
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [reload])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const filtered = useMemo(() => {
|
||||
return invoices.filter((inv) => {
|
||||
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
||||
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
|
||||
if (filter === 'overdue' && !overdue) return false
|
||||
// InvoiceNinja liefert status_id als Text ("4" = bezahlt)
|
||||
if (filter === 'paid' && String(inv.status_id) !== '4') return false
|
||||
if (search) {
|
||||
const s = search.toLowerCase()
|
||||
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
|
||||
const handleAction = useCallback(
|
||||
async (aktion) => {
|
||||
const plan = planTodoAction(aktion)
|
||||
switch (plan.kind) {
|
||||
case 'bank-connect': {
|
||||
const { url } = await startBankAuth()
|
||||
const target = safeUrl(url)
|
||||
if (!target) throw new Error('Die Bank hat keine Weiterleitung geliefert.')
|
||||
window.location.assign(target)
|
||||
return
|
||||
}
|
||||
case 'bank-refresh':
|
||||
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
|
||||
})
|
||||
}, [invoices, filter, search, today])
|
||||
},
|
||||
[navigate, refresh, reload]
|
||||
)
|
||||
|
||||
const openPdf = async (id) => {
|
||||
setOpeningId(id)
|
||||
try { await integrationsApi.openInvoicePdf(id) }
|
||||
catch (err) { alert('PDF-Fehler: ' + err.message) }
|
||||
finally { setOpeningId(null) }
|
||||
let body
|
||||
if (!data && loading) {
|
||||
body = (
|
||||
<div className="empty" role="status">
|
||||
<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 (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Finanzen"
|
||||
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>
|
||||
<PageHeader title="Finanzen" subtitle="Kontostand, Aufgaben und Zahlungen im Blick" />
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1147,3 +1147,61 @@ textarea.form-control {
|
||||
.legal-form-row { grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
}
|
||||
Reference in new issue
Block a user