Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
import React, { useState, useEffect, useRef } from 'react';
import assetService from '../../services/assetService';
import userService from '../../services/userService';
import { toast } from 'react-toastify';
const AssetAssignModal = ({ asset, onClose, onAssigned }) => {
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [search, setSearch] = useState('');
const [dropOpen, setDropOpen] = useState(false);
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 300 });
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(true);
const inputRef = useRef(null);
const updateDropPos = () => {
if (inputRef.current) {
const r = inputRef.current.getBoundingClientRect();
setDropPos({ top: r.bottom + 2, left: r.left, width: r.width });
}
};
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
const data = await userService.getAll();
const sorted = data
.filter(u => u.is_active)
.sort((a, b) => {
const nameA = `${a.last_name} ${a.first_name}`.trim().toLowerCase();
const nameB = `${b.last_name} ${b.first_name}`.trim().toLowerCase();
return nameA.localeCompare(nameB, 'de');
});
setUsers(sorted);
} catch {
toast.error('Fehler beim Laden der Benutzer');
} finally {
setLoadingUsers(false);
}
};
const filtered = users.filter(u => {
const q = search.toLowerCase();
return !q
|| u.username?.toLowerCase().includes(q)
|| u.first_name?.toLowerCase().includes(q)
|| u.last_name?.toLowerCase().includes(q)
|| `${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
});
const handleSelect = (user) => {
setSelectedUser(user);
setSearch('');
setDropOpen(false);
};
const handleClear = () => {
setSelectedUser(null);
setSearch('');
setTimeout(() => inputRef.current?.focus(), 50);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!selectedUser) {
toast.error('Bitte einen Benutzer auswählen');
return;
}
setLoading(true);
try {
await assetService.assign(asset.id, selectedUser.id, notes);
toast.success('Asset erfolgreich zugewiesen');
onAssigned();
} catch (error) {
toast.error(error.message || 'Fehler beim Zuweisen');
} finally {
setLoading(false);
}
};
const displayName = (u) =>
u.first_name || u.last_name
? `${u.first_name || ''} ${u.last_name || ''}`.trim()
: u.username;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Asset zuweisen</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Asset</label>
<input
type="text"
className="form-input"
value={`${asset.name} (${asset.serial_number})`}
disabled
/>
</div>
<div className="form-group">
<label className="form-label">Benutzer auswählen *</label>
{loadingUsers ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>Lade Benutzer</p>
) : selectedUser ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', border: '1px solid var(--border-color)', borderRadius: 6, background: 'var(--input-bg, var(--card-bg))' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: '0.875rem', flexShrink: 0 }}>
{displayName(selectedUser).charAt(0).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>{displayName(selectedUser)}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>@{selectedUser.username}</div>
</div>
<button type="button" onClick={handleClear} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1rem', padding: '2px 6px' }}></button>
</div>
) : (
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
className="form-input"
style={{ width: '100%' }}
placeholder="Name oder Benutzername suchen…"
value={search}
onChange={e => { setSearch(e.target.value); updateDropPos(); setDropOpen(true); }}
onFocus={() => { requestAnimationFrame(() => { updateDropPos(); setDropOpen(true); }); }}
onBlur={() => setTimeout(() => setDropOpen(false), 150)}
autoFocus
/>
{dropOpen && (
<div style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, width: dropPos.width, zIndex: 9999, background: 'var(--card-bg, #1e1e2e)', border: '1px solid var(--border-color)', borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.5)', maxHeight: 240, overflowY: 'auto' }}>
{filtered.length === 0 ? (
<div style={{ padding: '10px 12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>Kein Benutzer gefunden</div>
) : filtered.map(u => (
<div
key={u.id}
onMouseDown={() => handleSelect(u)}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', cursor: 'pointer' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg, rgba(255,255,255,0.05))'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: '0.75rem', flexShrink: 0 }}>
{displayName(u).charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '0.85rem' }}>{displayName(u)}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>@{u.username}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea
className="form-textarea"
rows="3"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optionale Notizen zur Zuweisung…"
/>
</div>
<div className="card-footer">
<button type="button" onClick={onClose} className="btn btn-secondary" disabled={loading}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={loading || loadingUsers || !selectedUser}>
{loading ? 'Zuweisen…' : 'Zuweisen'}
</button>
</div>
</form>
</div>
</div>
);
};
export default AssetAssignModal;

View File

@@ -0,0 +1,380 @@
import React, { useState, useEffect } from 'react';
import assetService from '../../services/assetService';
import { useAuth } from '../../context/AuthContext';
import { toast } from 'react-toastify';
import ProduktionAssetWizard from './ProduktionAssetWizard';
import { calculateAfa, formatEuro, getDefaultUsefulLife } from '../../utils/afaUtils';
const AssetModal = ({ asset, onClose, onSaved }) => {
const { isTechniker } = useAuth();
const isProduktion = isTechniker();
const [assetTypes, setAssetTypes] = useState([]);
useEffect(() => {
assetService.getTypes().then(setAssetTypes).catch(() => {});
}, []);
const [formData, setFormData] = useState({
name: '',
type: '',
department: 'IT',
serial_number: '',
model: '',
status: 'verfuegbar',
purchase_date: '',
description: '',
teamviewer_id: '',
last_maintenance_date: '',
next_maintenance_date: '',
maintenance_interval_months: '',
maintenance_notes: '',
inventory_number: '',
purchase_price: '',
useful_life_years: '',
residual_value: '',
os: '',
ip_address: '',
manufacturer: '',
});
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [showAfa, setShowAfa] = useState(false);
useEffect(() => {
if (asset) {
setFormData({
name: asset.name || '',
type: asset.type || 'Notebook',
department: asset.department || 'IT',
serial_number: asset.serial_number || '',
model: asset.model || '',
status: asset.status || 'verfuegbar',
purchase_date: asset.purchase_date || '',
description: asset.description || '',
teamviewer_id: asset.teamviewer_id || '',
last_maintenance_date: asset.last_maintenance_date || '',
next_maintenance_date: asset.next_maintenance_date || '',
maintenance_interval_months: asset.maintenance_interval_months || '',
maintenance_notes: asset.maintenance_notes || '',
inventory_number: asset.inventory_number || '',
purchase_price: asset.purchase_price || '',
useful_life_years: asset.useful_life_years || '',
residual_value: asset.residual_value || '',
os: asset.os || '',
ip_address: asset.ip_address || '',
manufacturer: asset.manufacturer || '',
});
// Abschnitt aufklappen wenn Felder vorhanden
if (asset.purchase_price || asset.inventory_number) setShowAfa(true);
}
}, [asset]); // eslint-disable-line react-hooks/exhaustive-deps
// Produktion-Rolle bekommt den Wizard
if (isProduktion) {
return <ProduktionAssetWizard asset={asset} onClose={onClose} onSaved={onSaved} />;
}
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (asset) {
await assetService.update(asset.id, formData);
toast.success('Asset erfolgreich aktualisiert');
} else {
await assetService.create(formData);
toast.success('Asset erfolgreich erstellt');
}
onSaved();
} catch (error) {
toast.error(error.message || 'Fehler beim Speichern');
} finally {
setLoading(false);
}
};
const set = (field) => (e) => setFormData({ ...formData, [field]: e.target.value });
const handleSyncAgent = async () => {
setSyncing(true);
try {
const res = await assetService.syncFromAgent(asset.id);
const updated = res.data;
setFormData(prev => ({
...prev,
serial_number: updated.serial_number || prev.serial_number,
os: updated.os || prev.os || '',
ip_address: updated.ip_address || prev.ip_address || '',
manufacturer: updated.manufacturer || prev.manufacturer || '',
}));
toast.success(`Agent-Daten übernommen (${res.synced_from})`);
} catch (err) {
toast.error(err.response?.data?.message || 'Kein Agent gefunden — Hostname muss mit Asset-Name übereinstimmen');
} finally {
setSyncing(false);
}
};
// AfA-Vorschau berechnen
const afaPreview = calculateAfa(
formData.purchase_price,
formData.useful_life_years,
formData.residual_value,
formData.purchase_date
);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">
{asset ? 'Asset bearbeiten' : 'Neues Asset'}
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{asset && (
<button
type="button"
onClick={handleSyncAgent}
disabled={syncing}
title="Daten vom IT Nexus Agent übernehmen"
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: syncing ? 'var(--bg-secondary)' : 'rgba(13,148,136,0.12)',
border: '1px solid var(--cereda-primary)',
borderRadius: 6, color: 'var(--cereda-primary)',
fontSize: 12, fontWeight: 600, cursor: syncing ? 'not-allowed' : 'pointer',
padding: '5px 10px',
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="13" height="13" strokeLinecap="round" strokeLinejoin="round" style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }}>
<path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.78 1 6.43 2.57L21 8"/><path d="M21 3v5h-5"/>
</svg>
{syncing ? 'Synchronisiere…' : 'Agent sync'}
</button>
)}
<button className="modal-close" onClick={onClose}>×</button>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Name*</label>
<input type="text" className="form-input" value={formData.name} onChange={set('name')} required />
</div>
<div className="form-group">
<label className="form-label">Typ*</label>
<select className="form-select" value={formData.type} onChange={set('type')} required>
<option value=""> Typ wählen </option>
{assetTypes.map(t => <option key={t.id} value={t.name}>{t.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Bereich*</label>
<select className="form-select" value={formData.department} onChange={set('department')}>
<option value="IT">IT</option>
<option value="Produktion">Produktion</option>
<option value="Techniker">Techniker</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Seriennummer*</label>
<input type="text" className="form-input" value={formData.serial_number} onChange={set('serial_number')} required />
</div>
<div className="form-group">
<label className="form-label">Modell</label>
<input type="text" className="form-input" value={formData.model} onChange={set('model')} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="form-group">
<label className="form-label">Hersteller</label>
<input type="text" className="form-input" placeholder="z.B. HP, Dell, Lenovo" value={formData.manufacturer} onChange={set('manufacturer')} />
</div>
<div className="form-group">
<label className="form-label">Betriebssystem</label>
<input type="text" className="form-input" placeholder="z.B. Windows 11 Pro" value={formData.os} onChange={set('os')} />
</div>
</div>
<div className="form-group">
<label className="form-label">IP-Adresse</label>
<input type="text" className="form-input" placeholder="z.B. 192.168.0.42" value={formData.ip_address} onChange={set('ip_address')} />
</div>
<div className="form-group">
<label className="form-label">Status*</label>
<select className="form-select" value={formData.status} onChange={set('status')} required>
<option value="verfuegbar">Verfügbar</option>
<option value="zugewiesen">Zugewiesen</option>
<option value="inaktiv">Inaktiv</option>
<option value="beschaedigt">Beschädigt</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Kaufdatum</label>
<input type="date" className="form-input" value={formData.purchase_date} onChange={set('purchase_date')} />
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea className="form-textarea" rows="3" value={formData.description} onChange={set('description')} />
</div>
<div className="form-group">
<label className="form-label">TeamViewer ID</label>
<input type="text" className="form-input" placeholder="z.B. 123456789" value={formData.teamviewer_id} onChange={set('teamviewer_id')} />
</div>
<div style={{ borderTop: '1px solid var(--border-color)', margin: '16px 0 12px', paddingTop: '12px' }}>
<p className="form-label" style={{ marginBottom: '12px', color: 'var(--text-muted)', fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Wartung &amp; Prüfung
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="form-group">
<label className="form-label">Letzte Wartung</label>
<input type="date" className="form-input" value={formData.last_maintenance_date} onChange={set('last_maintenance_date')} />
</div>
<div className="form-group">
<label className="form-label">Nächste Prüfung</label>
<input type="date" className="form-input" value={formData.next_maintenance_date} onChange={set('next_maintenance_date')} />
</div>
</div>
<div className="form-group">
<label className="form-label">Intervall (Monate)</label>
<input type="number" className="form-input" min="1" max="120" placeholder="z.B. 12" value={formData.maintenance_interval_months} onChange={set('maintenance_interval_months')} />
</div>
<div className="form-group">
<label className="form-label">Wartungsnotizen</label>
<textarea className="form-textarea" rows="2" placeholder="z.B. VDE-Prüfung, nächste UVV-Prüfung fällig..." value={formData.maintenance_notes} onChange={set('maintenance_notes')} />
</div>
{/* Anlagevermögen */}
<div style={{ borderTop: '1px solid var(--border-color)', margin: '16px 0 0' }}>
<button
type="button"
onClick={() => setShowAfa(p => !p)}
style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text-muted)', fontSize: '12px',
textTransform: 'uppercase', letterSpacing: '0.05em',
fontWeight: 600, padding: '12px 0',
}}
>
<span style={{ fontSize: '10px', transition: 'transform 0.15s', transform: showAfa ? 'rotate(90deg)' : 'rotate(0deg)' }}></span>
Anlagevermögen (optional)
</button>
</div>
{showAfa && (
<div style={{ marginBottom: '8px' }}>
<div className="form-group">
<label className="form-label">Inventarnummer</label>
<input
type="text"
className="form-input"
placeholder={asset ? '' : 'wird automatisch vergeben (ANL-XXXX)'}
value={formData.inventory_number}
onChange={set('inventory_number')}
/>
{!asset && !formData.inventory_number && (
<span style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px', display: 'block' }}>
Leer lassen automatisch als ANL-XXXX vergeben
</span>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
<div className="form-group">
<label className="form-label">Anschaffungswert ()</label>
<input
type="number"
className="form-input"
min="0"
step="0.01"
placeholder="z.B. 1200.00"
value={formData.purchase_price}
onChange={set('purchase_price')}
/>
</div>
<div className="form-group">
<label className="form-label">Nutzungsdauer (Jahre)</label>
<input
type="number"
className="form-input"
min="1"
max="50"
placeholder={`Standard: ${getDefaultUsefulLife(formData.type)} J`}
value={formData.useful_life_years}
onChange={set('useful_life_years')}
/>
</div>
<div className="form-group">
<label className="form-label">Restwert ()</label>
<input
type="number"
className="form-input"
min="0"
step="0.01"
placeholder="0,00"
value={formData.residual_value}
onChange={set('residual_value')}
/>
</div>
</div>
{/* AfA-Vorschau */}
{afaPreview && (
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
padding: '10px 14px',
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: '12px',
marginBottom: '8px',
}}>
<div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '2px' }}>AfA/Jahr</div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{formatEuro(afaPreview.annualDepreciation)}</div>
</div>
<div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '2px' }}>Buchwert heute</div>
<div style={{ fontWeight: 600, fontSize: '14px', color: 'var(--cereda-primary)' }}>{formatEuro(afaPreview.currentBookValue)}</div>
</div>
<div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '2px' }}>Abgeschrieben am</div>
<div style={{ fontWeight: 600, fontSize: '14px', color: afaPreview.isFullyDepreciated ? '#6b7280' : '#16a34a' }}>
{afaPreview.fullyDepreciatedDate.toLocaleDateString('de-DE')}
</div>
</div>
</div>
)}
</div>
)}
<div className="card-footer">
<button type="button" onClick={onClose} className="btn btn-secondary" disabled={loading}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Speichern...' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
export default AssetModal;

View File

@@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import assetService from '../../services/assetService';
import { toast } from 'react-toastify';
const InspectionSection = ({ assetId, onUpdated }) => {
const [inspections, setInspections] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
inspection_date: new Date().toISOString().split('T')[0],
result: 'bestanden',
notes: '',
next_due_date: '',
});
useEffect(() => { load(); }, [assetId]); // eslint-disable-line
const load = async () => {
try {
const data = await assetService.getInspections(assetId);
setInspections(data);
} catch (_) {}
finally { setLoading(false); }
};
const setF = (field) => (e) => setForm({ ...form, [field]: e.target.value });
const handleSubmit = async () => {
setSaving(true);
try {
await assetService.createInspection(assetId, form);
toast.success('Prüfung eingetragen');
setShowForm(false);
setForm({ inspection_date: new Date().toISOString().split('T')[0], result: 'bestanden', notes: '', next_due_date: '' });
load();
if (onUpdated) onUpdated();
} catch (e) {
toast.error(e.message || 'Fehler');
} finally {
setSaving(false);
}
};
return (
<div style={{ marginTop: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0 }}>
Prüfprotokoll
</h3>
<button className="btn btn-primary" style={{ padding: '6px 14px', fontSize: 13 }} onClick={() => setShowForm(!showForm)}>
+ Prüfung eintragen
</button>
</div>
{showForm && (
<div style={{ background: 'var(--bg-card, #1e293b)', border: '1px solid var(--border-color)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-group">
<label className="form-label">Prüfdatum*</label>
<input type="date" className="form-input" value={form.inspection_date} onChange={setF('inspection_date')} />
</div>
<div className="form-group">
<label className="form-label">Ergebnis*</label>
<select className="form-select" value={form.result} onChange={setF('result')}>
<option value="bestanden"> Bestanden</option>
<option value="nicht_bestanden"> Nicht bestanden</option>
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Nächste Fälligkeit</label>
<input type="date" className="form-input" value={form.next_due_date} onChange={setF('next_due_date')} />
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea className="form-textarea" rows="2" value={form.notes} onChange={setF('notes')} placeholder="z.B. VDE-Prüfung bestanden, Kabel erneuert..." />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setShowForm(false)} disabled={saving}>Abbrechen</button>
<button className="btn btn-primary" onClick={handleSubmit} disabled={saving}>{saving ? 'Speichern...' : 'Speichern'}</button>
</div>
</div>
)}
{loading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Laden...</p>
) : inspections.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13, fontStyle: 'italic' }}>Noch keine Prüfungen eingetragen.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{inspections.map(ins => (
<div key={ins.id} style={{
display: 'flex', gap: 12, alignItems: 'flex-start',
padding: '10px 14px', borderRadius: 8,
background: 'var(--bg-card, #1e293b)',
border: `1px solid ${ins.result === 'bestanden' ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'}`,
}}>
<span style={{ fontSize: 18, marginTop: 1 }}>{ins.result === 'bestanden' ? '✅' : '❌'}</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
{ins.result === 'bestanden' ? 'Bestanden' : 'Nicht bestanden'}
</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{ins.inspection_date}</span>
</div>
{ins.notes && <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '2px 0' }}>{ins.notes}</p>}
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: 0 }}>
Durchgeführt von: {ins.inspector_name || 'Unbekannt'}
{ins.next_due_date && ` · Nächste Prüfung: ${ins.next_due_date}`}
</p>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default InspectionSection;

View File

@@ -0,0 +1,333 @@
import React, { useState } from 'react';
import assetService from '../../services/assetService';
import { toast } from 'react-toastify';
import { getDefaultUsefulLife } from '../../utils/afaUtils';
const TEMPLATES = [
{
type: 'Maschine',
icon: '⚙️',
label: 'Maschine',
description: 'CNC, Presse, Förderanlage, ...',
},
{
type: 'Werkzeug',
icon: '🔧',
label: 'Werkzeug',
description: 'Akkuschrauber, Bohrer, Messgerät, ...',
},
{
type: 'Sonstiges',
icon: '📦',
label: 'Sonstiges',
description: 'Alles weitere',
},
];
const STEPS = ['Vorlage', 'Details', 'Wartung'];
const empty = {
name: '',
type: '',
department: 'Produktion',
serial_number: '',
model: '',
status: 'verfuegbar',
purchase_date: '',
description: '',
last_maintenance_date: '',
next_maintenance_date: '',
maintenance_interval_months: '',
maintenance_notes: '',
inventory_number: '',
purchase_price: '',
useful_life_years: '',
residual_value: '',
};
const ProduktionAssetWizard = ({ asset, onClose, onSaved }) => {
const isEdit = !!asset;
const [step, setStep] = useState(isEdit ? 1 : 0);
const [formData, setFormData] = useState(
isEdit
? {
name: asset.name || '',
type: asset.type || 'Maschine',
department: asset.department || 'Produktion',
serial_number: asset.serial_number || '',
model: asset.model || '',
status: asset.status || 'verfuegbar',
purchase_date: asset.purchase_date || '',
description: asset.description || '',
last_maintenance_date: asset.last_maintenance_date || '',
next_maintenance_date: asset.next_maintenance_date || '',
maintenance_interval_months: asset.maintenance_interval_months || '',
maintenance_notes: asset.maintenance_notes || '',
inventory_number: asset.inventory_number || '',
purchase_price: asset.purchase_price || '',
useful_life_years: asset.useful_life_years || '',
residual_value: asset.residual_value || '',
}
: empty
);
const [loading, setLoading] = useState(false);
const set = (field) => (e) => {
const updated = { ...formData, [field]: e.target.value };
// Auto-calculate next due date
if ((field === 'last_maintenance_date' || field === 'maintenance_interval_months')) {
const last = updated.last_maintenance_date;
const interval = parseInt(updated.maintenance_interval_months);
if (last && interval > 0) {
const d = new Date(last);
d.setMonth(d.getMonth() + interval);
updated.next_maintenance_date = d.toISOString().split('T')[0];
}
}
setFormData(updated);
};
const selectTemplate = (type) => {
setFormData({ ...formData, type });
setStep(1);
};
const handleSubmit = async () => {
if (!formData.name.trim()) {
toast.error('Bitte einen Namen eingeben');
return;
}
setLoading(true);
try {
if (isEdit) {
await assetService.update(asset.id, formData);
toast.success('Asset erfolgreich aktualisiert');
} else {
await assetService.create(formData);
toast.success('Asset erfolgreich erstellt');
}
onSaved();
} catch (error) {
toast.error(error.message || 'Fehler beim Speichern');
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 520 }}>
<div className="modal-header">
<h2 className="modal-title">
{isEdit ? 'Asset bearbeiten' : 'Neues Asset'}
</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
{/* Step indicator */}
<div style={{ display: 'flex', gap: 0, margin: '0 24px 20px', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-color)' }}>
{STEPS.map((label, i) => (
<div
key={i}
style={{
flex: 1,
padding: '8px 4px',
textAlign: 'center',
fontSize: 12,
fontWeight: step === i ? 700 : 400,
background: step === i ? 'var(--accent-color, #0d9488)' : i < step ? 'var(--accent-muted, #0d948822)' : 'transparent',
color: step === i ? '#fff' : i < step ? 'var(--accent-color, #0d9488)' : 'var(--text-muted)',
transition: 'all 0.2s',
}}
>
{i < step ? '✓ ' : `${i + 1}. `}{label}
</div>
))}
</div>
<div style={{ padding: '0 24px 24px' }}>
{/* STEP 0 Vorlage wählen */}
{step === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ color: 'var(--text-muted)', fontSize: 13, marginBottom: 4 }}>
Wähle eine Vorlage für das neue Asset:
</p>
{TEMPLATES.map((tpl) => (
<button
key={tpl.type}
type="button"
onClick={() => selectTemplate(tpl.type)}
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 20px',
border: '1px solid var(--border-color)',
borderRadius: 10,
background: 'var(--bg-card, #1e293b)',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 0.15s, background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent-color, #0d9488)'}
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-color)'}
>
<span style={{ fontSize: 32 }}>{tpl.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)' }}>{tpl.label}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{tpl.description}</div>
</div>
</button>
))}
</div>
)}
{/* STEP 1 Details */}
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div className="form-group">
<label className="form-label">Typ</label>
<select className="form-select" value={formData.type} onChange={set('type')}>
<option value="Maschine"> Maschine</option>
<option value="Werkzeug">🔧 Werkzeug</option>
<option value="Sonstiges">📦 Sonstiges</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Name*</label>
<input type="text" className="form-input" value={formData.name} onChange={set('name')} autoFocus placeholder="z.B. Akkuschrauber Bosch GSR 18V" />
</div>
<div className="form-group">
<label className="form-label">Bereich*</label>
<select className="form-select" value={formData.department} onChange={set('department')}>
<option value="Produktion">Produktion</option>
<option value="Techniker">Techniker</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Modell</label>
<input type="text" className="form-input" value={formData.model} onChange={set('model')} placeholder="z.B. GSR 18V-55" />
</div>
<div className="form-group">
<label className="form-label">Seriennummer <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(optional)</span></label>
<input type="text" className="form-input" value={formData.serial_number} onChange={set('serial_number')} placeholder="Wird automatisch vergeben wenn leer" />
</div>
<div className="form-group">
<label className="form-label">Status*</label>
<select className="form-select" value={formData.status} onChange={set('status')}>
<option value="verfuegbar">Verfügbar</option>
<option value="zugewiesen">Zugewiesen</option>
<option value="inaktiv">Inaktiv</option>
<option value="beschaedigt">Beschädigt</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Kaufdatum</label>
<input type="date" className="form-input" value={formData.purchase_date} onChange={set('purchase_date')} />
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea className="form-textarea" rows="2" value={formData.description} onChange={set('description')} />
</div>
</div>
)}
{/* STEP 2 Wartung & Prüfung */}
{step === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<p style={{ color: 'var(--text-muted)', fontSize: 13, marginBottom: 8 }}>
Wartungs- und Prüfinformationen (optional)
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-group">
<label className="form-label">Letzte Wartung</label>
<input type="date" className="form-input" value={formData.last_maintenance_date} onChange={set('last_maintenance_date')} />
</div>
<div className="form-group">
<label className="form-label">Nächste Prüfung</label>
<input type="date" className="form-input" value={formData.next_maintenance_date} onChange={set('next_maintenance_date')} />
{formData.last_maintenance_date && formData.maintenance_interval_months && (
<p style={{ fontSize: 11, color: 'var(--accent-color, #0d9488)', marginTop: 4 }}>
Automatisch berechnet aus letzter Wartung + Intervall
</p>
)}
</div>
</div>
<div className="form-group">
<label className="form-label">Intervall (Monate)</label>
<input type="number" className="form-input" min="1" max="120" placeholder="z.B. 12" value={formData.maintenance_interval_months} onChange={set('maintenance_interval_months')} />
</div>
<div className="form-group">
<label className="form-label">Wartungsnotizen</label>
<textarea className="form-textarea" rows="3" placeholder="z.B. VDE-Prüfung, UVV-Prüfung fällig..." value={formData.maintenance_notes} onChange={set('maintenance_notes')} />
</div>
{/* Anlagevermögen */}
<div style={{ borderTop: '1px solid var(--border-color)', marginTop: 12, paddingTop: 12 }}>
<p style={{ color: 'var(--text-muted)', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
Anlagevermögen (optional)
</p>
<div className="form-group">
<label className="form-label">Inventarnummer</label>
<input
type="text"
className="form-input"
placeholder={isEdit ? '' : 'wird automatisch vergeben (ANL-XXXX)'}
value={formData.inventory_number}
onChange={set('inventory_number')}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div className="form-group">
<label className="form-label">Anschaffungswert ()</label>
<input type="number" className="form-input" min="0" step="0.01" placeholder="z.B. 2500.00" value={formData.purchase_price} onChange={set('purchase_price')} />
</div>
<div className="form-group">
<label className="form-label">Nutzungsdauer (J)</label>
<input type="number" className="form-input" min="1" max="50" placeholder={`Standard: ${getDefaultUsefulLife(formData.type)} J`} value={formData.useful_life_years} onChange={set('useful_life_years')} />
</div>
<div className="form-group">
<label className="form-label">Restwert ()</label>
<input type="number" className="form-input" min="0" step="0.01" placeholder="0,00" value={formData.residual_value} onChange={set('residual_value')} />
</div>
</div>
</div>
</div>
)}
{/* Navigation */}
<div className="card-footer" style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border-color)' }}>
{step > 0 && (
<button type="button" className="btn btn-secondary" onClick={() => setStep(step - 1)} disabled={loading}>
Zurück
</button>
)}
{step === 0 && (
<button type="button" className="btn btn-secondary" onClick={onClose}>
Abbrechen
</button>
)}
{step < 2 && step > 0 && (
<button
type="button"
className="btn btn-primary"
onClick={() => setStep(step + 1)}
disabled={!formData.name.trim()}
>
Weiter
</button>
)}
{step === 2 && (
<button type="button" className="btn btn-primary" onClick={handleSubmit} disabled={loading}>
{loading ? 'Speichern...' : 'Speichern'}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default ProduktionAssetWizard;

View File

@@ -0,0 +1,296 @@
import React, { useState, useRef, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import aiService from '../../services/aiService';
import { marked } from 'marked';
marked.use({ breaks: true, gfm: true });
const renderMd = (text) => {
try {
const html = marked.parse(String(text || ''), { async: false });
return { __html: typeof html === 'string' ? html : String(html) };
} catch {
return { __html: String(text || '').replace(/\n/g, '<br>') };
}
};
const STAFF_ROLES = ['super_admin', 'admin', 'support', 'bearbeiter'];
const WELCOME_MSG = {
role: 'assistant',
content: 'Hallo! Ich bin dein KI-Assistent für IT-Support. Wie kann ich dir helfen?\n\nDu kannst mich z.B. nach Troubleshooting-Schritten, Lösungen für bekannte Probleme oder Ticket-Einschätzungen fragen.',
};
export default function AiChatWidget() {
const { user } = useAuth();
const location = useLocation();
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState([WELCOME_MSG]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [configured, setConfigured] = useState(true);
const bottomRef = useRef(null);
const inputRef = useRef(null);
const isStaff = user && STAFF_ROLES.includes(user.role_name);
const isPublicPage = ['/health', '/defect', '/login', '/tv'].some(p => location.pathname.startsWith(p));
// Status prüfen (immer, auch wenn nicht Staff)
useEffect(() => {
if (!isStaff) return;
aiService.getStatus()
.then(s => setConfigured(s.configured))
.catch(() => setConfigured(false));
}, [isStaff]);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Nur für Staff-Rollen und nicht auf öffentlichen Seiten anzeigen
if (!isStaff || isPublicPage) return null;
async function sendMessage() {
const text = input.trim();
if (!text || loading) return;
const userMsg = { role: 'user', content: text };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
setLoading(true);
try {
// Nur die letzten 20 Nachrichten als Kontext senden (ohne Welcome-Msg)
const contextMsgs = newMessages
.filter(m => m !== WELCOME_MSG)
.slice(-20);
const reply = await aiService.chat(contextMsgs);
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
} catch (err) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Fehler: ${err.response?.data?.message || err.message || 'Unbekannter Fehler'}`,
isError: true,
}]);
} finally {
setLoading(false);
setTimeout(() => inputRef.current?.focus(), 50);
}
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function clearChat() {
setMessages([WELCOME_MSG]);
}
return (
<>
{/* Floating Button */}
<button
onClick={() => setOpen(o => !o)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
width: 52,
height: 52,
borderRadius: '50%',
background: configured ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#6b7280',
border: 'none',
color: '#fff',
fontSize: 22,
cursor: 'pointer',
boxShadow: '0 4px 16px rgba(99,102,241,0.4)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s',
}}
title={configured ? 'KI-Assistent' : 'KI nicht konfiguriert'}
>
{open ? '✕' : '🤖'}
</button>
{/* Chat Panel */}
{open && (
<div style={{
position: 'fixed',
bottom: 88,
right: 24,
width: 380,
maxWidth: 'calc(100vw - 48px)',
height: 520,
background: 'var(--bg-primary, #fff)',
border: '1px solid var(--border-color, #e5e7eb)',
borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex',
flexDirection: 'column',
zIndex: 999,
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
padding: '12px 16px',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<span style={{ fontSize: 20 }}>🤖</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>KI-Assistent</div>
<div style={{ fontSize: 11, opacity: 0.8 }}>
{configured ? 'Claude claude-sonnet-4-6 · IT Support' : 'Nicht konfiguriert'}
</div>
</div>
<button
onClick={clearChat}
title="Chat leeren"
style={{
background: 'rgba(255,255,255,0.2)',
border: 'none',
color: '#fff',
borderRadius: 6,
padding: '2px 8px',
fontSize: 11,
cursor: 'pointer',
}}
>
Leeren
</button>
</div>
{!configured && (
<div style={{
padding: '10px 16px',
background: '#fef3c7',
color: '#92400e',
fontSize: 12,
borderBottom: '1px solid #fde68a',
}}>
ANTHROPIC_API_KEY nicht konfiguriert
</div>
)}
{/* Messages */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}>
{messages.map((msg, i) => (
<div key={i} style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}>
<div style={{
maxWidth: '85%',
padding: '8px 12px',
borderRadius: msg.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
background: msg.role === 'user'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: msg.isError
? '#fee2e2'
: 'var(--bg-secondary, #f3f4f6)',
color: msg.role === 'user' ? '#fff' : msg.isError ? '#991b1b' : 'var(--text-primary, #111)',
fontSize: 13,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{msg.role === 'assistant'
? <div className="md-content" dangerouslySetInnerHTML={renderMd(msg.content)} />
: msg.content}
</div>
</div>
))}
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div style={{
padding: '10px 14px',
borderRadius: '12px 12px 12px 2px',
background: 'var(--bg-secondary, #f3f4f6)',
fontSize: 18,
letterSpacing: 2,
}}>
<span style={{ animation: 'pulse 1.2s infinite' }}></span>
<span style={{ animation: 'pulse 1.2s 0.3s infinite', opacity: 0.5 }}></span>
<span style={{ animation: 'pulse 1.2s 0.6s infinite', opacity: 0.25 }}></span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{
padding: '10px 12px',
borderTop: '1px solid var(--border-color, #e5e7eb)',
display: 'flex',
gap: 8,
}}>
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nachricht eingeben... (Enter zum Senden)"
disabled={loading || !configured}
rows={2}
style={{
flex: 1,
resize: 'none',
padding: '8px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #d1d5db)',
fontSize: 13,
background: 'var(--bg-primary, #fff)',
color: 'var(--text-primary, #111)',
outline: 'none',
fontFamily: 'inherit',
}}
/>
<button
onClick={sendMessage}
disabled={loading || !input.trim() || !configured}
style={{
padding: '0 14px',
borderRadius: 8,
border: 'none',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff',
fontSize: 18,
cursor: 'pointer',
opacity: loading || !input.trim() || !configured ? 0.5 : 1,
alignSelf: 'flex-end',
height: 38,
}}
>
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import Sidebar from './Sidebar';
import Topbar from './Topbar';
const AppLayout = ({ children }) => {
return (
<div className="app-layout">
<Sidebar />
<div className="app-layout-main">
<Topbar />
<main className="app-layout-content">
{children}
</main>
</div>
</div>
);
};
export default AppLayout;

View File

@@ -0,0 +1,157 @@
import React from 'react';
const DEPT_COLORS = {
HR: { color: '#3b82f6', bg: 'rgba(59,130,246,0.08)', label: '🧑‍💼 HR / Personal' },
IT: { color: '#10b981', bg: 'rgba(16,185,129,0.08)', label: '💻 IT' },
VG: { color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', label: '👔 Vorgesetzter' },
BK: { color: '#a78bfa', bg: 'rgba(167,139,250,0.08)', label: '💶 Buchhaltung / Lohn' },
};
const TAG_STYLES = {
critical: { background: 'rgba(239,68,68,0.15)', color: '#ef4444' },
important: { background: 'rgba(245,158,11,0.15)', color: '#f59e0b' },
normal: { background: 'rgba(100,116,139,0.12)', color: '#64748b' },
};
const ChecklistEditor = ({ items, checkedItems, onChange, disabled = false }) => {
const handleToggle = (key) => {
if (disabled) return;
onChange({ ...checkedItems, [key]: !checkedItems[key] });
};
// Check if items have phase/dept structure
const isGrouped = items.length > 0 && items[0].phase !== undefined;
if (!isGrouped) {
// Legacy flat list
return (
<div className="checklist-editor">
{items.map((item) => (
<div key={item.key} className="checklist-item">
<label className="checklist-label">
<input
type="checkbox"
className="checklist-checkbox"
checked={!!checkedItems[item.key]}
onChange={() => handleToggle(item.key)}
disabled={disabled}
/>
<span className="checklist-text">{item.label}</span>
</label>
</div>
))}
</div>
);
}
// Grouped by phase → department
const phases = [...new Set(items.map(i => i.phase))];
const totalItems = items.length;
const totalChecked = items.filter(i => !!checkedItems[i.key]).length;
const totalPct = totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0;
return (
<div>
{/* Overall progress */}
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'var(--bg-secondary)', borderRadius: '8px', border: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-secondary)' }}>Gesamtfortschritt</span>
<span style={{ fontSize: '12px', fontFamily: 'monospace', color: totalPct === 100 ? '#10b981' : 'var(--text-muted)' }}>
{totalChecked} / {totalItems} · {totalPct}%
</span>
</div>
<div style={{ height: '4px', background: 'var(--border-color)', borderRadius: '2px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${totalPct}%`, background: totalPct === 100 ? '#10b981' : 'var(--cereda-primary)', borderRadius: '2px', transition: 'width 0.3s' }} />
</div>
</div>
{phases.map(phase => {
const phaseItems = items.filter(i => i.phase === phase);
const phaseChecked = phaseItems.filter(i => !!checkedItems[i.key]).length;
const phasePct = Math.round((phaseChecked / phaseItems.length) * 100);
const depts = [...new Set(phaseItems.map(i => i.dept))];
return (
<div key={phase} style={{ marginBottom: '16px', border: '1px solid var(--border-color)', borderRadius: '10px', overflow: 'hidden' }}>
{/* Phase header */}
<div style={{ padding: '10px 16px', background: 'var(--bg-tertiary)', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-primary)', flex: 1 }}>{phase}</span>
<span style={{ fontSize: '11px', fontFamily: 'monospace', color: phasePct === 100 ? '#10b981' : 'var(--text-muted)' }}>
{phaseChecked}/{phaseItems.length}
</span>
<div style={{ width: '60px', height: '3px', background: 'var(--border-color)', borderRadius: '2px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${phasePct}%`, background: phasePct === 100 ? '#10b981' : 'var(--cereda-primary)', transition: 'width 0.3s' }} />
</div>
</div>
{/* Department columns */}
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${depts.length}, 1fr)`, gap: '1px', background: 'var(--border-color)' }}>
{depts.map(dept => {
const dc = DEPT_COLORS[dept] || { color: 'var(--text-muted)', bg: 'var(--bg-secondary)', label: dept };
const deptItems = phaseItems.filter(i => i.dept === dept);
return (
<div key={dept} style={{ background: 'var(--bg-card)', padding: '10px 12px' }}>
<div style={{ fontSize: '10px', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: dc.color, borderLeft: `3px solid ${dc.color}`, paddingLeft: '6px', marginBottom: '8px' }}>
{dc.label}
</div>
{deptItems.map(item => {
const checked = !!checkedItems[item.key];
const tagStyle = TAG_STYLES[item.tag] || TAG_STYLES.normal;
return (
<div
key={item.key}
onClick={() => handleToggle(item.key)}
style={{
padding: '7px 8px',
marginBottom: '5px',
borderRadius: '6px',
border: `1px solid ${checked ? dc.color + '50' : 'var(--border-color)'}`,
borderLeft: `3px solid ${dc.color}`,
background: checked ? dc.bg : 'var(--bg-secondary)',
cursor: disabled ? 'default' : 'pointer',
transition: 'all 0.15s',
opacity: checked ? 0.7 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '7px' }}>
<div style={{
width: '15px', height: '15px', flexShrink: 0, marginTop: '1px',
border: `1.5px solid ${checked ? dc.color : 'var(--border-color)'}`,
borderRadius: '3px', background: checked ? dc.color : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '9px', color: '#fff',
}}>
{checked ? '✓' : ''}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11.5px', fontWeight: 600, color: checked ? 'var(--text-muted)' : 'var(--text-primary)', textDecoration: checked ? 'line-through' : 'none', lineHeight: 1.4 }}>
{item.label}
</div>
{item.detail && (
<div style={{ fontSize: '10.5px', color: 'var(--text-muted)', marginTop: '2px', lineHeight: 1.35 }}>
{item.detail}
</div>
)}
{item.tag && (
<span style={{ display: 'inline-block', marginTop: '4px', fontSize: '10px', fontFamily: 'monospace', padding: '1px 5px', borderRadius: '3px', ...tagStyle }}>
{item.tag === 'critical' ? '🔴 Kritisch' : item.tag === 'important' ? '⚡ Wichtig' : 'Standard'}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
};
export default ChecklistEditor;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const LoadingSpinner = () => {
return (
<div className="loading-spinner">
<div className="spinner"></div>
</div>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,16 @@
import React from 'react';
const Logo = ({ className, width = 200, height = 60 }) => {
return (
<img
src="/cereda-logo.png"
alt="Cereda Systems Logo"
className={className}
width={width}
height={height}
style={{ objectFit: 'contain' }}
/>
);
};
export default Logo;

View File

@@ -0,0 +1,400 @@
import React, { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
import Logo from './Logo';
const Navbar = () => {
const { user, logout, isSuperAdmin, isAdmin, isSupport, canViewLifecycle, isTechniker } = useAuth();
const { notifications, unreadCount, markAllRead, markRead, addTestNotification } = useNotifications();
const navigate = useNavigate();
const isStaff = isSupport();
const [showNotifDropdown, setShowNotifDropdown] = useState(false);
const [showUserDropdown, setShowUserDropdown] = useState(false);
const dropdownRef = useRef(null);
const userDropdownRef = useRef(null);
const handleLogout = async () => {
await logout();
navigate('/login');
};
// Dropdown schließen bei Klick außerhalb
useEffect(() => {
const handleClickOutside = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setShowNotifDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleBellClick = () => {
setShowNotifDropdown(prev => !prev);
};
const handleNotifClick = (notif) => {
markRead(notif.id);
setShowNotifDropdown(false);
navigate(`/tickets/${notif.ticketId}`);
};
const formatTime = (iso) => {
const d = new Date(iso);
const now = new Date();
const diffMin = Math.floor((now - d) / 60000);
if (diffMin < 1) return 'Gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min.`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `vor ${diffH} Std.`;
return d.toLocaleDateString('de-DE');
};
return (
<nav className="navbar">
<div className="navbar-container">
<Link to={isStaff ? '/dashboard' : '/portal'} className="navbar-brand">
<Logo className="navbar-logo" width={150} height={45} />
</Link>
<ul className="navbar-menu">
{isStaff ? (
<>
<li><Link to="/dashboard" className="navbar-link">Dashboard</Link></li>
<li><Link to="/fido-keys" className="navbar-link">FIDO-Keys</Link></li>
<li><Link to="/assets" className="navbar-link">Assets</Link></li>
<li><Link to="/tickets" className="navbar-link">Tickets</Link></li>
<li><Link to="/knowledge-base" className="navbar-link">Wissensdatenbank</Link></li>
{['hr_personal', 'buchhaltung', 'support', 'admin', 'super_admin'].includes(user?.role_name) && (
<li><Link to="/lifecycle" className="navbar-link">Lifecycle</Link></li>
)}
{(isSuperAdmin() || isAdmin()) && (
<li><Link to="/it-overview" className="navbar-link">IT Übersicht</Link></li>
)}
{['admin', 'super_admin', 'buchhaltung'].includes(user?.role_name) && (
<li><Link to="/anlagevermoegen" className="navbar-link">Anlagevermögen</Link></li>
)}
{(isSuperAdmin() || isAdmin()) && (
<li><Link to="/users" className="navbar-link">Benutzer</Link></li>
)}
</>
) : (
<>
<li><Link to="/portal" className="navbar-link">Mein Portal</Link></li>
{isTechniker() && (
<li><Link to="/assets" className="navbar-link">Assets</Link></li>
)}
{canViewLifecycle() && (
<li><Link to="/lifecycle" className="navbar-link">Lifecycle</Link></li>
)}
</>
)}
</ul>
<div className="user-menu">
{/* Benachrichtigungs-Glocke (nur für Staff) */}
{isStaff && (
<div ref={dropdownRef} style={{ position: 'relative' }}>
<button
onClick={handleBellClick}
title="Benachrichtigungen"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '34px',
height: '34px',
borderRadius: '8px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
fontSize: '18px',
cursor: 'pointer',
transition: 'var(--transition)',
flexShrink: 0,
position: 'relative',
}}
>
🔔
{unreadCount > 0 && (
<span style={{
position: 'absolute',
top: '-4px',
right: '-4px',
background: '#ef4444',
color: '#fff',
fontSize: '10px',
fontWeight: 700,
borderRadius: '10px',
minWidth: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 3px',
lineHeight: 1,
}}>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{showNotifDropdown && (
<div style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
width: '340px',
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
zIndex: 1000,
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: '1px solid var(--border-color)',
}}>
<span style={{ fontWeight: 700, fontSize: '14px', color: 'var(--text-primary)' }}>
Benachrichtigungen
</span>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={addTestNotification}
title="Test-Benachrichtigung senden"
style={{
background: 'none',
border: '1px solid var(--border-color)',
borderRadius: '4px',
color: 'var(--text-muted)',
fontSize: '11px',
cursor: 'pointer',
padding: '2px 6px',
}}
>
🧪 Test
</button>
{unreadCount > 0 && (
<button
onClick={markAllRead}
style={{
background: 'none',
border: 'none',
color: 'var(--cereda-primary)',
fontSize: '12px',
cursor: 'pointer',
fontWeight: 600,
padding: 0,
}}
>
Alle gelesen
</button>
)}
</div>
</div>
{/* Liste */}
<div style={{ maxHeight: '360px', overflowY: 'auto' }}>
{notifications.length === 0 ? (
<div style={{
padding: '32px 16px',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '13px',
}}>
<div style={{ fontSize: '28px', marginBottom: '8px' }}>🔕</div>
Keine Benachrichtigungen
</div>
) : (
notifications.map(notif => (
<div
key={notif.id}
onClick={() => handleNotifClick(notif)}
style={{
display: 'flex',
gap: '12px',
padding: '12px 16px',
cursor: 'pointer',
background: notif.read ? 'transparent' : 'rgba(59,130,246,0.06)',
borderBottom: '1px solid var(--border-color)',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = notif.read ? 'transparent' : 'rgba(59,130,246,0.06)'}
>
<div style={{
width: '36px',
height: '36px',
borderRadius: '8px',
background: notif.critical ? 'rgba(239,68,68,0.12)' : 'rgba(59,130,246,0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
flexShrink: 0,
}}>
🎫
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: notif.read ? 500 : 700,
fontSize: '13px',
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{notif.title}
</div>
<div style={{
fontSize: '12px',
color: 'var(--text-secondary)',
marginTop: '2px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{notif.body}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px' }}>
{formatTime(notif.createdAt)}
</div>
</div>
{!notif.read && (
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#3b82f6',
flexShrink: 0,
alignSelf: 'center',
}} />
)}
</div>
))
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div style={{
padding: '10px 16px',
borderTop: '1px solid var(--border-color)',
textAlign: 'center',
}}>
<Link
to="/tickets"
onClick={() => setShowNotifDropdown(false)}
style={{
fontSize: '12px',
color: 'var(--cereda-primary)',
textDecoration: 'none',
fontWeight: 600,
}}
>
Alle Tickets ansehen
</Link>
</div>
)}
</div>
)}
</div>
)}
{/* Zahnrad */}
{(isSuperAdmin() || isAdmin()) && (
<Link
to="/system"
className="navbar-gear-btn"
title="Systemeinstellungen"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '34px',
height: '34px',
borderRadius: '8px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
fontSize: '18px',
textDecoration: 'none',
transition: 'var(--transition)',
flexShrink: 0,
}}
>
</Link>
)}
<div
ref={userDropdownRef}
style={{ position: 'relative' }}
onMouseEnter={() => setShowUserDropdown(true)}
onMouseLeave={() => setShowUserDropdown(false)}
>
<div className="user-info" style={{ cursor: 'pointer', userSelect: 'none' }}>
<span>{user?.username}</span>
<span className="role-badge">{user?.role_name}</span>
</div>
{showUserDropdown && (
<div style={{
position: 'absolute',
top: '100%',
right: 0,
minWidth: '180px',
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '10px',
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
zIndex: 1000,
overflow: 'hidden',
paddingTop: '4px',
paddingBottom: '4px',
}}>
<Link
to="/mein-konto"
onClick={() => setShowUserDropdown(false)}
style={{
display: 'flex', alignItems: 'center', gap: '10px',
padding: '10px 16px', textDecoration: 'none',
color: 'var(--text-primary)', fontSize: '14px', fontWeight: 500,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<span>👤</span> Mein Konto
</Link>
<div style={{ height: '1px', background: 'var(--border-color)', margin: '2px 0' }} />
<button
onClick={handleLogout}
style={{
display: 'flex', alignItems: 'center', gap: '10px', width: '100%',
padding: '10px 16px', background: 'none', border: 'none',
color: '#ef4444', fontSize: '14px', fontWeight: 500,
cursor: 'pointer', textAlign: 'left', transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(239,68,68,0.07)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<span>🚪</span> Abmelden
</button>
</div>
)}
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import LoadingSpinner from './LoadingSpinner';
const ProtectedRoute = ({ children, allowedRoles, bypassPasswordCheck = false }) => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return <LoadingSpinner />;
}
if (!user) {
return <Navigate to="/login" replace />;
}
// Force password change if required (unless already on change-password page)
if (!bypassPasswordCheck && user.must_change_password && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />;
}
// Check role-based access
if (allowedRoles && !allowedRoles.includes(user.role_name)) {
const redirectPath = user.role_name === 'benutzer' ? '/portal' : '/dashboard';
return <Navigate to={redirectPath} replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
/* ── SVG Icon Components ────────────────────────────────────────── */
const I = ({ d, children, ...p }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" {...p}>
{d ? <path d={d} /> : children}
</svg>
);
const icons = {
dashboard: <I><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></I>,
portal: <I d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>,
tickets: <I><path d="M22 12c0-5.5-4.5-10-10-10S2 6.5 2 12v6a2 2 0 0 0 2 2h2v-7H4v-1a8 8 0 0 1 16 0v1h-2v7h2a2 2 0 0 0 2-2z"/></I>,
knowledge: <I><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v17H6.5a2.5 2.5 0 0 0 0 5H20"/><path d="M8 7h8M8 11h6"/></I>,
anleitungen: <I><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h4"/></I>,
assets: <I><rect x="2" y="7" width="20" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></I>,
warehouse: <I><path d="m21 16-9 5-9-5V8l9-5 9 5z"/><path d="m3 8 9 5 9-5M12 13v8"/></I>,
licenses: <I><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></I>,
maintenance: <I d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>,
fido: <I d="m15.5 7.5 3 3L22 7l-3-3M21 2l-9.6 9.6a5.5 5.5 0 1 1-2 2L19 4"/>,
lifecycle: <I><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.78 1 6.43 2.57L21 8"/><path d="M21 3v5h-5"/></I>,
anlagevermoegen: <I><path d="M3 21h18M5 11v6M9 11v6M15 11v6M19 11v6M3 7l9-4 9 4v3H3z"/></I>,
monitoring: <I d="M22 12h-4l-3 9L9 3l-3 9H2"/>,
defender: <I d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>,
patch: <I><path d="M12 8V4M12 4l-3 3M12 4l3 3M3 12h4M3 12l3-3M3 12l3 3M12 16v4M12 20l-3-3M12 20l3-3M21 12h-4M21 12l-3-3M21 12l-3 3"/></I>,
compliance: <I><path d="M9 12l2 2 4-4M21 12c0 5-3.5 9-9 9s-9-4-9-9V5l9-3 9 3z"/></I>,
entra: <I><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></I>,
users: <I><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></I>,
security: <I><path d="M12 2 4 5v6c0 5 3.5 8 8 11 4.5-3 8-6 8-11V5z"/><path d="M9 12l2 2 4-4"/></I>,
ki: <I><path d="M12 2a3 3 0 0 0-3 3v1H7a3 3 0 0 0-3 3v2H3a2 2 0 0 0 0 4h1v2a3 3 0 0 0 3 3h2v1a3 3 0 0 0 6 0v-1h2a3 3 0 0 0 3-3v-2h1a2 2 0 0 0 0-4h-1V9a3 3 0 0 0-3-3h-2V5a3 3 0 0 0-3-3z"/></I>,
onboarding: <I><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6M23 11h-6"/></I>,
announcements: <I><path d="M11 5L6 9H2v6h4l5 4V5zM19 12c0-2.5-1.5-5-4-6"/></I>,
system: <I><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></I>,
};
/* ── NavItem ─────────────────────────────────────────────────────── */
const NavItem = ({ to, icon, label, badge, badgeCls, active }) => (
<Link to={to} className={`sidebar-link${active ? ' sidebar-link--active' : ''}`}>
<span className="sidebar-link-icon">{icons[icon] || icons.dashboard}</span>
<span className="sidebar-link-label">{label}</span>
{badge != null && <span className={`sidebar-badge${badgeCls ? ' ' + badgeCls : ''}`}>{badge}</span>}
</Link>
);
/* ── Submenu ─────────────────────────────────────────────────────── */
const SubMenu = ({ label, icon, items, isActive }) => {
const hasActive = items.some(i => isActive(i.to));
const [open, setOpen] = useState(hasActive);
return (
<>
<button className={`sidebar-link${hasActive ? ' sidebar-link--active' : ''}`} onClick={() => setOpen(o => !o)}>
<span className="sidebar-link-icon">{icons[icon] || icons.compliance}</span>
<span className="sidebar-link-label">{label}</span>
<span className={`sidebar-submenu-chevron${open ? ' open' : ''}`}></span>
</button>
{open && (
<div className="sidebar-submenu">
{items.map(item => (
<Link key={item.to} to={item.to} className={`sidebar-link${isActive(item.to) ? ' sidebar-link--active' : ''}`} style={{ fontSize: 13 }}>
<span className="sidebar-link-icon">{icons[item.icon] || icons.dashboard}</span>
<span className="sidebar-link-label">{item.label}</span>
</Link>
))}
</div>
)}
</>
);
};
/* ═══════════════════════════════════════════════════════════════════
Sidebar
═══════════════════════════════════════════════════════════════════ */
const Sidebar = () => {
const location = useLocation();
const { user, isAdmin, isSupport, isTechniker, canViewLifecycle, canModifyFidoKeys } = useAuth();
const isStaff = isSupport();
const role = user?.role_name;
const isActive = (to) => {
if (to === '/dashboard' || to === '/portal') return location.pathname === to;
return location.pathname.startsWith(to);
};
return (
<aside className="sidebar">
{/* Brand */}
<Link to={isStaff ? '/dashboard' : '/portal'} className="sidebar-brand">
<div className="sidebar-brand-mark">C</div>
<div>
<div className="sidebar-brand-name">Cereda Systems</div>
<div className="sidebar-brand-sub">IT Nexus</div>
</div>
</Link>
{/* Nav Search */}
<div className="sidebar-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" width="14" height="14" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>
</svg>
<span>Suchen</span>
<span className="sidebar-search-kbd">K</span>
</div>
<nav className="sidebar-nav">
{/* Dashboard / Portal */}
<div className="sidebar-group">
{isStaff && <NavItem to="/dashboard" icon="dashboard" label="Dashboard" active={isActive('/dashboard')} />}
{!isStaff && <NavItem to="/portal" icon="portal" label="Mein Portal" active={isActive('/portal')} />}
</div>
{/* Helpdesk */}
<div className="sidebar-group">
<div className="sidebar-group-label">Helpdesk</div>
{isStaff && <NavItem to="/tickets" icon="tickets" label="Tickets" active={isActive('/tickets')} />}
{isStaff && <NavItem to="/knowledge-base" icon="knowledge" label="Wissensdatenbank" active={isActive('/knowledge-base')} />}
<NavItem to="/anleitungen" icon="anleitungen" label="Anleitungen" active={isActive('/anleitungen')} />
</div>
{/* IT-Verwaltung */}
{(isStaff || isTechniker() || canModifyFidoKeys() || canViewLifecycle() || ['admin','super_admin','buchhaltung'].includes(role)) && (
<div className="sidebar-group">
<div className="sidebar-group-label">IT-Verwaltung</div>
{(isStaff || isTechniker()) && <NavItem to="/assets" icon="assets" label="Assets" active={isActive('/assets')} />}
{isStaff && <NavItem to="/warehouse" icon="warehouse" label="Lager" active={isActive('/warehouse')} />}
{canModifyFidoKeys() && <NavItem to="/licenses" icon="licenses" label="Lizenzen" active={isActive('/licenses')} />}
{canModifyFidoKeys() && <NavItem to="/maintenance" icon="maintenance" label="Wartung" active={isActive('/maintenance')} />}
{isStaff && <NavItem to="/fido-keys" icon="fido" label="FIDO-Keys" active={isActive('/fido-keys')} />}
{canViewLifecycle() && <NavItem to="/lifecycle" icon="lifecycle" label="Lifecycle" active={isActive('/lifecycle')} />}
{['admin','super_admin','buchhaltung'].includes(role) && <NavItem to="/anlagevermoegen" icon="anlagevermoegen" label="Anlagevermögen" active={isActive('/anlagevermoegen')} />}
</div>
)}
{/* Monitoring & Security */}
{isAdmin() && (
<div className="sidebar-group">
<div className="sidebar-group-label">Monitoring & Security</div>
<NavItem to="/monitoring" icon="monitoring" label="Monitoring" active={isActive('/monitoring')} />
<NavItem to="/nexus-scanner" icon="monitoring" label="Nexus Scanner" badge="NEU" badgeCls="accent" active={isActive('/nexus-scanner')} />
<NavItem to="/defender" icon="defender" label="Microsoft Defender" active={isActive('/defender')} />
<NavItem to="/patch-management" icon="patch" label="Patch Management" active={isActive('/patch-management')} />
<SubMenu label="Compliance" icon="compliance" isActive={isActive} items={[
{ to: '/it-overview', label: 'IT Übersicht', icon: 'dashboard' },
{ to: '/iso', label: 'ISO-Zertifizierung', icon: 'compliance' },
{ to: '/risk', label: 'Risikoanalyse', icon: 'security' },
]} />
</div>
)}
{/* Administration */}
{isAdmin() && (
<div className="sidebar-group">
<div className="sidebar-group-label">Administration</div>
<NavItem to="/benutzerverwaltung" icon="users" label="Benutzerverwaltung" badge="NEU" badgeCls="accent" active={isActive('/benutzerverwaltung')} />
<NavItem to="/entra" icon="entra" label="Entra Rechte" active={isActive('/entra')} />
<NavItem to="/users" icon="users" label="Benutzer" active={isActive('/users')} />
<NavItem to="/announcements" icon="announcements" label="Ankündigungen" active={isActive('/announcements')} />
<NavItem to="/security-reports" icon="security" label="Security Reports" active={isActive('/security-reports')} />
<NavItem to="/ki-wissen" icon="ki" label="KI Wissensdatenbank" badge="NEU" badgeCls="accent" active={isActive('/ki-wissen')} />
<NavItem to="/proxmox" icon="monitoring" label="Proxmox" active={isActive('/proxmox')} />
<NavItem to="/docker" icon="monitoring" label="Docker" active={isActive('/docker')} />
<NavItem to="/system" icon="system" label="Systemeinstellungen" active={isActive('/system')} />
</div>
)}
</nav>
</aside>
);
};
export default Sidebar;

View File

@@ -0,0 +1,237 @@
import React, { useState, useRef, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
const ROUTE_LABELS = {
'/dashboard': 'Dashboard', '/portal': 'Mein Portal',
'/tickets': 'Tickets', '/knowledge-base': 'Wissensdatenbank', '/anleitungen': 'Anleitungen',
'/assets': 'Assets', '/warehouse': 'Lager', '/licenses': 'Lizenzen',
'/maintenance': 'Wartung', '/fido-keys': 'FIDO-Keys', '/lifecycle': 'Lifecycle',
'/anlagevermoegen': 'Anlagevermögen', '/monitoring': 'Monitoring',
'/defender': 'Microsoft Defender', '/patch-management': 'Patch Management',
'/it-overview': 'IT Übersicht', '/iso': 'ISO-Zertifizierung', '/risk': 'Risikoanalyse',
'/entra': 'Entra Rechte', '/users': 'Benutzer', '/announcements': 'Ankündigungen',
'/security-reports': 'Security Reports', '/ki-wissen': 'KI Wissensdatenbank',
'/system': 'Systemeinstellungen', '/mein-konto': 'Mein Konto',
'/onboarding': 'Onboarding', '/offboarding': 'Offboarding',
'/proxmox': 'Proxmox', '/docker': 'Docker', '/health': 'Health',
};
const getPageLabel = (pathname) => {
if (ROUTE_LABELS[pathname]) return ROUTE_LABELS[pathname];
for (const [route, label] of Object.entries(ROUTE_LABELS)) {
if (pathname.startsWith(route) && route !== '/') return label;
}
return pathname.replace('/', '').charAt(0).toUpperCase() + pathname.slice(2);
};
const formatTime = (iso) => {
const d = new Date(iso), now = new Date();
const diffMin = Math.floor((now - d) / 60000);
if (diffMin < 1) return 'Gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min.`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `vor ${diffH} Std.`;
return d.toLocaleDateString('de-DE');
};
/* SVG Icons */
const BellIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M13.7 21a2 2 0 0 1-3.4 0"/>
</svg>
);
const GearIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
);
const SunIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
);
const MoonIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
</svg>
);
const UserIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
);
const LogoutIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
</svg>
);
/* ═══════════════════════════════════════════════════════════════════
Topbar
═══════════════════════════════════════════════════════════════════ */
const Topbar = () => {
const { user, logout, isSuperAdmin, isAdmin, isSupport } = useAuth();
const { notifications, unreadCount, markAllRead, markRead, addTestNotification } = useNotifications();
const navigate = useNavigate();
const location = useLocation();
const isStaff = isSupport();
const [showNotif, setShowNotif] = useState(false);
const [showUser, setShowUser] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem('nexus-theme') || 'dark');
const notifRef = useRef(null);
const userRef = useRef(null);
const pageLabel = getPageLabel(location.pathname);
const initials = (() => {
const fn = user?.first_name?.[0] || '';
const ln = user?.last_name?.[0] || '';
return (fn + ln).toUpperCase() || (user?.username?.[0] || '?').toUpperCase();
})();
useEffect(() => {
const saved = localStorage.getItem('nexus-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
setTheme(saved);
}, []);
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('nexus-theme', next);
};
useEffect(() => {
const handler = (e) => {
if (notifRef.current && !notifRef.current.contains(e.target)) setShowNotif(false);
if (userRef.current && !userRef.current.contains(e.target)) setShowUser(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const handleNotifClick = (notif) => {
markRead(notif.id);
setShowNotif(false);
if (notif.ticketId) navigate(`/tickets/${notif.ticketId}`);
};
return (
<header className="topbar">
{/* Breadcrumb */}
<div className="topbar-breadcrumb">
<span>Cereda Systems</span>
<span className="bc-sep">/</span>
<span className="bc-current">{pageLabel}</span>
</div>
<div className="topbar-right">
{/* Theme Toggle */}
<button className="nx-theme-toggle" onClick={toggleTheme} aria-label="Theme umschalten">
<span className="tt-ico sun"><SunIcon /></span>
<span className="tt-ico moon"><MoonIcon /></span>
<span className="tt-thumb" />
</button>
{/* Notifications */}
{isStaff && (
<div ref={notifRef} style={{ position: 'relative' }}>
<button className="topbar-icon-btn" onClick={() => setShowNotif(p => !p)} title="Benachrichtigungen" style={{ position: 'relative' }}>
<BellIcon />
{unreadCount > 0 && (
<span style={{
position: 'absolute', top: '-3px', right: '-3px',
background: '#ff3b30', color: '#fff',
fontSize: '9px', fontWeight: 700,
borderRadius: '10px', minWidth: '16px', height: '16px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 3px', border: '2px solid var(--bg-primary)',
}}>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{showNotif && (
<div className="notif-dropdown">
<div className="notif-dropdown-header">
<span style={{ fontWeight: 700, fontSize: 14 }}>Benachrichtigungen</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={addTestNotification} title="Test" style={{ background: 'none', border: '1px solid var(--border-color)', borderRadius: 4, color: 'var(--text-muted)', fontSize: 11, cursor: 'pointer', padding: '2px 6px' }}>🧪</button>
{unreadCount > 0 && <button onClick={markAllRead} style={{ background: 'none', border: 'none', color: 'var(--cereda-primary)', fontSize: 12, cursor: 'pointer', fontWeight: 600, padding: 0 }}>Alle gelesen</button>}
</div>
</div>
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{notifications.length === 0 ? (
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>🔕</div>Keine Benachrichtigungen
</div>
) : notifications.map(notif => (
<div key={notif.id} onClick={() => handleNotifClick(notif)}
style={{ display: 'flex', gap: 12, padding: '12px 16px', cursor: 'pointer', background: notif.read ? 'transparent' : 'rgba(59,130,246,0.06)', borderBottom: '1px solid var(--border-color)', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = notif.read ? 'transparent' : 'rgba(59,130,246,0.06)'}>
<div style={{ width: 36, height: 36, borderRadius: 8, background: notif.critical ? 'rgba(239,68,68,0.12)' : 'rgba(59,130,246,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, flexShrink: 0 }}>🎫</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: notif.read ? 500 : 700, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{notif.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{notif.body}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>{formatTime(notif.createdAt)}</div>
</div>
{!notif.read && <div style={{ width: 8, height: 8, borderRadius: '50%', background: '#3b82f6', flexShrink: 0, alignSelf: 'center' }} />}
</div>
))}
</div>
{notifications.length > 0 && (
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border-color)', textAlign: 'center' }}>
<Link to="/tickets" onClick={() => setShowNotif(false)} style={{ fontSize: 12, color: 'var(--cereda-primary)', textDecoration: 'none', fontWeight: 600 }}>Alle Tickets </Link>
</div>
)}
</div>
)}
</div>
)}
{/* Settings */}
{(isSuperAdmin() || isAdmin()) && (
<Link to="/system" className="topbar-icon-btn" title="Systemeinstellungen" style={{ textDecoration: 'none' }}>
<GearIcon />
</Link>
)}
{/* User Chip */}
<div ref={userRef} style={{ position: 'relative' }}>
<div className="nx-user-chip" onClick={() => setShowUser(p => !p)}>
<span className="nx-user-name">{user?.username}</span>
<span className="nx-user-role">{user?.role_name}</span>
<div className="nx-user-avatar">{initials}</div>
</div>
{showUser && (
<div className="nx-user-dropdown">
<Link to="/mein-konto" onClick={() => setShowUser(false)} className="nx-user-dropdown-item">
<UserIcon /> Mein Konto
</Link>
<div className="nx-user-dropdown-divider" />
<button onClick={handleLogout} className="nx-user-dropdown-item danger">
<LogoutIcon /> Abmelden
</button>
</div>
)}
</div>
</div>
</header>
);
};
export default Topbar;