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
+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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user