Finanzen nur noch anzeigen, Dashboard und Admin-Konfiguration entfernt (Paket D)

- Finanzen: Liste, Zahlen, PDF und Link zu InvoiceNinja bleiben. Das
  Formular "Rechnung erstellen" (auf der Seite und als "Rechnung aus
  Ticket erstellen" im Ticket) ist weg; Rechnungen entstehen direkt in
  InvoiceNinja. Das Verknüpfen von Kunden mit InvoiceNinja bleibt.
- Filter "Bezahlt" repariert: InvoiceNinja liefert status_id als Text.
- FinancePage.jsx und InvoicePanel.jsx waren Windows-1252 kodiert und
  zeigten live "�"; jetzt UTF-8, dazu Umlaute in den Texten.
- Dashboard entfernt (doppelte Finanzzahlen, "Offen" zählte falsch);
  alte Links führen zu den Tickets.
- Admin: Reiter "Konfiguration" (nur noch Prioritäten, die seit Paket B
  niemand liest) mit useAdminConfig entfernt; Admin startet bei Kunden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-27 16:01:12 +00:00
1 parent 34c4db0228
commit 1a8604f7e6
12 files changed
+40 -617

No files matched your search

+6 -4
View File
@@ -1,7 +1,8 @@
# WOMS – Ticketsystem von WEBklar # WOMS – Ticketsystem von WEBklar
Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit
Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Rechnungen. Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Rechnungen (nur
anzeigen; Rechnungen werden in InvoiceNinja geschrieben).
React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`, React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`,
Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`). Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`).
@@ -27,7 +28,7 @@ Datum speichert WOMS als Text `dd.mm.yyyy`, Uhrzeiten als `hhmm` (z. B. `0930`).
| `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo | | `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo |
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen | | `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
| `roadmaps`, `roadmapNodes` | Projektpläne | Plan mit Schritten, verknüpft mit einem Ticket | | `roadmaps`, `roadmapNodes` | Projektpläne | Plan mit Schritten, verknüpft mit einem Ticket |
| `config` | Einstellungen | Prioritäten (dazu alte Listen, die nichts mehr liest) | | `config` | alte Einstellungen | wird von WOMS nicht mehr gelesen (Prioritäten, alte Listen) |
Zusammenspiel mit anderen Systemen: Zusammenspiel mit anderen Systemen:
@@ -36,5 +37,6 @@ Zusammenspiel mit anderen Systemen:
nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse
landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter- landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter-
und Projektverwaltung laufen über die Admin-API des Portals. und Projektverwaltung laufen über die Admin-API des Portals.
- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen) - **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen
und Paperless (Dokumente). anzeigen, Kunden verknüpfen). Die Paperless-Routen dort nutzt WOMS nicht mehr;
Paperless selbst ist gestoppt.
-2
View File
@@ -4,7 +4,6 @@ import Sidebar from './components/Navbar'
import Footer from './components/Footer' import Footer from './components/Footer'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import TicketsPage from './pages/TicketsPage' import TicketsPage from './pages/TicketsPage'
import DashboardPage from './pages/DashboardPage'
import PlanboardPage from './pages/PlanboardPage' import PlanboardPage from './pages/PlanboardPage'
import ProjectsPage from './pages/ProjectsPage' import ProjectsPage from './pages/ProjectsPage'
import RoadmapsPage from './pages/RoadmapsPage' import RoadmapsPage from './pages/RoadmapsPage'
@@ -46,7 +45,6 @@ function AppRoutes() {
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} /> <Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} /> <Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} /> <Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} /> <Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} /> <Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} /> <Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
+18 -99
View File
@@ -41,7 +41,7 @@ export default function InvoicePanel({ ticket }) {
}, [loadCustomer]) }, [loadCustomer])
const clientId = customer?.invoiceNinjaClientId || null const clientId = customer?.invoiceNinjaClientId || null
const { invoices, loading, error, refresh, createInvoice } = useInvoices(clientId) const { invoices, loading, error, refresh } = useInvoices(clientId)
if (customerLoading) { if (customerLoading) {
return ( return (
@@ -54,7 +54,7 @@ export default function InvoicePanel({ ticket }) {
if (!ticket.customerId) { if (!ticket.customerId) {
return ( return (
<div style={card}> <div style={card}>
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknuepft - keine Rechnungen verfuegbar.</p> <p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknüpft, keine Rechnungen verfügbar.</p>
</div> </div>
) )
} }
@@ -77,23 +77,16 @@ export default function InvoicePanel({ ticket }) {
{customer && customerStage(customer) === 'lead' ? ( {customer && customerStage(customer) === 'lead' ? (
<p style={{ color: '#a0aec0', margin: 0 }}> <p style={{ color: '#a0aec0', margin: 0 }}>
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollstaendige Daten noetig) - dann sind Rechnungen moeglich. Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollständige Daten nötig), dann sind Rechnungen möglich.
</p> </p>
) : !clientId && !isInvoiceReady(customer) ? ( ) : !clientId && !isInvoiceReady(customer) ? (
<p style={{ color: '#a0aec0', margin: 0 }}> <p style={{ color: '#a0aec0', margin: 0 }}>
Rechnungsdaten unvollstaendig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Strasse, PLZ und Stadt ergaenzen. Rechnungsdaten unvollständig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Straße, PLZ und Stadt ergänzen.
</p> </p>
) : !clientId ? ( ) : !clientId ? (
<LinkClient customer={customer} onLinked={loadCustomer} /> <LinkClient customer={customer} onLinked={loadCustomer} />
) : ( ) : (
<InvoiceList <InvoiceList invoices={invoices} loading={loading} error={error} />
ticket={ticket}
clientId={clientId}
invoices={invoices}
loading={loading}
error={error}
createInvoice={createInvoice}
/>
)} )}
</div> </div>
) )
@@ -163,13 +156,13 @@ function LinkClient({ customer, onLinked }) {
return ( return (
<div> <div>
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}> <p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
Dieser Kunde ist noch nicht mit InvoiceNinja verknuepft. Dieser Kunde ist noch nicht mit InvoiceNinja verknüpft.
</p> </p>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}> <div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<input <input
className="form-control" className="form-control"
style={{ margin: 0 }} style={{ margin: 0 }}
placeholder="InvoiceNinja-Kunde suchen..." placeholder="InvoiceNinja-Kunde suchen …"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && doSearch()} onKeyDown={(e) => e.key === 'Enter' && doSearch()}
@@ -186,11 +179,11 @@ function LinkClient({ customer, onLinked }) {
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}> <div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div> <div>
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong> <strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
{c.number ? <span style={{ color: '#a0aec0' }}> � {c.number}</span> : null} {c.number ? <span style={{ color: '#a0aec0' }}> · {c.number}</span> : null}
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null} {c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
</div> </div>
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}> <button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
<FaLink /> Verknuepfen <FaLink /> Verknüpfen
</button> </button>
</div> </div>
))} ))}
@@ -200,15 +193,15 @@ function LinkClient({ customer, onLinked }) {
</div> </div>
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}> <button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen &amp; verknuepfen</>} {saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen &amp; verknüpfen</>}
</button> </button>
</div> </div>
) )
} }
// ---------------------------------------------------- Rechnungsliste --------- // ---------------------------------------------------- Rechnungsliste ---------
function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice }) { // Nur anzeigen: neue Rechnungen entstehen direkt in InvoiceNinja
const [showCreate, setShowCreate] = useState(false) function InvoiceList({ invoices, loading, error }) {
const [openingId, setOpeningId] = useState(null) const [openingId, setOpeningId] = useState(null)
const openPdf = async (id) => { const openPdf = async (id) => {
@@ -216,7 +209,7 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
try { try {
await integrationsApi.openInvoicePdf(id) await integrationsApi.openInvoicePdf(id)
} catch (err) { } catch (err) {
alert('PDF konnte nicht geoeffnet werden: ' + err.message) alert('PDF konnte nicht geöffnet werden: ' + err.message)
} finally { } finally {
setOpeningId(null) setOpeningId(null)
} }
@@ -224,40 +217,23 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
return ( return (
<div> <div>
<button className="btn btn-green" style={{ marginBottom: '12px' }} onClick={() => setShowCreate((s) => !s)}>
<FaPlus /> Rechnung aus Ticket erstellen
</button>
{showCreate && (
<CreateInvoiceForm
ticket={ticket}
clientId={clientId}
onCancel={() => setShowCreate(false)}
onSubmit={async (payload) => {
const r = await createInvoice(payload)
if (r.success) setShowCreate(false)
return r
}}
/>
)}
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>} {error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{loading ? ( {loading ? (
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p> <p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
) : invoices.length === 0 ? ( ) : invoices.length === 0 ? (
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen fuer diesen Kunden.</p> <p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen für diesen Kunden.</p>
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table className="table" style={{ width: '100%', fontSize: '13px' }}> <table className="table" style={{ width: '100%', fontSize: '13px' }}>
<thead> <thead>
<tr style={{ color: '#a0aec0', textAlign: 'left' }}> <tr style={{ color: '#a0aec0', textAlign: 'left' }}>
<th>Nr.</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th> <th>Nr.</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{invoices.map((inv) => { {invoices.map((inv) => {
const st = INVOICE_STATUS[inv.status_id] || { label: '�', color: '#a0aec0' } const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: '#a0aec0' }
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date() const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
return ( return (
<tr key={inv.id}> <tr key={inv.id}>
@@ -266,9 +242,9 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td> <td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td> <td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td> <td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (ueberfaellig)' : ''}</span></td> <td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
<td> <td>
<button className="btn" title="PDF oeffnen" disabled={openingId === inv.id} <button className="btn" title="PDF öffnen" disabled={openingId === inv.id}
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }} style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => openPdf(inv.id)}> onClick={() => openPdf(inv.id)}>
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />} {openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
@@ -284,60 +260,3 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
</div> </div>
) )
} }
// ---------------------------------------------------- Rechnung anlegen -------
function CreateInvoiceForm({ ticket, clientId, onCancel, onSubmit }) {
const [description, setDescription] = useState(ticket.topic || ticket.title || '')
const [cost, setCost] = useState('')
const [quantity, setQuantity] = useState('1')
const [markSent, setMarkSent] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError(null)
const r = await onSubmit({
client_id: clientId,
po_number: ticket.woid || '',
public_notes: `Ticket ${ticket.woid || ''}: ${ticket.topic || ''}`.trim(),
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
markSent,
})
if (!r.success) {
setError(r.error || 'Fehler beim Anlegen')
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ ...card, marginBottom: '12px' }}>
<div className="form-group">
<label className="form-label">Leistung / Beschreibung</label>
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<div className="form-group" style={{ flex: 1 }}>
<label className="form-label">Einzelpreis (EUR)</label>
<input type="number" step="0.01" className="form-control" value={cost} onChange={(e) => setCost(e.target.value)} required />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="form-label">Menge</label>
<input type="number" step="1" className="form-control" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#cbd5e0', marginBottom: '12px' }}>
<input type="checkbox" checked={markSent} onChange={(e) => setMarkSent(e.target.checked)} />
Direkt als &quot;versendet&quot; markieren
</label>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ display: 'flex', gap: '8px' }}>
<button type="submit" className="btn btn-green" disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}
</button>
<button type="button" className="btn" style={{ background: '#374151', color: 'white' }} onClick={onCancel}>Abbrechen</button>
</div>
</form>
)
}
-7
View File
@@ -9,7 +9,6 @@ import {
IconFolders, IconFolders,
IconCalendar, IconCalendar,
IconRoute, IconRoute,
IconLayoutDashboard,
IconSettings, IconSettings,
IconLogout, IconLogout,
IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftCollapse,
@@ -48,12 +47,6 @@ export default function Sidebar() {
label: 'Finanzen', label: 'Finanzen',
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }], links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
}, },
{
label: 'Auswertung',
links: [
{ label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
],
},
{ {
label: 'System', label: 'System',
links: [ links: [
-156
View File
@@ -1,156 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, ID } from '../lib/appwrite'
// Standardwerte, solange das Config-Dokument fehlt oder nicht lesbar ist
const DEFAULT_CONFIG = {
ticketTypes: [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
],
systems: [
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
],
responseLevels: [
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
],
serviceTypes: ['Remote', 'On Site', 'Off Site'],
priorities: [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
{ value: 3, label: 'High' },
{ value: 4, label: 'Critical' }
]
}
function normalizePriorities(values) {
if (!Array.isArray(values)) return DEFAULT_CONFIG.priorities
const parsed = values.map((item, index) => {
if (typeof item === 'object' && item !== null) return item
if (typeof item === 'string') {
try {
const value = JSON.parse(item)
if (value && typeof value === 'object') return value
} catch {
return { value: index, label: item }
}
}
return null
}).filter(Boolean)
return parsed.length > 0 ? parsed : DEFAULT_CONFIG.priorities
}
export function useAdminConfig() {
const [config, setConfig] = useState(DEFAULT_CONFIG)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchConfig = useCallback(async () => {
try {
// Versuche Config-Dokument zu laden (ID: 'config')
try {
const doc = await databases.getDocument(
DATABASE_ID,
COLLECTIONS.CONFIG || 'config',
'config'
)
setConfig({
ticketTypes: doc.ticketTypes || DEFAULT_CONFIG.ticketTypes,
systems: doc.systems || DEFAULT_CONFIG.systems,
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
priorities: normalizePriorities(doc.priorities)
})
} catch (e) {
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
if (e.code === 404 || e.message?.includes('not found')) {
setConfig(DEFAULT_CONFIG)
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
} else {
throw e
}
}
setError(null)
} catch (err) {
console.error('Error fetching config:', err)
// Nur echte Fehler als Error setzen, nicht 404
if (err.code !== 404 && !err.message?.includes('not found')) {
setError(err.message)
} else {
setError(null)
}
setConfig(DEFAULT_CONFIG) // Fallback zu Defaults
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchConfig()
}, [fetchConfig])
const updateConfig = async (newConfig) => {
try {
const configData = {
ticketTypes: newConfig.ticketTypes,
systems: newConfig.systems,
responseLevels: newConfig.responseLevels,
serviceTypes: newConfig.serviceTypes,
priorities: normalizePriorities(newConfig.priorities).map((priority) =>
JSON.stringify(priority)
)
}
try {
// Versuche zu aktualisieren
await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.CONFIG || 'config',
'config',
configData
)
} catch (e) {
// Dokument existiert nicht (404) oder Collection existiert nicht
if (e.code === 404 || e.message?.includes('not found')) {
// Versuche zu erstellen
try {
await databases.createDocument(
DATABASE_ID,
COLLECTIONS.CONFIG || 'config',
'config',
configData
)
} catch (createErr) {
// Collection existiert nicht - zeige hilfreiche Fehlermeldung
if (createErr.code === 404 || createErr.message?.includes('Collection')) {
throw new Error('Die "config" Collection existiert noch nicht. Bitte erstelle sie zuerst in Appwrite.')
}
throw createErr
}
} else {
throw e
}
}
setConfig(newConfig)
return { success: true }
} catch (err) {
console.error('Error updating config:', err)
return { success: false, error: err.message }
}
}
return {
config,
loading,
error,
updateConfig,
refresh: fetchConfig
}
}
+1 -14
View File
@@ -32,18 +32,5 @@ export function useInvoices(clientId) {
fetchInvoices() fetchInvoices()
}, [fetchInvoices]) }, [fetchInvoices])
const createInvoice = useCallback( return { invoices, loading, error, refresh: fetchInvoices }
async (payload) => {
try {
const data = await integrationsApi.createInvoice(payload)
await fetchInvoices()
return { success: true, invoice: data.invoice }
} catch (err) {
return { success: false, error: err.message }
}
},
[fetchInvoices]
)
return { invoices, loading, error, refresh: fetchInvoices, createInvoice }
} }
-1
View File
@@ -28,7 +28,6 @@ export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-da
export const COLLECTIONS = { export const COLLECTIONS = {
WORKORDERS: 'workorders', WORKORDERS: 'workorders',
CONFIG: 'config',
CUSTOMERS: 'customers', CUSTOMERS: 'customers',
EMPLOYEES: 'employees', EMPLOYEES: 'employees',
WORKSHEETS: 'worksheets', WORKSHEETS: 'worksheets',
-1
View File
@@ -59,7 +59,6 @@ export const integrationsApi = {
// ---- InvoiceNinja: invoices ---- // ---- InvoiceNinja: invoices ----
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`), listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
invoicesSummary: () => call('/invoices/summary'), invoicesSummary: () => call('/invoices/summary'),
createInvoice: (payload) => call('/invoices', { method: 'POST', body: payload }),
async openInvoicePdf(id) { async openInvoicePdf(id) {
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`) const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
await openBlobInNewTab(blob) await openBlobInNewTab(blob)
-40
View File
@@ -101,13 +101,6 @@
.admin-notice.success { border-left-color: var(--ok); } .admin-notice.success { border-left-color: var(--ok); }
.admin-notice.error { border-left-color: var(--danger); } .admin-notice.error { border-left-color: var(--danger); }
.admin-config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.admin-config-card,
.admin-form-card, .admin-form-card,
.admin-list-card { .admin-list-card {
border: 1px solid var(--admin-line); border: 1px solid var(--admin-line);
@@ -115,8 +108,6 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
} }
.admin-config-card.priorities { grid-column: 1 / -1; }
.admin-section-head { .admin-section-head {
display: flex; display: flex;
min-height: 62px; min-height: 62px;
@@ -134,21 +125,6 @@
line-height: 1.3; line-height: 1.3;
} }
.admin-config-list { padding: 10px; }
.admin-config-row,
.admin-priority-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
padding: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.admin-priority-row { grid-template-columns: 90px minmax(0, 1fr) auto; }
.admin-config-row:last-child,
.admin-priority-row:last-child { border-bottom: 0; }
.admin-icon-button { .admin-icon-button {
display: inline-flex; display: inline-flex;
min-width: 34px; min-width: 34px;
@@ -179,19 +155,6 @@
background: rgba(239, 68, 68, 0.08); background: rgba(239, 68, 68, 0.08);
} }
.admin-savebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
padding: 13px 16px;
border: 1px solid var(--admin-line);
background: var(--surface-1);
}
.admin-savebar span { color: var(--text-muted); font-size: 13px; }
.admin-primary-button { .admin-primary-button {
display: inline-flex; display: inline-flex;
min-height: 40px; min-height: 40px;
@@ -449,8 +412,5 @@
.admin-current-user { width: 100%; } .admin-current-user { width: 100%; }
.admin-tabs { overflow-x: auto; } .admin-tabs { overflow-x: auto; }
.admin-tabs button { flex: 0 0 auto; padding: 11px 12px; } .admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
.admin-config-grid { grid-template-columns: 1fr; }
.admin-config-card.priorities { grid-column: auto; }
.admin-savebar { align-items: stretch; flex-direction: column; }
.admin-credential-result { grid-template-columns: 1fr; } .admin-credential-result { grid-template-columns: 1fr; }
} }
+3 -121
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { import {
FaFloppyDisk, FaFloppyDisk,
FaPlus, FaPlus,
@@ -11,22 +11,10 @@ import CopyableCredential, {
getCustomerPortalPassword, getCustomerPortalPassword,
} from '../components/CopyableCredential' } from '../components/CopyableCredential'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useAdminConfig } from '../hooks/useAdminConfig'
import { useCustomers } from '../hooks/useCustomers' import { useCustomers } from '../hooks/useCustomers'
import { useEmployees } from '../hooks/useEmployees' import { useEmployees } from '../hooks/useEmployees'
import './AdminPage.css' import './AdminPage.css'
// Ticketarten, Systeme, Reaktions- und Leistungsarten gibt das Ticketformular
// fest vor; sie sind hier nicht mehr editierbar, bleiben beim Speichern aber
// im Config-Dokument erhalten. Editierbar sind nur noch die Prioritaeten.
const EMPTY_CONFIG = {
ticketTypes: [],
systems: [],
responseLevels: [],
serviceTypes: [],
priorities: [],
}
const EMPTY_CUSTOMER = { const EMPTY_CUSTOMER = {
code: '', code: '',
name: '', name: '',
@@ -51,7 +39,6 @@ function Notice({ notice }) {
export default function AdminPage() { export default function AdminPage() {
const { user, isAdmin } = useAuth() const { user, isAdmin } = useAuth()
const { config, loading, error, updateConfig } = useAdminConfig()
const { const {
customers, customers,
loading: customersLoading, loading: customersLoading,
@@ -68,9 +55,7 @@ export default function AdminPage() {
deleteEmployee, deleteEmployee,
} = useEmployees() } = useEmployees()
const [activeSection, setActiveSection] = useState('config') const [activeSection, setActiveSection] = useState('customers')
const [localConfig, setLocalConfig] = useState(EMPTY_CONFIG)
const [savingConfig, setSavingConfig] = useState(false)
const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER) const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER)
const [editingCustomer, setEditingCustomer] = useState(null) const [editingCustomer, setEditingCustomer] = useState(null)
const [customerBusy, setCustomerBusy] = useState(false) const [customerBusy, setCustomerBusy] = useState(false)
@@ -80,12 +65,6 @@ export default function AdminPage() {
const [createdCredential, setCreatedCredential] = useState(null) const [createdCredential, setCreatedCredential] = useState(null)
const [notice, setNotice] = useState(null) const [notice, setNotice] = useState(null)
useEffect(() => {
if (config && Object.keys(config).length > 0) {
setLocalConfig({ ...EMPTY_CONFIG, ...config })
}
}, [config])
const showNotice = (type, text) => { const showNotice = (type, text) => {
setNotice({ type, text }) setNotice({ type, text })
window.setTimeout(() => setNotice(null), 4500) window.setTimeout(() => setNotice(null), 4500)
@@ -103,45 +82,6 @@ export default function AdminPage() {
) )
} }
const addConfigItem = (field) => {
setLocalConfig((current) => ({
...current,
[field]: [
...(current[field] || []),
field === 'priorities'
? { value: current.priorities?.length || 0, label: 'Neue Priorität' }
: '',
],
}))
}
const removeConfigItem = (field, index) => {
setLocalConfig((current) => ({
...current,
[field]: (current[field] || []).filter((_, itemIndex) => itemIndex !== index),
}))
}
const updateConfigItem = (field, index, value) => {
setLocalConfig((current) => {
const next = [...(current[field] || [])]
next[index] = value
return { ...current, [field]: next }
})
}
const saveConfiguration = async () => {
setSavingConfig(true)
const result = await updateConfig(localConfig)
setSavingConfig(false)
showNotice(
result.success ? 'success' : 'error',
result.success
? 'Konfiguration gespeichert.'
: `Speichern fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`
)
}
const resetCustomerForm = () => { const resetCustomerForm = () => {
setCustomerForm(EMPTY_CUSTOMER) setCustomerForm(EMPTY_CUSTOMER)
setEditingCustomer(null) setEditingCustomer(null)
@@ -261,7 +201,7 @@ export default function AdminPage() {
<div> <div>
<span className="admin-eyebrow">Systemverwaltung</span> <span className="admin-eyebrow">Systemverwaltung</span>
<h1>Admin</h1> <h1>Admin</h1>
<p>Konfiguration, Kundenkonten und Mitarbeiterzugänge zentral verwalten.</p> <p>Kundenkonten und Mitarbeiterzugänge verwalten.</p>
</div> </div>
<div className="admin-current-user"> <div className="admin-current-user">
<span>Angemeldet als</span> <span>Angemeldet als</span>
@@ -270,11 +210,9 @@ export default function AdminPage() {
</header> </header>
<Notice notice={notice} /> <Notice notice={notice} />
{error && <Notice notice={{ type: 'error', text: error }} />}
<nav className="admin-tabs" aria-label="Admin-Bereiche"> <nav className="admin-tabs" aria-label="Admin-Bereiche">
{[ {[
['config', 'Konfiguration'],
['customers', `Kunden (${customers.length})`], ['customers', `Kunden (${customers.length})`],
['employees', `Mitarbeiter (${employees.length})`], ['employees', `Mitarbeiter (${employees.length})`],
].map(([id, label]) => ( ].map(([id, label]) => (
@@ -289,62 +227,6 @@ export default function AdminPage() {
))} ))}
</nav> </nav>
{activeSection === 'config' && (
<section>
{loading && !config ? (
<div className="admin-loading"><FaSpinner className="spinner" /> Konfiguration wird geladen …</div>
) : (
<>
<div className="admin-config-grid">
<section className="admin-config-card priorities">
<div className="admin-section-head">
<div><h3>Prioritäten</h3><span>{localConfig.priorities?.length || 0} Einträge</span></div>
<button type="button" className="admin-icon-button" onClick={() => addConfigItem('priorities')}>
<FaPlus /><span>Hinzufügen</span>
</button>
</div>
<div className="admin-config-list">
{(localConfig.priorities || []).map((priority, index) => (
<div className="admin-priority-row" key={`priority-${index}`}>
<input
type="number"
className="form-control"
value={priority.value}
aria-label="Prioritätswert"
onChange={(event) => updateConfigItem('priorities', index, {
...priority,
value: Number(event.target.value),
})}
/>
<input
className="form-control"
value={priority.label}
aria-label="Prioritätsname"
onChange={(event) => updateConfigItem('priorities', index, {
...priority,
label: event.target.value,
})}
/>
<button type="button" className="admin-icon-button danger" onClick={() => removeConfigItem('priorities', index)}>
<FaTrash />
</button>
</div>
))}
</div>
</section>
</div>
<div className="admin-savebar">
<span>Änderungen gelten für neue und bestehende Ticketformulare.</span>
<button type="button" className="admin-primary-button" disabled={savingConfig} onClick={saveConfiguration}>
{savingConfig ? <FaSpinner className="spin" /> : <FaFloppyDisk />}
Konfiguration speichern
</button>
</div>
</>
)}
</section>
)}
{activeSection === 'customers' && ( {activeSection === 'customers' && (
<section className="admin-split-layout"> <section className="admin-split-layout">
<form className="admin-form-card" onSubmit={submitCustomer}> <form className="admin-form-card" onSubmit={submitCustomer}>
-98
View File
@@ -1,98 +0,0 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoiceDollar } from 'react-icons/fa6'
import { formatMoney } from '../lib/integrationsApi'
import { useFinanceSummary } from '../hooks/useFinanceSummary'
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
export default function DashboardPage() {
const { summary, loading: finLoading } = useFinanceSummary()
const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
const [recent, setRecent] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
try {
const [total, open, closed, awaiting, recentRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Closed'), Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(6)]),
])
if (!active) return
setStats({ total: total.total, open: open.total, awaiting: awaiting.total, closed: closed.total })
setRecent(recentRes.documents)
} catch (e) {
console.error(e)
} finally {
if (active) setLoading(false)
}
})()
return () => { active = false }
}, [])
return (
<div className="page">
<PageHeader
title="Dashboard"
subtitle="Überblick über Tickets und Finanzen"
actions={
<>
<Button variant="ghost" as={Link} to="/finance"><FaFileInvoiceDollar /> Finanzen</Button>
<Button variant="primary" as={Link} to="/tickets"><FaPlus /> Neues Ticket</Button>
</>
}
/>
<div className="kpi-grid">
<Kpi label={<><FaTicket /> Tickets gesamt</>} value={loading ? '...' : stats.total} accent="var(--info)" />
<Kpi label={<><FaClipboardList /> Offen</>} value={loading ? '...' : stats.open} accent="var(--ok)" />
<Kpi label={<><FaClock /> Wartend</>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
<Kpi label={<><FaCircleCheck /> Geschlossen</>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
</div>
<div className="kpi-grid">
<Kpi label="Offener Betrag" value={finLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
<Kpi label="Ueberfaellig" value={finLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
<Kpi label="Bezahlt (Monat)" value={finLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
<Card title="Letzte Tickets" pad={false}>
{loading ? (
<div className="empty">...</div>
) : recent.length === 0 ? (
<EmptyState title="Keine Tickets" />
) : (
<div className="list">
{recent.map((t) => (
<Link key={t.$id} to="/tickets" className="list-row">
<span className="mono" style={{ color: 'var(--accent)', minWidth: 56 }}>{t.woid || t.$id.slice(-5)}</span>
<div className="grow">
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
<div className="muted">{t.customerName || '-'}</div>
</div>
<UrgentPill priority={t.priority} />
<StatusPill status={t.status} />
</Link>
))}
</div>
)}
</Card>
<Card title="Schnellaktionen">
<div className="flex flex-col gap-2">
<Button as={Link} to="/tickets"><FaPlus /> Neues Ticket erstellen</Button>
<Button as={Link} to="/finance"><FaFileInvoiceDollar /> Rechnung erstellen</Button>
<Button as={Link} to="/customers" variant="ghost">Kunden verwalten</Button>
</div>
</Card>
</div>
</div>
)
}
+12 -74
View File
@@ -1,20 +1,17 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate, FaPlus } from 'react-icons/fa6' import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate } from 'react-icons/fa6'
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi' import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
import { useFinanceSummary } from '../hooks/useFinanceSummary' import { useFinanceSummary } from '../hooks/useFinanceSummary'
import { useCustomers } from '../hooks/useCustomers' import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
export default function FinancePage() { export default function FinancePage() {
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary() const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
const { customers } = useCustomers()
const [invoices, setInvoices] = useState([]) const [invoices, setInvoices] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [filter, setFilter] = useState('all') // all | open | overdue | paid const [filter, setFilter] = useState('all') // all | open | overdue | paid
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [openingId, setOpeningId] = useState(null) const [openingId, setOpeningId] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -37,7 +34,8 @@ export default function FinancePage() {
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
if (filter === 'open' && !(Number(inv.balance) > 0)) return false if (filter === 'open' && !(Number(inv.balance) > 0)) return false
if (filter === 'overdue' && !overdue) return false if (filter === 'overdue' && !overdue) return false
if (filter === 'paid' && inv.status_id !== 4) return false // InvoiceNinja liefert status_id als Text ("4" = bezahlt)
if (filter === 'paid' && String(inv.status_id) !== '4') return false
if (search) { if (search) {
const s = search.toLowerCase() const s = search.toLowerCase()
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
@@ -57,39 +55,32 @@ export default function FinancePage() {
<div className="page"> <div className="page">
<PageHeader <PageHeader
title="Finanzen" title="Finanzen"
subtitle="Rechnungen aus InvoiceNinja - Uebersicht, Status und schnelle Aktionen" subtitle="Rechnungen aus InvoiceNinja: Übersicht und Status. Neue Rechnungen direkt in InvoiceNinja schreiben."
actions={ actions={
<> <>
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer"> <Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
<FaArrowUpRightFromSquare /> InvoiceNinja <FaArrowUpRightFromSquare /> InvoiceNinja
</Button> </Button>
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</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"> <div className="kpi-grid">
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" /> <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="Ü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="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" /> <Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
</div> </div>
{showCreate && (
<div style={{ marginBottom: 16 }}>
<CreateInvoiceForm customers={customers} onDone={() => { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
</div>
)}
<Card pad={false}> <Card pad={false}>
<div className="ui-card-head"> <div className="ui-card-head">
<div className="seg"> <div className="seg">
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['paid', 'Bezahlt']].map(([id, label]) => ( {[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Überfällig'], ['paid', 'Bezahlt']].map(([id, label]) => (
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button> <button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
))} ))}
</div> </div>
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} /> <input className="form-control search-input" placeholder="Nummer oder Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
</div> </div>
<div className="ui-card-body" style={{ padding: 0 }}> <div className="ui-card-body" style={{ padding: 0 }}>
{loading ? ( {loading ? (
@@ -97,16 +88,16 @@ export default function FinancePage() {
) : error ? ( ) : error ? (
<div className="empty text-red">{error}</div> <div className="empty text-red">{error}</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen fuer diesen Filter." /> <EmptyState title="Keine Rechnungen" hint="Keine Rechnungen für diesen Filter." />
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table className="ui-table"> <table className="ui-table">
<thead> <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> <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> </thead>
<tbody> <tbody>
{filtered.map((inv) => { {filtered.map((inv) => {
const st = INVOICE_STATUS[inv.status_id] || { label: '—', color: 'var(--text-muted)' } 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 const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
return ( return (
<tr key={inv.id}> <tr key={inv.id}>
@@ -116,7 +107,7 @@ export default function FinancePage() {
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td> <td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
<td>{formatMoney(inv.amount)}</td> <td>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</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><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
<td> <td>
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF"> <Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />} {openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
@@ -134,56 +125,3 @@ export default function FinancePage() {
</div> </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>
)
}