+
+
+
- >
- )
- }
-
- return (
- <>
- {/* PixelBlast Background für Haupt-App */}
-
-
-
- >
+
)
}
diff --git a/src/components/DocumentsPanel.jsx b/src/components/DocumentsPanel.jsx
index e66d242..0892781 100644
--- a/src/components/DocumentsPanel.jsx
+++ b/src/components/DocumentsPanel.jsx
@@ -10,13 +10,13 @@ const card = {
border: '1px solid rgba(16, 185, 129, 0.2)',
}
-export default function DocumentsPanel({ ticket }) {
+export default function DocumentsPanel({ ticket, initialScope = 'ticket' }) {
const [docs, setDocs] = useState([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [query, setQuery] = useState('')
- const [scope, setScope] = useState('ticket') // 'ticket' | 'customer'
+ const [scope, setScope] = useState(initialScope) // 'ticket' | 'customer'
const [showUpload, setShowUpload] = useState(false)
const [busyId, setBusyId] = useState(null)
diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx
index a2163eb..f3925d3 100644
--- a/src/components/Footer.jsx
+++ b/src/components/Footer.jsx
@@ -1,12 +1,8 @@
export default function Footer() {
return (
)
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index 582c72e..310a0e5 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -1,174 +1,122 @@
import { useState } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
-import { motion } from 'motion/react'
-import {
- IconTicket,
- IconDeviceDesktop,
- IconChartBar,
- IconCalendar,
- IconFolders,
- IconFileText,
- IconBook,
+import {
+ IconTicket,
+ IconUsers,
+ IconReportMoney,
+ IconFiles,
+ IconFolders,
+ IconCalendar,
+ IconLayoutDashboard,
+ IconReport,
+ IconDeviceDesktop,
IconSettings,
- IconLogout
+ IconBook,
+ IconLogout,
+ IconLayoutSidebarLeftCollapse,
+ IconLayoutSidebarLeftExpand,
} from '@tabler/icons-react'
-import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
-export default function Navbar() {
+export default function Sidebar() {
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
- const [open, setOpen] = useState(false)
+ const location = useLocation()
+ const [collapsed, setCollapsed] = useState(false)
const handleLogout = async () => {
await logout()
navigate('/login')
}
- const links = [
+ const groups = [
{
- label: 'Tickets',
- href: '/tickets',
- icon:
,
+ label: 'Betrieb',
+ links: [
+ { label: 'Tickets', href: '/tickets', icon:
},
+ { label: 'Planboard', href: '/planboard', icon:
},
+ { label: 'Projekte', href: '/projects', icon:
},
+ ],
},
{
- label: 'Assets',
- href: '/assets',
- icon:
,
+ label: 'CRM',
+ links: [{ label: 'Kunden', href: '/customers', icon:
}],
},
{
- label: 'Dashboard',
- href: '/dashboard',
- icon:
,
+ label: 'Finanzen',
+ links: [{ label: 'Finanzen', href: '/finance', icon:
}],
},
{
- label: 'Planboard',
- href: '/planboard',
- icon:
,
+ label: 'Dokumente',
+ links: [{ label: 'Dokumente', href: '/documents', icon:
}],
},
{
- label: 'Projects',
- href: '/projects',
- icon:
,
+ label: 'Auswertung',
+ links: [
+ { label: 'Dashboard', href: '/dashboard', icon:
},
+ { label: 'Reports', href: '/reports', icon:
},
+ ],
},
{
- label: 'Reports',
- href: '/reports',
- icon:
,
- },
- {
- label: 'Docs',
- href: '/docs',
- icon:
,
+ label: 'System',
+ links: [
+ { label: 'Assets', href: '/assets', icon:
},
+ ...(isAdmin ? [{ label: 'Admin', href: '/admin', icon:
}] : []),
+ { label: 'Docs', href: '/docs', icon:
},
+ ],
},
]
- // Admin link nur wenn isAdmin
- if (isAdmin) {
- links.push({
- label: 'Admin',
- href: '/admin',
- icon:
,
- })
- }
+ const isActive = (href) =>
+ location.pathname === href || location.pathname.startsWith(href + '/')
return (
-
-
-
-
-
- {links.map((link, idx) => (
-
+
-
- {user ? (
- <>
-
- {(user.name || user.email).charAt(0).toUpperCase()}
-
- ),
- }}
- />
-
- ,
- }}
- />
-
- >
- ) : (
-
,
- }}
- />
- )}
-
-
-
- )
-}
+ ))}
+
-const Logo = () => {
- return (
-
-
-
- Webklar
-
-
+
+ {user && (
+
+
+ {(user.name || user.email || '?').charAt(0).toUpperCase()}
+
+
{user.name || user.email}
+
+ )}
+
+
+
+
)
}
diff --git a/src/components/TicketCard.jsx b/src/components/TicketCard.jsx
new file mode 100644
index 0000000..0425b94
--- /dev/null
+++ b/src/components/TicketCard.jsx
@@ -0,0 +1,152 @@
+import { useState } from 'react'
+import { FaChevronDown, FaPlay, FaStop, FaPlus, FaClockRotateLeft, FaPen } from 'react-icons/fa6'
+import { formatDistanceToNow, format } from 'date-fns'
+import { de } from 'date-fns/locale'
+import StatusDropdown from './StatusDropdown'
+import PriorityDropdown from './PriorityDropdown'
+import EditorDropdown from './EditorDropdown'
+import ResponseDropdown from './ResponseDropdown'
+import CreateWorksheetModal from './CreateWorksheetModal'
+import StatusHistoryModal from './StatusHistoryModal'
+import WorksheetList from './WorksheetList'
+import WorksheetStats from './WorksheetStats'
+import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
+import TicketProjectsModal from './TicketProjectsModal'
+import InvoicePanel from './InvoicePanel'
+import DocumentsPanel from './DocumentsPanel'
+import Tabs from './ui/Tabs'
+import Badge from './ui/Badge'
+import { StatusPill, PriorityPill } from './ui/StatusPill'
+import { useWorksheets } from '../hooks/useWorksheets'
+
+const TABS = [
+ { id: 'details', label: 'Details' },
+ { id: 'worksheets', label: 'Arbeitsblaetter' },
+ { id: 'invoices', label: 'Rechnungen' },
+ { id: 'documents', label: 'Dokumente' },
+ { id: 'project', label: 'Projekt / Preview' },
+]
+
+export default function TicketCard({ ticket, onUpdate }) {
+ const [expanded, setExpanded] = useState(false)
+ const [activeTab, setActiveTab] = useState('details')
+ const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
+ const [showHistoryModal, setShowHistoryModal] = useState(false)
+ const [showProjectsModal, setShowProjectsModal] = useState(false)
+ const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
+
+ const { worksheets, loading: worksheetsLoading, createWorksheet, getTotalTime } = useWorksheets(
+ expanded ? ticket.woid : null
+ )
+
+ const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
+ const elapsed = formatDistanceToNow(createdAt, { locale: de, addSuffix: true })
+
+ const stop = (e) => e.stopPropagation()
+
+ const handleCreateWorksheet = async (worksheetData, currentUser) => {
+ const result = await createWorksheet(worksheetData, currentUser)
+ if (result.success && worksheetData.newStatus !== ticket.status) {
+ await onUpdate(ticket.$id, {
+ status: worksheetData.newStatus,
+ responseLevel: worksheetData.newResponseLevel || ticket.responseLevel,
+ })
+ }
+ return result
+ }
+
+ return (
+
+
setExpanded((e) => !e)}>
+
+ {ticket.woid || ticket.$id?.slice(-5)}
+ {elapsed}
+
+
+
+
+ {ticket.customerName || 'Unbekannt'}
+ {ticket.customerLocation && {ticket.customerLocation}}
+
+
{ticket.topic || ticket.title || 'Kein Betreff'}
+
+
+
+ {ticket.type &&
{ticket.type}}
+ {ticket.systemType && ticket.systemType !== 'n/a' &&
{ticket.systemType}}
+ {ticket.serviceType &&
{ticket.serviceType}}
+
+
+
+
+
+ onUpdate(ticket.$id, { status: v })} />
+
+
+
onUpdate(ticket.$id, { priority: v })} />
+
+
setExpanded((e) => !e)} style={{ cursor: 'pointer' }} />
+
+
+
+ {expanded && (
+
+
+
{ticket.startDate || format(createdAt, 'dd.MM.yyyy')}
+
{ticket.deadline || '-'}
+ {ticket.requestedBy &&
Angefragt von: {ticket.requestedBy}}
+
+
onUpdate(ticket.$id, { assignedTo: v })} />
+
onUpdate(ticket.$id, { responseLevel: v })} />
+
+
+
+
+
+ {activeTab === 'details' && (
+
+
Ticket-Beschreibung
+
+ {ticket.details || 'Keine Details vorhanden.'}
+
+
+ )}
+
+ {activeTab === 'worksheets' && (
+
+
+
+
+
+
+ {worksheets.length > 0 && (
+
+
+
+ )}
+
+
+ )}
+
+ {activeTab === 'invoices' &&
}
+ {activeTab === 'documents' &&
}
+ {activeTab === 'project' && (
+
+
+
+
+ )}
+
+
+ )}
+
+
setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} />
+ setShowHistoryModal(false)} worksheets={worksheets} ticket={ticket} />
+ setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
+
+ )
+}
diff --git a/src/components/ui/Badge.jsx b/src/components/ui/Badge.jsx
new file mode 100644
index 0000000..dfbd866
--- /dev/null
+++ b/src/components/ui/Badge.jsx
@@ -0,0 +1,8 @@
+export default function Badge({ tone = 'muted', dot = false, children, className = '', style }) {
+ return (
+
+ {dot && }
+ {children}
+
+ )
+}
diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx
new file mode 100644
index 0000000..51482cc
--- /dev/null
+++ b/src/components/ui/Button.jsx
@@ -0,0 +1,23 @@
+const VARIANTS = {
+ default: 'ui-btn',
+ primary: 'ui-btn ui-btn-primary',
+ ghost: 'ui-btn ui-btn-ghost',
+ danger: 'ui-btn ui-btn-danger',
+}
+
+export default function Button({
+ variant = 'default',
+ size,
+ as = 'button',
+ className = '',
+ children,
+ ...props
+}) {
+ const cls = `${VARIANTS[variant] || VARIANTS.default}${size === 'sm' ? ' ui-btn-sm' : ''} ${className}`
+ const Comp = as
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ui/Card.jsx b/src/components/ui/Card.jsx
new file mode 100644
index 0000000..8abfb62
--- /dev/null
+++ b/src/components/ui/Card.jsx
@@ -0,0 +1,18 @@
+export default function Card({ title, actions, children, pad = true, className = '', bodyStyle, style }) {
+ if (!title && !actions) {
+ return (
+
+ {children}
+
+ )
+ }
+ return (
+
+
+
{title}
+ {actions &&
{actions}
}
+
+
{children}
+
+ )
+}
diff --git a/src/components/ui/EmptyState.jsx b/src/components/ui/EmptyState.jsx
new file mode 100644
index 0000000..bbdc627
--- /dev/null
+++ b/src/components/ui/EmptyState.jsx
@@ -0,0 +1,10 @@
+export default function EmptyState({ icon, title, hint, action }) {
+ return (
+
+ {icon &&
{icon}
}
+
{title}
+ {hint &&
{hint}
}
+ {action &&
{action}
}
+
+ )
+}
diff --git a/src/components/ui/Field.jsx b/src/components/ui/Field.jsx
new file mode 100644
index 0000000..036dc58
--- /dev/null
+++ b/src/components/ui/Field.jsx
@@ -0,0 +1,9 @@
+export default function Field({ label, children, hint }) {
+ return (
+
+ {label &&
}
+ {children}
+ {hint &&
{hint}
}
+
+ )
+}
diff --git a/src/components/ui/Kpi.jsx b/src/components/ui/Kpi.jsx
new file mode 100644
index 0000000..9391f41
--- /dev/null
+++ b/src/components/ui/Kpi.jsx
@@ -0,0 +1,10 @@
+export default function Kpi({ label, value, sub, icon, accent = 'var(--accent)' }) {
+ return (
+
+
+
{icon}{label}
+
{value}
+ {sub &&
{sub}
}
+
+ )
+}
diff --git a/src/components/ui/PageHeader.jsx b/src/components/ui/PageHeader.jsx
new file mode 100644
index 0000000..50c98c0
--- /dev/null
+++ b/src/components/ui/PageHeader.jsx
@@ -0,0 +1,12 @@
+export default function PageHeader({ title, subtitle, actions, children }) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+ {children}
+
+ {actions &&
{actions}
}
+
+ )
+}
diff --git a/src/components/ui/StatusPill.jsx b/src/components/ui/StatusPill.jsx
new file mode 100644
index 0000000..edf34f3
--- /dev/null
+++ b/src/components/ui/StatusPill.jsx
@@ -0,0 +1,36 @@
+const STATUS_CLASS = {
+ Open: 'status-open',
+ Occupied: 'status-occupied',
+ Assigned: 'status-assigned',
+ Awaiting: 'status-awaiting',
+ 'Added Info': 'status-awaiting',
+ Closed: 'status-closed',
+}
+
+const PRIORITY = {
+ 0: { cls: 'priority-none', label: 'Keine' },
+ 1: { cls: 'priority-low', label: 'Niedrig' },
+ 2: { cls: 'priority-medium', label: 'Mittel' },
+ 3: { cls: 'priority-high', label: 'Hoch' },
+ 4: { cls: 'priority-critical', label: 'Kritisch' },
+}
+
+const pillBase = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 6,
+ padding: '3px 10px',
+ borderRadius: 999,
+ fontSize: 12,
+ fontWeight: 700,
+}
+
+export function StatusPill({ status }) {
+ const cls = STATUS_CLASS[status] || 'status-open'
+ return
{status || 'Open'}
+}
+
+export function PriorityPill({ priority }) {
+ const p = PRIORITY[priority] ?? PRIORITY[1]
+ return
{p.label}
+}
diff --git a/src/components/ui/Tabs.jsx b/src/components/ui/Tabs.jsx
new file mode 100644
index 0000000..2fc5793
--- /dev/null
+++ b/src/components/ui/Tabs.jsx
@@ -0,0 +1,16 @@
+export default function Tabs({ tabs, active, onChange }) {
+ return (
+
+ {tabs.map((t) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/index.js b/src/components/ui/index.js
new file mode 100644
index 0000000..9debda1
--- /dev/null
+++ b/src/components/ui/index.js
@@ -0,0 +1,9 @@
+export { default as PageHeader } from './PageHeader'
+export { default as Card } from './Card'
+export { default as Button } from './Button'
+export { default as Badge } from './Badge'
+export { StatusPill, PriorityPill } from './StatusPill'
+export { default as Tabs } from './Tabs'
+export { default as EmptyState } from './EmptyState'
+export { default as Field } from './Field'
+export { default as Kpi } from './Kpi'
diff --git a/src/hooks/useFinanceSummary.js b/src/hooks/useFinanceSummary.js
new file mode 100644
index 0000000..f53f6cf
--- /dev/null
+++ b/src/hooks/useFinanceSummary.js
@@ -0,0 +1,24 @@
+import { useState, useEffect, useCallback } from 'react'
+import { integrationsApi } from '../lib/integrationsApi'
+
+export function useFinanceSummary() {
+ const [summary, setSummary] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ setSummary(await integrationsApi.invoicesSummary())
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ return { summary, loading, error, refresh: load }
+}
diff --git a/src/lib/integrationsApi.js b/src/lib/integrationsApi.js
index 1dc410c..2192de7 100644
--- a/src/lib/integrationsApi.js
+++ b/src/lib/integrationsApi.js
@@ -56,7 +56,8 @@ export const integrationsApi = {
createClient: (payload) => call('/clients', { method: 'POST', body: payload }),
// ---- InvoiceNinja: invoices ----
- listInvoices: (clientId) => call(`/invoices${qs({ clientId })}`),
+ listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
+ invoicesSummary: () => call('/invoices/summary'),
createInvoice: (payload) => call('/invoices', { method: 'POST', body: payload }),
async openInvoicePdf(id) {
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
@@ -74,6 +75,9 @@ export const integrationsApi = {
},
}
+// Oeffentliche InvoiceNinja-URL (fuer Deeplinks ins Portal)
+export const INVOICE_NINJA_PUBLIC_URL = 'https://invoice.webklar.com'
+
// InvoiceNinja status_id -> deutsche Bezeichnung + Farbe
export const INVOICE_STATUS = {
1: { label: 'Entwurf', color: '#a0aec0' },
diff --git a/src/pages/AssetsPage.jsx b/src/pages/AssetsPage.jsx
index bab87e8..9b2902e 100644
--- a/src/pages/AssetsPage.jsx
+++ b/src/pages/AssetsPage.jsx
@@ -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 (
-
-
+
+
-
- setSearchTerm(e.target.value)}
- className="input"
- style={{ width: '100%', maxWidth: '400px' }}
- />
-
+
setSearchTerm(e.target.value)}
+ style={{ maxWidth: 400, marginBottom: 16 }}
+ />
-
+
{assetCategories.map(({ icon: Icon, name, count }) => (
-
-
-
{name}
-
{count}
+
))}
-
-
Asset management module - Connect to Appwrite to manage your IT assets.
-
+
Asset-Modul - mit Appwrite verbinden, um IT-Assets zu verwalten.
)
}
diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx
new file mode 100644
index 0000000..e6e25d7
--- /dev/null
+++ b/src/pages/CustomerDetailPage.jsx
@@ -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
+ if (error || !customer) return
Zurueck} />
+
+ const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
+
+ return (
+
+
+ {customer.importantCustomer && }
+ {customer.name}
+
+ }
+ subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' ')}
+ actions={}
+ />
+
+
+
+ {tab === 'overview' && }
+ {tab === 'tickets' && }
+ {tab === 'invoices' && }
+ {tab === 'documents' && }
+ {tab === 'projects' && }
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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
+ if (tickets.length === 0) return
+
+ return (
+
+
+ {tickets.map((t) => (
+
+
{t.woid || t.$id.slice(-5)}
+
+
{t.topic || t.title || '-'}
+
{t.type} {t.systemType || 'n/a'}
+
+
+
+
+ ))}
+
+
+ )
+}
+
+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
+ if (projects.length === 0) return
+
+ return (
+
+ {projects.map((p) => )}
+
+ )
+}
diff --git a/src/pages/CustomersPage.jsx b/src/pages/CustomersPage.jsx
new file mode 100644
index 0000000..d3cba96
--- /dev/null
+++ b/src/pages/CustomersPage.jsx
@@ -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 (
+
+
setShowCreate((s) => !s)}> Neuer Kunde}
+ />
+
+ {showCreate && (
+
+ setShowCreate(false)}
+ onDone={async (data) => {
+ const r = await createCustomer(data)
+ if (r.success) { setShowCreate(false); refresh() }
+ return r
+ }}
+ />
+
+ )}
+
+
+
+
+
+
+
+
setSearch(e.target.value)} style={{ maxWidth: 320 }} />
+
+
+ {loading ? (
+
+ ) : filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((c) => (
+
+
+ {(c.name || '?').charAt(0).toUpperCase()}
+
+
+
+ {c.importantCustomer && }
+ {c.name}
+ {c.code && {c.code}}
+
+
{[c.location, c.email].filter(Boolean).join(' ') || '-'}
+
+
+ {c.invoiceNinjaClientId && Rechnungen}
+ {c.paperlessCorrespondent && Dokumente}
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx
index f44caf8..626d83c 100644
--- a/src/pages/DashboardPage.jsx
+++ b/src/pages/DashboardPage.jsx
@@ -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 }) => (
-
-
-
-
-
- {loading ? '...' : value}
- {label}
-
-
- )
-
return (
-
-
+
+
+
+
+ >
+ }
+ />
-
-
-
-
-
+
+ Tickets gesamt>} value={loading ? '...' : stats.total} accent="var(--info)" />
+ Offen>} value={loading ? '...' : stats.open} accent="var(--ok)" />
+ Wartend>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
+ Geschlossen>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
-
-
Quick Actions
-
+
+
+
+
+
+
+
+
+
+ {loading ? (
+ ...
+ ) : recent.length === 0 ? (
+
+ ) : (
+
+ {recent.map((t) => (
+
+
{t.woid || t.$id.slice(-5)}
+
+
{t.topic || t.title || '-'}
+
{t.customerName || '-'}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
)
diff --git a/src/pages/DocsPage.jsx b/src/pages/DocsPage.jsx
index 0c330ef..87700db 100644
--- a/src/pages/DocsPage.jsx
+++ b/src/pages/DocsPage.jsx
@@ -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 (
-
-
-
-
+
+
+
{sections.map(({ icon: Icon, title, content }) => (
-
-
-
{title}
-
{content}
-
+
+
+
+ {title}
+
+ {content}
+
))}
-
-
-
Keyboard Shortcuts
-
-
-
- | Key |
- Action |
-
-
-
- {shortcuts.map(({ key, action }) => (
-
- | {key} |
- {action} |
-
- ))}
-
-
-
)
}
diff --git a/src/pages/DocumentsPage.jsx b/src/pages/DocumentsPage.jsx
new file mode 100644
index 0000000..33d274d
--- /dev/null
+++ b/src/pages/DocumentsPage.jsx
@@ -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 (
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+
+ setQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && load()} />
+
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
{error}
+ ) : docs.length === 0 ? (
+
} title="Keine Dokumente" hint="Keine Dokumente gefunden." />
+ ) : (
+ <>
+
{count} Dokument(e)
+
+ {docs.map((d) => (
+
+
+
+
{d.title}
+
{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}
+
+
+
+
+ ))}
+
+ >
+ )}
+
+
+
+ setShowUpload(false)}
+ workorderId=""
+ ticket={correspondent ? { customerName: correspondent, woid: '' } : { customerName: '', woid: '' }}
+ onUploadComplete={() => setTimeout(load, 1500)}
+ />
+
+ )
+}
diff --git a/src/pages/FinancePage.jsx b/src/pages/FinancePage.jsx
new file mode 100644
index 0000000..76f5f01
--- /dev/null
+++ b/src/pages/FinancePage.jsx
@@ -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 (
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+
+
+
+
+ {showCreate && (
+
+ { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
+
+ )}
+
+
+
+
+ {[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['paid', 'Bezahlt']].map(([id, label]) => (
+
+ ))}
+
+
setSearch(e.target.value)} style={{ maxWidth: 280 }} />
+
+
+ {loading ? (
+
+ ) : error ? (
+
{error}
+ ) : filtered.length === 0 ? (
+
+ ) : (
+
+
+
+ | Nr. | Kunde | Datum | Faellig | Betrag | Offen | Status | |
+
+
+ {filtered.map((inv) => {
+ const st = INVOICE_STATUS[inv.status_id] || { label: '', color: 'var(--text-muted)' }
+ const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
+ return (
+
+ | {inv.number || inv.id?.slice(-6)} |
+ {inv.client_name || '-'} |
+ {inv.date || '-'} |
+ {inv.due_date || '-'} |
+ {formatMoney(inv.amount)} |
+ 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)} |
+ {st.label}{overdue ? ' (ueberf.)' : ''} |
+
+
+ |
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ )
+}
+
+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 (
+
+ {linkable.length === 0 ? (
+ Kein Kunde ist mit InvoiceNinja verknuepft. Bitte zuerst im Kunden-Bereich verknuepfen.
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/pages/PlanboardPage.jsx b/src/pages/PlanboardPage.jsx
index 0a213cd..f844110 100644
--- a/src/pages/PlanboardPage.jsx
+++ b/src/pages/PlanboardPage.jsx
@@ -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 (
-
-
-
-
-
-
- {format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
-
-
-
-
-
- {weekDays.map(day => (
-
-
- {format(day, 'EEEE')}
- {format(day, 'dd.MM')}
-
-
+
+
+
+
+ {format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
+
+
- ))}
-
+ }
+ />
-
-
Drag and drop tickets to schedule them on the planboard.
+
+ {weekDays.map((day) => (
+
+
+ {format(day, 'EEEE', { locale: de })}
+ {format(day, 'dd.MM')}
+
+
+
+ ))}
)
diff --git a/src/pages/ReportsPage.jsx b/src/pages/ReportsPage.jsx
index b1c9b9d..d2c87dc 100644
--- a/src/pages/ReportsPage.jsx
+++ b/src/pages/ReportsPage.jsx
@@ -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 (
-
-
+
+
-
-
-