This commit is contained in:
Basilosaurusrex
2025-12-29 22:28:43 +01:00
parent 7fb446c53a
commit 0e19df6895
73 changed files with 7907 additions and 32290 deletions

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import Navbar 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'
@@ -10,6 +11,7 @@ import PlanboardPage from './pages/PlanboardPage'
import ProjectsPage from './pages/ProjectsPage'
import ReportsPage from './pages/ReportsPage'
import DocsPage from './pages/DocsPage'
import AdminPage from './pages/AdminPage'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
@@ -44,6 +46,7 @@ function AppRoutes() {
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
</Routes>
)
}
@@ -51,11 +54,71 @@ 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 }}>
<AppRoutes />
</div>
</>
)
}
return (
<>
{user && <Navbar />}
<AppRoutes />
{user && <Footer />}
{/* 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}
/>
</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>
</>
)
}

View File

@@ -1,28 +1,31 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useAdminConfig } from '../hooks/useAdminConfig'
import { useEmployees } from '../hooks/useEmployees'
const TICKET_TYPES = [
// Fallback-Werte falls Config nicht geladen werden kann
const DEFAULT_TICKET_TYPES = [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
]
const SYSTEMS = [
const DEFAULT_SYSTEMS = [
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
]
const RESPONSE_LEVELS = [
const DEFAULT_RESPONSE_LEVELS = [
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
]
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site']
const DEFAULT_SERVICE_TYPES = ['Remote', 'On Site', 'Off Site']
const PRIORITIES = [
const DEFAULT_PRIORITIES = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
@@ -33,16 +36,27 @@ const PRIORITIES = [
const today = new Date().toLocaleDateString('de-DE')
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
const { config } = useAdminConfig()
const { employees } = useEmployees()
// Verwende Config-Werte oder Fallbacks
const TICKET_TYPES = config?.ticketTypes || DEFAULT_TICKET_TYPES
const SYSTEMS = config?.systems || DEFAULT_SYSTEMS
const RESPONSE_LEVELS = config?.responseLevels || DEFAULT_RESPONSE_LEVELS
const SERVICE_TYPES = config?.serviceTypes || DEFAULT_SERVICE_TYPES
const PRIORITIES = config?.priorities || DEFAULT_PRIORITIES
const [formData, setFormData] = useState({
customerId: '',
type: 'Supportrequest',
type: '',
systemType: '',
responseLevel: '',
serviceType: 'Remote',
serviceType: '',
priority: 1,
topic: '',
requestedBy: '',
requestedFor: '',
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
startDate: today,
startTime: '',
deadline: today,
@@ -54,39 +68,64 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Setze Default-Werte wenn Config geladen ist oder Modal geöffnet wird
useEffect(() => {
if (isOpen && (TICKET_TYPES.length > 0 || SERVICE_TYPES.length > 0)) {
setFormData(prev => ({
...prev,
type: prev.type || TICKET_TYPES[0] || 'Supportrequest',
serviceType: prev.serviceType || SERVICE_TYPES[0] || 'Remote',
priority: prev.priority || (PRIORITIES.find(p => p.value === 1)?.value || 1)
}))
}
// Reset error when modal opens
if (isOpen) {
setError('')
}
}, [isOpen, TICKET_TYPES, SERVICE_TYPES, PRIORITIES])
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Clear error when user makes changes
if (error) setError('')
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
try {
await onCreate(formData)
onClose()
setFormData({
customerId: '',
type: 'Supportrequest',
systemType: '',
responseLevel: '',
serviceType: 'Remote',
priority: 1,
topic: '',
requestedBy: '',
requestedFor: '',
startDate: today,
startTime: '',
deadline: today,
endTime: '',
estimate: '30',
mailCopyTo: '',
sendNotification: false,
details: ''
})
const result = await onCreate(formData)
if (result.success) {
onClose()
setFormData({
customerId: '',
type: TICKET_TYPES[0] || 'Supportrequest',
systemType: '',
responseLevel: '',
serviceType: SERVICE_TYPES[0] || 'Remote',
priority: PRIORITIES.find(p => p.value === 1)?.value || 1,
topic: '',
requestedBy: '',
requestedFor: '',
startDate: today,
startTime: '',
deadline: today,
endTime: '',
estimate: '30',
mailCopyTo: '',
sendNotification: false,
details: ''
})
} else {
setError(result.error || 'Fehler beim Erstellen des Tickets')
}
} catch (error) {
console.error('Error creating ticket:', error)
setError(error.message || 'Ein unerwarteter Fehler ist aufgetreten')
} finally {
setLoading(false)
}
@@ -102,6 +141,12 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
<div className="overlay-content">
<h2 className="mb-2">Create New Ticket</h2>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
@@ -115,7 +160,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
>
<option value="">Affected Customer</option>
{customers.map(c => (
<option key={c.id} value={c.id}>({c.code}) {c.name}</option>
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
))}
</select>
</div>
@@ -189,6 +234,32 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Assigned To</label>
<select
className="form-control"
value={formData.assignedTo}
onChange={(e) => {
const userId = e.target.value
handleChange('assignedTo', userId)
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned"
// Wenn kein Mitarbeiter → Status = "Open"
if (userId) {
handleChange('status', 'Assigned')
} else {
handleChange('status', 'Open')
}
}}
>
<option value="">Unassigned</option>
{employees.map(emp => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName}{emp.shortcode ? ` (${emp.shortcode})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="col col-6">

View File

@@ -0,0 +1,440 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
const STATUS_OPTIONS = [
'Open',
'Closed',
'Awaiting',
'Added Info',
'Occupied',
'Halted',
'Cancelled',
'Aborted',
'Assigned',
'In Test'
]
const RESPONSE_LEVELS = [
'KEY USER',
'1st Level',
'2nd Level',
'3rd Level',
'FS/FE',
'24/7',
'TECH MGMT',
'Backoffice',
'BUSI MGMT',
'n/a'
]
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
const { user } = useAuth()
const today = new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
const [formData, setFormData] = useState({
serviceType: 'Remote',
newStatus: workorder?.status || 'Open',
newResponseLevel: workorder?.responseLevel || '',
totalTime: 0,
startDate: today,
startTime: '',
endDate: today,
endTime: '',
details: '',
isComment: false
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true)
// Reset form wenn Modal geöffnet wird
useEffect(() => {
if (isOpen && workorder) {
setFormData({
serviceType: 'Remote',
newStatus: workorder.status || 'Open',
newResponseLevel: workorder.responseLevel || '',
totalTime: 0,
startDate: today,
startTime: '',
endDate: today,
endTime: '',
details: '',
isComment: false
})
setError('')
setAutoCalculate(true)
}
}, [isOpen, workorder, today])
// Automatische Zeitberechnung
useEffect(() => {
if (autoCalculate && formData.startTime && formData.endTime && !formData.isComment) {
try {
const startHour = parseInt(formData.startTime.substring(0, 2))
const startMin = parseInt(formData.startTime.substring(2, 4))
const endHour = parseInt(formData.endTime.substring(0, 2))
const endMin = parseInt(formData.endTime.substring(2, 4))
if (!isNaN(startHour) && !isNaN(startMin) && !isNaN(endHour) && !isNaN(endMin)) {
const startTotal = startHour * 60 + startMin
const endTotal = endHour * 60 + endMin
let diff = endTotal - startTotal
if (diff < 0) {
diff += 24 * 60 // Overnight
}
setFormData(prev => ({ ...prev, totalTime: diff }))
}
} catch (err) {
// Ignoriere Fehler
}
}
}, [formData.startTime, formData.endTime, formData.isComment, autoCalculate])
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Wenn totalTime manuell geändert wird, deaktiviere Auto-Berechnung
if (field === 'totalTime') {
setAutoCalculate(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
try {
if (!formData.details.trim()) {
setError('Bitte Details eingeben')
setLoading(false)
return
}
const worksheetData = {
woid: workorder.woid,
workorderId: workorder.$id,
serviceType: formData.serviceType,
oldStatus: workorder.status,
newStatus: formData.newStatus,
oldResponseLevel: workorder.responseLevel || '',
newResponseLevel: formData.newResponseLevel,
totalTime: formData.isComment ? 0 : parseInt(formData.totalTime) || 0,
startDate: formData.startDate,
startTime: formData.startTime,
endDate: formData.endDate,
endTime: formData.endTime,
details: formData.details,
isComment: formData.isComment,
employeeShort: user?.prefs?.shortCode || '' // Aus User-Preferences
}
const result = await onCreate(worksheetData, user)
if (result.success) {
onClose()
} else {
setError(result.error || 'Fehler beim Erstellen des Worksheets')
}
} catch (err) {
console.error('Error creating worksheet:', err)
setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten')
} finally {
setLoading(false)
}
}
if (!isOpen || !workorder) return null
return (
<div className="overlay" style={{
width: '100%',
background: 'rgba(0,0,0,0.95)'
}}>
<a href="#" className="closebtn" onClick={(e) => { e.preventDefault(); onClose(); }} style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
borderRadius: '50%',
width: '60px',
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
transition: 'transform 0.2s ease'
}} onMouseEnter={(e) => e.currentTarget.style.transform = 'rotate(90deg)'} onMouseLeave={(e) => e.currentTarget.style.transform = 'rotate(0deg)'}>×</a>
<div className="overlay-content text-white text-left">
<form onSubmit={handleSubmit}>
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<div className="mb-4 p-4 rounded-3" style={{
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
boxShadow: '0 8px 32px rgba(45, 55, 72, 0.3)'
}}>
<h2 className="mb-0 d-flex align-items-center">
<span className="me-3" style={{
background: 'rgba(16, 185, 129, 0.4)',
borderRadius: '10px',
padding: '10px 15px'
}}>📝</span>
Create New Worksheet
<span className="ms-3 badge" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
fontSize: '1rem'
}}>WOID {workorder.woid}</span>
</h2>
</div>
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
{error && (
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<div className="alert p-4 rounded-3 border-0" style={{
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
color: 'white',
boxShadow: '0 4px 16px rgba(239, 68, 68, 0.3)'
}} role="alert">
<strong> {error}</strong>
</div>
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
)}
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
{/* Linke Spalte */}
<div className="col-5">
<span className="text-left">Service Type</span><br />
<select
className="form-select bg-dark text-white"
value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)}
required
>
{SERVICE_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
<br /><br />
<span className="text-left">New Status</span><br />
<select
className="form-select bg-dark text-white"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{STATUS_OPTIONS.map(status => (
<option key={status} value={status}>{status}</option>
))}
</select>
<br /><br />
<span className="text-left">New Response Level</span><br />
<select
className="form-select bg-dark text-white"
value={formData.newResponseLevel}
onChange={(e) => handleChange('newResponseLevel', e.target.value)}
>
<option value="">Select</option>
{RESPONSE_LEVELS.map(level => (
<option key={level} value={level}>{level}</option>
))}
</select>
<br /><br />
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="isComment"
checked={formData.isComment}
onChange={(e) => handleChange('isComment', e.target.checked)}
/>
<label className="form-check-label" htmlFor="isComment">
Nur Kommentar (keine Arbeitszeit)
</label>
</div>
<br />
</div>
{/* Rechte Spalte */}
<div className="col-5">
<span className="text-left">Total Time (Minuten)</span><br />
<input
type="number"
className="form-control bg-dark text-white"
min="0"
step="15"
value={formData.totalTime}
onChange={(e) => handleChange('totalTime', e.target.value)}
disabled={formData.isComment}
placeholder="0"
/>
<small className="text-muted">
{autoCalculate && formData.startTime && formData.endTime
? '✓ Automatisch berechnet'
: 'Manuell eingeben'}
</small>
<br /><br />
<span className="text-left">Start Date (dd.mm.yyyy)</span><br />
<input
type="text"
className="form-control bg-dark text-white"
value={formData.startDate}
onChange={(e) => handleChange('startDate', e.target.value)}
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
required
/>
<br /><br />
<span className="text-left">End Date (dd.mm.yyyy)</span><br />
<input
type="text"
className="form-control bg-dark text-white"
value={formData.endDate}
onChange={(e) => handleChange('endDate', e.target.value)}
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
required
/>
<br /><br />
<span className="text-left">Start Time (hhmm)</span><br />
<input
type="text"
className="form-control bg-dark text-white"
value={formData.startTime}
onChange={(e) => handleChange('startTime', e.target.value)}
pattern="[0-2][0-9][0-5][0-9]"
placeholder="1000"
maxLength="4"
/>
<br /><br />
<span className="text-left">End Time (hhmm)</span><br />
<input
type="text"
className="form-control bg-dark text-white"
value={formData.endTime}
onChange={(e) => handleChange('endTime', e.target.value)}
pattern="[0-2][0-9][0-5][0-9]"
placeholder="1030"
maxLength="4"
/>
<br /><br />
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<span className="text-left">Action Details</span><br />
<textarea
className="form-control bg-dark text-white"
rows="10"
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
placeholder="Beschreibe die durchgeführten Arbeiten..."
required
></textarea>
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10 text-center">
<p>&nbsp;</p>
<button
type="submit"
className="btn btn-lg px-5 py-3 border-0"
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
fontSize: '1.2rem',
fontWeight: 'bold',
boxShadow: '0 8px 32px rgba(16, 185, 129, 0.4)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 12px 40px rgba(16, 185, 129, 0.5)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 8px 32px rgba(16, 185, 129, 0.4)'
}}
disabled={loading}
>
{loading ? '⏳ Erstelle...' : '✨ CREATE NOW'}
</button>
<p>&nbsp;</p>
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
{/* Info Box */}
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<div className="p-4 rounded-3 border-0" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
boxShadow: '0 4px 16px rgba(74, 85, 104, 0.3)'
}} role="alert">
<strong className="d-block mb-2">📋 Current Work Order</strong>
<div className="d-flex flex-wrap gap-3">
<span className="badge px-3 py-2" style={{
background: 'rgba(16, 185, 129, 0.4)',
fontSize: '0.9rem'
}}>WOID: {workorder.woid}</span>
<span className="badge px-3 py-2" style={{
background: 'rgba(16, 185, 129, 0.4)',
fontSize: '0.9rem'
}}>Status: {workorder.status}</span>
<span className="badge px-3 py-2" style={{
background: 'rgba(16, 185, 129, 0.4)',
fontSize: '0.9rem'
}}>Topic: {workorder.topic}</span>
</div>
</div>
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,44 +1,34 @@
import { FaUserPlus } from 'react-icons/fa6'
// Diese Liste sollte aus der Datenbank kommen
const EDITORS = [
{ id: 'CHLE', name: 'Christian Lehmann' },
{ id: 'DIBR', name: 'Dietmar Bruckauf' },
{ id: 'DOAR', name: 'Dominik Armata' },
{ id: 'GRVO', name: 'Gregor Vowinkel' },
{ id: 'HADW', name: 'Hasan Dwiko' },
{ id: 'JEDI', name: 'Jessica Diaz' },
{ id: 'KNSO', name: 'Kenso Grimm' },
{ id: 'LUPL', name: 'Lukas Placzek' },
{ id: 'NIKI', name: 'Nikita Gaidach' },
{ id: 'MARK', name: 'Marco Kobza' },
{ id: 'MABA', name: 'Markus Bauer' },
{ id: 'MATS', name: 'Maksim Tschetschjotkin' },
{ id: 'PASI', name: 'Pascal Siegfried' },
{ id: 'NICT', name: 'Nico Stegmann' },
{ id: 'MEQU', name: 'Melissa Quednau' },
{ id: 'SASC', name: 'Saskia Schmahl' },
{ id: 'CHPA', name: 'Christin Paulus' },
{ id: 'SOSC', name: 'Sonja Schulze' },
{ id: 'WAWA', name: 'Walter Wawer' },
{ id: 'YAFO', name: 'Yannick Föller' },
{ id: 'TODE', name: 'Tobias Decker' }
]
import { useEmployees } from '../hooks/useEmployees'
export default function EditorDropdown({ value, onChange }) {
const { employees } = useEmployees()
// Finde den zugewiesenen Mitarbeiter anhand der userId
const assignedEmployee = employees.find(emp => emp.userId === value)
// Zeige Kürzel wenn zugewiesen, sonst Icon
const displayValue = assignedEmployee?.shortcode || <FaUserPlus size={20} />
return (
<div className="dropdown">
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
{value || <FaUserPlus size={20} />}
{displayValue}
</button>
<div className="dropdown-content">
{EDITORS.map(editor => (
<span
className="dropdown-item"
onClick={() => onChange('')}
>
<em>(Unassigned)</em>
</span>
{employees.map(employee => (
<span
key={editor.id}
key={employee.$id}
className="dropdown-item"
onClick={() => onChange(editor.id)}
onClick={() => onChange(employee.userId)}
>
{editor.name}
{employee.displayName}{employee.shortcode ? ` (${employee.shortcode})` : ''}
</span>
))}
</div>

View File

@@ -0,0 +1,138 @@
/* Modern Sidebar Styles */
.modern-sidebar-desktop {
height: 100vh;
padding: 1rem;
display: none;
flex-direction: column;
background: var(--sidebar-bg, #f5f5f5);
border-right: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
overflow: hidden;
transition: width 0.3s ease;
}
@media (min-width: 768px) {
.modern-sidebar-desktop {
display: flex;
}
}
/* Mobile Sidebar */
.modern-sidebar-mobile-header {
height: 60px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--sidebar-bg, #f5f5f5);
border-bottom: 1px solid var(--border-color, #e0e0e0);
width: 100%;
}
@media (min-width: 768px) {
.modern-sidebar-mobile-header {
display: none;
}
}
.modern-sidebar-mobile-toggle {
display: flex;
justify-content: flex-end;
width: 100%;
cursor: pointer;
color: var(--text-color, #333);
}
.modern-sidebar-mobile-toggle svg {
width: 24px;
height: 24px;
}
.modern-sidebar-mobile-menu {
position: fixed;
height: 100vh;
width: 100%;
inset: 0;
background: var(--sidebar-bg, #fff);
padding: 2.5rem;
z-index: 100;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.modern-sidebar-mobile-close {
position: absolute;
right: 2.5rem;
top: 2.5rem;
z-index: 50;
cursor: pointer;
color: var(--text-color, #333);
}
.modern-sidebar-mobile-close svg {
width: 24px;
height: 24px;
}
/* Sidebar Link */
.modern-sidebar-link {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.75rem 0.5rem;
color: var(--text-color, #333);
text-decoration: none;
border-radius: 6px;
transition: all 0.15s ease;
}
.modern-sidebar-link:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
transform: translateX(2px);
}
.modern-sidebar-link-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.modern-sidebar-link-icon svg {
width: 20px;
height: 20px;
}
.modern-sidebar-link-label {
font-size: 0.875rem;
white-space: pre;
margin: 0;
padding: 0;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.modern-sidebar-desktop,
.modern-sidebar-mobile-header,
.modern-sidebar-mobile-menu {
background: var(--dark-sidebar-bg, #1a1a1a);
border-color: var(--dark-border-color, #333);
}
.modern-sidebar-link {
color: var(--dark-text-color, #e0e0e0);
}
.modern-sidebar-link:hover {
background: var(--dark-hover-bg, rgba(255, 255, 255, 0.1));
}
.modern-sidebar-mobile-toggle,
.modern-sidebar-mobile-close {
color: var(--dark-text-color, #e0e0e0);
}
}

View File

@@ -0,0 +1,116 @@
import { useState, createContext, useContext } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { IconMenu2, IconX } from '@tabler/icons-react'
import { cn } from '../lib/utils'
import './ModernSidebar.css'
const SidebarContext = createContext(undefined)
export const useSidebar = () => {
const context = useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider')
}
return context
}
export const SidebarProvider = ({ children, open: openProp, setOpen: setOpenProp, animate = true }) => {
const [openState, setOpenState] = useState(false)
const open = openProp !== undefined ? openProp : openState
const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState
return (
<SidebarContext.Provider value={{ open, setOpen, animate }}>
{children}
</SidebarContext.Provider>
)
}
export const Sidebar = ({ children, open, setOpen, animate }) => {
return (
<SidebarProvider open={open} setOpen={setOpen} animate={animate}>
{children}
</SidebarProvider>
)
}
export const SidebarBody = ({ className, children, ...props }) => {
return (
<>
<DesktopSidebar className={className} {...props}>{children}</DesktopSidebar>
<MobileSidebar className={className}>{children}</MobileSidebar>
</>
)
}
export const DesktopSidebar = ({ className, children, ...props }) => {
const { open, setOpen, animate } = useSidebar()
return (
<motion.div
className={cn('modern-sidebar-desktop', className)}
animate={{
width: animate ? (open ? '300px' : '60px') : '300px',
}}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
{...props}
>
{children}
</motion.div>
)
}
export const MobileSidebar = ({ className, children }) => {
const { open, setOpen } = useSidebar()
return (
<>
<div className="modern-sidebar-mobile-header">
<div className="modern-sidebar-mobile-toggle">
<IconMenu2 onClick={() => setOpen(!open)} />
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ x: '-100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-100%', opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className={cn('modern-sidebar-mobile-menu', className)}
>
<div className="modern-sidebar-mobile-close" onClick={() => setOpen(!open)}>
<IconX />
</div>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</>
)
}
export const SidebarLink = ({ link, className, ...props }) => {
const { open, animate } = useSidebar()
return (
<a
href={link.href}
className={cn('modern-sidebar-link', className)}
{...props}
>
<span className="modern-sidebar-link-icon">{link.icon}</span>
<motion.span
animate={{
display: animate ? (open ? 'inline-block' : 'none') : 'inline-block',
opacity: animate ? (open ? 1 : 0) : 1,
}}
className="modern-sidebar-link-label"
>
{link.label}
</motion.span>
</a>
)
}

View File

@@ -1,49 +1,174 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { FaUser } from 'react-icons/fa'
import { motion } from 'motion/react'
import {
IconTicket,
IconDeviceDesktop,
IconChartBar,
IconCalendar,
IconFolders,
IconFileText,
IconBook,
IconSettings,
IconLogout
} from '@tabler/icons-react'
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
export default function Navbar() {
const { user, logout } = useAuth()
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const handleLogout = async () => {
await logout()
navigate('/login')
}
return (
<nav className="navbar">
<div className="navbar-brand">
<span>NetWEB</span>
<img src="/logo.png" alt="" height="18" width="18" />
<span>Systems</span>
</div>
<ul className="navbar-nav">
<li><Link to="/tickets" className="nav-link">Tickets</Link></li>
<li><Link to="/assets" className="nav-link">Assets</Link></li>
<li><Link to="/dashboard" className="nav-link">Dashboard</Link></li>
<li><Link to="/planboard" className="nav-link">Planboard</Link></li>
<li><Link to="/projects" className="nav-link">Projects</Link></li>
<li><Link to="/reports" className="nav-link">Reports</Link></li>
<li><Link to="/docs" className="nav-link">Docs</Link></li>
</ul>
const links = [
{
label: 'Tickets',
href: '/tickets',
icon: <IconTicket className="icon" />,
},
{
label: 'Assets',
href: '/assets',
icon: <IconDeviceDesktop className="icon" />,
},
{
label: 'Dashboard',
href: '/dashboard',
icon: <IconChartBar className="icon" />,
},
{
label: 'Planboard',
href: '/planboard',
icon: <IconCalendar className="icon" />,
},
{
label: 'Projects',
href: '/projects',
icon: <IconFolders className="icon" />,
},
{
label: 'Reports',
href: '/reports',
icon: <IconFileText className="icon" />,
},
{
label: 'Docs',
href: '/docs',
icon: <IconBook className="icon" />,
},
]
<div className="nav-right">
{user ? (
<button
onClick={handleLogout}
className="nav-link"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
<FaUser /> Logout, {user.name || user.email}
</button>
) : (
<Link to="/login" className="nav-link">
<FaUser /> Login
</Link>
)}
</div>
</nav>
// Admin link nur wenn isAdmin
if (isAdmin) {
links.push({
label: 'Admin',
href: '/admin',
icon: <IconSettings className="icon" />,
})
}
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} />
))}
</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>
)
}
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>
)
}

View File

@@ -0,0 +1,7 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View File

@@ -0,0 +1,674 @@
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
import './PixelBlast.css';
const createTouchTexture = () => {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D context not available');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const trail = [];
let last = null;
const maxAge = 64;
let radius = 0.1 * size;
const speed = 1 / maxAge;
const clear = () => {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const drawPoint = p => {
const pos = { x: p.x * size, y: (1 - p.y) * size };
let intensity = 1;
const easeOutSine = t => Math.sin((t * Math.PI) / 2);
const easeOutQuad = t => -t * (t - 2);
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
intensity *= p.force;
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
const offset = size * 5;
ctx.shadowOffsetX = offset;
ctx.shadowOffsetY = offset;
ctx.shadowBlur = radius;
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
ctx.beginPath();
ctx.fillStyle = 'rgba(255,0,0,1)';
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
ctx.fill();
};
const addTouch = norm => {
let force = 0;
let vx = 0;
let vy = 0;
if (last) {
const dx = norm.x - last.x;
const dy = norm.y - last.y;
if (dx === 0 && dy === 0) return;
const dd = dx * dx + dy * dy;
const d = Math.sqrt(dd);
vx = dx / (d || 1);
vy = dy / (d || 1);
force = Math.min(dd * 10000, 1);
}
last = { x: norm.x, y: norm.y };
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
};
const update = () => {
clear();
for (let i = trail.length - 1; i >= 0; i--) {
const point = trail[i];
const f = point.force * speed * (1 - point.age / maxAge);
point.x += point.vx * f;
point.y += point.vy * f;
point.age++;
if (point.age > maxAge) trail.splice(i, 1);
}
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
texture.needsUpdate = true;
};
return {
canvas,
texture,
addTouch,
update,
set radiusScale(v) {
radius = 0.1 * size * v;
},
get radiusScale() {
return radius / (0.1 * size);
},
size
};
};
const createLiquidEffect = (texture, opts) => {
const fragment = `
uniform sampler2D uTexture;
uniform float uStrength;
uniform float uTime;
uniform float uFreq;
void mainUv(inout vec2 uv) {
vec4 tex = texture2D(uTexture, uv);
float vx = tex.r * 2.0 - 1.0;
float vy = tex.g * 2.0 - 1.0;
float intensity = tex.b;
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
float amt = uStrength * intensity * wave;
uv += vec2(vx, vy) * amt;
}
`;
return new Effect('LiquidEffect', fragment, {
uniforms: new Map([
['uTexture', new THREE.Uniform(texture)],
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
['uTime', new THREE.Uniform(0)],
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
])
});
};
const SHAPE_MAP = {
square: 0,
circle: 1,
triangle: 2,
diamond: 3
};
const VERTEX_SRC = `
void main() {
gl_Position = vec4(position, 1.0);
}
`;
const FRAGMENT_SRC = `
precision highp float;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixelSize;
uniform float uScale;
uniform float uDensity;
uniform float uPixelJitter;
uniform int uEnableRipples;
uniform float uRippleSpeed;
uniform float uRippleThickness;
uniform float uRippleIntensity;
uniform float uEdgeFade;
uniform int uShapeType;
const int SHAPE_SQUARE = 0;
const int SHAPE_CIRCLE = 1;
const int SHAPE_TRIANGLE = 2;
const int SHAPE_DIAMOND = 3;
const int MAX_CLICKS = 10;
uniform vec2 uClickPos [MAX_CLICKS];
uniform float uClickTimes[MAX_CLICKS];
out vec4 fragColor;
float Bayer2(vec2 a) {
a = floor(a);
return fract(a.x / 2. + a.y * a.y * .75);
}
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
#define FBM_OCTAVES 5
#define FBM_LACUNARITY 1.25
#define FBM_GAIN 1.0
float hash11(float n){ return fract(sin(n)*43758.5453); }
float vnoise(vec3 p){
vec3 ip = floor(p);
vec3 fp = fract(p);
float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
float x00 = mix(n000, n100, w.x);
float x10 = mix(n010, n110, w.x);
float x01 = mix(n001, n101, w.x);
float x11 = mix(n011, n111, w.x);
float y0 = mix(x00, x10, w.y);
float y1 = mix(x01, x11, w.y);
return mix(y0, y1, w.z) * 2.0 - 1.0;
}
float fbm2(vec2 uv, float t){
vec3 p = vec3(uv * uScale, t);
float amp = 1.0;
float freq = 1.0;
float sum = 1.0;
for (int i = 0; i < FBM_OCTAVES; ++i){
sum += amp * vnoise(p * freq);
freq *= FBM_LACUNARITY;
amp *= FBM_GAIN;
}
return sum * 0.5 + 0.5;
}
float maskCircle(vec2 p, float cov){
float r = sqrt(cov) * .25;
float d = length(p - 0.5) - r;
float aa = 0.5 * fwidth(d);
return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
}
float maskTriangle(vec2 p, vec2 id, float cov){
bool flip = mod(id.x + id.y, 2.0) > 0.5;
if (flip) p.x = 1.0 - p.x;
float r = sqrt(cov);
float d = p.y - r*(1.0 - p.x);
float aa = fwidth(d);
return cov * clamp(0.5 - d/aa, 0.0, 1.0);
}
float maskDiamond(vec2 p, float cov){
float r = sqrt(cov) * 0.564;
return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
}
void main(){
float pixelSize = uPixelSize;
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
float aspectRatio = uResolution.x / uResolution.y;
vec2 pixelId = floor(fragCoord / pixelSize);
vec2 pixelUV = fract(fragCoord / pixelSize);
float cellPixelSize = 8.0 * pixelSize;
vec2 cellId = floor(fragCoord / cellPixelSize);
vec2 cellCoord = cellId * cellPixelSize;
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
float base = fbm2(uv, uTime * 0.05);
base = base * 0.5 - 0.65;
float feed = base + (uDensity - 0.5) * 0.3;
float speed = uRippleSpeed;
float thickness = uRippleThickness;
const float dampT = 1.0;
const float dampR = 10.0;
if (uEnableRipples == 1) {
for (int i = 0; i < MAX_CLICKS; ++i){
vec2 pos = uClickPos[i];
if (pos.x < 0.0) continue;
float cellPixelSize = 8.0 * pixelSize;
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
float t = max(uTime - uClickTimes[i], 0.0);
float r = distance(uv, cuv);
float waveR = speed * t;
float ring = exp(-pow((r - waveR) / thickness, 2.0));
float atten = exp(-dampT * t) * exp(-dampR * r);
feed = max(feed, ring * atten * uRippleIntensity);
}
}
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
float bw = step(0.5, feed + bayer);
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
float coverage = bw * jitterScale;
float M;
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
else M = coverage;
if (uEdgeFade > 0.0) {
vec2 norm = gl_FragCoord.xy / uResolution;
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
float fade = smoothstep(0.0, uEdgeFade, edge);
M *= fade;
}
vec3 color = uColor;
fragColor = vec4(color, M);
}
`;
const MAX_CLICKS = 10;
const PixelBlast = ({
variant = 'square',
pixelSize = 3,
color = '#10b981',
className,
style,
antialias = true,
patternScale = 2,
patternDensity = 1,
liquid = false,
liquidStrength = 0.1,
liquidRadius = 1,
pixelSizeJitter = 0,
enableRipples = true,
rippleIntensityScale = 1,
rippleThickness = 0.1,
rippleSpeed = 0.3,
liquidWobbleSpeed = 4.5,
autoPauseOffscreen = true,
speed = 0.5,
transparent = true,
edgeFade = 0.5,
noiseAmount = 0
}) => {
const containerRef = useRef(null);
const visibilityRef = useRef({ visible: true });
const speedRef = useRef(speed);
const threeRef = useRef(null);
const prevConfigRef = useRef(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
speedRef.current = speed;
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
const cfg = { antialias, liquid, noiseAmount };
let mustReinit = false;
if (!threeRef.current) mustReinit = true;
else if (prevConfigRef.current) {
for (const k of needsReinitKeys)
if (prevConfigRef.current[k] !== cfg[k]) {
mustReinit = true;
break;
}
}
if (mustReinit) {
if (threeRef.current) {
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
threeRef.current = null;
}
const canvas = document.createElement('canvas');
const renderer = new THREE.WebGLRenderer({
canvas,
antialias,
alpha: true,
powerPreference: 'high-performance'
});
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
container.appendChild(renderer.domElement);
if (transparent) renderer.setClearAlpha(0);
else renderer.setClearColor(0x000000, 1);
const uniforms = {
uResolution: { value: new THREE.Vector2(0, 0) },
uTime: { value: 0 },
uColor: { value: new THREE.Color(color) },
uClickPos: {
value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
},
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
uShapeType: { value: SHAPE_MAP[variant] ?? 0 },
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
uScale: { value: patternScale },
uDensity: { value: patternDensity },
uPixelJitter: { value: pixelSizeJitter },
uEnableRipples: { value: enableRipples ? 1 : 0 },
uRippleSpeed: { value: rippleSpeed },
uRippleThickness: { value: rippleThickness },
uRippleIntensity: { value: rippleIntensityScale },
uEdgeFade: { value: edgeFade }
};
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SRC,
fragmentShader: FRAGMENT_SRC,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
glslVersion: THREE.GLSL3
});
const quadGeom = new THREE.PlaneGeometry(2, 2);
const quad = new THREE.Mesh(quadGeom, material);
scene.add(quad);
const clock = new THREE.Clock();
const setSize = () => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
renderer.setSize(w, h, false);
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
if (threeRef.current?.composer)
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
};
setSize();
const ro = new ResizeObserver(setSize);
ro.observe(container);
const randomFloat = () => {
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
const u32 = new Uint32Array(1);
window.crypto.getRandomValues(u32);
return u32[0] / 0xffffffff;
}
return Math.random();
};
const timeOffset = randomFloat() * 1000;
let composer;
let touch;
let liquidEffect;
if (liquid) {
touch = createTouchTexture();
touch.radiusScale = liquidRadius;
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
liquidEffect = createLiquidEffect(touch.texture, {
strength: liquidStrength,
freq: liquidWobbleSpeed
});
const effectPass = new EffectPass(camera, liquidEffect);
effectPass.renderToScreen = true;
composer.addPass(renderPass);
composer.addPass(effectPass);
}
if (noiseAmount > 0) {
if (!composer) {
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
}
const noiseEffect = new Effect(
'NoiseEffect',
`uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
{
uniforms: new Map([
['uTime', new THREE.Uniform(0)],
['uAmount', new THREE.Uniform(noiseAmount)]
])
}
);
const noisePass = new EffectPass(camera, noiseEffect);
noisePass.renderToScreen = true;
if (composer && composer.passes.length > 0) composer.passes.forEach(p => (p.renderToScreen = false));
composer.addPass(noisePass);
}
if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
const mapToPixels = e => {
const rect = renderer.domElement.getBoundingClientRect();
const scaleX = renderer.domElement.width / rect.width;
const scaleY = renderer.domElement.height / rect.height;
const fx = (e.clientX - rect.left) * scaleX;
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
return {
fx,
fy,
w: renderer.domElement.width,
h: renderer.domElement.height
};
};
const onPointerDown = e => {
const { fx, fy } = mapToPixels(e);
const ix = threeRef.current?.clickIx ?? 0;
uniforms.uClickPos.value[ix].set(fx, fy);
uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
};
const onPointerMove = e => {
if (!touch) return;
const { fx, fy, w, h } = mapToPixels(e);
touch.addTouch({ x: fx / w, y: fy / h });
};
renderer.domElement.addEventListener('pointerdown', onPointerDown, {
passive: true
});
renderer.domElement.addEventListener('pointermove', onPointerMove, {
passive: true
});
let raf = 0;
const animate = () => {
if (autoPauseOffscreen && !visibilityRef.current.visible) {
raf = requestAnimationFrame(animate);
return;
}
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
if (liquidEffect) liquidEffect.uniforms.get('uTime').value = uniforms.uTime.value;
if (composer) {
if (touch) touch.update();
composer.passes.forEach(p => {
const effs = p.effects;
if (effs)
effs.forEach(eff => {
const u = eff.uniforms?.get('uTime');
if (u) u.value = uniforms.uTime.value;
});
});
composer.render();
} else renderer.render(scene, camera);
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
threeRef.current = {
renderer,
scene,
camera,
material,
clock,
clickIx: 0,
uniforms,
resizeObserver: ro,
raf,
quad,
timeOffset,
composer,
touch,
liquidEffect
};
} else {
const t = threeRef.current;
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
t.uniforms.uColor.value.set(color);
t.uniforms.uScale.value = patternScale;
t.uniforms.uDensity.value = patternDensity;
t.uniforms.uPixelJitter.value = pixelSizeJitter;
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
t.uniforms.uRippleThickness.value = rippleThickness;
t.uniforms.uRippleSpeed.value = rippleSpeed;
t.uniforms.uEdgeFade.value = edgeFade;
if (transparent) t.renderer.setClearAlpha(0);
else t.renderer.setClearColor(0x000000, 1);
if (t.liquidEffect) {
const uStrength = t.liquidEffect.uniforms.get('uStrength');
if (uStrength) uStrength.value = liquidStrength;
const uFreq = t.liquidEffect.uniforms.get('uFreq');
if (uFreq) uFreq.value = liquidWobbleSpeed;
}
if (t.touch) t.touch.radiusScale = liquidRadius;
}
prevConfigRef.current = cfg;
return () => {
if (threeRef.current && mustReinit) return;
if (!threeRef.current) return;
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
threeRef.current = null;
};
}, [
antialias,
liquid,
noiseAmount,
pixelSize,
patternScale,
patternDensity,
enableRipples,
rippleIntensityScale,
rippleThickness,
rippleSpeed,
pixelSizeJitter,
edgeFade,
transparent,
liquidStrength,
liquidRadius,
liquidWobbleSpeed,
autoPauseOffscreen,
variant,
color,
speed
]);
return (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={style}
aria-label="PixelBlast interactive background"
/>
);
};
export default PixelBlast;

View File

@@ -1,11 +1,15 @@
import { useState } from 'react'
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear } from 'react-icons/fa6'
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus } 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 WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats'
import { useWorksheets } from '../hooks/useWorksheets'
const PRIORITY_CLASSES = {
0: 'priority-none',
@@ -41,6 +45,15 @@ const APPROVAL_ICONS = {
export default function TicketRow({ ticket, onUpdate, onExpand }) {
const [expanded, setExpanded] = useState(false)
const [locked, setLocked] = useState(true)
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
// Worksheets für dieses Ticket laden (nur wenn expanded)
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 })
@@ -71,13 +84,27 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
setLocked(!locked)
}
const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheet(worksheetData, currentUser)
// Wenn Status geändert wurde, aktualisiere Work Order
if (result.success && worksheetData.newStatus !== ticket.status) {
await onUpdate(ticket.$id, {
status: worksheetData.newStatus,
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
})
}
return result
}
const ApprovalIcon = APPROVAL_ICONS[ticket.approvalStatus] || FaUserGear
return (
<>
<tr className="ticket-row">
<td className="ticket-id" rowSpan={2}>
<div>{ticket.woid || ticket.$id?.slice(-5)}</div>
<div><strong>WOID:</strong> {ticket.woid || ticket.$id?.slice(-5)}</div>
<div className="ticket-time">{elapsed}</div>
</td>
<td className="ticket-info" rowSpan={2}>
@@ -153,16 +180,82 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
<tr>
<td colSpan={10} className="p-2">
<div className="card">
<div className="card-header">Details - WOID {ticket.woid || ticket.$id}</div>
<div className="card-header d-flex justify-content-between align-items-center" style={{
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
color: 'white',
padding: '1rem 1.5rem'
}}>
<span className="fs-5 fw-bold">Details - WOID {ticket.woid || ticket.$id}</span>
<button
className="btn btn-sm px-4 py-2 border-0 fw-bold"
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
onClick={() => setShowCreateWorksheet(true)}
>
<FaPlus className="me-2" /> Add Worksheet
</button>
</div>
<div className="card-body">
<p><strong>Beschreibung:</strong></p>
<p>{ticket.details || 'Keine Details vorhanden.'}</p>
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
background: 'linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)',
border: '2px solid #10b981'
}}>
<h5 className="mb-3" style={{ color: '#1a202c', fontWeight: 'bold' }}>
📋 Ticket-Beschreibung
</h5>
<p style={{
fontSize: '1.1rem',
lineHeight: '1.8',
color: '#1f2937',
whiteSpace: 'pre-wrap',
margin: 0
}}>
{ticket.details || 'Keine Details vorhanden.'}
</p>
</div>
<hr />
<h5 className="mt-4 mb-3">Worksheets (Arbeitsschritte)</h5>
{/* Statistiken */}
{worksheets.length > 0 && (
<>
<WorksheetStats worksheets={worksheets} />
<hr />
</>
)}
{/* Worksheet-Liste */}
<WorksheetList
worksheets={worksheets}
totalTime={getTotalTime()}
loading={worksheetsLoading}
/>
</div>
</div>
</td>
</tr>
</>
)}
<CreateWorksheetModal
isOpen={showCreateWorksheet}
onClose={() => setShowCreateWorksheet(false)}
workorder={ticket}
onCreate={handleCreateWorksheet}
/>
<tr className="spacer">
<td colSpan={10} style={{ height: '8px', background: '#fff' }}></td>
</tr>

View File

@@ -0,0 +1,189 @@
import { FaClock, FaUser, FaExchangeAlt, FaComment } from 'react-icons/fa'
export default function WorksheetList({ worksheets, totalTime, loading }) {
if (loading) {
return (
<div className="text-center p-4">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
)
}
if (!worksheets || worksheets.length === 0) {
return (
<div className="alert alert-info" role="alert">
<FaComment className="me-2" />
Noch keine Worksheets vorhanden. Erstelle das erste Worksheet für dieses Ticket.
</div>
)
}
const formatTime = (minutes) => {
if (!minutes || minutes === 0) return '-'
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
const formatDateTime = (date, time) => {
if (!time) return date
const hours = time.substring(0, 2)
const mins = time.substring(2, 4)
return `${date} ${hours}:${mins}`
}
return (
<div className="worksheet-list">
{/* Gesamtzeit-Header */}
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
background: 'linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)',
border: 'none'
}}>
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center">
<FaClock className="me-3" size={24} style={{ color: '#059669' }} />
<div>
<strong className="fs-5 d-block" style={{ color: '#064e3b' }}>Gesamtarbeitszeit</strong>
<span className="fs-3 fw-bold" style={{ color: '#059669' }}>{formatTime(totalTime)}</span>
</div>
</div>
<div className="text-end">
<span className="badge px-3 py-2" style={{
background: 'rgba(5, 150, 105, 0.2)',
color: '#059669',
fontSize: '1rem'
}}>
{worksheets.filter(ws => !ws.isComment).length} Worksheet(s)
</span>
</div>
</div>
</div>
{/* Worksheet-Einträge */}
<div className="timeline">
{worksheets.map((ws, index) => (
<div key={ws.$id} className="timeline-item mb-4" style={{
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
}}>
<div className="card border-0 shadow-sm overflow-hidden" style={{
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}} onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)'
}} onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
}}>
<div className="card-header d-flex justify-content-between align-items-center py-3" style={{
background: ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
border: 'none'
}}>
<div>
<strong className="fs-6">WSID {ws.wsid}</strong>
{ws.isComment && (
<span className="badge ms-2" style={{
background: 'rgba(255,255,255,0.3)'
}}>
<FaComment className="me-1" /> Kommentar
</span>
)}
</div>
<small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime)}
</small>
</div>
<div className="card-body p-4">
{/* Mitarbeiter & Zeit */}
<div className="row mb-3">
<div className="col-md-6">
<div className="d-flex align-items-center">
<FaUser className="me-2" style={{ color: '#10b981' }} />
<strong>{ws.employeeName}</strong>
{ws.employeeShort && (
<span className="badge ms-2" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white'
}}>{ws.employeeShort}</span>
)}
</div>
</div>
<div className="col-md-6 text-md-end">
{!ws.isComment && (
<div className="d-flex align-items-center justify-content-md-end">
<FaClock className="me-2" style={{ color: '#10b981' }} />
<strong className="fs-5" style={{ color: '#10b981' }}>{formatTime(ws.totalTime)}</strong>
</div>
)}
</div>
</div>
{/* Service Type */}
<div className="mb-3">
<span className="badge px-3 py-2" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
fontSize: '0.85rem'
}}>{ws.serviceType}</span>
</div>
{/* Status-Änderung */}
{ws.oldStatus !== ws.newStatus && (
<div className="mb-3 p-3 rounded-3" style={{
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
}}>
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
<span className="text-muted">Status:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
<span className="mx-2" style={{ color: '#10b981' }}></span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
</div>
)}
{/* Response Level-Änderung */}
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
<div className="mb-3">
<span className="text-muted">Response Level:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
<span className="mx-2"></span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
</div>
)}
{/* Details */}
<div className="mt-3 p-3 rounded-3" style={{
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.1)'
}}>
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
{ws.details}
</small>
</div>
</div>
</div>
</div>
))}
</div>
<style>{`
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import { FaClock, FaUsers, FaHistory, FaChartLine } from 'react-icons/fa'
export default function WorksheetStats({ worksheets }) {
if (!worksheets || worksheets.length === 0) {
return null
}
// Gesamtarbeitszeit
const totalMinutes = worksheets
.filter(ws => !ws.isComment)
.reduce((sum, ws) => sum + (ws.totalTime || 0), 0)
// Nach Mitarbeiter gruppieren
const byEmployee = worksheets.reduce((acc, ws) => {
const empId = ws.employeeId
if (!acc[empId]) {
acc[empId] = {
name: ws.employeeName,
short: ws.employeeShort,
time: 0,
count: 0
}
}
if (!ws.isComment) {
acc[empId].time += ws.totalTime || 0
}
acc[empId].count += 1
return acc
}, {})
// Status-Historie
const statusHistory = worksheets
.filter(ws => ws.oldStatus && ws.newStatus && ws.oldStatus !== ws.newStatus)
.map(ws => ({
date: ws.startDate,
time: ws.startTime,
from: ws.oldStatus,
to: ws.newStatus,
employee: ws.employeeName
}))
// Service Type Verteilung
const byServiceType = worksheets.reduce((acc, ws) => {
const type = ws.serviceType || 'Unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
const formatTime = (minutes) => {
if (!minutes || minutes === 0) return '0min'
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
const formatTimeShort = (time) => {
if (!time || time.length !== 4) return '-'
return `${time.substring(0, 2)}:${time.substring(2, 4)}`
}
return (
<div className="worksheet-stats mb-4">
<div className="row g-4">
{/* Gesamtübersicht */}
<div className="col-lg-4 col-md-6">
<div className="card h-100 border-0 shadow-sm" style={{
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
color: 'white'
}}>
<div className="card-body p-4">
<h6 className="card-title mb-3 d-flex align-items-center">
<FaChartLine className="me-2" size={20} style={{ color: '#4ade80' }} />
<strong>Gesamtübersicht</strong>
</h6>
<div className="mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
<span style={{ opacity: 0.9 }}>Worksheets:</span>
<strong className="fs-5">{worksheets.length}</strong>
</div>
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
<span style={{ opacity: 0.9 }}>Arbeitszeit:</span>
<strong className="fs-5">{formatTime(totalMinutes)}</strong>
</div>
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
<span style={{ opacity: 0.9 }}>Kommentare:</span>
<strong className="fs-5">{worksheets.filter(ws => ws.isComment).length}</strong>
</div>
<div className="d-flex justify-content-between align-items-center">
<span style={{ opacity: 0.9 }}>Ø pro Worksheet:</span>
<strong className="fs-5">
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
</strong>
</div>
</div>
</div>
</div>
</div>
{/* Nach Mitarbeiter */}
<div className="col-lg-4 col-md-6">
<div className="card h-100 border-0 shadow-sm" style={{
background: 'linear-gradient(135deg, #22c55e 0%, #10b981 100%)',
color: 'white'
}}>
<div className="card-body p-4">
<h6 className="card-title mb-3 d-flex align-items-center">
<FaUsers className="me-2" size={20} />
<strong>Nach Mitarbeiter</strong>
</h6>
<div className="mt-3">
{Object.values(byEmployee).map((emp, idx) => (
<div key={idx} className="mb-3 pb-3" style={{ borderBottom: idx < Object.values(byEmployee).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
<div className="d-flex justify-content-between align-items-center">
<div>
<strong className="d-block">{emp.name}</strong>
{emp.short && (
<span className="badge mt-1" style={{
background: 'rgba(255,255,255,0.25)'
}}>{emp.short}</span>
)}
</div>
<div className="text-end">
<div className="fs-5 fw-bold">{formatTime(emp.time)}</div>
<small style={{ opacity: 0.8 }}>{emp.count} WS</small>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Service Type Verteilung */}
<div className="col-lg-4 col-md-6">
<div className="card h-100 border-0 shadow-sm" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white'
}}>
<div className="card-body p-4">
<h6 className="card-title mb-3 d-flex align-items-center">
<FaClock className="me-2" size={20} style={{ color: '#4ade80' }} />
<strong>Service Types</strong>
</h6>
<div className="mt-3">
{Object.entries(byServiceType).map(([type, count], idx) => (
<div key={type} className="d-flex justify-content-between align-items-center mb-3 pb-3" style={{ borderBottom: idx < Object.entries(byServiceType).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
<span className="badge px-3 py-2" style={{
background: 'rgba(255,255,255,0.25)',
fontSize: '0.9rem'
}}>{type}</span>
<strong className="fs-5">{count}</strong>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Status-Historie */}
{statusHistory.length > 0 && (
<div className="card border-0 shadow-sm mt-3" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
}}>
<div className="card-body p-4">
<h6 className="card-title text-white mb-3 d-flex align-items-center">
<FaHistory className="me-2" size={20} />
<strong>Status-Historie</strong>
</h6>
<div className="table-responsive mt-3">
<table className="table table-sm" style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
<thead>
<tr style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Datum</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Zeit</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Von</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}></th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Nach</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Mitarbeiter</th>
</tr>
</thead>
<tbody>
{statusHistory.reverse().map((change, idx) => (
<tr key={idx} style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.date}</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{formatTimeShort(change.time)}</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
<span className="badge" style={{
background: 'rgba(255,255,255,0.25)'
}}>{change.from}</span>
</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}></td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
<span className="badge" style={{
background: 'rgba(255,255,255,0.4)',
fontWeight: 'bold'
}}>{change.to}</span>
</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.employee}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,11 +1,46 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { account } from '../lib/appwrite'
import { account, databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
const AuthContext = createContext()
// Demo mode when Appwrite is not configured
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Hilfsfunktion: Fügt User automatisch zur employees Collection hinzu
async function ensureEmployeeExists(user) {
if (!user || DEMO_MODE) return
try {
// Prüfe ob User bereits in employees Collection existiert
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
[Query.equal('userId', user.$id)]
)
// Wenn User noch nicht existiert, füge ihn hinzu
if (response.documents.length === 0) {
await databases.createDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
ID.unique(),
{
userId: user.$id,
displayName: user.name || user.email,
email: user.email,
shortcode: '' // Kürzel wird später vom Admin hinzugefügt
}
)
console.log('✅ User automatisch zur Mitarbeiter-Liste hinzugefügt')
}
} catch (error) {
// Fehler ignorieren wenn Collection nicht existiert oder Permissions fehlen
if (error.code !== 404) {
console.warn('Could not add user to employees collection:', error.message)
}
}
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
@@ -28,7 +63,14 @@ export function AuthProvider({ children }) {
try {
const session = await account.get()
setUser(session)
// Automatisch zur employees Collection hinzufügen
await ensureEmployeeExists(session)
} catch (error) {
// Kein Fehler loggen beim initialen Check - das ist normal wenn nicht eingeloggt
// Nur loggen wenn es ein unerwarteter Fehler ist (nicht 401)
if (error.code !== 401 && error.code !== 404) {
console.error('Unexpected error checking user:', error)
}
setUser(null)
} finally {
setLoading(false)
@@ -45,11 +87,38 @@ export function AuthProvider({ children }) {
}
try {
await account.createEmailPasswordSession(email, password)
await checkUser()
// Appwrite 1.5.7 / SDK 13.0 - versuche beide Methoden für Kompatibilität
try {
await account.createEmailSession(email, password)
} catch (e) {
// Fallback für ältere API
if (account.createEmailPasswordSession) {
await account.createEmailPasswordSession(email, password)
} else {
throw e
}
}
// User-Daten laden und automatisch zur employees Collection hinzufügen
const session = await account.get()
setUser(session)
await ensureEmployeeExists(session)
return { success: true }
} catch (error) {
return { success: false, error: error.message }
console.error('Login error:', error)
let errorMessage = error.message || 'Login fehlgeschlagen'
// Bessere Fehlermeldungen
if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
errorMessage = 'Ungültige Email oder Passwort'
} else if (errorMessage.includes('User not found')) {
errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.'
} else if (errorMessage.includes('Email/Password')) {
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
}
return { success: false, error: errorMessage }
}
}
@@ -74,20 +143,42 @@ export function AuthProvider({ children }) {
}
try {
await account.create('unique()', email, password, name)
// Appwrite SDK 13.0 verwendet ID.unique() für die User ID
await account.create(ID.unique(), email, password, name)
// Login ruft automatisch ensureEmployeeExists auf
await login(email, password)
return { success: true }
} catch (error) {
return { success: false, error: error.message }
console.error('Register error:', error)
let errorMessage = error.message || 'Registrierung fehlgeschlagen'
// Bessere Fehlermeldungen
if (errorMessage.includes('already exists') || errorMessage.includes('duplicate')) {
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
} else if (errorMessage.includes('Email/Password')) {
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
} else if (errorMessage.includes('password') && errorMessage.includes('length')) {
errorMessage = 'Das Passwort muss mindestens 8 Zeichen lang sein.'
}
return { success: false, error: errorMessage }
}
}
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist
const isAdmin = () => {
if (!user) return false
// Prüfe ob Benutzer das "admin" Label hat
return user.labels?.includes('admin') || false
}
const value = {
user,
loading,
login,
logout,
register
register,
isAdmin: isAdmin()
}
return (

152
src/hooks/useAdminConfig.js Normal file
View File

@@ -0,0 +1,152 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, ID } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Default-Werte für Demo-Modus
const DEFAULT_CONFIG = {
ticketTypes: [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
],
systems: [
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
],
responseLevels: [
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
],
serviceTypes: ['Remote', 'On Site', 'Off Site'],
priorities: [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
{ value: 3, label: 'High' },
{ value: 4, label: 'Critical' }
]
}
export function useAdminConfig() {
const [config, setConfig] = useState(DEFAULT_CONFIG)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchConfig = useCallback(async () => {
if (DEMO_MODE) {
setConfig(DEFAULT_CONFIG)
setLoading(false)
return
}
try {
// Versuche Config-Dokument zu laden (ID: 'config')
try {
const doc = await databases.getDocument(
DATABASE_ID,
COLLECTIONS.CONFIG || 'config',
'config'
)
setConfig({
ticketTypes: doc.ticketTypes || DEFAULT_CONFIG.ticketTypes,
systems: doc.systems || DEFAULT_CONFIG.systems,
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
priorities: doc.priorities || DEFAULT_CONFIG.priorities
})
} catch (e) {
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
if (e.code === 404 || e.message?.includes('not found')) {
setConfig(DEFAULT_CONFIG)
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
} else {
throw e
}
}
setError(null)
} catch (err) {
console.error('Error fetching config:', err)
// Nur echte Fehler als Error setzen, nicht 404
if (err.code !== 404 && !err.message?.includes('not found')) {
setError(err.message)
} else {
setError(null)
}
setConfig(DEFAULT_CONFIG) // Fallback zu Defaults
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchConfig()
}, [fetchConfig])
const updateConfig = async (newConfig) => {
if (DEMO_MODE) {
setConfig(newConfig)
localStorage.setItem('admin_config', JSON.stringify(newConfig))
return { success: true }
}
try {
const configData = {
ticketTypes: newConfig.ticketTypes,
systems: newConfig.systems,
responseLevels: newConfig.responseLevels,
serviceTypes: newConfig.serviceTypes,
priorities: newConfig.priorities
}
try {
// Versuche zu aktualisieren
await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.CONFIG || 'config',
'config',
configData
)
} catch (e) {
// Dokument existiert nicht (404) oder Collection existiert nicht
if (e.code === 404 || e.message?.includes('not found')) {
// Versuche zu erstellen
try {
await databases.createDocument(
DATABASE_ID,
COLLECTIONS.CONFIG || 'config',
'config',
configData
)
} catch (createErr) {
// Collection existiert nicht - zeige hilfreiche Fehlermeldung
if (createErr.code === 404 || createErr.message?.includes('Collection')) {
throw new Error('Die "config" Collection existiert noch nicht. Bitte erstelle sie zuerst in Appwrite.')
}
throw createErr
}
} else {
throw e
}
}
setConfig(newConfig)
return { success: true }
} catch (err) {
console.error('Error updating config:', err)
return { success: false, error: err.message }
}
}
return {
config,
loading,
error,
updateConfig,
refresh: fetchConfig
}
}

121
src/hooks/useCustomers.js Normal file
View File

@@ -0,0 +1,121 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Demo-Kunden für Testing
const DEMO_CUSTOMERS = [
{ $id: '1', code: 'C001', name: 'Kunde A', location: 'Berlin', email: 'kunde.a@example.com', phone: '030-123456' },
{ $id: '2', code: 'C002', name: 'Kunde B', location: 'München', email: 'kunde.b@example.com', phone: '089-654321' }
]
export function useCustomers() {
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchCustomers = useCallback(async () => {
if (DEMO_MODE) {
setCustomers(DEMO_CUSTOMERS)
setLoading(false)
return
}
try {
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
[Query.orderAsc('name')]
)
setCustomers(response.documents)
setError(null)
} catch (err) {
console.error('Error fetching customers:', err)
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
if (err.code === 404 || err.message?.includes('not found')) {
setCustomers([])
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
} else {
setError(err.message)
setCustomers([])
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchCustomers()
}, [fetchCustomers])
const createCustomer = async (data) => {
if (DEMO_MODE) {
const newCustomer = { ...data, $id: Date.now().toString() }
setCustomers(prev => [...prev, newCustomer])
return { success: true, data: newCustomer }
}
try {
const response = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
ID.unique(),
data
)
setCustomers(prev => [...prev, response])
return { success: true, data: response }
} catch (err) {
return { success: false, error: err.message }
}
}
const updateCustomer = async (id, data) => {
if (DEMO_MODE) {
setCustomers(prev => prev.map(c => c.$id === id ? { ...c, ...data } : c))
return { success: true }
}
try {
const response = await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
id,
data
)
setCustomers(prev => prev.map(c => c.$id === id ? response : c))
return { success: true, data: response }
} catch (err) {
return { success: false, error: err.message }
}
}
const deleteCustomer = async (id) => {
if (DEMO_MODE) {
setCustomers(prev => prev.filter(c => c.$id !== id))
return { success: true }
}
try {
await databases.deleteDocument(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
id
)
setCustomers(prev => prev.filter(c => c.$id !== id))
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
return {
customers,
loading,
error,
refresh: fetchCustomers,
createCustomer,
updateCustomer,
deleteCustomer
}
}

207
src/hooks/useEmployees.js Normal file
View File

@@ -0,0 +1,207 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, account, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Demo-Mitarbeiter für Testing
const DEMO_EMPLOYEES = [
{ $id: '1', userId: 'user1', displayName: 'Kenso Grimm', email: 'kenso@example.com', shortcode: 'KNSO' },
{ $id: '2', userId: 'user2', displayName: 'Christian Lehmann', email: 'christian@example.com', shortcode: 'CHLE' }
]
export function useEmployees() {
const [employees, setEmployees] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [syncing, setSyncing] = useState(false)
const fetchEmployees = useCallback(async () => {
setLoading(true)
if (DEMO_MODE) {
setEmployees(DEMO_EMPLOYEES)
setLoading(false)
return
}
try {
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
[Query.orderAsc('displayName')]
)
setEmployees(response.documents)
setError(null)
} catch (err) {
console.error('Error fetching employees:', err)
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
if (err.code === 404 || err.message?.includes('not found')) {
setEmployees([])
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
} else {
setError(err.message)
setEmployees([])
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchEmployees()
}, [fetchEmployees])
const createEmployee = async (data) => {
if (DEMO_MODE) {
const newEmployee = { ...data, $id: Date.now().toString() }
setEmployees(prev => [...prev, newEmployee])
return { success: true, data: newEmployee }
}
try {
// Validierung
if (!data.userId || !data.displayName) {
return { success: false, error: 'userId und displayName sind erforderlich' }
}
const response = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
ID.unique(),
{
userId: data.userId,
displayName: data.displayName,
email: data.email || '',
shortcode: data.shortcode || ''
}
)
setEmployees(prev => [...prev, response])
return { success: true, data: response }
} catch (err) {
console.error('Error creating employee:', err)
return { success: false, error: err.message }
}
}
const updateEmployee = async (id, data) => {
if (DEMO_MODE) {
setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e))
return { success: true }
}
try {
const response = await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
id,
data
)
setEmployees(prev => prev.map(e => e.$id === id ? response : e))
return { success: true, data: response }
} catch (err) {
console.error('Error updating employee:', err)
return { success: false, error: err.message }
}
}
const deleteEmployee = async (id) => {
if (DEMO_MODE) {
setEmployees(prev => prev.filter(e => e.$id !== id))
return { success: true }
}
try {
await databases.deleteDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
id
)
setEmployees(prev => prev.filter(e => e.$id !== id))
return { success: true }
} catch (err) {
console.error('Error deleting employee:', err)
return { success: false, error: err.message }
}
}
/**
* Synchronisiert Appwrite Auth Users mit der employees Collection
* Erstellt fehlende Einträge für neue Users
*/
const syncWithAuthUsers = async () => {
if (DEMO_MODE) {
return { success: true, message: 'Demo-Modus: Keine Synchronisierung nötig' }
}
setSyncing(true)
try {
// 1. Lade alle Appwrite Auth Users
// Hinweis: In Appwrite 1.5.7 gibt es möglicherweise keine direkte List-Users API
// für normale User. Diese Funktion benötigt Server-Side Code oder Admin-API-Key.
// Für jetzt implementieren wir einen Workaround: Wir bieten ein manuelles Add-Interface.
// Alternative: Wenn der User Appwrite Admin ist, können wir versuchen:
// const users = await account.listUsers() // Funktioniert nur mit Admin-Rechten
// Da das nicht direkt möglich ist, geben wir eine Info zurück
return {
success: false,
error: 'Automatische Synchronisierung erfordert Admin-API-Zugriff. Bitte füge Mitarbeiter manuell hinzu oder verwende die Appwrite Server API.'
}
} catch (err) {
console.error('Error syncing auth users:', err)
return { success: false, error: err.message }
} finally {
setSyncing(false)
}
}
/**
* Erstellt einen Employee-Eintrag für den aktuell eingeloggten User
* Nützlich für Self-Service
*/
const createSelfEmployee = async (shortcode = '') => {
if (DEMO_MODE) {
return { success: true, message: 'Demo-Modus' }
}
try {
// Hole aktuellen User
const currentUser = await account.get()
// Prüfe, ob Employee bereits existiert
const existing = employees.find(e => e.userId === currentUser.$id)
if (existing) {
return { success: false, error: 'Mitarbeiter-Eintrag existiert bereits' }
}
// Erstelle Employee-Eintrag
const result = await createEmployee({
userId: currentUser.$id,
displayName: currentUser.name || currentUser.email,
email: currentUser.email,
shortcode: shortcode
})
return result
} catch (err) {
console.error('Error creating self employee:', err)
return { success: false, error: err.message }
}
}
return {
employees,
loading,
error,
syncing,
refresh: fetchEmployees,
createEmployee,
updateEmployee,
deleteEmployee,
syncWithAuthUsers,
createSelfEmployee
}
}

View File

@@ -5,11 +5,11 @@ const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Demo data for testing without Appwrite
const DEMO_WORKORDERS = [
{ $id: '1', title: 'Server Wartung', description: 'Monatliche Wartung', status: 'Open', priority: 2, type: 'Maintenance', customerName: 'Kunde A', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
{ $id: '2', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
{ $id: '3', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
{ $id: '4', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },
{ $id: '5', title: 'Kritischer Serverausfall', description: 'Produktionsserver down', status: 'Open', priority: 4, type: 'Emergency', customerName: 'Kunde E', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
{ $id: '1', woid: '10001', title: 'Server Wartung', description: 'Monatliche Wartung', status: 'Open', priority: 2, type: 'Maintenance', customerName: 'Kunde A', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
{ $id: '2', woid: '10002', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
{ $id: '3', woid: '10003', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
{ $id: '4', woid: '10004', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },
{ $id: '5', woid: '10005', title: 'Kritischer Serverausfall', description: 'Produktionsserver down', status: 'Open', priority: 4, type: 'Emergency', customerName: 'Kunde E', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
]
export function useWorkorders(filters = {}) {
@@ -44,16 +44,27 @@ export function useWorkorders(filters = {}) {
queries.push(Query.limit(filters.limit))
}
// Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or()
// Wir filtern clientseitig für mehrere Werte
if (filters.status && filters.status.length > 0) {
queries.push(Query.equal('status', filters.status))
if (filters.status.length === 1) {
queries.push(Query.equal('status', filters.status[0]))
}
// Für mehrere Werte: Clientseitig filtern (siehe unten)
}
if (filters.type && filters.type.length > 0) {
queries.push(Query.equal('type', filters.type))
if (filters.type.length === 1) {
queries.push(Query.equal('type', filters.type[0]))
}
// Für mehrere Werte: Clientseitig filtern
}
if (filters.priority && filters.priority.length > 0) {
queries.push(Query.equal('priority', filters.priority))
if (filters.priority.length === 1) {
queries.push(Query.equal('priority', filters.priority[0]))
}
// Für mehrere Werte: Clientseitig filtern
}
if (filters.customerId) {
@@ -64,16 +75,48 @@ export function useWorkorders(filters = {}) {
queries.push(Query.equal('assignedTo', filters.assignedTo))
}
// Debug: Zeige Collection ID
if (import.meta.env.DEV) {
console.log('📋 Fetching workorders:')
console.log(' Database ID:', DATABASE_ID)
console.log(' Collection ID:', COLLECTIONS.WORKORDERS)
console.log(' Queries:', queries.length)
}
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.WORKORDERS,
queries
)
setWorkorders(response.documents)
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
let filteredDocs = response.documents
if (filters.status && filters.status.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status))
}
if (filters.type && filters.type.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type))
}
if (filters.priority && filters.priority.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority))
}
setWorkorders(filteredDocs)
setError(null)
} catch (err) {
setError(err.message)
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
// Bessere Fehlermeldungen
if (err.code === 401 || errorMessage.includes('not authorized') || errorMessage.includes('Unauthorized')) {
errorMessage = 'Berechtigung fehlt: Bitte überprüfe die Read-Berechtigungen der Collection in Appwrite. Die Collection muss "Users" oder "Any" als Read-Berechtigung haben.'
} else if (errorMessage.includes('Collection') && errorMessage.includes('not found')) {
errorMessage = 'Collection nicht gefunden: Bitte überprüfe die Collection ID in der Konfiguration.'
}
setError(errorMessage)
console.error('Error fetching workorders:', err)
} finally {
setLoading(false)
@@ -86,26 +129,107 @@ export function useWorkorders(filters = {}) {
const createWorkorder = async (data) => {
if (DEMO_MODE) {
const newWo = { ...data, $id: Date.now().toString(), status: 'Open', $createdAt: new Date().toISOString() }
// Finde höchste WOID und +1
const maxWoid = workorders.length > 0
? Math.max(...workorders.map(wo => parseInt(wo.woid)).filter(w => !isNaN(w)))
: 9999;
const nextWoid = maxWoid + 1;
const newWo = { ...data, $id: Date.now().toString(), woid: nextWoid.toString(), status: 'Open', $createdAt: new Date().toISOString() }
setWorkorders(prev => [newWo, ...prev])
return { success: true, data: newWo }
}
try {
// Validierung: Prüfe required fields
if (!data.topic || data.topic.trim() === '') {
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
}
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open"
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
// Generiere sequentielle 5-stellige WOID (wie im Original-System)
const generateWOID = () => {
// Finde die höchste bestehende WOID
if (workorders.length === 0) {
return '10000'; // Starte bei 10000 wenn keine Tickets existieren
}
const maxWoid = Math.max(
...workorders
.map(wo => parseInt(wo.woid))
.filter(woid => !isNaN(woid) && woid > 0)
);
// Wenn keine gültige WOID gefunden wurde, starte bei 10000
if (maxWoid === -Infinity || isNaN(maxWoid)) {
return '10000';
}
// Gib die nächste Nummer zurück (sequentiell)
return (maxWoid + 1).toString();
}
// Bereite Daten für Appwrite vor
const workorderData = {
// Required fields
topic: data.topic.trim(),
status: data.status || autoStatus, // Verwende übergebenen Status oder automatischen Status
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
woid: generateWOID(), // 5-stellige Zahl
// Optional fields - nur senden wenn vorhanden
type: data.type || '',
systemType: data.systemType || '',
responseLevel: data.responseLevel || '',
serviceType: data.serviceType || 'Remote',
customerId: data.customerId || '',
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
requestedBy: data.requestedBy || '',
requestedFor: data.requestedFor || '',
startDate: data.startDate || '',
startTime: data.startTime || '',
deadline: data.deadline || '',
endTime: data.endTime || '',
estimate: data.estimate || '',
mailCopyTo: data.mailCopyTo || '',
sendNotification: data.sendNotification || false,
details: data.details || '',
// Datetime field
createdAt: new Date().toISOString()
}
// Entferne leere Strings (außer für required fields)
Object.keys(workorderData).forEach(key => {
if (workorderData[key] === '' && key !== 'topic' && key !== 'status') {
delete workorderData[key]
}
})
console.log('Creating workorder with data:', workorderData)
const response = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.WORKORDERS,
ID.unique(),
{
...data,
status: 'Open',
createdAt: new Date().toISOString()
}
workorderData
)
setWorkorders(prev => [response, ...prev])
return { success: true, data: response }
} catch (err) {
return { success: false, error: err.message }
console.error('Error creating workorder:', err)
let errorMessage = err.message || 'Fehler beim Erstellen des Tickets'
// Bessere Fehlermeldungen
if (err.code === 400 || errorMessage.includes('Bad Request')) {
errorMessage = 'Ungültige Daten: Bitte überprüfe, ob alle Pflichtfelder ausgefüllt sind und die Daten korrekt sind. Details: ' + (err.message || 'Unbekannter Fehler')
} else if (errorMessage.includes('required') || errorMessage.includes('missing')) {
errorMessage = 'Pflichtfelder fehlen: Bitte fülle alle erforderlichen Felder aus.'
}
return { success: false, error: errorMessage }
}
}
@@ -116,11 +240,27 @@ export function useWorkorders(filters = {}) {
}
try {
// Status-Automatik beim Update:
// Wenn assignedTo gesetzt wird → Status = "Assigned"
// Wenn assignedTo entfernt wird UND Status = "Assigned" → Status = "Open"
const updateData = { ...data }
if ('assignedTo' in updateData) {
if (updateData.assignedTo && updateData.assignedTo !== '') {
// Mitarbeiter zugewiesen → Status auf "Assigned" setzen
if (!updateData.status) {
updateData.status = 'Assigned'
}
} else if (!updateData.status) {
// Keine Zuweisung mehr → Status auf "Open" setzen (nur wenn nicht explizit anders gesetzt)
updateData.status = 'Open'
}
}
const response = await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.WORKORDERS,
id,
data
updateData
)
setWorkorders(prev =>
prev.map(wo => wo.$id === id ? response : wo)

362
src/hooks/useWorksheets.js Normal file
View File

@@ -0,0 +1,362 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Demo data für Testing
const DEMO_WORKSHEETS = [
{
$id: '1',
wsid: '100001',
woid: '10001',
workorderId: '1',
employeeId: 'emp1',
employeeName: 'Max Müller',
employeeShort: 'MAMU',
serviceType: 'Remote',
oldStatus: 'Open',
newStatus: 'Occupied',
totalTime: 30,
startDate: '29.12.2025',
startTime: '1000',
endDate: '29.12.2025',
endTime: '1030',
details: 'Router neu gestartet',
isComment: false,
$createdAt: new Date().toISOString()
},
]
export function useWorksheets(woid = null) {
const [worksheets, setWorksheets] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchWorksheets = useCallback(async () => {
setLoading(true)
if (DEMO_MODE) {
// Filter demo data by WOID if provided
const filtered = woid
? DEMO_WORKSHEETS.filter(ws => ws.woid === woid)
: DEMO_WORKSHEETS
setWorksheets(filtered)
setLoading(false)
return
}
try {
const queries = [Query.orderDesc('$createdAt')]
// Filter by WOID if provided
if (woid) {
queries.push(Query.equal('woid', woid))
}
if (import.meta.env.DEV) {
console.log('📋 Fetching worksheets:')
console.log(' Database ID:', DATABASE_ID)
console.log(' Collection ID:', COLLECTIONS.WORKSHEETS)
console.log(' WOID Filter:', woid || 'none')
}
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.WORKSHEETS,
queries
)
setWorksheets(response.documents)
setError(null)
} catch (err) {
let errorMessage = err.message || 'Fehler beim Laden der Worksheets'
if (err.code === 401 || errorMessage.includes('not authorized')) {
errorMessage = 'Berechtigung fehlt: Bitte überprüfe die Read-Berechtigungen der Worksheets Collection.'
} else if (errorMessage.includes('Collection') && errorMessage.includes('not found')) {
errorMessage = 'Worksheets Collection nicht gefunden. Bitte erstelle die Collection in Appwrite (siehe WORKSHEETS_COLLECTION_SETUP.md).'
}
setError(errorMessage)
console.error('Error fetching worksheets:', err)
} finally {
setLoading(false)
}
}, [woid])
useEffect(() => {
fetchWorksheets()
}, [fetchWorksheets])
/**
* Generiert eine eindeutige 6-stellige WSID
* Startet bei 100000 und zählt sequentiell hoch
*/
const generateWSID = useCallback(async () => {
if (DEMO_MODE) {
const maxWsid = worksheets.length > 0
? Math.max(...worksheets.map(ws => parseInt(ws.wsid)).filter(w => !isNaN(w)))
: 99999
return (maxWsid + 1).toString()
}
try {
// Hole ALLE Worksheets (nicht gefiltert) um höchste WSID zu finden
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.WORKSHEETS,
[Query.orderDesc('wsid'), Query.limit(1)]
)
if (response.documents.length === 0) {
return '100000' // Erste WSID
}
const highestWsid = parseInt(response.documents[0].wsid)
if (isNaN(highestWsid)) {
console.warn('Ungültige WSID gefunden, starte bei 100000')
return '100000'
}
return (highestWsid + 1).toString()
} catch (err) {
console.error('Error generating WSID:', err)
// Fallback: Verwende lokale Worksheets
const maxWsid = worksheets.length > 0
? Math.max(...worksheets.map(ws => parseInt(ws.wsid)).filter(w => !isNaN(w)))
: 99999
return (maxWsid + 1).toString()
}
}, [worksheets])
/**
* Berechnet Arbeitszeit aus Start- und Endzeit
* Format: "1000" = 10:00, "1430" = 14:30
* @returns Minuten oder null wenn ungültig
*/
const calculateTime = (startTime, endTime) => {
if (!startTime || !endTime) return null
try {
const startHour = parseInt(startTime.substring(0, 2))
const startMin = parseInt(startTime.substring(2, 4))
const endHour = parseInt(endTime.substring(0, 2))
const endMin = parseInt(endTime.substring(2, 4))
if (isNaN(startHour) || isNaN(startMin) || isNaN(endHour) || isNaN(endMin)) {
return null
}
const startTotal = startHour * 60 + startMin
const endTotal = endHour * 60 + endMin
let diff = endTotal - startTotal
// Handle overnight (z.B. 23:00 - 01:00)
if (diff < 0) {
diff += 24 * 60
}
return diff
} catch (err) {
return null
}
}
/**
* Erstellt ein neues Worksheet
*/
const createWorksheet = async (data, currentUser) => {
if (DEMO_MODE) {
const wsid = await generateWSID()
const newWs = {
...data,
$id: Date.now().toString(),
wsid,
$createdAt: new Date().toISOString()
}
setWorksheets(prev => [newWs, ...prev])
return { success: true, data: newWs }
}
try {
// Validierung
if (!data.woid || data.woid.trim() === '') {
return { success: false, error: 'WOID ist erforderlich' }
}
if (!data.workorderId || data.workorderId.trim() === '') {
return { success: false, error: 'Work Order ID ist erforderlich' }
}
if (!data.details || data.details.trim() === '') {
return { success: false, error: 'Details sind erforderlich' }
}
// WSID generieren
const wsid = await generateWSID()
// Automatische Zeitberechnung (wenn nicht manuell angegeben)
let totalTime = data.totalTime || 0
if (!data.isComment && data.startTime && data.endTime && !data.totalTime) {
const calculatedTime = calculateTime(data.startTime, data.endTime)
if (calculatedTime !== null) {
totalTime = calculatedTime
}
}
// Worksheet-Daten vorbereiten
const worksheetData = {
wsid,
woid: data.woid.trim(),
workorderId: data.workorderId.trim(),
employeeId: currentUser.$id,
employeeName: currentUser.name || currentUser.email,
employeeShort: data.employeeShort || '',
serviceType: data.serviceType || 'Remote',
oldStatus: data.oldStatus || '',
newStatus: data.newStatus || data.oldStatus || '',
oldResponseLevel: data.oldResponseLevel || '',
newResponseLevel: data.newResponseLevel || data.oldResponseLevel || '',
totalTime: parseInt(totalTime) || 0,
startDate: data.startDate || '',
startTime: data.startTime || '',
endDate: data.endDate || data.startDate || '',
endTime: data.endTime || '',
details: data.details.trim(),
isComment: data.isComment || false,
createdAt: new Date().toISOString()
}
console.log('Creating worksheet with data:', worksheetData)
const response = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.WORKSHEETS,
ID.unique(),
worksheetData
)
setWorksheets(prev => [response, ...prev])
return { success: true, data: response }
} catch (err) {
console.error('Error creating worksheet:', err)
return {
success: false,
error: err.message || 'Fehler beim Erstellen des Worksheets'
}
}
}
/**
* Aktualisiert ein Worksheet
*/
const updateWorksheet = async (id, data) => {
if (DEMO_MODE) {
setWorksheets(prev => prev.map(ws => ws.$id === id ? { ...ws, ...data } : ws))
return { success: true }
}
try {
const response = await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.WORKSHEETS,
id,
data
)
setWorksheets(prev => prev.map(ws => ws.$id === id ? response : ws))
return { success: true, data: response }
} catch (err) {
console.error('Error updating worksheet:', err)
return { success: false, error: err.message }
}
}
/**
* Löscht ein Worksheet (sollte normalerweise nicht erlaubt sein - Audit Trail!)
*/
const deleteWorksheet = async (id) => {
if (DEMO_MODE) {
setWorksheets(prev => prev.filter(ws => ws.$id !== id))
return { success: true }
}
try {
await databases.deleteDocument(
DATABASE_ID,
COLLECTIONS.WORKSHEETS,
id
)
setWorksheets(prev => prev.filter(ws => ws.$id !== id))
return { success: true }
} catch (err) {
console.error('Error deleting worksheet:', err)
return { success: false, error: err.message }
}
}
/**
* Berechnet die Gesamtarbeitszeit für alle Worksheets
* @returns Minuten
*/
const getTotalTime = useCallback(() => {
return worksheets
.filter(ws => !ws.isComment)
.reduce((sum, ws) => sum + (ws.totalTime || 0), 0)
}, [worksheets])
/**
* Gruppiert Worksheets nach Mitarbeiter
* @returns Object mit employeeId als Key
*/
const getWorksheetsByEmployee = useCallback(() => {
return worksheets.reduce((acc, ws) => {
const empId = ws.employeeId
if (!acc[empId]) {
acc[empId] = {
employeeName: ws.employeeName,
employeeShort: ws.employeeShort,
worksheets: [],
totalTime: 0
}
}
acc[empId].worksheets.push(ws)
if (!ws.isComment) {
acc[empId].totalTime += ws.totalTime || 0
}
return acc
}, {})
}, [worksheets])
/**
* Gibt die Status-Historie zurück (chronologisch)
*/
const getStatusHistory = useCallback(() => {
return worksheets
.filter(ws => ws.oldStatus && ws.newStatus)
.map(ws => ({
wsid: ws.wsid,
date: ws.startDate,
time: ws.startTime,
employee: ws.employeeName,
from: ws.oldStatus,
to: ws.newStatus,
details: ws.details
}))
.reverse() // Älteste zuerst
}, [worksheets])
return {
worksheets,
loading,
error,
createWorksheet,
updateWorksheet,
deleteWorksheet,
refresh: fetchWorksheets,
getTotalTime,
getWorksheetsByEmployee,
getStatusHistory,
calculateTime
}
}

View File

@@ -1,8 +1,24 @@
import { Client, Account, Databases, Storage, ID, Query } from 'appwrite'
// Debug: Zeige geladene Umgebungsvariablen (nur in Development)
if (import.meta.env.DEV) {
console.log('🔧 Appwrite Konfiguration:')
console.log('Endpoint:', import.meta.env.VITE_APPWRITE_ENDPOINT || 'NICHT GESETZT')
console.log('Project ID:', import.meta.env.VITE_APPWRITE_PROJECT_ID || 'NICHT GESETZT')
console.log('Database ID:', import.meta.env.VITE_APPWRITE_DATABASE_ID || 'NICHT GESETZT')
}
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1'
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
if (!projectId) {
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!')
console.error('Bitte überprüfe deine .env Datei im Root-Verzeichnis.')
}
const client = new Client()
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1')
.setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID || '')
.setEndpoint(endpoint)
.setProject(projectId)
export const account = new Account(client)
export const databases = new Databases(client)
@@ -10,11 +26,13 @@ export const storage = new Storage(client)
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database'
// Collection IDs
// Collection IDs - Verwende die tatsächlichen Collection IDs aus Appwrite!
export const COLLECTIONS = {
WORKORDERS: 'workorders',
WORKSHEETS: 'worksheets',
CUSTOMERS: 'customers',
WORKORDERS: '6943bf7d001901baa60c', // Collection ID für workorders
CONFIG: 'config', // Collection ID für Admin-Konfiguration (wird erstellt)
CUSTOMERS: '694bd1fb002b2e583d13', // Collection ID für customers
EMPLOYEES: '695280510031c6c6153b', // Collection ID für employees
WORKSHEETS: '6952dbcf0032a92e1168', // Collection ID für worksheets ✅
USERS: 'users',
ATTACHMENTS: 'attachments'
}

7
src/lib/utils.js Normal file
View File

@@ -0,0 +1,7 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

698
src/pages/AdminPage.jsx Normal file
View File

@@ -0,0 +1,698 @@
import { useState, useEffect } from 'react'
import { useAdminConfig } from '../hooks/useAdminConfig'
import { useAuth } from '../context/AuthContext'
import { useCustomers } from '../hooks/useCustomers'
import { useEmployees } from '../hooks/useEmployees'
import { FaPlus, FaTrash, FaFloppyDisk, FaSpinner } from 'react-icons/fa6'
import { FaEdit } from 'react-icons/fa'
export default function AdminPage() {
const { user, isAdmin } = useAuth()
const { config, loading, error, updateConfig } = useAdminConfig()
const { customers, loading: customersLoading, createCustomer, updateCustomer, deleteCustomer, refresh: refreshCustomers } = useCustomers()
const { employees, loading: employeesLoading, createEmployee, updateEmployee, deleteEmployee, refresh: refreshEmployees } = useEmployees()
const [localConfig, setLocalConfig] = useState(() => {
// Initialisiere mit Default-Werten falls config noch nicht geladen
if (config && Object.keys(config).length > 0) {
return config
}
return {
ticketTypes: [],
systems: [],
responseLevels: [],
serviceTypes: [],
priorities: []
}
})
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState('')
const [editingCustomer, setEditingCustomer] = useState(null)
const [customerForm, setCustomerForm] = useState({ code: '', name: '', location: '', email: '', phone: '' })
const [editingEmployee, setEditingEmployee] = useState(null)
const [employeeForm, setEmployeeForm] = useState({ userId: '', displayName: '', email: '', shortcode: '' })
// Update localConfig when config loads
useEffect(() => {
if (config && Object.keys(config).length > 0) {
setLocalConfig(config)
}
}, [config])
if (!isAdmin) {
return (
<div className="main-content">
<div className="card">
<div className="card-header">
<h2>Zugriff verweigert</h2>
</div>
<div className="card-body">
<p>Du hast keine Berechtigung, auf diese Seite zuzugreifen.</p>
</div>
</div>
</div>
)
}
const handleAddItem = (field) => {
setLocalConfig(prev => ({
...prev,
[field]: [...prev[field], field === 'priorities' ? { value: prev[field].length, label: 'New' } : 'New Item']
}))
}
const handleRemoveItem = (field, index) => {
setLocalConfig(prev => ({
...prev,
[field]: prev[field].filter((_, i) => i !== index)
}))
}
const handleUpdateItem = (field, index, value) => {
setLocalConfig(prev => {
const newArray = [...prev[field]]
if (field === 'priorities') {
newArray[index] = { ...newArray[index], ...value }
} else {
newArray[index] = value
}
return { ...prev, [field]: newArray }
})
}
const handleSave = async () => {
setSaving(true)
setSaveMessage('')
const result = await updateConfig(localConfig)
if (result.success) {
setSaveMessage('Konfiguration erfolgreich gespeichert!')
setTimeout(() => setSaveMessage(''), 3000)
} else {
setSaveMessage('Fehler beim Speichern: ' + (result.error || 'Unbekannter Fehler'))
}
setSaving(false)
}
if (loading && !config) {
return (
<div className="main-content text-center p-4">
<FaSpinner className="spinner" size={32} />
<p>Lade Konfiguration...</p>
</div>
)
}
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Admin Panel - Dropdown Konfiguration</h2>
</header>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
Fehler: {error}
</div>
)}
{saveMessage && (
<div className={`p-2 mb-2 ${saveMessage.includes('erfolgreich') ? 'bg-green text-white' : 'bg-red text-white'}`} style={{ borderRadius: '4px' }}>
{saveMessage}
</div>
)}
<div className="row">
{/* Ticket Types */}
<div className="col col-6">
<div className="card mb-2">
<div className="card-header">
<h3>Work Order Types</h3>
</div>
<div className="card-body">
{localConfig.ticketTypes?.map((type, index) => (
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
className="form-control"
value={type}
onChange={(e) => handleUpdateItem('ticketTypes', index, e.target.value)}
style={{ flex: 1 }}
/>
<button
className="btn btn-red"
onClick={() => handleRemoveItem('ticketTypes', index)}
>
<FaTrash />
</button>
</div>
))}
<button
className="btn btn-green"
onClick={() => handleAddItem('ticketTypes')}
style={{ width: '100%', marginTop: '8px' }}
>
<FaPlus /> Hinzufügen
</button>
</div>
</div>
</div>
{/* Systems */}
<div className="col col-6">
<div className="card mb-2">
<div className="card-header">
<h3>Affected Systems</h3>
</div>
<div className="card-body">
{localConfig.systems?.map((system, index) => (
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
className="form-control"
value={system}
onChange={(e) => handleUpdateItem('systems', index, e.target.value)}
style={{ flex: 1 }}
/>
<button
className="btn btn-red"
onClick={() => handleRemoveItem('systems', index)}
>
<FaTrash />
</button>
</div>
))}
<button
className="btn btn-green"
onClick={() => handleAddItem('systems')}
style={{ width: '100%', marginTop: '8px' }}
>
<FaPlus /> Hinzufügen
</button>
</div>
</div>
</div>
</div>
<div className="row">
{/* Response Levels */}
<div className="col col-6">
<div className="card mb-2">
<div className="card-header">
<h3>Response Levels</h3>
</div>
<div className="card-body">
{localConfig.responseLevels?.map((level, index) => (
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
className="form-control"
value={level}
onChange={(e) => handleUpdateItem('responseLevels', index, e.target.value)}
style={{ flex: 1 }}
/>
<button
className="btn btn-red"
onClick={() => handleRemoveItem('responseLevels', index)}
>
<FaTrash />
</button>
</div>
))}
<button
className="btn btn-green"
onClick={() => handleAddItem('responseLevels')}
style={{ width: '100%', marginTop: '8px' }}
>
<FaPlus /> Hinzufügen
</button>
</div>
</div>
</div>
{/* Service Types */}
<div className="col col-6">
<div className="card mb-2">
<div className="card-header">
<h3>Service Types</h3>
</div>
<div className="card-body">
{localConfig.serviceTypes?.map((type, index) => (
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
className="form-control"
value={type}
onChange={(e) => handleUpdateItem('serviceTypes', index, e.target.value)}
style={{ flex: 1 }}
/>
<button
className="btn btn-red"
onClick={() => handleRemoveItem('serviceTypes', index)}
>
<FaTrash />
</button>
</div>
))}
<button
className="btn btn-green"
onClick={() => handleAddItem('serviceTypes')}
style={{ width: '100%', marginTop: '8px' }}
>
<FaPlus /> Hinzufügen
</button>
</div>
</div>
</div>
</div>
{/* Priorities */}
<div className="card mb-2">
<div className="card-header">
<h3>Priorities</h3>
</div>
<div className="card-body">
{localConfig.priorities?.map((priority, index) => (
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="number"
className="form-control"
value={priority.value}
onChange={(e) => handleUpdateItem('priorities', index, { ...priority, value: parseInt(e.target.value) })}
style={{ width: '100px' }}
placeholder="Value"
/>
<input
type="text"
className="form-control"
value={priority.label}
onChange={(e) => handleUpdateItem('priorities', index, { ...priority, label: e.target.value })}
style={{ flex: 1 }}
placeholder="Label"
/>
<button
className="btn btn-red"
onClick={() => handleRemoveItem('priorities', index)}
>
<FaTrash />
</button>
</div>
))}
<button
className="btn btn-green"
onClick={() => handleAddItem('priorities')}
style={{ width: '100%', marginTop: '8px' }}
>
<FaPlus /> Hinzufügen
</button>
</div>
</div>
{/* Customers */}
<div className="card mb-2">
<div className="card-header">
<h3>Customers</h3>
</div>
<div className="card-body">
{customersLoading ? (
<div className="text-center p-2">
<FaSpinner className="spinner" /> Lade Kunden...
</div>
) : (
<>
<table className="table" style={{ marginBottom: '16px' }}>
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Location</th>
<th>Email</th>
<th>Phone</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{customers.map((customer) => (
<tr key={customer.$id}>
{editingCustomer === customer.$id ? (
<>
<td>
<input
type="text"
className="form-control"
value={customerForm.code}
onChange={(e) => setCustomerForm(prev => ({ ...prev, code: e.target.value }))}
style={{ width: '100px' }}
/>
</td>
<td>
<input
type="text"
className="form-control"
value={customerForm.name}
onChange={(e) => setCustomerForm(prev => ({ ...prev, name: e.target.value }))}
/>
</td>
<td>
<input
type="text"
className="form-control"
value={customerForm.location}
onChange={(e) => setCustomerForm(prev => ({ ...prev, location: e.target.value }))}
/>
</td>
<td>
<input
type="email"
className="form-control"
value={customerForm.email}
onChange={(e) => setCustomerForm(prev => ({ ...prev, email: e.target.value }))}
/>
</td>
<td>
<input
type="text"
className="form-control"
value={customerForm.phone}
onChange={(e) => setCustomerForm(prev => ({ ...prev, phone: e.target.value }))}
/>
</td>
<td>
<button
className="btn btn-green"
onClick={async () => {
const result = await updateCustomer(customer.$id, customerForm)
if (result.success) {
setEditingCustomer(null)
setCustomerForm({ code: '', name: '', location: '', email: '', phone: '' })
}
}}
>
Speichern
</button>
<button
className="btn"
onClick={() => {
setEditingCustomer(null)
setCustomerForm({ code: '', name: '', location: '', email: '', phone: '' })
}}
style={{ marginLeft: '4px' }}
>
Abbrechen
</button>
</td>
</>
) : (
<>
<td>{customer.code || '-'}</td>
<td>{customer.name || '-'}</td>
<td>{customer.location || '-'}</td>
<td>{customer.email || '-'}</td>
<td>{customer.phone || '-'}</td>
<td>
<button
className="btn"
onClick={() => {
setEditingCustomer(customer.$id)
setCustomerForm({
code: customer.code || '',
name: customer.name || '',
location: customer.location || '',
email: customer.email || '',
phone: customer.phone || ''
})
}}
>
<FaEdit />
</button>
<button
className="btn btn-red"
onClick={async () => {
if (confirm(`Möchtest du ${customer.name || customer.code} wirklich löschen?`)) {
await deleteCustomer(customer.$id)
}
}}
style={{ marginLeft: '4px' }}
>
<FaTrash />
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
<div className="card" style={{ background: '#f5f5f5', padding: '16px' }}>
<h4 style={{ marginTop: 0 }}>Neuen Kunden hinzufügen</h4>
<div className="row">
<div className="col col-3">
<div className="form-group">
<label className="form-label">Code</label>
<input
type="text"
className="form-control"
value={customerForm.code}
onChange={(e) => setCustomerForm(prev => ({ ...prev, code: e.target.value }))}
placeholder="C001"
/>
</div>
</div>
<div className="col col-3">
<div className="form-group">
<label className="form-label">Name</label>
<input
type="text"
className="form-control"
value={customerForm.name}
onChange={(e) => setCustomerForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="Kundenname"
required
/>
</div>
</div>
<div className="col col-3">
<div className="form-group">
<label className="form-label">Location</label>
<input
type="text"
className="form-control"
value={customerForm.location}
onChange={(e) => setCustomerForm(prev => ({ ...prev, location: e.target.value }))}
placeholder="Stadt"
/>
</div>
</div>
<div className="col col-3">
<div className="form-group">
<label className="form-label">Email</label>
<input
type="email"
className="form-control"
value={customerForm.email}
onChange={(e) => setCustomerForm(prev => ({ ...prev, email: e.target.value }))}
placeholder="email@example.com"
/>
</div>
</div>
</div>
<div className="row">
<div className="col col-3">
<div className="form-group">
<label className="form-label">Phone</label>
<input
type="text"
className="form-control"
value={customerForm.phone}
onChange={(e) => setCustomerForm(prev => ({ ...prev, phone: e.target.value }))}
placeholder="030-123456"
/>
</div>
</div>
<div className="col col-9">
<div className="form-group" style={{ marginTop: '24px' }}>
<button
className="btn btn-green"
onClick={async () => {
if (!customerForm.name) {
alert('Bitte gib mindestens einen Namen ein.')
return
}
const result = await createCustomer(customerForm)
if (result.success) {
setCustomerForm({ code: '', name: '', location: '', email: '', phone: '' })
} else {
alert('Fehler beim Erstellen: ' + (result.error || 'Unbekannter Fehler'))
}
}}
>
<FaPlus /> Kunden hinzufügen
</button>
</div>
</div>
</div>
</div>
</>
)}
</div>
</div>
{/* Employees */}
<div className="card mb-2">
<div className="card-header">
<h3>Mitarbeiter & Kürzel</h3>
</div>
<div className="card-body">
{employeesLoading ? (
<div className="text-center p-2">
<FaSpinner className="spinner" /> Lade Mitarbeiter...
</div>
) : (
<>
{employees.length === 0 ? (
<div style={{ padding: '20px', textAlign: 'center', background: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 'bold' }}>
Noch keine Mitarbeiter in der Liste
</p>
<p style={{ margin: 0, fontSize: '14px', color: '#666' }}>
Sobald sich Benutzer einloggen, werden sie automatisch hier angezeigt.
</p>
</div>
) : (
<table className="table" style={{ marginBottom: '16px' }}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Kürzel</th>
<th>User ID</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.$id}>
{editingEmployee === employee.$id ? (
<>
<td>{employee.displayName || '-'}</td>
<td>{employee.email || '-'}</td>
<td>
<input
type="text"
className="form-control"
value={employeeForm.shortcode}
onChange={(e) => setEmployeeForm(prev => ({ ...prev, shortcode: e.target.value.toUpperCase() }))}
placeholder="KNSO"
style={{ width: '100px' }}
maxLength={10}
autoFocus
/>
</td>
<td>
<small style={{ color: '#666' }}>{employee.userId.substring(0, 12)}...</small>
</td>
<td>
<button
className="btn btn-green"
onClick={async () => {
const result = await updateEmployee(employee.$id, {
shortcode: employeeForm.shortcode
})
if (result.success) {
setEditingEmployee(null)
setEmployeeForm({ userId: '', displayName: '', email: '', shortcode: '' })
} else {
alert('Fehler: ' + (result.error || 'Unbekannter Fehler'))
}
}}
>
Speichern
</button>
<button
className="btn"
onClick={() => {
setEditingEmployee(null)
setEmployeeForm({ userId: '', displayName: '', email: '', shortcode: '' })
}}
style={{ marginLeft: '4px' }}
>
Abbrechen
</button>
</td>
</>
) : (
<>
<td>{employee.displayName || '-'}</td>
<td>{employee.email || '-'}</td>
<td>
<strong style={{ color: employee.shortcode ? '#007bff' : '#999' }}>
{employee.shortcode || '(kein Kürzel)'}
</strong>
</td>
<td>
<small style={{ color: '#666' }}>{employee.userId.substring(0, 12)}...</small>
</td>
<td>
<button
className="btn btn-blue"
onClick={() => {
setEditingEmployee(employee.$id)
setEmployeeForm({
userId: employee.userId,
displayName: employee.displayName || '',
email: employee.email || '',
shortcode: employee.shortcode || ''
})
}}
title="Kürzel bearbeiten"
>
<FaEdit /> Kürzel
</button>
<button
className="btn btn-red"
onClick={async () => {
if (confirm(`Möchtest du ${employee.displayName} wirklich aus der Mitarbeiter-Liste entfernen?`)) {
await deleteEmployee(employee.$id)
}
}}
style={{ marginLeft: '4px' }}
title="Aus Mitarbeiter-Liste entfernen"
>
<FaTrash />
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
</div>
<div className="text-center mt-2">
<button
className="btn btn-dark"
onClick={handleSave}
disabled={saving}
style={{ minWidth: '200px' }}
>
{saving ? (
<>
<FaSpinner className="spinner" /> Speichere...
</>
) : (
<>
<FaFloppyDisk /> Konfiguration speichern
</>
)}
</button>
</div>
</div>
)
}

View File

@@ -5,10 +5,12 @@ import { useAuth } from '../context/AuthContext'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [isRegistering, setIsRegistering] = useState(false)
const { login } = useAuth()
const { login, register } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
@@ -16,15 +18,36 @@ export default function LoginPage() {
setError('')
setLoading(true)
const result = await login(email, password)
if (result.success) {
navigate('/tickets')
} else {
setError(result.error || 'Login failed')
try {
const result = isRegistering
? await register(email, password, name || email.split('@')[0])
: await login(email, password)
if (result.success) {
navigate('/tickets')
} else {
// Bessere Fehlermeldungen
let errorMessage = result.error || 'Login fehlgeschlagen'
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('401')) {
errorMessage = 'Ungültige Email oder Passwort. Bitte überprüfe deine Eingaben.'
} else if (errorMessage.includes('User already exists')) {
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
setIsRegistering(false)
} else if (errorMessage.includes('User with the same email already exists')) {
errorMessage = 'Diese Email ist bereits registriert. Bitte logge dich ein.'
setIsRegistering(false)
} else if (errorMessage.includes('Email/Password') || errorMessage.includes('auth')) {
errorMessage = 'Email/Password Authentifizierung ist möglicherweise nicht aktiviert. Bitte überprüfe deine Appwrite-Konfiguration.'
}
setError(errorMessage)
}
} catch (err) {
setError('Ein unerwarteter Fehler ist aufgetreten: ' + (err.message || 'Unbekannter Fehler'))
} finally {
setLoading(false)
}
setLoading(false)
}
return (
@@ -37,7 +60,7 @@ export default function LoginPage() {
}}>
<div className="card" style={{ width: '400px' }}>
<div className="card-header text-center">
<h2>NetWEB Systems WOMS 2.0</h2>
<h2>Webklar WOMS 2.0</h2>
</div>
<div className="card-body">
<form onSubmit={handleSubmit}>
@@ -47,6 +70,19 @@ export default function LoginPage() {
</div>
)}
{isRegistering && (
<div className="form-group">
<label className="form-label">Name (optional)</label>
<input
type="text"
className="form-control"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Dein Name"
/>
</div>
)}
<div className="form-group">
<label className="form-label">Email</label>
<input
@@ -55,6 +91,7 @@ export default function LoginPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="deine@email.com"
/>
</div>
@@ -66,16 +103,42 @@ export default function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
minLength={8}
/>
</div>
<button
type="submit"
className="btn btn-green"
style={{ width: '100%' }}
style={{ width: '100%', marginBottom: '10px' }}
disabled={loading}
>
{loading ? 'Logging in...' : 'Login'}
{loading
? (isRegistering ? 'Registrierung läuft...' : 'Login läuft...')
: (isRegistering ? 'Registrieren' : 'Login')
}
</button>
<button
type="button"
className="btn"
style={{
width: '100%',
background: 'transparent',
border: '1px solid #ccc',
color: '#333'
}}
onClick={() => {
setIsRegistering(!isRegistering)
setError('')
}}
disabled={loading}
>
{isRegistering
? 'Bereits registriert? Hier einloggen'
: 'Noch kein Account? Hier registrieren'
}
</button>
</form>
</div>

View File

@@ -2,8 +2,8 @@ import { useState } from 'react'
import { format } from 'date-fns'
import { FaAngleDown, FaSpinner } from 'react-icons/fa6'
import { useWorkorders } from '../hooks/useWorkorders'
import { useCustomers } from '../hooks/useCustomers'
import TicketRow from '../components/TicketRow'
import TicketFilters from '../components/TicketFilters'
import CreateTicketModal from '../components/CreateTicketModal'
import QuickOverviewModal from '../components/QuickOverviewModal'
@@ -17,6 +17,7 @@ export default function TicketsPage() {
})
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
const { customers } = useCustomers()
const [showCreateModal, setShowCreateModal] = useState(false)
const [showOverviewModal, setShowOverviewModal] = useState(false)
@@ -54,104 +55,230 @@ export default function TicketsPage() {
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Active Tickets Overview</h2>
</header>
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.2)',
padding: '24px',
marginBottom: '24px',
textAlign: 'center'
}}>
<h2 style={{
color: 'var(--dark-text)',
marginBottom: '12px',
fontSize: '28px',
fontWeight: 'bold'
}}>
Active Tickets Overview
</h2>
<p style={{ color: '#a0aec0', marginBottom: '8px' }}>
Work Order loading limit is set to <span style={{
fontSize: '24px',
color: 'var(--green-primary)',
fontWeight: 'bold'
}}>{limit}</span>.
Reduce value to increase reload speed.
</p>
<p style={{ color: '#718096', fontSize: '12px' }}>
Last page reload: {format(new Date(), 'dd.MM.yyyy, HH:mm:ss')}
</p>
</div>
<p className="text-center text-grey">
Work Order loading limit is set to <span className="text-xlarge">{limit}</span>.
Reduce value to increase reload speed.
</p>
<p className="text-center text-grey text-small">
Last page reload: {format(new Date(), 'dd.MM.yyyy, HH:mm:ss')}
</p>
{/* Unified Control Panel - All in One */}
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.2)',
overflow: 'hidden',
marginBottom: '24px'
}}>
{/* Extended Filters + Quick Selection - TOP */}
<div style={{
padding: '20px',
borderBottom: '1px solid rgba(16, 185, 129, 0.2)'
}}>
{/* Main Filter Row */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
alignItems: 'center',
marginBottom: '16px'
}}>
<input
type="text"
placeholder="WOID"
className="form-control"
style={{ margin: 0 }}
value={filters.woid || ''}
onChange={(e) => setFilters({ ...filters, woid: e.target.value })}
/>
<input
type="text"
placeholder="Created Date"
className="form-control"
style={{ margin: 0 }}
value={filters.createdDate || ''}
onChange={(e) => setFilters({ ...filters, createdDate: e.target.value })}
/>
<select
className="form-control"
style={{ margin: 0 }}
value={filters.type?.[0] || ''}
onChange={(e) => setFilters({ ...filters, type: e.target.value ? [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={filters.system?.[0] || ''}
onChange={(e) => setFilters({ ...filters, system: e.target.value ? [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>
<input
type="text"
placeholder="Customer"
className="form-control"
style={{ margin: 0 }}
value={filters.customer || ''}
onChange={(e) => setFilters({ ...filters, customer: e.target.value })}
/>
<input
type="text"
placeholder="Topic / User"
className="form-control"
style={{ margin: 0 }}
value={filters.userTopic || ''}
onChange={(e) => setFilters({ ...filters, userTopic: e.target.value })}
/>
<select
className="form-control"
style={{ margin: 0 }}
value={filters.priority?.[0] ?? ''}
onChange={(e) => setFilters({ ...filters, priority: e.target.value ? [parseInt(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>
<button
className="btn btn-green"
onClick={handleApplyFilters}
style={{ margin: 0 }}
>
Apply!
</button>
</div>
<hr className="mb-2" />
{/* Quick Selection Buttons */}
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
paddingTop: '16px',
borderTop: '1px solid rgba(16, 185, 129, 0.1)'
}}>
<button
className="btn btn-green"
onClick={() => { setFilters(prev => ({ ...prev, type: ['Procurement'] })); handleApplyFilters(); }}
>
Procurements
</button>
<button
className="btn btn-green"
onClick={() => { setFilters(prev => ({ ...prev, priority: [4] })); handleApplyFilters(); }}
>
Criticals
</button>
<button
className="btn btn-green"
onClick={() => { setFilters(prev => ({ ...prev, priority: [3] })); handleApplyFilters(); }}
>
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 })) }}
>
10
</button>
<button
className="btn btn-green"
onClick={() => { setLimit(25); setFilters(prev => ({ ...prev, limit: 25 })) }}
>
25
</button>
</div>
</div>
{/* Slider + Action Buttons - BOTTOM */}
<div style={{
padding: '20px',
display: 'flex',
gap: '16px',
alignItems: 'center',
flexWrap: 'wrap'
}}>
<div style={{ flex: '1', minWidth: '200px' }}>
<input
type="range"
min="5"
max="50"
value={limit}
className="slider"
onChange={handleLimitChange}
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<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>
</div>
</div>
</div>
<table className="table table-hover">
<tbody>
<TicketFilters
filters={filters}
onChange={handleFilterChange}
onApply={handleApplyFilters}
/>
<tr>
<td colSpan={10} className="bg-dark-grey">
<div className="slider-container text-center">
<input
type="range"
min="5"
max="50"
value={limit}
className="slider"
onChange={handleLimitChange}
/>
</div>
</td>
</tr>
<tr>
<td colSpan={10} className="text-center p-1">
<button
className="btn btn-teal"
onClick={() => setFilters(prev => ({ ...prev, type: ['Procurement'] }))}
>
Procurements
</button>
{' '}
<button
className="btn btn-teal"
onClick={() => setFilters(prev => ({ ...prev, priority: [4] }))}
>
Criticals
</button>
{' '}
<button
className="btn btn-teal"
onClick={() => setFilters(prev => ({ ...prev, priority: [3] }))}
>
Highs
</button>
{' '}
<button
className="btn btn-teal"
onClick={() => { setLimit(10); setFilters(prev => ({ ...prev, limit: 10 })) }}
>
10
</button>
{' '}
<button
className="btn btn-teal"
onClick={() => { setLimit(25); setFilters(prev => ({ ...prev, limit: 25 })) }}
>
25
</button>
</td>
</tr>
<tr>
<td colSpan={10} className="text-center p-1">
<button
className="btn btn-dark"
onClick={() => setShowCreateModal(true)}
>
CREATE NEW TICKET
</button>
{' '}
<button
className="btn btn-dark"
onClick={() => setShowOverviewModal(true)}
>
QUICK OVERVIEW
</button>
</td>
</tr>
<tr className="spacer">
<td colSpan={10} style={{ height: '16px' }}></td>
</tr>
{loading ? (
<tr>
<td colSpan={10} className="text-center p-2">
@@ -184,26 +311,47 @@ export default function TicketsPage() {
</table>
{workorders.length > 0 && workorders.length >= limit && (
<div className="text-center mt-2">
<div style={{ textAlign: 'center', marginTop: '24px' }}>
<button
className="btn"
style={{ background: 'none', border: 'none' }}
className="btn btn-green"
style={{
padding: '16px 32px',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 auto'
}}
onClick={handleLoadMore}
>
<FaAngleDown size={48} />
Load More <FaAngleDown size={20} />
</button>
</div>
)}
<p className="text-center text-grey mt-2">
Summary: Listed a total of {workorders.length} Workorders.
<br />EOL =)
</p>
<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>
<CreateTicketModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onCreate={handleCreate}
customers={customers}
/>
<QuickOverviewModal

View File

@@ -4,31 +4,67 @@
box-sizing: border-box;
}
:root {
--dark-bg: #1a202c;
--dark-card-bg: rgba(45, 55, 72, 0.95);
--dark-text: #e2e8f0;
--green-primary: #10b981;
--green-secondary: #059669;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
}
body {
font-family: 'Lato', sans-serif;
background-color: #fff;
color: #000;
background-color: var(--dark-bg);
color: var(--dark-text);
overflow: hidden;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: rgba(26, 32, 44, 0.5);
}
::-webkit-scrollbar-thumb {
background: var(--green-primary);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--green-secondary);
}
/* Text Selection */
::selection {
background: rgba(16, 185, 129, 0.3);
color: white;
}
/* Color Classes */
.bg-dark-grey { background-color: #616161; }
.bg-light-grey { background-color: #f1f1f1; }
.bg-white { background-color: #fff; }
.bg-black { background-color: #000; }
.bg-green { background-color: #4CAF50; }
.bg-teal { background-color: #009688; }
.bg-blue { background-color: #2196F3; }
.bg-blue-grey { background-color: #607D8B; }
.bg-red { background-color: #f44336; }
.bg-yellow { background-color: #ffeb3b; }
.bg-amber { background-color: #ffc107; }
.bg-orange { background-color: #ff9800; }
.bg-dark-grey { background-color: #616161 !important; }
.bg-light-grey { background-color: #f1f1f1 !important; }
.bg-white { background-color: #fff !important; }
.bg-black { background-color: #000 !important; }
.bg-green { background-color: #4CAF50 !important; }
.bg-teal { background-color: #009688 !important; }
.bg-blue { background-color: #2196F3 !important; }
.bg-blue-grey { background-color: #607D8B !important; }
.bg-red { background-color: #f44336 !important; }
.bg-yellow { background-color: #ffeb3b !important; }
.bg-amber { background-color: #ffc107 !important; }
.bg-orange { background-color: #ff9800 !important; }
.text-white { color: #fff; }
.text-black { color: #000; }
.text-grey { color: #9e9e9e; }
.text-green { color: #4CAF50; }
.text-red { color: #f44336; }
.text-white { color: #fff !important; }
.text-black { color: #000 !important; }
.text-grey { color: #9e9e9e !important; }
.text-green { color: #4CAF50 !important; }
.text-red { color: #f44336 !important; }
/* Layout */
.container {
@@ -48,7 +84,8 @@ body {
top: 0;
left: 0;
right: 0;
background: #000;
background: linear-gradient(135deg, rgba(26, 32, 44, 0.98) 0%, rgba(17, 24, 39, 0.98) 100%);
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
z-index: 199;
display: flex;
align-items: center;
@@ -61,6 +98,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.navbar-nav {
@@ -70,13 +108,15 @@ body {
.nav-link {
padding: 16px 24px;
color: #fff;
color: var(--dark-text);
text-decoration: none;
transition: background 0.3s;
transition: all 0.2s ease;
border-radius: 6px;
}
.nav-link:hover {
background: #4CAF50;
background: rgba(16, 185, 129, 0.2);
color: var(--green-primary);
}
.nav-right {
@@ -85,80 +125,147 @@ body {
/* Buttons */
.btn {
padding: 8px 16px;
padding: 10px 20px;
border: none;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
border-radius: 8px;
font-weight: 600;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.btn-dark {
background: #616161;
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
color: #fff;
}
.btn-dark:hover {
background: #4CAF50;
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(74, 85, 104, 0.5);
}
.btn-green {
background: #4CAF50;
color: #fff;
.btn-green,
.btn-primary {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
color: #fff !important;
border: none !important;
}
.btn-green:hover,
.btn-primary:hover {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.5);
}
.btn-teal {
background: #009688;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #fff;
}
.btn-secondary {
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%) !important;
color: #fff !important;
border: none !important;
}
/* Table */
.table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
color: var(--dark-text) !important;
background: transparent !important;
}
.table th,
.table td {
padding: 8px 12px;
text-align: left;
border: 1px solid #ddd;
border: 1px solid rgba(16, 185, 129, 0.2);
background: transparent;
}
/* Remove borders between rowspan cells */
.table td[rowspan] {
border-top: 1px solid rgba(16, 185, 129, 0.2);
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
border-left: 1px solid rgba(16, 185, 129, 0.2);
border-right: 1px solid rgba(16, 185, 129, 0.2);
}
.table th {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
color: var(--dark-text);
font-weight: bold;
}
.table tbody {
background: transparent;
}
.table-hover tbody tr {
background: transparent;
}
.table-hover tbody tr:hover {
background: #f5f5f5;
background: transparent !important;
}
/* Spacer rows between tickets */
.spacer,
.spacer td {
background: transparent !important;
border: none !important;
}
/* Cards */
.card {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
background: rgba(45, 55, 72, 0.95) !important;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
border-radius: 8px;
margin-bottom: 16px;
color: var(--dark-text) !important;
}
.card-header {
padding: 12px 16px;
background: #616161;
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
color: #fff;
font-weight: bold;
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
}
.card-body {
padding: 16px;
color: var(--dark-text);
}
/* Forms */
.form-control {
.form-control,
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px 14px;
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 8px;
font-size: 14px;
background: rgba(31, 41, 55, 0.6) !important;
color: var(--dark-text) !important;
transition: all 0.2s ease;
}
.form-control:focus {
.form-control::placeholder {
color: rgba(226, 232, 240, 0.5);
}
.form-control:focus,
.form-select:focus {
outline: none;
border-color: #4CAF50;
border-color: var(--green-primary) !important;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15) !important;
background: rgba(31, 41, 55, 0.8) !important;
}
.form-group {
@@ -169,11 +276,17 @@ body {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--dark-text);
}
select.form-control {
background: #616161;
color: #fff;
background: rgba(31, 41, 55, 0.6) !important;
color: var(--dark-text) !important;
}
textarea.form-control {
resize: vertical;
min-height: 100px;
}
/* Grid */
@@ -209,42 +322,64 @@ select.form-control {
/* Ticket Row */
.ticket-row {
border: 1px solid #ddd;
border: none !important;
background: transparent !important;
}
.ticket-row td {
background: rgba(45, 55, 72, 0.95);
border: 1px solid rgba(16, 185, 129, 0.2);
}
/* Second row of ticket (no top border to avoid line in rowspan cells) */
.ticket-row + .ticket-row td {
border-top: none;
}
.ticket-row:hover td {
background: rgba(45, 55, 72, 0.95);
}
.ticket-row:hover {
border: none;
box-shadow: none;
}
.ticket-id {
background: #616161;
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
color: #fff;
text-align: center;
vertical-align: middle;
font-size: 18px;
font-weight: bold;
padding: 16px;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
}
.ticket-time {
font-size: 11px;
color: #ccc;
color: #a0aec0;
}
.ticket-info {
background: #f1f1f1;
background: rgba(31, 41, 55, 0.6) !important;
font-size: 13px;
color: var(--dark-text);
}
/* Status Colors */
.status-open { background: #4CAF50; color: #fff; }
.status-occupied { background: #607D8B; color: #fff; }
.status-assigned { background: #009688; color: #fff; }
.status-awaiting { background: #ff9800; color: #fff; }
.status-closed { background: #9e9e9e; color: #fff; }
.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; }
/* Priority Colors */
.priority-none { background: #2196F3; color: #fff; }
.priority-low { background: #4CAF50; color: #fff; }
.priority-medium { background: #ffc107; color: #000; }
.priority-high { background: #ff9800; color: #fff; }
.priority-critical { background: #f44336; color: #fff; }
.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; }
/* Dropdown */
.dropdown {
@@ -255,9 +390,11 @@ select.form-control {
.dropdown-content {
display: none;
position: absolute;
background: #616161;
background: rgba(45, 55, 72, 0.98);
min-width: 160px;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
box-shadow: 0 8px 16px rgba(0,0,0,0.4);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 6px;
z-index: 100;
}
@@ -267,13 +404,15 @@ select.form-control {
.dropdown-item {
padding: 8px 16px;
color: #fff;
color: var(--dark-text);
cursor: pointer;
display: block;
transition: background 0.2s ease;
}
.dropdown-item:hover {
background: #4CAF50;
background: rgba(16, 185, 129, 0.2);
color: var(--green-primary);
}
/* Modal/Overlay */
@@ -283,7 +422,7 @@ select.form-control {
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.9);
background: rgba(0,0,0,0.95);
z-index: 200;
overflow-y: auto;
padding: 60px 20px;
@@ -296,6 +435,12 @@ select.form-control {
font-size: 48px;
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
}
.overlay-close:hover {
color: var(--green-primary);
transform: rotate(90deg);
}
.overlay-content {
@@ -304,32 +449,62 @@ select.form-control {
color: #fff;
}
.modal-content {
background: rgba(45, 55, 72, 0.98) !important;
border: 1px solid rgba(16, 185, 129, 0.3) !important;
color: var(--dark-text) !important;
}
/* Slider */
.slider-container {
width: 100%;
padding: 16px 0;
padding: 8px 0;
}
.slider {
width: 100%;
height: 10px;
background: #d3d3d3;
height: 8px;
background: rgba(31, 41, 55, 0.6);
border-radius: 8px;
outline: none;
opacity: 0.7;
transition: opacity 0.2s;
transition: all 0.2s;
-webkit-appearance: none;
}
.slider:hover {
opacity: 1;
background: rgba(31, 41, 55, 0.8);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 25px;
background: #4CAF50;
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
transition: all 0.2s;
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.6);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
border: none;
transition: all 0.2s;
}
.slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.6);
}
/* File Upload */
@@ -348,7 +523,13 @@ select.form-control {
/* Spinner */
.spinner {
border: 4px solid rgba(16, 185, 129, 0.2);
border-top: 4px solid var(--green-primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
@@ -356,10 +537,68 @@ select.form-control {
to { transform: rotate(360deg); }
}
/* Modern Card Style */
.modern-card {
background: rgba(45, 55, 72, 0.95);
border-radius: 12px;
border: 1px solid rgba(16, 185, 129, 0.2);
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.modern-card:hover {
border-color: rgba(16, 185, 129, 0.4);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
/* Unified Control Panel - No internal rounded corners */
.control-panel-section {
padding: 16px;
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
}
.control-panel-section:last-child {
border-bottom: none;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.slide-in {
animation: slideIn 0.3s ease-out;
}
/* Footer */
.footer {
background: #000;
color: #fff;
background: linear-gradient(135deg, rgba(26, 32, 44, 0.98) 0%, rgba(17, 24, 39, 0.98) 100%);
border-top: 1px solid rgba(16, 185, 129, 0.2);
color: var(--dark-text);
text-align: center;
padding: 24px;
margin-top: 32px;