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:
96
src/App.jsx
96
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 (
|
||||
<div className="text-center p-4">
|
||||
<div className="text-center p-4" style={{ marginTop: 80 }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
|
||||
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
|
||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||
@@ -53,74 +60,21 @@ function AppRoutes() {
|
||||
|
||||
function AppContent() {
|
||||
const { user } = useAuth()
|
||||
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
{/* PixelBlast Background für Login-Seite */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 0,
|
||||
background: '#1a202c'
|
||||
}}>
|
||||
<PixelBlast
|
||||
color="#10b981"
|
||||
variant="circle"
|
||||
pixelSize={4}
|
||||
patternScale={2}
|
||||
patternDensity={0.8}
|
||||
enableRipples={true}
|
||||
rippleIntensityScale={1.5}
|
||||
speed={0.3}
|
||||
edgeFade={0.3}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
return <AppRoutes />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar />
|
||||
<div className="app-main">
|
||||
<div className="app-scroll">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* PixelBlast Background für Haupt-App */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 0,
|
||||
background: '#1a202c'
|
||||
}}>
|
||||
<PixelBlast
|
||||
color="#10b981"
|
||||
variant="circle"
|
||||
pixelSize={4}
|
||||
patternScale={2}
|
||||
patternDensity={0.8}
|
||||
enableRipples={true}
|
||||
rippleIntensityScale={1.5}
|
||||
speed={0.3}
|
||||
edgeFade={0.3}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
<Navbar />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<AppRoutes />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<p>
|
||||
WOMS 2.0 - Work Order Management System |
|
||||
Powered by <a href="https://appwrite.io" target="_blank" rel="noopener noreferrer">Appwrite</a> & React
|
||||
</p>
|
||||
<p className="text-small text-grey">
|
||||
© {new Date().getFullYear()} - All rights reserved
|
||||
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
||||
Webklar Ticket-Plattform · © {new Date().getFullYear()}
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -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: <IconTicket className="icon" />,
|
||||
label: 'Betrieb',
|
||||
links: [
|
||||
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
|
||||
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
||||
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Assets',
|
||||
href: '/assets',
|
||||
icon: <IconDeviceDesktop className="icon" />,
|
||||
label: 'CRM',
|
||||
links: [{ label: 'Kunden', href: '/customers', icon: <IconUsers /> }],
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: <IconChartBar className="icon" />,
|
||||
label: 'Finanzen',
|
||||
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
|
||||
},
|
||||
{
|
||||
label: 'Planboard',
|
||||
href: '/planboard',
|
||||
icon: <IconCalendar className="icon" />,
|
||||
label: 'Dokumente',
|
||||
links: [{ label: 'Dokumente', href: '/documents', icon: <IconFiles /> }],
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
href: '/projects',
|
||||
icon: <IconFolders className="icon" />,
|
||||
label: 'Auswertung',
|
||||
links: [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
|
||||
{ label: 'Reports', href: '/reports', icon: <IconReport /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
href: '/reports',
|
||||
icon: <IconFileText className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: '/docs',
|
||||
icon: <IconBook className="icon" />,
|
||||
label: 'System',
|
||||
links: [
|
||||
{ label: 'Assets', href: '/assets', icon: <IconDeviceDesktop /> },
|
||||
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
|
||||
{ label: 'Docs', href: '/docs', icon: <IconBook /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Admin link nur wenn isAdmin
|
||||
if (isAdmin) {
|
||||
links.push({
|
||||
label: 'Admin',
|
||||
href: '/admin',
|
||||
icon: <IconSettings className="icon" />,
|
||||
})
|
||||
}
|
||||
const isActive = (href) =>
|
||||
location.pathname === href || location.pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<Sidebar open={open} setOpen={setOpen}>
|
||||
<SidebarBody className="justify-between gap-10">
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowX: 'hidden', overflowY: 'auto' }}>
|
||||
<Logo />
|
||||
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{links.map((link, idx) => (
|
||||
<SidebarLink key={idx} link={link} />
|
||||
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="side-head">
|
||||
<Link to="/" className="side-logo" title="Webklar">W</Link>
|
||||
<div className="side-brand">
|
||||
Webklar
|
||||
<small>Ticket-Plattform</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="side-nav">
|
||||
{groups.map((group) => (
|
||||
<div className="side-group" key={group.label}>
|
||||
<div className="side-group-label">{group.label}</div>
|
||||
{group.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
to={link.href}
|
||||
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
|
||||
title={link.label}
|
||||
>
|
||||
{link.icon}
|
||||
<span className="side-link-label">{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{user ? (
|
||||
<>
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: user.name || user.email,
|
||||
href: '#',
|
||||
icon: (
|
||||
<div style={{
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{(user.name || user.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div onClick={handleLogout} style={{ cursor: 'pointer', marginTop: '0.5rem' }}>
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: 'Logout',
|
||||
href: '#',
|
||||
icon: <IconLogout className="icon" />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: 'Login',
|
||||
href: '/login',
|
||||
icon: <IconLogout className="icon" />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarBody>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
))}
|
||||
</nav>
|
||||
|
||||
const Logo = () => {
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.25rem 0',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'normal',
|
||||
color: 'var(--text-color, #333)',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '20px',
|
||||
width: '24px',
|
||||
flexShrink: 0,
|
||||
borderRadius: '4px 2px 4px 2px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}} />
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'pre',
|
||||
color: 'var(--text-color, #333)'
|
||||
}}
|
||||
>
|
||||
Webklar
|
||||
</motion.span>
|
||||
</Link>
|
||||
<div className="side-foot">
|
||||
{user && (
|
||||
<div className="side-link" title={user.name || user.email} style={{ cursor: 'default' }}>
|
||||
<div className="side-logo" style={{ width: 28, height: 28, fontSize: 12, background: 'linear-gradient(135deg,#667eea,#764ba2)' }}>
|
||||
{(user.name || user.email || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="side-link-label text-ellipsis">{user.name || user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="side-toggle" onClick={handleLogout} title="Logout">
|
||||
<IconLogout size={20} />
|
||||
<span className="side-link-label">Logout</span>
|
||||
</button>
|
||||
<button className="side-toggle" onClick={() => setCollapsed((c) => !c)} title="Menue ein-/ausklappen">
|
||||
{collapsed ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
|
||||
<span className="side-link-label">Einklappen</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
152
src/components/TicketCard.jsx
Normal file
152
src/components/TicketCard.jsx
Normal file
@@ -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 (
|
||||
<div className={`tcard ${expanded ? 'open' : ''}`}>
|
||||
<div className="tcard-main" onClick={() => setExpanded((e) => !e)}>
|
||||
<div className="tcard-woid">
|
||||
<span className="id">{ticket.woid || ticket.$id?.slice(-5)}</span>
|
||||
<span className="age">{elapsed}</span>
|
||||
</div>
|
||||
|
||||
<div className="tcard-body">
|
||||
<div className="tcard-row1">
|
||||
<span className="tcard-customer">{ticket.customerName || 'Unbekannt'}</span>
|
||||
{ticket.customerLocation && <span className="tcard-loc"><EFBFBD> {ticket.customerLocation}</span>}
|
||||
</div>
|
||||
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
|
||||
<div className="tcard-badges">
|
||||
<StatusPill status={ticket.status} />
|
||||
<PriorityPill priority={ticket.priority} />
|
||||
{ticket.type && <Badge tone="muted">{ticket.type}</Badge>}
|
||||
{ticket.systemType && ticket.systemType !== 'n/a' && <Badge tone="muted">{ticket.systemType}</Badge>}
|
||||
{ticket.serviceType && <Badge tone="info">{ticket.serviceType}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tcard-side" onClick={stop}>
|
||||
<div style={{ minWidth: 120 }}>
|
||||
<StatusDropdown value={ticket.status} onChange={(v) => onUpdate(ticket.$id, { status: v })} />
|
||||
</div>
|
||||
<div style={{ minWidth: 110 }}>
|
||||
<PriorityDropdown value={ticket.priority} onChange={(v) => onUpdate(ticket.$id, { priority: v })} />
|
||||
</div>
|
||||
<FaChevronDown className={`chevron ${expanded ? 'up' : ''}`} onClick={() => setExpanded((e) => !e)} style={{ cursor: 'pointer' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="tcard-detail">
|
||||
<div className="flex gap-3 wrap" style={{ marginBottom: 16, fontSize: 13 }}>
|
||||
<span className="muted"><FaPlay style={{ color: 'var(--ok)' }} /> {ticket.startDate || format(createdAt, 'dd.MM.yyyy')}</span>
|
||||
<span className="muted"><FaStop style={{ color: 'var(--danger)' }} /> {ticket.deadline || '-'}</span>
|
||||
{ticket.requestedBy && <span className="muted">Angefragt von: {ticket.requestedBy}</span>}
|
||||
<span className="grow" />
|
||||
<div style={{ minWidth: 150 }}><EditorDropdown value={ticket.assignedTo} onChange={(v) => onUpdate(ticket.$id, { assignedTo: v })} /></div>
|
||||
<div style={{ minWidth: 150 }}><ResponseDropdown value={ticket.responseLevel} onChange={(v) => onUpdate(ticket.$id, { responseLevel: v })} /></div>
|
||||
</div>
|
||||
|
||||
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<div className="ticket-tab-content">
|
||||
{activeTab === 'details' && (
|
||||
<div className="ui-card pad">
|
||||
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
|
||||
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
|
||||
{ticket.details || 'Keine Details vorhanden.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'worksheets' && (
|
||||
<div>
|
||||
<div className="flex gap-2" style={{ marginBottom: 16 }}>
|
||||
<button className="ui-btn ui-btn-primary grow" onClick={() => setShowCreateWorksheet(true)}>
|
||||
<FaPlus /> Arbeitsblatt hinzufuegen
|
||||
</button>
|
||||
<button className="ui-btn" onClick={() => setShowProjectsModal(true)} title="Projekte"><FaPen /></button>
|
||||
<button className="ui-btn" onClick={() => setShowHistoryModal(true)} title="Verlauf"><FaClockRotateLeft /></button>
|
||||
</div>
|
||||
{worksheets.length > 0 && (
|
||||
<div className="ui-card pad" style={{ marginBottom: 16 }}>
|
||||
<WorksheetStats worksheets={worksheets} />
|
||||
</div>
|
||||
)}
|
||||
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||
{activeTab === 'project' && (
|
||||
<div>
|
||||
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
||||
<FaPen /> Projekte zuweisen / bearbeiten
|
||||
</button>
|
||||
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateWorksheetModal isOpen={showCreateWorksheet} onClose={() => setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} />
|
||||
<StatusHistoryModal isOpen={showHistoryModal} onClose={() => setShowHistoryModal(false)} worksheets={worksheets} ticket={ticket} />
|
||||
<TicketProjectsModal isOpen={showProjectsModal} onClose={() => setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
src/components/ui/Badge.jsx
Normal file
8
src/components/ui/Badge.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Badge({ tone = 'muted', dot = false, children, className = '', style }) {
|
||||
return (
|
||||
<span className={`badge badge-${tone} ${className}`} style={style}>
|
||||
{dot && <span className="dot" />}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
23
src/components/ui/Button.jsx
Normal file
23
src/components/ui/Button.jsx
Normal file
@@ -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 (
|
||||
<Comp className={cls} {...props}>
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
18
src/components/ui/Card.jsx
Normal file
18
src/components/ui/Card.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export default function Card({ title, actions, children, pad = true, className = '', bodyStyle, style }) {
|
||||
if (!title && !actions) {
|
||||
return (
|
||||
<div className={`ui-card ${pad ? 'pad' : ''} ${className}`} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={`ui-card ${className}`} style={style}>
|
||||
<div className="ui-card-head">
|
||||
<h3>{title}</h3>
|
||||
{actions && <div className="flex gap-2 items-center">{actions}</div>}
|
||||
</div>
|
||||
<div className="ui-card-body" style={bodyStyle}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/components/ui/EmptyState.jsx
Normal file
10
src/components/ui/EmptyState.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function EmptyState({ icon, title, hint, action }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
{icon && <div className="empty-icon">{icon}</div>}
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>{title}</div>
|
||||
{hint && <div className="faint" style={{ fontSize: 13 }}>{hint}</div>}
|
||||
{action && <div style={{ marginTop: 16 }}>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
src/components/ui/Field.jsx
Normal file
9
src/components/ui/Field.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function Field({ label, children, hint }) {
|
||||
return (
|
||||
<div className="field">
|
||||
{label && <label>{label}</label>}
|
||||
{children}
|
||||
{hint && <div className="faint" style={{ fontSize: 12 }}>{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/components/ui/Kpi.jsx
Normal file
10
src/components/ui/Kpi.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function Kpi({ label, value, sub, icon, accent = 'var(--accent)' }) {
|
||||
return (
|
||||
<div className="kpi">
|
||||
<span className="kpi-accent" style={{ background: accent }} />
|
||||
<div className="kpi-label">{icon}{label}</div>
|
||||
<div className="kpi-value">{value}</div>
|
||||
{sub && <div className="kpi-sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
src/components/ui/PageHeader.jsx
Normal file
12
src/components/ui/PageHeader.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function PageHeader({ title, subtitle, actions, children }) {
|
||||
return (
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{subtitle && <div className="page-subtitle">{subtitle}</div>}
|
||||
{children}
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/ui/StatusPill.jsx
Normal file
36
src/components/ui/StatusPill.jsx
Normal file
@@ -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 <span className={cls} style={pillBase}>{status || 'Open'}</span>
|
||||
}
|
||||
|
||||
export function PriorityPill({ priority }) {
|
||||
const p = PRIORITY[priority] ?? PRIORITY[1]
|
||||
return <span className={p.cls} style={pillBase}>{p.label}</span>
|
||||
}
|
||||
16
src/components/ui/Tabs.jsx
Normal file
16
src/components/ui/Tabs.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function Tabs({ tabs, active, onChange }) {
|
||||
return (
|
||||
<div className="ticket-tabs">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`ticket-tab ${active === t.id ? 'ticket-tab-active' : ''}`}
|
||||
onClick={() => onChange(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
src/components/ui/index.js
Normal file
9
src/components/ui/index.js
Normal file
@@ -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'
|
||||
24
src/hooks/useFinanceSummary.js
Normal file
24
src/hooks/useFinanceSummary.js
Normal file
@@ -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 }
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,21 +5,69 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--dark-bg: #1a202c;
|
||||
--dark-card-bg: rgba(45, 55, 72, 0.95);
|
||||
--dark-text: #e2e8f0;
|
||||
--green-primary: #10b981;
|
||||
--green-secondary: #059669;
|
||||
/* Surfaces (calm dark) */
|
||||
--bg: #0e131a;
|
||||
--surface-1: #151b23;
|
||||
--surface-2: #1b2430;
|
||||
--surface-3: #222d3a;
|
||||
--surface-hover: #243040;
|
||||
|
||||
/* Borders / lines */
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--border-accent: rgba(16, 185, 129, 0.35);
|
||||
|
||||
/* Text */
|
||||
--text: #e6edf3;
|
||||
--text-muted: #9aa7b4;
|
||||
--text-faint: #6b7785;
|
||||
|
||||
/* Accent */
|
||||
--accent: #10b981;
|
||||
--accent-2: #059669;
|
||||
--accent-soft: rgba(16, 185, 129, 0.14);
|
||||
|
||||
/* Semantic */
|
||||
--info: #3b82f6;
|
||||
--warn: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--ok: #10b981;
|
||||
|
||||
/* Spacing */
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
|
||||
--sp-4: 16px; --sp-5: 24px; --sp-6: 32px;
|
||||
|
||||
/* Radius */
|
||||
--r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-pill: 999px;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-1: 0 1px 2px rgba(0,0,0,0.3);
|
||||
--shadow-2: 0 4px 14px rgba(0,0,0,0.3);
|
||||
--shadow-3: 0 12px 32px rgba(0,0,0,0.4);
|
||||
|
||||
/* Legacy aliases (Rueckwaertskompatibilitaet) */
|
||||
--dark-bg: var(--bg);
|
||||
--dark-card-bg: var(--surface-2);
|
||||
--dark-text: var(--text);
|
||||
--green-primary: var(--accent);
|
||||
--green-secondary: var(--accent-2);
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1f2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
--sidebar-w: 248px;
|
||||
--sidebar-w-collapsed: 72px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
background-color: var(--dark-bg);
|
||||
color: var(--dark-text);
|
||||
font-family: 'Lato', system-ui, -apple-system, sans-serif;
|
||||
background: radial-gradient(1200px 700px at 80% -10%, rgba(16,185,129,0.06), transparent 60%),
|
||||
radial-gradient(900px 600px at -10% 110%, rgba(59,130,246,0.05), transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
@@ -74,8 +122,10 @@ body {
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-top: 64px;
|
||||
padding: 16px;
|
||||
padding: var(--sp-5);
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
@@ -432,19 +482,19 @@ textarea.form-control {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Status Colors */
|
||||
.status-open { background: #4CAF50 !important; color: #fff !important; }
|
||||
.status-occupied { background: #607D8B !important; color: #fff !important; }
|
||||
.status-assigned { background: #009688 !important; color: #fff !important; }
|
||||
.status-awaiting { background: #ff9800 !important; color: #fff !important; }
|
||||
.status-closed { background: #9e9e9e !important; color: #fff !important; }
|
||||
/* Status Colors (ruhiger, semantisch) */
|
||||
.status-open { background: rgba(16,185,129,0.18) !important; color: #34d399 !important; }
|
||||
.status-occupied { background: rgba(148,163,184,0.18) !important; color: #cbd5e0 !important; }
|
||||
.status-assigned { background: rgba(20,184,166,0.18) !important; color: #2dd4bf !important; }
|
||||
.status-awaiting { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
||||
.status-closed { background: rgba(148,163,184,0.14) !important; color: #94a3b8 !important; }
|
||||
|
||||
/* Priority Colors */
|
||||
.priority-none { background: #2196F3 !important; color: #fff !important; }
|
||||
.priority-low { background: #4CAF50 !important; color: #fff !important; }
|
||||
.priority-medium { background: #ffc107 !important; color: #000 !important; }
|
||||
.priority-high { background: #ff9800 !important; color: #fff !important; }
|
||||
.priority-critical { background: #f44336 !important; color: #fff !important; }
|
||||
/* Priority Colors (ruhiger, semantisch) */
|
||||
.priority-none { background: rgba(59,130,246,0.16) !important; color: #93c5fd !important; }
|
||||
.priority-low { background: rgba(16,185,129,0.16) !important; color: #34d399 !important; }
|
||||
.priority-medium { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
||||
.priority-high { background: rgba(249,115,22,0.18) !important; color: #fb923c !important; }
|
||||
.priority-critical { background: rgba(239,68,68,0.18) !important; color: #f87171 !important; }
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
@@ -828,3 +878,241 @@ textarea.form-control {
|
||||
.navbar-nav { display: none; }
|
||||
.ticket-tab { padding: 8px 12px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ============================================================= */
|
||||
/* =================== REDESIGN (Designsystem) =============== */
|
||||
/* ============================================================= */
|
||||
|
||||
/* --- App-Shell --- */
|
||||
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
||||
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-scroll { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* --- Sidebar (fix, Labels sichtbar, gruppiert) --- */
|
||||
.side {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, var(--surface-1), var(--bg));
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.side.collapsed { width: var(--sidebar-w-collapsed); }
|
||||
.side-head {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
min-height: 64px;
|
||||
}
|
||||
.side-logo {
|
||||
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 800;
|
||||
}
|
||||
.side-brand { font-weight: 800; font-size: 16px; letter-spacing: 0.2px; white-space: nowrap; }
|
||||
.side-brand small { display: block; font-weight: 500; font-size: 11px; color: var(--text-muted); }
|
||||
.side-nav { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-2); }
|
||||
.side-group { margin-bottom: var(--sp-4); }
|
||||
.side-group-label {
|
||||
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.7px;
|
||||
color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-2);
|
||||
}
|
||||
.side.collapsed .side-group-label,
|
||||
.side.collapsed .side-link-label,
|
||||
.side.collapsed .side-brand { display: none; }
|
||||
.side-link {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 10px var(--sp-3); margin: 2px var(--sp-1);
|
||||
border-radius: var(--r-sm); color: var(--text-muted);
|
||||
text-decoration: none; transition: all 0.15s ease; white-space: nowrap;
|
||||
}
|
||||
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
.side-link:hover { background: var(--surface-2); color: var(--text); }
|
||||
.side-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.side-link.active svg { color: var(--accent); }
|
||||
.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); }
|
||||
.side-toggle {
|
||||
background: transparent; border: none; color: var(--text-muted);
|
||||
cursor: pointer; padding: var(--sp-2); border-radius: var(--r-sm);
|
||||
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
||||
}
|
||||
.side-toggle:hover { background: var(--surface-2); color: var(--text); }
|
||||
|
||||
/* --- Page header + container --- */
|
||||
.page { padding: var(--sp-5); max-width: 1320px; margin: 0 auto; width: 100%; }
|
||||
.page-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap;
|
||||
}
|
||||
.page-title { font-size: 24px; font-weight: 800; margin: 0; }
|
||||
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
||||
.page-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
|
||||
/* --- UI Card / Section --- */
|
||||
.ui-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
.ui-card.pad { padding: var(--sp-4); }
|
||||
.ui-card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ui-card-head h3 { margin: 0; font-size: 16px; font-weight: 700; }
|
||||
.ui-card-body { padding: var(--sp-4); }
|
||||
|
||||
/* --- Buttons (Redesign-Varianten) --- */
|
||||
.ui-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 9px 16px; border-radius: var(--r-sm); border: 1px solid transparent;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
|
||||
background: var(--surface-2); color: var(--text); text-decoration: none;
|
||||
}
|
||||
.ui-btn:hover { background: var(--surface-hover); }
|
||||
.ui-btn-primary { background: var(--accent); color: #06231a; border-color: transparent; }
|
||||
.ui-btn-primary:hover { background: #34d399; }
|
||||
.ui-btn-ghost { background: transparent; border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.ui-btn-ghost:hover { background: var(--surface-2); color: var(--text); }
|
||||
.ui-btn-danger { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||
.ui-btn-danger:hover { background: rgba(239,68,68,0.25); }
|
||||
.ui-btn-sm { padding: 6px 10px; font-size: 13px; }
|
||||
.ui-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
/* --- Badges / Pills --- */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 3px 10px; border-radius: var(--r-pill);
|
||||
font-size: 12px; font-weight: 600; line-height: 1.4;
|
||||
background: var(--surface-2); color: var(--text-muted);
|
||||
}
|
||||
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.badge-info { background: rgba(59,130,246,0.16); color: #93c5fd; }
|
||||
.badge-ok { background: rgba(16,185,129,0.16); color: #34d399; }
|
||||
.badge-warn { background: rgba(245,158,11,0.18); color: #fbbf24; }
|
||||
.badge-danger { background: rgba(239,68,68,0.18); color: #f87171; }
|
||||
.badge-muted { background: var(--surface-2); color: var(--text-muted); }
|
||||
|
||||
/* --- KPI / Stat cards --- */
|
||||
.kpi-grid {
|
||||
display: grid; gap: var(--sp-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
.kpi {
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); padding: var(--sp-4);
|
||||
display: flex; flex-direction: column; gap: 6px; position: relative; overflow: hidden;
|
||||
}
|
||||
.kpi-accent { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
|
||||
.kpi-label { color: var(--text-muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.kpi-value { font-size: 28px; font-weight: 800; }
|
||||
.kpi-sub { font-size: 12px; color: var(--text-faint); }
|
||||
/* legacy dashboard stat classes -> new look */
|
||||
.stats-grid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); margin-bottom: var(--sp-5); }
|
||||
.stat-card { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); display: flex; align-items: center; gap: var(--sp-4); }
|
||||
.stat-info { display: flex; flex-direction: column; }
|
||||
.stat-value { font-size: 26px; font-weight: 800; }
|
||||
.stat-label { color: var(--text-muted); font-size: 13px; }
|
||||
.dashboard-section { margin-top: var(--sp-5); }
|
||||
.quick-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-3); }
|
||||
|
||||
/* --- Ticket Card (ersetzt Tabelle) --- */
|
||||
.tickets-list { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.tcard {
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); overflow: hidden; transition: border-color 0.15s ease;
|
||||
}
|
||||
.tcard:hover { border-color: var(--border-strong); }
|
||||
.tcard.open { border-color: var(--border-accent); }
|
||||
.tcard-main {
|
||||
display: grid; grid-template-columns: 92px 1fr auto; gap: var(--sp-4);
|
||||
padding: var(--sp-4); align-items: center; cursor: pointer;
|
||||
}
|
||||
.tcard-woid { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||
.tcard-woid .id { font-weight: 800; font-size: 15px; color: var(--accent); }
|
||||
.tcard-woid .age { font-size: 11px; color: var(--text-faint); }
|
||||
.tcard-body { min-width: 0; }
|
||||
.tcard-row1 { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.tcard-customer { font-weight: 700; }
|
||||
.tcard-loc { color: var(--text-muted); font-size: 13px; }
|
||||
.tcard-topic { color: var(--text-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tcard-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.tcard-side { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.tcard-detail { border-top: 1px solid var(--border); padding: var(--sp-4); background: var(--surface-1); }
|
||||
.chevron { color: var(--text-muted); transition: transform 0.2s ease; }
|
||||
.chevron.up { transform: rotate(180deg); }
|
||||
@media (max-width: 720px) {
|
||||
.tcard-main { grid-template-columns: 64px 1fr; }
|
||||
.tcard-side { grid-column: 1 / -1; justify-content: space-between; }
|
||||
}
|
||||
|
||||
/* --- Generic data list rows (Kunden/Finanzen/Dokumente) --- */
|
||||
.list { display: flex; flex-direction: column; }
|
||||
.list-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
transition: background 0.12s ease; text-decoration: none; color: var(--text);
|
||||
}
|
||||
.list-row:last-child { border-bottom: none; }
|
||||
.list-row:hover { background: var(--surface-2); }
|
||||
.list-row .grow { flex: 1; min-width: 0; }
|
||||
.list-row .muted { color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
/* --- Clean table (ohne rowSpan-Tricks) --- */
|
||||
.ui-table { width: 100%; border-collapse: collapse; }
|
||||
.ui-table th {
|
||||
text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px;
|
||||
color: var(--text-faint); font-weight: 600; padding: 10px var(--sp-3);
|
||||
border-bottom: 1px solid var(--border); background: transparent;
|
||||
}
|
||||
.ui-table td { padding: 11px var(--sp-3); border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.ui-table tbody tr:hover td { background: var(--surface-2); }
|
||||
|
||||
/* --- Segmented control / toolbar --- */
|
||||
.toolbar { display: flex; gap: var(--sp-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--sp-4); }
|
||||
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
|
||||
.seg button {
|
||||
border: none; background: transparent; color: var(--text-muted);
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.seg button.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.search-input { flex: 1; min-width: 200px; }
|
||||
|
||||
/* --- Empty state --- */
|
||||
.empty { text-align: center; color: var(--text-muted); padding: var(--sp-6) var(--sp-4); }
|
||||
.empty .empty-icon { font-size: 32px; opacity: 0.5; margin-bottom: var(--sp-3); }
|
||||
|
||||
/* --- Field --- */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--sp-3); }
|
||||
.field label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
/* --- Tab restyle (ruhiger) --- */
|
||||
.ticket-tabs { border-bottom-color: var(--border); }
|
||||
.ticket-tab { background: var(--surface-2); border-color: var(--border); color: var(--text-muted); }
|
||||
.ticket-tab:hover { background: var(--surface-hover); color: var(--text); }
|
||||
.ticket-tab-active { background: var(--accent); color: #06231a; }
|
||||
|
||||
/* --- Soften legacy cards/forms --- */
|
||||
.card { background: var(--surface-1) !important; border: 1px solid var(--border) !important; border-radius: var(--r-md); }
|
||||
.card-header { background: var(--surface-2); border-bottom: 1px solid var(--border); }
|
||||
.modern-card { background: var(--surface-1); border: 1px solid var(--border); }
|
||||
.modern-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-2); }
|
||||
.form-control, .form-select { background: var(--surface-2) !important; border: 1px solid var(--border) !important; border-radius: var(--r-sm); }
|
||||
.form-control:focus, .form-select:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-soft) !important; }
|
||||
.footer { background: var(--surface-1); border-top: 1px solid var(--border); padding: var(--sp-4); margin-top: 0; }
|
||||
|
||||
/* --- Utilities --- */
|
||||
.muted { color: var(--text-muted); }
|
||||
.faint { color: var(--text-faint); }
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: var(--sp-1); } .gap-2 { gap: var(--sp-2); } .gap-3 { gap: var(--sp-3); } .gap-4 { gap: var(--sp-4); }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
.wrap { flex-wrap: wrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
Reference in New Issue
Block a user