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:
1 parent
34c4db0228
commit
1a8604f7e6
12 files changed
+40
-617
No files matched your search
@@ -1,7 +1,8 @@
|
||||
# WOMS – Ticketsystem von WEBklar
|
||||
|
||||
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`,
|
||||
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 |
|
||||
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
|
||||
| `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:
|
||||
|
||||
@@ -36,5 +37,6 @@ Zusammenspiel mit anderen Systemen:
|
||||
nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse
|
||||
landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter-
|
||||
und Projektverwaltung laufen über die Admin-API des Portals.
|
||||
- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen)
|
||||
und Paperless (Dokumente).
|
||||
- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen
|
||||
anzeigen, Kunden verknüpfen). Die Paperless-Routen dort nutzt WOMS nicht mehr;
|
||||
Paperless selbst ist gestoppt.
|
||||
@@ -4,7 +4,6 @@ import Sidebar from './components/Navbar'
|
||||
import Footer from './components/Footer'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import TicketsPage from './pages/TicketsPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import PlanboardPage from './pages/PlanboardPage'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import RoadmapsPage from './pages/RoadmapsPage'
|
||||
@@ -46,7 +45,6 @@ function AppRoutes() {
|
||||
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
|
||||
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
||||
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function InvoicePanel({ ticket }) {
|
||||
}, [loadCustomer])
|
||||
|
||||
const clientId = customer?.invoiceNinjaClientId || null
|
||||
const { invoices, loading, error, refresh, createInvoice } = useInvoices(clientId)
|
||||
const { invoices, loading, error, refresh } = useInvoices(clientId)
|
||||
|
||||
if (customerLoading) {
|
||||
return (
|
||||
@@ -54,7 +54,7 @@ export default function InvoicePanel({ ticket }) {
|
||||
if (!ticket.customerId) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -77,23 +77,16 @@ export default function InvoicePanel({ ticket }) {
|
||||
|
||||
{customer && customerStage(customer) === 'lead' ? (
|
||||
<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>
|
||||
) : !clientId && !isInvoiceReady(customer) ? (
|
||||
<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>
|
||||
) : !clientId ? (
|
||||
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||
) : (
|
||||
<InvoiceList
|
||||
ticket={ticket}
|
||||
clientId={clientId}
|
||||
invoices={invoices}
|
||||
loading={loading}
|
||||
error={error}
|
||||
createInvoice={createInvoice}
|
||||
/>
|
||||
<InvoiceList invoices={invoices} loading={loading} error={error} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -163,13 +156,13 @@ function LinkClient({ customer, onLinked }) {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
placeholder="InvoiceNinja-Kunde suchen..."
|
||||
placeholder="InvoiceNinja-Kunde suchen …"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
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>
|
||||
<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}
|
||||
</div>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
|
||||
<FaLink /> Verknuepfen
|
||||
<FaLink /> Verknüpfen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -200,15 +193,15 @@ function LinkClient({ customer, onLinked }) {
|
||||
</div>
|
||||
|
||||
<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 & verknuepfen</>}
|
||||
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen & verknüpfen</>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Rechnungsliste ---------
|
||||
function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice }) {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
// Nur anzeigen: neue Rechnungen entstehen direkt in InvoiceNinja
|
||||
function InvoiceList({ invoices, loading, error }) {
|
||||
const [openingId, setOpeningId] = useState(null)
|
||||
|
||||
const openPdf = async (id) => {
|
||||
@@ -216,7 +209,7 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
|
||||
try {
|
||||
await integrationsApi.openInvoicePdf(id)
|
||||
} catch (err) {
|
||||
alert('PDF konnte nicht geoeffnet werden: ' + err.message)
|
||||
alert('PDF konnte nicht geöffnet werden: ' + err.message)
|
||||
} finally {
|
||||
setOpeningId(null)
|
||||
}
|
||||
@@ -224,40 +217,23 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
|
||||
|
||||
return (
|
||||
<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>}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
|
||||
) : 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' }}>
|
||||
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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()
|
||||
return (
|
||||
<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: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</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>
|
||||
<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' }}
|
||||
onClick={() => openPdf(inv.id)}>
|
||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||
@@ -284,60 +260,3 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
|
||||
</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 "versendet" 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>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IconFolders,
|
||||
IconCalendar,
|
||||
IconRoute,
|
||||
IconLayoutDashboard,
|
||||
IconSettings,
|
||||
IconLogout,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
@@ -48,12 +47,6 @@ export default function Sidebar() {
|
||||
label: 'Finanzen',
|
||||
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
|
||||
},
|
||||
{
|
||||
label: 'Auswertung',
|
||||
links: [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
links: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -32,18 +32,5 @@ export function useInvoices(clientId) {
|
||||
fetchInvoices()
|
||||
}, [fetchInvoices])
|
||||
|
||||
const createInvoice = useCallback(
|
||||
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 }
|
||||
return { invoices, loading, error, refresh: fetchInvoices }
|
||||
}
|
||||
@@ -28,7 +28,6 @@ export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-da
|
||||
|
||||
export const COLLECTIONS = {
|
||||
WORKORDERS: 'workorders',
|
||||
CONFIG: 'config',
|
||||
CUSTOMERS: 'customers',
|
||||
EMPLOYEES: 'employees',
|
||||
WORKSHEETS: 'worksheets',
|
||||
|
||||
@@ -59,7 +59,6 @@ export const integrationsApi = {
|
||||
// ---- InvoiceNinja: invoices ----
|
||||
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
|
||||
invoicesSummary: () => call('/invoices/summary'),
|
||||
createInvoice: (payload) => call('/invoices', { method: 'POST', body: payload }),
|
||||
async openInvoicePdf(id) {
|
||||
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
|
||||
await openBlobInNewTab(blob)
|
||||
|
||||
@@ -101,13 +101,6 @@
|
||||
.admin-notice.success { border-left-color: var(--ok); }
|
||||
.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-list-card {
|
||||
border: 1px solid var(--admin-line);
|
||||
@@ -115,8 +108,6 @@
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.admin-config-card.priorities { grid-column: 1 / -1; }
|
||||
|
||||
.admin-section-head {
|
||||
display: flex;
|
||||
min-height: 62px;
|
||||
@@ -134,21 +125,6 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
min-width: 34px;
|
||||
@@ -179,19 +155,6 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
@@ -449,8 +412,5 @@
|
||||
.admin-current-user { width: 100%; }
|
||||
.admin-tabs { overflow-x: auto; }
|
||||
.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; }
|
||||
}
|
||||
+3
-121
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FaFloppyDisk,
|
||||
FaPlus,
|
||||
@@ -11,22 +11,10 @@ import CopyableCredential, {
|
||||
getCustomerPortalPassword,
|
||||
} from '../components/CopyableCredential'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAdminConfig } from '../hooks/useAdminConfig'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
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 = {
|
||||
code: '',
|
||||
name: '',
|
||||
@@ -51,7 +39,6 @@ function Notice({ notice }) {
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const { config, loading, error, updateConfig } = useAdminConfig()
|
||||
const {
|
||||
customers,
|
||||
loading: customersLoading,
|
||||
@@ -68,9 +55,7 @@ export default function AdminPage() {
|
||||
deleteEmployee,
|
||||
} = useEmployees()
|
||||
|
||||
const [activeSection, setActiveSection] = useState('config')
|
||||
const [localConfig, setLocalConfig] = useState(EMPTY_CONFIG)
|
||||
const [savingConfig, setSavingConfig] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState('customers')
|
||||
const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER)
|
||||
const [editingCustomer, setEditingCustomer] = useState(null)
|
||||
const [customerBusy, setCustomerBusy] = useState(false)
|
||||
@@ -80,12 +65,6 @@ export default function AdminPage() {
|
||||
const [createdCredential, setCreatedCredential] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
setLocalConfig({ ...EMPTY_CONFIG, ...config })
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const showNotice = (type, text) => {
|
||||
setNotice({ type, text })
|
||||
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 = () => {
|
||||
setCustomerForm(EMPTY_CUSTOMER)
|
||||
setEditingCustomer(null)
|
||||
@@ -261,7 +201,7 @@ export default function AdminPage() {
|
||||
<div>
|
||||
<span className="admin-eyebrow">Systemverwaltung</span>
|
||||
<h1>Admin</h1>
|
||||
<p>Konfiguration, Kundenkonten und Mitarbeiterzugänge zentral verwalten.</p>
|
||||
<p>Kundenkonten und Mitarbeiterzugänge verwalten.</p>
|
||||
</div>
|
||||
<div className="admin-current-user">
|
||||
<span>Angemeldet als</span>
|
||||
@@ -270,11 +210,9 @@ export default function AdminPage() {
|
||||
</header>
|
||||
|
||||
<Notice notice={notice} />
|
||||
{error && <Notice notice={{ type: 'error', text: error }} />}
|
||||
|
||||
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
||||
{[
|
||||
['config', 'Konfiguration'],
|
||||
['customers', `Kunden (${customers.length})`],
|
||||
['employees', `Mitarbeiter (${employees.length})`],
|
||||
].map(([id, label]) => (
|
||||
@@ -289,62 +227,6 @@ export default function AdminPage() {
|
||||
))}
|
||||
</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' && (
|
||||
<section className="admin-split-layout">
|
||||
<form className="admin-form-card" onSubmit={submitCustomer}>
|
||||
|
||||
@@ -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
@@ -1,20 +1,17 @@
|
||||
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 { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
|
||||
import { PageHeader, Card, Button, Kpi, EmptyState } 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)
|
||||
@@ -37,7 +34,8 @@ export default function FinancePage() {
|
||||
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
|
||||
// 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
|
||||
@@ -57,39 +55,32 @@ export default function FinancePage() {
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
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={
|
||||
<>
|
||||
<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="Ü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>
|
||||
|
||||
{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]) => (
|
||||
{[['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 }} />
|
||||
<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 ? (
|
||||
@@ -97,16 +88,16 @@ export default function FinancePage() {
|
||||
) : error ? (
|
||||
<div className="empty text-red">{error}</div>
|
||||
) : 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' }}>
|
||||
<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>
|
||||
<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 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}>
|
||||
@@ -116,7 +107,7 @@ export default function FinancePage() {
|
||||
<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><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 />}
|
||||
@@ -134,56 +125,3 @@ export default function FinancePage() {
|
||||
</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