feat: Komplett-Redesign + Ausbau zur Plattform

- Ruhiges Dark-Designsystem (Tokens, Karten, Badges/Pills, KPIs) in global.css,
  animierter PixelBlast-Hintergrund entfernt (kleineres Bundle)
- Aufgeraeumter App-Rahmen: feste Sidebar mit sichtbaren Labels, gruppierter
  Navigation, aktivem Zustand und Collapse
- Wiederverwendbare UI-Bausteine unter src/components/ui/
- Ticketliste als scanbare Karten (TicketCard) statt rowSpan-Tabelle
- Neue Bereiche: Kunden (CRM-360), Finanzen-Cockpit, Dokumente-Browser
- Dashboard als echtes Cockpit (Ticket-/Finanz-/Dokument-Kennzahlen)
- Alle Seiten auf einheitlichen PageHeader/Container/Tokens umgestellt

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Webklar Deploy
2026-06-17 09:41:33 +00:00
parent 8f6927fbb0
commit d20a4de117
28 changed files with 1639 additions and 840 deletions

View File

@@ -1,47 +1,40 @@
import { useState } from 'react'
import { FaServer, FaDesktop, FaPrint, FaNetworkWired } from 'react-icons/fa6'
import { PageHeader, Card } from '../components/ui'
export default function AssetsPage() {
const [searchTerm, setSearchTerm] = useState('')
// Placeholder data - would come from Appwrite in production
const assetCategories = [
{ icon: FaServer, name: 'Servers', count: 0 },
{ icon: FaDesktop, name: 'Workstations', count: 0 },
{ icon: FaPrint, name: 'Printers', count: 0 },
{ icon: FaNetworkWired, name: 'Network Devices', count: 0 }
{ icon: FaServer, name: 'Server', count: 0 },
{ icon: FaDesktop, name: 'Arbeitsplaetze', count: 0 },
{ icon: FaPrint, name: 'Drucker', count: 0 },
{ icon: FaNetworkWired, name: 'Netzwerkgeraete', count: 0 },
]
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Assets Management</h2>
</header>
<div className="page">
<PageHeader title="Assets" subtitle="IT-Inventar und Geraete" />
<div className="search-bar mb-2">
<input
type="text"
placeholder="Search assets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input"
style={{ width: '100%', maxWidth: '400px' }}
/>
</div>
<input
className="form-control"
placeholder="Assets suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ maxWidth: 400, marginBottom: 16 }}
/>
<div className="assets-grid">
<div className="kpi-grid">
{assetCategories.map(({ icon: Icon, name, count }) => (
<div key={name} className="asset-card">
<Icon size={48} className="text-teal" />
<h4>{name}</h4>
<span className="asset-count">{count}</span>
<div key={name} className="kpi">
<span className="kpi-accent" />
<div className="kpi-label"><Icon /> {name}</div>
<div className="kpi-value">{count}</div>
</div>
))}
</div>
<div className="text-center mt-4 text-grey">
<p>Asset management module - Connect to Appwrite to manage your IT assets.</p>
</div>
<Card><p className="muted" style={{ margin: 0 }}>Asset-Modul - mit Appwrite verbinden, um IT-Assets zu verwalten.</p></Card>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, Link } from 'react-router-dom'
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk } from 'react-icons/fa6'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
import InvoicePanel from '../components/InvoicePanel'
import DocumentsPanel from '../components/DocumentsPanel'
import AssignedProjectCard from '../components/AssignedProjectCard'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
const TABS = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'documents', label: 'Dokumente' },
{ id: 'projects', label: 'Projekte' },
]
export default function CustomerDetailPage() {
const { id } = useParams()
const [customer, setCustomer] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [tab, setTab] = useState('overview')
const load = useCallback(async () => {
setLoading(true)
try {
setCustomer(await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id))
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurueck</Button>} /></div>
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
return (
<div className="page">
<PageHeader
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
{customer.name}
</span>
}
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' <20> ')}
actions={<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>}
/>
<Tabs tabs={TABS} active={tab} onChange={setTab} />
{tab === 'overview' && <OverviewTab customer={customer} onSaved={load} />}
{tab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{tab === 'invoices' && <InvoicePanel ticket={syntheticTicket} />}
{tab === 'documents' && <DocumentsPanel ticket={syntheticTicket} initialScope="customer" />}
{tab === 'projects' && <CustomerProjects customerId={customer.$id} />}
</div>
)
}
function OverviewTab({ customer, onSaved }) {
const [form, setForm] = useState({
name: customer.name || '',
code: customer.code || '',
location: customer.location || '',
email: customer.email || '',
phone: customer.phone || '',
notes: customer.notes || '',
importantCustomer: !!customer.importantCustomer,
paperlessCorrespondent: customer.paperlessCorrespondent || '',
})
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const save = async () => {
setBusy(true); setMsg(null)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
setMsg('Gespeichert')
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
} finally { setBusy(false) }
}
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<Card title="Stammdaten">
<div className="flex gap-3 wrap">
<Field label="Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></Field>
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
</div>
<Field label="Ort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
<div className="flex gap-3 wrap">
<Field label="E-Mail"><input className="form-control" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
</div>
<Field label="Notizen"><textarea className="form-control" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></Field>
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
Wichtiger Kunde
</label>
<div className="flex gap-2 items-center">
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
{msg && <span className="muted">{msg}</span>}
</div>
</Card>
<Card title="Verknuepfungen">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="muted">InvoiceNinja</span>
{customer.invoiceNinjaClientId
? <Badge tone="ok" dot>verknuepft</Badge>
: <Badge tone="muted">nicht verknuepft</Badge>}
</div>
<div className="faint" style={{ fontSize: 12 }}>
Verknuepfung erfolgt im Tab &quot;Rechnungen&quot; (Kunde suchen oder neu anlegen).
</div>
<Field label="Paperless-Korrespondent">
<input className="form-control" value={form.paperlessCorrespondent} onChange={(e) => setForm({ ...form, paperlessCorrespondent: e.target.value })} placeholder={customer.name} />
</Field>
<div className="faint" style={{ fontSize: 12 }}>Leer = Kundenname wird als Korrespondent verwendet.</div>
</div>
</Card>
</div>
)
}
function CustomerTickets({ customerId }) {
const [tickets, setTickets] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(100),
])
if (active) setTickets(res.documents)
} catch {
if (active) setTickets([])
} finally {
if (active) setLoading(false)
}
})()
return () => { active = false }
}, [customerId])
if (loading) return <Card><FaSpinner className="spinner" /></Card>
if (tickets.length === 0) return <Card><EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." /></Card>
return (
<Card pad={false}>
<div className="list">
{tickets.map((t) => (
<div key={t.$id} className="list-row">
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
<div className="grow">
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
<div className="muted">{t.type} <EFBFBD> {t.systemType || 'n/a'}</div>
</div>
<PriorityPill priority={t.priority} />
<StatusPill status={t.status} />
</div>
))}
</div>
</Card>
)
}
function CustomerProjects({ customerId }) {
const { fetchAllProjects } = useWebsiteProjects()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
const all = await fetchAllProjects()
if (active) setProjects((all || []).filter((p) => p.customerId === customerId))
if (active) setLoading(false)
})()
return () => { active = false }
}, [customerId, fetchAllProjects])
if (loading) return <Card><FaSpinner className="spinner" /></Card>
if (projects.length === 0) return <Card><EmptyState title="Keine Projekte" hint="Diesem Kunden sind keine Projekte/Previews zugeordnet." /></Card>
return (
<Card title={`Projekte (${projects.length})`}>
{projects.map((p) => <AssignedProjectCard key={p.$id} project={p} />)}
</Card>
)
}

119
src/pages/CustomersPage.jsx Normal file
View File

@@ -0,0 +1,119 @@
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar, FaFileLines } from 'react-icons/fa6'
import { useCustomers } from '../hooks/useCustomers'
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
export default function CustomersPage() {
const { customers, loading, createCustomer, refresh } = useCustomers()
const [search, setSearch] = useState('')
const [onlyImportant, setOnlyImportant] = useState(false)
const [showCreate, setShowCreate] = useState(false)
const filtered = useMemo(() => {
return (customers || [])
.filter((c) => (onlyImportant ? c.importantCustomer : true))
.filter((c) => {
if (!search) return true
const s = search.toLowerCase()
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
})
}, [customers, search, onlyImportant])
return (
<div className="page">
<PageHeader
title="Kunden"
subtitle="Stammdaten, Verknuepfungen und 360-Grad-Sicht je Kunde"
actions={<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neuer Kunde</Button>}
/>
{showCreate && (
<div style={{ marginBottom: 16 }}>
<CreateCustomerForm
onCancel={() => setShowCreate(false)}
onDone={async (data) => {
const r = await createCustomer(data)
if (r.success) { setShowCreate(false); refresh() }
return r
}}
/>
</div>
)}
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<div className="seg">
<button className={!onlyImportant ? 'active' : ''} onClick={() => setOnlyImportant(false)}>Alle</button>
<button className={onlyImportant ? 'active' : ''} onClick={() => setOnlyImportant(true)}>Wichtige</button>
</div>
<input className="form-control search-input" placeholder="Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Kunden" hint="Lege deinen ersten Kunden an." />
) : (
<div className="list">
{filtered.map((c) => (
<Link key={c.$id} to={`/customers/${c.$id}`} className="list-row">
<div className="side-logo" style={{ width: 34, height: 34, fontSize: 13 }}>
{(c.name || '?').charAt(0).toUpperCase()}
</div>
<div className="grow">
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
{c.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} title="Wichtiger Kunde" />}
{c.name}
{c.code && <span className="faint" style={{ fontWeight: 400 }}><EFBFBD> {c.code}</span>}
</div>
<div className="muted">{[c.location, c.email].filter(Boolean).join(' <20> ') || '-'}</div>
</div>
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
{c.invoiceNinjaClientId && <Badge tone="ok"><FaFileInvoiceDollar /> Rechnungen</Badge>}
{c.paperlessCorrespondent && <Badge tone="info"><FaFileLines /> Dokumente</Badge>}
</div>
</Link>
))}
</div>
)}
</div>
</Card>
</div>
)
}
function CreateCustomerForm({ onCancel, onDone }) {
const [form, setForm] = useState({ name: '', code: '', location: '', email: '', phone: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!form.name) { setError('Name erforderlich'); return }
setBusy(true); setError(null)
const r = await onDone(form)
if (!r.success) { setError(r.error || 'Fehler'); setBusy(false) }
}
return (
<Card title="Neuer Kunde">
<form onSubmit={submit}>
<div className="flex gap-3 wrap">
<Field label="Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></Field>
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="Ort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: 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" /> : 'Anlegen'}</Button>
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
</div>
</form>
</Card>
)
}

View File

@@ -1,72 +1,104 @@
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 } from 'react-icons/fa6'
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoiceDollar, FaUpload } from 'react-icons/fa6'
import { integrationsApi, formatMoney } from '../lib/integrationsApi'
import { useFinanceSummary } from '../hooks/useFinanceSummary'
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
export default function DashboardPage() {
const [stats, setStats] = useState({
totalTickets: 0,
openTickets: 0,
closedTickets: 0,
pendingTickets: 0
})
const { summary, loading: finLoading } = useFinanceSummary()
const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
const [recent, setRecent] = useState([])
const [docCount, setDocCount] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchStats() {
let active = true
;(async () => {
try {
const [total, open, closed, pending] = await Promise.all([
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.equal('status', 'Awaiting'), Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(6)]),
])
setStats({
totalTickets: total.total,
openTickets: open.total,
closedTickets: closed.total,
pendingTickets: pending.total
})
} catch (err) {
console.error('Error fetching stats:', err)
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 {
setLoading(false)
if (active) setLoading(false)
}
}
fetchStats()
try {
const d = await integrationsApi.listDocuments({ page_size: 1 })
if (active) setDocCount(d.count)
} catch { /* ignore */ }
})()
return () => { active = false }
}, [])
const StatCard = ({ icon: Icon, label, value, color }) => (
<div className="stat-card" style={{ borderLeft: `4px solid ${color}` }}>
<div className="stat-icon" style={{ color }}>
<Icon size={32} />
</div>
<div className="stat-info">
<span className="stat-value">{loading ? '...' : value}</span>
<span className="stat-label">{label}</span>
</div>
</div>
)
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Dashboard</h2>
</header>
<div className="page">
<PageHeader
title="Dashboard"
subtitle="Ueberblick ueber Tickets, Finanzen und Dokumente"
actions={
<>
<Button variant="ghost" as={Link} to="/finance"><FaFileInvoiceDollar /> Finanzen</Button>
<Button variant="primary" as={Link} to="/tickets"><FaPlus /> Neues Ticket</Button>
</>
}
/>
<div className="stats-grid">
<StatCard icon={FaTicket} label="Total Tickets" value={stats.totalTickets} color="#2196F3" />
<StatCard icon={FaClipboardList} label="Open Tickets" value={stats.openTickets} color="#4CAF50" />
<StatCard icon={FaClock} label="Pending" value={stats.pendingTickets} color="#FF9800" />
<StatCard icon={FaCircleCheck} label="Closed" value={stats.closedTickets} color="#9E9E9E" />
<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="dashboard-section mt-4">
<h3>Quick Actions</h3>
<div className="quick-actions">
<a href="/tickets" className="btn btn-teal">View All Tickets</a>
<a href="/reports" className="btn btn-dark">Generate Report</a>
</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)" />
<Kpi label="Dokumente" value={docCount ?? '...'} accent="var(--info)" />
</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>
<PriorityPill 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="/documents"><FaUpload /> Dokument hochladen</Button>
<Button as={Link} to="/customers" variant="ghost">Kunden verwalten</Button>
</div>
</Card>
</div>
</div>
)

View File

@@ -1,66 +1,27 @@
import { FaBook, FaCircleQuestion, FaKeyboard } from 'react-icons/fa6'
import { PageHeader, Card } from '../components/ui'
export default function DocsPage() {
const sections = [
{
icon: FaBook,
title: 'Getting Started',
content: 'Learn how to create and manage work orders in WOMS 2.0.'
},
{
icon: FaCircleQuestion,
title: 'FAQ',
content: 'Frequently asked questions about the system.'
},
{
icon: FaKeyboard,
title: 'Keyboard Shortcuts',
content: 'Speed up your workflow with keyboard shortcuts.'
}
]
const shortcuts = [
{ key: 'N', action: 'New Ticket' },
{ key: 'F', action: 'Focus Search' },
{ key: 'R', action: 'Refresh List' },
{ key: 'Esc', action: 'Close Modal' }
{ icon: FaBook, title: 'Erste Schritte', content: 'Tickets, Rechnungen und Dokumente in der Plattform verwalten.' },
{ icon: FaCircleQuestion, title: 'FAQ', content: 'Haeufige Fragen zum System.' },
{ icon: FaKeyboard, title: 'Tipps', content: 'Schneller arbeiten mit der Plattform.' },
]
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Documentation</h2>
</header>
<div className="docs-grid">
<div className="page">
<PageHeader title="Dokumentation" subtitle="Hilfe und Anleitungen" />
<div className="kpi-grid">
{sections.map(({ icon: Icon, title, content }) => (
<div key={title} className="doc-card">
<Icon size={32} className="text-teal" />
<h4>{title}</h4>
<p className="text-grey">{content}</p>
</div>
<Card key={title}>
<div className="flex items-center gap-3" style={{ marginBottom: 8 }}>
<Icon size={22} style={{ color: 'var(--accent)' }} />
<strong>{title}</strong>
</div>
<p className="muted" style={{ margin: 0 }}>{content}</p>
</Card>
))}
</div>
<div className="shortcuts-section mt-4">
<h3 className="text-center">Keyboard Shortcuts</h3>
<table className="table" style={{ maxWidth: '400px', margin: '0 auto' }}>
<thead>
<tr>
<th>Key</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{shortcuts.map(({ key, action }) => (
<tr key={key}>
<td><kbd>{key}</kbd></td>
<td>{action}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

111
src/pages/DocumentsPage.jsx Normal file
View File

@@ -0,0 +1,111 @@
import { useState, useEffect, useCallback } from 'react'
import { FaFileLines, FaEye, FaDownload, FaSpinner, FaMagnifyingGlass, FaRotate, FaUpload } from 'react-icons/fa6'
import { integrationsApi } from '../lib/integrationsApi'
import { useCustomers } from '../hooks/useCustomers'
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
import FileUploadModal from '../components/FileUploadModal'
export default function DocumentsPage() {
const { customers } = useCustomers()
const [docs, setDocs] = useState([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [query, setQuery] = useState('')
const [correspondent, setCorrespondent] = useState('')
const [busyId, setBusyId] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params = { page_size: 50 }
if (query) params.query = query
if (correspondent) params.correspondent = correspondent
const data = await integrationsApi.listDocuments(params)
setDocs(data.documents || [])
setCount(data.count || 0)
} catch (err) {
setError(err.message)
setDocs([])
} finally {
setLoading(false)
}
}, [query, correspondent])
useEffect(() => { load() }, [load])
const open = async (id, kind) => {
setBusyId(id + kind)
try { await integrationsApi.openDocument(id, kind) }
catch (err) { alert('Fehler: ' + err.message) }
finally { setBusyId(null) }
}
return (
<div className="page">
<PageHeader
title="Dokumente"
subtitle="Alle Belege und Kundendokumente aus Paperless-ngx"
actions={
<>
<Button variant="ghost" onClick={load}><FaRotate /> Aktualisieren</Button>
<Button variant="primary" onClick={() => setShowUpload(true)}><FaUpload /> Hochladen</Button>
</>
}
/>
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<select className="form-control" style={{ maxWidth: 240 }} value={correspondent} onChange={(e) => setCorrespondent(e.target.value)}>
<option value="">Alle Kunden</option>
{customers.map((c) => <option key={c.$id} value={c.name}>{c.name}</option>)}
</select>
<div className="flex gap-2 grow" style={{ minWidth: 220 }}>
<input className="form-control" placeholder="Volltextsuche..." value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && load()} />
<Button variant="primary" onClick={load}><FaMagnifyingGlass /></Button>
</div>
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<div className="empty text-red">{error}</div>
) : docs.length === 0 ? (
<EmptyState icon={<FaFileLines />} title="Keine Dokumente" hint="Keine Dokumente gefunden." />
) : (
<>
<div className="faint" style={{ padding: '8px 16px', fontSize: 12 }}>{count} Dokument(e)</div>
<div className="list">
{docs.map((d) => (
<div key={d.id} className="list-row">
<FaFileLines style={{ color: 'var(--accent)' }} />
<div className="grow">
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{d.title}</div>
<div className="muted">{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}</div>
</div>
<Button size="sm" variant="ghost" disabled={busyId === d.id + 'preview'} onClick={() => open(d.id, 'preview')} title="Vorschau">
{busyId === d.id + 'preview' ? <FaSpinner className="spinner" /> : <FaEye />}
</Button>
<Button size="sm" variant="ghost" disabled={busyId === d.id + 'download'} onClick={() => open(d.id, 'download')} title="Download">
{busyId === d.id + 'download' ? <FaSpinner className="spinner" /> : <FaDownload />}
</Button>
</div>
))}
</div>
</>
)}
</div>
</Card>
<FileUploadModal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
workorderId=""
ticket={correspondent ? { customerName: correspondent, woid: '' } : { customerName: '', woid: '' }}
onUploadComplete={() => setTimeout(load, 1500)}
/>
</div>
)
}

189
src/pages/FinancePage.jsx Normal file
View File

@@ -0,0 +1,189 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate, FaPlus } from 'react-icons/fa6'
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
import { useFinanceSummary } from '../hooks/useFinanceSummary'
import { useCustomers } from '../hooks/useCustomers'
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
export default function FinancePage() {
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
const { customers } = useCustomers()
const [invoices, setInvoices] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [filter, setFilter] = useState('all') // all | open | overdue | paid
const [search, setSearch] = useState('')
const [openingId, setOpeningId] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await integrationsApi.listInvoices(undefined, 400)
setInvoices(data.invoices || [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const today = new Date().toISOString().slice(0, 10)
const filtered = useMemo(() => {
return invoices.filter((inv) => {
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
if (filter === 'overdue' && !overdue) return false
if (filter === 'paid' && inv.status_id !== 4) return false
if (search) {
const s = search.toLowerCase()
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
}
return true
})
}, [invoices, filter, search, today])
const openPdf = async (id) => {
setOpeningId(id)
try { await integrationsApi.openInvoicePdf(id) }
catch (err) { alert('PDF-Fehler: ' + err.message) }
finally { setOpeningId(null) }
}
return (
<div className="page">
<PageHeader
title="Finanzen"
subtitle="Rechnungen aus InvoiceNinja - Uebersicht, Status und schnelle Aktionen"
actions={
<>
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
<FaArrowUpRightFromSquare /> InvoiceNinja
</Button>
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Rechnung erstellen</Button>
</>
}
/>
<div className="kpi-grid">
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
<Kpi label="Ueberfaellig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
</div>
{showCreate && (
<div style={{ marginBottom: 16 }}>
<CreateInvoiceForm customers={customers} onDone={() => { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
</div>
)}
<Card pad={false}>
<div className="ui-card-head">
<div className="seg">
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['paid', 'Bezahlt']].map(([id, label]) => (
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
))}
</div>
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<div className="empty text-red">{error}</div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen fuer diesen Filter." />
) : (
<div style={{ overflowX: 'auto' }}>
<table className="ui-table">
<thead>
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{filtered.map((inv) => {
const st = INVOICE_STATUS[inv.status_id] || { label: '<27>', color: 'var(--text-muted)' }
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
return (
<tr key={inv.id}>
<td className="mono">{inv.number || inv.id?.slice(-6)}</td>
<td>{inv.client_name || '-'}</td>
<td className="muted">{inv.date || '-'}</td>
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
<td>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (ueberf.)' : ''}</span></td>
<td>
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</Card>
</div>
)
}
function CreateInvoiceForm({ customers, onDone, onCancel }) {
const linkable = (customers || []).filter((c) => c.invoiceNinjaClientId)
const [clientId, setClientId] = useState('')
const [description, setDescription] = useState('')
const [cost, setCost] = useState('')
const [quantity, setQuantity] = useState('1')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!clientId) { setError('Bitte Kunde waehlen'); return }
setBusy(true); setError(null)
try {
await integrationsApi.createInvoice({
client_id: clientId,
public_notes: description,
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
})
onDone()
} catch (err) { setError(err.message); setBusy(false) }
}
return (
<Card title="Neue Rechnung">
{linkable.length === 0 ? (
<p className="muted">Kein Kunde ist mit InvoiceNinja verknuepft. Bitte zuerst im Kunden-Bereich verknuepfen.</p>
) : (
<form onSubmit={submit}>
<Field label="Kunde">
<select className="form-control" value={clientId} onChange={(e) => setClientId(e.target.value)} required>
<option value="">Kunde waehlen...</option>
{linkable.map((c) => <option key={c.$id} value={c.invoiceNinjaClientId}>{c.name}</option>)}
</select>
</Field>
<Field label="Leistung / Beschreibung">
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
</Field>
<div className="flex gap-3">
<Field label="Einzelpreis (EUR)"><input className="form-control" type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} required /></Field>
<Field label="Menge"><input className="form-control" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} /></Field>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}</Button>
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
</div>
</form>
)}
</Card>
)
}

View File

@@ -1,45 +1,42 @@
import { useState } from 'react'
import { format, addDays, startOfWeek } from 'date-fns'
import { de } from 'date-fns/locale'
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
export default function PlanboardPage() {
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
const navigateWeek = (direction) => {
setCurrentWeek(prev => addDays(prev, direction * 7))
}
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Planboard</h2>
</header>
<div className="planboard-nav text-center mb-2">
<button className="btn btn-dark" onClick={() => navigateWeek(-1)}> Previous Week</button>
<span className="mx-2">
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
</span>
<button className="btn btn-dark" onClick={() => navigateWeek(1)}>Next Week </button>
</div>
<div className="planboard-grid">
{weekDays.map(day => (
<div key={day.toISOString()} className="planboard-day">
<div className="day-header">
<strong>{format(day, 'EEEE')}</strong>
<span>{format(day, 'dd.MM')}</span>
</div>
<div className="day-content">
<p className="text-grey text-small">No tasks scheduled</p>
</div>
<div className="page">
<PageHeader
title="Planboard"
subtitle="Wochenplanung"
actions={
<div className="flex gap-2 items-center">
<Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
<span className="muted" style={{ minWidth: 200, textAlign: 'center' }}>
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
</span>
<Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
</div>
))}
</div>
}
/>
<div className="text-center mt-4 text-grey">
<p>Drag and drop tickets to schedule them on the planboard.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
{weekDays.map((day) => (
<Card key={day.toISOString()} pad={false}>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
</div>
<div style={{ padding: 12, minHeight: 120 }}>
<p className="faint" style={{ fontSize: 12 }}>Keine Termine</p>
</div>
</Card>
))}
</div>
</div>
)

View File

@@ -1,81 +1,48 @@
import { useState } from 'react'
import { format, subDays } from 'date-fns'
import { FaFileExport, FaChartBar } from 'react-icons/fa6'
import { PageHeader, Card, Button, Field, EmptyState } from '../components/ui'
export default function ReportsPage() {
const [dateRange, setDateRange] = useState({
from: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
to: format(new Date(), 'yyyy-MM-dd')
to: format(new Date(), 'yyyy-MM-dd'),
})
const [reportType, setReportType] = useState('tickets')
const reportTypes = [
{ value: 'tickets', label: 'Ticket Summary' },
{ value: 'performance', label: 'Performance Report' },
{ value: 'customer', label: 'Customer Report' },
{ value: 'technician', label: 'Technician Report' }
{ value: 'tickets', label: 'Ticket-Uebersicht' },
{ value: 'performance', label: 'Leistungsbericht' },
{ value: 'customer', label: 'Kundenbericht' },
{ value: 'technician', label: 'Techniker-Bericht' },
]
const handleGenerateReport = () => {
// Would generate report from Appwrite data
alert(`Generating ${reportType} report from ${dateRange.from} to ${dateRange.to}`)
const handleGenerate = () => {
alert(`Bericht "${reportType}" von ${dateRange.from} bis ${dateRange.to}`)
}
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Reports</h2>
</header>
<div className="page">
<PageHeader title="Reports" subtitle="Auswertungen und Exporte" />
<div className="report-filters card p-2">
<div className="filter-row">
<label>
Report Type:
<select
value={reportType}
onChange={(e) => setReportType(e.target.value)}
className="input ml-1"
>
{reportTypes.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
<Card title="Bericht erstellen">
<div className="flex gap-3 wrap">
<Field label="Berichtstyp">
<select className="form-control" value={reportType} onChange={(e) => setReportType(e.target.value)}>
{reportTypes.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</label>
</Field>
<Field label="Von"><input className="form-control" type="date" value={dateRange.from} onChange={(e) => setDateRange((p) => ({ ...p, from: e.target.value }))} /></Field>
<Field label="Bis"><input className="form-control" type="date" value={dateRange.to} onChange={(e) => setDateRange((p) => ({ ...p, to: e.target.value }))} /></Field>
</div>
<div className="filter-row mt-2">
<label>
From:
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange(prev => ({ ...prev, from: e.target.value }))}
className="input ml-1"
/>
</label>
<label className="ml-2">
To:
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange(prev => ({ ...prev, to: e.target.value }))}
className="input ml-1"
/>
</label>
<div className="flex gap-2" style={{ marginTop: 8 }}>
<Button variant="primary" onClick={handleGenerate}><FaChartBar /> Bericht erstellen</Button>
<Button variant="ghost"><FaFileExport /> Als PDF exportieren</Button>
</div>
</Card>
<div className="text-center mt-2">
<button className="btn btn-teal" onClick={handleGenerateReport}>
<FaChartBar /> Generate Report
</button>
<button className="btn btn-dark ml-1">
<FaFileExport /> Export PDF
</button>
</div>
</div>
<div className="text-center mt-4 text-grey">
<p>Select report parameters and click Generate to view your report.</p>
<div style={{ marginTop: 16 }}>
<Card><EmptyState icon={<FaChartBar />} title="Noch kein Bericht" hint="Parameter waehlen und Bericht erstellen." /></Card>
</div>
</div>
)

View File

@@ -1,42 +1,30 @@
import { useState } from 'react'
import { format } from 'date-fns'
import { FaAngleDown, FaSpinner } from 'react-icons/fa6'
import { FaAngleDown, FaSpinner, FaPlus, FaList, FaSliders } from 'react-icons/fa6'
import { useWorkorders } from '../hooks/useWorkorders'
import { useCustomers } from '../hooks/useCustomers'
import TicketRow from '../components/TicketRow'
import TicketCard from '../components/TicketCard'
import CreateTicketModal from '../components/CreateTicketModal'
import QuickOverviewModal from '../components/QuickOverviewModal'
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
const STATUS_PRESETS = ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info']
export default function TicketsPage() {
const [limit, setLimit] = useState(10)
// Aktive Filter (werden für API-Calls verwendet)
const [filters, setFilters] = useState({
status: ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info'],
type: [],
priority: [],
limit: 10
})
// Lokale Filter-Eingaben (werden nur beim Apply angewendet)
const [filters, setFilters] = useState({ status: STATUS_PRESETS, type: [], priority: [], limit: 10 })
const [localFilters, setLocalFilters] = useState({
woid: '',
customer: '',
userTopic: '',
createdDate: '',
type: '',
system: '',
priority: ''
woid: '', customer: '', userTopic: '', createdDate: '', type: '', system: '', priority: '',
})
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
const { customers } = useCustomers()
const [showCreateModal, setShowCreateModal] = useState(false)
const [showOverviewModal, setShowOverviewModal] = useState(false)
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const handleApplyFilters = () => {
// Wende lokale Filter auf aktive Filter an
setFilters(prev => ({
const applyFilters = () => {
setFilters((prev) => ({
...prev,
woid: localFilters.woid || undefined,
customer: localFilters.customer || undefined,
@@ -45,380 +33,96 @@ export default function TicketsPage() {
type: localFilters.type ? [localFilters.type] : [],
system: localFilters.system ? [localFilters.system] : [],
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
limit: limit
limit,
}))
}
const handleLimitChange = (e) => {
const newLimit = parseInt(e.target.value)
setLimit(newLimit)
// Limit-Änderung wird sofort angewendet (kein Apply nötig)
setFilters(prev => ({ ...prev, limit: newLimit }))
}
const handleUpdate = async (id, data) => {
await updateWorkorder(id, data)
}
const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
const handleCreate = async (data) => {
const result = await createWorkorder(data)
if (result.success) {
setShowCreateModal(false)
}
if (result.success) setShowCreateModal(false)
return result
}
const handleLoadMore = () => {
setLimit(prev => prev + 10)
setFilters(prev => ({ ...prev, limit: prev.limit + 10 }))
const loadMore = () => {
const next = limit + 10
setLimit(next)
setFilters((prev) => ({ ...prev, limit: next }))
}
return (
<div className="main-content">
{/* Sticky Header Container */}
<div style={{
position: 'sticky',
top: 0,
zIndex: 100,
marginBottom: '24px'
}}>
{/* Compact Control Panel */}
<div style={{
background: 'rgba(26, 32, 44, 0.4)',
backdropFilter: 'blur(25px) saturate(180%)',
WebkitBackdropFilter: 'blur(25px) saturate(180%)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.3)',
overflow: 'hidden',
padding: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
marginBottom: '16px'
}}>
{/* Title Row */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '16px',
flexWrap: 'wrap',
gap: '12px'
}}>
<h2 style={{
color: 'var(--dark-text)',
fontSize: '24px',
fontWeight: 'bold',
margin: 0
}}>
Tickets
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-dark"
onClick={() => setShowCreateModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
CREATE NEW TICKET
</button>
<button
className="btn btn-dark"
onClick={() => setShowOverviewModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
QUICK OVERVIEW
</button>
<button
className="btn btn-green"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
style={{ whiteSpace: 'nowrap' }}
>
{showAdvancedFilters ? '▲ Hide Filters' : '▼ Show Filters'}
</button>
</div>
</div>
<div className="page">
<PageHeader
title="Tickets"
subtitle="Alle Arbeitsauftraege auf einen Blick"
actions={
<>
<Button variant="ghost" onClick={() => setShowOverviewModal(true)}><FaList /> Schnelluebersicht</Button>
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
</>
}
/>
{/* Main Search Bar */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
alignItems: 'center'
}}>
<input
type="text"
placeholder="WOID"
className="form-control"
style={{ margin: 0 }}
value={localFilters.woid || ''}
onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })}
/>
<input
type="text"
placeholder="Customer"
className="form-control"
style={{ margin: 0 }}
value={localFilters.customer || ''}
onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })}
/>
<input
type="text"
placeholder="User"
className="form-control"
style={{ margin: 0 }}
value={localFilters.userTopic || ''}
onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })}
/>
<button
className="btn btn-green"
onClick={handleApplyFilters}
style={{ margin: 0 }}
>
Apply!
</button>
</div>
{/* Advanced Filters - Collapsible */}
{showAdvancedFilters && (
<div style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid rgba(16, 185, 129, 0.2)'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
alignItems: 'center',
marginBottom: '16px'
}}>
<input
type="text"
placeholder="Created Date"
className="form-control"
style={{ margin: 0 }}
value={localFilters.createdDate || ''}
onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })}
/>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.type || ''}
onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}
>
<option value="">Type / Location</option>
<option>Home Office</option>
<option>Holidays</option>
<option>Trip</option>
<option>Supportrequest</option>
<option>Change Request</option>
<option>Maintenance</option>
<option>Project</option>
<option>Procurement</option>
<option>Emergency Call</option>
</select>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.system || ''}
onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}
>
<option value="">System</option>
<option>Client</option>
<option>Server</option>
<option>Network</option>
<option>EDI</option>
<option>TOS</option>
<option>Reports</option>
<option>n/a</option>
</select>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.priority || ''}
onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}
>
<option value="">Priority</option>
<option value="0">None</option>
<option value="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</option>
<option value="4">Critical</option>
</select>
</div>
{/* Quick Selection Buttons */}
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
marginBottom: '16px'
}}>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, type: 'Procurement' }))
setFilters(prev => ({ ...prev, type: ['Procurement'] }))
setTimeout(() => refresh(), 0)
}}
>
Procurements
</button>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, priority: '4' }))
setFilters(prev => ({ ...prev, priority: [4] }))
setTimeout(() => refresh(), 0)
}}
>
Criticals
</button>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, priority: '3' }))
setFilters(prev => ({ ...prev, priority: [3] }))
setTimeout(() => refresh(), 0)
}}
>
Highs
</button>
<div style={{
width: '1px',
height: '32px',
background: 'rgba(16, 185, 129, 0.3)',
margin: '0 8px'
}}></div>
<button
className="btn btn-green"
onClick={() => {
setLimit(10)
setFilters(prev => ({ ...prev, limit: 10 }))
setTimeout(() => refresh(), 0)
}}
>
10
</button>
<button
className="btn btn-green"
onClick={() => {
setLimit(25)
setFilters(prev => ({ ...prev, limit: 25 }))
setTimeout(() => refresh(), 0)
}}
>
25
</button>
</div>
{/* Slider */}
<div style={{
display: 'flex',
gap: '16px',
alignItems: 'center'
}}>
<span style={{ color: 'var(--dark-text)', minWidth: '120px' }}>
Load Limit: {limit}
</span>
<div style={{ flex: '1' }}>
<input
type="range"
min="5"
max="50"
value={limit}
className="slider"
onChange={handleLimitChange}
/>
</div>
</div>
</div>
)}
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
<div className="toolbar" style={{ marginBottom: showAdvanced ? 16 : 0 }}>
<input className="form-control" style={{ maxWidth: 160 }} placeholder="WOID" value={localFilters.woid} onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })} />
<input className="form-control" style={{ maxWidth: 220 }} placeholder="Kunde" value={localFilters.customer} onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })} />
<input className="form-control search-input" placeholder="Benutzer / Thema" value={localFilters.userTopic} onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })} />
<Button variant="primary" onClick={applyFilters}>Anwenden</Button>
</div>
</div>
<table className="table table-hover">
<tbody>
{loading ? (
<tr>
<td colSpan={10} className="text-center p-2">
<FaSpinner className="spinner" size={32} />
<p>Loading...</p>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={10} className="text-center p-2 text-red">
Error: {error}
</td>
</tr>
) : workorders.length === 0 ? (
<tr>
<td colSpan={10} className="text-center p-2">
No tickets found matching your filters.
</td>
</tr>
) : (
workorders.map(ticket => (
<TicketRow
key={ticket.$id}
ticket={ticket}
onUpdate={handleUpdate}
/>
))
)}
</tbody>
</table>
{showAdvanced && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
<input type="date" className="form-control" value={localFilters.createdDate} onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })} />
<select className="form-control" value={localFilters.type} onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}>
<option value="">Typ / Ort</option>
<option>Supportrequest</option><option>Change Request</option><option>Maintenance</option>
<option>Project</option><option>Procurement</option><option>Emergency Call</option>
<option>Home Office</option><option>Holidays</option><option>Trip</option>
</select>
<select className="form-control" value={localFilters.system} onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}>
<option value="">System</option>
<option>Client</option><option>Server</option><option>Network</option>
<option>EDI</option><option>TOS</option><option>Reports</option><option>n/a</option>
</select>
<select className="form-control" value={localFilters.priority} onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}>
<option value="">Prioritaet</option>
<option value="0">Keine</option><option value="1">Niedrig</option><option value="2">Mittel</option>
<option value="3">Hoch</option><option value="4">Kritisch</option>
</select>
</div>
<div className="flex gap-2 wrap" style={{ marginTop: 12 }}>
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [4] })); setTimeout(refresh, 0) }}>Kritische</Button>
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [3] })); setTimeout(refresh, 0) }}>Hohe</Button>
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, type: ['Procurement'] })); setTimeout(refresh, 0) }}>Beschaffung</Button>
</div>
</div>
)}
</Card>
{workorders.length > 0 && workorders.length >= limit && (
<div style={{ textAlign: 'center', marginTop: '24px' }}>
<button
className="btn btn-green"
style={{
padding: '16px 32px',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 auto'
}}
onClick={handleLoadMore}
>
Load More <FaAngleDown size={20} />
</button>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<Card><div className="text-red">Fehler: {error}</div></Card>
) : workorders.length === 0 ? (
<Card><EmptyState title="Keine Tickets" hint="Keine Tickets passen zu deinen Filtern." action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>} /></Card>
) : (
<div className="tickets-list">
{workorders.map((ticket) => (
<TicketCard key={ticket.$id} ticket={ticket} onUpdate={handleUpdate} />
))}
</div>
)}
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.2)',
padding: '16px',
marginTop: '24px',
textAlign: 'center',
color: '#a0aec0'
}}>
<p style={{ margin: 0 }}>
Summary: Listed a total of <span style={{
color: 'var(--green-primary)',
fontWeight: 'bold'
}}>{workorders.length}</span> Workorders.
<br />EOL =)
</p>
</div>
{workorders.length > 0 && workorders.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
</div>
)}
<CreateTicketModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onCreate={handleCreate}
customers={customers}
/>
<QuickOverviewModal
isOpen={showOverviewModal}
onClose={() => setShowOverviewModal(false)}
workorders={workorders}
/>
<CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
<QuickOverviewModal isOpen={showOverviewModal} onClose={() => setShowOverviewModal(false)} workorders={workorders} />
</div>
)
}