Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 0069a62a09
commit 88a98b858f
10 files changed
+1001 -111

No files matched your search

+97 -111
View File
@@ -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>
)
}