- Ruhiges Dark-Designsystem (Tokens, Karten, Badges/Pills, KPIs) in global.css, animierter PixelBlast-Hintergrund entfernt (kleineres Bundle) - Aufgeraeumter App-Rahmen: feste Sidebar mit sichtbaren Labels, gruppierter Navigation, aktivem Zustand und Collapse - Wiederverwendbare UI-Bausteine unter src/components/ui/ - Ticketliste als scanbare Karten (TicketCard) statt rowSpan-Tabelle - Neue Bereiche: Kunden (CRM-360), Finanzen-Cockpit, Dokumente-Browser - Dashboard als echtes Cockpit (Ticket-/Finanz-/Dokument-Kennzahlen) - Alle Seiten auf einheitlichen PageHeader/Container/Tokens umgestellt Co-authored-by: Cursor <cursoragent@cursor.com>
190 lines
8.8 KiB
JavaScript
190 lines
8.8 KiB
JavaScript
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate, FaPlus } from 'react-icons/fa6'
|
||
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
|
||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||
import { useCustomers } from '../hooks/useCustomers'
|
||
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
|
||
|
||
export default function FinancePage() {
|
||
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
|
||
const { customers } = useCustomers()
|
||
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 [showCreate, setShowCreate] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const data = await integrationsApi.listInvoices(undefined, 400)
|
||
setInvoices(data.invoices || [])
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
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
|
||
if (filter === 'paid' && inv.status_id !== 4) return false
|
||
if (search) {
|
||
const s = search.toLowerCase()
|
||
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
|
||
}
|
||
return true
|
||
})
|
||
}, [invoices, filter, search, today])
|
||
|
||
const openPdf = async (id) => {
|
||
setOpeningId(id)
|
||
try { await integrationsApi.openInvoicePdf(id) }
|
||
catch (err) { alert('PDF-Fehler: ' + err.message) }
|
||
finally { setOpeningId(null) }
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<PageHeader
|
||
title="Finanzen"
|
||
subtitle="Rechnungen aus InvoiceNinja - Uebersicht, Status und schnelle Aktionen"
|
||
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>
|
||
<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Rechnung erstellen</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="Ueberfaellig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} 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>
|
||
|
||
{showCreate && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<CreateInvoiceForm customers={customers} onDone={() => { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
|
||
</div>
|
||
)}
|
||
|
||
<Card pad={false}>
|
||
<div className="ui-card-head">
|
||
<div className="seg">
|
||
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['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 fuer diesen Filter." />
|
||
) : (
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table className="ui-table">
|
||
<thead>
|
||
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Faellig</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: '<27>', 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 ? ' (ueberf.)' : ''}</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>
|
||
)
|
||
}
|
||
|
||
function CreateInvoiceForm({ customers, onDone, onCancel }) {
|
||
const linkable = (customers || []).filter((c) => c.invoiceNinjaClientId)
|
||
const [clientId, setClientId] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [cost, setCost] = useState('')
|
||
const [quantity, setQuantity] = useState('1')
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
|
||
const submit = async (e) => {
|
||
e.preventDefault()
|
||
if (!clientId) { setError('Bitte Kunde waehlen'); return }
|
||
setBusy(true); setError(null)
|
||
try {
|
||
await integrationsApi.createInvoice({
|
||
client_id: clientId,
|
||
public_notes: description,
|
||
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
|
||
})
|
||
onDone()
|
||
} catch (err) { setError(err.message); setBusy(false) }
|
||
}
|
||
|
||
return (
|
||
<Card title="Neue Rechnung">
|
||
{linkable.length === 0 ? (
|
||
<p className="muted">Kein Kunde ist mit InvoiceNinja verknuepft. Bitte zuerst im Kunden-Bereich verknuepfen.</p>
|
||
) : (
|
||
<form onSubmit={submit}>
|
||
<Field label="Kunde">
|
||
<select className="form-control" value={clientId} onChange={(e) => setClientId(e.target.value)} required>
|
||
<option value="">Kunde waehlen...</option>
|
||
{linkable.map((c) => <option key={c.$id} value={c.invoiceNinjaClientId}>{c.name}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Leistung / Beschreibung">
|
||
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||
</Field>
|
||
<div className="flex gap-3">
|
||
<Field label="Einzelpreis (EUR)"><input className="form-control" type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} required /></Field>
|
||
<Field label="Menge"><input className="form-control" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} /></Field>
|
||
</div>
|
||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||
<div className="flex gap-2">
|
||
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}</Button>
|
||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|