diff --git a/src/App.jsx b/src/App.jsx index 11deffa..d8be347 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' -import Navbar from './components/Navbar' +import Sidebar from './components/Navbar' import Footer from './components/Footer' -import PixelBlast from './components/PixelBlast' import LoginPage from './pages/LoginPage' import TicketsPage from './pages/TicketsPage' import DashboardPage from './pages/DashboardPage' @@ -12,34 +11,42 @@ import ProjectsPage from './pages/ProjectsPage' import ReportsPage from './pages/ReportsPage' import DocsPage from './pages/DocsPage' import AdminPage from './pages/AdminPage' +import CustomersPage from './pages/CustomersPage' +import CustomerDetailPage from './pages/CustomerDetailPage' +import FinancePage from './pages/FinancePage' +import DocumentsPage from './pages/DocumentsPage' function ProtectedRoute({ children }) { const { user, loading } = useAuth() - + if (loading) { return ( -
+

Loading...

) } - + if (!user) { return } - + return children } function AppRoutes() { const { user } = useAuth() - + return ( : } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -53,74 +60,21 @@ function AppRoutes() { function AppContent() { const { user } = useAuth() - + if (!user) { - return ( - <> - {/* PixelBlast Background für Login-Seite */} -
- -
-
+ return + } + + return ( +
+ +
+
- - ) - } - - 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 (
-

- WOMS 2.0 - Work Order Management System | - Powered by Appwrite & React -

-

- © {new Date().getFullYear()} - All rights reserved +

+ Webklar Ticket-Plattform · © {new Date().getFullYear()}

) 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 ( -
-
-

Assets Management

-
+
+ -
- 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} +
+ +
{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 ( +
+ +
+ setForm({ ...form, name: e.target.value })} /> + setForm({ ...form, code: e.target.value })} /> +
+ setForm({ ...form, location: e.target.value })} /> +
+ setForm({ ...form, email: e.target.value })} /> + setForm({ ...form, phone: e.target.value })} /> +
+