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