feat: Komplett-Redesign + Ausbau zur Plattform
- 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>
This commit is contained in:
189
src/pages/FinancePage.jsx
Normal file
189
src/pages/FinancePage.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user