woms 3.0
This commit is contained in:
69
src/App.jsx
69
src/App.jsx
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
440
src/components/CreateWorksheetModal.jsx
Normal file
440
src/components/CreateWorksheetModal.jsx
Normal 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"> </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"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </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"> </div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </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"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </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"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </div>
|
||||
<div className="col-10 text-center">
|
||||
<p> </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> </p>
|
||||
</div>
|
||||
<div className="col-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </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"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
138
src/components/ModernSidebar.css
Normal file
138
src/components/ModernSidebar.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
116
src/components/ModernSidebar.jsx
Normal file
116
src/components/ModernSidebar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
7
src/components/PixelBlast.css
Normal file
7
src/components/PixelBlast.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.pixel-blast-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
674
src/components/PixelBlast.jsx
Normal file
674
src/components/PixelBlast.jsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
189
src/components/WorksheetList.jsx
Normal file
189
src/components/WorksheetList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
212
src/components/WorksheetStats.jsx
Normal file
212
src/components/WorksheetStats.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
152
src/hooks/useAdminConfig.js
Normal 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
121
src/hooks/useCustomers.js
Normal 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
207
src/hooks/useEmployees.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
362
src/hooks/useWorksheets.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
7
src/lib/utils.js
Normal 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
698
src/pages/AdminPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user