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

-2
View File
@@ -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>} />
+18 -99
View File
@@ -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 &amp; verknuepfen</>}
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen &amp; 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 &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,
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: [
-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])
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 }
}
-1
View File
@@ -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',
-1
View File
@@ -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)
-40
View File
@@ -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
View File
@@ -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}>
-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 { 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>
)
}