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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,49 +1,174 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { FaUser } from 'react-icons/fa'
import { motion } from 'motion/react'
import {
IconTicket,
IconDeviceDesktop,
IconChartBar,
IconCalendar,
IconFolders,
IconFileText,
IconBook,
IconSettings,
IconLogout
} from '@tabler/icons-react'
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
export default function Navbar() {
const { user, logout } = useAuth()
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const handleLogout = async () => {
await logout()
navigate('/login')
}
return (
<nav className="navbar">
<div className="navbar-brand">
<span>NetWEB</span>
<img src="/logo.png" alt="" height="18" width="18" />
<span>Systems</span>
</div>
<ul className="navbar-nav">
<li><Link to="/tickets" className="nav-link">Tickets</Link></li>
<li><Link to="/assets" className="nav-link">Assets</Link></li>
<li><Link to="/dashboard" className="nav-link">Dashboard</Link></li>
<li><Link to="/planboard" className="nav-link">Planboard</Link></li>
<li><Link to="/projects" className="nav-link">Projects</Link></li>
<li><Link to="/reports" className="nav-link">Reports</Link></li>
<li><Link to="/docs" className="nav-link">Docs</Link></li>
</ul>
const links = [
{
label: 'Tickets',
href: '/tickets',
icon: <IconTicket className="icon" />,
},
{
label: 'Assets',
href: '/assets',
icon: <IconDeviceDesktop className="icon" />,
},
{
label: 'Dashboard',
href: '/dashboard',
icon: <IconChartBar className="icon" />,
},
{
label: 'Planboard',
href: '/planboard',
icon: <IconCalendar className="icon" />,
},
{
label: 'Projects',
href: '/projects',
icon: <IconFolders className="icon" />,
},
{
label: 'Reports',
href: '/reports',
icon: <IconFileText className="icon" />,
},
{
label: 'Docs',
href: '/docs',
icon: <IconBook className="icon" />,
},
]
<div className="nav-right">
{user ? (
<button
onClick={handleLogout}
className="nav-link"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
<FaUser /> Logout, {user.name || user.email}
</button>
) : (
<Link to="/login" className="nav-link">
<FaUser /> Login
</Link>
)}
</div>
</nav>
// Admin link nur wenn isAdmin
if (isAdmin) {
links.push({
label: 'Admin',
href: '/admin',
icon: <IconSettings className="icon" />,
})
}
return (
<Sidebar open={open} setOpen={setOpen}>
<SidebarBody className="justify-between gap-10">
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowX: 'hidden', overflowY: 'auto' }}>
<Logo />
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{links.map((link, idx) => (
<SidebarLink key={idx} link={link} />
))}
</div>
</div>
<div>
{user ? (
<>
<SidebarLink
link={{
label: user.name || user.email,
href: '#',
icon: (
<div style={{
height: '28px',
width: '28px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
flexShrink: 0
}}>
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
),
}}
/>
<div onClick={handleLogout} style={{ cursor: 'pointer', marginTop: '0.5rem' }}>
<SidebarLink
link={{
label: 'Logout',
href: '#',
icon: <IconLogout className="icon" />,
}}
/>
</div>
</>
) : (
<SidebarLink
link={{
label: 'Login',
href: '/login',
icon: <IconLogout className="icon" />,
}}
/>
)}
</div>
</SidebarBody>
</Sidebar>
)
}
const Logo = () => {
return (
<Link
to="/"
style={{
position: 'relative',
zIndex: 20,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.25rem 0',
fontSize: '0.875rem',
fontWeight: 'normal',
color: 'var(--text-color, #333)',
textDecoration: 'none'
}}
>
<div style={{
height: '20px',
width: '24px',
flexShrink: 0,
borderRadius: '4px 2px 4px 2px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
}} />
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
fontWeight: 500,
whiteSpace: 'pre',
color: 'var(--text-color, #333)'
}}
>
Webklar
</motion.span>
</Link>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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