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

39
frontend/src/App.js Normal file
View File

@@ -0,0 +1,39 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { AuthProvider } from './context/AuthContext';
import { NotificationProvider } from './context/NotificationContext';
import { ThemeProvider } from './context/ThemeContext';
import AppRoutes from './routes';
import AiChatWidget from './components/common/AiChatWidget';
function App() {
return (
<Router>
<ThemeProvider>
<AuthProvider>
<NotificationProvider>
<div className="app">
<AppRoutes />
<AiChatWidget />
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
</div>
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
</Router>
);
}
export default App;

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;

View File

@@ -0,0 +1,101 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import authService from '../services/authService';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
// Kiosk-Modus: Token aus URL → in localStorage speichern, URL bereinigen
localStorage.setItem('token', urlToken);
window.history.replaceState({}, '', window.location.pathname);
authService.fetchUserFromToken(urlToken).then(userData => {
if (userData) {
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
}
setLoading(false);
});
return;
}
const storedUser = authService.getStoredUser();
const token = authService.getToken();
if (storedUser && token) {
setUser(storedUser);
}
setLoading(false);
}, []);
const login = async (username, password) => {
const data = await authService.login(username, password);
setUser(data.user);
return data;
};
// Called after Microsoft OAuth2 redirect with token in URL
const loginWithToken = (token, userData) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
};
const logout = async () => {
await authService.logout();
setUser(null);
};
const updateUser = (updatedUser) => {
setUser(updatedUser);
localStorage.setItem('user', JSON.stringify(updatedUser));
};
const hasRole = (allowedRoles) => {
if (!user || !user.role_name) return false;
return allowedRoles.includes(user.role_name);
};
const isSuperAdmin = () => hasRole(['super_admin']);
const isAdmin = () => hasRole(['super_admin', 'admin']);
const isTechniker = () => hasRole(['produktion']);
const isSupport = () => hasRole(['super_admin', 'admin', 'support', 'bearbeiter']);
const canViewLifecycle = () => hasRole(['super_admin', 'admin', 'support', 'hr_personal', 'buchhaltung']);
const canModifyFidoKeys = () => hasRole(['super_admin', 'admin', 'bearbeiter']);
const canViewFidoKeys = () => hasRole(['super_admin', 'admin', 'bearbeiter', 'benutzer']);
const canViewTickets = () => !!user;
const canModifyTickets = () => hasRole(['super_admin', 'admin', 'support']);
const value = {
user,
loading,
login,
loginWithToken,
logout,
updateUser,
hasRole,
isSuperAdmin,
isAdmin,
isTechniker,
isSupport,
canViewLifecycle,
canModifyFidoKeys,
canViewFidoKeys,
canViewTickets,
canModifyTickets,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,164 @@
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import ticketService from '../services/ticketService';
import teamsActivityService from '../services/teamsActivityService';
import { useAuth } from './AuthContext';
const NotificationContext = createContext(null);
export const useNotifications = () => useContext(NotificationContext);
const STORAGE_KEY = 'it_notif_last_seen';
const POLL_INTERVAL = 15000; // 15 Sekunden
const PRIO_LABEL = { kritisch: '🔴 Kritisch', hoch: '🟠 Hoch', mittel: '🔵 Mittel', niedrig: '🟢 Niedrig' };
export const NotificationProvider = ({ children }) => {
const { isAuthenticated, isSupport } = useAuth();
const [notifications, setNotifications] = useState([]);
const [permission, setPermission] = useState(Notification?.permission || 'default');
const lastSeenRef = useRef(localStorage.getItem(STORAGE_KEY) || new Date().toISOString());
const knownIdsRef = useRef(new Set());
const pollRef = useRef(null);
const requestPermission = useCallback(async () => {
if (!('Notification' in window)) return;
const result = await Notification.requestPermission();
setPermission(result);
}, []);
const sendBrowserNotif = useCallback((title, body, tag) => {
if (permission !== 'granted' || !('Notification' in window)) return;
try {
new Notification(title, { body, icon: '/favicon.ico', tag });
} catch {}
}, [permission]);
const markAllRead = useCallback(() => {
const now = new Date().toISOString();
localStorage.setItem(STORAGE_KEY, now);
lastSeenRef.current = now;
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
teamsActivityService.markRead().catch(() => {});
}, []);
const markRead = useCallback((id) => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
}, []);
const knownTeamsChannelIdsRef = useRef(new Set());
const poll = useCallback(async () => {
if (!isAuthenticated || !isSupport()) return;
const newNotifs = [];
// Ticket-Benachrichtigungen
try {
const tickets = await ticketService.getAll();
tickets.forEach(ticket => {
if (knownIdsRef.current.has(ticket.id)) return;
knownIdsRef.current.add(ticket.id);
const createdAt = ticket.created_at;
if (createdAt > lastSeenRef.current) {
const notif = {
id: `ticket-${ticket.id}-new`,
type: 'new_ticket',
ticketId: ticket.id,
ticketNumber: ticket.ticket_number,
title: `Neues Ticket: ${ticket.title}`,
body: `${PRIO_LABEL[ticket.priority] || ticket.priority} · ${ticket.category}`,
createdAt,
read: false,
critical: ticket.priority === 'kritisch' || ticket.priority === 'hoch',
};
newNotifs.push(notif);
sendBrowserNotif(
`📋 ${ticket.ticket_number}: ${ticket.title}`,
`${PRIO_LABEL[ticket.priority]} · ${ticket.category}`,
`ticket-${ticket.id}`
);
}
});
} catch {}
// Teams-Kanal-Benachrichtigungen
try {
const newChannels = await teamsActivityService.getNewChannels();
for (const ch of newChannels) {
if (knownTeamsChannelIdsRef.current.has(ch.id)) continue;
knownTeamsChannelIdsRef.current.add(ch.id);
newNotifs.push({
id: `teams-channel-${ch.id}`,
type: 'new_teams_channel',
title: `Neuer Teams-Kanal: ${ch.channel_name}`,
body: `Team: ${ch.team_name}`,
createdAt: ch.first_seen_at,
read: false,
critical: false,
});
sendBrowserNotif(
`💬 Neuer Teams-Kanal: ${ch.channel_name}`,
`Team: ${ch.team_name}`,
`teams-channel-${ch.id}`
);
}
} catch {}
if (newNotifs.length > 0) {
setNotifications(prev => [...newNotifs, ...prev].slice(0, 50));
}
}, [isAuthenticated, isSupport, sendBrowserNotif]);
// Initial load + polling
useEffect(() => {
if (!isAuthenticated || !isSupport()) return;
// Initial: alle bekannten IDs laden ohne Notifications zu erzeugen
ticketService.getAll().then(tickets => {
tickets.forEach(t => knownIdsRef.current.add(t.id));
}).catch(() => {});
pollRef.current = setInterval(poll, POLL_INTERVAL);
return () => clearInterval(pollRef.current);
}, [isAuthenticated, isSupport, poll]);
// Browser Notification Permission beim ersten Laden anfragen
useEffect(() => {
if (isAuthenticated && isSupport() && 'Notification' in window && Notification.permission === 'default') {
// Kurz warten damit die UI zuerst lädt
setTimeout(requestPermission, 3000);
}
}, [isAuthenticated, isSupport, requestPermission]);
const addTestNotification = useCallback(() => {
const notif = {
id: `test-${Date.now()}`,
type: 'new_ticket',
ticketId: 9999,
ticketNumber: 'TKT-TEST',
title: 'Neues Ticket: Test-Benachrichtigung',
body: '🔴 Kritisch · Hardware',
createdAt: new Date().toISOString(),
read: false,
critical: true,
};
setNotifications(prev => [notif, ...prev].slice(0, 50));
sendBrowserNotif('📋 TKT-TEST: Test-Benachrichtigung', '🔴 Kritisch · Hardware', 'test-notif');
}, [sendBrowserNotif]);
const unreadCount = notifications.filter(n => !n.read).length;
return (
<NotificationContext.Provider value={{
notifications,
unreadCount,
markAllRead,
markRead,
permission,
requestPermission,
pollNow: poll,
addTestNotification,
}}>
{children}
</NotificationContext.Provider>
);
};

View File

@@ -0,0 +1,42 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({ theme: 'default', setTheme: () => {} });
export const THEMES = {
default: {
label: 'IT Nexus',
desc: 'Dunkles Teal-Theme',
preview: { bg: '#0f172a', sidebar: '#1e293b', accent: '#3fa3a3', text: '#f1f5f9' },
},
'cereda-desk': {
label: 'simDesk',
desc: 'Dunkles Violet-Theme',
preview: { bg: '#030712', sidebar: '#0f172a', accent: '#7c5cf6', text: '#f1f5f9' },
},
};
export function ThemeProvider({ children }) {
const [theme, setThemeState] = useState(() => localStorage.getItem('it-nexus-theme') || 'default');
const setTheme = (t) => {
setThemeState(t);
localStorage.setItem('it-nexus-theme', t);
};
useEffect(() => {
const html = document.documentElement;
if (theme === 'default') {
html.removeAttribute('data-theme');
} else {
html.setAttribute('data-theme', theme);
}
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

11
frontend/src/index.js Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './styles/App.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,660 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import monitoringService from '../services/monitoringService';
import assetService from '../services/assetService';
import api from '../services/api';
import { toast } from 'react-toastify';
// ─── CSS Variables injected inline (design from Device Detail.html) ─────────────
const C = {
teal: '#14b8a8',
teal2: '#2dd2c2',
tealDim: 'rgba(20,184,168,0.14)',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
purple: '#7c3aed',
};
// ─── Helpers ────────────────────────────────────────────────────────────────────
const timeAgo = (iso) => {
if (!iso) return '';
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min`;
if (m < 1440) return `vor ${Math.floor(m / 60)} Std`;
return `vor ${Math.floor(m / 1440)} Tagen`;
};
const uptimeStr = (h) => {
if (!h) return '';
const d = Math.floor(h / 24), rh = Math.floor(h % 24), rm = Math.floor((h * 60) % 60);
if (d > 0) return `${d}d ${rh}h ${rm}min`;
return `${rh}h ${rm}min`;
};
const pct = (used, total) => (total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0);
const barColor = (p, warn = 70, crit = 90) => {
if (p >= crit) return C.danger;
if (p >= warn) return C.warning;
return C.teal;
};
const colorFor = (name) => {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
const hues = [200, 220, 260, 280, 170, 150, 35, 20, 0, 320];
return `hsl(${hues[h % hues.length]}, 60%, 50%)`;
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => toast.success('Kopiert!', { autoClose: 1200 }));
};
// ─── Sub-components ─────────────────────────────────────────────────────────────
const MetricTile = ({ label, icon, value, unit, of: ofVal, footnote, pctVal, warnAt = 70, critAt = 90 }) => {
const p = pctVal ?? 0;
const col = barColor(p, warnAt, critAt);
return (
<div style={{
background: 'var(--bg-secondary)', border: '1px solid var(--border-color)',
borderRadius: 18, padding: '22px 24px', display: 'flex', flexDirection: 'column', gap: 14,
boxShadow: '0 1px 2px rgba(0,0,0,0.35)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, fontSize: 11.5, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.10em', color: 'var(--text-muted)' }}>
<span style={{ width: 26, height: 26, borderRadius: 7, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13 }}>{icon}</span>
{label}
</div>
<span style={{ fontSize: 13.5, fontWeight: 600, color: col, fontVariantNumeric: 'tabular-nums' }}>{p} %</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, fontVariantNumeric: 'tabular-nums' }}>
<span style={{ fontSize: 36, fontWeight: 600, letterSpacing: '-0.03em', lineHeight: 1, color: 'var(--text-primary)' }}>{value}</span>
{unit && <span style={{ fontSize: 14, color: 'var(--text-muted)', fontWeight: 500 }}>{unit}</span>}
{ofVal && <span style={{ fontSize: 14, color: 'var(--text-muted)' }}>{ofVal}</span>}
</div>
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-tertiary)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${p}%`, borderRadius: 99, background: col, transition: 'width .4s ease' }} />
</div>
{footnote && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 6 }}>
{footnote}
</div>
)}
</div>
);
};
const SecRow = ({ ok, warn: isWarn, name, desc, state }) => {
const bg = isWarn ? 'rgba(245,158,11,0.16)' : ok ? 'rgba(34,197,94,0.14)' : 'rgba(239,68,68,0.14)';
const color = isWarn ? C.warning : ok ? C.success : C.danger;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '12px 22px', borderTop: '1px solid var(--border-color)' }}>
<div style={{ width: 28, height: 28, borderRadius: 8, background: bg, color, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 14 }}>
{ok && !isWarn ? '✓' : isWarn ? '!' : '✗'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text-primary)' }}>{name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{desc}</div>
</div>
<span style={{
fontSize: 11.5, fontWeight: 600, color,
padding: '3px 9px', borderRadius: 999,
background: bg,
}}>{state}</span>
</div>
);
};
const DlRow = ({ label, value, mono, copyVal }) => (
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: 16, padding: '11px 22px', alignItems: 'center', borderTop: '1px solid var(--border-color)' }}>
<div style={{ fontSize: 12.5, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</div>
<div style={{ fontSize: 13.5, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', fontFamily: mono ? 'ui-monospace, monospace' : 'inherit', fontSize: mono ? 12.5 : 13.5 }}>
{value}
{copyVal && (
<button onClick={() => copyToClipboard(copyVal)} title="Kopieren" style={{ marginLeft: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0, fontSize: 11, opacity: 0.6 }}></button>
)}
</div>
</div>
);
const CardHead = ({ icon, title, meta }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '18px 22px', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--bg-tertiary)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 15 }}>
{icon}
</div>
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 600, letterSpacing: '-0.005em', color: 'var(--text-primary)' }}>{title}</h2>
{meta && <span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}>{meta}</span>}
</div>
);
const Btn = ({ onClick, children, variant = 'default', disabled }) => {
const styles = {
default: { background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
primary: { background: C.teal, color: '#fff', border: `1px solid ${C.teal}` },
danger: { background: 'var(--bg-secondary)', color: C.danger, border: `1px solid rgba(239,68,68,0.30)` },
};
return (
<button onClick={onClick} disabled={disabled} style={{
...styles[variant],
borderRadius: 10, padding: '9px 14px', fontSize: 13, fontWeight: 500,
cursor: disabled ? 'not-allowed' : 'pointer', display: 'inline-flex', alignItems: 'center', gap: 7,
whiteSpace: 'nowrap', transition: 'background .14s', opacity: disabled ? 0.5 : 1,
fontFamily: 'inherit',
}}>
{children}
</button>
);
};
// ─── Main Page ───────────────────────────────────────────────────────────────────
export default function AgentDetailPage() {
const { id, hostname } = useParams();
const navigate = useNavigate();
const [agent, setAgent] = useState(null);
const [asset, setAsset] = useState(null);
const [assignedUser, setAssignedUser] = useState(null);
const [loading, setLoading] = useState(true);
const [swQuery, setSwQuery] = useState('');
const [sendingAnn, setSendingAnn] = useState(false);
const [annText, setAnnText] = useState('');
const [showAnnModal, setShowAnnModal] = useState(false);
const [patchHistory, setPatchHistory] = useState([]);
const [countdown, setCountdown] = useState(60);
const agentRef = useRef(null);
const refreshTimer = useRef(null);
const countdownTimer = useRef(null);
useEffect(() => {
loadAgent();
return () => {
clearTimeout(refreshTimer.current);
clearInterval(countdownTimer.current);
};
}, [id, hostname]);
const loadAgent = async (silent = false) => {
try {
if (!silent) setLoading(true);
let data;
if (hostname) {
const all = await monitoringService.getAll();
data = all.find(a => a.hostname?.toLowerCase() === decodeURIComponent(hostname).toLowerCase());
if (!data) throw new Error('not found');
} else {
data = await monitoringService.getById(id);
}
setAgent(data);
agentRef.current = data;
// Countdown neu starten basierend auf last_checkin
clearInterval(countdownTimer.current);
const startCountdown = () => {
const secSince = data.last_checkin
? Math.floor((Date.now() - new Date(data.last_checkin).getTime()) / 1000)
: 60;
let remaining = Math.max(0, 60 - (secSince % 60));
setCountdown(remaining);
countdownTimer.current = setInterval(() => {
setCountdown(p => {
if (p <= 1) { loadAgent(true); return 60; }
return p - 1;
});
}, 1000);
};
startCountdown();
// Patch History laden
try {
const ph = await api.get(`/patch/commands?agent_id=${data.id}`);
setPatchHistory(ph.data || []);
} catch {}
// Asset + User nur beim ersten Laden
if (!silent) {
try {
const assets = await assetService.getAll();
const match = assets.find(a => a.name?.toLowerCase() === data.hostname?.toLowerCase());
if (match) {
setAsset(match);
if (match.assigned_to_user_id) {
const userRes = await api.get(`/users/${match.assigned_to_user_id}`);
setAssignedUser(userRes.data.data);
}
}
} catch {}
}
} catch {
if (!silent) {
toast.error('Agent nicht gefunden');
navigate('/monitoring');
}
} finally {
if (!silent) setLoading(false);
}
};
const handleSendCommand = async (command) => {
try {
await api.post('/patch/commands/trigger', { agent_id: agent.id, command });
toast.success('Befehl gesendet');
} catch {
toast.error('Fehler beim Senden');
}
};
const handleSendAnnouncement = async () => {
if (!annText.trim()) return;
setSendingAnn(true);
try {
await api.post('/announcements', {
title: 'Nachricht vom IT-Team',
message: annText,
type: 'info',
target_type: 'specific',
target_agent_ids: [agent.id],
});
toast.success('Ankündigung gesendet');
setShowAnnModal(false);
setAnnText('');
} catch {
toast.error('Fehler beim Senden');
} finally {
setSendingAnn(false);
}
};
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '60vh', color: 'var(--text-muted)' }}>
Lade Gerätedaten
</div>
);
if (!agent) return null;
const sw = agent.installed_software || [];
const filteredSw = sw.filter(s => {
if (!swQuery) return true;
const q = swQuery.toLowerCase();
const nm = typeof s === 'string' ? s : (s.name || s.Name || s.DisplayName || '');
const pub = typeof s === 'string' ? '' : (s.publisher || s.Publisher || '');
return nm.toLowerCase().includes(q) || pub.toLowerCase().includes(q);
});
const cpuPct = agent.cpu_usage_percent ?? 0;
const ramPct = pct(agent.ram_used_gb, agent.ram_total_gb);
const diskUsed = (agent.disk_total_gb ?? 0) - (agent.disk_free_gb ?? 0);
const diskPct = pct(diskUsed, agent.disk_total_gb);
const bitlockerOk = agent.bitlocker_status === 'on' || agent.bitlocker_status === 'encrypted';
const defenderOk = agent.defender_enabled === 1;
const sigAge = agent.defender_signatures_age >= 0 ? agent.defender_signatures_age : null;
const defenderWarn = defenderOk && sigAge !== null && sigAge > 7;
const tpmOk = agent.tpm_present === 1;
const tpmVersion = agent.tpm_version ? agent.tpm_version.split(',')[0].trim() : null;
const secureBootOk = agent.secure_boot === 1;
const win11Ok = agent.win11_ready === 1;
const isWorkgroup = !agent.domain || agent.domain === 'WORKGROUP';
const allSecOk = bitlockerOk && defenderOk && !defenderWarn && tpmOk && secureBootOk;
const userInitials = assignedUser
? ((assignedUser.first_name?.[0] || '') + (assignedUser.last_name?.[0] || '')).toUpperCase() || assignedUser.username?.[0]?.toUpperCase()
: null;
return (
<div style={{ maxWidth: 1320, margin: '0 auto', padding: '24px 28px 80px' }}>
{/* Back link */}
<Link to={hostname ? '/assets' : '/monitoring'} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--text-muted)', fontSize: 13, textDecoration: 'none', marginBottom: 20 }}>
Zurück zu {hostname ? 'Assets' : 'Monitoring'}
</Link>
{/* Offline Banner */}
{agent.status === 'offline' && (
<div style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.35)', borderRadius: 12, padding: '12px 18px', marginBottom: 20, display: 'flex', alignItems: 'center', gap: 10, color: C.danger, fontSize: 13.5, fontWeight: 500 }}>
Dieses Gerät ist offline Daten vom letzten Check-in ({timeAgo(agent.last_checkin)})
</div>
)}
{/* ── HERO ─────────────────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 24, alignItems: 'flex-end', marginBottom: 28, paddingBottom: 28, borderBottom: '1px solid var(--border-color)' }}>
<div>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.10em', marginBottom: 6 }}>
<span style={{ color: C.purple }}>Workstation · Windows</span>
{agent.domain && <span>· {agent.domain}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'rgba(124,58,237,0.14)', color: C.purple, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, flexShrink: 0 }}>
🖥
</div>
<h1 style={{ margin: 0, fontSize: 32, fontWeight: 600, letterSpacing: '-0.025em', lineHeight: 1.1, color: 'var(--text-primary)' }}>
{agent.hostname}
</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 11px', borderRadius: 999, fontSize: 12, fontWeight: 600,
background: agent.status === 'online' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.14)',
color: agent.status === 'online' ? C.success : C.danger,
}}>
<span style={{
width: 7, height: 7, borderRadius: '50%', background: 'currentColor',
...(agent.status === 'online' ? { animation: 'agPulse 1.6s ease-out infinite' } : {}),
}} />
{agent.status === 'online' ? 'Online' : 'Offline'}
</span>
</div>
<div style={{ fontSize: 13.5, color: 'var(--text-muted)', marginTop: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>Letzter Check-in <strong style={{ color: 'var(--text-secondary)' }}>{timeAgo(agent.last_checkin)}</strong></span>
<span>·</span>
<span>Nächster in <strong style={{ color: countdown <= 10 ? C.warning : 'var(--text-secondary)', fontFamily: 'ui-monospace, monospace' }}>{countdown}s</strong></span>
<span>·</span>
<span>Agent <strong style={{ color: 'var(--text-secondary)' }}>v{agent.agent_version || ''}</strong></span>
{agent.ip_address && <><span>·</span><span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12.5 }}>{agent.ip_address}</span></>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Btn onClick={() => setShowAnnModal(true)}>📢 Ankündigung senden</Btn>
<Btn onClick={() => handleSendCommand('check_updates')}>🔄 Updates prüfen</Btn>
{agent.ip_address && (
<Btn onClick={() => window.open(`rdp://${agent.ip_address}`, '_blank') || (window.location.href = `ms-rd:openLocalSubnetRDP?computer=${agent.ip_address}`)}>
🖥 RDP
</Btn>
)}
<Btn variant="danger" onClick={() => {
if (window.confirm(`Reboot für ${agent.hostname} anfordern?`)) handleSendCommand('reboot');
}}> Reboot anfordern</Btn>
</div>
</div>
{/* ── METRIC TILES ─────────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 22 }}>
<MetricTile
label="CPU" icon="⚙"
value={cpuPct.toFixed(0)} unit="%"
pctVal={cpuPct} warnAt={80} critAt={90}
footnote={<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11.5, color: 'var(--text-secondary)' }}>{agent.cpu_model?.replace(/\(R\)|\(TM\)/g, '') || ''}{agent.cpu_cores ? ` · ${agent.cpu_cores} Kerne` : ''}</span>}
/>
<MetricTile
label="RAM" icon="💾"
value={agent.ram_used_gb?.toFixed(1) ?? ''} ofVal={`/ ${agent.ram_total_gb?.toFixed(0) ?? ''} GB`}
pctVal={ramPct} warnAt={85} critAt={95}
footnote={<span>{ramPct}% belegt · {((agent.ram_total_gb ?? 0) - (agent.ram_used_gb ?? 0)).toFixed(1)} GB frei</span>}
/>
<MetricTile
label="Festplatte C:" icon="🗄"
value={agent.disk_free_gb?.toFixed(0) ?? ''} ofVal={`GB frei von ${agent.disk_total_gb?.toFixed(0) ?? ''} GB`}
pctVal={diskPct} warnAt={75} critAt={90}
footnote={<span>{diskPct}% belegt</span>}
/>
</div>
{/* ── TWO COLUMNS: SYSTEM + SECURITY ───────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 22 }}>
{/* System Info */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="🖥" title="Systeminformationen" meta="Hardware & OS" />
<div>
<DlRow label="Betriebssystem" value={agent.os_name || ''} />
<DlRow label="IP-Adresse" value={agent.ip_address || ''} mono copyVal={agent.ip_address} />
<DlRow label="MAC-Adresse" value={agent.mac_address || ''} mono copyVal={agent.mac_address} />
<DlRow label="Domain" value={agent.domain ? <>{agent.domain}{!isWorkgroup && <span style={{ color: 'var(--text-muted)', fontSize: 12 }}> (Active Directory)</span>}</> : ''} />
<DlRow label="Letzter Benutzer" value={agent.last_user ? <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12.5 }}>{agent.last_user}</span> : ''} />
<DlRow label="Seriennummer" value={agent.hardware_serial || ''} mono copyVal={agent.hardware_serial} />
<DlRow label="Uptime" value={<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{uptimeStr(agent.uptime_hours)}</strong>} />
</div>
</div>
{/* Security */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="🛡" title="Sicherheit & Compliance" meta={allSecOk ? 'Alle Checks bestanden' : 'Handlungsbedarf'} />
<div>
<SecRow
ok={bitlockerOk} warn={false}
name="BitLocker"
desc={bitlockerOk ? 'Systemlaufwerk C: verschlüsselt' : 'Laufwerk C: nicht verschlüsselt'}
state={bitlockerOk ? 'Verschlüsselt' : agent.bitlocker_status === 'unknown' ? 'Unbekannt' : 'Nicht verschlüsselt'}
/>
<SecRow
ok={defenderOk} warn={defenderWarn}
name="Windows Defender"
desc={defenderOk
? `Echtzeitschutz aktiv · Signaturen ${sigAge !== null ? `${sigAge} Tag${sigAge !== 1 ? 'e' : ''} alt` : 'aktuell'}`
: 'Echtzeitschutz inaktiv'}
state={defenderOk ? (defenderWarn ? 'Veraltet' : 'Aktiv') : 'Inaktiv'}
/>
<SecRow
ok={tpmOk} warn={false}
name="TPM"
desc={tpmOk ? `TPM ${tpmVersion || '2.0'} vorhanden` : 'Kein TPM gefunden'}
state={tpmOk ? 'Vorhanden' : 'Nicht gefunden'}
/>
<SecRow
ok={secureBootOk} warn={false}
name="Secure Boot"
desc={secureBootOk ? 'UEFI-Firmware konfiguriert' : 'Secure Boot deaktiviert'}
state={secureBootOk ? 'Aktiviert' : 'Deaktiviert'}
/>
<SecRow
ok={win11Ok} warn={false}
name="Windows 11 Kompatibilität"
desc={win11Ok ? 'Alle Hardware-Anforderungen erfüllt' : 'Nicht kompatibel'}
state={win11Ok ? 'Bereit' : 'Nicht bereit'}
/>
</div>
</div>
</div>
{/* ── BENUTZER CARD ────────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: asset ? '1fr 1fr' : '1fr', gap: 16, marginBottom: 22 }}>
{/* Zugewiesener Benutzer */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="👤" title="Zugewiesener Benutzer" meta={assignedUser ? 'Aus Asset-Daten' : ''} />
{assignedUser ? (
<div style={{ padding: '20px 22px', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: 52, height: 52, borderRadius: '50%', flexShrink: 0,
background: `linear-gradient(135deg, ${C.teal}, #0a7a78)`,
color: '#fff', fontWeight: 700, fontSize: 18,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{userInitials}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>
{assignedUser.first_name} {assignedUser.last_name}
{(!assignedUser.first_name && !assignedUser.last_name) && assignedUser.username}
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-muted)', marginTop: 2 }}>@{assignedUser.username}</div>
{assignedUser.email && <div style={{ fontSize: 12.5, color: 'var(--text-muted)' }}>{assignedUser.email}</div>}
{assignedUser.department && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{assignedUser.department}</div>}
</div>
<Link to={`/users`} style={{ fontSize: 12.5, color: C.teal, textDecoration: 'none', fontWeight: 500 }}>Profil </Link>
</div>
) : (
<div style={{ padding: '20px 22px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 42, height: 42, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20 }}>?</div>
<div>
<div style={{ fontSize: 13.5, color: 'var(--text-secondary)', fontWeight: 500 }}>Nicht zugewiesen</div>
{agent.last_user && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Zuletzt angemeldet: <span style={{ fontFamily: 'monospace' }}>{agent.last_user}</span></div>}
</div>
</div>
)}
</div>
{/* Asset-Verknüpfung */}
{asset && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="🏷" title="Verknüpftes Asset" meta="IT Nexus Asset-Verwaltung" />
<div style={{ padding: '16px 22px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>{asset.name}</div>
<span style={{ fontSize: 11.5, fontWeight: 600, padding: '2px 8px', borderRadius: 6, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}>{asset.type || 'Notebook'}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px', fontSize: 12.5, color: 'var(--text-muted)' }}>
{asset.manufacturer && <div>Hersteller: <strong style={{ color: 'var(--text-secondary)' }}>{asset.manufacturer}</strong></div>}
{asset.model && <div>Modell: <strong style={{ color: 'var(--text-secondary)' }}>{asset.model}</strong></div>}
{asset.serial_number && <div>S/N: <span style={{ fontFamily: 'monospace', color: 'var(--text-secondary)' }}>{asset.serial_number}</span></div>}
{asset.status && <div>Status: <strong style={{ color: 'var(--text-secondary)' }}>{asset.status}</strong></div>}
</div>
<Link to="/assets" style={{ display: 'inline-block', marginTop: 12, fontSize: 12.5, color: C.teal, textDecoration: 'none', fontWeight: 500 }}>
Asset öffnen
</Link>
</div>
</div>
)}
</div>
{/* ── UPDATES BANNER ───────────────────────────────────────────────── */}
{(agent.windows_updates_pending ?? 0) > 0 && (
<div style={{
background: `linear-gradient(135deg, rgba(245,158,11,0.08), transparent 60%), var(--bg-secondary)`,
border: '1px solid rgba(245,158,11,0.28)',
borderRadius: 18, padding: '22px 24px', marginBottom: 22,
display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 18, alignItems: 'center',
boxShadow: '0 1px 2px rgba(0,0,0,0.35)',
}}>
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'rgba(245,158,11,0.16)', color: C.warning, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, flexShrink: 0 }}>
🔄
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 600, letterSpacing: '-0.01em', display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-primary)' }}>
Ausstehende Updates
<span style={{ background: C.warning, color: '#fff', padding: '2px 9px', borderRadius: 999, fontSize: 11, fontWeight: 700 }}>{agent.windows_updates_pending}</span>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-muted)', marginTop: 4 }}>Updates können über Patch Management installiert werden</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Btn onClick={() => handleSendCommand('install_updates')} variant="primary"> Updates installieren</Btn>
</div>
</div>
)}
{/* ── PATCH HISTORY ────────────────────────────────────────────────── */}
{patchHistory.length > 0 && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)', marginBottom: 22 }}>
<CardHead icon="📋" title="Patch-Historie" meta={`${patchHistory.length} Einträge`} />
<div>
{patchHistory.slice(0, 10).map((cmd, i) => {
const statusColor = cmd.status === 'done' ? C.success : cmd.status === 'error' ? C.danger : cmd.status === 'running' ? C.warning : 'var(--text-muted)';
const statusLabel = { done: 'Erledigt', error: 'Fehler', running: 'Läuft', pending: 'Ausstehend', sent: 'Gesendet' }[cmd.status] || cmd.status;
const cmdLabel = { install_updates: 'Updates installieren', check_updates: 'Updates prüfen', reboot: 'Neustart', upgrade_win11: 'Win11 Upgrade', update_agent: 'Agent Update' }[cmd.command] || cmd.command;
return (
<div key={cmd.id} style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 16, padding: '11px 22px', alignItems: 'center', borderTop: i === 0 ? 'none' : '1px solid var(--border-color)' }}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text-primary)' }}>{cmdLabel}</div>
{cmd.result && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{cmd.result}</div>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right', whiteSpace: 'nowrap' }}>
{cmd.triggered_by_username && <div>von {cmd.triggered_by_username}</div>}
<div>{new Date(cmd.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
</div>
<span style={{ fontSize: 11.5, fontWeight: 600, padding: '3px 10px', borderRadius: 999, background: `${statusColor}18`, color: statusColor, whiteSpace: 'nowrap' }}>
{statusLabel}
</span>
</div>
);
})}
</div>
</div>
)}
{/* ── INSTALLED SOFTWARE ───────────────────────────────────────────── */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="📦" title="Installierte Software" meta={`${sw.length} Programme`} />
<div style={{ padding: '14px 22px', borderBottom: '1px solid var(--border-color)', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ position: 'relative', flex: 1, maxWidth: 380 }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }}>🔍</span>
<input
value={swQuery}
onChange={e => setSwQuery(e.target.value)}
placeholder="Nach Name oder Hersteller suchen…"
style={{
width: '100%', boxSizing: 'border-box',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
border: '1px solid var(--border-color)', borderRadius: 10,
padding: '8px 14px 8px 36px', fontSize: 13, outline: 'none',
fontFamily: 'inherit',
}}
/>
</div>
<span style={{ marginLeft: 'auto', fontSize: 12.5, color: 'var(--text-muted)' }}>
{swQuery ? `${filteredSw.length} Treffer` : `${sw.length} Programme`}
</span>
</div>
{sw.length === 0 ? (
<div style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--text-muted)' }}>
Keine Software-Daten verfügbar Check-in abwarten
</div>
) : filteredSw.length === 0 ? (
<div style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--text-muted)' }}>Keine Programme gefunden.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
{filteredSw.map((s, i) => {
const nm = typeof s === 'string' ? s : (s.name || s.Name || s.DisplayName || '?');
const ver = typeof s === 'string' ? '' : (s.version || s.Version || s.DisplayVersion || '');
const pub = typeof s === 'string' ? '' : (s.publisher || s.Publisher || '');
const col = colorFor(nm);
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 22px', borderBottom: '1px solid var(--border-color)', minWidth: 0 }}>
<div style={{ width: 30, height: 30, borderRadius: 7, background: `${col}22`, color: col, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontWeight: 700, fontSize: 13 }}>
{nm[0]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{nm}</div>
<div style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{ver}{pub ? ` · ${pub}` : ''}</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */}
{showAnnModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={e => e.target === e.currentTarget && setShowAnnModal(false)}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 16, width: '100%', maxWidth: 500, border: '1px solid var(--border-color)', overflow: 'hidden' }}>
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>📢 Ankündigung senden an {agent.hostname}</h2>
<button onClick={() => setShowAnnModal(false)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 22, cursor: 'pointer', lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ background: 'rgba(20,184,168,0.10)', border: '1px solid rgba(20,184,168,0.3)', borderRadius: 8, padding: '8px 12px', fontSize: 12.5, color: '#2dd2c2' }}>
🎯 Wird <strong>nur</strong> an <strong>{agent?.hostname}</strong> gesendet (Agent-ID: {agent?.id})
</div>
<textarea
value={annText}
onChange={e => setAnnText(e.target.value)}
placeholder="Nachricht eingeben…"
rows={4}
style={{ width: '100%', boxSizing: 'border-box', background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '10px 14px', fontSize: 13, fontFamily: 'inherit', outline: 'none', resize: 'vertical' }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Btn onClick={() => setShowAnnModal(false)}>Abbrechen</Btn>
<Btn variant="primary" onClick={handleSendAnnouncement} disabled={sendingAnn || !annText.trim()}>
{sendingAnn ? 'Sende…' : '📢 Senden'}
</Btn>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes agPulse {
0% { box-shadow: 0 0 0 0 rgba(34,197,94,0.5); }
70% { box-shadow: 0 0 0 6px rgba(34,197,94,0); }
100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,375 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import aiService from '../services/aiService';
import { toast } from 'react-toastify';
import { Marked } from 'marked';
const marked = new Marked({ breaks: true, gfm: true });
const renderMd = (text) => ({ __html: marked.parse(text) });
const WELCOME_MSG = {
role: 'assistant',
content: 'Hallo! Ich bin dein KI-Assistent für IT-Support. Du kannst mich nach Lösungen für bekannte Probleme, Troubleshooting-Schritten oder Ticket-Einschätzungen fragen.',
};
const CATEGORY_LIST = ['Software', 'Hardware', 'Allgemein', 'SelectLine'];
export default function AiPage() {
const { isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState('chat');
// Chat State
const [messages, setMessages] = useState([WELCOME_MSG]);
const [input, setInput] = useState('');
const [chatLoading, setChatLoading] = useState(false);
const [configured, setConfigured] = useState(true);
const bottomRef = useRef(null);
const inputRef = useRef(null);
// Knowledge Base State
const [kbEntries, setKbEntries] = useState([]);
const [kbLoading, setKbLoading] = useState(false);
const [showKbForm, setShowKbForm] = useState(false);
const [kbForm, setKbForm] = useState({ problem: '', solution: '', category: 'Allgemein', tags: '' });
const [kbSaving, setKbSaving] = useState(false);
useEffect(() => {
aiService.getStatus()
.then(s => setConfigured(s.configured))
.catch(() => setConfigured(false));
}, []);
useEffect(() => {
if (activeTab === 'kb') loadKb();
}, [activeTab]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function loadKb() {
setKbLoading(true);
try {
const data = await aiService.getKnowledgeBase();
setKbEntries(data);
} catch (err) {
toast.error('Wissensdatenbank konnte nicht geladen werden');
} finally {
setKbLoading(false);
}
}
async function sendMessage() {
const text = input.trim();
if (!text || chatLoading) return;
const userMsg = { role: 'user', content: text };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
setChatLoading(true);
try {
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}`,
isError: true,
}]);
} finally {
setChatLoading(false);
setTimeout(() => inputRef.current?.focus(), 50);
}
}
async function saveKbEntry() {
if (!kbForm.problem.trim() || !kbForm.solution.trim()) {
toast.error('Problem und Lösung sind pflichtfelder');
return;
}
setKbSaving(true);
try {
await aiService.addKnowledgeEntry(kbForm);
toast.success('Eintrag gespeichert');
setKbForm({ problem: '', solution: '', category: 'Allgemein', tags: '' });
setShowKbForm(false);
loadKb();
} catch (err) {
toast.error('Fehler beim Speichern');
} finally {
setKbSaving(false);
}
}
async function deleteKbEntry(id) {
if (!window.confirm('Eintrag wirklich löschen?')) return;
try {
await aiService.deleteKnowledgeEntry(id);
toast.success('Eintrag gelöscht');
setKbEntries(prev => prev.filter(e => e.id !== id));
} catch {
toast.error('Fehler beim Löschen');
}
}
const TAB = (active) => ({
padding: '10px 16px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: active ? 600 : 400,
color: active ? 'var(--cereda-primary)' : 'var(--text-muted)',
borderBottom: active ? '2px solid var(--cereda-primary)' : '2px solid transparent',
});
return (
<div className="main-content">
<div style={{ maxWidth: 960, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
<span style={{ fontSize: 28 }}>🤖</span>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>KI-Assistent</h1>
{!configured && (
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 6, background: '#fef3c7', color: '#92400e', border: '1px solid #fde68a' }}>
Nicht konfiguriert
</span>
)}
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.875rem' }}>
Claude claude-sonnet-4-6 · IT-Support-Assistent mit Wissensdatenbank
</p>
</div>
{!configured && (
<div className="card" style={{ padding: '16px 20px', marginBottom: 20, background: '#fef3c7', border: '1px solid #fde68a' }}>
<strong> ANTHROPIC_API_KEY fehlt</strong>
<p style={{ margin: '4px 0 0', fontSize: '0.875rem', color: '#92400e' }}>
Füge <code>ANTHROPIC_API_KEY=sk-ant-...</code> in der docker-compose.yml als Umgebungsvariable hinzu und deploye neu.
</p>
</div>
)}
<div className="card" style={{ overflow: 'hidden' }}>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 8px' }}>
<button style={TAB(activeTab === 'chat')} onClick={() => setActiveTab('chat')}>
💬 Chat
</button>
<button style={TAB(activeTab === 'kb')} onClick={() => setActiveTab('kb')}>
📚 Wissensdatenbank ({kbEntries.length})
</button>
</div>
{/* Chat Tab */}
{activeTab === 'chat' && (
<div style={{ display: 'flex', flexDirection: 'column', height: 560 }}>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{messages.map((msg, i) => (
<div key={i} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start' }}>
<div style={{
maxWidth: '75%',
padding: '10px 14px',
borderRadius: msg.role === 'user' ? '14px 14px 2px 14px' : '14px 14px 14px 2px',
background: msg.role === 'user'
? 'linear-gradient(135deg, var(--cereda-primary), #8b5cf6)'
: msg.isError ? '#fee2e2' : 'var(--bg-tertiary)',
color: msg.role === 'user' ? '#fff' : msg.isError ? '#991b1b' : 'var(--text-primary)',
fontSize: '0.875rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
}}>
{msg.role === 'assistant'
? <div className="md-content" dangerouslySetInnerHTML={renderMd(msg.content)} />
: msg.content}
</div>
</div>
))}
{chatLoading && (
<div style={{ display: 'flex' }}>
<div style={{ padding: '10px 16px', borderRadius: '14px 14px 14px 2px', background: 'var(--bg-tertiary)', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
KI denkt nach
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--border-color)', display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
placeholder="Nachricht eingeben… (Enter zum Senden, Shift+Enter für Zeilenumbruch)"
disabled={chatLoading || !configured}
rows={2}
style={{
flex: 1, resize: 'none', padding: '10px 12px', borderRadius: 8,
border: '1px solid var(--border-color)', fontSize: '0.875rem',
background: 'var(--bg-primary)', color: 'var(--text-primary)',
outline: 'none', fontFamily: 'inherit',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'stretch' }}>
<button
onClick={sendMessage}
disabled={chatLoading || !input.trim() || !configured}
className="btn btn-primary"
style={{ height: 38, padding: '0 20px', whiteSpace: 'nowrap' }}
>
Senden
</button>
<button
onClick={() => setMessages([WELCOME_MSG])}
className="btn btn-secondary"
style={{ height: 38, padding: '0 20px', fontSize: '0.75rem', whiteSpace: 'nowrap' }}
>
Leeren
</button>
</div>
</div>
</div>
)}
{/* Knowledge Base Tab */}
{activeTab === 'kb' && (
<div style={{ padding: '20px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Bekannte Probleme & Lösungen</h3>
<p style={{ margin: '2px 0 0', fontSize: '0.8125rem', color: 'var(--text-muted)' }}>
Diese Einträge werden als Kontext an den KI-Assistenten weitergegeben.
</p>
</div>
{isAdmin() && (
<button className="btn btn-primary" onClick={() => setShowKbForm(f => !f)}>
+ Eintrag hinzufügen
</button>
)}
</div>
{/* Add Form */}
{showKbForm && isAdmin() && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: 20, marginBottom: 20 }}>
<h4 style={{ margin: '0 0 16px', fontSize: '0.9375rem' }}>Neuer Eintrag</h4>
<div style={{ display: 'grid', gap: 12 }}>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Problem *</label>
<textarea
value={kbForm.problem}
onChange={e => setKbForm(f => ({ ...f, problem: e.target.value }))}
placeholder="Beschreibe das Problem"
rows={2}
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', resize: 'vertical', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Lösung *</label>
<textarea
value={kbForm.solution}
onChange={e => setKbForm(f => ({ ...f, solution: e.target.value }))}
placeholder="Beschreibe die Lösung Schritt für Schritt"
rows={4}
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', resize: 'vertical', boxSizing: 'border-box' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Kategorie</label>
<select
value={kbForm.category}
onChange={e => setKbForm(f => ({ ...f, category: e.target.value }))}
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
>
{CATEGORY_LIST.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Tags</label>
<input
type="text"
value={kbForm.tags}
onChange={e => setKbForm(f => ({ ...f, tags: e.target.value }))}
placeholder="z.B. VPN, Outlook, Drucker"
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', boxSizing: 'border-box' }}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setShowKbForm(false)}>Abbrechen</button>
<button className="btn btn-primary" onClick={saveKbEntry} disabled={kbSaving}>
{kbSaving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* Entries */}
{kbLoading ? (
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>Lade</div>
) : kbEntries.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 8 }}>📚</div>
<p style={{ margin: 0 }}>Noch keine Einträge vorhanden.</p>
{isAdmin() && <p style={{ margin: '4px 0 0', fontSize: '0.8125rem' }}>Füge bekannte Probleme & Lösungen hinzu, damit die KI besser helfen kann.</p>}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{kbEntries.map(entry => (
<div key={entry.id} style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '14px 18px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, padding: '2px 8px', borderRadius: 4, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}>
{entry.category}
</span>
{entry.tags && (
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)' }}>
🏷 {entry.tags}
</span>
)}
</div>
<div style={{ marginBottom: 8 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Problem: </span>
<span style={{ fontSize: '0.875rem', color: 'var(--text-primary)', whiteSpace: 'pre-wrap' }}>{entry.problem}</span>
</div>
<div>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--success)' }}>Lösung: </span>
<span style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{entry.solution}</span>
</div>
</div>
{isAdmin() && (
<button
onClick={() => deleteKbEntry(entry.id)}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'transparent', color: 'var(--danger)', cursor: 'pointer', fontSize: '0.8125rem', flexShrink: 0 }}
title="Löschen"
>
🗑
</button>
)}
</div>
<div style={{ marginTop: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Erstellt von {entry.created_by_username || 'System'} · {new Date(entry.created_at).toLocaleDateString('de-DE')}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import React, { useState, useEffect, useMemo } from 'react';
import assetService from '../services/assetService';
import { calculateAfa, formatEuro } from '../utils/afaUtils';
const AnlagevermoegenPage = () => {
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
useEffect(() => {
assetService.getAll()
.then(data => setAssets(data || []))
.catch(() => setAssets([]))
.finally(() => setLoading(false));
}, []);
// Nur Assets mit Kaufpreis > 0
const anlagen = useMemo(() => {
return assets
.filter(a => a.purchase_price > 0)
.map(a => ({
...a,
afa: calculateAfa(a.purchase_price, a.useful_life_years, a.residual_value, a.purchase_date),
}));
}, [assets]);
const filtered = useMemo(() => {
if (!search.trim()) return anlagen;
const q = search.toLowerCase();
return anlagen.filter(a =>
(a.name || '').toLowerCase().includes(q) ||
(a.inventory_number || '').toLowerCase().includes(q) ||
(a.serial_number || '').toLowerCase().includes(q) ||
(a.model || '').toLowerCase().includes(q)
);
}, [anlagen, search]);
// Summenwerte
const totals = useMemo(() => {
let totalPurchase = 0, totalBookValue = 0, totalCumulative = 0;
anlagen.forEach(a => {
totalPurchase += a.purchase_price || 0;
if (a.afa) {
totalBookValue += a.afa.currentBookValue;
totalCumulative += a.afa.cumulativeDepreciation;
} else {
totalBookValue += a.purchase_price || 0;
}
});
return { totalPurchase, totalBookValue, totalCumulative };
}, [anlagen]);
const handleExportCsv = () => {
const today = new Date().toISOString().split('T')[0];
const headers = ['Inv.-Nr.', 'Name', 'Typ', 'Bereich', 'Seriennummer', 'Kaufdatum',
'Anschaffungswert (€)', 'Nutzungsdauer (J)', 'AfA/Jahr (€)', 'Restwert (€)',
'Buchwert heute (€)', 'Kum. AfA (€)', 'Abgeschrieben am', 'Status'];
const rows = filtered.map(a => {
const afa = a.afa;
return [
a.inventory_number || '',
a.name || '',
a.type || '',
a.department || '',
a.serial_number || '',
a.purchase_date || '',
(a.purchase_price || 0).toFixed(2).replace('.', ','),
a.useful_life_years || '',
afa ? afa.annualDepreciation.toFixed(2).replace('.', ',') : '',
(a.residual_value || 0).toFixed(2).replace('.', ','),
afa ? afa.currentBookValue.toFixed(2).replace('.', ',') : (a.purchase_price || 0).toFixed(2).replace('.', ','),
afa ? afa.cumulativeDepreciation.toFixed(2).replace('.', ',') : '0,00',
afa ? afa.fullyDepreciatedDate.toLocaleDateString('de-DE') : '',
afa ? (afa.isFullyDepreciated ? 'Abgeschrieben' : 'Aktiv') : 'Aktiv',
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';');
});
const csv = '\uFEFF' + [headers.map(h => `"${h}"`).join(';'), ...rows].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `anlagevermoegen_${today}.csv`;
a.click();
URL.revokeObjectURL(url);
};
if (loading) {
return (
<div className="page-container">
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>
Lade Anlagevermögen
</div>
</div>
);
}
return (
<div className="page-container">
{/* Header */}
<div className="page-header" style={{ marginBottom: '24px' }}>
<div>
<h1 className="page-title">Anlagevermögen</h1>
<p className="page-subtitle">AfA-Tracking für Inventar mit Kaufpreisen</p>
</div>
<button
onClick={handleExportCsv}
className="btn btn-secondary"
disabled={filtered.length === 0}
>
CSV exportieren
</button>
</div>
{/* Kacheln */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '24px' }}>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '6px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Gesamtanschaffungswert
</div>
<div style={{ fontSize: '24px', fontWeight: 700, color: 'var(--text-primary)' }}>
{formatEuro(totals.totalPurchase)}
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
{anlagen.length} Anlage{anlagen.length !== 1 ? 'n' : ''}
</div>
</div>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '6px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Buchwert heute
</div>
<div style={{ fontSize: '24px', fontWeight: 700, color: 'var(--cereda-primary)' }}>
{formatEuro(totals.totalBookValue)}
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
Aktueller Zeitwert
</div>
</div>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '6px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Kumulierte AfA
</div>
<div style={{ fontSize: '24px', fontWeight: 700, color: '#ef4444' }}>
{formatEuro(totals.totalCumulative)}
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
Bereits abgeschrieben
</div>
</div>
</div>
{/* Suchfeld */}
<div style={{ marginBottom: '16px' }}>
<input
type="text"
className="form-input"
placeholder="🔍 Suche nach Name, Inv.-Nr., Seriennummer, Modell…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{/* Tabelle */}
<div className="card">
{filtered.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--text-muted)' }}>
{anlagen.length === 0
? 'Keine Assets mit Kaufpreis vorhanden. Trage Anschaffungswerte in den Asset-Einstellungen ein.'
: 'Keine Ergebnisse für diese Suche.'}
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th>Inv.-Nr.</th>
<th>Name</th>
<th>Typ</th>
<th>Bereich</th>
<th>Kaufdatum</th>
<th style={{ textAlign: 'right' }}>Anschaffungs&shy;wert</th>
<th style={{ textAlign: 'center' }}>ND (J)</th>
<th style={{ textAlign: 'right' }}>AfA/Jahr</th>
<th style={{ textAlign: 'right' }}>Buchwert heute</th>
<th>Abgeschrieben am</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filtered.map(a => {
const afa = a.afa;
const depDate = afa ? afa.fullyDepreciatedDate : null;
const isFuture = depDate && !afa.isFullyDepreciated;
return (
<tr key={a.id}>
<td>
<span style={{ fontFamily: 'monospace', fontWeight: 600, fontSize: '13px', color: 'var(--cereda-primary)' }}>
{a.inventory_number || ''}
</span>
</td>
<td style={{ fontWeight: 500 }}>{a.name}</td>
<td>{a.type}</td>
<td>{a.department}</td>
<td>{a.purchase_date ? new Date(a.purchase_date).toLocaleDateString('de-DE') : ''}</td>
<td style={{ textAlign: 'right' }}>{formatEuro(a.purchase_price)}</td>
<td style={{ textAlign: 'center' }}>{a.useful_life_years || ''}</td>
<td style={{ textAlign: 'right' }}>{afa ? formatEuro(afa.annualDepreciation) : ''}</td>
<td style={{ textAlign: 'right', fontWeight: 600 }}>
{afa ? formatEuro(afa.currentBookValue) : formatEuro(a.purchase_price)}
</td>
<td style={{ color: isFuture ? '#16a34a' : 'var(--text-muted)', fontWeight: isFuture ? 500 : 400 }}>
{depDate ? depDate.toLocaleDateString('de-DE') : ''}
</td>
<td>
{afa ? (
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: '6px',
fontSize: '11px',
fontWeight: 600,
background: afa.isFullyDepreciated ? 'rgba(107,114,128,0.12)' : 'rgba(22,163,74,0.12)',
color: afa.isFullyDepreciated ? '#6b7280' : '#16a34a',
}}>
{afa.isFullyDepreciated ? 'Abgeschrieben' : 'Aktiv'}
</span>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '12px' }}>Keine AfA</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default AnlagevermoegenPage;

View File

@@ -0,0 +1,632 @@
import React, { useState, useMemo } from 'react';
const METHOD_COLORS = {
GET: { bg: '#1d4ed8', light: '#1d4ed820', text: '#60a5fa' },
POST: { bg: '#15803d', light: '#15803d20', text: '#4ade80' },
PUT: { bg: '#92400e', light: '#92400e20', text: '#fbbf24' },
PATCH: { bg: '#7c3aed', light: '#7c3aed20', text: '#c084fc' },
DELETE: { bg: '#991b1b', light: '#991b1b20', text: '#f87171' },
};
const API_ENDPOINTS = [
{
category: 'Authentifizierung',
icon: '🔐',
description: 'Login, Logout, Passwort, Benachrichtigungen und Microsoft OAuth',
endpoints: [
{ method: 'POST', path: '/api/auth/login', description: 'Benutzer-Login mit Benutzername und Passwort', auth: false },
{ method: 'POST', path: '/api/auth/logout', description: 'Aktive Sitzung beenden', auth: false },
{ method: 'GET', path: '/api/auth/me', description: 'Informationen zum aktuell eingeloggten Benutzer', auth: true },
{ method: 'POST', path: '/api/auth/change-password', description: 'Eigenes Passwort ändern', auth: true },
{ method: 'PUT', path: '/api/auth/notifications', description: 'E-Mail-Benachrichtigungen für Endbenutzer aktivieren/deaktivieren', auth: true },
{ method: 'PUT', path: '/api/auth/staff-notifications', description: 'Granulare E-Mail-Benachrichtigungseinstellungen für Staff (Ticket erstellt, zugewiesen, neue Antwort, Wochenreport)', auth: true, role: 'Staff' },
{ method: 'GET', path: '/api/auth/microsoft', description: 'Microsoft Azure AD OAuth2 Login starten', auth: false, note: 'Redirect zu Microsoft' },
{ method: 'GET', path: '/api/auth/microsoft/callback', description: 'OAuth2 Callback-Handler nach Microsoft-Login', auth: false, note: 'Intern' },
],
},
{
category: 'Assets',
icon: '💼',
description: 'IT-Geräteverwaltung Notebooks, Monitore, FIDO-Keys etc.',
endpoints: [
{ method: 'GET', path: '/api/assets', description: 'Alle Assets auflisten', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/assets/stats', description: 'Gesamtstatistiken (Anzahl nach Status/Typ)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/assets/mine', description: 'Eigene zugewiesene Assets abrufen', auth: true, role: 'Alle' },
{ method: 'GET', path: '/api/assets/serial/:serialNumber', description: 'Asset anhand der Seriennummer suchen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/assets/status/:status', description: 'Assets nach Status filtern (verfuegbar, zugewiesen, …)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/assets/type/:type', description: 'Assets nach Typ filtern (Notebook, Monitor, …)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/assets/:id', description: 'Ein bestimmtes Asset nach ID abrufen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/assets/:id/history', description: 'Zuordnungshistorie eines Assets', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/assets/:id/label', description: 'Asset-Label als PDF generieren', auth: true, role: 'Admin+', note: 'PDF' },
{ method: 'GET', path: '/api/assets/:id/handover-protocol', description: 'Übergabeprotokoll als PDF generieren', auth: true, role: 'Admin+', note: 'PDF' },
{ method: 'POST', path: '/api/assets', description: 'Neues Asset anlegen', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/assets/:id/assign', description: 'Asset einem Benutzer zuweisen', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/assets/:id/unassign', description: 'Zuweisung aufheben / Status setzen (verfuegbar, inaktiv)', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/assets/import/intune', description: 'Geräte aus Microsoft Intune importieren', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/assets/:id', description: 'Asset-Daten aktualisieren (Felder, Wartung, etc.)', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/assets/:id', description: 'Asset dauerhaft löschen', auth: true, role: 'Admin' },
],
},
{
category: 'FIDO-Keys',
icon: '🔑',
description: 'Verwaltung von Hardware-Sicherheitsschlüsseln',
endpoints: [
{ method: 'GET', path: '/api/fido-keys', description: 'Alle FIDO-Keys auflisten', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/fido-keys/stats', description: 'Statistiken (Aktiv, Inaktiv, Zugewiesen)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/fido-keys/serial/:serialNumber', description: 'Key anhand der Seriennummer suchen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/fido-keys/status/:status', description: 'Keys nach Status filtern', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/fido-keys/:id', description: 'Einzelnen FIDO-Key nach ID abrufen', auth: true, role: 'Admin+' },
{ method: 'POST', path: '/api/fido-keys', description: 'Neuen FIDO-Key registrieren', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/fido-keys/:id', description: 'Key-Daten aktualisieren', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/fido-keys/:id/status', description: 'Status eines Keys ändern (aktiv/inaktiv)', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/fido-keys/:id', description: 'FIDO-Key dauerhaft löschen', auth: true, role: 'Admin' },
],
},
{
category: 'Tickets',
icon: '🎫',
description: 'Support-Ticket-System mit Kommentaren, Links, Mehrfach-Bearbeitung und Echtzeit-Updates',
endpoints: [
{ method: 'GET', path: '/api/tickets', description: 'Alle Tickets auflisten (mit Filter- und Sortieroptionen)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/tickets/stats', description: 'Ticket-Statistiken nach Status und Priorität', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/tickets/metrics', description: 'Erweiterte Ticket-Metriken (Lösungszeiten, SLA, Trends)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/tickets/routing', description: 'Ticket-Routing-Konfiguration abrufen', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/tickets/routing', description: 'Ticket-Routing-Konfiguration speichern', auth: true, role: 'Admin' },
{ method: 'GET', path: '/api/tickets/:id', description: 'Ticket-Detail inkl. Kommentare abrufen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/tickets/:id/events', description: 'Server-Sent Events (SSE) für Echtzeit-Updates', auth: false, note: 'Token als Query-Param (?token=…)' },
{ method: 'GET', path: '/api/tickets/:id/history', description: 'Änderungshistorie eines Tickets abrufen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/tickets/:id/pdf', description: 'Ticket als PDF exportieren', auth: true, role: 'Admin+', note: 'PDF' },
{ method: 'GET', path: '/api/tickets/:id/links', description: 'Verknüpfte Tickets eines Tickets abrufen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/tickets/:id/feedback', description: 'Zufriedenheits-Feedback für abgeschlossenes Ticket abrufen', auth: false, note: 'Öffentlich (Token-Link)' },
{ method: 'POST', path: '/api/tickets', description: 'Neues Ticket erstellen (authentifiziert)', auth: true, role: 'Alle' },
{ method: 'POST', path: '/api/tickets/public', description: 'Ticket ohne Login erstellen (öffentliches Formular)', auth: false },
{ method: 'PUT', path: '/api/tickets/bulk', description: 'Mehrere Tickets gleichzeitig aktualisieren (Bulk-Update)', auth: true, role: 'Staff' },
{ method: 'PUT', path: '/api/tickets/:id', description: 'Ticket aktualisieren (Status, Priorität, Zuweisung)', auth: true, role: 'Staff' },
{ method: 'PUT', path: '/api/tickets/:id/snooze', description: 'Ticket vorübergehend zurückstellen (Snooze bis Datum)', auth: true, role: 'Staff' },
{ method: 'DELETE', path: '/api/tickets/:id', description: 'Ticket dauerhaft löschen', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/tickets/:id/comments', description: 'Kommentar zu einem Ticket hinzufügen', auth: true, role: 'Alle' },
{ method: 'POST', path: '/api/tickets/:id/ai-reply', description: 'KI-gestützten Antwortvorschlag für ein Ticket generieren', auth: true, role: 'Staff' },
{ method: 'DELETE', path: '/api/tickets/:id/comments/:commentId', description: 'Kommentar löschen', auth: true, role: 'Staff' },
{ method: 'POST', path: '/api/tickets/:id/links', description: 'Ticket mit einem anderen Ticket verknüpfen', auth: true, role: 'Staff' },
{ method: 'DELETE', path: '/api/tickets/:id/links/:linkId', description: 'Ticket-Verknüpfung entfernen', auth: true, role: 'Staff' },
{ method: 'POST', path: '/api/tickets/:id/assignees', description: 'Weiteren Bearbeiter zu einem Ticket hinzufügen', auth: true, role: 'Staff' },
{ method: 'DELETE', path: '/api/tickets/:id/assignees/:userId', description: 'Bearbeiter von einem Ticket entfernen', auth: true, role: 'Staff' },
],
},
{
category: 'Benutzer',
icon: '👥',
description: 'Benutzerverwaltung, Rollen und Azure AD Import',
endpoints: [
{ method: 'GET', path: '/api/users', description: 'Alle Benutzer auflisten', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/users/roles', description: 'Alle verfügbaren Rollen abrufen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/users/audit-logs', description: 'Audit-Log aller Systemaktivitäten', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/users/audit-logs/user/:userId', description: 'Audit-Log für einen bestimmten Benutzer', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/users/audit-logs/entity/:type/:id', description: 'Audit-Log für eine bestimmte Entität', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/users/:id', description: 'Einzelnen Benutzer nach ID abrufen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/users/import/azure/groups', description: 'Azure AD Gruppen auflisten (für Import)', auth: true, role: 'Super Admin' },
{ method: 'POST', path: '/api/users', description: 'Neuen Benutzer manuell anlegen', auth: true, role: 'Super Admin' },
{ method: 'POST', path: '/api/users/import/azure', description: 'Benutzer aus Azure AD / Entra ID importieren', auth: true, role: 'Super Admin' },
{ method: 'PUT', path: '/api/users/:id', description: 'Benutzerdaten aktualisieren', auth: true, role: 'Super Admin' },
{ method: 'PUT', path: '/api/users/:id/role', description: 'Rolle eines Benutzers ändern', auth: true, role: 'Super Admin' },
{ method: 'PUT', path: '/api/users/:id/activate', description: 'Benutzer aktivieren oder deaktivieren', auth: true, role: 'Super Admin' },
{ method: 'DELETE', path: '/api/users/:id', description: 'Benutzer dauerhaft löschen', auth: true, role: 'Super Admin' },
],
},
{
category: 'Lizenzen',
icon: '📜',
description: 'Softwarelizenz-Verwaltung und Entra ID Import',
endpoints: [
{ method: 'GET', path: '/api/licenses', description: 'Alle Lizenzen auflisten', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/licenses/statistics', description: 'Lizenz-Statistiken (Gesamt, Ablaufend, Abgelaufen)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/licenses/:id', description: 'Lizenz nach ID abrufen', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/licenses/:id/users', description: 'Benutzer einer bestimmten Lizenz abrufen', auth: true, role: 'Admin+' },
{ method: 'POST', path: '/api/licenses', description: 'Neue Lizenz anlegen', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/licenses/import/entra', description: 'Lizenzen aus Microsoft Entra ID importieren', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/licenses/:id', description: 'Lizenz aktualisieren', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/licenses/:id', description: 'Lizenz dauerhaft löschen', auth: true, role: 'Admin' },
],
},
{
category: 'Onboarding',
icon: '📥',
description: 'Mitarbeiter-Einstellungsprozess mit Checklisten, Bestätigungs-E-Mails und PDF-Protokollen',
endpoints: [
{ method: 'GET', path: '/api/onboarding/confirm/:token', description: 'Mitarbeiter bestätigt Übergabe über Token-Link (E-Mail)', auth: false, note: 'Öffentlich' },
{ method: 'GET', path: '/api/onboarding', description: 'Alle Onboarding-Protokolle auflisten', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'GET', path: '/api/onboarding/stats', description: 'Onboarding-Statistiken', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'GET', path: '/api/onboarding/entra-manager', description: 'Abteilungsleiter aus Entra ID ermitteln (?department=…)', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'GET', path: '/api/onboarding/entra-search', description: 'Benutzer nach Name in Entra ID suchen (?name=…)', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'GET', path: '/api/onboarding/:id', description: 'Onboarding-Protokoll nach ID abrufen', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/onboarding', description: 'Neues Onboarding-Protokoll erstellen', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'PUT', path: '/api/onboarding/:id', description: 'Protokoll aktualisieren (Checkliste, Status)', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/onboarding/:id/send-confirmation', description: 'Bestätigungs-E-Mail an Mitarbeiter (erneut) senden', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/onboarding/:id/regenerate-pdf', description: 'Onboarding-PDF neu generieren', auth: true, role: 'Admin', note: 'PDF' },
{ method: 'DELETE', path: '/api/onboarding/:id', description: 'Onboarding-Protokoll löschen', auth: true, role: 'Lifecycle-Rollen' },
],
},
{
category: 'Offboarding',
icon: '📤',
description: 'Mitarbeiter-Austritts­prozess mit Asset-Rückgabe und PDF-Protokollen',
endpoints: [
{ method: 'GET', path: '/api/offboarding', description: 'Alle Offboarding-Protokolle auflisten', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'GET', path: '/api/offboarding/stats', description: 'Offboarding-Statistiken', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'GET', path: '/api/offboarding/:id', description: 'Offboarding-Protokoll nach ID abrufen', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/offboarding', description: 'Neues Offboarding-Protokoll erstellen', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'PUT', path: '/api/offboarding/:id', description: 'Protokoll aktualisieren', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/offboarding/:id/return-assets', description: 'Alle Assets als zurückgegeben markieren', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/offboarding/:id/regenerate-pdf', description: 'Offboarding-PDF neu generieren', auth: true, role: 'Admin', note: 'PDF' },
{ method: 'DELETE', path: '/api/offboarding/:id', description: 'Offboarding-Protokoll löschen', auth: true, role: 'Lifecycle-Rollen' },
],
},
{
category: 'Onboarding-Prozesse',
icon: '📋',
description: 'Konfiguration von Abteilungen und Prozess-Checklisten für den Lifecycle',
endpoints: [
{ method: 'GET', path: '/api/onboarding-processes/departments', description: 'Alle Abteilungen auflisten', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/onboarding-processes/departments', description: 'Neue Abteilung anlegen', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/onboarding-processes/departments/reorder', description: 'Sortierreihenfolge der Abteilungen ändern', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/onboarding-processes/departments/:id', description: 'Abteilung aktualisieren', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/onboarding-processes/departments/:id', description: 'Abteilung löschen', auth: true, role: 'Admin' },
{ method: 'GET', path: '/api/onboarding-processes/processes', description: 'Alle Prozess-Checklisten auflisten', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'POST', path: '/api/onboarding-processes/processes', description: 'Neuen Prozess erstellen', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'PUT', path: '/api/onboarding-processes/processes/reorder', description: 'Sortierreihenfolge der Prozesse ändern', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'PUT', path: '/api/onboarding-processes/processes/:id', description: 'Prozess aktualisieren', auth: true, role: 'Lifecycle-Rollen' },
{ method: 'DELETE', path: '/api/onboarding-processes/processes/:id', description: 'Prozess löschen', auth: true, role: 'Lifecycle-Rollen' },
],
},
{
category: 'KI & Wissensdatenbank',
icon: '🤖',
description: 'KI-Chat, Ticket-Analyse und Wissensdatenbank-Verwaltung (Claude AI)',
endpoints: [
{ method: 'GET', path: '/api/ai/status', description: 'KI-Status abrufen (verfügbar, Modell-Info)', auth: true, role: 'Staff' },
{ method: 'POST', path: '/api/ai/chat', description: 'Nachricht an KI-Assistenten senden (Chat)', auth: true, role: 'Staff' },
{ method: 'GET', path: '/api/ai/knowledge-base', description: 'Alle Einträge der Wissensdatenbank abrufen', auth: true, role: 'Staff' },
{ method: 'POST', path: '/api/ai/knowledge-base', description: 'Neuen Eintrag zur Wissensdatenbank hinzufügen', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/ai/knowledge-base/import-text', description: 'Text direkt in die Wissensdatenbank importieren', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/ai/knowledge-base/import-url', description: 'URL-Inhalt in die Wissensdatenbank importieren', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/ai/knowledge-base/import-crawl', description: 'Website crawlen und Inhalt importieren', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/ai/knowledge-base/:id', description: 'Wissensdatenbank-Eintrag aktualisieren', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/ai/knowledge-base/:id/images', description: 'Bild zu einem Wissensdatenbank-Eintrag hochladen', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/ai/knowledge-base/:id/images/:filename',description: 'Bild aus einem Wissensdatenbank-Eintrag löschen', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/ai/knowledge-base/:id', description: 'Wissensdatenbank-Eintrag löschen', auth: true, role: 'Admin' },
],
},
{
category: 'IT Themen',
icon: '🖥️',
description: 'IT-Themen für die IT-Übersicht (Kategorien für Hardware, Software, Netzwerk etc.)',
endpoints: [
{ method: 'GET', path: '/api/it-topics', description: 'Alle IT-Themen auflisten', auth: true, role: 'Staff' },
{ method: 'GET', path: '/api/it-topics/:id', description: 'Einzelnes IT-Thema nach ID abrufen', auth: true, role: 'Staff' },
{ method: 'POST', path: '/api/it-topics', description: 'Neues IT-Thema erstellen', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/it-topics/:id', description: 'IT-Thema aktualisieren', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/it-topics/:id', description: 'IT-Thema löschen', auth: true, role: 'Admin' },
],
},
{
category: 'Einstellungen',
icon: '⚙️',
description: 'Ticketkategorien, Vorlagen, E-Mail-Templates und E-Mail-Design',
endpoints: [
{ method: 'GET', path: '/api/settings/categories', description: 'Alle Ticket-Kategorien auflisten', auth: true, role: 'Alle' },
{ method: 'POST', path: '/api/settings/categories', description: 'Neue Ticket-Kategorie erstellen', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/settings/categories/reorder', description: 'Sortierreihenfolge der Kategorien ändern', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/settings/categories/:id', description: 'Ticket-Kategorie aktualisieren', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/settings/categories/:id', description: 'Ticket-Kategorie löschen', auth: true, role: 'Admin' },
{ method: 'GET', path: '/api/settings/templates', description: 'Alle Ticket-Vorlagen auflisten', auth: true, role: 'Alle' },
{ method: 'POST', path: '/api/settings/templates', description: 'Neue Ticket-Vorlage erstellen', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/settings/templates/:id', description: 'Ticket-Vorlage aktualisieren', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/settings/templates/:id', description: 'Ticket-Vorlage löschen', auth: true, role: 'Admin' },
{ method: 'GET', path: '/api/settings/email-templates', description: 'Alle E-Mail-Templates abrufen', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/settings/email-templates/:type', description: 'E-Mail-Template eines bestimmten Typs bearbeiten', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/settings/email-templates/:type/reset', description: 'E-Mail-Template auf Standard zurücksetzen', auth: true, role: 'Admin' },
{ method: 'GET', path: '/api/settings/email-design', description: 'Globales E-Mail-Design abrufen (Farben, Logo, Footer)', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/settings/email-design', description: 'Globales E-Mail-Design aktualisieren', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/settings/email-design/reset', description: 'E-Mail-Design auf Standard zurücksetzen', auth: true, role: 'Admin' },
{ method: 'POST', path: '/api/settings/email-preview/:type', description: 'Vorschau-E-Mail eines bestimmten Templates senden', auth: true, role: 'Admin' },
],
},
{
category: 'ISO-Zertifizierung',
icon: '🛡️',
description: 'ISO 27001 Zertifizierungsaufgaben Kategorien, Status, Fortschritt',
endpoints: [
{ method: 'GET', path: '/api/iso-tasks', description: 'Alle ISO-Aufgaben auflisten (optional: ?category=…&status=…)', auth: true, role: 'Admin+' },
{ method: 'POST', path: '/api/iso-tasks', description: 'Neue ISO-Aufgabe erstellen', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/iso-tasks/:id', description: 'ISO-Aufgabe aktualisieren (Status, Notizen, Verantwortlicher)', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/iso-tasks/:id', description: 'ISO-Aufgabe löschen', auth: true, role: 'Admin' },
],
},
{
category: 'Risikoanalyse',
icon: '⚠️',
description: 'IT-Risikoerfassung mit manuellen Einträgen und automatischem M365-Sync',
endpoints: [
{ method: 'GET', path: '/api/risks', description: 'Alle Risiken auflisten (optional: ?category=…&status=…&source=…)', auth: true, role: 'Admin+' },
{ method: 'GET', path: '/api/risks/stats', description: 'Risikostatistiken (Gesamt, Kritisch, Hoch, Mittel, Niedrig, Behoben)', auth: true, role: 'Admin+' },
{ method: 'POST', path: '/api/risks/sync', description: 'M365-Sync ausführen (Secure Score, Defender, Intune, MFA, Risky Users)', auth: true, role: 'Admin', note: 'Benötigt Azure-Permissions' },
{ method: 'POST', path: '/api/risks', description: 'Manuelles Risiko erstellen', auth: true, role: 'Admin' },
{ method: 'PUT', path: '/api/risks/:id', description: 'Risiko aktualisieren (Status, Mitigation, Verantwortlicher)', auth: true, role: 'Admin' },
{ method: 'DELETE', path: '/api/risks/:id', description: 'Risiko löschen', auth: true, role: 'Admin' },
],
},
{
category: 'Entra ID Rechte',
icon: '🔐',
description: 'Benutzer, Gruppen und Admin-Rollen aus Microsoft Entra ID (Azure AD)',
endpoints: [
{ method: 'GET', path: '/api/entra/users', description: 'Alle Entra-Benutzer mit MFA-Status laden', auth: true, role: 'Admin', note: 'User.Read.All + AuditLog.Read.All' },
{ method: 'GET', path: '/api/entra/users/:id/groups', description: 'Gruppen-Mitgliedschaften eines bestimmten Benutzers', auth: true, role: 'Admin', note: 'GroupMember.Read.All' },
{ method: 'GET', path: '/api/entra/groups', description: 'Alle Entra-Gruppen auflisten', auth: true, role: 'Admin', note: 'Group.Read.All' },
{ method: 'GET', path: '/api/entra/groups/:id/members', description: 'Mitglieder einer bestimmten Gruppe laden', auth: true, role: 'Admin', note: 'GroupMember.Read.All' },
{ method: 'POST', path: '/api/entra/groups/:id/members', description: 'Benutzer zu Gruppe hinzufügen (Body: { userId })', auth: true, role: 'Admin', note: 'GroupMember.ReadWrite.All' },
{ method: 'DELETE', path: '/api/entra/groups/:id/members/:userId', description: 'Benutzer aus Gruppe entfernen', auth: true, role: 'Admin', note: 'GroupMember.ReadWrite.All' },
{ method: 'GET', path: '/api/entra/roles', description: 'Alle aktivierten Directory-Rollen mit Mitgliedern', auth: true, role: 'Admin', note: 'RoleManagement.Read.Directory' },
],
},
{
category: 'System',
icon: '🩺',
description: 'Server-Health und Systeminformationen',
endpoints: [
{ method: 'GET', path: '/health', description: 'Server-Status, Timestamp und Liveness-Check', auth: false },
],
},
];
const MethodBadge = ({ method }) => {
const c = METHOD_COLORS[method] || METHOD_COLORS.GET;
return (
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: '4px',
background: c.light,
color: c.text,
fontSize: '11px',
fontWeight: '700',
fontFamily: 'monospace',
letterSpacing: '0.05em',
border: `1px solid ${c.text}40`,
minWidth: '54px',
textAlign: 'center',
flexShrink: 0,
}}>
{method}
</span>
);
};
const ApiDocsPage = () => {
const [search, setSearch] = useState('');
const [expandedCategory, setExpandedCategory] = useState(null);
const [expandedEndpoints, setExpandedEndpoints] = useState({});
const toggleCategory = (cat) => {
setExpandedCategory(prev => prev === cat ? null : cat);
};
const toggleEndpoint = (key) => {
setExpandedEndpoints(prev => ({ ...prev, [key]: !prev[key] }));
};
const filtered = useMemo(() => {
if (!search.trim()) return API_ENDPOINTS;
const q = search.toLowerCase();
return API_ENDPOINTS.map(cat => ({
...cat,
endpoints: cat.endpoints.filter(ep =>
ep.path.toLowerCase().includes(q) ||
ep.method.toLowerCase().includes(q) ||
ep.description.toLowerCase().includes(q) ||
(ep.role || '').toLowerCase().includes(q)
),
})).filter(cat => cat.endpoints.length > 0);
}, [search]);
const totalEndpoints = API_ENDPOINTS.reduce((sum, c) => sum + c.endpoints.length, 0);
return (
<div style={{ padding: '32px 24px', maxWidth: '960px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '28px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '700', color: 'var(--text-primary)', margin: 0 }}>
📖 API Dokumentation
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', marginTop: '4px' }}>
IT-Nexus REST API {totalEndpoints} Endpunkte in {API_ENDPOINTS.length} Kategorien
</p>
</div>
{/* Base URL + Auth Info */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '12px',
marginBottom: '24px',
}}>
<InfoCard icon="🌐" title="Base URL">
<code style={{ fontFamily: 'monospace', fontSize: '13px', color: 'var(--cereda-primary)' }}>
https://it-nexus.cereda-systems.de
</code>
</InfoCard>
<InfoCard icon="🔒" title="Authentifizierung">
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
Bearer Token <code style={{ fontFamily: 'monospace', fontSize: '12px' }}>Authorization: Bearer &lt;JWT&gt;</code>
</span>
</InfoCard>
<InfoCard icon="📦" title="Content-Type">
<code style={{ fontFamily: 'monospace', fontSize: '13px', color: 'var(--text-secondary)' }}>
application/json
</code>
</InfoCard>
</div>
{/* Method Legend */}
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)',
padding: '14px 18px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap',
}}>
<span style={{ fontSize: '12px', color: 'var(--text-muted)', fontWeight: '600' }}>METHODEN:</span>
{Object.entries(METHOD_COLORS).map(([m]) => (
<MethodBadge key={m} method={m} />
))}
</div>
{/* Search */}
<div style={{ position: 'relative', marginBottom: '20px' }}>
<span style={{
position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)',
color: 'var(--text-muted)', fontSize: '16px', pointerEvents: 'none',
}}>🔍</span>
<input
type="text"
placeholder="Suche nach Pfad, Methode oder Beschreibung…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{
width: '100%',
padding: '11px 14px 11px 40px',
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-primary)',
fontSize: '14px',
outline: 'none',
transition: 'var(--transition)',
boxSizing: 'border-box',
}}
onFocus={e => e.target.style.borderColor = 'var(--cereda-primary)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
{search && (
<button
onClick={() => setSearch('')}
style={{
position: 'absolute', right: '12px', top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', color: 'var(--text-muted)',
cursor: 'pointer', fontSize: '16px', padding: '4px',
}}
></button>
)}
</div>
{/* Endpoint Categories */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{filtered.map((cat) => {
const isOpen = search.trim() ? true : expandedCategory === cat.category;
return (
<div
key={cat.category}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
}}
>
{/* Category Header */}
<button
onClick={() => toggleCategory(cat.category)}
style={{
width: '100%',
background: 'none',
border: 'none',
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
textAlign: 'left',
borderBottom: isOpen ? '1px solid var(--border-color)' : 'none',
transition: 'var(--transition)',
}}
>
<span style={{ fontSize: '20px' }}>{cat.icon}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: '700', fontSize: '15px', color: 'var(--text-primary)' }}>
{cat.category}
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
{cat.description}
</div>
</div>
<span style={{
background: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
borderRadius: '12px',
padding: '2px 10px',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}>
{cat.endpoints.length}
</span>
<span style={{
color: 'var(--text-muted)',
fontSize: '16px',
transition: 'transform 0.2s',
transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
}}></span>
</button>
{/* Endpoints */}
{isOpen && (
<div>
{cat.endpoints.map((ep, i) => {
const key = `${cat.category}-${i}`;
const isEpOpen = expandedEndpoints[key];
return (
<div
key={key}
style={{
borderBottom: i < cat.endpoints.length - 1 ? '1px solid var(--border-color)' : 'none',
}}
>
<button
onClick={() => toggleEndpoint(key)}
style={{
width: '100%',
background: isEpOpen ? 'var(--bg-primary)' : 'none',
border: 'none',
padding: '12px 20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.15s',
}}
>
<MethodBadge method={ep.method} />
<code style={{
fontFamily: 'monospace',
fontSize: '13px',
color: 'var(--text-primary)',
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{ep.path}
</code>
<span style={{
fontSize: '12px',
color: 'var(--text-muted)',
flex: '0 0 auto',
display: 'none',
}} className="ep-desc">
{ep.description}
</span>
{ep.auth ? (
<span style={badgeStyle('var(--warning)')}>🔒 Auth</span>
) : (
<span style={badgeStyle('var(--success)')}>🌐 Öffentlich</span>
)}
{ep.role && (
<span style={badgeStyle('var(--info)')}>{ep.role}</span>
)}
{ep.note && (
<span style={badgeStyle('var(--text-muted)')}>{ep.note}</span>
)}
</button>
{/* Expanded detail */}
{isEpOpen && (
<div style={{
background: 'var(--bg-primary)',
padding: '0 20px 16px 20px',
borderTop: '1px solid var(--border-color)',
}}>
<div style={{ paddingTop: '14px' }}>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '12px' }}>
{ep.description}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<DetailField label="Methode" value={ep.method} mono />
<DetailField label="Pfad" value={ep.path} mono />
<DetailField label="Auth erforderlich" value={ep.auth ? 'Ja' : 'Nein'} />
{ep.role && <DetailField label="Mindest-Rolle" value={ep.role} />}
{ep.note && <DetailField label="Hinweis" value={ep.note} />}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '32px', marginBottom: '12px' }}>🔍</div>
<div>Keine Endpunkte für {search}" gefunden</div>
</div>
)}
</div>
</div>
);
};
const badgeStyle = (color) => ({
display: 'inline-flex',
alignItems: 'center',
padding: '2px 8px',
borderRadius: '12px',
background: `${color}20`,
color: color,
fontSize: '11px',
fontWeight: '600',
flexShrink: 0,
whiteSpace: 'nowrap',
});
const InfoCard = ({ icon, title, children }) => (
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)',
padding: '14px 16px',
}}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', fontWeight: '600', marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span>{icon}</span> {title}
</div>
{children}
</div>
);
const DetailField = ({ label, value, mono }) => (
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-sm)',
padding: '6px 12px',
}}>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '2px' }}>
{label}
</div>
<div style={{ fontSize: '12px', color: 'var(--text-primary)', fontFamily: mono ? 'monospace' : 'inherit' }}>
{value}
</div>
</div>
);
export default ApiDocsPage;

View File

@@ -0,0 +1,674 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import assetService from '../services/assetService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import AssetModal from '../components/assets/AssetModal';
import AssetAssignModal from '../components/assets/AssetAssignModal';
import InspectionSection from '../components/assets/InspectionSection';
import { toast } from 'react-toastify';
const DEPT_COLORS = {
'IT': { bg: 'rgba(59,130,246,0.12)', text: '#3b82f6', border: '#3b82f6' },
'Produktion': { bg: 'rgba(245,158,11,0.12)', text: '#f59e0b', border: '#f59e0b' },
'Techniker': { bg: 'rgba(34,197,94,0.12)', text: '#22c55e', border: '#22c55e' },
};
const PAGE_SIZE = 25;
const AssetsPage = () => {
const { isAdmin, isSuperAdmin, isTechniker } = useAuth();
const [assets, setAssets] = useState([]);
const [filteredAssets, setFilteredAssets] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [deptFilter, setDeptFilter] = useState('all');
const [maintenanceFilter, setMaintenanceFilter] = useState(false);
const [sortField, setSortField] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const [currentPage, setCurrentPage] = useState(1);
const [assetTypes, setAssetTypes] = useState([]);
const [showAssetModal, setShowAssetModal] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
const [editingAsset, setEditingAsset] = useState(null);
const [assigningAsset, setAssigningAsset] = useState(null);
const [labelModal, setLabelModal] = useState(null);
const [labelCopies, setLabelCopies] = useState(1);
const [labelSize, setLabelSize] = useState('medium');
const [labelLoading, setLabelLoading] = useState(false);
const [intuneLoading, setIntuneLoading] = useState(false);
const [showCsvImport, setShowCsvImport] = useState(false);
const [csvFile, setCsvFile] = useState(null);
const [csvPreview, setCsvPreview] = useState(null);
const [csvImporting, setCsvImporting] = useState(false);
const [inspectionAsset, setInspectionAsset] = useState(null);
const [openMenuId, setOpenMenuId] = useState(null);
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
useEffect(() => {
loadAssets();
assetService.getTypes().then(setAssetTypes).catch(() => {});
}, []);
useEffect(() => {
if (openMenuId === null) return;
const close = () => setOpenMenuId(null);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [openMenuId]);
useEffect(() => {
filterAssets();
setCurrentPage(1);
}, [assets, searchTerm, statusFilter, typeFilter, deptFilter, maintenanceFilter]);
const canModifyAssets = () => {
return isSuperAdmin() || isAdmin() || isTechniker();
};
const handleSort = (field) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
setCurrentPage(1);
};
const SortIcon = ({ field }) => {
if (sortField !== field) return <span style={{ opacity: 0.3, marginLeft: 4 }}></span>;
return <span style={{ marginLeft: 4, color: 'var(--cereda-primary)' }}>{sortDir === 'asc' ? '↑' : '↓'}</span>;
};
const loadAssets = async () => {
try {
const data = await assetService.getAll();
setAssets(data);
} catch (error) {
toast.error('Fehler beim Laden der Assets');
} finally {
setLoading(false);
}
};
const filterAssets = () => {
let filtered = [...assets];
// Search filter
if (searchTerm) {
filtered = filtered.filter(
(asset) =>
asset.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.serial_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.inventory_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.model?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Status filter
if (statusFilter !== 'all') {
filtered = filtered.filter((asset) => asset.status === statusFilter);
}
// Type filter
if (typeFilter !== 'all') {
filtered = filtered.filter((asset) => asset.type === typeFilter);
}
// Department filter
if (deptFilter !== 'all') {
filtered = filtered.filter((asset) => (asset.department || 'IT') === deptFilter);
}
// Maintenance filter
if (maintenanceFilter) {
const today = new Date().toISOString().slice(0, 10);
filtered = filtered.filter(
(asset) => asset.next_maintenance_date && asset.next_maintenance_date <= today
);
}
setFilteredAssets(filtered);
};
const handleCreate = () => {
setEditingAsset(null);
setShowAssetModal(true);
};
const handleEdit = (asset) => {
setEditingAsset(asset);
setShowAssetModal(true);
};
const handleAssetModalClose = () => {
setShowAssetModal(false);
setEditingAsset(null);
};
const handleAssetSaved = () => {
loadAssets();
handleAssetModalClose();
};
const handleAssign = (asset) => {
setAssigningAsset(asset);
setShowAssignModal(true);
};
const handleUnassign = async (asset) => {
if (!window.confirm('Möchten Sie die Zuweisung dieses Assets wirklich aufheben?')) {
return;
}
try {
await assetService.unassign(asset.id, 'verfuegbar');
toast.success('Zuweisung erfolgreich aufgehoben');
loadAssets();
} catch (error) {
toast.error(error.message || 'Fehler beim Aufheben der Zuweisung');
}
};
const handleAssignModalClose = () => {
setShowAssignModal(false);
setAssigningAsset(null);
};
const handleAssigned = () => {
loadAssets();
handleAssignModalClose();
};
const handlePrintLabel = async () => {
setLabelLoading(true);
try {
await assetService.printLabel(labelModal.id, labelCopies, labelSize);
setLabelModal(null);
} catch (error) {
toast.error('Fehler beim Erstellen des Labels');
} finally {
setLabelLoading(false);
}
};
const handleIntuneImport = async () => {
setIntuneLoading(true);
try {
const result = await assetService.importFromIntune();
toast.success(
`Intune-Import abgeschlossen: ${result.imported} importiert, ${result.skipped} übersprungen` +
(result.errors.length > 0 ? `, ${result.errors.length} Fehler` : '')
);
if (result.imported > 0) loadAssets();
} catch (error) {
toast.error(error.message || 'Fehler beim Intune-Import');
} finally {
setIntuneLoading(false);
}
};
const handleExportCsv = () => {
const headers = ['Name', 'Typ', 'Seriennummer', 'Modell', 'Status', 'Zugewiesen an', 'Kaufdatum', 'Nächste Prüfung', 'TeamViewer-ID'];
const rows = filteredAssets.map(a => [
a.name, a.type, a.serial_number, a.model || '', a.status,
a.assigned_to_username || '', a.purchase_date || '', a.next_maintenance_date || '', a.teamviewer_id || '',
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'));
const csv = [headers.join(';'), ...rows].join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `assets_${new Date().toISOString().slice(0,10)}.csv`;
a.click(); URL.revokeObjectURL(url);
toast.success(`${filteredAssets.length} Assets exportiert`);
};
const handleCsvFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
setCsvFile(file);
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target.result.replace(/^\uFEFF/, '');
const lines = text.split(/\r?\n/).filter(l => l.trim());
const sep = lines[0].includes(';') ? ';' : ',';
const headers = lines[0].split(sep).map(h => h.replace(/^"|"$/g, '').trim().toLowerCase());
const rows = lines.slice(1).map(line => {
const vals = line.split(sep).map(v => v.replace(/^"|"$/g, '').trim());
const obj = {};
headers.forEach((h, i) => { obj[h] = vals[i] || ''; });
return obj;
}).filter(r => r.name || r['name']);
setCsvPreview({ headers, rows: rows.slice(0, 5), total: rows.length, allRows: rows });
};
reader.readAsText(file, 'UTF-8');
};
const handleCsvImport = async () => {
if (!csvPreview) return;
setCsvImporting(true);
let imported = 0, errors = 0;
for (const row of csvPreview.allRows) {
try {
await assetService.create({
name: row.name || row['name'] || '',
type: row.typ || row.type || 'Other',
serial_number: row.seriennummer || row['serial_number'] || row['serialnumber'] || '',
model: row.modell || row.model || '',
status: row.status || 'verfuegbar',
purchase_date: row.kaufdatum || row['purchase_date'] || '',
teamviewer_id: row['teamviewer-id'] || row.teamviewer_id || '',
});
imported++;
} catch { errors++; }
}
toast.success(`CSV-Import: ${imported} importiert${errors > 0 ? `, ${errors} Fehler` : ''}`);
setShowCsvImport(false); setCsvFile(null); setCsvPreview(null);
loadAssets();
setCsvImporting(false);
};
const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie dieses Asset wirklich löschen?')) {
return;
}
try {
await assetService.delete(id);
toast.success('Asset erfolgreich gelöscht');
loadAssets();
} catch (error) {
toast.error(error.message || 'Fehler beim Löschen');
}
};
if (loading) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
// Sort + paginate in render
const sorted = [...filteredAssets].sort((a, b) => {
let va = (a[sortField] || '').toString().toLowerCase();
let vb = (b[sortField] || '').toString().toLowerCase();
return sortDir === 'asc' ? va.localeCompare(vb, 'de') : vb.localeCompare(va, 'de');
});
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const pagedAssets = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const hasAnyMaintenanceDate = assets.some(a => a.next_maintenance_date);
return (
<div className="main-content">
<div className="container">
<div className="flex justify-between items-center mb-3">
<div>
<h1 style={{ marginBottom: 0 }}>Assets</h1>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{filteredAssets.length} von {assets.length}</span>
</div>
<div className="flex" style={{ gap: '0.5rem' }}>
<button onClick={handleExportCsv} className="btn btn-secondary" title="Aktuell gefilterte Assets als CSV exportieren">
CSV Export
</button>
{canModifyAssets() && (
<>
<button onClick={() => setShowCsvImport(true)} className="btn btn-secondary" title="Assets aus CSV-Datei importieren">
CSV Import
</button>
{!isTechniker() && (
<button onClick={handleIntuneImport} className="btn btn-secondary" disabled={intuneLoading} title="Geräte aus Microsoft Intune importieren">
{intuneLoading ? 'Importiere...' : '☁️ Aus Intune importieren'}
</button>
)}
<button onClick={handleCreate} className="btn btn-primary">+ Neues Asset</button>
</>
)}
</div>
</div>
{/* Search and Filter — kompakte einzeilige Leiste */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input
type="text"
className="search-input"
placeholder="Suche nach Name, Seriennummer oder Modell..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ flex: '1 1 220px', minWidth: 180, maxWidth: 340 }}
/>
<select className="form-select" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={{ flex: '0 0 auto', width: 'auto' }}>
<option value="all">Alle Typen</option>
{assetTypes.map(t => (
<option key={t.id} value={t.name}>{t.name}</option>
))}
</select>
{(isAdmin() || isSuperAdmin()) && (
<select className="form-select" value={deptFilter} onChange={(e) => setDeptFilter(e.target.value)} style={{ flex: '0 0 auto', width: 'auto' }}>
<option value="all">Alle Bereiche</option>
<option value="IT">IT</option>
<option value="Produktion">Produktion</option>
<option value="Techniker">Techniker</option>
</select>
)}
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
{[
{ key: 'all', label: 'Alle', cls: statusFilter === 'all' ? 'btn-primary' : 'btn-secondary' },
{ key: 'verfuegbar', label: 'Verfügbar', cls: statusFilter === 'verfuegbar' ? 'btn-success' : 'btn-secondary' },
{ key: 'zugewiesen', label: 'Zugewiesen', cls: statusFilter === 'zugewiesen' ? 'btn-primary' : 'btn-secondary' },
{ key: 'inaktiv', label: 'Inaktiv', cls: statusFilter === 'inaktiv' ? 'btn-secondary active' : 'btn-secondary' },
{ key: 'beschaedigt', label: 'Beschädigt', cls: statusFilter === 'beschaedigt' ? 'btn-danger' : 'btn-secondary' },
].map(s => (
<button key={s.key} className={`btn ${s.cls} btn-small`} onClick={() => setStatusFilter(s.key)}>{s.label}</button>
))}
<button
className={`btn ${maintenanceFilter ? 'btn-warning' : 'btn-secondary'} btn-small`}
onClick={() => setMaintenanceFilter(!maintenanceFilter)}
title="Nur Assets anzeigen, deren Prüftermin überschritten ist"
> Wartung fällig</button>
</div>
</div>
{/* Table */}
<div className="card" style={{ overflowX: 'auto' }}>
<table className="table" style={{ tableLayout: 'fixed', width: '100%', minWidth: hasAnyMaintenanceDate ? '1020px' : '860px' }}>
<colgroup>
<col style={{ width: hasAnyMaintenanceDate ? '17%' : '19%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: hasAnyMaintenanceDate ? '13%' : '15%' }} />
<col style={{ width: hasAnyMaintenanceDate ? '12%' : '13%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: hasAnyMaintenanceDate ? '12%' : '15%' }} />
{hasAnyMaintenanceDate && <col style={{ width: '11%' }} />}
<col style={{ width: hasAnyMaintenanceDate ? '8%' : '11%' }} />
</colgroup>
<thead>
<tr>
{[
{ field: 'name', label: 'Name' },
{ field: 'type', label: 'Typ' },
{ field: 'department', label: 'Bereich' },
{ field: 'inventory_number', label: 'Inventarnummer' },
{ field: 'model', label: 'Modell' },
{ field: 'status', label: 'Status' },
{ field: 'assigned_to_username', label: 'Zugewiesen an' },
].map(col => (
<th key={col.field} onClick={() => handleSort(col.field)} style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
{col.label}<SortIcon field={col.field} />
</th>
))}
{hasAnyMaintenanceDate && (
<th onClick={() => handleSort('next_maintenance_date')} style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }} title="Nächste Prüfung">
Prüfung<SortIcon field="next_maintenance_date" />
</th>
)}
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{pagedAssets.length === 0 ? (
<tr>
<td colSpan={hasAnyMaintenanceDate ? 9 : 8} className="text-center">
Keine Assets gefunden
</td>
</tr>
) : (
pagedAssets.map((asset) => {
const today = new Date().toISOString().slice(0, 10);
const maintenanceOverdue = asset.next_maintenance_date && asset.next_maintenance_date <= today;
const dept = asset.department || 'IT';
const dc = DEPT_COLORS[dept] || DEPT_COLORS['IT'];
return (
<tr key={asset.id} style={maintenanceOverdue ? { backgroundColor: 'rgba(245,158,11,0.08)' } : {}}>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.name}>
<Link to={`/monitoring/device/host/${encodeURIComponent(asset.name)}`} style={{ color: 'inherit', textDecoration: 'none', borderBottom: '1px dotted var(--text-muted)' }} title="Agent-Details öffnen">
{asset.name}
</Link>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{asset.type}</td>
<td>
<span style={{ background: dc.bg, color: dc.text, border: `1px solid ${dc.border}`, borderRadius: '20px', padding: '2px 10px', fontSize: '11px', fontWeight: 600 }}>
{dept}
</span>
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.inventory_number || ''}>{asset.inventory_number || '—'}</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.model || ''}>{asset.model || '—'}</td>
<td>
<span className={`status-badge status-${asset.status}`}>
{{ verfuegbar: 'Verfügbar', zugewiesen: 'Zugewiesen', inaktiv: 'Inaktiv', beschaedigt: 'Beschädigt' }[asset.status] || asset.status}
</span>
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.assigned_to_username || ''}>{asset.assigned_to_username || '—'}</td>
{hasAnyMaintenanceDate && (
<td>
{asset.next_maintenance_date ? (
<span style={{ color: maintenanceOverdue ? 'var(--warning)' : 'inherit', fontWeight: maintenanceOverdue ? 600 : 400 }}>
{maintenanceOverdue && '⚠️ '}
{new Date(asset.next_maintenance_date).toLocaleDateString('de-DE')}
</span>
) : '—'}
</td>
)}
<td>
<button
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right - 180 });
setOpenMenuId(openMenuId === asset.id ? null : asset.id);
}}
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 6, padding: '4px 10px', cursor: 'pointer', color: 'var(--text-primary)', fontSize: 18, lineHeight: 1 }}
title="Aktionen"
></button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.75rem', fontSize: 13, color: 'var(--text-muted)' }}>
<span>{((currentPage - 1) * PAGE_SIZE) + 1}{Math.min(currentPage * PAGE_SIZE, sorted.length)} von {sorted.length} Assets</span>
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>«</button>
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(p => p - 1)} disabled={currentPage === 1}></button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 1)
.reduce((acc, p, idx, arr) => {
if (idx > 0 && p - arr[idx - 1] > 1) acc.push('...');
acc.push(p);
return acc;
}, [])
.map((p, i) => p === '...'
? <span key={`e${i}`} style={{ padding: '0 4px' }}></span>
: <button key={p} className={`btn btn-small ${currentPage === p ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setCurrentPage(p)}>{p}</button>
)
}
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(p => p + 1)} disabled={currentPage === totalPages}></button>
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>»</button>
</div>
</div>
)}
{/* Asset Modal */}
{showAssetModal && (
<AssetModal
asset={editingAsset}
onClose={handleAssetModalClose}
onSaved={handleAssetSaved}
/>
)}
{/* Assign Modal */}
{showAssignModal && (
<AssetAssignModal
asset={assigningAsset}
onClose={handleAssignModalClose}
onAssigned={handleAssigned}
/>
)}
{/* Label Modal */}
{labelModal && (
<div className="modal-overlay" onClick={() => setLabelModal(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Label drucken</h2>
<button className="modal-close" onClick={() => setLabelModal(null)}>×</button>
</div>
<div className="modal-body">
<p><strong>{labelModal.name}</strong> {labelModal.serial_number}</p>
<div className="form-group" style={{ marginTop: '1rem' }}>
<label className="form-label">Größe</label>
<select
className="form-select"
value={labelSize}
onChange={(e) => setLabelSize(e.target.value)}
>
<option value="small">Klein (50×25 mm) A4</option>
<option value="medium">Mittel (62×29 mm) A4</option>
<option value="large">Groß (90×38 mm) A4</option>
<option value="ptouch12">Brother P700 12 mm Band</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Anzahl Kopien</label>
<div className="flex items-center" style={{ gap: '0.5rem' }}>
<button
className="btn btn-secondary btn-small"
onClick={() => setLabelCopies(Math.max(1, labelCopies - 1))}
></button>
<span style={{ minWidth: '2rem', textAlign: 'center' }}>{labelCopies}</span>
<button
className="btn btn-secondary btn-small"
onClick={() => setLabelCopies(Math.min(100, labelCopies + 1))}
>+</button>
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setLabelModal(null)}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handlePrintLabel}
disabled={labelLoading}
>
{labelLoading ? 'Wird erstellt...' : 'PDF öffnen'}
</button>
</div>
</div>
</div>
)}
{/* CSV Import Modal */}
{showCsvImport && (
<div className="modal-overlay" onClick={() => { setShowCsvImport(false); setCsvFile(null); setCsvPreview(null); }}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title"> Assets per CSV importieren</h2>
<button className="modal-close" onClick={() => { setShowCsvImport(false); setCsvFile(null); setCsvPreview(null); }}>×</button>
</div>
<div style={{ padding: '20px 24px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '16px' }}>
CSV-Spalten (Semikolon oder Komma getrennt):<br />
<code style={{ background: 'var(--bg-secondary)', padding: '2px 6px', borderRadius: '4px', fontSize: '12px' }}>
Name;Typ;Seriennummer;Modell;Status;Kaufdatum;TeamViewer-ID
</code>
</p>
<input type="file" accept=".csv,.txt" onChange={handleCsvFileChange}
style={{ display: 'block', marginBottom: '16px', fontSize: '13px', color: 'var(--text-primary)' }} />
{csvPreview && (
<div>
<p style={{ fontWeight: 600, fontSize: '13px', color: 'var(--text-primary)', marginBottom: '8px' }}>
Vorschau ({csvPreview.total} Assets erkannt, erste 5):
</p>
<div style={{ overflowX: 'auto', border: '1px solid var(--border-color)', borderRadius: '8px' }}>
<table className="table" style={{ margin: 0, fontSize: '12px' }}>
<thead>
<tr>{csvPreview.headers.map(h => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{csvPreview.rows.map((row, i) => (
<tr key={i}>{csvPreview.headers.map(h => <td key={h}>{row[h]}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<div className="card-footer">
<button className="btn btn-secondary" onClick={() => { setShowCsvImport(false); setCsvFile(null); setCsvPreview(null); }}>
Abbrechen
</button>
<button className="btn btn-primary" onClick={handleCsvImport}
disabled={!csvPreview || csvImporting}>
{csvImporting ? '⏳ Importiere…' : `${csvPreview?.total || 0} Assets importieren`}
</button>
</div>
</div>
</div>
)}
{/* Actions Dropdown (fixed position to escape overflow) */}
{openMenuId !== null && (() => {
const asset = filteredAssets.find(a => a.id === openMenuId);
if (!asset) return null;
return (
<div
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, zIndex: 9999, background: 'var(--bg-card, #1e293b)', border: '1px solid var(--border-color)', borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.4)', minWidth: 200, overflow: 'hidden' }}
onClick={(e) => e.stopPropagation()}
>
{[
{ label: '🔍 Prüfprotokoll', action: () => { setInspectionAsset(asset); setOpenMenuId(null); } },
{ label: '🏷️ Label', action: () => { setLabelModal(asset); setLabelCopies(1); setLabelSize('medium'); setOpenMenuId(null); } },
canModifyAssets() && { label: '✏️ Bearbeiten', action: () => { handleEdit(asset); setOpenMenuId(null); } },
canModifyAssets() && asset.status === 'verfuegbar' && { label: '👤 Zuweisen', action: () => { handleAssign(asset); setOpenMenuId(null); } },
canModifyAssets() && asset.status === 'zugewiesen' && { label: '🔓 Freigeben', action: () => { handleUnassign(asset); setOpenMenuId(null); } },
canModifyAssets() && asset.status === 'zugewiesen' && { label: '📄 Übergabeprotokoll', action: () => { assetService.printHandoverProtocol(asset.id).catch(() => toast.error('Fehler')); setOpenMenuId(null); } },
canModifyAssets() && { label: '🗑️ Löschen', action: () => { handleDelete(asset.id); setOpenMenuId(null); }, danger: true },
].filter(Boolean).map((item, i) => (
<button key={i} onClick={item.action} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '10px 16px', background: 'none', border: 'none', borderBottom: '1px solid var(--border-color)', cursor: 'pointer', fontSize: 13, color: item.danger ? '#ef4444' : 'var(--text-primary)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
>{item.label}</button>
))}
</div>
);
})()}
{/* Inspection Modal */}
{inspectionAsset && (
<div className="modal-overlay" onClick={() => setInspectionAsset(null)}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 640 }}>
<div className="modal-header">
<h2 className="modal-title">Prüfprotokoll {inspectionAsset.name}</h2>
<button className="modal-close" onClick={() => setInspectionAsset(null)}>×</button>
</div>
<div style={{ padding: '8px 24px 24px' }}>
<InspectionSection
assetId={inspectionAsset.id}
onUpdated={loadAssets}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default AssetsPage;

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import authService from '../services/authService';
import api from '../services/api';
import { toast } from 'react-toastify';
const ChangePasswordPage = () => {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [notifLoading, setNotifLoading] = useState(false);
const { user, updateUser } = useAuth();
const navigate = useNavigate();
const emailNotificationsEnabled = user?.email_notifications !== false && user?.email_notifications !== 0;
const handleToggleNotifications = async () => {
setNotifLoading(true);
try {
const newValue = !emailNotificationsEnabled;
const res = await api.put('/auth/notifications', { email_notifications: newValue });
const updatedUser = { ...user, email_notifications: newValue ? 1 : 0 };
localStorage.setItem('user', JSON.stringify(updatedUser));
updateUser(updatedUser);
toast.success(newValue ? 'E-Mail-Benachrichtigungen aktiviert' : 'E-Mail-Benachrichtigungen deaktiviert');
} catch {
toast.error('Fehler beim Speichern');
} finally {
setNotifLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
// Validation
if (newPassword !== confirmPassword) {
toast.error('Passwörter stimmen nicht überein');
return;
}
if (newPassword.length < 8) {
toast.error('Passwort muss mindestens 8 Zeichen lang sein');
return;
}
if (!/[A-Z]/.test(newPassword)) {
toast.error('Passwort muss mindestens einen Großbuchstaben enthalten');
return;
}
if (!/[a-z]/.test(newPassword)) {
toast.error('Passwort muss mindestens einen Kleinbuchstaben enthalten');
return;
}
if (!/[0-9]/.test(newPassword)) {
toast.error('Passwort muss mindestens eine Zahl enthalten');
return;
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(newPassword)) {
toast.error('Passwort muss mindestens ein Sonderzeichen enthalten');
return;
}
setLoading(true);
try {
await authService.changePassword(currentPassword, newPassword);
// Update user in context (must_change_password = false)
const updatedUser = { ...user, must_change_password: false };
localStorage.setItem('user', JSON.stringify(updatedUser));
updateUser(updatedUser);
toast.success('Passwort erfolgreich geändert!');
navigate('/dashboard');
} catch (error) {
toast.error(error.message || 'Passwort ändern fehlgeschlagen');
} finally {
setLoading(false);
}
};
return (
<div className="page-container">
<div className="page-header">
<h1>Passwort ändern</h1>
</div>
{/* Persistente Warnung - kann nicht geschlossen werden */}
<div style={{
backgroundColor: '#fef3c7',
borderLeft: '4px solid #f59e0b',
padding: '16px',
marginBottom: '24px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<span style={{ fontSize: '24px' }}></span>
<div>
<strong style={{ color: '#92400e', display: 'block', marginBottom: '4px' }}>
Passwortänderung erforderlich
</strong>
<span style={{ color: '#78350f' }}>
Bitte ändern Sie Ihr Passwort beim ersten Login. Diese Meldung bleibt sichtbar bis das Passwort geändert wurde.
</span>
</div>
</div>
{/* E-Mail-Benachrichtigungen */}
<div className="card" style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9375rem', color: 'var(--text-primary)', marginBottom: '4px' }}>
E-Mail-Benachrichtigungen
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', lineHeight: 1.5 }}>
{emailNotificationsEnabled
? 'Du erhältst E-Mails bei Ticket-Updates und Support-Antworten.'
: 'Du erhältst keine E-Mails bei Ticket-Aktivitäten.'}
</div>
</div>
<button
type="button"
onClick={handleToggleNotifications}
disabled={notifLoading}
style={{
position: 'relative',
width: '48px', height: '26px',
borderRadius: '13px',
border: 'none',
cursor: notifLoading ? 'not-allowed' : 'pointer',
background: emailNotificationsEnabled ? 'var(--cereda-primary)' : 'var(--bg-tertiary)',
transition: 'background 0.2s',
flexShrink: 0,
padding: 0,
}}
>
<span style={{
position: 'absolute',
top: '3px',
left: emailNotificationsEnabled ? '25px' : '3px',
width: '20px', height: '20px',
borderRadius: '50%',
background: '#fff',
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</button>
</div>
</div>
<div className="card">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Aktuelles Passwort</label>
<input
type="password"
className="form-input"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">Neues Passwort</label>
<input
type="password"
className="form-input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
<small className="form-help">
Mindestens 8 Zeichen, 1 Großbuchstabe, 1 Kleinbuchstabe, 1 Zahl und 1 Sonderzeichen (!@#$%^&*(),.?":{}|&lt;&gt;)
</small>
</div>
<div className="form-group">
<label className="form-label">Neues Passwort bestätigen</label>
<input
type="password"
className="form-input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="modal-footer">
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? 'Speichern...' : 'Passwort ändern'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ChangePasswordPage;

View File

@@ -0,0 +1,757 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import fidoKeyService from '../services/fidoKeyService';
import ticketService from '../services/ticketService';
import assetService from '../services/assetService';
import onboardingService from '../services/onboardingService';
import offboardingService from '../services/offboardingService';
import userService from '../services/userService';
import licenseService from '../services/licenseService';
import warehouseService from '../services/warehouseService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const API = process.env.REACT_APP_API_URL || '/api';
const authFetch = (url) =>
fetch(url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
.then(r => r.ok ? r.json() : null).catch(() => null);
const today = new Date();
const hour = today.getHours();
const greeting = hour < 12 ? 'Guten Morgen' : hour < 17 ? 'Guten Tag' : 'Guten Abend';
const dateStr = today.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
const circumference = 2 * Math.PI * 42;
const actionPillClass = (action = '') => {
if (action.startsWith('CREATE')) return 'create';
if (action.startsWith('DELETE')) return 'delete';
return 'update';
};
const actionPillLabel = (action = '') => {
if (action.startsWith('CREATE')) return ` ${action}`;
if (action.startsWith('DELETE')) return `${action}`;
return `${action}`;
};
const avatarColors = ['var(--cereda-primary)', '#007aff', '#af52de', '#ff9500', '#34c759', '#ff3b30'];
const getAvatarColor = (str = '') => avatarColors[str.charCodeAt(0) % avatarColors.length];
/* ═══════════════════════════════════════════════════════════════════
DashboardPage
═══════════════════════════════════════════════════════════════════ */
const DashboardPage = () => {
const { user, isSuperAdmin, isAdmin } = useAuth();
const [fidoStats, setFidoStats] = useState(null);
const [assetStats, setAssetStats] = useState(null);
const [ticketStats, setTicketStats] = useState(null);
const [maintenanceStats, setMaintenanceStats] = useState(null);
const [licenseStats, setLicenseStats] = useState(null);
const [onboardingStats, setOnboardingStats] = useState(null);
const [offboardingStats, setOffboardingStats] = useState(null);
const [recentLogs, setRecentLogs] = useState([]);
const [ticketMetrics, setTicketMetrics] = useState(null);
const [stockViolations, setStockViolations] = useState([]);
const [monitoringStats, setMonitoringStats] = useState(null);
const [patchStats, setPatchStats] = useState(null);
const [lastSecReport, setLastSecReport] = useState(null);
const [announcements, setAnnouncements] = useState([]);
const [shareCount, setShareCount] = useState(null);
const [logFilter, setLogFilter] = useState('Alle');
const [loading, setLoading] = useState(true);
useEffect(() => { loadDashboardData(); }, []);
const loadDashboardData = async () => {
try {
const fStats = await fidoKeyService.getStatistics();
setFidoStats(fStats);
try { const tStats = await ticketService.getStats(); setTicketStats(tStats); const tMetrics = await ticketService.getMetrics(30); setTicketMetrics(tMetrics); } catch { /* */ }
try {
const aStats = await assetService.getStatistics(); setAssetStats(aStats);
const allAssets = await assetService.getAll();
const todayStr = new Date().toISOString().slice(0, 10);
const in30Str = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const overdue = allAssets.filter(a => a.next_maintenance_date && a.next_maintenance_date <= todayStr).length;
const upcoming = allAssets.filter(a => a.next_maintenance_date && a.next_maintenance_date > todayStr && a.next_maintenance_date <= in30Str).length;
if (overdue > 0 || upcoming > 0) setMaintenanceStats({ overdue, upcoming });
} catch { /* */ }
try { const lStats = await licenseService.getStatistics(); if (lStats.expired > 0 || lStats.expiringSoon > 0) setLicenseStats(lStats); } catch { /* */ }
if (isAdmin()) {
try { const s = await onboardingService.getStatistics(); setOnboardingStats(s); } catch { /* */ }
try { const s = await offboardingService.getStatistics(); setOffboardingStats(s); } catch { /* */ }
try { const logs = await userService.getAuditLogs(10, 0); setRecentLogs(logs); } catch { /* */ }
try { const v = await warehouseService.getViolations(); setStockViolations(v); } catch { /* */ }
const monData = await authFetch(`${API}/monitoring`);
if (monData) {
const agents = Array.isArray(monData) ? monData : (monData.data || monData.agents || []);
const now = Date.now();
const online = agents.filter(a => a.last_checkin && (now - new Date(a.last_checkin + 'Z').getTime()) < 15 * 60 * 1000).length;
const offline = agents.length - online;
const pendingUpdates = agents.reduce((s, a) => s + (a.windows_updates_pending || 0), 0);
const noEncrypt = agents.filter(a => a.bitlocker_status && a.bitlocker_status !== 'encrypted').length;
setMonitoringStats({ total: agents.length, online, offline, pendingUpdates, noEncrypt });
}
const patchData = await authFetch(`${API}/patch/overview`);
if (patchData?.stats) setPatchStats(patchData.stats);
const secData = await authFetch(`${API}/security-reports`);
if (secData?.length > 0) setLastSecReport(secData[0]);
const annData = await authFetch(`${API}/announcements`);
if (Array.isArray(annData)) setAnnouncements(annData.filter(a => a.active).slice(0, 3));
const sharesData = await authFetch(`${API}/shares`);
if (Array.isArray(sharesData)) {
const now = Date.now();
const active = sharesData.filter(s => {
if (s.expires_at && new Date(s.expires_at + 'Z') < new Date()) return false;
if (s.max_downloads && s.download_count >= s.max_downloads) return false;
return true;
}).length;
const expiringSoon = sharesData.filter(s => {
if (!s.expires_at) return false;
const exp = new Date(s.expires_at + 'Z');
if (exp < new Date()) return false;
return (exp - now) < 7 * 86400 * 1000;
}).length;
setShareCount({ total: sharesData.length, active, expiringSoon });
}
}
} catch { toast.error('Fehler beim Laden der Dashboard-Daten'); }
finally { setLoading(false); }
};
if (loading) return <div className="main-content"><LoadingSpinner /></div>;
const patchPct = patchStats ? Math.round((patchStats.compliant || 0) / Math.max(patchStats.total || 1, 1) * 100) : 0;
const dashoffset = circumference * (1 - patchPct / 100);
const agentOnlinePct = monitoringStats?.total > 0 ? (monitoringStats.online / monitoringStats.total * 100).toFixed(1) : 0;
const riskLevel = lastSecReport?.risk_level || 'unbekannt';
const riskClass = riskLevel === 'hoch' ? 'risk-high' : riskLevel === 'mittel' ? 'risk-mid' : '';
const riskLabel = riskLevel === 'hoch' ? 'Hohes Risiko' : riskLevel === 'mittel' ? 'Mittleres Risiko' : 'Niedriges Risiko';
const filterMap = {
'Alle': null, 'Assets': 'asset', 'Benutzer': 'user', 'Onboarding': 'onboarding_protocol', 'Tickets': 'ticket',
};
const filteredLogs = logFilter === 'Alle'
? recentLogs
: recentLogs.filter(l => l.entity_type === filterMap[logFilter]);
return (
<div className="main-content">
<div className="db-content">
{/* ═══ HERO ═══ */}
<section className="db-hero">
<div>
<div className="db-hero-date">{dateStr}</div>
<h1 className="db-hero-title">
{greeting}, <span className="db-accent">{user?.first_name || user?.username}.</span>
</h1>
<p className="db-hero-sub">
Ein Überblick über alle Systeme, Geräte und Vorgänge bei Cereda Systems auf einen Blick.
</p>
<div className="db-hero-status">
<span className="db-pdot" />
{monitoringStats
? `${monitoringStats.online} von ${monitoringStats.total} Agents online`
: 'System bereit'}
</div>
</div>
<div className="db-hero-actions">
<Link to="/tickets" className="db-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
Neues Ticket
</Link>
{(isSuperAdmin() || isAdmin()) && (
<Link to="/monitoring" className="db-btn db-accent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
Monitoring
</Link>
)}
</div>
</section>
{/* ═══ ALERTS ═══ */}
{(maintenanceStats?.overdue > 0 || licenseStats?.expiringSoon > 0 || licenseStats?.expired > 0 || (monitoringStats?.offline > 0)) && (
<div className="db-alerts">
{maintenanceStats?.overdue > 0 && (
<Link to="/assets?filter=maintenance" className="db-alert a-red">
<div className="db-ai">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 9v4M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
</svg>
</div>
<div>
<div className="db-ah">Wartung überfällig <span className="db-ac">{maintenanceStats.overdue}</span></div>
<div className="db-am">{maintenanceStats.overdue} Gerät{maintenanceStats.overdue > 1 ? 'e haben' : ' hat'} die planmäßige Wartung verpasst.</div>
<span className="db-alink">Zu Assets </span>
</div>
</Link>
)}
{(licenseStats?.expired > 0 || licenseStats?.expiringSoon > 0) && (
<Link to="/licenses" className="db-alert a-amber">
<div className="db-ai">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
</div>
<div>
<div className="db-ah">Lizenzen ablaufend <span className="db-ac">{(licenseStats.expired || 0) + (licenseStats.expiringSoon || 0)}</span></div>
<div className="db-am">{licenseStats.expired > 0 ? `${licenseStats.expired} abgelaufen · ` : ''}{licenseStats.expiringSoon > 0 ? `${licenseStats.expiringSoon} in 30 Tagen` : ''}</div>
<span className="db-alink">Lizenzen prüfen </span>
</div>
</Link>
)}
{monitoringStats?.offline > 0 && (
<Link to="/monitoring" className="db-alert a-blue">
<div className="db-ai">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div>
<div className="db-ah">Geräte offline <span className="db-ac">{monitoringStats.offline}</span></div>
<div className="db-am">{monitoringStats.offline} Agent{monitoringStats.offline > 1 ? 's sind' : ' ist'} seit über 15 Min nicht erreichbar.</div>
<span className="db-alink">Monitoring öffnen </span>
</div>
</Link>
)}
{stockViolations.length > 0 && (
<Link to="/warehouse" className="db-alert a-amber">
<div className="db-ai">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>
</svg>
</div>
<div>
<div className="db-ah">Unter Mindestbestand <span className="db-ac">{stockViolations.length}</span></div>
<div className="db-am">{stockViolations.map(v => `${v.category}: ${v.current_stock}/${v.min_stock}`).join(' · ')}</div>
<span className="db-alink">Lager prüfen </span>
</div>
</Link>
)}
</div>
)}
{/* ═══ SCHNELLZUGRIFF ═══ */}
<div className="db-block">
<div className="db-section-head">
<div>
<h2>Schnellzugriff.</h2>
<p>Direkt zu den meistgenutzten Bereichen.</p>
</div>
</div>
<div className="db-quick-grid">
<Link to="/fido-keys" className="db-quick-card qc-key">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15.5 7.5 3 3L22 7l-3-3M21 2l-9.6 9.6a5.5 5.5 0 1 1-2 2L19 4"/></svg>
</div>
<div className="db-quick-title">FIDO-Keys</div>
<div className="db-quick-sub">{fidoStats?.total || 0} gesamt · {fidoStats?.active || 0} aktiv</div>
</Link>
{(isSuperAdmin() || isAdmin()) && <>
<Link to="/assets" className="db-quick-card qc-asset">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>
</div>
<div className="db-quick-title">Assets</div>
<div className="db-quick-sub">{assetStats?.total || 0} Geräte</div>
</Link>
<Link to="/tickets" className="db-quick-card qc-tix">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 10h18"/></svg>
</div>
<div className="db-quick-title">Tickets</div>
<div className="db-quick-sub">{ticketStats?.open || 0} offen</div>
</Link>
<Link to="/monitoring" className="db-quick-card qc-mon">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<div className="db-quick-title">Monitoring</div>
<div className="db-quick-sub">{monitoringStats ? `${monitoringStats.online}/${monitoringStats.total} online` : 'Live-Status'}</div>
</Link>
<Link to="/patch-management" className="db-quick-card qc-patch">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v6M5 8l3 3M19 8l-3 3M3 14h18M5 20l4-4M19 20l-4-4M12 14v8"/></svg>
</div>
<div className="db-quick-title">Patches</div>
<div className="db-quick-sub">{patchStats ? `${patchPct} % konform` : 'Update-Rollout'}</div>
</Link>
<Link to="/security-reports" className="db-quick-card qc-sec">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
</div>
<div className="db-quick-title">Security</div>
<div className="db-quick-sub">{lastSecReport ? riskLabel : 'M365 Reports'}</div>
</Link>
<Link to="/onboarding" className="db-quick-card qc-on">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>
</div>
<div className="db-quick-title">Onboarding</div>
<div className="db-quick-sub">{((onboardingStats?.pending || 0) + (onboardingStats?.in_progress || 0))} offen</div>
</Link>
<Link to="/users" className="db-quick-card qc-usr">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>
</div>
<div className="db-quick-title">Benutzer</div>
<div className="db-quick-sub">Rollen & Zugänge</div>
</Link>
<Link to="/licenses" className="db-quick-card qc-lic">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M9 7h6M9 11h6M9 15h4"/></svg>
</div>
<div className="db-quick-title">Lizenzen</div>
<div className="db-quick-sub">{licenseStats?.expiringSoon > 0 ? `${licenseStats.expiringSoon} laufen ab` : 'Software & Ablauf'}</div>
</Link>
<a href="/share-tool.html" target="_blank" rel="noopener noreferrer" className="db-quick-card qc-share">
<div className="db-quick-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/></svg>
</div>
<div className="db-quick-title">Secure Share</div>
<div className="db-quick-sub">{shareCount ? `${shareCount.active} aktive Links` : 'Dateien teilen'}</div>
</a>
</>}
</div>
</div>
{/* ═══ KPI ÜBERSICHT ═══ */}
<div className="db-block">
<div className="db-section-head">
<div>
<h2>Übersicht.</h2>
<p>Die wichtigsten Zahlen aus Helpdesk, Assets und IT.</p>
</div>
</div>
<div className="db-kpi-grid">
<Link to="/fido-keys" style={{ textDecoration: 'none' }}>
<div className="db-kpi k-key">
<div className="db-kpi-head">
<div className="db-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15.5 7.5 3 3L22 7l-3-3M21 2l-9.6 9.6a5.5 5.5 0 1 1-2 2L19 4"/></svg>
</div>
<span className="db-kpi-trend flat"> 0</span>
</div>
<div className="db-kpi-value">{fidoStats?.total || 0}</div>
<div className="db-kpi-label">FIDO-Keys gesamt</div>
<div className="db-kpi-sub">{fidoStats?.active || 0} aktiv · {fidoStats?.assigned || 0} zugewiesen</div>
</div>
</Link>
{assetStats && (
<Link to="/assets" style={{ textDecoration: 'none' }}>
<div className="db-kpi k-asset">
<div className="db-kpi-head">
<div className="db-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>
</div>
<span className="db-kpi-trend up"> Bestand</span>
</div>
<div className="db-kpi-value">
{assetStats?.verfuegbar || 0}<small> / {assetStats?.total || 0}</small>
</div>
<div className="db-kpi-label">Verfügbare Assets</div>
<div className="db-kpi-sub">{assetStats?.zugewiesen || 0} zugewiesen · aktiv im Einsatz</div>
</div>
</Link>
)}
{ticketStats && (
<Link to="/tickets" style={{ textDecoration: 'none' }}>
<div className="db-kpi k-tix">
<div className="db-kpi-head">
<div className="db-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 10h18"/></svg>
</div>
<span className={`db-kpi-trend ${ticketStats.open > 0 ? 'down' : 'flat'}`}>{ticketStats.open > 0 ? `${ticketStats.open}` : '→ 0'}</span>
</div>
<div className="db-kpi-value">{ticketStats?.open || 0}</div>
<div className="db-kpi-label">Tickets offen</div>
<div className="db-kpi-sub">{ticketStats?.inProgress || 0} in Bearbeitung · {ticketStats?.critical || 0} kritisch</div>
</div>
</Link>
)}
{onboardingStats && (
<Link to="/onboarding" style={{ textDecoration: 'none' }}>
<div className="db-kpi k-on">
<div className="db-kpi-head">
<div className="db-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>
</div>
<span className="db-kpi-trend flat"> 0</span>
</div>
<div className="db-kpi-value">{(onboardingStats?.pending || 0) + (onboardingStats?.in_progress || 0)}</div>
<div className="db-kpi-label">Onboardings offen</div>
<div className="db-kpi-sub">{onboardingStats?.pending || 0} ausstehend · {onboardingStats?.in_progress || 0} aktiv</div>
</div>
</Link>
)}
</div>
</div>
{/* ═══ ASSET-TYPEN ═══ */}
{assetStats && (
<div className="db-block">
<div className="db-section-head">
<div>
<h2>Asset-Typen.</h2>
<p>Verteilung der Geräte im Bestand.</p>
</div>
<Link to="/assets" className="db-btn">
Alle anzeigen
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</Link>
</div>
<div className="db-breakdown-grid">
{[
{ cls: 'bd-note', label: 'Notebooks', value: assetStats?.byType?.notebook || 0, total: assetStats?.total || 1, icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M2 20h20"/></svg> },
{ cls: 'bd-mon', label: 'Monitore', value: assetStats?.byType?.monitor || 0, total: assetStats?.total || 1, icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> },
{ cls: 'bd-head', label: 'Headsets', value: assetStats?.byType?.headset || 0, total: assetStats?.total || 1, icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1v-7h3zM3 19a2 2 0 0 0 2 2h1v-7H3z"/></svg> },
{ cls: 'bd-other', label: 'Sonstiges', value: assetStats?.byType?.other || 0, total: assetStats?.total || 1, icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/></svg> },
].map(({ cls, label, value, total, icon }) => {
const pct = Math.round(value / total * 100);
return (
<div key={label} className={`db-breakdown ${cls}`}>
<div className="db-breakdown-top">
<div className="db-breakdown-icon">{icon}</div>
<div className="db-breakdown-name">{label}</div>
<div className="db-breakdown-pct">{pct} %</div>
</div>
<div className="db-breakdown-num">{value}</div>
<div className="db-breakdown-bar"><span style={{ width: `${pct}%` }} /></div>
</div>
);
})}
</div>
</div>
)}
{/* ═══ SYSTEM & SICHERHEIT ═══ */}
{isAdmin() && (monitoringStats || patchStats || lastSecReport || shareCount !== null) && (
<div className="db-block">
<div className="db-section-head">
<div>
<h2>System & Sicherheit.</h2>
<p>Status aus Monitoring, Patch-Management, Defender und Secure Share.</p>
</div>
</div>
<div className="db-info-grid">
{/* Feature: Ankündigungen */}
<div className="db-info-card db-feature">
<div className="db-info-eyebrow">
<span className="db-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5zM19 12c0-2.5-1.5-5-4-6M22 12c0-4.5-3-8-7-9"/></svg>
</span>
Ankündigungen
</div>
<h3 className="db-info-title">Was gerade läuft.</h3>
<p className="db-info-sub">Aktuelle Mitteilungen und Roll-Outs für dein IT-Team.</p>
{announcements.length > 0 ? (
<div className="db-announce-list">
{announcements.map((ann, i) => {
const initials = ((ann.created_by_name || ann.username || '?').split(' ').map(w => w[0]).join('').toUpperCase()).slice(0, 2);
const avClass = i === 0 ? 'db-av' : i === 1 ? 'db-av s2' : 'db-av s3';
const when = (() => {
const d = new Date(ann.created_at);
const diff = Date.now() - d.getTime();
if (diff < 3600000) return `vor ${Math.round(diff / 60000)} Min`;
if (diff < 86400000) return `vor ${Math.round(diff / 3600000)} h`;
if (diff < 172800000) return 'gestern';
return `vor ${Math.round(diff / 86400000)} Tagen`;
})();
return (
<div key={ann.id} className="db-announce">
<div className={avClass}>{initials}</div>
<div>
<h4>{ann.title}</h4>
<p>{ann.message?.slice(0, 100)}{ann.message?.length > 100 ? '…' : ''}</p>
<div className="db-when">{when} · {ann.created_by_name || ann.username || 'System'}</div>
</div>
</div>
);
})}
</div>
) : (
<div className="db-empty-ann">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 32, height: 32, opacity: 0.35 }}>
<path d="M11 5L6 9H2v6h4l5 4V5zM19 12c0-2.5-1.5-5-4-6"/>
</svg>
Keine aktiven Ankündigungen
</div>
)}
<Link to="/announcements" className="db-feat-cta">
Alle Ankündigungen
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</Link>
</div>
{/* Windows Agents */}
{monitoringStats && (
<Link to="/monitoring" style={{ textDecoration: 'none' }}>
<div className="db-info-card">
<div className="db-info-eyebrow">
<span className="db-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 5h8v8H3zM13 5h8v8h-8zM3 15h8v6H3zM13 15h8v6h-8z"/></svg>
</span>
Windows-Agents
</div>
<h3 className="db-info-title">
{monitoringStats.offline === 0 ? 'Alle online.' : `${monitoringStats.offline} offline.`}
</h3>
<div className="db-agents-stat">
<span className="num">{monitoringStats.online}</span>
<span className="total">/ {monitoringStats.total}</span>
</div>
<div className="db-agents-bar">
<span style={{ width: `${agentOnlinePct}%`, background: '#34c759' }} />
<span style={{ width: `${100 - agentOnlinePct}%`, background: '#ef4444' }} />
</div>
<div className="db-agents-legend">
<div><i style={{ background: '#34c759' }} />{monitoringStats.online} online</div>
<div><i style={{ background: '#ef4444' }} />{monitoringStats.offline} offline</div>
{monitoringStats.pendingUpdates > 0 && <div><i style={{ background: '#f59e0b' }} />{monitoringStats.pendingUpdates} Updates</div>}
</div>
</div>
</Link>
)}
{/* Patch Compliance Ring */}
{patchStats && (
<Link to="/patch-management" style={{ textDecoration: 'none' }}>
<div className="db-info-card">
<div className="db-info-eyebrow">
<span className="db-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v6M5 8l3 3M19 8l-3 3M3 14h18"/></svg>
</span>
Patch-Compliance
</div>
<h3 className="db-info-title">{patchPct} % konform.</h3>
<div className="db-ring-wrap">
<div className="db-ring">
<svg viewBox="0 0 100 100">
<circle className="bg" cx="50" cy="50" r="42" fill="none" stroke="var(--border-color)" strokeWidth="10"/>
<circle className="fg" cx="50" cy="50" r="42" fill="none" stroke="var(--cereda-primary)" strokeWidth="10"
strokeDasharray={circumference.toFixed(1)} strokeDashoffset={dashoffset.toFixed(1)}
strokeLinecap="round"/>
</svg>
<div className="db-ring-pct">{patchPct}%</div>
</div>
<div className="db-ring-stats">
<div><b>{patchStats.compliant || 0}</b> aktuell</div>
<div><b>{patchStats.warnings || 0}</b> Warnungen</div>
<div><b>{patchStats.offline || 0}</b> offline</div>
{patchStats.total_pending_updates > 0 && <div><b>{patchStats.total_pending_updates}</b> Updates aust.</div>}
</div>
</div>
</div>
</Link>
)}
{/* Security Report */}
{lastSecReport && (
<Link to="/security-reports" style={{ textDecoration: 'none' }}>
<div className="db-info-card">
<div className="db-info-eyebrow">
<span className="db-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</span>
Security-Report
</div>
<h3 className="db-info-title">{riskLabel}.</h3>
<span className={`db-risk-badge-wrap ${riskClass}`}>
<span className="db-rdot" />
{lastSecReport.report_period || 'Letzter Report'} · {new Date(lastSecReport.created_at).toLocaleDateString('de-DE')}
</span>
{lastSecReport.analysis?.login_stats && (
<div className="db-risk-stats">
<div className="db-risk-stat">
<div className={`rv ${(lastSecReport.analysis.login_stats.failed || 0) > 500 ? 'red' : ''}`}>
{(lastSecReport.analysis.login_stats.failed || 0).toLocaleString('de-DE')}
</div>
<div className="rl">Login-Fehler</div>
</div>
<div className="db-risk-stat">
<div className="rv">{(lastSecReport.analysis.login_stats.success_rate || 0).toFixed(1)}%</div>
<div className="rl">Erfolgsrate</div>
</div>
{lastSecReport.analysis.non_european_logins?.length > 0 && (
<div className="db-risk-stat">
<div className="rv amber">{lastSecReport.analysis.non_european_logins.length}</div>
<div className="rl">Länder außerh. EU</div>
</div>
)}
</div>
)}
</div>
</Link>
)}
{/* Secure Share */}
{shareCount !== null && (
<a href="/share-tool.html" target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }}>
<div className="db-info-card">
<div className="db-info-eyebrow">
<span className="db-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 1 0-7.07-7.07L11 4.5M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07L13 19.5"/></svg>
</span>
Secure Share
</div>
<h3 className="db-info-title">Aktive Links.</h3>
<div className="db-share-stat">{shareCount.active} <span>Links</span></div>
<div className="db-share-list">
<div><span className="db-sdot" style={{ background: '#34c759' }} />{shareCount.active} aktiv</div>
{shareCount.expiringSoon > 0 && <div><span className="db-sdot" style={{ background: '#f59e0b' }} />{shareCount.expiringSoon} ablaufend 7 T</div>}
<div><span className="db-sdot" style={{ background: 'var(--text-muted)' }} />{shareCount.total - shareCount.active} abgelaufen</div>
</div>
</div>
</a>
)}
</div>
</div>
)}
{/* ═══ HELPDESK METRIKEN ═══ */}
{ticketMetrics && (
<div className="db-block">
<div className="db-section-head">
<div>
<h2>Helpdesk-Leistung.</h2>
<p>Lösungszeiten nach Priorität und Top-Bearbeiter.</p>
</div>
</div>
<div className="db-two-col">
<div className="db-card">
<div className="db-card-head">
<h3 className="db-card-title">Ø Lösungszeit</h3>
<p className="db-card-sub">Letzte 30 Tage · alle Tickets</p>
</div>
<div className="db-metrics-grid">
{(ticketMetrics.avgResolution || []).slice(0, 3).map((r, i) => {
const cls = ['m-hi', 'm-mid', 'm-low'][i] || 'm-low';
const val = r.avg_hours < 1 ? '<1' : r.avg_hours < 24 ? r.avg_hours : Math.round(r.avg_hours / 24);
const unit = r.avg_hours < 24 ? 'h' : 'T';
return (
<div key={r.priority} className={`db-metric ${cls}`}>
<div className="db-metric-prio">
<span className="db-mp" />
{r.priority}
</div>
<div className="db-metric-value">{val}<span className="u">{unit}</span></div>
<div className="db-metric-label">Ø Lösungszeit</div>
</div>
);
})}
{ticketMetrics.slaBreaches?.length > 0 && (
<div className="db-metric m-sla">
<div className="db-metric-prio">
<span className="db-mp" />
SLA
</div>
<div className="db-metric-value">{ticketMetrics.slaBreaches.reduce((s, b) => s + b.count, 0)}</div>
<div className="db-metric-label">Verletzungen offen</div>
</div>
)}
</div>
</div>
{ticketMetrics.topAssignees?.length > 0 && (
<div className="db-card">
<div className="db-card-head">
<h3 className="db-card-title">Top-Bearbeiter</h3>
<p className="db-card-sub">Gelöste Tickets · 30 Tage</p>
</div>
{ticketMetrics.topAssignees.slice(0, 4).map((a, i) => {
const initial = (a.first_name?.[0] || a.username[0]).toUpperCase();
const rankCls = i === 0 ? 'gold' : i === 1 ? 'silver' : '';
return (
<div key={a.username} className="db-performer">
<div className="db-perf-av" style={{ background: getAvatarColor(a.username) }}>{initial}</div>
<div>
<div className="db-perf-name">{a.first_name || a.username}</div>
<div className="db-perf-stats">{a.closed} Tickets gelöst · {a.total} gesamt</div>
</div>
<div className={`db-perf-rank ${rankCls}`}>#{i + 1}</div>
</div>
);
})}
</div>
)}
</div>
</div>
)}
{/* ═══ LETZTE AKTIVITÄTEN ═══ */}
{isAdmin() && recentLogs.length > 0 && (
<div className="db-block" style={{ marginBottom: 0 }}>
<div className="db-section-head">
<div>
<h2>Letzte Aktivitäten.</h2>
<p>Alle Schreibvorgänge im Audit-Log.</p>
</div>
</div>
<div className="db-table-card">
<div className="db-table-head">
<div className="db-filter-pills">
{['Alle', 'Assets', 'Benutzer', 'Onboarding', 'Tickets'].map(f => (
<button key={f} className={logFilter === f ? 'on' : ''} onClick={() => setLogFilter(f)}>{f}</button>
))}
</div>
</div>
<table className="db-table">
<thead>
<tr>
<th style={{ width: 220 }}>Benutzer</th>
<th style={{ width: 240 }}>Aktion</th>
<th>Typ</th>
<th style={{ width: 160 }}>Zeitpunkt</th>
</tr>
</thead>
<tbody>
{filteredLogs.map(log => (
<tr key={log.id}>
<td>
<div className="db-user-cell">
<div className="db-av-sm" style={{ background: getAvatarColor(log.username) }}>
{(log.username || '?')[0].toUpperCase()}
</div>
<span>{log.username}</span>
</div>
</td>
<td>
<span className={`db-action-pill ${actionPillClass(log.action)}`}>
{actionPillLabel(log.action)}
</span>
</td>
<td><span className="db-type-pill">{log.entity_type}</span></td>
<td className="db-time-cell">{new Date(log.created_at).toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const DefectReportPage = () => {
const [serial, setSerial] = useState('');
const [assetName, setAssetName] = useState('');
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
description: '',
requester_name: '',
requester_email: '',
});
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sn = params.get('serial') || '';
setSerial(sn);
if (sn) {
setFormData(f => ({ ...f, title: `Defektmeldung: ${sn}` }));
axios.get(`/api/assets/serial/${encodeURIComponent(sn)}`)
.then(r => {
const a = r.data?.data;
if (a?.name) {
setAssetName(a.name);
setFormData(f => ({ ...f, title: `Defektmeldung: ${a.name}` }));
}
})
.catch(() => {});
}
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await axios.post('/api/tickets/public', {
...formData,
serial_number: serial,
category: 'Hardware',
priority: 'mittel',
});
setSubmitted(true);
} catch {
alert('Fehler beim Senden. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
};
const inputStyle = {
width: '100%', padding: '10px 12px',
border: '1px solid #d1d5db', borderRadius: '8px',
fontSize: '0.9375rem', boxSizing: 'border-box',
outline: 'none', fontFamily: 'inherit',
};
const labelStyle = {
display: 'block', fontSize: '0.875rem',
fontWeight: 600, color: '#374151', marginBottom: '6px',
};
if (submitted) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f8fafc', padding: '20px' }}>
<div style={{ textAlign: 'center', maxWidth: '420px' }}>
<div style={{ fontSize: '4rem', marginBottom: '16px' }}></div>
<h1 style={{ color: '#1a1a2e', fontSize: '1.75rem', margin: '0 0 12px' }}>Meldung eingegangen!</h1>
<p style={{ color: '#6b7280', lineHeight: 1.6 }}>
Ihr IT-Support hat die Defektmeldung erhalten und wird sich schnellstmöglich um das Problem kümmern.
</p>
<p style={{ color: '#9ca3af', fontSize: '0.875rem', marginTop: '24px' }}>
Sie können dieses Fenster jetzt schließen.
</p>
</div>
</div>
);
}
return (
<div style={{ minHeight: '100vh', background: '#f8fafc', padding: '20px', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '60px' }}>
<div style={{ width: '100%', maxWidth: '520px' }}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '8px' }}>🔧</div>
<h1 style={{ color: '#1a1a2e', fontSize: '1.5rem', margin: '0 0 8px', fontFamily: 'sans-serif' }}>Defektmeldung</h1>
{assetName && (
<p style={{ color: '#6b7280', margin: 0, fontFamily: 'sans-serif' }}>
Gerät: <strong>{assetName}</strong>
</p>
)}
{serial && !assetName && (
<p style={{ color: '#9ca3af', margin: 0, fontSize: '0.875rem', fontFamily: 'sans-serif' }}>
Seriennummer: {serial}
</p>
)}
</div>
{/* Form Card */}
<div style={{ background: 'white', borderRadius: '12px', padding: '28px', boxShadow: '0 1px 4px rgba(0,0,0,0.1)', fontFamily: 'sans-serif' }}>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={labelStyle}>Problem-Beschreibung *</label>
<input
type="text"
required
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="Kurze Beschreibung des Defekts"
style={inputStyle}
autoFocus
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={labelStyle}>Details (optional)</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Was ist genau passiert? Wann? Fehlermeldungen?"
rows={4}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '24px' }}>
<div>
<label style={labelStyle}>Ihr Name</label>
<input
type="text"
value={formData.requester_name}
onChange={e => setFormData({ ...formData, requester_name: e.target.value })}
placeholder="Max Mustermann"
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>E-Mail (optional)</label>
<input
type="email"
value={formData.requester_email}
onChange={e => setFormData({ ...formData, requester_email: e.target.value })}
placeholder="max@beispiel.de"
style={inputStyle}
/>
</div>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%', padding: '13px',
background: loading ? '#9ca3af' : '#0d9488',
color: 'white', border: 'none', borderRadius: '8px',
fontSize: '1rem', fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
fontFamily: 'inherit',
}}
>
{loading ? 'Wird gesendet…' : '📨 Defekt melden'}
</button>
</form>
</div>
<p style={{ textAlign: 'center', color: '#9ca3af', fontSize: '0.75rem', marginTop: '20px', fontFamily: 'sans-serif' }}>
Powered by IT Nexus · Cereda Systems GmbH
</p>
</div>
</div>
);
};
export default DefectReportPage;

View File

@@ -0,0 +1,211 @@
import React, { useState, useEffect, useCallback } from 'react';
import externalAlertService from '../services/externalAlertService';
import { toast } from 'react-toastify';
const SC = { ok: '#34d399', warn: '#f59e0b', crit: '#ef4444', unknown: '#6b7280' };
const timeAgo = (iso) => {
if (!iso) return '';
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min.`;
const h = Math.floor(m / 60);
if (h < 24) return `vor ${h} Std.`;
return `vor ${Math.floor(h / 24)} Tag(en)`;
};
const sevColor = (s) => s === 'CRIT' ? SC.crit : s === 'WARN' ? SC.warn : s === 'OK' ? SC.ok : SC.unknown;
const DefenderPage = () => {
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(true);
const [hideAcknowledged, setHideAcknowledged] = useState(true);
const [expandedAlerts, setExpandedAlerts] = useState({});
const loadAlerts = useCallback(async () => {
try {
const all = await externalAlertService.getAll();
setAlerts(all.filter(a => a.source === 'mdo'));
} catch {
toast.error('Fehler beim Laden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAlerts();
const r = setInterval(loadAlerts, 60000);
return () => clearInterval(r);
}, [loadAlerts]);
const handleAcknowledge = async (id) => {
try { await externalAlertService.acknowledge(id); loadAlerts(); toast.success('Quittiert'); }
catch { toast.error('Fehler'); }
};
const handleCreateTicket = async (id) => {
try { await externalAlertService.createTicket(id); loadAlerts(); toast.success('Ticket erstellt'); }
catch { toast.error('Fehler beim Erstellen'); }
};
const handleRemove = async (id) => {
if (!window.confirm('Alert wirklich löschen?')) return;
try { await externalAlertService.remove(id); loadAlerts(); toast.success('Gelöscht'); }
catch { toast.error('Fehler'); }
};
const toggleExpand = (id) => setExpandedAlerts(p => ({ ...p, [id]: !p[id] }));
const parseAi = (alert) => { try { return alert.ai_analysis ? (typeof alert.ai_analysis === 'string' ? JSON.parse(alert.ai_analysis) : alert.ai_analysis) : null; } catch { return null; } };
const parseRaw = (alert) => { try { return alert.raw_body ? (typeof alert.raw_body === 'string' ? JSON.parse(alert.raw_body) : alert.raw_body) : null; } catch { return null; } };
const unacked = alerts.filter(a => !a.acknowledged).length;
const hiddenCount = alerts.filter(a => a.acknowledged).length;
const visible = hideAcknowledged ? alerts.filter(a => !a.acknowledged) : alerts;
const labelStyle = { fontSize: 11, color: 'var(--text-muted)', minWidth: 150, flexShrink: 0 };
const valStyle = { fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' };
const row = (label, val) => val ? (
<div style={{ display: 'flex', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--border-color)' }}>
<span style={labelStyle}>{label}</span>
<span style={valStyle}>{val}</span>
</div>
) : null;
const renderCard = (alert) => {
const color = sevColor(alert.severity);
const expanded = expandedAlerts[alert.id];
const ai = parseAi(alert);
const raw = parseRaw(alert);
return (
<div key={alert.id} style={{
background: 'var(--bg-secondary)',
border: `1px solid ${alert.acknowledged ? 'var(--border-color)' : color + '44'}`,
borderLeft: `4px solid ${color}`,
borderRadius: 10,
padding: '14px 16px',
opacity: alert.acknowledged ? 0.65 : 1,
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 6, background: color + '22', color, border: `1px solid ${color}44`, letterSpacing: 1 }}>
{alert.severity}
</span>
{alert.acknowledged && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: 'var(--bg-tertiary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)' }}> Quittiert</span>}
{alert.ticket_id && <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 6, background: SC.ok + '22', color: SC.ok, border: `1px solid ${SC.ok}44` }}>🎫 Ticket #{alert.ticket_id}</span>}
</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 4 }}>{alert.message}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<span>👤 {alert.device || '(kein Benutzer)'}</span>
{alert.service && <span>📂 {alert.service}</span>}
{alert.state_time && <span>🕐 {new Date(alert.state_time).toLocaleString('de-DE')}</span>}
<span>Eingang: {timeAgo(alert.created_at)}</span>
</div>
</div>
{/* Buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
{!alert.ticket_id && (
<button onClick={() => handleCreateTicket(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: `1px solid var(--accent)`, background: 'var(--accent)', color: '#fff', fontSize: 12, cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}>🎫 Ticket</button>
)}
{!alert.acknowledged && (
<button onClick={() => handleAcknowledge(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap' }}> Quittieren</button>
)}
<button onClick={() => handleRemove(alert.id)} style={{ padding: '5px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>🗑</button>
</div>
</div>
{/* Metadata */}
{raw && (
<div style={{ marginTop: 12, padding: '10px 12px', background: 'var(--bg-primary)', borderRadius: 8 }}>
{row('Kategorie', raw.category)}
{row('Bedrohungsfamilie', raw.threatFamilyName)}
{row('Angriffsvektor', ai?.attack_vector || (raw.attackTechniques || [])[0])}
{row('Bedrohungstyp', ai?.threat_type)}
{row('Status (Defender)', alert.state_transition)}
</div>
)}
{/* KI-Toggle */}
{(ai?.what || ai?.recommendation) && (
<button onClick={() => toggleExpand(alert.id)} style={{ marginTop: 10, padding: '4px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>
{expanded ? '▲ Weniger' : '▼ KI-Analyse anzeigen'}
</button>
)}
{!ai && <div style={{ marginTop: 8, fontSize: 11, color: 'var(--text-muted)' }}> KI-Analyse ausstehend</div>}
{/* KI-Detail */}
{expanded && ai && (
<div style={{ marginTop: 12, padding: '12px', background: 'var(--bg-primary)', borderRadius: 8 }}>
{ai.what && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 4 }}>Was ist passiert?</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{ai.what}</div>
</div>
)}
{ai.recommendation && (
<div style={{ display: 'flex', gap: 5, padding: '8px 10px', borderRadius: 7, background: ai.action_needed ? `${SC.warn}12` : `${SC.ok}12`, border: `1px solid ${ai.action_needed ? SC.warn : SC.ok}30` }}>
<span style={{ flexShrink: 0 }}>{ai.action_needed ? '⚠️' : '✅'}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5 }}>{ai.recommendation}</span>
</div>
)}
</div>
)}
</div>
);
};
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Laden</div>;
return (
<div style={{ padding: 24 }}>
{/* Titel */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>🛡 Microsoft Defender</h1>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>Microsoft Defender for Office 365 Security Alerts</div>
</div>
<button onClick={loadAlerts} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer' }}> Aktualisieren</button>
</div>
{/* Stats */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
{[
{ label: 'Gesamt', value: alerts.length, color: 'var(--text-muted)' },
{ label: 'Offen', value: unacked, color: unacked > 0 ? SC.crit : SC.ok },
{ label: 'Kritisch', value: alerts.filter(a => a.severity === 'CRIT' && !a.acknowledged).length, color: SC.crit },
{ label: 'Warnung', value: alerts.filter(a => a.severity === 'WARN' && !a.acknowledged).length, color: SC.warn },
].map(stat => (
<div key={stat.label} style={{ padding: '10px 18px', borderRadius: 10, background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', textAlign: 'center', minWidth: 80 }}>
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{stat.label}</div>
</div>
))}
</div>
{/* Filter */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{alerts.length} Alert{alerts.length !== 1 ? 's' : ''} · {unacked} nicht quittiert</span>
{hiddenCount > 0 && (
<button onClick={() => setHideAcknowledged(h => !h)} style={{ padding: '3px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: hideAcknowledged ? 'var(--accent)' : 'none', color: hideAcknowledged ? '#fff' : 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>
{hideAcknowledged ? `✓ Quittierte ausgeblendet (${hiddenCount})` : `Quittierte ausblenden (${hiddenCount})`}
</button>
)}
</div>
{/* Liste */}
{visible.length === 0 ? (
<div style={{ textAlign: 'center', padding: 80, color: 'var(--text-muted)', fontSize: 14 }}>
Keine aktiven Microsoft Defender Alerts
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{visible.map(renderCard)}
</div>
)}
</div>
);
};
export default DefenderPage;

View File

@@ -0,0 +1,245 @@
import React, { useState, useEffect, useCallback } from 'react';
import api from '../services/api';
const fmtMB = b => b ? (b / 1024 / 1024).toFixed(0) + ' MB' : '—';
const fmtUptime = s => {
if (!s) return '—';
const now = Math.floor(Date.now() / 1000);
const sec = now - s;
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
};
const STATE_COLOR = { running: '#10B981', exited: '#EF4444', paused: '#F59E0B', created: '#6B7280', restarting: '#3B82F6' };
const STATE_LABEL = { running: 'Running', exited: 'Stopped', paused: 'Paused', created: 'Created', restarting: 'Restarting' };
function StatBox({ label, value, color }) {
return (
<div style={{ background: 'var(--bg-secondary)', borderRadius: 10, padding: '12px 16px', textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 800, color: color || 'var(--text-primary)', fontFamily: 'monospace' }}>{value}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{label}</div>
</div>
);
}
function UsageBar({ pct, label }) {
const color = pct >= 85 ? '#EF4444' : pct >= 60 ? '#F59E0B' : '#10B981';
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 3 }}>
<span style={{ color: 'var(--text-muted)' }}>{label}</span>
<span style={{ color, fontWeight: 700 }}>{pct}%</span>
</div>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 4, height: 6, overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, pct)}%`, height: '100%', background: color, borderRadius: 4, transition: 'width .4s' }} />
</div>
</div>
);
}
function LogModal({ container, onClose }) {
const [logs, setLogs] = useState('Lade Logs...');
const [lines, setLines] = useState(100);
const load = useCallback(async () => {
setLogs('Lade Logs...');
try {
const r = await api.get(`/docker/containers/${container.id}/logs?lines=${lines}`);
setLogs(r.data.logs || '(keine Logs)');
} catch (e) {
setLogs('Fehler: ' + (e.response?.data?.error || e.message));
}
}, [container.id, lines]);
useEffect(() => { load(); }, [load]);
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div style={{ background: 'var(--bg-card)', borderRadius: 14, width: '85vw', maxWidth: 900, maxHeight: '80vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxShadow: '0 25px 60px rgba(0,0,0,0.4)' }}>
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ fontWeight: 800, color: 'var(--text-primary)', fontSize: 15 }}>📋 Logs {container.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 10 }}>{container.image}</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select value={lines} onChange={e => setLines(Number(e.target.value))}
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
{[50, 100, 200, 500].map(n => <option key={n} value={n}>Letzte {n} Zeilen</option>)}
</select>
<button onClick={load} style={{ padding: '5px 12px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 12, color: 'var(--text-primary)' }}>🔄</button>
<button onClick={onClose} style={{ padding: '5px 12px', borderRadius: 6, border: 'none', background: '#EF4444', color: '#fff', cursor: 'pointer', fontSize: 12, fontWeight: 700 }}></button>
</div>
</div>
<pre style={{ margin: 0, padding: 16, overflowY: 'auto', flex: 1, background: '#0d1117', color: '#a5f3a5', fontSize: 12, fontFamily: 'Cascadia Mono, Consolas, monospace', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{logs}
</pre>
</div>
</div>
);
}
export default function DockerPage() {
const [containers, setContainers] = useState([]);
const [stats, setStats] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [logContainer, setLogContainer] = useState(null);
const [actionLoading, setActionLoading] = useState({});
const [lastUpdate, setLastUpdate] = useState(null);
const loadContainers = useCallback(async () => {
try {
const r = await api.get('/docker/containers');
setContainers(r.data);
setError(null);
setLastUpdate(new Date());
} catch (e) {
setError(e.response?.data?.error || 'Docker nicht erreichbar');
}
setLoading(false);
}, []);
const loadStats = useCallback(async () => {
try {
const r = await api.get('/docker/stats');
const m = {};
r.data.forEach(s => { m[s.id] = s; });
setStats(m);
} catch { }
}, []);
useEffect(() => {
loadContainers();
loadStats();
const t1 = setInterval(loadContainers, 15000);
const t2 = setInterval(loadStats, 10000);
return () => { clearInterval(t1); clearInterval(t2); };
}, [loadContainers, loadStats]);
const doAction = async (id, name, action) => {
const labels = { start: 'Starte', stop: 'Stoppe', restart: 'Restartet' };
if (action === 'stop' && !window.confirm(`${name} stoppen?`)) return;
setActionLoading(p => ({ ...p, [id + action]: true }));
try {
await api.post(`/docker/containers/${id}/${action}`);
setTimeout(loadContainers, 1500);
} catch (e) {
alert(`Fehler: ${e.response?.data?.error || e.message}`);
}
setActionLoading(p => ({ ...p, [id + action]: false }));
};
const running = containers.filter(c => c.state === 'running').length;
const stopped = containers.filter(c => c.state === 'exited').length;
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Verbinde mit Docker...</div>;
if (error) return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>🐳</div>
<div style={{ color: '#EF4444', fontWeight: 700, marginBottom: 8 }}>Docker nicht erreichbar</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{error}</div>
<button className="btn btn-secondary" style={{ marginTop: 16 }} onClick={loadContainers}>🔄 Erneut versuchen</button>
</div>
);
return (
<div style={{ padding: 28 }}>
{logContainer && <LogModal container={logContainer} onClose={() => setLogContainer(null)} />}
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 24, fontWeight: 800 }}>🐳 Docker Container</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Server: 192.168.0.194 · {containers.length} Container · Aktualisiert: {lastUpdate?.toLocaleTimeString('de-DE') || '—'}
</p>
</div>
<button className="btn btn-secondary" onClick={() => { loadContainers(); loadStats(); }}>🔄 Aktualisieren</button>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 10, marginBottom: 24 }}>
<StatBox label="Gesamt" value={containers.length} />
<StatBox label="Running" value={running} color="#10B981" />
<StatBox label="Stopped" value={stopped} color={stopped > 0 ? '#EF4444' : 'var(--text-muted)'} />
</div>
{/* Container Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))', gap: 14 }}>
{containers.map(c => {
const s = stats[c.id];
const color = STATE_COLOR[c.state] || '#6B7280';
const isRunning = c.state === 'running';
return (
<div key={c.id} style={{ background: 'var(--bg-card)', border: `1px solid var(--border-color)`, borderRadius: 12, overflow: 'hidden', borderTop: `3px solid ${color}` }}>
<div style={{ padding: '14px 18px' }}>
{/* Header row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 800, color: 'var(--text-primary)', fontSize: 15 }}>{c.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2, fontFamily: 'monospace' }}>{c.image}</div>
</div>
<span style={{ padding: '3px 10px', borderRadius: 20, fontSize: 11, fontWeight: 700, background: `${color}20`, color }}>
{STATE_LABEL[c.state] || c.state}
</span>
</div>
{/* Info row */}
<div style={{ display: 'flex', gap: 16, marginBottom: isRunning && s ? 12 : 0, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
ID: <span style={{ fontFamily: 'monospace', color: 'var(--text-secondary)' }}>{c.id}</span>
</span>
{isRunning && (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Laufzeit: <span style={{ color: 'var(--text-secondary)' }}>{fmtUptime(c.created)}</span>
</span>
)}
{c.ports.filter(p => p.PublicPort).length > 0 && (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Ports: <span style={{ color: 'var(--cereda-primary)', fontFamily: 'monospace' }}>
{c.ports.filter(p => p.PublicPort).map(p => p.PublicPort).join(', ')}
</span>
</span>
)}
</div>
{/* Stats bars */}
{isRunning && s && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
<UsageBar pct={s.cpuPct} label="CPU" />
<UsageBar pct={s.memPct} label={`RAM (${fmtMB(s.memUsed)})`} />
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border-color)', paddingTop: 10 }}>
{!isRunning && (
<button onClick={() => doAction(c.id, c.name, 'start')} disabled={actionLoading[c.id + 'start']}
style={{ padding: '5px 12px', borderRadius: 6, border: 'none', background: '#10B981', color: '#fff', cursor: 'pointer', fontSize: 12, fontWeight: 700, opacity: actionLoading[c.id + 'start'] ? 0.6 : 1 }}>
Start
</button>
)}
{isRunning && (
<button onClick={() => doAction(c.id, c.name, 'stop')} disabled={actionLoading[c.id + 'stop']}
style={{ padding: '5px 12px', borderRadius: 6, border: 'none', background: '#EF4444', color: '#fff', cursor: 'pointer', fontSize: 12, fontWeight: 700, opacity: actionLoading[c.id + 'stop'] ? 0.6 : 1 }}>
Stop
</button>
)}
<button onClick={() => doAction(c.id, c.name, 'restart')} disabled={actionLoading[c.id + 'restart']}
style={{ padding: '5px 12px', borderRadius: 6, border: 'none', background: '#F59E0B', color: '#fff', cursor: 'pointer', fontSize: 12, fontWeight: 700, opacity: actionLoading[c.id + 'restart'] ? 0.6 : 1 }}>
🔄 Restart
</button>
<button onClick={() => setLogContainer(c)}
style={{ padding: '5px 12px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 12 }}>
📋 Logs
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react';
import { Marked } from 'marked';
const marked = new Marked({ breaks: true, gfm: true });
export default function DocsPage() {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState('');
const [toc, setToc] = useState([]);
useEffect(() => {
fetch('/DOKUMENTATION.md')
.then(res => { if (!res.ok) throw new Error(); return res.text(); })
.then(text => {
setContent(text);
// Extract headings for TOC
const headings = [];
const lines = text.split('\n');
lines.forEach(line => {
const h2 = line.match(/^## (.+)/);
const h3 = line.match(/^### (.+)/);
if (h2) headings.push({ level: 2, text: h2[1], id: slugify(h2[1]) });
if (h3) headings.push({ level: 3, text: h3[1], id: slugify(h3[1]) });
});
setToc(headings);
})
.catch(() => setError('Dokumentation konnte nicht geladen werden.'))
.finally(() => setLoading(false));
}, []);
const slugify = (text) =>
text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').trim();
const getHtml = () => {
let text = content;
if (search.trim()) {
// Highlight search terms
const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
text = text.replace(regex, '**$1**');
}
// Add IDs to headings for anchor links
const html = marked.parse(text);
return html.replace(/<h([23])>(.*?)<\/h\1>/g, (_, level, heading) => {
const id = slugify(heading.replace(/<[^>]+>/g, ''));
return `<h${level} id="${id}">${heading}</h${level}>`;
});
};
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '60vh', color: 'var(--text-muted)' }}>
Dokumentation wird geladen...
</div>
);
if (error) return (
<div style={{ padding: '40px', color: 'var(--danger)', textAlign: 'center' }}>{error}</div>
);
return (
<div style={{ display: 'flex', maxWidth: '1200px', margin: '0 auto', padding: '32px 24px', gap: '32px' }}>
{/* Sidebar TOC */}
<aside style={{
width: '240px',
flexShrink: 0,
position: 'sticky',
top: '24px',
alignSelf: 'flex-start',
maxHeight: 'calc(100vh - 80px)',
overflowY: 'auto',
}}>
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-xl)',
padding: '16px',
}}>
<div style={{ fontSize: '11px', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '12px' }}>
Inhalt
</div>
{toc.map((item, i) => (
<a
key={i}
href={`#${item.id}`}
style={{
display: 'block',
padding: item.level === 2 ? '5px 0' : '3px 0 3px 12px',
fontSize: item.level === 2 ? '13px' : '12px',
fontWeight: item.level === 2 ? 600 : 400,
color: item.level === 2 ? 'var(--text-primary)' : 'var(--text-muted)',
textDecoration: 'none',
borderLeft: item.level === 3 ? '2px solid var(--border-color)' : 'none',
lineHeight: '1.4',
}}
onMouseEnter={e => e.currentTarget.style.color = 'var(--cereda-primary)'}
onMouseLeave={e => e.currentTarget.style.color = item.level === 2 ? 'var(--text-primary)' : 'var(--text-muted)'}
>
{item.text}
</a>
))}
</div>
</aside>
{/* Main content */}
<main style={{ flex: 1, minWidth: 0 }}>
{/* Header */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '22px', fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 6px', display: 'flex', alignItems: 'center', gap: '10px' }}>
📄 Dokumentation
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: 0 }}>
IT Nexus Vollständige Systemdokumentation
</p>
</div>
{/* Search */}
<div style={{ marginBottom: '24px' }}>
<input
type="text"
className="form-input"
placeholder="In Dokumentation suchen..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ maxWidth: '360px' }}
/>
</div>
{/* Markdown content */}
<div
className="docs-content"
dangerouslySetInnerHTML={{ __html: getHtml() }}
/>
</main>
</div>
);
}

View File

@@ -0,0 +1,665 @@
import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'react-toastify';
import entraService from '../services/entraService';
import { useAuth } from '../context/AuthContext';
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
const ROLE_RISK = {
'Global Administrator': 'kritisch',
'Privileged Role Administrator': 'kritisch',
'Security Administrator': 'hoch',
'Intune Administrator': 'hoch',
'Exchange Administrator': 'hoch',
'SharePoint Administrator': 'mittel',
'Teams Administrator': 'mittel',
'Helpdesk Administrator': 'mittel',
'User Administrator': 'mittel',
'Security Reader': 'niedrig',
'Reports Reader': 'niedrig',
'Directory Readers': 'niedrig',
};
function getRoleRisk(name) {
return ROLE_RISK[name] || 'niedrig';
}
const riskStyle = {
kritisch: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: '#ef4444' },
hoch: { bg: 'rgba(249,115,22,0.15)', text: '#f97316', border: '#f97316' },
mittel: { bg: 'rgba(245,158,11,0.15)', text: '#f59e0b', border: '#f59e0b' },
niedrig: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: '#22c55e' },
};
const glass = {
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
backdropFilter: 'blur(10px)',
};
function initials(name) {
return (name || '?').split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase();
}
// ─── Komponenten ─────────────────────────────────────────────────────────────
function StatCard({ icon, label, value, sub, color }) {
return (
<div style={{ ...glass, padding: '20px 24px', flex: 1, minWidth: 160 }}>
<div style={{ fontSize: 26, marginBottom: 6 }}>{icon}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: color || '#fff' }}>{value}</div>
<div style={{ fontSize: 13, color: '#94a3b8', fontWeight: 600, marginTop: 2 }}>{label}</div>
{sub && <div style={{ fontSize: 11, color: '#64748b', marginTop: 4 }}>{sub}</div>}
</div>
);
}
function Avatar({ name, size = 36 }) {
return (
<div style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.35, fontWeight: 700, color: '#fff',
}}>{initials(name)}</div>
);
}
function GroupChip({ name }) {
return (
<span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 12, marginRight: 4, marginBottom: 3,
background: 'rgba(99,102,241,0.15)', color: '#a5b4fc',
border: '1px solid rgba(99,102,241,0.3)', fontSize: 11,
}}>{name}</span>
);
}
// ─── User Detail Panel ────────────────────────────────────────────────────────
function UserDetailPanel({ user, onClose, onGroupRemove, onGroupAdd }) {
const [groups, setGroups] = useState([]);
const [loadingGroups, setLoadingGroups] = useState(true);
const [removingId, setRemovingId] = useState(null);
const [showGroupPicker, setShowGroupPicker] = useState(false);
const [allGroups, setAllGroups] = useState([]);
const [addingId, setAddingId] = useState(null);
useEffect(() => {
if (!user) return;
setLoadingGroups(true);
setGroups([]);
entraService.getUserGroups(user.id)
.then(setGroups)
.catch(() => toast.error('Gruppen konnten nicht geladen werden'))
.finally(() => setLoadingGroups(false));
}, [user]);
const handleRemove = async (groupId, groupName) => {
setRemovingId(groupId);
try {
await entraService.removeGroupMember(groupId, user.id);
setGroups(g => g.filter(x => x.id !== groupId));
toast.success(`Aus "${groupName}" entfernt`);
onGroupRemove?.();
} catch (e) {
toast.error(e.response?.data?.message || e.message);
} finally {
setRemovingId(null);
}
};
const openGroupPicker = async () => {
if (allGroups.length === 0) {
try { setAllGroups(await entraService.getGroups()); } catch { toast.error('Gruppen konnten nicht geladen werden'); }
}
setShowGroupPicker(true);
};
const handleAdd = async (group) => {
setAddingId(group.id);
try {
await entraService.addGroupMember(group.id, user.id);
setGroups(g => [...g, group]);
setShowGroupPicker(false);
toast.success(`Zu "${group.displayName}" hinzugefügt`);
onGroupAdd?.();
} catch (e) {
toast.error(e.response?.data?.message || e.message);
} finally {
setAddingId(null);
}
};
if (!user) return null;
const groupIds = new Set(groups.map(g => g.id));
const availableGroups = allGroups.filter(g => !groupIds.has(g.id));
return (
<div style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: 400, zIndex: 200,
background: 'rgba(10,15,30,0.97)', borderLeft: '1px solid rgba(255,255,255,0.1)',
padding: 28, overflowY: 'auto', backdropFilter: 'blur(20px)',
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<Avatar name={user.displayName} size={52} />
<div>
<div style={{ fontWeight: 700, fontSize: 16, color: '#f1f5f9' }}>{user.displayName}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{user.jobTitle || user.department || '—'}</div>
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: 20 }}></button>
</div>
{/* Status Badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
<span style={{
padding: '4px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
background: user.accountEnabled ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: user.accountEnabled ? '#22c55e' : '#ef4444',
border: `1px solid ${user.accountEnabled ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
}}>{user.accountEnabled ? '✓ Konto aktiv' : '✗ Deaktiviert'}</span>
{user.mfaRegistered !== null && (
<span style={{
padding: '4px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
background: user.mfaRegistered ? 'rgba(34,197,94,0.15)' : 'rgba(245,158,11,0.15)',
color: user.mfaRegistered ? '#22c55e' : '#f59e0b',
border: `1px solid ${user.mfaRegistered ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
}}>{user.mfaRegistered ? '🔐 MFA aktiv' : '⚠️ Kein MFA'}</span>
)}
</div>
{/* E-Mail */}
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 11, color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>E-Mail</div>
<div style={{ fontSize: 13, color: '#94a3b8' }}>{user.mail}</div>
</div>
{/* MFA Methoden */}
{user.mfaMethods?.length > 0 && (
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 11, color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>MFA-Methoden</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{user.mfaMethods.map(m => (
<span key={m} style={{
padding: '3px 10px', borderRadius: 8, fontSize: 11,
background: 'rgba(99,102,241,0.12)', color: '#a5b4fc',
border: '1px solid rgba(99,102,241,0.25)',
}}>{m}</span>
))}
</div>
</div>
)}
{/* Gruppen */}
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 11, color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
Gruppen {loadingGroups ? '…' : `(${groups.length})`}
</div>
{loadingGroups ? (
<div style={{ fontSize: 12, color: '#475569' }}>Wird geladen</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{groups.map(g => (
<div key={g.id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '7px 10px', borderRadius: 8,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)',
}}>
<span style={{ fontSize: 12, color: '#a5b4fc' }}>👥 {g.displayName}</span>
<button
onClick={() => handleRemove(g.id, g.displayName)}
disabled={removingId === g.id}
style={{ background: 'none', border: 'none', color: removingId === g.id ? '#475569' : '#ef4444', cursor: 'pointer', fontSize: 13 }}
>{removingId === g.id ? '…' : '✕'}</button>
</div>
))}
{groups.length === 0 && <div style={{ fontSize: 12, color: '#475569' }}>Keine Gruppen</div>}
</div>
)}
<button onClick={openGroupPicker} style={{
marginTop: 10, padding: '7px 14px', borderRadius: 8, fontSize: 12, width: '100%',
background: 'rgba(99,102,241,0.1)', border: '1px dashed rgba(99,102,241,0.3)',
color: '#a5b4fc', cursor: 'pointer',
}}>+ Gruppe hinzufügen</button>
{/* Group Picker */}
{showGroupPicker && (
<div style={{ marginTop: 10, ...glass, padding: 10, maxHeight: 200, overflowY: 'auto' }}>
{availableGroups.length === 0 && <div style={{ fontSize: 12, color: '#475569' }}>Keine weiteren Gruppen</div>}
{availableGroups.map(g => (
<button
key={g.id}
onClick={() => handleAdd(g)}
disabled={addingId === g.id}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '7px 10px', borderRadius: 6, marginBottom: 4,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
color: '#e2e8f0', cursor: 'pointer', fontSize: 12,
}}
>{addingId === g.id ? '…' : g.displayName}</button>
))}
</div>
)}
</div>
{/* Link zu Entra Portal */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.07)', paddingTop: 16 }}>
<a
href={`https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/${user.id}`}
target="_blank" rel="noopener noreferrer"
style={{
display: 'block', padding: '9px 14px', borderRadius: 8, fontSize: 13, textAlign: 'center',
background: 'rgba(99,102,241,0.12)', border: '1px solid rgba(99,102,241,0.3)',
color: '#a5b4fc', textDecoration: 'none',
}}
>Im Entra-Portal öffnen </a>
</div>
</div>
);
}
// ─── Users Tab ────────────────────────────────────────────────────────────────
function UsersTab({ users, selectedUser, onUserClick }) {
const [search, setSearch] = useState('');
const [filterMfa, setFilterMfa] = useState('');
const filtered = users.filter(u => {
const q = search.toLowerCase();
const matchSearch = !q || u.displayName?.toLowerCase().includes(q) || u.mail?.toLowerCase().includes(q) || u.department?.toLowerCase().includes(q);
const matchMfa = filterMfa === '' || (filterMfa === 'mfa' ? u.mfaRegistered : u.mfaRegistered === false);
return matchSearch && matchMfa;
});
return (
<div>
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Benutzer, E-Mail oder Abteilung suchen…"
style={{
flex: 1, padding: '9px 14px', borderRadius: 8, outline: 'none',
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)',
color: '#fff', fontSize: 13,
}}
/>
<select value={filterMfa} onChange={e => setFilterMfa(e.target.value)} style={{
padding: '9px 14px', borderRadius: 8, outline: 'none',
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)',
color: '#fff', fontSize: 13,
}}>
<option value="">Alle Benutzer</option>
<option value="mfa">MFA aktiv</option>
<option value="nomfa">Kein MFA</option>
</select>
</div>
<div style={{ ...glass, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
{['Benutzer', 'E-Mail / Stelle', 'Abteilung', 'Status', ''].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: 11, color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(u => (
<tr
key={u.id}
onClick={() => onUserClick(u)}
style={{
borderBottom: '1px solid rgba(255,255,255,0.05)', cursor: 'pointer',
background: selectedUser?.id === u.id ? 'rgba(99,102,241,0.1)' : 'transparent',
transition: 'background 0.15s',
}}
onMouseEnter={e => { if (selectedUser?.id !== u.id) e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }}
onMouseLeave={e => { e.currentTarget.style.background = selectedUser?.id === u.id ? 'rgba(99,102,241,0.1)' : 'transparent'; }}
>
<td style={{ padding: '11px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar name={u.displayName} size={34} />
<span style={{ fontWeight: 600, fontSize: 13, color: '#e2e8f0' }}>{u.displayName}</span>
</div>
</td>
<td style={{ padding: '11px 16px' }}>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{u.mail}</div>
{u.jobTitle && <div style={{ fontSize: 11, color: '#64748b', marginTop: 1 }}>{u.jobTitle}</div>}
</td>
<td style={{ padding: '11px 16px', fontSize: 12, color: '#64748b' }}>{u.department || '—'}</td>
<td style={{ padding: '11px 16px' }}>
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
<span style={{
padding: '2px 8px', borderRadius: 8, fontSize: 11, fontWeight: 600,
background: u.accountEnabled ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: u.accountEnabled ? '#22c55e' : '#ef4444',
border: `1px solid ${u.accountEnabled ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
}}>{u.accountEnabled ? 'Aktiv' : 'Deaktiviert'}</span>
{u.mfaRegistered !== null && !u.mfaRegistered && (
<span style={{
padding: '2px 8px', borderRadius: 8, fontSize: 11, fontWeight: 600,
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
border: '1px solid rgba(245,158,11,0.3)',
}}>Kein MFA</span>
)}
</div>
</td>
<td style={{ padding: '11px 16px' }}>
<button style={{
padding: '5px 12px', borderRadius: 6, fontSize: 12,
background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.3)',
color: '#a5b4fc', cursor: 'pointer',
}}>Details</button>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={5} style={{ padding: '24px 16px', textAlign: 'center', color: '#475569', fontSize: 13 }}>Keine Benutzer gefunden</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// ─── Groups Tab ───────────────────────────────────────────────────────────────
function GroupsTab({ groups }) {
const [expandedId, setExpandedId] = useState(null);
const [members, setMembers] = useState({});
const [loadingId, setLoadingId] = useState(null);
const toggle = async (group) => {
if (expandedId === group.id) { setExpandedId(null); return; }
setExpandedId(group.id);
if (members[group.id]) return;
setLoadingId(group.id);
try {
const m = await entraService.getGroupMembers(group.id);
setMembers(prev => ({ ...prev, [group.id]: m }));
} catch { toast.error('Mitglieder konnten nicht geladen werden'); }
finally { setLoadingId(null); }
};
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 14 }}>
{groups.map(g => {
const isOpen = expandedId === g.id;
const gMembers = members[g.id] || [];
return (
<div key={g.id} style={{ ...glass, overflow: 'hidden' }}>
<div
style={{ padding: '14px 18px', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
onClick={() => toggle(g)}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 16 }}>👥</span>
<span style={{ fontWeight: 700, fontSize: 14, color: '#e2e8f0' }}>{g.displayName}</span>
</div>
{g.description && <div style={{ fontSize: 11, color: '#64748b', marginTop: 3 }}>{g.description}</div>}
</div>
<span style={{ color: '#475569', fontSize: 12 }}>{isOpen ? '▲' : '▼'}</span>
</div>
{isOpen && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.07)', padding: '12px 18px' }}>
{loadingId === g.id ? (
<div style={{ fontSize: 12, color: '#475569' }}>Wird geladen</div>
) : gMembers.length === 0 ? (
<div style={{ fontSize: 12, color: '#475569' }}>Keine Mitglieder</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{gMembers.map(u => (
<div key={u.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar name={u.displayName} size={28} />
<div>
<div style={{ fontSize: 12, color: '#e2e8f0', fontWeight: 600 }}>{u.displayName}</div>
<div style={{ fontSize: 10, color: '#64748b' }}>{u.mail || u.userPrincipalName}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
// ─── Roles Tab ────────────────────────────────────────────────────────────────
function RolesTab({ roles }) {
const active = roles.filter(r => r.members?.length > 0);
const inactive = roles.filter(r => !r.members?.length);
const RoleCard = ({ role }) => {
const risk = getRoleRisk(role.displayName);
const s = riskStyle[risk];
return (
<div style={{ ...glass, padding: '16px 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: '#e2e8f0' }}>{role.displayName}</span>
<span style={{
padding: '2px 8px', borderRadius: 8, fontSize: 11, fontWeight: 700,
background: s.bg, color: s.text, border: `1px solid ${s.border}`,
}}>{risk.charAt(0).toUpperCase() + risk.slice(1)}</span>
</div>
{role.description && <div style={{ fontSize: 12, color: '#64748b', marginBottom: 10 }}>{role.description}</div>}
</div>
</div>
{role.members?.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{role.members.map(m => (
<div key={m.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 20,
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)',
}}>
<Avatar name={m.displayName} size={20} />
<span style={{ fontSize: 12, color: '#e2e8f0' }}>{m.displayName}</span>
</div>
))}
</div>
) : (
<span style={{ fontSize: 12, color: '#475569' }}>Keine Zuweisung</span>
)}
</div>
);
};
return (
<div>
{active.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12 }}>
Belegte Rollen ({active.length})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{active.map(r => <RoleCard key={r.id} role={r} />)}
</div>
</div>
)}
{inactive.length > 0 && (
<div>
<div style={{ fontSize: 12, color: '#475569', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12 }}>
Nicht belegt ({inactive.length})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{inactive.map(r => <RoleCard key={r.id} role={r} />)}
</div>
</div>
)}
</div>
);
}
// ─── Hauptseite ───────────────────────────────────────────────────────────────
export default function EntraPage() {
const { isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState('users');
const [users, setUsers] = useState([]);
const [groups, setGroups] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const loadUsers = useCallback(async () => {
try {
const data = await entraService.getUsers();
setUsers(data);
} catch (e) {
toast.error(`Benutzer: ${e.response?.data?.message || e.message}`);
}
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [u, g, r] = await Promise.allSettled([
entraService.getUsers(),
entraService.getGroups(),
entraService.getRoles(),
]);
if (u.status === 'fulfilled') setUsers(u.value);
else toast.error(`Benutzer: ${u.reason?.response?.data?.message || u.reason?.message}`);
if (g.status === 'fulfilled') setGroups(g.value);
else toast.warn(`Gruppen: ${g.reason?.response?.data?.message || g.reason?.message}`);
if (r.status === 'fulfilled') setRoles(r.value);
else toast.warn(`Rollen: ${r.reason?.response?.data?.message || r.reason?.message}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadAll(); }, [loadAll]);
const handleSync = async () => {
setSyncing(true);
try {
await loadAll();
toast.success('Daten aus Entra ID aktualisiert');
} finally {
setSyncing(false);
}
};
const noMfa = users.filter(u => u.mfaRegistered === false).length;
const inactive = users.filter(u => !u.accountEnabled).length;
const adminCount = roles.reduce((sum, r) => sum + (r.members?.length || 0), 0);
const tabs = [
{ key: 'users', label: 'Benutzer', icon: '👤', count: users.length },
{ key: 'groups', label: 'Gruppen', icon: '👥', count: groups.length },
{ key: 'roles', label: 'Admin-Rollen', icon: '🛡️', count: roles.filter(r => r.members?.length > 0).length },
];
return (
<div style={{ padding: '28px 32px', maxWidth: 1400, margin: '0 auto', color: '#e2e8f0' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
<div>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: '#f1f5f9', marginBottom: 4 }}>
Entra ID Rechteverwaltung
</h1>
<p style={{ margin: 0, color: '#64748b', fontSize: 13 }}>
Benutzer, Gruppen und Admin-Rollen aus Microsoft Entra ID
</p>
</div>
<button
onClick={handleSync}
disabled={syncing}
style={{
padding: '9px 18px', borderRadius: 8, fontSize: 13, fontWeight: 600,
background: syncing ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
color: syncing ? '#475569' : '#94a3b8', cursor: syncing ? 'default' : 'pointer',
}}
>{syncing ? '⏳ Wird geladen…' : '🔄 Aktualisieren'}</button>
</div>
{/* Stats */}
{!loading && (
<div style={{ display: 'flex', gap: 14, marginBottom: 28, flexWrap: 'wrap' }}>
<StatCard icon="👤" label="Benutzer gesamt" value={users.length} sub={inactive > 0 ? `${inactive} deaktiviert` : 'Alle aktiv'} />
<StatCard icon="👥" label="Gruppen" value={groups.length} sub="in Entra ID" />
<StatCard icon="🛡️" label="Admin-Rollenzuweisungen" value={adminCount} sub="Directory Roles" color={adminCount > 3 ? '#f97316' : '#e2e8f0'} />
<StatCard icon="⚠️" label="Ohne MFA" value={noMfa} sub="Handlungsbedarf" color={noMfa > 0 ? '#f59e0b' : '#22c55e'} />
{users.length > 0 && (
<StatCard icon="🔐" label="MFA-Abdeckung"
value={`${Math.round(((users.length - noMfa) / users.length) * 100)}%`}
sub="der Benutzer" color={noMfa === 0 ? '#22c55e' : '#f59e0b'} />
)}
</div>
)}
{/* Lade-Indikator */}
{loading && (
<div style={{ ...glass, padding: '40px', textAlign: 'center', marginBottom: 24, color: '#64748b', fontSize: 14 }}>
Daten werden aus Entra ID geladen
</div>
)}
{/* Tabs */}
{!loading && (
<>
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
{tabs.map(t => (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
style={{
padding: '10px 20px', borderRadius: '8px 8px 0 0', fontSize: 13, fontWeight: 600,
background: activeTab === t.key ? 'rgba(99,102,241,0.15)' : 'transparent',
border: activeTab === t.key ? '1px solid rgba(99,102,241,0.3)' : '1px solid transparent',
borderBottom: activeTab === t.key ? '1px solid rgba(10,15,30,1)' : '1px solid transparent',
color: activeTab === t.key ? '#a5b4fc' : '#64748b',
cursor: 'pointer', marginBottom: -1,
display: 'flex', alignItems: 'center', gap: 6,
}}
>
{t.icon} {t.label}
<span style={{
padding: '1px 7px', borderRadius: 10, fontSize: 11,
background: activeTab === t.key ? 'rgba(99,102,241,0.3)' : 'rgba(255,255,255,0.06)',
color: activeTab === t.key ? '#c7d2fe' : '#475569',
}}>{t.count}</span>
</button>
))}
</div>
<div style={{ marginRight: selectedUser ? 416 : 0, transition: 'margin-right 0.2s' }}>
{activeTab === 'users' && <UsersTab users={users} selectedUser={selectedUser} onUserClick={setSelectedUser} />}
{activeTab === 'groups' && <GroupsTab groups={groups} />}
{activeTab === 'roles' && <RolesTab roles={roles} />}
</div>
</>
)}
{/* Detail Panel */}
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUser(null)}
onGroupRemove={loadUsers}
onGroupAdd={loadUsers}
/>
</div>
);
}

View File

@@ -0,0 +1,352 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import fidoKeyService from '../services/fidoKeyService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const FidoKeysPage = () => {
const { canModifyFidoKeys, isAdmin } = useAuth();
const [keys, setKeys] = useState([]);
const [filteredKeys, setFilteredKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [showModal, setShowModal] = useState(false);
const [editingKey, setEditingKey] = useState(null);
const [formData, setFormData] = useState({
name: '',
serial_number: '',
status: 'aktiv',
description: '',
});
useEffect(() => {
loadKeys();
}, []);
useEffect(() => {
filterKeys();
}, [keys, searchTerm, statusFilter]);
const loadKeys = async () => {
try {
const data = await fidoKeyService.getAll();
setKeys(data);
} catch (error) {
toast.error('Fehler beim Laden der FIDO-Keys');
} finally {
setLoading(false);
}
};
const filterKeys = () => {
let filtered = [...keys];
// Search filter
if (searchTerm) {
filtered = filtered.filter(
(key) =>
key.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
key.serial_number.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Status filter
if (statusFilter !== 'all') {
filtered = filtered.filter((key) => key.status === statusFilter);
}
setFilteredKeys(filtered);
};
const handleCreate = () => {
setEditingKey(null);
setFormData({
name: '',
serial_number: '',
status: 'aktiv',
description: '',
});
setShowModal(true);
};
const handleEdit = (key) => {
setEditingKey(key);
setFormData({
name: key.name,
serial_number: key.serial_number,
status: key.status,
description: key.description || '',
});
setShowModal(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingKey) {
await fidoKeyService.update(editingKey.id, formData);
toast.success('FIDO-Key erfolgreich aktualisiert');
} else {
await fidoKeyService.create(formData);
toast.success('FIDO-Key erfolgreich erstellt');
}
setShowModal(false);
loadKeys();
} catch (error) {
toast.error(error.message || 'Fehler beim Speichern');
}
};
const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie diesen FIDO-Key wirklich löschen?')) {
return;
}
try {
await fidoKeyService.delete(id);
toast.success('FIDO-Key erfolgreich gelöscht');
loadKeys();
} catch (error) {
toast.error(error.message || 'Fehler beim Löschen');
}
};
const handleStatusToggle = async (key) => {
const newStatus = key.status === 'aktiv' ? 'inaktiv' : 'aktiv';
try {
await fidoKeyService.updateStatus(key.id, newStatus);
toast.success('Status erfolgreich geändert');
loadKeys();
} catch (error) {
toast.error(error.message || 'Fehler beim Ändern des Status');
}
};
if (loading) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
return (
<div className="main-content">
<div className="container">
<div className="flex justify-between items-center mb-3">
<h1>FIDO-Keys</h1>
{canModifyFidoKeys() && (
<button onClick={handleCreate} className="btn btn-primary">
+ Neuer FIDO-Key
</button>
)}
</div>
{/* Search and Filter */}
<div className="search-container">
<input
type="text"
className="search-input"
placeholder="Suche nach Name oder Seriennummer..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="filter-buttons">
<button
className={`btn ${
statusFilter === 'all' ? 'btn-primary' : 'btn-secondary'
} btn-small`}
onClick={() => setStatusFilter('all')}
>
Alle
</button>
<button
className={`btn ${
statusFilter === 'aktiv' ? 'btn-success' : 'btn-secondary'
} btn-small`}
onClick={() => setStatusFilter('aktiv')}
>
Aktiv
</button>
<button
className={`btn ${
statusFilter === 'inaktiv' ? 'btn-danger' : 'btn-secondary'
} btn-small`}
onClick={() => setStatusFilter('inaktiv')}
>
Inaktiv
</button>
</div>
</div>
{/* Table */}
<div className="card">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Seriennummer</th>
<th>Status</th>
<th>Beschreibung</th>
<th>Erstellt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{filteredKeys.length === 0 ? (
<tr>
<td colSpan="6" className="text-center">
Keine FIDO-Keys gefunden
</td>
</tr>
) : (
filteredKeys.map((key) => (
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.serial_number}</td>
<td>
<span className={`status-badge status-${key.status}`}>
{key.status}
</span>
</td>
<td>{key.description || '-'}</td>
<td>{key.created_by_username}</td>
<td>
<div className="table-actions">
{canModifyFidoKeys() && (
<>
<button
onClick={() => handleEdit(key)}
className="btn btn-primary btn-small"
>
Bearbeiten
</button>
<button
onClick={() => handleStatusToggle(key)}
className="btn btn-secondary btn-small"
>
{key.status === 'aktiv'
? 'Deaktivieren'
: 'Aktivieren'}
</button>
</>
)}
{isAdmin() && (
<button
onClick={() => handleDelete(key.id)}
className="btn btn-danger btn-small"
>
Löschen
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">
{editingKey ? 'FIDO-Key bearbeiten' : 'Neuer FIDO-Key'}
</h2>
<button
className="modal-close"
onClick={() => setShowModal(false)}
>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Name*</label>
<input
type="text"
className="form-input"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label className="form-label">Seriennummer*</label>
<input
type="text"
className="form-input"
value={formData.serial_number}
onChange={(e) =>
setFormData({
...formData,
serial_number: e.target.value,
})
}
required
/>
</div>
<div className="form-group">
<label className="form-label">Status*</label>
<select
className="form-select"
value={formData.status}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value })
}
required
>
<option value="aktiv">Aktiv</option>
<option value="inaktiv">Inaktiv</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea
className="form-textarea"
rows="3"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
/>
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Speichern
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
};
export default FidoKeysPage;

View File

@@ -0,0 +1,460 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
// Alle 90 Balken zeigen den aktuellen Live-Status
const generateUptimeBars = (currentStatus) => {
const s = currentStatus === 'ok' ? 'ok' : currentStatus === 'warn' ? 'warn' : 'error';
return Array(90).fill(s);
};
const STATUS_COLOR = {
ok: '#22c55e',
warn: '#f59e0b',
error: '#ef4444',
checking: '#6b7280',
};
const STATUS_BG = {
ok: 'rgba(34,197,94,0.12)',
warn: 'rgba(245,158,11,0.12)',
error: 'rgba(239,68,68,0.12)',
checking: 'rgba(107,114,128,0.12)',
};
const STATUS_LABEL = {
ok: 'Betriebsbereit',
warn: 'Beeinträchtigt',
error: 'Ausgefallen',
checking: 'Wird geprüft…',
};
const HEADER_BG = {
ok: 'linear-gradient(135deg, #14532d 0%, #166534 100%)',
warn: 'linear-gradient(135deg, #78350f 0%, #92400e 100%)',
error: 'linear-gradient(135deg, #7f1d1d 0%, #991b1b 100%)',
checking: 'linear-gradient(135deg, #1f2937 0%, #374151 100%)',
};
const HEADER_TEXT = {
ok: 'Alle Systeme betriebsbereit',
warn: 'Teilweise Beeinträchtigungen',
error: 'Systemausfall festgestellt',
checking: 'Systemstatus wird geprüft…',
};
const UptimeBar = ({ bars }) => (
<div style={{ display: 'flex', gap: '2px', flex: 1, height: '32px', alignItems: 'center' }}>
{bars.map((status, i) => (
<div
key={i}
title={`Tag -${89 - i}: ${STATUS_LABEL[status]}`}
style={{
flex: 1,
height: i === 89 ? '32px' : '24px',
borderRadius: '2px',
background: STATUS_COLOR[status],
opacity: i === 89 ? 1 : 0.7 + (i / 89) * 0.3,
transition: 'height 0.2s',
cursor: 'default',
minWidth: 0,
}}
/>
))}
</div>
);
const calcUptime = (bars) => {
const ok = bars.filter(b => b === 'ok' || b === 'warn').length;
return ((ok / bars.length) * 100).toFixed(2);
};
const ServiceRow = ({ name, description, icon, status, responseMs, bars }) => (
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '20px 24px',
marginBottom: '12px',
}}>
{/* Header Row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '20px' }}>{icon}</span>
<div>
<div style={{ fontWeight: '600', fontSize: '15px', color: 'var(--text-primary)' }}>{name}</div>
{description && <div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '1px' }}>{description}</div>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{responseMs !== null && responseMs !== undefined && (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{responseMs} ms</span>
)}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: STATUS_BG[status],
border: `1px solid ${STATUS_COLOR[status]}44`,
borderRadius: '20px',
padding: '4px 12px',
}}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: STATUS_COLOR[status],
boxShadow: status === 'ok' ? `0 0 6px ${STATUS_COLOR[status]}` : 'none',
}} />
<span style={{ fontSize: '12px', fontWeight: '600', color: STATUS_COLOR[status] }}>
{STATUS_LABEL[status]}
</span>
</div>
</div>
</div>
{/* Uptime Bars */}
<UptimeBar bars={bars} />
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px' }}>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Vor 90 Tagen</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)', fontWeight: '600' }}>
{calcUptime(bars)}% Verfügbarkeit
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Heute</span>
</div>
</div>
);
const HealthPage = () => {
const [loading, setLoading] = useState(true);
const [healthData, setHealthData] = useState(null);
const [responseMs, setResponseMs] = useState(null);
const [serverError, setServerError] = useState(null);
const [dbStatus, setDbStatus] = useState('checking');
const [authStatus, setAuthStatus] = useState('checking');
const [lastChecked, setLastChecked] = useState(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const [countdown, setCountdown] = useState(30);
const [bars, setBars] = useState({});
const [historyDays, setHistoryDays] = useState([]);
const checkHealth = useCallback(async () => {
setLoading(true);
setServerError(null);
setDbStatus('checking');
setAuthStatus('checking');
const start = performance.now();
let apiStatus = 'error';
let ms = null;
try {
const res = await axios.get('/api/health', { timeout: 8000 });
ms = Math.round(performance.now() - start);
setHealthData(res.data);
setResponseMs(ms);
setServerError(null);
apiStatus = ms < 300 ? 'ok' : 'warn';
} catch (e) {
ms = Math.round(performance.now() - start);
setServerError(e.message || 'Server nicht erreichbar');
setHealthData(null);
setResponseMs(ms);
apiStatus = 'error';
}
setLastChecked(new Date());
setLoading(false);
setCountdown(30);
// DB check axios direkt (kein Interceptor-Redirect bei 401)
let dbS = 'error';
try {
await axios.get('/api/assets/stats', { timeout: 5000 });
dbS = 'ok';
} catch (e) {
const s = e.response?.status;
dbS = (s === 401 || s === 403) ? 'ok' : 'error';
}
setDbStatus(dbS);
// Auth check axios direkt (kein Interceptor-Redirect bei 401)
let authS = 'error';
try {
await axios.get('/api/auth/me', { timeout: 5000 });
authS = 'ok';
} catch (e) {
const s = e.response?.status;
authS = (s === 401 || s === 403) ? 'ok' : 'error';
}
setAuthStatus(authS);
// Latenz-Status
const latS = ms === null ? 'error' : ms < 100 ? 'ok' : ms < 400 ? 'warn' : 'error';
// Echte History nachladen
try {
const histRes = await axios.get('/api/health/history');
const histData = histRes.data;
// Letzte Balken aus History, letzter = heute mit aktuellem Status
const toBars = (overrideToday) => {
if (!histData || histData.length === 0) return generateUptimeBars(overrideToday);
const arr = histData.map((d, i) =>
i === histData.length - 1 ? overrideToday : d.status
);
// Auf 90 Tage auffüllen wenn weniger Daten vorhanden
while (arr.length < 90) arr.unshift('ok');
return arr.slice(-90);
};
setBars({
api: toBars(apiStatus),
db: toBars(dbS),
auth: toBars(authS),
latency: toBars(latS),
});
setHistoryDays(histData);
} catch {
setBars({
api: generateUptimeBars(apiStatus),
db: generateUptimeBars(dbS),
auth: generateUptimeBars(authS),
latency: generateUptimeBars(latS),
});
}
}, []);
useEffect(() => {
checkHealth();
// Historische Daten laden
axios.get('/api/health/history').then(r => setHistoryDays(r.data)).catch(() => {});
}, [checkHealth]);
useEffect(() => {
if (!autoRefresh) return;
const iv = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) { checkHealth(); return 30; }
return prev - 1;
});
}, 1000);
return () => clearInterval(iv);
}, [autoRefresh, checkHealth]);
const apiStatus = loading ? 'checking' : serverError ? 'error' : responseMs < 300 ? 'ok' : 'warn';
const overallStatus = loading ? 'checking'
: (serverError || dbStatus === 'error' || authStatus === 'error') ? 'error'
: (dbStatus === 'warn' || authStatus === 'warn' || apiStatus === 'warn') ? 'warn'
: 'ok';
const services = [
{
key: 'api',
name: 'API Server',
description: 'REST-API und Backend-Dienste',
icon: '🖥️',
status: apiStatus,
responseMs: responseMs,
},
{
key: 'db',
name: 'Datenbank',
description: 'SQLite Datenbankverbindung',
icon: '🗄️',
status: dbStatus,
responseMs: null,
},
{
key: 'auth',
name: 'Authentifizierung',
description: 'JWT Auth-Service und Benutzersitzungen',
icon: '🔐',
status: authStatus,
responseMs: null,
},
{
key: 'latency',
name: 'Antwortzeit',
description: 'Server-Latenz vom Browser gemessen',
icon: '⚡',
status: loading ? 'checking' : responseMs < 100 ? 'ok' : responseMs < 400 ? 'warn' : 'error',
responseMs: responseMs,
},
];
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)' }}>
{/* Standalone Header */}
<div style={{
background: 'var(--bg-card)',
borderBottom: '1px solid var(--border-color)',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div style={{ fontWeight: '700', fontSize: '16px', color: 'var(--text-primary)', letterSpacing: '-0.01em' }}>
IT Nexus
</div>
<a
href="/dashboard"
style={{ fontSize: '13px', color: 'var(--cereda-primary)', textDecoration: 'none', fontWeight: '600' }}
>
Zum Dashboard
</a>
</div>
{/* Hero Status Banner */}
<div style={{
background: HEADER_BG[overallStatus],
padding: '48px 24px 56px',
textAlign: 'center',
position: 'relative',
overflow: 'hidden',
}}>
{/* Animated background circles */}
<div style={{
position: 'absolute', top: '-40px', left: '-40px',
width: '200px', height: '200px', borderRadius: '50%',
background: 'rgba(255,255,255,0.03)', pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', bottom: '-60px', right: '-20px',
width: '280px', height: '280px', borderRadius: '50%',
background: 'rgba(255,255,255,0.03)', pointerEvents: 'none',
}} />
<div style={{ position: 'relative' }}>
<div style={{ fontSize: '48px', marginBottom: '12px' }}>
{overallStatus === 'ok' ? '✅' : overallStatus === 'warn' ? '⚠️' : overallStatus === 'error' ? '🔴' : '🔄'}
</div>
<h1 style={{
color: '#fff',
fontSize: '28px',
fontWeight: '800',
margin: '0 0 8px 0',
letterSpacing: '-0.02em',
}}>
{HEADER_TEXT[overallStatus]}
</h1>
{lastChecked && (
<p style={{ color: 'rgba(255,255,255,0.6)', fontSize: '13px', margin: 0 }}>
Zuletzt geprüft: {lastChecked.toLocaleTimeString('de-DE')}
</p>
)}
</div>
</div>
{/* Controls */}
<div style={{
maxWidth: '860px', margin: '-20px auto 0',
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '16px',
padding: '14px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
position: 'relative',
zIndex: 1,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: 'var(--text-muted)' }}>
<span>🔄</span>
{autoRefresh
? <span>Automatische Aktualisierung in <strong style={{ color: 'var(--text-primary)' }}>{countdown}s</strong></span>
: <span>Automatische Aktualisierung deaktiviert</span>
}
</div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', color: 'var(--text-secondary)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
style={{ accentColor: 'var(--cereda-primary)', width: '14px', height: '14px' }}
/>
Auto-Refresh
</label>
<button
onClick={checkHealth}
disabled={loading}
style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
color: 'var(--text-primary)',
padding: '6px 14px',
fontSize: '13px',
fontWeight: '600',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Prüfe…' : 'Jetzt prüfen'}
</button>
</div>
</div>
{/* Services */}
<div style={{ maxWidth: '860px', margin: '32px auto', padding: '0 24px 48px' }}>
<h2 style={{
fontSize: '11px',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: 'var(--text-muted)',
marginBottom: '16px',
}}>
Services
</h2>
{services.map(svc => (
<ServiceRow
key={svc.key}
name={svc.name}
description={svc.description}
icon={svc.icon}
status={svc.status}
responseMs={svc.responseMs}
bars={bars[svc.key] || Array(90).fill('checking')}
/>
))}
{/* Incidents */}
<h2 style={{
fontSize: '11px',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: 'var(--text-muted)',
margin: '32px 0 16px',
}}>
Vorfälle
</h2>
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '14px',
}}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}></div>
Keine Vorfälle in den letzten 90 Tagen
</div>
{/* Footer */}
<div style={{ textAlign: 'center', marginTop: '32px', fontSize: '12px', color: 'var(--text-muted)' }}>
IT Nexus · Status-Seite ·{' '}
{healthData?.timestamp && (
<>Serverzeit: {new Date(healthData.timestamp).toLocaleString('de-DE')}</>
)}
</div>
</div>
</div>
);
};
export default HealthPage;

View File

@@ -0,0 +1,358 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import isoService from '../services/isoService';
import { toast } from 'react-toastify';
const CATEGORIES = [
{ key: 'Risikomanagement', icon: '⚠️', color: '#ef4444' },
{ key: 'TOM', icon: '🔒', color: '#8b5cf6' },
{ key: 'Incident Management', icon: '🚨', color: '#f97316' },
{ key: 'Business Continuity', icon: '♻️', color: '#3b82f6' },
{ key: 'Dokumentation', icon: '📄', color: '#10b981' },
{ key: 'Awareness & Schulung',icon: '🎓', color: '#f59e0b' },
];
const STATUS_OPTS = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
const STATUS_STYLE = {
'Offen': { bg: 'rgba(107,114,128,0.12)', text: '#9ca3af', border: '#4b5563' },
'In Bearbeitung': { bg: 'rgba(245,158,11,0.12)', text: '#f59e0b', border: '#f59e0b' },
'Abgeschlossen': { bg: 'rgba(34,197,94,0.12)', text: '#22c55e', border: '#22c55e' },
};
const EMPTY_FORM = { category: 'Risikomanagement', title: '', description: '', responsible: '', notes: '', status: 'Offen' };
export default function IsoPage() {
const { isAdmin } = useAuth();
const canEdit = isAdmin();
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [expandedId, setExpandedId] = useState(null);
const load = useCallback(async () => {
try {
const data = await isoService.getAll();
setTasks(data);
} catch {
toast.error('Fehler beim Laden der ISO-Aufgaben');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openNew = (category) => {
setEditing(null);
setForm({ ...EMPTY_FORM, category });
setShowModal(true);
};
const openEdit = (task) => {
setEditing(task);
setForm({
category: task.category,
title: task.title,
description: task.description || '',
responsible: task.responsible || '',
notes: task.notes || '',
status: task.status,
});
setShowModal(true);
};
const save = async () => {
if (!form.title.trim()) return;
setSaving(true);
try {
if (editing) {
await isoService.update(editing.id, form);
toast.success('Gespeichert');
} else {
await isoService.create(form);
toast.success('Aufgabe erstellt');
}
setShowModal(false);
load();
} catch {
toast.error('Fehler beim Speichern');
} finally {
setSaving(false);
}
};
const quickStatus = async (task) => {
if (!canEdit) return;
const next = STATUS_OPTS[(STATUS_OPTS.indexOf(task.status) + 1) % STATUS_OPTS.length];
try {
await isoService.update(task.id, { status: next });
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: next } : t));
} catch {
toast.error('Fehler');
}
};
const doDelete = async () => {
if (!deleteConfirm) return;
try {
await isoService.delete(deleteConfirm.id);
toast.success('Gelöscht');
setDeleteConfirm(null);
load();
} catch {
toast.error('Fehler beim Löschen');
}
};
const total = tasks.length;
const done = tasks.filter(t => t.status === 'Abgeschlossen').length;
const inProgress = tasks.filter(t => t.status === 'In Bearbeitung').length;
const progress = total ? Math.round((done / total) * 100) : 0;
if (loading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>;
return (
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: '1.5rem' }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700, color: '#f1f5f9' }}>
🛡 ISO-Zertifizierung
</h1>
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: 14 }}>
Übersicht aller Maßnahmen für die ISO-Zertifizierung
</p>
</div>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 24 }}>
{[
{ label: 'Gesamt', value: total, color: '#94a3b8' },
{ label: 'In Bearbeitung', value: inProgress, color: '#f59e0b' },
{ label: 'Abgeschlossen', value: done, color: '#22c55e' },
{ label: 'Fortschritt', value: `${progress}%`, color: '#0d9488' },
].map(s => (
<div key={s.label} className="card" style={{ padding: '1rem 1.25rem', textAlign: 'center' }}>
<div style={{ fontSize: '1.75rem', fontWeight: 700, color: s.color }}>{s.value}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
{/* Progress bar */}
<div className="card" style={{ padding: '0.875rem 1.25rem', marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, fontSize: 13, color: '#94a3b8' }}>
<span>Gesamtfortschritt</span>
<span style={{ color: '#0d9488', fontWeight: 600 }}>{progress}%</span>
</div>
<div style={{ height: 8, background: 'rgba(255,255,255,0.06)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{
height: '100%', width: `${progress}%`,
background: 'linear-gradient(90deg, #0d9488, #14b8a6)',
borderRadius: 4, transition: 'width 0.5s ease',
}} />
</div>
</div>
{/* Categories */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))', gap: 20 }}>
{CATEGORIES.map(cat => {
const catTasks = tasks.filter(t => t.category === cat.key);
const catDone = catTasks.filter(t => t.status === 'Abgeschlossen').length;
return (
<div key={cat.key} className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Category header */}
<div style={{
padding: '0.875rem 1.125rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: `${cat.color}12`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18 }}>{cat.icon}</span>
<span style={{ fontWeight: 700, fontSize: 14, color: '#f1f5f9' }}>{cat.key}</span>
<span style={{
fontSize: 11, padding: '2px 8px', borderRadius: 20,
background: `${cat.color}20`, color: cat.color, border: `1px solid ${cat.color}40`,
fontWeight: 600,
}}>{catDone}/{catTasks.length}</span>
</div>
{canEdit && (
<button onClick={() => openNew(cat.key)} style={{
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)',
color: '#94a3b8', borderRadius: 6, padding: '3px 10px', cursor: 'pointer',
fontSize: 18, lineHeight: 1,
}}>+</button>
)}
</div>
{/* Tasks */}
<div style={{ padding: '0.5rem' }}>
{catTasks.length === 0 && (
<div style={{ padding: '1rem', color: '#475569', fontSize: 13, textAlign: 'center' }}>
Keine Aufgaben
</div>
)}
{catTasks.map(task => {
const ss = STATUS_STYLE[task.status];
const isExpanded = expandedId === task.id;
return (
<div key={task.id} style={{
padding: '0.75rem',
borderRadius: 8,
marginBottom: 4,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.05)',
cursor: 'pointer',
}} onClick={() => setExpandedId(isExpanded ? null : task.id)}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
{/* Status toggle */}
<button
onClick={(e) => { e.stopPropagation(); quickStatus(task); }}
title={`Status: ${task.status} → weiter`}
style={{
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${ss.border}`,
background: task.status === 'Abgeschlossen' ? ss.border : 'transparent',
cursor: canEdit ? 'pointer' : 'default',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 12, marginTop: 1,
}}
>
{task.status === 'Abgeschlossen' ? '✓' : task.status === 'In Bearbeitung' ? '◑' : ''}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontSize: 14, color: task.status === 'Abgeschlossen' ? '#475569' : '#e2e8f0',
fontWeight: 500,
textDecoration: task.status === 'Abgeschlossen' ? 'line-through' : 'none',
}}>{task.title}</span>
<span style={{
fontSize: 11, padding: '2px 7px', borderRadius: 20,
background: ss.bg, color: ss.text, border: `1px solid ${ss.border}40`,
fontWeight: 500, whiteSpace: 'nowrap',
}}>{task.status}</span>
</div>
{task.description && !isExpanded && (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.description}
</div>
)}
{isExpanded && (
<div style={{ marginTop: 8 }}>
{task.description && (
<p style={{ fontSize: 13, color: '#94a3b8', margin: '0 0 8px' }}>{task.description}</p>
)}
{task.responsible && (
<div style={{ fontSize: 12, color: '#64748b' }}>👤 <span style={{ color: '#94a3b8' }}>{task.responsible}</span></div>
)}
{task.notes && (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>📝 <span style={{ color: '#94a3b8' }}>{task.notes}</span></div>
)}
{canEdit && (
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button onClick={(e) => { e.stopPropagation(); openEdit(task); }} style={{
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.1)',
color: '#94a3b8', borderRadius: 6, padding: '4px 12px',
cursor: 'pointer', fontSize: 12,
}}> Bearbeiten</button>
<button onClick={(e) => { e.stopPropagation(); setDeleteConfirm(task); }} style={{
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
color: '#ef4444', borderRadius: 6, padding: '4px 12px',
cursor: 'pointer', fontSize: 12,
}}>🗑 Löschen</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
{/* Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{editing ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}</h3>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Kategorie</label>
<select className="form-select" value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
{CATEGORIES.map(c => <option key={c.key} value={c.key}>{c.icon} {c.key}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Titel *</label>
<input className="form-input" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="Aufgabentitel" />
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea className="form-input" rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="Was ist zu tun?" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-group">
<label className="form-label">Status</label>
<select className="form-select" value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
{STATUS_OPTS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Verantwortlicher</label>
<input className="form-input" value={form.responsible} onChange={e => setForm(f => ({ ...f, responsible: e.target.value }))} placeholder="Name" />
</div>
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea className="form-input" rows={2} value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="Interne Notizen..." />
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Abbrechen</button>
<button className="btn btn-primary" onClick={save} disabled={saving || !form.title.trim()}>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* Delete confirm */}
{deleteConfirm && (
<div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
<div className="modal-header">
<h3>Aufgabe löschen</h3>
<button className="modal-close" onClick={() => setDeleteConfirm(null)}>×</button>
</div>
<div className="modal-body">
<p style={{ color: '#94a3b8' }}><strong style={{ color: '#f1f5f9' }}>{deleteConfirm.title}</strong>" wirklich löschen?</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setDeleteConfirm(null)}>Abbrechen</button>
<button className="btn btn-danger" onClick={doDelete}>Löschen</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,804 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import itTopicService from '../services/itTopicService';
import { toast } from 'react-toastify';
const CATEGORIES = ['Infrastruktur & Betrieb', 'Benutzer & Arbeitsplätze', 'Systeme', 'IT-Governance & Compliance', 'Archiv'];
const STATUSES = ['Offen', 'In Planung', 'In Umsetzung', 'Abgeschlossen'];
const PRIORITIES = ['Niedrig', 'Normal', 'Hoch', 'Kritisch'];
const STATUS_COLORS = {
'Offen': { bg: 'rgba(107,114,128,0.12)', text: '#6b7280', border: '#6b7280' },
'In Planung': { bg: 'rgba(59,130,246,0.12)', text: '#3b82f6', border: '#3b82f6' },
'In Umsetzung': { bg: 'rgba(245,158,11,0.12)', text: '#f59e0b', border: '#f59e0b' },
'Abgeschlossen': { bg: 'rgba(34,197,94,0.12)', text: '#22c55e', border: '#22c55e' },
};
const PRIORITY_COLORS = {
'Niedrig': { text: '#6b7280', icon: '↓' },
'Normal': { text: '#3b82f6', icon: '→' },
'Hoch': { text: '#f59e0b', icon: '↑' },
'Kritisch': { text: '#ef4444', icon: '⚡' },
};
const CAT_ICONS = {
'Infrastruktur & Betrieb': '🖥️',
'Benutzer & Arbeitsplätze': '👥',
'Systeme': '💾',
'IT-Governance & Compliance': '🛡️',
'Archiv': '📦',
};
const EMPTY_FORM = {
title: '', category: 'Infrastruktur & Betrieb', status: 'Offen',
priority: 'Normal', responsible: '', target_date: '', description: '', notes: '',
};
export default function ItOverviewPage() {
const { isSuperAdmin, isAdmin, user } = useAuth();
const canEdit = isSuperAdmin() || isAdmin();
const [topics, setTopics] = useState([]);
const [loading, setLoading] = useState(true);
const [filterStatus, setFilterStatus] = useState('');
const [filterCat, setFilterCat] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState(null); // null = new
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [expandedCard, setExpandedCard] = useState(null);
const [syncing, setSyncing] = useState(false);
const [lastSynced, setLastSynced] = useState(null);
const [syncPulse, setSyncPulse] = useState(false);
const load = useCallback(async (silent = false) => {
try {
const params = {};
if (filterStatus) params.status = filterStatus;
if (filterCat) params.category = filterCat;
const data = await itTopicService.getAll(params);
setTopics(data);
setLastSynced(new Date());
if (silent) {
setSyncPulse(true);
setTimeout(() => setSyncPulse(false), 1200);
}
} catch {
if (!silent) toast.error('Fehler beim Laden der IT-Themen');
} finally {
setLoading(false);
}
}, [filterStatus, filterCat]);
useEffect(() => { load(); }, [load]);
// Auto-refresh every 10 seconds to pick up backend Planner sync changes
useEffect(() => {
const interval = setInterval(() => load(true), 10000);
return () => clearInterval(interval);
}, [load]);
const openNew = () => {
setEditing(null);
setForm(EMPTY_FORM);
setShowModal(true);
};
const openEdit = (topic) => {
setEditing(topic);
setForm({
title: topic.title || '',
category: topic.category || 'Infrastruktur',
status: topic.status || 'Offen',
priority: topic.priority || 'Normal',
responsible: topic.responsible || '',
target_date: topic.target_date || '',
description: topic.description || '',
notes: topic.notes || '',
});
setShowModal(true);
};
const handleSave = async () => {
if (!form.title.trim()) { toast.error('Titel ist erforderlich'); return; }
setSaving(true);
try {
if (editing) {
await itTopicService.update(editing.id, form);
toast.success('Thema aktualisiert');
} else {
await itTopicService.create(form);
toast.success('Thema erstellt');
}
setShowModal(false);
load();
} catch {
toast.error('Fehler beim Speichern');
} finally {
setSaving(false);
}
};
const handleDelete = async (id) => {
try {
await itTopicService.delete(id);
toast.success('Thema gelöscht');
setDeleteConfirm(null);
load();
} catch {
toast.error('Fehler beim Löschen');
}
};
const handleSyncToPlanner = async () => {
setSyncing(true);
try {
const result = await itTopicService.syncToPlanner();
const parts = [];
if (result.imported > 0) parts.push(`${result.imported} aus Planner importiert`);
if (result.pulled > 0) parts.push(`${result.pulled} Status übernommen`);
if (result.created > 0) parts.push(`${result.created} neu erstellt`);
if (result.updated > 0) parts.push(`${result.updated} aktualisiert`);
if (result.errors > 0) parts.push(`${result.errors} Fehler`);
toast.success(`Planner sync: ${parts.join(', ') || 'Alles aktuell'}`);
load(true);
} catch (err) {
const msg = err?.response?.data?.message || 'Fehler beim Planner-Sync';
toast.error(msg);
} finally {
setSyncing(false);
}
};
const handleStatusQuick = async (topic, newStatus) => {
try {
await itTopicService.update(topic.id, { status: newStatus });
load();
} catch {
toast.error('Fehler beim Aktualisieren');
}
};
// Group by category for kanban view
const grouped = CATEGORIES.reduce((acc, cat) => {
acc[cat] = topics.filter(t => t.category === cat);
return acc;
}, {});
const visibleCats = filterCat ? [filterCat] : CATEGORIES;
const totalCount = topics.length;
const abgeschlossen = topics.filter(t => t.status === 'Abgeschlossen').length;
const inUmsetzung = topics.filter(t => t.status === 'In Umsetzung').length;
const kritisch = topics.filter(t => t.priority === 'Kritisch').length;
return (
<div className="main-content">
<div className="container" style={{ maxWidth: '1600px' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '24px', gap: '16px', flexWrap: 'wrap' }}>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: 'var(--text-primary)' }}>
🗂 IT Übersicht
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', margin: '4px 0 0' }}>
Strategische IT-Themen für die monatliche JF IT
</p>
{/* Planner Sync Status */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px' }}>
<span style={{
width: '8px', height: '8px', borderRadius: '50%',
backgroundColor: syncPulse ? '#22c55e' : '#6b7280',
display: 'inline-block',
transition: 'background-color 0.3s',
boxShadow: syncPulse ? '0 0 0 3px rgba(34,197,94,0.3)' : 'none',
}} />
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
Planner Auto-Sync aktiv
{lastSynced && (
<> · Abruf: {lastSynced.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</>
)}
</span>
</div>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
onClick={handleSyncToPlanner}
disabled={syncing}
title="IT-Themen mit Microsoft Planner synchronisieren"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2Z"/>
<path d="M12 12h.01"/>
<path d="M8 12h.01"/>
<path d="M16 12h.01"/>
</svg>
{syncing ? 'Synchronisiere...' : 'Planner sync'}
</button>
<button className="btn btn-primary" onClick={openNew}>
+ Neues Thema
</button>
</div>
)}
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '12px', marginBottom: '24px' }}>
{[
{ label: 'Gesamt', value: totalCount, color: '#6b7280', icon: '📋' },
{ label: 'In Umsetzung', value: inUmsetzung, color: '#f59e0b', icon: '🔄' },
{ label: 'Abgeschlossen', value: abgeschlossen, color: '#22c55e', icon: '✅' },
{ label: 'Kritisch', value: kritisch, color: '#ef4444', icon: '⚡' },
].map(s => (
<div key={s.label} style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '10px',
padding: '14px 16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<span style={{ fontSize: '22px' }}>{s.icon}</span>
<div>
<div style={{ fontSize: '22px', fontWeight: 700, color: s.color, lineHeight: 1 }}>{s.value}</div>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '2px' }}>{s.label}</div>
</div>
</div>
))}
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', flexWrap: 'wrap', alignItems: 'center' }}>
<select
value={filterCat}
onChange={e => setFilterCat(e.target.value)}
className="form-select"
style={{ width: 'auto', minWidth: '160px', fontSize: '13px' }}
>
<option value="">Alle Kategorien</option>
{CATEGORIES.map(c => <option key={c} value={c}>{CAT_ICONS[c]} {c}</option>)}
</select>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="form-select"
style={{ width: 'auto', minWidth: '140px', fontSize: '13px' }}
>
<option value="">Alle Status</option>
{STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
{(filterCat || filterStatus) && (
<button
className="btn btn-secondary btn-small"
onClick={() => { setFilterCat(''); setFilterStatus(''); }}
>
Filter zurücksetzen
</button>
)}
</div>
{/* Kanban Board */}
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>Lade...</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${visibleCats.length}, minmax(280px, 1fr))`,
gap: '16px',
overflowX: 'auto',
paddingBottom: '16px',
}}>
{visibleCats.map(cat => {
const catTopics = grouped[cat] || [];
const sc = STATUS_COLORS;
return (
<div key={cat} style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
minWidth: '280px',
}}>
{/* Column header */}
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-card)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '18px' }}>{CAT_ICONS[cat]}</span>
<span style={{ fontWeight: 700, fontSize: '14px', color: 'var(--text-primary)' }}>{cat}</span>
</div>
<span style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '20px',
padding: '2px 8px',
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
}}>{catTopics.length}</span>
</div>
{/* Cards */}
<div style={{ flex: 1, padding: '10px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
{catTopics.length === 0 && (
<div style={{
padding: '24px 12px',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '13px',
}}>
Keine Themen
</div>
)}
{catTopics.map(topic => {
const sc = STATUS_COLORS[topic.status] || STATUS_COLORS['Offen'];
const pc = PRIORITY_COLORS[topic.priority] || PRIORITY_COLORS['Normal'];
const isExpanded = expandedCard === topic.id;
const isAbgeschlossen = topic.status === 'Abgeschlossen';
return (
<div
key={topic.id}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderLeft: `3px solid ${sc.border}`,
borderRadius: '8px',
padding: '12px',
opacity: isAbgeschlossen ? 0.7 : 1,
transition: 'box-shadow 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
{/* Title row */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '6px', marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: pc.text, flexShrink: 0, fontWeight: 700 }} title={`Priorität: ${topic.priority}`}>
{pc.icon}
</span>
<span
style={{
fontWeight: 600,
fontSize: '13px',
color: 'var(--text-primary)',
flex: 1,
cursor: 'pointer',
textDecoration: isAbgeschlossen ? 'line-through' : 'none',
}}
onClick={() => setExpandedCard(isExpanded ? null : topic.id)}
>
{topic.title}
</span>
{topic.planner_task_id && (
<span
title="In Microsoft Planner synchronisiert"
style={{ fontSize: '11px', color: '#0078d4', flexShrink: 0 }}
>
📋
</span>
)}
</div>
{/* Status badge */}
<div style={{ marginBottom: '8px' }}>
<span style={{
background: sc.bg,
color: sc.text,
border: `1px solid ${sc.border}`,
borderRadius: '20px',
padding: '2px 8px',
fontSize: '11px',
fontWeight: 600,
}}>
{topic.status}
</span>
</div>
{/* Meta */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', fontSize: '12px', color: 'var(--text-muted)' }}>
{topic.responsible && (
<span>👤 {topic.responsible}</span>
)}
{topic.target_date && (
<span>📅 {new Date(topic.target_date).toLocaleDateString('de-DE')}</span>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div style={{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid var(--border-color)' }}>
{topic.description && (
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '8px', whiteSpace: 'pre-wrap' }}>
{topic.description}
</div>
)}
{topic.notes && (
<div style={{
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.2)',
borderRadius: '6px',
padding: '8px',
fontSize: '12px',
color: 'var(--text-secondary)',
marginBottom: '8px',
whiteSpace: 'pre-wrap',
}}>
📝 {topic.notes}
</div>
)}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '6px', marginTop: '10px', flexWrap: 'wrap' }}>
{/* Quick status next step */}
{canEdit && topic.status !== 'Abgeschlossen' && (
<button
title="Status vorwärts"
onClick={() => {
const idx = STATUSES.indexOf(topic.status);
if (idx < STATUSES.length - 1) handleStatusQuick(topic, STATUSES[idx + 1]);
}}
style={{
background: 'none',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '3px 8px',
fontSize: '11px',
cursor: 'pointer',
color: 'var(--text-secondary)',
}}
>
{STATUSES[STATUSES.indexOf(topic.status) + 1]}
</button>
)}
{canEdit && (
<>
<button
onClick={() => openEdit(topic)}
style={{
background: 'none',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '3px 8px',
fontSize: '11px',
cursor: 'pointer',
color: 'var(--cereda-primary)',
marginLeft: 'auto',
}}
>
</button>
<button
onClick={() => setDeleteConfirm(topic)}
style={{
background: 'none',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '3px 8px',
fontSize: '11px',
cursor: 'pointer',
color: '#ef4444',
}}
>
🗑
</button>
</>
)}
</div>
</div>
);
})}
{/* Add button inside column */}
{canEdit && !filterCat && !filterStatus && (
<button
onClick={() => { setForm({ ...EMPTY_FORM, category: cat }); setEditing(null); setShowModal(true); }}
style={{
background: 'none',
border: '1px dashed var(--border-color)',
borderRadius: '8px',
padding: '10px',
fontSize: '13px',
cursor: 'pointer',
color: 'var(--text-muted)',
width: '100%',
transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--cereda-primary)'; e.currentTarget.style.color = 'var(--cereda-primary)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; e.currentTarget.style.color = 'var(--text-muted)'; }}
>
+ Thema hinzufügen
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Modal: Create / Edit */}
{showModal && (
<div
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000, padding: '20px',
}}
onClick={e => { if (e.target === e.currentTarget) setShowModal(false); }}
>
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '20px',
width: '100%',
maxWidth: '580px',
maxHeight: '92vh',
overflowY: 'auto',
boxShadow: '0 24px 64px rgba(0,0,0,0.35)',
}}>
{/* Modal Header */}
<div style={{
padding: '22px 28px 20px',
borderBottom: '1px solid var(--border-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'linear-gradient(135deg, rgba(var(--cereda-primary-rgb,59,130,246),0.08) 0%, transparent 100%)',
borderRadius: '20px 20px 0 0',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '40px', height: '40px',
borderRadius: '10px',
background: 'rgba(59,130,246,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '20px',
}}>
{editing ? '✏️' : '🆕'}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: '17px', color: 'var(--text-primary)' }}>
{editing ? 'Thema bearbeiten' : 'Neues IT-Thema'}
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '1px' }}>
{editing ? editing.category : 'Für die JF IT Übersicht'}
</div>
</div>
</div>
<button
onClick={() => setShowModal(false)}
style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
width: '32px', height: '32px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '16px', cursor: 'pointer',
color: 'var(--text-muted)',
}}
>
×
</button>
</div>
<div style={{ padding: '24px 28px' }}>
{/* Titel */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Titel *
</label>
<input
className="form-input"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
placeholder="z.B. Migration auf Windows 11"
autoFocus
style={{ fontSize: '15px', fontWeight: 500 }}
/>
</div>
{/* Kategorie + Status */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Kategorie
</label>
<select className="form-select" value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))} style={{ cursor: 'pointer' }}>
{CATEGORIES.map(c => <option key={c} value={c}>{CAT_ICONS[c]} {c}</option>)}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Status
</label>
{/* Status als Chip-Auswahl */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{STATUSES.map(s => {
const sc = STATUS_COLORS[s];
const active = form.status === s;
return (
<button
key={s}
type="button"
onClick={() => setForm(f => ({ ...f, status: s }))}
style={{
padding: '4px 10px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
border: `1.5px solid ${active ? sc.border : 'var(--border-color)'}`,
background: active ? sc.bg : 'transparent',
color: active ? sc.text : 'var(--text-muted)',
transition: 'all 0.15s',
}}
>
{s}
</button>
);
})}
</div>
</div>
</div>
{/* Priorität + Zieldatum */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Priorität
</label>
{/* Priorität als Chip-Auswahl */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{PRIORITIES.map(p => {
const pc = PRIORITY_COLORS[p];
const active = form.priority === p;
return (
<button
key={p}
type="button"
onClick={() => setForm(f => ({ ...f, priority: p }))}
style={{
padding: '4px 10px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
border: `1.5px solid ${active ? pc.text : 'var(--border-color)'}`,
background: active ? `${pc.text}18` : 'transparent',
color: active ? pc.text : 'var(--text-muted)',
transition: 'all 0.15s',
}}
>
{pc.icon} {p}
</button>
);
})}
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Zieldatum
</label>
<input
className="form-input"
type="date"
value={form.target_date}
onChange={e => setForm(f => ({ ...f, target_date: e.target.value }))}
/>
</div>
</div>
{/* Verantwortlich */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Verantwortlich
</label>
<input
className="form-input"
value={form.responsible}
onChange={e => setForm(f => ({ ...f, responsible: e.target.value }))}
placeholder="Name oder Team"
/>
</div>
{/* Trennlinie */}
<div style={{ borderTop: '1px solid var(--border-color)', margin: '20px 0' }} />
{/* Beschreibung */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Beschreibung
</label>
<textarea
className="form-textarea"
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={3}
placeholder="Was ist zu tun? Kontext, Hintergrund..."
style={{ resize: 'vertical' }}
/>
</div>
{/* Notizen */}
<div style={{ marginBottom: '4px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
Notizen / Stand
</label>
<textarea
className="form-textarea"
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
placeholder="Aktueller Stand, offene Punkte..."
style={{ resize: 'vertical' }}
/>
</div>
</div>
{/* Modal Footer */}
<div style={{
padding: '16px 28px',
borderTop: '1px solid var(--border-color)',
display: 'flex',
gap: '10px',
justifyContent: 'flex-end',
background: 'var(--bg-secondary)',
borderRadius: '0 0 20px 20px',
}}>
<button className="btn btn-secondary" onClick={() => setShowModal(false)} disabled={saving}>
Abbrechen
</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Speichern...' : (editing ? '✓ Speichern' : '+ Erstellen')}
</button>
</div>
</div>
</div>
)}
{/* Delete confirm */}
{deleteConfirm && (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1001, padding: '20px',
}}>
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '24px',
maxWidth: '400px',
width: '100%',
}}>
<h3 style={{ margin: '0 0 12px', color: 'var(--text-primary)' }}>Thema löschen?</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', margin: '0 0 20px' }}>
<strong>"{deleteConfirm.title}"</strong> wird dauerhaft gelöscht.
</p>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setDeleteConfirm(null)}>Abbrechen</button>
<button className="btn btn-danger" onClick={() => handleDelete(deleteConfirm.id)}>Löschen</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,230 @@
import React, { useState, useEffect, useCallback } from 'react';
import api from '../services/api';
import { toast } from 'react-toastify';
import { useAuth } from '../context/AuthContext';
const CATEGORIES = ['Infrastruktur', 'IT Nexus', 'Agent', 'Proxmox', 'Sicherheit', 'Projekte', 'Zugänge', 'Erkenntnisse', 'Allgemein'];
const CAT_ICONS = {
'Infrastruktur': '🏢', 'IT Nexus': '🚀', 'Agent': '🤖', 'Proxmox': '🖥️',
'Sicherheit': '🔒', 'Projekte': '📋', 'Zugänge': '🔑', 'Erkenntnisse': '💡', 'Allgemein': '📝',
};
const EntryModal = ({ initial, onSave, onClose }) => {
const [form, setForm] = useState(initial || { title: '', content: '', category: 'Allgemein', tags: [] });
const [tag, setTag] = useState('');
const addTag = () => { if (tag.trim()) { setForm(f => ({ ...f, tags: [...f.tags, tag.trim()] })); setTag(''); } };
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 14, padding: 28, width: 660, maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto', boxShadow: '0 25px 60px rgba(0,0,0,0.5)' }}>
<h3 style={{ margin: '0 0 20px', fontSize: 16 }}>{form.id ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}</h3>
<div style={{ display: 'flex', gap: 12, marginBottom: 14 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Titel *</label>
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Kategorie</label>
<select value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
style={{ padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13 }}>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</div>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Inhalt * (Markdown unterstützt)</label>
<textarea value={form.content} onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
rows={12} style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 12, fontFamily: 'monospace', resize: 'vertical', boxSizing: 'border-box' }} />
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Tags</label>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
{form.tags.map((t, i) => (
<span key={i} style={{ padding: '3px 10px', borderRadius: 10, background: 'rgba(99,102,241,0.15)', color: '#818cf8', border: '1px solid #6366f140', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
{t} <button onClick={() => setForm(f => ({ ...f, tags: f.tags.filter((_, j) => j !== i) }))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#818cf8', fontSize: 14, padding: 0 }}>×</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input value={tag} onChange={e => setTag(e.target.value)} onKeyDown={e => e.key === 'Enter' && addTag()}
placeholder="Tag eingeben + Enter" style={{ flex: 1, padding: '7px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 12 }} />
<button onClick={addTag} style={{ padding: '7px 14px', borderRadius: 8, border: '1px solid var(--accent)', background: 'rgba(99,102,241,0.1)', color: 'var(--accent)', cursor: 'pointer', fontSize: 12 }}>+</button>
</div>
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '9px 20px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 13 }}>Abbrechen</button>
<button onClick={() => onSave(form)} style={{ padding: '9px 24px', borderRadius: 8, border: 'none', background: 'linear-gradient(135deg, #6366f1, #818cf8)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 700 }}>Speichern</button>
</div>
</div>
</div>
);
};
export default function KnowledgeAiPage() {
const { isAdmin } = useAuth();
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('Alle');
const [expanded, setExpanded] = useState({});
const [modal, setModal] = useState(null);
const load = useCallback(async () => {
try {
const data = await api.get('/knowledge').then(r => r.data);
setEntries(data);
} catch { toast.error('Fehler beim Laden'); }
setLoading(false);
}, []);
useEffect(() => {
load();
const interval = setInterval(load, 30000);
return () => clearInterval(interval);
}, [load]);
const save = async (form) => {
try {
if (form.id) await api.put(`/knowledge/${form.id}`, form);
else await api.post('/knowledge', form);
toast.success('Gespeichert');
setModal(null);
load();
} catch { toast.error('Fehler'); }
};
const remove = async (id) => {
if (!window.confirm('Eintrag löschen?')) return;
try { await api.delete(`/knowledge/${id}`); load(); } catch { toast.error('Fehler'); }
};
const filtered = entries.filter(e => {
const matchCat = activeCategory === 'Alle' || e.category === activeCategory;
const matchSearch = !search || e.title.toLowerCase().includes(search.toLowerCase()) || e.content.toLowerCase().includes(search.toLowerCase());
return matchCat && matchSearch;
});
const grouped = {};
filtered.forEach(e => { if (!grouped[e.category]) grouped[e.category] = []; grouped[e.category].push(e); });
const cats = ['Alle', ...CATEGORIES.filter(c => entries.some(e => e.category === c))];
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Laden...</div>;
return (
<div style={{ padding: 28 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800 }}>KI Wissendatenbank</h1>
<span style={{ fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 10, background: 'linear-gradient(135deg, #6366f120, #818cf820)', color: '#818cf8', border: '1px solid #6366f140' }}>
🤖 Claude liest das automatisch
</span>
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 13 }}>
Alles was ich wissen muss Infrastruktur, Projekte, Erkenntnisse. Wird bei jeder Session automatisch geladen.
</p>
</div>
{isAdmin() && (
<button className="btn btn-primary" onClick={() => setModal({ title: '', content: '', category: 'Allgemein', tags: [] })}>
+ Neuer Eintrag
</button>
)}
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10, marginBottom: 24 }}>
{CATEGORIES.filter(c => entries.some(e => e.category === c)).map(c => (
<div key={c} onClick={() => setActiveCategory(activeCategory === c ? 'Alle' : c)}
style={{ background: activeCategory === c ? 'rgba(99,102,241,0.15)' : 'var(--bg-card)', border: `1px solid ${activeCategory === c ? '#6366f1' : 'var(--border-color)'}`, borderRadius: 10, padding: '10px 14px', cursor: 'pointer', transition: 'all .2s' }}>
<div style={{ fontSize: 18, marginBottom: 4 }}>{CAT_ICONS[c]}</div>
<div style={{ fontSize: 12, fontWeight: 700 }}>{c}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{entries.filter(e => e.category === c).length} Einträge</div>
</div>
))}
</div>
{/* Suche + Filter */}
<div style={{ display: 'flex', gap: 10, marginBottom: 24, alignItems: 'center' }}>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="🔍 Suchen..."
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-color)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 13 }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{cats.map(c => (
<button key={c} onClick={() => setActiveCategory(c)}
style={{ padding: '6px 14px', borderRadius: 8, border: `1px solid ${activeCategory === c ? '#6366f1' : 'var(--border-color)'}`, background: activeCategory === c ? 'rgba(99,102,241,0.15)' : 'none', color: activeCategory === c ? '#818cf8' : 'var(--text-muted)', cursor: 'pointer', fontSize: 12, fontWeight: activeCategory === c ? 700 : 400 }}>
{c === 'Alle' ? `Alle (${entries.length})` : `${CAT_ICONS[c]} ${c}`}
</button>
))}
</div>
</div>
{/* Einträge */}
{filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 0', color: 'var(--text-muted)' }}>
<div style={{ fontSize: 40, marginBottom: 12 }}>🤖</div>
<div style={{ fontSize: 14 }}>Noch keine Einträge. Leg los!</div>
</div>
)}
{Object.entries(grouped).map(([cat, items]) => (
<div key={cat} style={{ marginBottom: 28 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 18 }}>{CAT_ICONS[cat]}</span>
<span style={{ fontWeight: 700, fontSize: 15 }}>{cat}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', background: 'var(--bg-secondary)', padding: '2px 8px', borderRadius: 8 }}>{items.length}</span>
</div>
{items.map(e => {
const isOpen = expanded[e.id];
const tags = JSON.parse(e.tags || '[]');
return (
<div key={e.id} style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 10, marginBottom: 8, overflow: 'hidden' }}>
{/* Titel-Zeile */}
<div onClick={() => setExpanded(p => ({ ...p, [e.id]: !p[e.id] }))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', cursor: 'pointer', userSelect: 'none' }}>
<span style={{ color: 'var(--text-muted)', fontSize: 12, transition: 'transform .2s', transform: isOpen ? 'rotate(90deg)' : 'none' }}></span>
<span style={{ fontWeight: 600, fontSize: 14, flex: 1 }}>{e.title}</span>
{tags.map((t, i) => (
<span key={i} style={{ fontSize: 10, padding: '2px 8px', borderRadius: 8, background: 'rgba(99,102,241,0.1)', color: '#818cf8', border: '1px solid #6366f130' }}>{t}</span>
))}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{new Date(e.updated_at).toLocaleDateString('de-DE')}
</span>
{isAdmin() && (
<div style={{ display: 'flex', gap: 6 }} onClick={ev => ev.stopPropagation()}>
<button onClick={() => setModal({ ...e, tags: JSON.parse(e.tags || '[]') })}
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}></button>
<button onClick={() => remove(e.id)}
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontSize: 11 }}>🗑</button>
</div>
)}
</div>
{/* Inhalt */}
{isOpen && (
<div style={{ borderTop: '1px solid var(--border-color)', padding: '16px 20px', background: 'var(--bg-secondary)' }}>
<pre style={{ margin: 0, fontFamily: 'inherit', fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', lineHeight: 1.7 }}>{e.content}</pre>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--text-muted)' }}>
Erstellt von <strong>{e.author_name || 'System'}</strong> · Aktualisiert {new Date(e.updated_at).toLocaleString('de-DE')}
</div>
</div>
)}
</div>
);
})}
</div>
))}
{modal && <EntryModal initial={modal} onSave={save} onClose={() => setModal(null)} />}
</div>
);
}

View File

@@ -0,0 +1,592 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import aiService from '../services/aiService';
import ticketService from '../services/ticketService';
import { toast } from 'react-toastify';
import { Marked } from 'marked';
const marked = new Marked({ breaks: true, gfm: true });
const renderMd = (text) => ({ __html: marked.parse(text || '') });
const CATEGORIES = ['Allgemein', 'Software', 'Hardware', 'Netzwerk', 'SelectLine', 'Sonstiges'];
const CAT_COLOR = { Software: '#6366f1', Hardware: '#f59e0b', SelectLine: '#10b981', Netzwerk: '#3b82f6', Allgemein: '#64748b', Sonstiges: '#8b5cf6' };
const catColor = (c) => CAT_COLOR[c] || '#64748b';
const EMPTY_FORM = { problem: '', solution: '', category: 'Allgemein', tags: '' };
export default function KnowledgeBasePage() {
const { isAdmin, isSuperAdmin } = useAuth();
const canEdit = isAdmin() || isSuperAdmin();
// Tab state
const [tab, setTab] = useState('artikel');
// KB Articles state
const [articles, setArticles] = useState([]);
const [loadingArt, setLoadingArt] = useState(true);
const [searchArt, setSearchArt] = useState('');
const [catFilter, setCatFilter] = useState('Alle');
const [expanded, setExpanded] = useState(null);
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importTab, setImportTab] = useState('text'); // 'text' | 'file' | 'url'
const [importText, setImportText] = useState('');
const [importFile, setImportFile] = useState(null);
const [importUrl, setImportUrl] = useState('');
const [importCrawl, setImportCrawl] = useState(false);
const [importCrawlMax, setImportCrawlMax] = useState(20);
const [importing, setImporting] = useState(false);
// Closed tickets state
const [tickets, setTickets] = useState([]);
const [loadingTickets, setLoadingTickets] = useState(false);
const [searchTickets, setSearchTickets] = useState('');
const [ticketCat, setTicketCat] = useState('Alle');
const [ticketsLoaded, setTicketsLoaded] = useState(false);
const loadArticles = useCallback(async () => {
setLoadingArt(true);
try {
const data = await aiService.getKnowledgeBase();
setArticles(data);
} catch { toast.error('Fehler beim Laden der Wissensdatenbank'); }
finally { setLoadingArt(false); }
}, []);
const loadTickets = useCallback(async () => {
if (ticketsLoaded) return;
setLoadingTickets(true);
try {
const data = await ticketService.getAll({ status: 'geschlossen' });
setTickets(Array.isArray(data) ? data : []);
setTicketsLoaded(true);
} catch { setTickets([]); }
finally { setLoadingTickets(false); }
}, [ticketsLoaded]);
useEffect(() => { loadArticles(); }, [loadArticles]);
useEffect(() => { if (tab === 'tickets') loadTickets(); }, [tab, loadTickets]);
// ── Article handlers ─────────────────────────────────────────────────────
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setShowModal(true);
};
const openEdit = (art, e) => {
e.stopPropagation();
setEditingId(art.id);
setForm({ problem: art.problem, solution: art.solution, category: art.category || 'Allgemein', tags: art.tags || '' });
setShowModal(true);
};
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
try {
if (editingId) {
await aiService.updateKnowledgeEntry(editingId, form);
toast.success('Artikel aktualisiert');
} else {
await aiService.addKnowledgeEntry(form);
toast.success('Artikel erstellt');
}
setShowModal(false);
loadArticles();
} catch { toast.error('Fehler beim Speichern'); }
finally { setSaving(false); }
};
const handleImport = async (e) => {
e.preventDefault();
setImporting(true);
try {
let result;
if (importTab === 'file' && importFile) {
result = await aiService.importFileToKb(importFile);
} else if (importTab === 'url') {
result = importCrawl
? await aiService.importCrawlToKb(importUrl, importCrawlMax)
: await aiService.importUrlToKb(importUrl);
} else {
result = await aiService.importTextToKb(importText);
}
const pagesInfo = result.pages ? ` (${result.pages} Seiten)` : '';
toast.success(`${result.created} Artikel erstellt${pagesInfo}`);
setShowImport(false);
setImportText('');
setImportFile(null);
setImportUrl('');
setImportCrawl(false);
setImportCrawlMax(20);
setImportTab('text');
loadArticles();
} catch (err) { toast.error(err?.response?.data?.message || err?.message || 'Fehler beim Importieren'); }
finally { setImporting(false); }
};
const handleDelete = async (id, e) => {
e.stopPropagation();
if (!window.confirm('Artikel wirklich löschen?')) return;
try {
await aiService.deleteKnowledgeEntry(id);
toast.success('Artikel gelöscht');
setExpanded(null);
loadArticles();
} catch { toast.error('Fehler beim Löschen'); }
};
const handleImageUpload = async (artId, e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) { toast.error('Max. 5 MB'); return; }
try {
await aiService.uploadKbImage(artId, file);
toast.success('Bild hochgeladen');
loadArticles();
} catch { toast.error('Fehler beim Hochladen'); }
e.target.value = '';
};
const handleImageDelete = async (artId, filename, e) => {
e.stopPropagation();
if (!window.confirm('Bild löschen?')) return;
try {
await aiService.deleteKbImage(artId, filename);
loadArticles();
} catch { toast.error('Fehler beim Löschen'); }
};
// ── Filtered data ─────────────────────────────────────────────────────────
const filteredArticles = articles.filter(a => {
const q = searchArt.toLowerCase();
const matchQ = !q || a.problem?.toLowerCase().includes(q) || a.solution?.toLowerCase().includes(q) || a.tags?.toLowerCase().includes(q);
const matchC = catFilter === 'Alle' || a.category === catFilter;
return matchQ && matchC;
});
const ticketCats = ['Alle', ...Array.from(new Set(tickets.map(t => t.category).filter(Boolean)))];
const filteredTickets = tickets.filter(t => {
const q = searchTickets.toLowerCase();
const matchQ = !q || t.title?.toLowerCase().includes(q) || t.ticket_number?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q);
const matchC = ticketCat === 'Alle' || t.category === ticketCat;
return matchQ && matchC;
});
const formatDate = (d) => d ? new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '—';
const getDuration = (a, b) => {
if (!a || !b) return null;
const h = Math.floor((new Date(b) - new Date(a)) / 3600000);
return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="main-content">
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>📚 Wissensdatenbank</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
{tab === 'artikel'
? `${articles.length} Artikel · durchsuchbare Lösungen`
: `${tickets.length} geschlossene Tickets`}
</p>
</div>
{tab === 'artikel' && canEdit && (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-secondary" onClick={() => setShowImport(true)}>📥 Text importieren</button>
<button className="btn btn-primary" onClick={openCreate}>+ Neuer Artikel</button>
</div>
)}
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border-color)', paddingBottom: 0 }}>
{[
{ key: 'artikel', label: '📖 Artikel', count: articles.length },
{ key: 'tickets', label: '🎫 Geschlossene Tickets', count: ticketsLoaded ? tickets.length : null },
].map(t => (
<button key={t.key} onClick={() => setTab(t.key)} style={{
padding: '8px 18px', border: 'none', background: 'transparent', cursor: 'pointer',
fontSize: '0.875rem', fontWeight: tab === t.key ? 700 : 400,
color: tab === t.key ? 'var(--primary)' : 'var(--text-muted)',
borderBottom: tab === t.key ? '2px solid var(--primary)' : '2px solid transparent',
marginBottom: -1,
}}>
{t.label}{t.count !== null ? <span style={{ marginLeft: 6, fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 10 }}>{t.count}</span> : null}
</button>
))}
</div>
{/* ── ARTIKEL TAB ── */}
{tab === 'artikel' && (
<>
{/* Filter bar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<input className="form-input" style={{ flex: 1, minWidth: 200 }}
placeholder="Suchen in Artikeln…"
value={searchArt} onChange={e => setSearchArt(e.target.value)} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{['Alle', ...CATEGORIES].map(c => (
<button key={c} onClick={() => setCatFilter(c)} style={{
padding: '5px 12px', borderRadius: 20,
border: `1px solid ${catFilter === c ? catColor(c) : 'var(--border-color)'}`,
background: catFilter === c ? catColor(c) : 'transparent',
color: catFilter === c ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer', fontSize: '0.78rem', fontWeight: catFilter === c ? 600 : 400,
}}>{c}</button>
))}
</div>
</div>
{loadingArt ? (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>Lade</div>
) : filteredArticles.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 8 }}>📭</div>
<div>{articles.length === 0 ? 'Noch keine Artikel. Erstelle den ersten!' : 'Keine Treffer.'}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredArticles.map(art => (
<div key={art.id} className="card" style={{ padding: 0, overflow: 'hidden', cursor: 'pointer', borderColor: expanded === art.id ? 'var(--primary)' : undefined }}
onClick={() => setExpanded(expanded === art.id ? null : art.id)}>
{/* Header row */}
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: catColor(art.category), flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{art.problem}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<span style={{ padding: '1px 7px', borderRadius: 10, background: catColor(art.category) + '22', color: catColor(art.category), fontWeight: 600 }}>{art.category}</span>
{art.tags && art.tags.split(',').map(tag => (
<span key={tag.trim()} style={{ padding: '1px 7px', borderRadius: 10, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}>#{tag.trim()}</span>
))}
{art.auto_generated ? <span style={{ color: '#8b5cf6', fontWeight: 600 }}>🤖 KI</span> : null}
{art.source_ticket_id ? <span style={{ color: 'var(--text-muted)' }}>Ticket #{art.source_ticket_id}</span> : null}
<span>📅 {formatDate(art.created_at)}</span>
{art.created_by_username && <span>von {art.created_by_username}</span>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{canEdit && (
<>
<button className="btn btn-secondary btn-small" onClick={(e) => openEdit(art, e)}
style={{ fontSize: '11px', padding: '3px 8px' }}></button>
<button className="btn btn-danger btn-small" onClick={(e) => handleDelete(art.id, e)}
style={{ fontSize: '11px', padding: '3px 8px' }}>🗑</button>
</>
)}
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem' }}>{expanded === art.id ? '▲' : '▼'}</span>
</div>
</div>
{/* Expanded solution */}
{expanded === art.id && (
<div style={{ padding: '0 16px 14px 16px', borderTop: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '12px 0 6px' }}>
Lösung
</div>
<div className="md-content" style={{ fontSize: '0.875rem', lineHeight: 1.6, color: 'var(--text-primary)' }}
dangerouslySetInnerHTML={renderMd(art.solution)} />
{/* Images */}
{(() => {
const imgs = (() => { try { return JSON.parse(art.images || '[]'); } catch { return []; } })();
return (imgs.length > 0 || canEdit) && (
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
Bilder
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-start' }}>
{imgs.map(img => (
<div key={img} style={{ position: 'relative', display: 'inline-block' }}>
<img src={`/uploads/kb/${img}`} alt={img}
style={{ maxWidth: 220, maxHeight: 160, borderRadius: 6, border: '1px solid var(--border-color)', cursor: 'pointer', display: 'block' }}
onClick={e => { e.stopPropagation(); window.open(`/uploads/kb/${img}`, '_blank'); }} />
{canEdit && (
<button onClick={e => handleImageDelete(art.id, img, e)}
style={{ position: 'absolute', top: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '2px 6px', cursor: 'pointer', fontSize: '11px' }}>
×
</button>
)}
</div>
))}
{canEdit && (
<label onClick={e => e.stopPropagation()} style={{ width: 80, height: 80, border: '2px dashed var(--border-color)', borderRadius: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: '0.72rem', color: 'var(--text-muted)', gap: 4 }}>
<span style={{ fontSize: '1.2rem' }}>+</span>
<span>Bild</span>
<input type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => handleImageUpload(art.id, e)} />
</label>
)}
</div>
</div>
);
})()}
</div>
)}
</div>
))}
</div>
)}
</>
)}
{/* ── TICKETS TAB ── */}
{tab === 'tickets' && (
<>
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<input className="form-input" style={{ flex: 1, minWidth: 200 }}
placeholder="Suchen nach Titel, Ticketnummer…"
value={searchTickets} onChange={e => setSearchTickets(e.target.value)} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{ticketCats.map(c => (
<button key={c} onClick={() => setTicketCat(c)} style={{
padding: '5px 12px', borderRadius: 20,
border: `1px solid ${ticketCat === c ? catColor(c) : 'var(--border-color)'}`,
background: ticketCat === c ? catColor(c) : 'transparent',
color: ticketCat === c ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer', fontSize: '0.78rem', fontWeight: ticketCat === c ? 600 : 400,
}}>{c}</button>
))}
</div>
</div>
{loadingTickets ? (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>Lade</div>
) : filteredTickets.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 8 }}>📭</div>
<div>Keine geschlossenen Tickets gefunden</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredTickets.map(t => (
<Link key={t.id} to={`/tickets/${t.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="card" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 14, transition: 'border-color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-color)'}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: catColor(t.category), flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: 'var(--text-muted)' }}>{t.ticket_number}</span>
<span style={{ fontWeight: 600, fontSize: '0.875rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</span>
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: 2 }}>
{t.requester_name && <span>👤 {t.requester_name}</span>}
{t.assigned_to_username && <span style={{ marginLeft: 10 }}>🛠 {t.assigned_to_first_name || ''} {t.assigned_to_last_name || t.assigned_to_username}</span>}
</div>
</div>
<span style={{ padding: '2px 8px', borderRadius: 10, background: catColor(t.category) + '22', color: catColor(t.category), fontSize: '0.72rem', fontWeight: 600, flexShrink: 0 }}>
{t.category}
</span>
{t.satisfaction_rating === 'gut' && (
<span title="Feedback: Positiv" style={{ fontSize: '1rem', flexShrink: 0 }}>👍</span>
)}
{t.satisfaction_rating === 'schlecht' && (
<span title="Feedback: Negativ" style={{ fontSize: '1rem', flexShrink: 0 }}>👎</span>
)}
<div style={{ textAlign: 'right', flexShrink: 0, fontSize: '0.72rem', color: 'var(--text-muted)' }}>
<div> {formatDate(t.closed_at || t.updated_at)}</div>
{getDuration(t.created_at, t.closed_at || t.updated_at) && (
<div style={{ marginTop: 2 }}> {getDuration(t.created_at, t.closed_at || t.updated_at)}</div>
)}
</div>
</div>
</Link>
))}
</div>
)}
</>
)}
</div>
{/* ── Import Modal ── */}
{showImport && (
<div className="modal-overlay" onClick={() => setShowImport(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">📥 In Wissensdatenbank importieren</h2>
<button className="modal-close" onClick={() => { setShowImport(false); setImportTab('text'); setImportFile(null); setImportUrl(''); }}>×</button>
</div>
{/* Sub-tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 24px' }}>
{[{ key: 'text', label: '📝 Text einfügen' }, { key: 'file', label: '📎 Datei hochladen' }, { key: 'url', label: '🌐 URL importieren' }].map(t => (
<button key={t.key} type="button" onClick={() => setImportTab(t.key)} style={{
padding: '10px 14px', border: 'none', background: 'transparent', cursor: 'pointer',
fontSize: '0.83rem', fontWeight: importTab === t.key ? 700 : 400,
color: importTab === t.key ? 'var(--primary)' : 'var(--text-muted)',
borderBottom: importTab === t.key ? '2px solid var(--primary)' : '2px solid transparent',
marginBottom: -1,
}}>{t.label}</button>
))}
</div>
<form onSubmit={handleImport}>
<div style={{ padding: '16px 24px 0' }}>
{importTab === 'text' ? (
<>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: 12, marginTop: 0 }}>
Füge beliebigen Text ein (Handbuch, Anleitung, E-Mail-Inhalt usw.).<br />
Die KI extrahiert automatisch Problem/Lösungs-Paare.
</p>
<div className="form-group">
<textarea className="form-textarea" rows="12" required={importTab === 'text'}
placeholder="Text hier einfügen…"
value={importText}
onChange={e => setImportText(e.target.value)} />
</div>
</>
) : importTab === 'file' ? (
<>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: 16, marginTop: 0 }}>
Lade eine Datei hoch. Unterstützte Formate: <strong>PDF, DOCX, DOC, EML, TXT</strong>.<br />
Der Inhalt wird von der KI gelesen und in Artikel umgewandelt.
</p>
<label style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
border: '2px dashed var(--border-color)', borderRadius: 8, padding: '2rem', cursor: 'pointer',
background: 'var(--bg-secondary)', gap: 8, marginBottom: 16,
borderColor: importFile ? 'var(--primary)' : 'var(--border-color)',
}}>
<span style={{ fontSize: '2rem' }}>{importFile ? '✅' : '📂'}</span>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)' }}>
{importFile ? importFile.name : 'Datei auswählen'}
</span>
{importFile && (
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
{(importFile.size / 1024).toFixed(0)} KB
</span>
)}
<input type="file" accept=".pdf,.docx,.doc,.eml,.txt" style={{ display: 'none' }}
onChange={e => setImportFile(e.target.files?.[0] || null)} />
</label>
{importFile && (
<button type="button" className="btn btn-secondary btn-small"
onClick={() => setImportFile(null)}
style={{ marginBottom: 8 }}>
Datei entfernen
</button>
)}
</>
) : importTab === 'url' ? (
<>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: 16, marginTop: 0 }}>
Gib die URL einer Hilfe- oder Dokumentationsseite ein.<br />
Die KI liest den Inhalt und erstellt daraus Wissensdatenbank-Artikel.
</p>
<div className="form-group">
<label className="form-label">URL *</label>
<input className="form-input" type="url" required={importTab === 'url'}
placeholder="https://hilfe.selectline.de/…"
value={importUrl}
onChange={e => setImportUrl(e.target.value)} />
</div>
{/* Crawler option */}
<div style={{ marginTop: 14, padding: '12px 14px', background: 'var(--bg-secondary)', borderRadius: 8, border: `1px solid ${importCrawl ? 'var(--primary)' : 'var(--border-color)'}` }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={importCrawl} onChange={e => setImportCrawl(e.target.checked)}
style={{ width: 16, height: 16, cursor: 'pointer' }} />
<div>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>🕷 Unterseiten automatisch crawlen</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 2 }}>
Folgt allen Links auf derselben Domain &amp; demselben Pfad
</div>
</div>
</label>
{importCrawl && (
<div style={{ marginTop: 12 }}>
<label style={{ fontSize: '0.8rem', fontWeight: 600, display: 'block', marginBottom: 6 }}>
Max. Seiten: <span style={{ color: 'var(--primary)' }}>{importCrawlMax}</span>
</label>
<input type="range" min={2} max={100} step={1}
value={importCrawlMax} onChange={e => setImportCrawlMax(parseInt(e.target.value))}
style={{ width: '100%', accentColor: 'var(--primary)' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
<span>2</span><span>100</span>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 6 }}>
Mehr Seiten = längere Wartezeit. Bei 20+ Seiten kann es 12 Minuten dauern.
</div>
</div>
)}
</div>
</>
) : null}
</div>
<div className="card-footer" style={{ marginTop: 8 }}>
<button type="button" className="btn btn-secondary" onClick={() => { setShowImport(false); setImportTab('text'); setImportFile(null); setImportUrl(''); }}>Abbrechen</button>
<button type="submit" className="btn btn-primary"
disabled={importing || (importTab === 'text' ? importText.trim().length < 20 : importTab === 'file' ? !importFile : importUrl.trim().length < 10)}>
{importing
? (importTab === 'url' && importCrawl ? `🕷️ Crawle Seiten…` : '🤖 KI analysiert…')
: '🤖 Artikel generieren'}
</button>
</div>
</form>
</div>
</div>
)}
{/* ── Create / Edit Modal ── */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingId ? 'Artikel bearbeiten' : 'Neuer Artikel'}</h2>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<form onSubmit={handleSave}>
<div style={{ padding: '0 24px' }}>
<div className="form-group">
<label className="form-label">Problem / Titel *</label>
<input className="form-input" required placeholder="Kurze Problembeschreibung…"
value={form.problem} onChange={e => setForm({ ...form, problem: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">Lösung *</label>
<textarea className="form-textarea" rows="6" required placeholder="Schritt-für-Schritt-Lösung…"
value={form.solution} onChange={e => setForm({ ...form, solution: e.target.value })} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Kategorie</label>
<select className="form-select" value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Tags (kommagetrennt)</label>
<input className="form-input" placeholder="z.B. vpn, outlook, drucker"
value={form.tags} onChange={e => setForm({ ...form, tags: e.target.value })} />
</div>
</div>
</div>
<div className="card-footer" style={{ marginTop: 16 }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>Abbrechen</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern…' : editingId ? 'Aktualisieren' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,746 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import licenseService from '../services/licenseService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const LICENSE_TYPES = [
{ value: 'subscription', label: 'Abonnement' },
{ value: 'oem', label: 'OEM' },
{ value: 'volume', label: 'Volumenlizenz' },
{ value: 'perpetual', label: 'Dauerlizenz' },
];
const TYPE_META = {
subscription: { label: 'Abonnement', color: '#0ea5e9', bg: 'rgba(14,165,233,0.12)' },
oem: { label: 'OEM', color: '#a78bfa', bg: 'rgba(167,139,250,0.12)' },
volume: { label: 'Volumen', color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
perpetual: { label: 'Dauerlizenz', color: '#fb923c', bg: 'rgba(251,146,60,0.12)' },
};
const VENDOR_ICON = (vendor) => {
if (!vendor) return '📦';
const v = vendor.toLowerCase();
if (v.includes('microsoft')) return '🪟';
if (v.includes('adobe')) return '🅰️';
if (v.includes('google')) return '🔍';
if (v.includes('apple')) return '🍎';
if (v.includes('cisco')) return '🔗';
if (v.includes('vmware') || v.includes('broadcom')) return '☁️';
return '📦';
};
const EMPTY_FORM = {
name: '', vendor: '', license_type: 'subscription',
product_key: '', seats: '', purchase_date: '', expiry_date: '', cost: '', notes: '',
};
const today = new Date().toISOString().slice(0, 10);
const in30 = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const getExpiryStatus = (d) => {
if (!d) return null;
if (d < today) return 'expired';
if (d <= in30) return 'soon';
return 'ok';
};
/* ── Formular-Sektion ───────────────────────────────────────── */
const Section = ({ title, children }) => (
<div>
<div style={{
fontSize: '10.5px', fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.09em', color: 'var(--text-muted)',
marginBottom: '10px', paddingBottom: '6px',
borderBottom: '1px solid var(--border-color)',
}}>{title}</div>
{children}
</div>
);
/* ── Typ-Badge ──────────────────────────────────────────────── */
const TypeBadge = ({ type }) => {
const m = TYPE_META[type] || { label: type, color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' };
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: '20px',
fontSize: '11.5px', fontWeight: 600, letterSpacing: '0.02em',
color: m.color, background: m.bg, border: `1px solid ${m.color}33`,
}}>
{m.label}
</span>
);
};
/* ── Sitzplatz-Balken ───────────────────────────────────────── */
const SeatsDisplay = ({ seats, notes }) => {
if (seats === null || seats === undefined) return <span style={{ color: 'var(--text-muted)' }}></span>;
const match = notes && notes.match(/Genutzt:\s*(\d+)\s*\/\s*\d+/);
const used = match ? parseInt(match[1]) : null;
const pct = used !== null ? Math.min(100, Math.round((used / seats) * 100)) : null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px' }}>
<span style={{ fontSize: '13px', fontWeight: 600 }}>
{used !== null ? `${used} / ${seats}` : seats}
</span>
{pct !== null && (
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--border-color)', width: '60px' }}>
<div style={{
height: '100%', borderRadius: '2px', width: `${pct}%`,
background: pct >= 90 ? '#ef4444' : pct >= 70 ? '#f59e0b' : '#10b981',
transition: 'width 0.3s',
}} />
</div>
)}
</div>
);
};
const LicensesPage = () => {
const { isAdmin } = useAuth();
const canEdit = isAdmin();
const [licenses, setLicenses] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState('');
const [filterExpiry, setFilterExpiry] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingLicense, setEditingLicense] = useState(null);
const [formData, setFormData] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [showKey, setShowKey] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingLicense, setDeletingLicense] = useState(null);
const [entraLoading, setEntraLoading] = useState(false);
// User-list modal state
const [showUsersModal, setShowUsersModal] = useState(false);
const [usersLicense, setUsersLicense] = useState(null);
const [usersData, setUsersData] = useState([]);
const [usersLoading, setUsersLoading] = useState(false);
const [usersSearch, setUsersSearch] = useState('');
const [usersStatusFilter, setUsersStatusFilter] = useState('');
const loadLicenses = useCallback(async () => {
try { setLicenses(await licenseService.getAll()); }
catch { toast.error('Fehler beim Laden der Lizenzen'); }
finally { setLoading(false); }
}, []);
useEffect(() => { loadLicenses(); }, [loadLicenses]);
const openCreate = () => { setEditingLicense(null); setFormData(EMPTY_FORM); setShowKey(false); setShowModal(true); };
const openEdit = (l) => {
setEditingLicense(l);
setFormData({
name: l.name || '', vendor: l.vendor || '', license_type: l.license_type || 'subscription',
product_key: l.product_key || '',
seats: l.seats !== null && l.seats !== undefined ? String(l.seats) : '',
purchase_date: l.purchase_date || '', expiry_date: l.expiry_date || '',
cost: l.cost !== null && l.cost !== undefined ? String(l.cost) : '',
notes: l.notes || '',
});
setShowKey(false); setShowModal(true);
};
const openUsers = async (license) => {
setUsersLicense(license);
setUsersData([]);
setUsersSearch('');
setUsersStatusFilter('');
setShowUsersModal(true);
setUsersLoading(true);
try {
const data = await licenseService.getLicenseUsers(license.id);
setUsersData(data);
} catch (err) {
toast.error(err.response?.data?.message || 'Benutzer konnten nicht geladen werden');
setShowUsersModal(false);
} finally {
setUsersLoading(false);
}
};
const set = (f) => (e) => setFormData(p => ({ ...p, [f]: e.target.value }));
const handleSave = async (e) => {
e.preventDefault();
if (!formData.name.trim()) { toast.error('Name ist erforderlich'); return; }
setSaving(true);
try {
if (editingLicense) { await licenseService.update(editingLicense.id, formData); toast.success('Lizenz aktualisiert'); }
else { await licenseService.create(formData); toast.success('Lizenz erstellt'); }
setShowModal(false); loadLicenses();
} catch (err) { toast.error(err.response?.data?.message || 'Fehler beim Speichern'); }
finally { setSaving(false); }
};
const handleDelete = async () => {
try {
await licenseService.delete(deletingLicense.id);
toast.success('Lizenz gelöscht');
setShowDeleteModal(false); setDeletingLicense(null); loadLicenses();
} catch { toast.error('Fehler beim Löschen'); }
};
const handleEntraImport = async () => {
setEntraLoading(true);
try {
const result = await licenseService.importFromEntra();
const msg = [];
if (result.imported > 0) msg.push(`${result.imported} neu importiert`);
if (result.updated > 0) msg.push(`${result.updated} aktualisiert`);
if (result.skipped > 0) msg.push(`${result.skipped} übersprungen`);
if (result.imported > 0 || result.updated > 0) {
toast.success(msg.join(' · '));
loadLicenses();
} else {
toast.info(msg.join(' · ') || 'Keine Lizenzen gefunden');
}
} catch (err) { toast.error(err.response?.data?.message || 'Entra-Import fehlgeschlagen'); }
finally { setEntraLoading(false); }
};
const filtered = licenses.filter((l) => {
const q = search.toLowerCase();
const matchSearch = !q || l.name.toLowerCase().includes(q) || (l.vendor || '').toLowerCase().includes(q);
const matchType = !filterType || l.license_type === filterType;
const s = getExpiryStatus(l.expiry_date);
const matchExpiry = !filterExpiry ||
(filterExpiry === 'expired' && s === 'expired') ||
(filterExpiry === 'soon' && s === 'soon') ||
(filterExpiry === 'ok' && (s === 'ok' || s === null));
return matchSearch && matchType && matchExpiry;
});
const filteredUsers = usersData.filter((u) => {
const q = usersSearch.toLowerCase();
const matchSearch = !q
|| (u.displayName || '').toLowerCase().includes(q)
|| (u.mail || '').toLowerCase().includes(q)
|| (u.userPrincipalName || '').toLowerCase().includes(q);
const matchStatus = !usersStatusFilter
|| (usersStatusFilter === 'active' && u.accountEnabled !== false)
|| (usersStatusFilter === 'disabled' && u.accountEnabled === false);
return matchSearch && matchStatus;
});
const expiredCount = licenses.filter(l => l.expiry_date && l.expiry_date < today).length;
const soonCount = licenses.filter(l => l.expiry_date && l.expiry_date >= today && l.expiry_date <= in30).length;
if (loading) return <div className="main-content"><LoadingSpinner /></div>;
return (
<div className="main-content">
<div className="container">
{/* ── Header ─────────────────────────────────── */}
<div className="page-header">
<div>
<h1>Software-Lizenzen</h1>
<p className="page-subtitle">
{licenses.length} Lizenz{licenses.length !== 1 ? 'en' : ''} verwaltet
{expiredCount > 0 && <span style={{ color: '#ef4444', marginLeft: '10px' }}>· {expiredCount} abgelaufen</span>}
{soonCount > 0 && <span style={{ color: '#f59e0b', marginLeft: '10px' }}>· {soonCount} läuft bald ab</span>}
</p>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn btn-secondary" onClick={handleEntraImport} disabled={entraLoading}>
{entraLoading ? '⏳ Importiere...' : '☁️ Aus Entra importieren'}
</button>
<button className="btn btn-primary" onClick={openCreate}>+ Neue Lizenz</button>
</div>
)}
</div>
{/* ── Status-Karten ───────────────────────────── */}
{(expiredCount > 0 || soonCount > 0) && (
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px', flexWrap: 'wrap' }}>
{expiredCount > 0 && (
<div className="stat-card stat-card-danger" style={{ cursor: 'pointer', flex: 'none', padding: '12px 20px' }}
onClick={() => setFilterExpiry(filterExpiry === 'expired' ? '' : 'expired')}>
<div className="stat-icon" style={{ fontSize: '18px' }}>🔴</div>
<div className="stat-content">
<div className="stat-value" style={{ fontSize: '22px' }}>{expiredCount}</div>
<div className="stat-label">Abgelaufen</div>
</div>
</div>
)}
{soonCount > 0 && (
<div className="stat-card stat-card-warning" style={{ cursor: 'pointer', flex: 'none', padding: '12px 20px' }}
onClick={() => setFilterExpiry(filterExpiry === 'soon' ? '' : 'soon')}>
<div className="stat-icon" style={{ fontSize: '18px' }}></div>
<div className="stat-content">
<div className="stat-value" style={{ fontSize: '22px' }}>{soonCount}</div>
<div className="stat-label">Läuft in 30 Tagen ab</div>
</div>
</div>
)}
</div>
)}
{/* ── Filter ──────────────────────────────────── */}
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', flexWrap: 'wrap', alignItems: 'center' }}>
<input type="text" className="form-control" placeholder="Suche nach Name oder Hersteller..."
value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: '300px' }} />
<select className="form-control" value={filterType} onChange={(e) => setFilterType(e.target.value)} style={{ maxWidth: '170px' }}>
<option value="">Alle Typen</option>
{LICENSE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<select className="form-control" value={filterExpiry} onChange={(e) => setFilterExpiry(e.target.value)} style={{ maxWidth: '170px' }}>
<option value="">Alle Status</option>
<option value="expired">Abgelaufen</option>
<option value="soon">Läuft bald ab</option>
<option value="ok">Aktiv</option>
</select>
{(search || filterType || filterExpiry) && (
<button className="btn btn-secondary" onClick={() => { setSearch(''); setFilterType(''); setFilterExpiry(''); }}>
Zurücksetzen
</button>
)}
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: '13px' }}>
{filtered.length} Ergebnis{filtered.length !== 1 ? 'se' : ''}
</span>
</div>
{/* ── Tabelle ─────────────────────────────────── */}
<div className="card" style={{ overflowX: 'auto' }}>
<table className="table" style={{ tableLayout: 'fixed', minWidth: '1020px' }}>
<colgroup>
<col style={{ width: '24%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<th>Name</th>
<th>Hersteller</th>
<th>Typ</th>
<th>Seats / Nutzung</th>
<th>Kaufdatum</th>
<th>Ablaufdatum</th>
<th>Kosten ()</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan="8" style={{ textAlign: 'center', padding: '48px', color: 'var(--text-muted)' }}>
Keine Lizenzen gefunden
</td></tr>
) : filtered.map((license) => {
const status = getExpiryStatus(license.expiry_date);
const rowStyle = status === 'expired'
? { backgroundColor: 'rgba(239,68,68,0.06)', borderLeft: '3px solid #ef4444' }
: status === 'soon'
? { backgroundColor: 'rgba(245,158,11,0.06)', borderLeft: '3px solid #f59e0b' }
: {};
return (
<tr key={license.id} style={rowStyle}>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={license.name}>
<span style={{ fontWeight: 600 }}>{license.name}</span>
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span title={license.vendor || ''} style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span>{VENDOR_ICON(license.vendor)}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>{license.vendor || '—'}</span>
</span>
</td>
<td><TypeBadge type={license.license_type} /></td>
<td><SeatsDisplay seats={license.seats} notes={license.notes} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: '13px' }}>
{license.purchase_date ? new Date(license.purchase_date).toLocaleDateString('de-DE') : '—'}
</td>
<td>
{license.expiry_date ? (
<span style={{
display: 'flex', alignItems: 'center', gap: '5px', fontSize: '13px',
color: status === 'expired' ? '#ef4444' : status === 'soon' ? '#f59e0b' : 'var(--text-muted)',
fontWeight: status ? 600 : 400,
}}>
{status === 'expired' && '🔴'}
{status === 'soon' && '⚠️'}
{new Date(license.expiry_date).toLocaleDateString('de-DE')}
</span>
) : <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ fontSize: '13px' }}>
{license.cost !== null && license.cost !== undefined
? <span style={{ fontWeight: 600 }}>{parseFloat(license.cost).toLocaleString('de-DE', { minimumFractionDigits: 2 })} </span>
: <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td>
<div style={{ display: 'flex', gap: '5px', flexWrap: 'wrap' }}>
{license.sku_id && (
<button
className="btn btn-sm btn-secondary"
onClick={() => openUsers(license)}
title="Zugewiesene Benutzer anzeigen"
style={{ padding: '4px 8px', minWidth: 0 }}
>👥</button>
)}
{canEdit && (<>
<button
className="btn btn-sm btn-secondary"
onClick={() => openEdit(license)}
title="Bearbeiten"
style={{ padding: '4px 8px', minWidth: 0 }}
></button>
<button
className="btn btn-sm btn-danger"
onClick={() => { setDeletingLicense(license); setShowDeleteModal(true); }}
title="Löschen"
style={{ padding: '4px 8px', minWidth: 0 }}
>🗑</button>
</>)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* ════════════ Create / Edit Modal ════════════ */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" style={{ maxWidth: '640px', width: '95%' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 style={{ margin: 0 }}>{editingLicense ? 'Lizenz bearbeiten' : 'Neue Lizenz'}</h2>
<p style={{ margin: '2px 0 0', fontSize: '13px', color: 'var(--text-muted)' }}>
{editingLicense ? editingLicense.name : 'Neue Software-Lizenz anlegen'}
</p>
</div>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<form onSubmit={handleSave}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<Section title="Produkt">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">Name *</label>
<input type="text" className="form-control" value={formData.name} onChange={set('name')}
placeholder="z.B. Microsoft 365 Business Premium" required autoFocus />
</div>
<div className="form-group">
<label className="form-label">Hersteller</label>
<input type="text" className="form-control" value={formData.vendor} onChange={set('vendor')} placeholder="z.B. Microsoft" />
</div>
<div className="form-group">
<label className="form-label">Lizenztyp</label>
<select className="form-control" value={formData.license_type} onChange={set('license_type')}>
{LICENSE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
</div>
</Section>
<Section title="Umfang & Kosten">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div className="form-group">
<label className="form-label">Anzahl Seats</label>
<input type="number" className="form-control" value={formData.seats} onChange={set('seats')}
placeholder="Leer = unbegrenzt" min="1" />
</div>
<div className="form-group">
<label className="form-label">Kosten ()</label>
<input type="number" className="form-control" value={formData.cost} onChange={set('cost')}
placeholder="0.00" min="0" step="0.01" />
</div>
</div>
</Section>
<Section title="Laufzeit">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div className="form-group">
<label className="form-label">Kaufdatum</label>
<input type="date" className="form-control" value={formData.purchase_date} onChange={set('purchase_date')} />
</div>
<div className="form-group">
<label className="form-label">Ablaufdatum</label>
<input type="date" className="form-control" value={formData.expiry_date} onChange={set('expiry_date')}
style={formData.expiry_date && formData.expiry_date < today
? { borderColor: '#ef4444', color: '#ef4444' } : {}} />
</div>
</div>
</Section>
<Section title="Lizenzschlüssel">
<div className="form-group">
<div style={{ display: 'flex', gap: '8px' }}>
<input
type={showKey ? 'text' : 'password'}
className="form-control"
value={formData.product_key}
onChange={set('product_key')}
placeholder="XXXXX-XXXXX-XXXXX-XXXXX (optional)"
style={{ fontFamily: 'monospace' }}
/>
<button type="button" className="btn btn-secondary"
onClick={() => setShowKey(!showKey)} style={{ whiteSpace: 'nowrap', minWidth: '100px' }}>
{showKey ? '🙈 Verbergen' : '👁 Anzeigen'}
</button>
</div>
</div>
</Section>
<Section title="Notizen">
<div className="form-group">
<textarea className="form-control" rows={3} value={formData.notes} onChange={set('notes')}
placeholder="Weitere Informationen zur Lizenz..." style={{ resize: 'vertical' }} />
</div>
</Section>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>Abbrechen</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern...' : (editingLicense ? 'Änderungen speichern' : 'Lizenz anlegen')}
</button>
</div>
</form>
</div>
</div>
)}
{/* ════════════ Users Modal ════════════ */}
{showUsersModal && usersLicense && (
<div className="modal-overlay" onClick={() => setShowUsersModal(false)}
style={{ backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', background: 'rgba(0,0,0,0.65)' }}>
<div className="modal" style={{
maxWidth: '620px', width: '95%', overflow: 'hidden',
background: 'linear-gradient(160deg, var(--bg-card, #1e2433) 0%, var(--bg-secondary, #161b27) 100%)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(14,165,233,0.08)',
}} onClick={(e) => e.stopPropagation()}>
{/* Header with gradient accent */}
<div style={{
background: 'linear-gradient(135deg, rgba(14,165,233,0.12) 0%, rgba(99,102,241,0.08) 100%)',
borderBottom: '1px solid var(--border-color)',
padding: '20px 24px',
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', minWidth: 0 }}>
<div style={{
width: '44px', height: '44px', borderRadius: '12px', flexShrink: 0,
background: 'rgba(14,165,233,0.15)', border: '1px solid rgba(14,165,233,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '22px',
}}>👥</div>
<div style={{ minWidth: 0 }}>
<h2 style={{ margin: 0, fontSize: '17px' }}>Zugewiesene Benutzer</h2>
<p style={{ margin: '3px 0 0', fontSize: '13px', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{VENDOR_ICON(usersLicense.vendor)}&nbsp;{usersLicense.name}
</p>
</div>
</div>
<button className="modal-close" onClick={() => setShowUsersModal(false)} style={{ flexShrink: 0 }}>×</button>
</div>
{/* Stats bar */}
{!usersLoading && usersData.length > 0 && (
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)' }}>
{[
{ key: '', label: 'Gesamt', value: usersData.length, color: '#0ea5e9' },
{ key: 'active', label: 'Aktiv', value: usersData.filter(u => u.accountEnabled !== false).length, color: '#10b981' },
{ key: 'disabled', label: 'Deaktiviert', value: usersData.filter(u => u.accountEnabled === false).length, color: '#ef4444' },
].map((s, i) => {
const isActive = usersStatusFilter === s.key;
return (
<div key={i} onClick={() => setUsersStatusFilter(isActive ? '' : s.key)}
style={{
flex: 1, padding: '12px 16px', textAlign: 'center',
cursor: 'pointer', userSelect: 'none',
borderRight: i < 2 ? '1px solid var(--border-color)' : 'none',
background: isActive ? `${s.color}18` : 'transparent',
borderBottom: isActive ? `2px solid ${s.color}` : '2px solid transparent',
transition: 'background 0.15s, border-color 0.15s',
}}
>
<div style={{ fontSize: '20px', fontWeight: 700, color: s.color }}>{s.value}</div>
<div style={{
fontSize: '10.5px', textTransform: 'uppercase', letterSpacing: '0.06em',
color: isActive ? s.color : 'var(--text-muted)',
fontWeight: isActive ? 700 : 400,
marginTop: '2px',
}}>{s.label}</div>
</div>
);
})}
</div>
)}
<div className="modal-body" style={{ padding: '16px 20px' }}>
{usersLoading ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '48px', gap: '14px' }}>
<LoadingSpinner />
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Benutzer werden aus Entra ID geladen</span>
</div>
) : usersData.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '40px', marginBottom: '12px', opacity: 0.5 }}>👤</div>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>Keine Benutzer gefunden</div>
<div style={{ fontSize: '13px' }}>Dieser Lizenz ist kein Benutzer zugewiesen</div>
</div>
) : (
<>
{/* Search */}
<div style={{ position: 'relative', marginBottom: '14px' }}>
<svg style={{
position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)',
color: 'var(--text-muted)', pointerEvents: 'none', flexShrink: 0,
}} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
className="form-control"
placeholder="Name oder E-Mail suchen…"
value={usersSearch}
onChange={(e) => setUsersSearch(e.target.value)}
style={{
paddingLeft: '38px',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '10px',
fontSize: '13.5px',
transition: 'border-color 0.2s, box-shadow 0.2s',
}}
onFocus={e => { e.target.style.borderColor = 'rgba(14,165,233,0.5)'; e.target.style.boxShadow = '0 0 0 3px rgba(14,165,233,0.1)'; }}
onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.1)'; e.target.style.boxShadow = 'none'; }}
autoFocus
/>
{usersSearch && (
<button onClick={() => setUsersSearch('')} style={{
position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text-muted)', fontSize: '16px', lineHeight: 1, padding: '2px',
}}>×</button>
)}
</div>
{/* User list */}
<div style={{ maxHeight: '360px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '5px',
paddingRight: '2px',
}}>
{filteredUsers.length === 0 ? (
<div style={{ textAlign: 'center', padding: '28px', color: 'var(--text-muted)', fontSize: '13px' }}>
Kein Treffer für {usersSearch}"
</div>
) : filteredUsers.map((user) => {
const initials = (user.displayName || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
const disabled = user.accountEnabled === false;
// Deterministic avatar color from name
const colors = ['#0ea5e9','#8b5cf6','#10b981','#f59e0b','#ef4444','#ec4899','#14b8a6'];
const colorIdx = (user.displayName || '').charCodeAt(0) % colors.length;
const avatarColor = colors[colorIdx];
return (
<div key={user.id} style={{
display: 'flex', alignItems: 'center', gap: '12px',
padding: '10px 12px', borderRadius: '10px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
transition: 'border-color 0.15s',
opacity: disabled ? 0.5 : 1,
}}>
{/* Avatar */}
<div style={{
width: '38px', height: '38px', borderRadius: '10px', flexShrink: 0,
background: `${avatarColor}22`,
border: `1px solid ${avatarColor}44`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '13px', fontWeight: 700, color: avatarColor,
letterSpacing: '-0.5px',
}}>
{initials}
</div>
{/* Info */}
<div style={{ overflow: 'hidden', flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: 600, fontSize: '13.5px',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{user.displayName || user.userPrincipalName}
</div>
<div style={{
fontSize: '12px', color: 'var(--text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{user.mail || user.userPrincipalName}
</div>
</div>
{/* Status */}
{disabled ? (
<span style={{
fontSize: '11px', padding: '3px 9px', borderRadius: '20px', flexShrink: 0,
background: 'rgba(239,68,68,0.1)', color: '#ef4444',
border: '1px solid rgba(239,68,68,0.2)', fontWeight: 600,
}}>Deaktiviert</span>
) : (
<span style={{
fontSize: '11px', padding: '3px 9px', borderRadius: '20px', flexShrink: 0,
background: 'rgba(16,185,129,0.1)', color: '#10b981',
border: '1px solid rgba(16,185,129,0.2)', fontWeight: 600,
}}>Aktiv</span>
)}
</div>
);
})}
</div>
</>
)}
</div>
<div className="modal-footer">
{!usersLoading && usersData.length > 0 && (
<span style={{ fontSize: '12px', color: 'var(--text-muted)', marginRight: 'auto' }}>
{filteredUsers.length} von {usersData.length} Benutzer{usersData.length !== 1 ? 'n' : ''}
{(usersSearch || usersStatusFilter) && (
<button onClick={() => { setUsersSearch(''); setUsersStatusFilter(''); }}
style={{ marginLeft: '10px', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '12px', textDecoration: 'underline', padding: 0 }}>
Filter zurücksetzen
</button>
)}
</span>
)}
<button className="btn btn-secondary" onClick={() => setShowUsersModal(false)}>Schließen</button>
</div>
</div>
</div>
)}
{/* ════════════ Delete Modal ════════════ */}
{showDeleteModal && deletingLicense && (
<div className="modal-overlay" onClick={() => setShowDeleteModal(false)}>
<div className="modal" style={{ maxWidth: '420px' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Lizenz löschen</h2>
<button className="modal-close" onClick={() => setShowDeleteModal(false)}>×</button>
</div>
<div className="modal-body">
<p>Soll <strong>{deletingLicense.name}</strong> wirklich gelöscht werden?</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Abbrechen</button>
<button className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
export default LicensesPage;

View File

@@ -0,0 +1,313 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { toast } from 'react-toastify';
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showForgotModal, setShowForgotModal] = useState(false);
const { login, loginWithToken } = useAuth();
const navigate = useNavigate();
const errRef = useRef(null);
// Handle OAuth2 callback
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const userParam = params.get('user');
const err = params.get('error');
if (err) {
const messages = {
auth_failed: 'Microsoft-Anmeldung fehlgeschlagen.',
account_inactive: 'Ihr Konto ist deaktiviert. Bitte kontaktieren Sie den Administrator.',
azure_not_configured: 'Microsoft-Login ist nicht konfiguriert.',
token_failed: 'Token-Austausch fehlgeschlagen.',
internal_error: 'Interner Fehler beim Microsoft-Login.',
};
setError(messages[err] || 'Anmeldung fehlgeschlagen.');
window.history.replaceState({}, '', '/login');
return;
}
if (token && userParam) {
try {
const user = JSON.parse(decodeURIComponent(userParam));
loginWithToken(token, user);
window.history.replaceState({}, '', '/');
toast.success(`Willkommen, ${user.first_name || user.username}!`);
navigate(user.must_change_password ? '/change-password' : '/dashboard');
} catch {
setError('Fehler bei der Microsoft-Anmeldung.');
window.history.replaceState({}, '', '/login');
}
}
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError('Bitte Benutzername und Passwort eingeben.');
return;
}
setLoading(true);
try {
const data = await login(username, password);
navigate(data.user.must_change_password ? '/change-password' : '/dashboard');
toast.success('Erfolgreich angemeldet!');
} catch (err) {
setError(err.message || 'Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.');
} finally {
setLoading(false);
}
};
return (
<div className="login-split-root" data-theme={localStorage.getItem('nexus-theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')}>
<div className="login-split">
{/* ── LEFT: Brand ── */}
<aside className="login-brand">
<div className="login-brand-grid" />
<div className="login-brand-circuit" aria-hidden="true">
<svg viewBox="0 0 800 1000" preserveAspectRatio="xMidYMid slice">
<path className="lc-line" d="M0,180 L200,180 L240,140 L420,140 L460,180 L640,180 L680,220 L800,220"/>
<path className="lc-line" d="M0,360 L120,360 L160,400 L300,400 L340,360 L520,360 L560,400 L800,400"/>
<path className="lc-line" d="M0,640 L180,640 L220,680 L380,680 L420,720 L800,720"/>
<path className="lc-line" d="M0,860 L220,860 L260,820 L460,820 L500,860 L800,860"/>
<path className="lc-line" d="M140,0 L140,120 L180,160 L180,300"/>
<path className="lc-line" d="M540,0 L540,80 L580,120 L580,320"/>
<path className="lc-line" d="M280,1000 L280,880 L320,840 L320,720"/>
<path className="lc-line" d="M660,1000 L660,920 L620,880 L620,740"/>
<circle className="lc-node" cx="200" cy="180" r="3"/>
<circle className="lc-node" cx="460" cy="180" r="3"/>
<circle className="lc-node" cx="160" cy="400" r="3"/>
<circle className="lc-node" cx="340" cy="360" r="3"/>
<circle className="lc-node" cx="560" cy="400" r="3"/>
<circle className="lc-node" cx="220" cy="640" r="3"/>
<circle className="lc-node" cx="420" cy="720" r="3"/>
<circle className="lc-node" cx="500" cy="860" r="3"/>
<circle className="lc-pulse" cx="460" cy="180" r="3"/>
<circle className="lc-pulse p2" cx="340" cy="360" r="3"/>
<circle className="lc-pulse p3" cx="420" cy="720" r="3"/>
<circle className="lc-pulse p4" cx="580" cy="320" r="3"/>
</svg>
</div>
<div className="login-brand-head">
<img src="/cereda-logo.png" alt="Cereda Systems" />
<span className="login-brand-tag">
<span className="login-brand-tag-dot" />
System Online
</span>
</div>
<div className="login-brand-mid">
<span className="login-eyebrow">IT NEXUS</span>
<h1 className="login-brand-h1">
Eine Plattform.<br/>
<span className="login-accent">Deine ganze IT.</span>
</h1>
<p className="login-brand-lede">
Helpdesk, Monitoring, Patches, Identity und Secure Share vereint in einem Workspace für das CeredaITTeam.
</p>
<div className="login-features">
<div className="login-feat">
<div className="login-feat-ico">🎫</div>
<b>Helpdesk</b>
<span>Tickets & Wissensdatenbank</span>
</div>
<div className="login-feat">
<div className="login-feat-ico">📡</div>
<b>Monitoring</b>
<span>Geräte & Patch-Status</span>
</div>
<div className="login-feat">
<div className="login-feat-ico">🛡</div>
<b>Security</b>
<span>Defender & Reports</span>
</div>
</div>
</div>
</aside>
{/* ── RIGHT: Login Form ── */}
<section className="login-form-side">
<div className="login-form-top">
<button
className="login-theme-btn"
aria-label="Dark Mode umschalten"
onClick={() => {
const root = document.documentElement;
const cur = root.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const next = cur === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
// also update wrapper
document.querySelector('.login-split-root')?.setAttribute('data-theme', next);
try { localStorage.setItem('nexus-theme', next); } catch {}
}}
>
<span className="ltt-ico sun">
<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>
</span>
<span className="ltt-ico moon">
<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>
</span>
<span className="ltt-thumb" />
</button>
</div>
<div className="login-stage">
<form className="login-inner" onSubmit={handleSubmit} noValidate>
<h1 className="login-h1">Anmelden.</h1>
<p className="login-sub">Willkommen zurück wähle deinen Weg.</p>
<button
type="button"
className="login-ms-btn"
onClick={() => { window.location.href = '/api/auth/microsoft'; }}
>
<span className="login-ms-logo" aria-hidden="true">
<span/><span/><span/><span/>
</span>
Mit Microsoft anmelden
</button>
<div className="login-divider">oder mit Benutzername</div>
<div className="login-field">
<label htmlFor="login-username">Benutzername</label>
<div className="login-input-wrap">
<input
id="login-username"
className="login-input"
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="benutzername"
autoComplete="username"
autoFocus
required
/>
</div>
</div>
<div className="login-field">
<label htmlFor="login-password">Passwort</label>
<div className="login-input-wrap">
<input
id="login-password"
className="login-input"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••••"
autoComplete="current-password"
required
/>
<button
type="button"
className="login-eye-btn"
onClick={() => setShowPassword(s => !s)}
aria-label="Passwort anzeigen"
>
{showPassword ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-6.5 0-10-7-10-7a18.4 18.4 0 0 1 4.06-4.94"/><path d="M9.9 4.24A10 10 0 0 1 12 4c6.5 0 10 7 10 7a18.4 18.4 0 0 1-3.16 4.19"/><path d="m2 2 20 20"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
className="login-submit-btn"
disabled={loading}
>
{loading ? (
<><span className="login-spinner" />&nbsp;&nbsp;Wird angemeldet</>
) : (
<>
Anmelden
<svg className="lsb-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M13 6l6 6-6 6"/>
</svg>
</>
)}
</button>
<div className="login-foot-row">
<label className="login-remember">
<input type="checkbox" />
<span className="login-check-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="m5 12 5 5L20 7"/>
</svg>
</span>
Angemeldet bleiben
</label>
<button
type="button"
className="login-forgot"
onClick={() => setShowForgotModal(true)}
>
Passwort vergessen?
</button>
</div>
{error && (
<div className="login-err on" ref={errRef}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/>
</svg>
<span>{error}</span>
</div>
)}
<div className="login-page-foot">
Developed by <b>Simon Grüßing</b> · Cereda Systems GmbH
</div>
</form>
</div>
</section>
</div>
{/* Passwort vergessen Modal */}
{showForgotModal && (
<div className="modal-overlay" onClick={() => setShowForgotModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>Passwort vergessen?</h2>
<button className="modal-close" onClick={() => setShowForgotModal(false)}>×</button>
</div>
<div className="modal-body">
<p style={{ marginBottom: 15 }}>Bitte wenden Sie sich an Ihren Administrator, um Ihr Passwort zurückzusetzen.</p>
<p style={{ color: '#64748b', fontSize: 14 }}>Aus Sicherheitsgründen können Passwörter nur von Administratoren zurückgesetzt werden.</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowForgotModal(false)}>Verstanden</button>
</div>
</div>
</div>
)}
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import assetService from '../services/assetService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const today = new Date().toISOString().slice(0, 10);
const in7 = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const in30 = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const in90 = new Date(Date.now() + 90 * 86400000).toISOString().slice(0, 10);
const getRange = (date) => {
if (!date) return null;
if (date < today) return { label: 'Überfällig', color: '#ef4444', bg: 'rgba(239,68,68,0.1)', icon: '🔴', order: 0 };
if (date <= in7) return { label: 'Diese Woche', color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', icon: '🟠', order: 1 };
if (date <= in30) return { label: 'Nächste 30 Tage', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)', icon: '🔵', order: 2 };
if (date <= in90) return { label: 'Nächste 90 Tage', color: '#22c55e', bg: 'rgba(34,197,94,0.1)', icon: '🟢', order: 3 };
return { label: 'Später', color: '#6b7280', bg: 'rgba(107,114,128,0.08)', icon: '⚪', order: 4 };
};
const TYPE_ICON = { Notebook: '💻', Monitor: '🖥️', Headset: '🎧', Other: '📦' };
const MaintenancePage = () => {
const navigate = useNavigate();
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
useEffect(() => {
assetService.getAll()
.then(data => setAssets(data.filter(a => a.next_maintenance_date)))
.catch(() => toast.error('Fehler beim Laden der Assets'))
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="main-content"><LoadingSpinner /></div>;
const sorted = [...assets].sort((a, b) =>
(a.next_maintenance_date || '').localeCompare(b.next_maintenance_date || '')
);
const groups = {};
sorted.forEach(asset => {
const range = getRange(asset.next_maintenance_date);
if (!range) return;
if (filter !== 'all' && range.order !== Number(filter)) return;
const key = range.label;
if (!groups[key]) groups[key] = { ...range, assets: [] };
groups[key].assets.push(asset);
});
const groupOrder = ['Überfällig', 'Diese Woche', 'Nächste 30 Tage', 'Nächste 90 Tage', 'Später'];
const totalShown = Object.values(groups).reduce((s, g) => s + g.assets.length, 0);
const filterOptions = [
{ value: 'all', label: 'Alle' },
{ value: '0', label: '🔴 Überfällig' },
{ value: '1', label: '🟠 Diese Woche' },
{ value: '2', label: '🔵 30 Tage' },
{ value: '3', label: '🟢 90 Tage' },
];
return (
<div className="main-content">
<div className="container">
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.75rem', fontWeight: 800, color: 'var(--text-primary)' }}>
🔧 Wartungskalender
</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-secondary)', fontSize: '14px' }}>
{assets.length} Assets mit Wartungsterminen · {totalShown} angezeigt
</p>
</div>
<button className="btn btn-secondary" onClick={() => navigate('/assets')}>
Zur Asset-Übersicht
</button>
</div>
{/* Summary Cards */}
<div className="dashboard-grid" style={{ marginBottom: '24px' }}>
{[
{ label: 'Überfällig', count: assets.filter(a => a.next_maintenance_date < today).length, color: '#ef4444', icon: '🔴' },
{ label: 'Diese Woche', count: assets.filter(a => a.next_maintenance_date >= today && a.next_maintenance_date <= in7).length, color: '#f59e0b', icon: '🟠' },
{ label: 'Nächste 30 Tage', count: assets.filter(a => a.next_maintenance_date > in7 && a.next_maintenance_date <= in30).length, color: '#3b82f6', icon: '🔵' },
{ label: 'Nächste 90 Tage', count: assets.filter(a => a.next_maintenance_date > in30 && a.next_maintenance_date <= in90).length, color: '#22c55e', icon: '🟢' },
].map(s => (
<div key={s.label} className="stat-card" style={{ cursor: 'default', borderLeft: `4px solid ${s.color}` }}>
<div className="stat-icon">{s.icon}</div>
<div className="stat-content">
<div className="stat-value" style={{ color: s.color }}>{s.count}</div>
<div className="stat-label">{s.label}</div>
</div>
</div>
))}
</div>
{/* Filter */}
<div className="card" style={{ padding: '12px 16px', marginBottom: '20px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{filterOptions.map(opt => (
<button key={opt.value}
className={`btn btn-small ${filter === opt.value ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilter(opt.value)}>
{opt.label}
</button>
))}
</div>
{/* Groups */}
{groupOrder.filter(g => groups[g]).map(groupName => {
const group = groups[groupName];
return (
<div key={groupName} style={{ marginBottom: '28px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<span style={{ fontSize: '16px' }}>{group.icon}</span>
<h2 style={{ margin: 0, fontSize: '13px', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: group.color }}>
{group.label}
</h2>
<span style={{ fontSize: '12px', color: 'var(--text-muted)', fontWeight: 600 }}>
({group.assets.length})
</span>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden', border: `1px solid ${group.color}33` }}>
<table className="table" style={{ margin: 0 }}>
<thead>
<tr>
<th>Asset</th>
<th>Typ</th>
<th>Seriennummer</th>
<th>Zugewiesen an</th>
<th>Letzte Wartung</th>
<th>Nächste Prüfung</th>
<th>Intervall</th>
<th>Notizen</th>
</tr>
</thead>
<tbody>
{group.assets.map(asset => {
const daysLeft = Math.ceil((new Date(asset.next_maintenance_date) - new Date()) / 86400000);
return (
<tr key={asset.id} style={{ cursor: 'pointer' }}
onClick={() => navigate('/assets')}>
<td>
<div style={{ fontWeight: 600, fontSize: '14px', color: 'var(--text-primary)' }}>
{TYPE_ICON[asset.type] || '📦'} {asset.name}
</div>
</td>
<td style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{asset.type}</td>
<td style={{ fontSize: '12px', fontFamily: 'monospace', color: 'var(--text-muted)' }}>{asset.serial_number}</td>
<td style={{ fontSize: '13px' }}>{asset.assigned_to_username || '—'}</td>
<td style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
{asset.last_maintenance_date
? new Date(asset.last_maintenance_date).toLocaleDateString('de-DE')
: '—'}
</td>
<td>
<div style={{ fontWeight: 700, color: group.color, fontSize: '14px' }}>
{new Date(asset.next_maintenance_date).toLocaleDateString('de-DE')}
</div>
<div style={{ fontSize: '11px', color: group.color, opacity: 0.8 }}>
{daysLeft < 0 ? `${Math.abs(daysLeft)} Tage überfällig` : daysLeft === 0 ? 'Heute!' : `in ${daysLeft} Tagen`}
</div>
</td>
<td style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
{asset.maintenance_interval_months ? `alle ${asset.maintenance_interval_months} Monate` : '—'}
</td>
<td style={{ fontSize: '12px', color: 'var(--text-muted)', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={asset.maintenance_notes || ''}>
{asset.maintenance_notes || '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
{totalShown === 0 && (
<div className="card" style={{ textAlign: 'center', padding: '48px', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '48px', marginBottom: '12px' }}>🎉</div>
<h3 style={{ margin: '0 0 8px', color: 'var(--text-secondary)' }}>Keine Wartungen fällig</h3>
<p style={{ margin: 0, fontSize: '14px' }}>Alle Prüftermine sind im grünen Bereich.</p>
</div>
)}
</div>
</div>
);
};
export default MaintenancePage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,243 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useTheme, THEMES } from '../context/ThemeContext';
import api from '../services/api';
import { toast } from 'react-toastify';
const STAFF_ROLES = ['super_admin', 'admin', 'support', 'bearbeiter'];
const Toggle = ({ checked, onChange, disabled }) => (
<button
disabled={disabled}
onClick={onChange}
style={{
width: '44px', height: '24px', borderRadius: '12px', border: 'none',
background: checked ? 'var(--cereda-primary)' : 'var(--border-color)',
cursor: disabled ? 'not-allowed' : 'pointer',
position: 'relative', transition: 'background 0.2s', flexShrink: 0,
opacity: disabled ? 0.6 : 1,
}}
>
<span style={{
position: 'absolute', top: '3px',
left: checked ? '22px' : '3px',
width: '18px', height: '18px', borderRadius: '50%',
background: '#fff', transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</button>
);
const Section = ({ title, children }) => (
<div style={{
background: 'var(--bg-secondary)', border: '1px solid var(--border-color)',
borderRadius: '12px', padding: '24px', marginBottom: '20px',
}}>
<div style={{ fontWeight: 700, fontSize: '0.9375rem', color: 'var(--text-primary)', marginBottom: '18px' }}>
{title}
</div>
{children}
</div>
);
const MyAccountPage = () => {
const { user, updateUser } = useAuth();
const { theme, setTheme } = useTheme();
const isStaff = STAFF_ROLES.includes(user?.role_name);
// Simple notification toggle (non-staff)
const [emailNotif, setEmailNotif] = useState(user?.email_notifications !== 0);
const [savingNotif, setSavingNotif] = useState(false);
// Staff notification toggles
const [staffNotif, setStaffNotif] = useState({
notif_ticket_created: !!(user?.notif_ticket_created),
notif_ticket_assigned: user?.notif_ticket_assigned !== 0,
notif_new_comment: user?.notif_new_comment !== 0,
notif_weekly_report: user?.notif_weekly_report !== 0,
});
const [savingStaffNotif, setSavingStaffNotif] = useState(false);
const saveEmailNotif = async (enabled) => {
setSavingNotif(true);
try {
await api.put('/auth/notifications', { email_notifications: enabled });
setEmailNotif(enabled);
updateUser({ ...user, email_notifications: enabled ? 1 : 0 });
toast.success(enabled ? 'Benachrichtigungen aktiviert' : 'Benachrichtigungen deaktiviert');
} catch {
toast.error('Fehler beim Speichern');
} finally {
setSavingNotif(false);
}
};
const toggleStaffNotif = async (key) => {
const updated = { ...staffNotif, [key]: !staffNotif[key] };
setStaffNotif(updated);
setSavingStaffNotif(true);
try {
await api.put('/auth/staff-notifications', updated);
updateUser({ ...user, ...Object.fromEntries(Object.entries(updated).map(([k, v]) => [k, v ? 1 : 0])) });
toast.success('Einstellung gespeichert');
} catch {
setStaffNotif(staffNotif);
toast.error('Fehler beim Speichern');
} finally {
setSavingStaffNotif(false);
}
};
const initials = [user?.first_name, user?.last_name]
.filter(Boolean).map(n => n[0].toUpperCase()).join('') || user?.username?.[0]?.toUpperCase() || '?';
return (
<div style={{ padding: '32px', maxWidth: '600px', margin: '0 auto' }}>
<h1 style={{ fontWeight: 800, fontSize: '1.5rem', color: 'var(--text-primary)', marginBottom: '28px', marginTop: 0 }}>
Mein Konto
</h1>
{/* Profil */}
<Section title="👤 Profil">
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div style={{
width: '64px', height: '64px', borderRadius: '50%', flexShrink: 0,
background: 'var(--cereda-primary)', display: 'flex', alignItems: 'center',
justifyContent: 'center', fontSize: '24px', fontWeight: 800, color: '#fff',
}}>
{initials}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: '1.0625rem', color: 'var(--text-primary)' }}>
{[user?.first_name, user?.last_name].filter(Boolean).join(' ') || user?.username}
</div>
{(user?.first_name || user?.last_name) && (
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginTop: '2px' }}>
@{user?.username}
</div>
)}
{user?.email && (
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginTop: '2px' }}>
{user.email}
</div>
)}
<span style={{
display: 'inline-block', marginTop: '6px', fontSize: '0.75rem', fontWeight: 600,
padding: '2px 10px', borderRadius: '12px',
background: 'rgba(59,130,246,0.1)', color: 'var(--cereda-primary)',
}}>
{user?.role_name}
</span>
</div>
</div>
</Section>
{/* Darstellung */}
<Section title="🎨 Darstellung">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
{Object.entries(THEMES).map(([key, t]) => {
const active = theme === key;
return (
<button key={key} onClick={() => setTheme(key)} style={{
background: 'var(--bg-primary)', border: `2px solid ${active ? t.preview.accent : 'var(--border-color)'}`,
borderRadius: '10px', padding: '14px', cursor: 'pointer', textAlign: 'left',
transition: 'border-color 0.15s', position: 'relative', overflow: 'hidden',
}}>
{/* Mini-Preview */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '10px', borderRadius: '6px', overflow: 'hidden', height: '52px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ width: '30%', background: t.preview.sidebar, display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 5px' }}>
{[1,2,3].map(i => (
<div key={i} style={{ height: '4px', borderRadius: '2px', background: i === 1 ? t.preview.accent : 'rgba(255,255,255,0.1)' }} />
))}
</div>
<div style={{ flex: 1, background: t.preview.bg, padding: '6px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ height: '4px', width: '60%', borderRadius: '2px', background: t.preview.accent, opacity: 0.8 }} />
<div style={{ height: '3px', width: '80%', borderRadius: '2px', background: 'rgba(255,255,255,0.12)' }} />
<div style={{ height: '3px', width: '50%', borderRadius: '2px', background: 'rgba(255,255,255,0.08)' }} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 700, fontSize: '0.875rem', color: 'var(--text-primary)' }}>{t.label}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '2px' }}>{t.desc}</div>
</div>
{active && (
<div style={{ width: '18px', height: '18px', borderRadius: '50%', background: t.preview.accent, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: '10px', color: '#fff', fontWeight: 800 }}></span>
</div>
)}
</div>
</button>
);
})}
</div>
</Section>
{/* E-Mail-Benachrichtigungen */}
<Section title="📧 E-Mail-Benachrichtigungen">
{!isStaff ? (
<>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: '8px',
background: 'var(--bg-primary)', border: '1px solid var(--border-color)',
marginBottom: '12px',
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--text-primary)' }}>
E-Mail-Benachrichtigungen aktivieren
</div>
<div style={{ fontSize: '0.775rem', color: 'var(--text-muted)', marginTop: '3px' }}>
Erhalte E-Mails bei Aktualisierungen deiner IT-Anfragen
</div>
</div>
<Toggle checked={emailNotif} onChange={() => saveEmailNotif(!emailNotif)} disabled={savingNotif} />
</div>
{emailNotif && (
<ul style={{ margin: 0, paddingLeft: '16px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
{[
'Bestätigung wenn du eine neue Anfrage erstellst',
'Neue Kommentare des IT-Supports',
'Statusänderungen deiner Anfragen',
'Zufriedenheits-Umfrage nach Abschluss',
].map(t => (
<li key={t} style={{ fontSize: '0.775rem', color: 'var(--text-muted)' }}>{t}</li>
))}
</ul>
)}
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '6px' }}>
Wähle für welche Ereignisse du E-Mails erhalten möchtest.
</div>
{[
{ key: 'notif_ticket_created', label: 'Neues Ticket eingegangen', desc: 'Wenn ein neues Ticket im System erstellt wird' },
{ key: 'notif_ticket_assigned', label: 'Ticket zugewiesen', desc: 'Wenn dir ein Ticket zugewiesen wird' },
{ key: 'notif_new_comment', label: 'Neue Antwort auf Ticket', desc: 'Wenn ein Mitarbeiter auf dein Ticket antwortet' },
{ key: 'notif_weekly_report', label: 'Wöchentlicher Report', desc: 'Montags: Zusammenfassung offener und geschlossener Tickets' },
].map(({ key, label, desc }) => (
<div key={key} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: '8px',
background: 'var(--bg-primary)', border: '1px solid var(--border-color)',
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--text-primary)' }}>{label}</div>
<div style={{ fontSize: '0.775rem', color: 'var(--text-muted)', marginTop: '3px' }}>{desc}</div>
</div>
<Toggle
checked={staffNotif[key]}
onChange={() => toggleStaffNotif(key)}
disabled={savingStaffNotif}
/>
</div>
))}
</div>
)}
</Section>
</div>
);
};
export default MyAccountPage;

View File

@@ -0,0 +1,802 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import networkMonitorService from '../services/networkMonitorService';
import { toast } from 'react-toastify';
// ─── Constants ────────────────────────────────────────────────────────────────
const TYPE_META = {
host: { label: 'Host', icon: '🖥' },
switch: { label: 'Switch', icon: '🔀' },
router: { label: 'Router', icon: '🌐' },
printer: { label: 'Drucker', icon: '🖨' },
nas: { label: 'NAS', icon: '💾' },
service: { label: 'Service', icon: '⚙' },
};
const CHECK_TYPES = ['icmp', 'http', 'https', 'tcp'];
const INTERVALS = [
{ value: 15, label: '15 Sek' },
{ value: 30, label: '30 Sek' },
{ value: 60, label: '1 Min' },
{ value: 300, label: '5 Min' },
{ value: 600, label: '10 Min' },
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
const timeAgo = (iso) => {
if (!iso) return '';
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min`;
if (m < 1440) return `vor ${Math.floor(m / 60)} Std`;
return `vor ${Math.floor(m / 1440)} Tagen`;
};
const statusColor = (s) => s === 'up' ? '#34d399' : s === 'down' ? '#ef4444' : '#6b7280';
const statusLabel = (s) => s === 'up' ? 'Online' : s === 'down' ? 'Offline' : 'Unbekannt';
// ─── Uptime Sparkline ─────────────────────────────────────────────────────────
const UptimeSparkline = ({ checks }) => {
if (!checks || checks.length === 0) return <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>Keine Daten</div>;
const seg = checks.slice(-80);
const w = 240, h = 28, sw = w / seg.length;
return (
<svg width={w} height={h} style={{ borderRadius: 4, overflow: 'hidden' }}>
{seg.map((c, i) => (
<rect key={i} x={i * sw} y={0} width={Math.max(sw - 0.5, 1)} height={h}
fill={c.status === 'up' ? '#34d399' : '#ef4444'} opacity={0.85} />
))}
</svg>
);
};
// ─── RTT Sparkline ────────────────────────────────────────────────────────────
const RttSparkline = ({ checks }) => {
const vals = checks.filter(c => c.rtt_ms != null).map(c => c.rtt_ms);
if (vals.length < 2) return <div style={{ color: 'var(--text-muted)', fontSize: 11 }}></div>;
const max = Math.max(...vals);
const w = 240, h = 40;
const points = vals.slice(-60).map((v, i) => {
const x = (i / (vals.slice(-60).length - 1)) * w;
const y = h - (v / max) * (h - 4) - 2;
return `${x},${y}`;
}).join(' ');
return (
<svg width={w} height={h}>
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="1.5" />
</svg>
);
};
// ─── Status Dot ───────────────────────────────────────────────────────────────
const StatusDot = ({ status, size = 10 }) => (
<span style={{
display: 'inline-block', width: size, height: size, borderRadius: '50%',
background: statusColor(status),
boxShadow: status === 'up' ? `0 0 6px ${statusColor(status)}` : 'none',
animation: status === 'up' ? 'nmPulse 2s infinite' : 'none',
flexShrink: 0,
}} />
);
// ─── Device Card ─────────────────────────────────────────────────────────────
const DeviceCard = ({ device, onClick }) => {
const meta = TYPE_META[device.type] || TYPE_META.host;
const borderColor = statusColor(device.last_status);
return (
<div onClick={onClick} style={{
background: 'var(--bg-secondary)', border: `1px solid var(--border-color)`,
borderTop: `3px solid ${borderColor}`, borderRadius: 10, padding: '14px 16px',
cursor: 'pointer', transition: 'transform .15s, box-shadow .15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(0,0,0,.3)'; }}
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 20 }}>{meta.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 160 }}>{device.name}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{device.host}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 8px', borderRadius: 12, fontSize: 11, fontWeight: 600,
background: `${statusColor(device.last_status)}20`, color: statusColor(device.last_status), border: `1px solid ${statusColor(device.last_status)}40` }}>
<StatusDot status={device.last_status} size={7} />
{statusLabel(device.last_status)}
</div>
</div>
{device.location && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 6 }}>📍 {device.location}</div>}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: 'var(--text-muted)', marginTop: 8 }}>
<span>{device.check_type?.toUpperCase()} · {TYPE_META[device.type]?.label}</span>
<span>{device.last_rtt_ms != null ? `${Math.round(device.last_rtt_ms)} ms` : ''}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>{timeAgo(device.last_checked)}</div>
</div>
);
};
// ─── Discovery Modal ──────────────────────────────────────────────────────────
const TYPE_META_DISCOVER = { ...TYPE_META };
const DiscoverModal = ({ onClose, onAdd }) => {
const [subnet, setSubnet] = useState('192.168.0');
const [scanning, setScanning] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState(null);
const [selected, setSelected] = useState({});
const [typeOverrides, setTypeOverrides] = useState({});
const [adding, setAdding] = useState(false);
const progressRef = useRef(null);
const startScan = async () => {
setScanning(true);
setResults(null);
setSelected({});
setProgress(0);
// Animate progress bar during scan
let p = 0;
progressRef.current = setInterval(() => {
p = Math.min(p + 1.2, 90);
setProgress(p);
}, 500);
try {
const res = await networkMonitorService.discover(subnet);
clearInterval(progressRef.current);
setProgress(100);
setResults(res.data || []);
// Auto-select all found
const sel = {};
(res.data || []).forEach(d => sel[d.ip] = true);
setSelected(sel);
} catch {
clearInterval(progressRef.current);
toast.error('Scan fehlgeschlagen');
} finally {
setScanning(false);
}
};
const addSelected = async () => {
const toAdd = (results || []).filter(d => selected[d.ip]);
if (!toAdd.length) return;
setAdding(true);
let added = 0;
for (const d of toAdd) {
try {
const type = typeOverrides[d.ip] || d.type;
const device = await networkMonitorService.create({
name: d.hostname || d.ip,
type,
host: d.ip,
check_type: d.check_type,
interval_sec: 60,
timeout_sec: 5,
enabled: 1,
});
onAdd(device);
added++;
} catch {}
}
toast.success(`${added} Gerät${added !== 1 ? 'e' : ''} hinzugefügt`);
setAdding(false);
onClose();
};
const toggleAll = (val) => {
const sel = {};
(results || []).forEach(d => sel[d.ip] = val);
setSelected(sel);
};
const inputStyle = { padding: '8px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13 };
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.65)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={e => e.target === e.currentTarget && !scanning && onClose()}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 12, width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column', border: '1px solid var(--border-color)' }}>
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: 16, fontWeight: 700 }}>📡 Netzwerk scannen</h2>
{!scanning && <button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 22, cursor: 'pointer' }}>×</button>}
</div>
<div style={{ padding: 20, overflowY: 'auto', flex: 1 }}>
{/* Subnet input */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.4px', display: 'block', marginBottom: 6 }}>Subnetz (erste 3 Oktette)</label>
<input style={{ ...inputStyle, width: '100%', boxSizing: 'border-box' }}
value={subnet} onChange={e => setSubnet(e.target.value)}
placeholder="192.168.0" disabled={scanning} />
</div>
<button onClick={startScan} disabled={scanning || !subnet}
style={{ padding: '9px 20px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: scanning ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: 13, whiteSpace: 'nowrap' }}>
{scanning ? 'Scanne...' : '▶ Scan starten'}
</button>
</div>
{/* Progress */}
{(scanning || results !== null) && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
<span>{scanning ? `Scanne ${subnet}.1 ${subnet}.254...` : `Scan abgeschlossen ${results?.length || 0} Geräte gefunden`}</span>
<span>{Math.round(progress)}%</span>
</div>
<div style={{ background: 'var(--border-color)', borderRadius: 4, height: 6, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${progress}%`, background: progress === 100 ? '#34d399' : 'var(--accent)', borderRadius: 4, transition: 'width .4s' }} />
</div>
</div>
)}
{/* Results */}
{results !== null && results.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 30, fontSize: 14 }}>
Keine neuen Geräte gefunden im Subnetz {subnet}.x
</div>
)}
{results && results.length > 0 && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{Object.values(selected).filter(Boolean).length} von {results.length} ausgewählt</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => toggleAll(true)} style={{ fontSize: 11, padding: '3px 10px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>Alle</button>
<button onClick={() => toggleAll(false)} style={{ fontSize: 11, padding: '3px 10px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>Keine</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{results.map(d => (
<div key={d.ip} onClick={() => setSelected(s => ({ ...s, [d.ip]: !s[d.ip] }))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', borderRadius: 8,
background: selected[d.ip] ? 'rgba(63,163,163,.12)' : 'var(--bg-secondary)',
border: `1px solid ${selected[d.ip] ? 'var(--accent)' : 'var(--border-color)'}`,
cursor: 'pointer', transition: 'all .1s' }}>
<input type="checkbox" checked={!!selected[d.ip]} onChange={() => {}} style={{ accentColor: 'var(--accent)' }} />
<span style={{ fontSize: 18 }}>{TYPE_META[typeOverrides[d.ip] || d.type]?.icon || '🖥'}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.ip}</div>
{d.hostname && <div style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.hostname}</div>}
{d.open_ports?.length > 0 && <div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}>Ports: {d.open_ports.join(', ')}</div>}
</div>
<select value={typeOverrides[d.ip] || d.type}
onChange={e => { e.stopPropagation(); setTypeOverrides(t => ({ ...t, [d.ip]: e.target.value })); }}
onClick={e => e.stopPropagation()}
style={{ fontSize: 11, padding: '3px 6px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'var(--bg-primary)', color: 'var(--text-primary)', cursor: 'pointer' }}>
{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v.icon} {v.label}</option>)}
</select>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{d.check_type.toUpperCase()}</span>
</div>
))}
</div>
</>
)}
</div>
{/* Footer */}
{results && results.length > 0 && (
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} disabled={adding} style={{ padding: '8px 16px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13 }}>Abbrechen</button>
<button onClick={addSelected} disabled={adding || !Object.values(selected).some(Boolean)}
style={{ padding: '8px 18px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: 'pointer', fontWeight: 700, fontSize: 13 }}>
{adding ? 'Füge hinzu...' : `${Object.values(selected).filter(Boolean).length} Gerät${Object.values(selected).filter(Boolean).length !== 1 ? 'e' : ''} hinzufügen`}
</button>
</div>
)}
</div>
</div>
);
};
// ─── Device Form Modal ────────────────────────────────────────────────────────
const DeviceFormModal = ({ device, onSave, onClose }) => {
const [form, setForm] = useState({
name: '', type: 'host', host: '', check_type: 'icmp',
port: '', http_path: '/', http_keyword: '', interval_sec: 60,
timeout_sec: 5, enabled: 1, notify_email: '', location: '',
...(device || {}),
});
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const save = async () => {
if (!form.name || !form.host) return toast.error('Name und Host sind Pflichtfelder');
setSaving(true);
try {
const result = device
? await networkMonitorService.update(device.id, form)
: await networkMonitorService.create(form);
onSave(result);
onClose();
toast.success(device ? 'Gerät aktualisiert' : 'Gerät hinzugefügt');
} catch { toast.error('Fehler beim Speichern'); }
finally { setSaving(false); }
};
const inputStyle = { width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: 13, boxSizing: 'border-box' };
const labelStyle = { fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, marginBottom: 4, display: 'block', textTransform: 'uppercase', letterSpacing: '.4px' };
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 12, width: '100%', maxWidth: 540, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-color)' }}>
<div style={{ padding: '18px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: 16, fontWeight: 700 }}>{device ? 'Gerät bearbeiten' : 'Neues Gerät'}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
</div>
<div style={{ padding: 20, display: 'grid', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Name *</label>
<input style={inputStyle} value={form.name} onChange={e => set('name', e.target.value)} placeholder="z.B. Core Switch" />
</div>
<div>
<label style={labelStyle}>Typ</label>
<select style={inputStyle} value={form.type} onChange={e => set('type', e.target.value)}>
{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v.icon} {v.label}</option>)}
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Host / IP *</label>
<input style={inputStyle} value={form.host} onChange={e => set('host', e.target.value)} placeholder="192.168.0.1" />
</div>
<div>
<label style={labelStyle}>Standort</label>
<input style={inputStyle} value={form.location || ''} onChange={e => set('location', e.target.value)} placeholder="z.B. Serverraum" />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Check-Typ</label>
<select style={inputStyle} value={form.check_type} onChange={e => set('check_type', e.target.value)}>
{CHECK_TYPES.map(t => <option key={t} value={t}>{t.toUpperCase()}</option>)}
</select>
</div>
{(form.check_type === 'tcp' || form.check_type === 'http' || form.check_type === 'https') && (
<div>
<label style={labelStyle}>Port {form.check_type === 'tcp' ? '*' : '(optional)'}</label>
<input style={inputStyle} type="number" value={form.port || ''} onChange={e => set('port', e.target.value)} placeholder={form.check_type === 'https' ? '443' : form.check_type === 'http' ? '80' : ''} />
</div>
)}
<div>
<label style={labelStyle}>Intervall</label>
<select style={inputStyle} value={form.interval_sec} onChange={e => set('interval_sec', parseInt(e.target.value))}>
{INTERVALS.map(i => <option key={i.value} value={i.value}>{i.label}</option>)}
</select>
</div>
</div>
{(form.check_type === 'http' || form.check_type === 'https') && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Pfad</label>
<input style={inputStyle} value={form.http_path || '/'} onChange={e => set('http_path', e.target.value)} placeholder="/" />
</div>
<div>
<label style={labelStyle}>Keyword (optional)</label>
<input style={inputStyle} value={form.http_keyword || ''} onChange={e => set('http_keyword', e.target.value)} placeholder="Erwarteter Text in Response" />
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Timeout (Sek)</label>
<input style={inputStyle} type="number" min="1" max="30" value={form.timeout_sec} onChange={e => set('timeout_sec', parseInt(e.target.value))} />
</div>
<div>
<label style={labelStyle}>Alert E-Mail</label>
<input style={inputStyle} value={form.notify_email || ''} onChange={e => set('notify_email', e.target.value)} placeholder="admin@firma.de" />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" id="enabled" checked={!!form.enabled} onChange={e => set('enabled', e.target.checked ? 1 : 0)} />
<label htmlFor="enabled" style={{ fontSize: 13, cursor: 'pointer' }}>Monitoring aktiv</label>
</div>
</div>
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13 }}>Abbrechen</button>
<button onClick={save} disabled={saving} style={{ padding: '8px 18px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
);
};
// ─── Detail Modal ─────────────────────────────────────────────────────────────
const DetailModal = ({ device, onClose, onEdit, onDelete, onCheckNow }) => {
const [checksData, setChecksData] = useState(null);
const [timeRange, setTimeRange] = useState(24);
const [checking, setChecking] = useState(false);
useEffect(() => {
networkMonitorService.getChecks(device.id, timeRange)
.then(setChecksData).catch(() => {});
}, [device.id, timeRange]);
const doCheckNow = async () => {
setChecking(true);
try {
await onCheckNow(device.id);
toast.success('Check ausgeführt');
const data = await networkMonitorService.getChecks(device.id, timeRange);
setChecksData(data);
} catch { toast.error('Check fehlgeschlagen'); }
finally { setChecking(false); }
};
const meta = TYPE_META[device.type] || TYPE_META.host;
const uptime = checksData?.uptime;
const checks = checksData?.checks || [];
const lastChecks = [...checks].reverse().slice(0, 20);
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 12, width: '100%', maxWidth: 660, maxHeight: '90vh', overflow: 'auto', border: `1px solid var(--border-color)`, borderTop: `3px solid ${statusColor(device.last_status)}` }}>
{/* Header */}
<div style={{ padding: '16px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 24 }}>{meta.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>{device.name}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{device.host} · {meta.label}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 12, fontSize: 12, fontWeight: 600,
background: `${statusColor(device.last_status)}20`, color: statusColor(device.last_status) }}>
<StatusDot status={device.last_status} size={8} />
{statusLabel(device.last_status)}
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 22, cursor: 'pointer' }}>×</button>
</div>
</div>
<div style={{ padding: 20 }}>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, marginBottom: 20 }}>
{[
{ label: 'Letzter Check', value: timeAgo(device.last_checked) },
{ label: 'Antwortzeit', value: device.last_rtt_ms != null ? `${Math.round(device.last_rtt_ms)} ms` : '' },
{ label: `Uptime ${timeRange}h`, value: uptime?.total > 0 ? `${uptime.pct}%` : '', color: uptime?.pct >= 99 ? '#34d399' : uptime?.pct >= 90 ? '#f59e0b' : '#ef4444' },
].map(m => (
<div key={m.label} style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: '10px 14px' }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 4 }}>{m.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: m.color || 'var(--text-primary)' }}>{m.value}</div>
</div>
))}
</div>
{/* Time range + Uptime graph */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px' }}>Uptime-Verlauf</div>
<div style={{ display: 'flex', gap: 4 }}>
{[24, 48, 168].map(h => (
<button key={h} onClick={() => setTimeRange(h)} style={{
padding: '3px 10px', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer',
border: `1px solid ${timeRange === h ? 'var(--accent)' : 'var(--border-color)'}`,
background: timeRange === h ? 'var(--accent)' : 'var(--bg-secondary)',
color: timeRange === h ? '#fff' : 'var(--text-muted)',
}}>{h === 168 ? '7 Tage' : `${h}h`}</button>
))}
</div>
</div>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: 10 }}>
<UptimeSparkline checks={checks} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
<span> Grün = Online &nbsp; Rot = Offline</span>
<span>{uptime?.up ?? 0}/{uptime?.total ?? 0} Checks OK</span>
</div>
</div>
</div>
{/* RTT Graph */}
{checks.some(c => c.rtt_ms != null) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 8 }}>Antwortzeit (ms)</div>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: 10 }}>
<RttSparkline checks={checks} />
</div>
</div>
)}
{/* Info Grid */}
<div style={{ background: 'var(--bg-secondary)', borderRadius: 8, padding: '12px 14px', marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 8 }}>Konfiguration</div>
<div style={{ display: 'grid', gridTemplateColumns: '130px 1fr', gap: '5px 12px', fontSize: 13, lineHeight: 1.7 }}>
{[
['Check-Typ', device.check_type?.toUpperCase()],
['Intervall', `${device.interval_sec}s`],
['Timeout', `${device.timeout_sec}s`],
device.port && ['Port', device.port],
device.location && ['Standort', device.location],
device.notify_email && ['Alert E-Mail', device.notify_email],
].filter(Boolean).map(([k, v]) => (
<React.Fragment key={k}>
<span style={{ color: 'var(--text-muted)' }}>{k}</span>
<span style={{ fontWeight: 600 }}>{v}</span>
</React.Fragment>
))}
</div>
</div>
{/* Check History */}
{lastChecks.length > 0 && (
<div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.4px', marginBottom: 8 }}>Letzte Checks</div>
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<tbody>
{lastChecks.map((c, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '5px 0', width: 16 }}><StatusDot status={c.status} size={8} /></td>
<td style={{ padding: '5px 8px', color: c.status === 'up' ? '#34d399' : '#ef4444', fontWeight: 600 }}>{c.status === 'up' ? 'Online' : 'Offline'}</td>
<td style={{ padding: '5px 8px', color: 'var(--text-muted)' }}>{c.rtt_ms != null ? `${Math.round(c.rtt_ms)} ms` : ''}</td>
<td style={{ padding: '5px 0', color: 'var(--text-muted)', textAlign: 'right' }}>{new Date(c.checked_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</td>
{c.error_msg && <td style={{ padding: '5px 8px', color: '#ef4444', fontSize: 11 }}>{c.error_msg}</td>}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Footer */}
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', gap: 8 }}>
<button onClick={doCheckNow} disabled={checking} style={{ padding: '7px 14px', borderRadius: 7, border: '1px solid var(--accent)', background: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{checking ? '...' : '▶ Jetzt prüfen'}
</button>
<button onClick={() => { onEdit(device); onClose(); }} style={{ padding: '7px 14px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13 }}>Bearbeiten</button>
<div style={{ flex: 1 }} />
<button onClick={() => onDelete(device)} style={{ padding: '7px 14px', borderRadius: 7, border: '1px solid #ef4444', background: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 13 }}>Löschen</button>
</div>
</div>
</div>
);
};
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NetworkMonitorPage() {
const [devices, setDevices] = useState([]);
const [stats, setStats] = useState(null);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [selected, setSelected] = useState(null);
const [editDevice, setEditDevice] = useState(null);
const [showForm, setShowForm] = useState(false);
const [showDiscover, setShowDiscover] = useState(false);
const [loading, setLoading] = useState(true);
const esRef = useRef(null);
const loadStats = useCallback(() => {
networkMonitorService.getStats().then(setStats).catch(() => {});
}, []);
// SSE connection with polling fallback
useEffect(() => {
let pollTimer = null;
let sseConnected = false;
const loadViaApi = () => {
networkMonitorService.getAll()
.then(d => { setDevices(d); setLoading(false); })
.catch(() => setLoading(false));
};
const startPolling = () => {
if (pollTimer) return;
loadViaApi();
pollTimer = setInterval(loadViaApi, 15000);
};
let es;
try {
es = networkMonitorService.createSSE();
esRef.current = es;
// If SSE doesn't connect within 4s, fall back to polling
const sseTimeout = setTimeout(() => {
if (!sseConnected) { es.close(); startPolling(); }
}, 4000);
es.addEventListener('initial_state', e => {
sseConnected = true;
clearTimeout(sseTimeout);
setDevices(JSON.parse(e.data));
setLoading(false);
});
es.addEventListener('device_update', e => {
const updated = JSON.parse(e.data);
setDevices(prev => {
const exists = prev.find(d => d.id === updated.id);
return exists ? prev.map(d => d.id === updated.id ? updated : d) : [...prev, updated];
});
setSelected(prev => prev?.id === updated.id ? updated : prev);
});
es.addEventListener('device_deleted', e => {
const { id } = JSON.parse(e.data);
setDevices(prev => prev.filter(d => d.id !== id));
});
es.onerror = () => {
clearTimeout(sseTimeout);
if (!sseConnected) { es.close(); startPolling(); }
};
} catch {
startPolling();
}
loadStats();
return () => {
es?.close();
if (pollTimer) clearInterval(pollTimer);
};
}, [loadStats]);
// Refresh stats when devices change
useEffect(() => { loadStats(); }, [devices.length, loadStats]);
const filtered = devices.filter(d => {
if (filterStatus !== 'all' && d.last_status !== filterStatus) return false;
if (filterType !== 'all' && d.type !== filterType) return false;
if (search) {
const s = search.toLowerCase();
return d.name.toLowerCase().includes(s) || d.host.toLowerCase().includes(s) || (d.location || '').toLowerCase().includes(s);
}
return true;
});
const handleDelete = async (device) => {
if (!window.confirm(`${device.name} wirklich löschen?`)) return;
try {
await networkMonitorService.delete(device.id);
setSelected(null);
toast.success('Gerät gelöscht');
} catch { toast.error('Fehler beim Löschen'); }
};
const handleSave = (device) => {
setDevices(prev => {
const exists = prev.find(d => d.id === device.id);
return exists ? prev.map(d => d.id === device.id ? device : d) : [...prev, device];
});
};
const onlineCount = devices.filter(d => d.last_status === 'up').length;
const offlineCount = devices.filter(d => d.last_status === 'down').length;
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Verbinde mit Monitoring-Service...</div>;
return (
<div style={{ padding: 24 }}>
<style>{`
@keyframes nmPulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(1.3)} }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22); }
`}</style>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Netzwerk Monitoring</h1>
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Echtzeit-Überwachung aller Netzwerkgeräte</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowDiscover(true)} style={{
padding: '9px 16px', borderRadius: 8, border: '1px solid var(--border-color)',
background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13, fontWeight: 600,
}}>📡 Auto-Discovery</button>
<button onClick={() => { setEditDevice(null); setShowForm(true); }} style={{
padding: '9px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)',
color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600,
}}>+ Manuell hinzufügen</button>
</div>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 12, marginBottom: 24 }}>
{[
{ label: 'Gesamt', value: devices.length, color: 'var(--accent)', border: 'var(--accent)' },
{ label: 'Online', value: onlineCount, color: '#34d399', border: '#34d399', sub: devices.length > 0 ? `${Math.round(onlineCount / devices.length * 100)}%` : '' },
{ label: 'Offline', value: offlineCount, color: offlineCount > 0 ? '#ef4444' : 'var(--text-muted)', border: offlineCount > 0 ? '#ef4444' : 'var(--border-color)' },
{ label: 'Unbekannt', value: devices.filter(d => d.last_status === 'unknown').length, color: 'var(--text-muted)', border: 'var(--border-color)' },
].map(s => (
<div key={s.label} className="card" style={{ padding: '14px 16px', borderLeft: `3px solid ${s.border}` }}>
<div style={{ fontSize: 24, fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{s.label}</div>
{s.sub && <div style={{ fontSize: 11, color: s.color, marginTop: 2 }}>{s.sub} verfügbar</div>}
</div>
))}
</div>
{/* Toolbar */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<input type="text" placeholder="Suchen..." value={search} onChange={e => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 180, padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13 }} />
<select value={filterType} onChange={e => setFilterType(e.target.value)}
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13 }}>
<option value="all">Alle Typen</option>
{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v.icon} {v.label}</option>)}
</select>
<div style={{ display: 'flex', gap: 6 }}>
{[
{ key: 'all', label: 'Alle' },
{ key: 'up', label: '● Online', color: '#34d399' },
{ key: 'down', label: '● Offline', color: '#ef4444' },
].map(f => (
<button key={f.key} onClick={() => setFilterStatus(f.key)} style={{
padding: '7px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
border: `1px solid ${filterStatus === f.key ? 'var(--accent)' : 'var(--border-color)'}`,
background: filterStatus === f.key ? 'var(--accent)' : 'var(--bg-secondary)',
color: filterStatus === f.key ? '#fff' : f.color || 'var(--text-primary)',
}}>{f.label}</button>
))}
</div>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginBottom: 12 }}>
{filtered.length} von {devices.length} Geräten
</div>
{/* Empty State */}
{devices.length === 0 && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--text-muted)' }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>📡</div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>Noch keine Geräte</div>
<div style={{ fontSize: 13, marginBottom: 20 }}>Füge Switches, Router, Server oder Services hinzu</div>
<button onClick={() => setShowForm(true)} style={{ padding: '9px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#fff', cursor: 'pointer', fontWeight: 600 }}>
+ Erstes Gerät hinzufügen
</button>
</div>
)}
{/* Device Grid */}
{filtered.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
{filtered.map(d => <DeviceCard key={d.id} device={d} onClick={() => setSelected(d)} />)}
</div>
)}
{filtered.length === 0 && devices.length > 0 && (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>
Keine Geräte gefunden
</div>
)}
{/* Modals */}
{selected && (
<DetailModal
device={selected}
onClose={() => setSelected(null)}
onEdit={d => { setEditDevice(d); setShowForm(true); }}
onDelete={d => { handleDelete(d); setSelected(null); }}
onCheckNow={networkMonitorService.checkNow}
/>
)}
{showForm && (
<DeviceFormModal
device={editDevice}
onSave={handleSave}
onClose={() => { setShowForm(false); setEditDevice(null); }}
/>
)}
{showDiscover && (
<DiscoverModal
onClose={() => setShowDiscover(false)}
onAdd={handleSave}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,534 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import offboardingService from '../services/offboardingService';
import userService from '../services/userService';
import assetService from '../services/assetService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import ChecklistEditor from '../components/common/ChecklistEditor';
import { toast } from 'react-toastify';
const CHECKLIST_ITEMS = [
{ key: 'hardware_returned', label: 'Gesamte Hardware zurückgegeben' },
{ key: 'hardware_condition_good', label: 'Hardware in gutem Zustand' },
{ key: 'accounts_deactivated', label: 'Benutzerkonten deaktiviert' },
{ key: 'access_revoked', label: 'Zugriffsrechte entzogen' },
{ key: 'knowledge_transfer', label: 'Wissensübergabe abgeschlossen' },
{ key: 'final_clearance', label: 'Abschlussgespräch durchgeführt' }
];
const OffboardingPage = () => {
const { isAdmin, isSuperAdmin } = useAuth();
const [protocols, setProtocols] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showReturnModal, setShowReturnModal] = useState(false);
const [editingProtocol, setEditingProtocol] = useState(null);
const [users, setUsers] = useState([]);
const [assignedAssets, setAssignedAssets] = useState([]);
const [assetReturns, setAssetReturns] = useState([]);
const [formData, setFormData] = useState({
employee_user_id: '',
exit_date: new Date().toISOString().split('T')[0],
status: 'pending',
checklist_data: {},
notes: ''
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [protocolsData, usersData] = await Promise.all([
offboardingService.getAll(),
userService.getAll()
]);
setProtocols(protocolsData);
setUsers(usersData);
} catch (error) {
toast.error('Fehler beim Laden der Daten');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingProtocol(null);
setFormData({
employee_user_id: '',
exit_date: new Date().toISOString().split('T')[0],
status: 'pending',
checklist_data: {},
notes: ''
});
setShowModal(true);
};
const handleEdit = (protocol) => {
setEditingProtocol(protocol);
setFormData({
employee_user_id: protocol.employee_user_id,
exit_date: protocol.exit_date,
status: protocol.status,
checklist_data: protocol.checklist_data ? JSON.parse(protocol.checklist_data) : {},
notes: protocol.notes || ''
});
setShowDetailModal(true);
};
const handleReturnAssets = async (protocol) => {
setEditingProtocol(protocol);
try {
const allAssets = await assetService.getAll();
const userAssets = allAssets.filter(
a => a.assigned_to_user_id === protocol.employee_user_id && a.status === 'zugewiesen'
);
setAssignedAssets(userAssets);
setAssetReturns(userAssets.map(asset => ({
asset_id: asset.id,
condition: 'gut'
})));
setShowReturnModal(true);
} catch (error) {
toast.error('Fehler beim Laden der Assets');
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingProtocol) {
await offboardingService.update(editingProtocol.id, {
status: formData.status,
checklist_data: formData.checklist_data,
notes: formData.notes
});
toast.success('Protokoll erfolgreich aktualisiert');
setShowModal(false);
setShowDetailModal(false);
loadData();
} else {
const newProtocol = await offboardingService.create(formData);
toast.success('Offboarding-Protokoll erfolgreich erstellt');
setShowModal(false);
// Direkt zur Asset-Rückgabe springen
await loadData();
const refreshedProtocol = await offboardingService.getById(newProtocol.id);
handleReturnAssets(refreshedProtocol);
}
} catch (error) {
toast.error(error.message || 'Fehler beim Speichern');
}
};
const handleSubmitReturn = async (e) => {
e.preventDefault();
try {
await offboardingService.returnAssets(editingProtocol.id, assetReturns);
toast.success('Assets erfolgreich zurückgegeben und PDF generiert');
setShowReturnModal(false);
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Zurückgeben der Assets');
}
};
const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie dieses Protokoll wirklich löschen?')) {
return;
}
try {
await offboardingService.delete(id);
toast.success('Protokoll erfolgreich gelöscht');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Löschen');
}
};
const handleDownloadPdf = (protocol) => {
if (protocol.pdf_file_path) {
const url = offboardingService.downloadPdf(protocol.pdf_file_path);
window.open(url, '_blank');
} else {
toast.warning('PDF noch nicht generiert');
}
};
const updateAssetCondition = (assetId, condition) => {
setAssetReturns(assetReturns.map(ar =>
ar.asset_id === assetId ? { ...ar, condition } : ar
));
};
const handleComplete = async () => {
if (!editingProtocol) return;
try {
await offboardingService.update(editingProtocol.id, {
status: 'completed',
checklist_data: formData.checklist_data,
notes: formData.notes,
completion_date: new Date().toISOString().split('T')[0]
});
toast.success('Offboarding erfolgreich abgeschlossen!');
setShowDetailModal(false);
loadData();
} catch (error) {
toast.error('Fehler beim Abschließen');
}
};
const getStatusBadgeClass = (status) => {
const classes = {
pending: 'status-pending',
in_progress: 'status-in_progress',
completed: 'status-completed'
};
return classes[status] || '';
};
const getStatusLabel = (status) => {
const labels = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen'
};
return labels[status] || status;
};
if (loading) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
return (
<div className="main-content">
<div className="container">
<div className="flex justify-between items-center mb-3">
<h1>Offboarding</h1>
{(isAdmin() || isSuperAdmin()) && (
<button onClick={handleCreate} className="btn btn-primary">
+ Neues Offboarding
</button>
)}
</div>
<div className="card">
<table className="table">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Austrittsdatum</th>
<th>Status</th>
<th>PDF</th>
<th>Erstellt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{protocols.length === 0 ? (
<tr>
<td colSpan="6" className="text-center">
Keine Offboarding-Protokolle gefunden
</td>
</tr>
) : (
protocols.map((protocol) => (
<tr key={protocol.id}>
<td>
{protocol.employee_first_name} {protocol.employee_last_name}
<br />
<small className="text-muted">{protocol.employee_email}</small>
</td>
<td>{new Date(protocol.exit_date).toLocaleDateString('de-DE')}</td>
<td>
<span className={`status-badge ${getStatusBadgeClass(protocol.status)}`}>
{getStatusLabel(protocol.status)}
</span>
</td>
<td>
{protocol.pdf_file_path ? (
<button
onClick={() => handleDownloadPdf(protocol)}
className="btn btn-success btn-small"
>
📄 Download
</button>
) : (
<span className="text-muted">Nicht verfügbar</span>
)}
</td>
<td>{protocol.created_by_username}</td>
<td>
<div className="table-actions">
<button
onClick={() => handleEdit(protocol)}
className="btn btn-primary btn-small"
>
Bearbeiten
</button>
<button
onClick={() => handleReturnAssets(protocol)}
className="btn btn-warning btn-small"
>
Assets zurück
</button>
<button
onClick={() => handleDelete(protocol.id)}
className="btn btn-danger btn-small"
>
Löschen
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Create Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Neues Offboarding</h2>
<button className="modal-close" onClick={() => setShowModal(false)}>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Mitarbeiter*</label>
<select
className="form-select"
value={formData.employee_user_id}
onChange={(e) =>
setFormData({ ...formData, employee_user_id: e.target.value })
}
required
>
<option value="">-- Bitte wählen --</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.first_name} {user.last_name} ({user.username})
</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Austrittsdatum*</label>
<input
type="date"
className="form-input"
value={formData.exit_date}
onChange={(e) =>
setFormData({ ...formData, exit_date: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea
className="form-textarea"
rows="3"
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
/>
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Erstellen
</button>
</div>
</form>
</div>
</div>
)}
{/* Detail/Edit Modal */}
{showDetailModal && editingProtocol && (
<div className="modal-overlay" onClick={() => setShowDetailModal(false)}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Offboarding bearbeiten</h2>
<button className="modal-close" onClick={() => setShowDetailModal(false)}>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Mitarbeiter</label>
<input
type="text"
className="form-input"
value={`${editingProtocol.employee_first_name} ${editingProtocol.employee_last_name}`}
disabled
/>
</div>
<div className="form-group">
<label className="form-label">Status*</label>
<select
className="form-select"
value={formData.status}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value })
}
required
>
<option value="pending">Ausstehend</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Checkliste</label>
<ChecklistEditor
items={CHECKLIST_ITEMS}
checkedItems={formData.checklist_data}
onChange={(checkedItems) =>
setFormData({ ...formData, checklist_data: checkedItems })
}
/>
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea
className="form-textarea"
rows="4"
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
/>
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowDetailModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Speichern
</button>
{formData.status !== 'completed' && (
<button
type="button"
onClick={handleComplete}
className="btn btn-success"
>
Offboarding abschließen
</button>
)}
</div>
</form>
</div>
</div>
)}
{/* Return Assets Modal */}
{showReturnModal && (
<div className="modal-overlay" onClick={() => setShowReturnModal(false)}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Assets zurückgeben</h2>
<button className="modal-close" onClick={() => setShowReturnModal(false)}>
×
</button>
</div>
<form onSubmit={handleSubmitReturn}>
<div className="form-group">
<label className="form-label">Zugewiesene Assets</label>
{assignedAssets.length === 0 ? (
<p className="text-muted">Keine Assets zugewiesen</p>
) : (
<table className="table">
<thead>
<tr>
<th>Typ</th>
<th>Name</th>
<th>Seriennummer</th>
<th>Zustand</th>
</tr>
</thead>
<tbody>
{assignedAssets.map((asset) => (
<tr key={asset.id}>
<td>{asset.type}</td>
<td>{asset.name}</td>
<td>{asset.serial_number}</td>
<td>
<select
className="form-select"
value={assetReturns.find(ar => ar.asset_id === asset.id)?.condition || 'gut'}
onChange={(e) => updateAssetCondition(asset.id, e.target.value)}
>
<option value="gut">Gut</option>
<option value="beschaedigt">Beschädigt</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowReturnModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Assets zurückgeben & PDF erstellen
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
};
export default OffboardingPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import axios from 'axios';
const OnboardingConfirmPage = () => {
const { token } = useParams();
const [status, setStatus] = useState('loading'); // loading | success | error | already
const [data, setData] = useState(null);
const [message, setMessage] = useState('');
useEffect(() => {
axios.get(`/api/onboarding/confirm/${token}`)
.then(res => {
setData(res.data.data);
setStatus('success');
})
.catch(err => {
const msg = err.response?.data?.message || 'Fehler';
if (msg.includes('bereits')) {
setStatus('already');
} else {
setStatus('error');
setMessage(msg);
}
});
}, [token]);
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg, #0ea5e9 0%, #0369a1 100%)',
padding: '20px',
}}>
<div style={{
background: '#fff', borderRadius: 16, padding: '48px 40px', maxWidth: 480, width: '100%',
textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,.15)',
}}>
{status === 'loading' && (
<>
<div style={{ fontSize: '3rem', marginBottom: 16 }}></div>
<h2 style={{ margin: 0, color: '#374151' }}>Wird verarbeitet...</h2>
</>
)}
{status === 'success' && (
<>
<div style={{ fontSize: '4rem', marginBottom: 16 }}></div>
<h2 style={{ margin: '0 0 12px', color: '#16a34a', fontSize: '1.5rem' }}>Übergabe bestätigt!</h2>
{data?.name && (
<p style={{ color: '#374151', fontSize: '1rem', margin: '0 0 8px' }}>
Vielen Dank, <strong>{data.name}</strong>!
</p>
)}
<p style={{ color: '#6b7280', fontSize: '0.9rem', margin: '8px 0 0', lineHeight: 1.6 }}>
Ihr Onboarding{data?.department ? ` in der Abteilung ${data.department}` : ''} wurde erfolgreich als abgeschlossen markiert.
</p>
<div style={{
marginTop: 24, padding: '16px', background: '#f0fdf4', borderRadius: 8,
border: '1px solid #bbf7d0',
}}>
<p style={{ margin: 0, color: '#15803d', fontSize: '0.875rem', fontWeight: 600 }}>
Willkommen im Team!
</p>
</div>
</>
)}
{status === 'already' && (
<>
<div style={{ fontSize: '3rem', marginBottom: 16 }}></div>
<h2 style={{ margin: '0 0 12px', color: '#0369a1', fontSize: '1.4rem' }}>Bereits bestätigt</h2>
<p style={{ color: '#6b7280', fontSize: '0.9rem' }}>
Ihre Übergabe wurde bereits bestätigt. Dieser Link ist nicht mehr aktiv.
</p>
</>
)}
{status === 'error' && (
<>
<div style={{ fontSize: '3rem', marginBottom: 16 }}></div>
<h2 style={{ margin: '0 0 12px', color: '#dc2626', fontSize: '1.4rem' }}>Ungültiger Link</h2>
<p style={{ color: '#6b7280', fontSize: '0.9rem' }}>
{message || 'Dieser Bestätigungslink ist ungültig oder abgelaufen.'}
</p>
<p style={{ color: '#9ca3af', fontSize: '0.8rem', marginTop: 12 }}>
Bitte wenden Sie sich an Ihre IT-Abteilung.
</p>
</>
)}
<p style={{ color: '#d1d5db', fontSize: '0.75rem', marginTop: 32, marginBottom: 0 }}>
Cereda Systems GmbH · IT-Nexus
</p>
</div>
</div>
);
};
export default OnboardingConfirmPage;

View File

@@ -0,0 +1,466 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import onboardingService from '../services/onboardingService';
import userService from '../services/userService';
import assetService from '../services/assetService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import ChecklistEditor from '../components/common/ChecklistEditor';
import { toast } from 'react-toastify';
const CHECKLIST_ITEMS = [
{ key: 'account_created', label: 'Benutzerkonto erstellt' },
{ key: 'email_configured', label: 'E-Mail-Konto konfiguriert' },
{ key: 'hardware_assigned', label: 'Hardware zugewiesen' },
{ key: 'software_installed', label: 'Software installiert' },
{ key: 'training_completed', label: 'Einarbeitungsschulung abgeschlossen' },
{ key: 'security_briefing', label: 'Sicherheitseinweisung durchgeführt' },
{ key: 'workspace_setup', label: 'Arbeitsplatz eingerichtet' }
];
const OnboardingPage = () => {
const { isAdmin, isSuperAdmin } = useAuth();
const [protocols, setProtocols] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [editingProtocol, setEditingProtocol] = useState(null);
const [users, setUsers] = useState([]);
const [assets, setAssets] = useState([]);
const [formData, setFormData] = useState({
employee_user_id: '',
start_date: new Date().toISOString().split('T')[0],
status: 'pending',
checklist_data: {},
notes: '',
asset_ids: []
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [protocolsData, usersData, assetsData] = await Promise.all([
onboardingService.getAll(),
userService.getAll(),
assetService.getAll()
]);
setProtocols(protocolsData);
setUsers(usersData.filter(u => u.is_active));
setAssets(assetsData.filter(a => a.status === 'verfuegbar'));
} catch (error) {
toast.error('Fehler beim Laden der Daten');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingProtocol(null);
setFormData({
employee_user_id: '',
start_date: new Date().toISOString().split('T')[0],
status: 'pending',
checklist_data: {},
notes: '',
asset_ids: []
});
setShowModal(true);
};
const handleEdit = (protocol) => {
setEditingProtocol(protocol);
setFormData({
employee_user_id: protocol.employee_user_id,
start_date: protocol.start_date,
status: protocol.status,
checklist_data: protocol.checklist_data ? JSON.parse(protocol.checklist_data) : {},
notes: protocol.notes || '',
asset_ids: []
});
setShowDetailModal(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingProtocol) {
await onboardingService.update(editingProtocol.id, {
status: formData.status,
checklist_data: formData.checklist_data,
notes: formData.notes
});
toast.success('Protokoll erfolgreich aktualisiert');
setShowModal(false);
setShowDetailModal(false);
loadData();
} else {
const newProtocol = await onboardingService.create(formData);
toast.success('Onboarding-Protokoll erfolgreich erstellt');
setShowModal(false);
// Direkt zur Checklist springen
await loadData();
const refreshedProtocol = await onboardingService.getById(newProtocol.id);
handleEdit(refreshedProtocol);
}
} catch (error) {
toast.error(error.message || 'Fehler beim Speichern');
}
};
const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie dieses Protokoll wirklich löschen?')) {
return;
}
try {
await onboardingService.delete(id);
toast.success('Protokoll erfolgreich gelöscht');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Löschen');
}
};
const handleDownloadPdf = (protocol) => {
if (protocol.pdf_file_path) {
const url = onboardingService.downloadPdf(protocol.pdf_file_path);
window.open(url, '_blank');
} else {
toast.warning('PDF noch nicht generiert');
}
};
const handleRegeneratePdf = async (id) => {
try {
await onboardingService.regeneratePdf(id);
toast.success('PDF erfolgreich neu generiert');
loadData();
} catch (error) {
toast.error('Fehler beim Generieren des PDFs');
}
};
const handleComplete = async () => {
if (!editingProtocol) return;
try {
await onboardingService.update(editingProtocol.id, {
status: 'completed',
checklist_data: formData.checklist_data,
notes: formData.notes,
completion_date: new Date().toISOString().split('T')[0]
});
toast.success('Onboarding erfolgreich abgeschlossen!');
setShowDetailModal(false);
loadData();
} catch (error) {
toast.error('Fehler beim Abschließen');
}
};
const getStatusBadgeClass = (status) => {
const classes = {
pending: 'status-pending',
in_progress: 'status-in_progress',
completed: 'status-completed'
};
return classes[status] || '';
};
const getStatusLabel = (status) => {
const labels = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen'
};
return labels[status] || status;
};
if (loading) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
return (
<div className="main-content">
<div className="container">
<div className="flex justify-between items-center mb-3">
<h1>Onboarding</h1>
{(isAdmin() || isSuperAdmin()) && (
<button onClick={handleCreate} className="btn btn-primary">
+ Neues Onboarding
</button>
)}
</div>
<div className="card">
<table className="table">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Startdatum</th>
<th>Status</th>
<th>PDF</th>
<th>Erstellt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{protocols.length === 0 ? (
<tr>
<td colSpan="6" className="text-center">
Keine Onboarding-Protokolle gefunden
</td>
</tr>
) : (
protocols.map((protocol) => (
<tr key={protocol.id}>
<td>
{protocol.employee_first_name} {protocol.employee_last_name}
<br />
<small className="text-muted">{protocol.employee_email}</small>
</td>
<td>{new Date(protocol.start_date).toLocaleDateString('de-DE')}</td>
<td>
<span className={`status-badge ${getStatusBadgeClass(protocol.status)}`}>
{getStatusLabel(protocol.status)}
</span>
</td>
<td>
{protocol.pdf_file_path ? (
<button
onClick={() => handleDownloadPdf(protocol)}
className="btn btn-success btn-small"
>
📄 Download
</button>
) : (
<span className="text-muted">Nicht verfügbar</span>
)}
</td>
<td>{protocol.created_by_username}</td>
<td>
<div className="table-actions">
<button
onClick={() => handleEdit(protocol)}
className="btn btn-primary btn-small"
>
Bearbeiten
</button>
<button
onClick={() => handleRegeneratePdf(protocol.id)}
className="btn btn-secondary btn-small"
>
PDF neu
</button>
<button
onClick={() => handleDelete(protocol.id)}
className="btn btn-danger btn-small"
>
Löschen
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Create Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Neues Onboarding</h2>
<button className="modal-close" onClick={() => setShowModal(false)}>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Mitarbeiter*</label>
<select
className="form-select"
value={formData.employee_user_id}
onChange={(e) =>
setFormData({ ...formData, employee_user_id: e.target.value })
}
required
>
<option value="">-- Bitte wählen --</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.first_name} {user.last_name} ({user.username})
</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Startdatum*</label>
<input
type="date"
className="form-input"
value={formData.start_date}
onChange={(e) =>
setFormData({ ...formData, start_date: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label className="form-label">Assets zuweisen</label>
<select
multiple
className="form-select"
value={formData.asset_ids}
onChange={(e) =>
setFormData({
...formData,
asset_ids: Array.from(e.target.selectedOptions, option => option.value)
})
}
style={{ minHeight: '120px' }}
>
{assets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.type} - {asset.name} ({asset.serial_number})
</option>
))}
</select>
<small className="text-muted">Strg/Cmd gedrückt halten für Mehrfachauswahl</small>
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea
className="form-textarea"
rows="3"
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
/>
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Erstellen
</button>
</div>
</form>
</div>
</div>
)}
{/* Detail/Edit Modal */}
{showDetailModal && editingProtocol && (
<div className="modal-overlay" onClick={() => setShowDetailModal(false)}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Onboarding bearbeiten</h2>
<button className="modal-close" onClick={() => setShowDetailModal(false)}>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Mitarbeiter</label>
<input
type="text"
className="form-input"
value={`${editingProtocol.employee_first_name} ${editingProtocol.employee_last_name}`}
disabled
/>
</div>
<div className="form-group">
<label className="form-label">Status*</label>
<select
className="form-select"
value={formData.status}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value })
}
required
>
<option value="pending">Ausstehend</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Checkliste</label>
<ChecklistEditor
items={CHECKLIST_ITEMS}
checkedItems={formData.checklist_data}
onChange={(checkedItems) =>
setFormData({ ...formData, checklist_data: checkedItems })
}
/>
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea
className="form-textarea"
rows="4"
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
/>
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowDetailModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Speichern
</button>
{formData.status !== 'completed' && (
<button
type="button"
onClick={handleComplete}
className="btn btn-success"
>
Onboarding abschließen
</button>
)}
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
};
export default OnboardingPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,410 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import portalGuideService from '../services/portalGuideService';
import { toast } from 'react-toastify';
const CATEGORIES = ['Allgemein', 'IT', 'HR', 'Buchhaltung', 'Produktion', 'Verwaltung', 'Sonstiges'];
const ICONS = ['📄', '📘', '📗', '📙', '📕', '📋', '🖥️', '🔧', '📊', '🛡️', '🌐', '📱', '🔑', '⚙️', '🏢'];
const ROLE_GROUPS = [
{ label: 'IT-Team', roles: ['super_admin', 'admin', 'support', 'bearbeiter'], icon: '🖥️' },
{ label: 'HR Personal', roles: ['hr_personal'], icon: '🧑‍💼' },
{ label: 'Buchhaltung', roles: ['buchhaltung'], icon: '💶' },
{ label: 'Produktion', roles: ['produktion', 'techniker'], icon: '🏭' },
{ label: 'Benutzer', roles: ['benutzer'], icon: '👤' },
];
export default function PortalPage() {
const { isAdmin, isSuperAdmin } = useAuth();
const canAdmin = isAdmin() || isSuperAdmin();
const [guides, setGuides] = useState([]);
const [loading, setLoading] = useState(true);
const [categoryFilter, setCategoryFilter] = useState('all');
const [search, setSearch] = useState('');
// Modal state
const [modal, setModal] = useState(null); // null | { mode: 'create'|'edit', guide? }
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ title: '', category: 'Allgemein', description: '', icon: '📄', html_content: '', visible_roles: [] });
const fileInputRef = useRef(null);
// Viewer state
const [viewer, setViewer] = useState(null); // null | guide object
const [viewerHtml, setViewerHtml] = useState('');
const load = async () => {
try {
const data = await portalGuideService.getAll();
setGuides(data);
} catch {
toast.error('Fehler beim Laden');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const filtered = guides.filter(g => {
const matchCat = categoryFilter === 'all' || g.category === categoryFilter;
const q = search.toLowerCase();
const matchSearch = !q || g.title.toLowerCase().includes(q) || g.category.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
return matchCat && matchSearch;
});
const categories = ['all', ...new Set(guides.map(g => g.category))];
const openCreate = () => {
setForm({ title: '', category: 'Allgemein', description: '', icon: '📄', html_content: '', visible_roles: [] });
setModal({ mode: 'create' });
};
const openEdit = async (g) => {
const full = await portalGuideService.getById(g.id);
let vr = [];
try { vr = JSON.parse(full.visible_roles || '[]'); } catch {}
setForm({ title: full.title, category: full.category, description: full.description || '', icon: full.icon || '📄', html_content: full.html_content, visible_roles: vr });
setModal({ mode: 'edit', guide: g });
};
const openViewer = async (g) => {
setViewer(g);
setViewerHtml('');
try {
const full = await portalGuideService.getById(g.id);
setViewerHtml(full.html_content || '');
} catch {
setViewerHtml('<p style="color:red">Fehler beim Laden der Anleitung.</p>');
}
};
const handleFileSelect = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
setForm(f => ({ ...f, html_content: ev.target.result, title: f.title || file.name.replace(/\.html?$/i, '') }));
};
reader.readAsText(file, 'UTF-8');
};
const save = async () => {
if (!form.title.trim()) return toast.error('Titel erforderlich');
if (!form.html_content.trim()) return toast.error('HTML-Inhalt fehlt');
setSaving(true);
try {
if (modal.mode === 'create') {
await portalGuideService.create(form);
toast.success('Anleitung erstellt');
} else {
await portalGuideService.update(modal.guide.id, form);
toast.success('Anleitung gespeichert');
}
setModal(null);
await load();
} catch (e) {
toast.error(e.response?.data?.error || 'Fehler');
} finally {
setSaving(false);
}
};
const remove = async (g) => {
if (!window.confirm(`"${g.title}" wirklich löschen?`)) return;
try {
await portalGuideService.delete(g.id);
toast.success('Gelöscht');
await load();
} catch {
toast.error('Fehler beim Löschen');
}
};
// ── Viewer ───────────────────────────────────────────────────────────────
if (viewer) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg-primary)' }}>
{/* Viewer Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px 20px', background: 'var(--bg-card)', borderBottom: '1px solid var(--border-color)', flexShrink: 0 }}>
<button
onClick={() => setViewer(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cereda-primary)', fontSize: '20px', lineHeight: 1, padding: '4px' }}
>
</button>
<span style={{ fontSize: '18px' }}>{viewer.icon}</span>
<div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '16px' }}>{viewer.title}</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{viewer.category}</div>
</div>
{canAdmin && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary btn-sm" onClick={() => { setViewer(null); setViewerHtml(''); openEdit(viewer); }}>Bearbeiten</button>
</div>
)}
</div>
{/* HTML Viewer */}
{viewerHtml ? (
<iframe
srcDoc={viewerHtml}
style={{ flex: 1, border: 'none', background: '#fff' }}
title={viewer.title}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
/>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#64748b' }}>Laden...</div>
</div>
)}
</div>
);
}
// ── Main List ────────────────────────────────────────────────────────────
return (
<div style={{ padding: '28px', maxWidth: '1200px', margin: '0 auto' }}>
{/* Header */}
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div>
<h1 style={{ margin: 0, color: 'var(--text-primary)', fontSize: '24px', fontWeight: 800 }}>Portal</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '14px' }}>Anleitungen und Dokumentationen</p>
</div>
{canAdmin && (
<button className="btn btn-primary" onClick={openCreate}>+ Anleitung hochladen</button>
)}
</div>
{/* Filter Row */}
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="form-input"
placeholder="Suchen..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: '220px' }}
/>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
style={{
padding: '6px 14px',
borderRadius: '20px',
border: categoryFilter === cat ? '1.5px solid var(--cereda-primary)' : '1px solid var(--border-color)',
background: categoryFilter === cat ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
color: categoryFilter === cat ? 'var(--cereda-primary)' : 'var(--text-secondary)',
fontWeight: categoryFilter === cat ? 700 : 400,
cursor: 'pointer',
fontSize: '13px',
}}
>
{cat === 'all' ? 'Alle' : cat}
</button>
))}
</div>
</div>
{/* Guides Grid */}
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>Laden...</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '48px', marginBottom: '12px' }}>📄</div>
<div style={{ fontSize: '16px' }}>Keine Anleitungen vorhanden</div>
{canAdmin && <div style={{ fontSize: '13px', marginTop: '6px' }}>Klicke auf "+ Anleitung hochladen" um zu beginnen</div>}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{filtered.map(g => (
<div
key={g.id}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '14px',
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s, transform 0.15s',
position: 'relative',
}}
onClick={() => openViewer(g)}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--cereda-primary)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; e.currentTarget.style.transform = ''; }}
>
<div style={{ fontSize: '36px', marginBottom: '12px' }}>{g.icon || '📄'}</div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '15px', marginBottom: '6px' }}>{g.title}</div>
{g.description && (
<div style={{ color: 'var(--text-muted)', fontSize: '13px', marginBottom: '10px', lineHeight: 1.4 }}>
{g.description.length > 80 ? g.description.slice(0, 80) + '…' : g.description}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{
background: 'rgba(13,148,136,0.1)',
color: 'var(--cereda-primary)',
borderRadius: '20px',
padding: '3px 10px',
fontSize: '11px',
fontWeight: 600,
}}>
{g.category}
</span>
{(() => {
let vr = [];
try { vr = JSON.parse(g.visible_roles || '[]'); } catch {}
if (vr.length > 0) {
const groupNames = ROLE_GROUPS.filter(rg => rg.roles.some(r => vr.includes(r))).map(rg => rg.label);
return <span title={`Nur für: ${groupNames.join(', ')}`} style={{ fontSize: 13, color: 'var(--text-muted)' }}>🔒</span>;
}
return null;
})()}
{canAdmin && (
<div style={{ display: 'flex', gap: '4px' }} onClick={e => e.stopPropagation()}>
<button
className="btn btn-secondary btn-sm"
style={{ padding: '3px 8px', fontSize: '11px' }}
onClick={() => openEdit(g)}
></button>
<button
style={{ padding: '3px 8px', fontSize: '11px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => remove(g)}
>🗑</button>
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Upload / Edit Modal */}
{modal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.65)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '20px' }}>
<div style={{ background: 'var(--bg-modal)', borderRadius: '16px', padding: '28px', width: '100%', maxWidth: '580px', border: '1px solid var(--border-color)', maxHeight: '90vh', overflowY: 'auto' }}>
<h3 style={{ margin: '0 0 20px', color: 'var(--text-primary)', fontSize: '18px' }}>
{modal.mode === 'create' ? '📄 Anleitung hochladen' : '✏️ Anleitung bearbeiten'}
</h3>
{/* Icon Picker */}
<label style={labelStyle}>Icon</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '16px' }}>
{ICONS.map(ic => (
<button
key={ic}
type="button"
onClick={() => setForm(f => ({ ...f, icon: ic }))}
style={{
fontSize: '20px',
padding: '6px 8px',
border: form.icon === ic ? '2px solid var(--cereda-primary)' : '1px solid var(--border-color)',
borderRadius: '8px',
background: form.icon === ic ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
cursor: 'pointer',
}}
>{ic}</button>
))}
</div>
{/* Title */}
<label style={labelStyle}>Titel *</label>
<input className="form-input" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="z.B. SelectLine Einführung" style={{ marginBottom: '12px' }} />
{/* Category */}
<label style={labelStyle}>Kategorie</label>
<select className="form-select" value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))} style={{ marginBottom: '12px' }}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{/* Description */}
<label style={labelStyle}>Kurzbeschreibung (optional)</label>
<textarea className="form-input" rows={2} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="Worum geht es in dieser Anleitung?" style={{ marginBottom: '16px', resize: 'vertical' }} />
{/* Visibility */}
<label style={labelStyle}>Sichtbar für</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
<button
type="button"
onClick={() => setForm(f => ({ ...f, visible_roles: [] }))}
style={{
padding: '7px 14px', borderRadius: 20, border: form.visible_roles.length === 0 ? '1.5px solid var(--cereda-primary)' : '1px solid var(--border-color)',
background: form.visible_roles.length === 0 ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
color: form.visible_roles.length === 0 ? 'var(--cereda-primary)' : 'var(--text-secondary)',
fontWeight: form.visible_roles.length === 0 ? 700 : 400, cursor: 'pointer', fontSize: 13,
}}
>🌐 Alle</button>
{ROLE_GROUPS.map(g => {
const active = g.roles.every(r => form.visible_roles.includes(r)) || g.roles.some(r => form.visible_roles.includes(r));
return (
<button
key={g.label}
type="button"
onClick={() => setForm(f => {
const newRoles = active
? f.visible_roles.filter(r => !g.roles.includes(r))
: [...new Set([...f.visible_roles, ...g.roles])];
return { ...f, visible_roles: newRoles };
})}
style={{
padding: '7px 14px', borderRadius: 20,
border: active ? '1.5px solid var(--cereda-primary)' : '1px solid var(--border-color)',
background: active ? 'rgba(13,148,136,0.1)' : 'var(--bg-card)',
color: active ? 'var(--cereda-primary)' : 'var(--text-secondary)',
fontWeight: active ? 700 : 400, cursor: 'pointer', fontSize: 13,
}}
>{g.icon} {g.label}</button>
);
})}
</div>
{/* File Upload */}
<label style={labelStyle}>HTML-Datei *</label>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', marginBottom: '8px' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => fileInputRef.current?.click()}
>
📂 Datei auswählen
</button>
{form.html_content && (
<span style={{ color: 'var(--cereda-primary)', fontSize: '13px', fontWeight: 600 }}>
Datei geladen ({Math.round(form.html_content.length / 1024)} KB)
</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".html,.htm"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{!form.html_content && (
<p style={{ fontSize: '12px', color: 'var(--text-muted)', margin: '0 0 16px' }}>
Wähle eine fertige .html Datei aus
</p>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '8px' }}>
<button className="btn btn-secondary" onClick={() => setModal(null)}>Abbrechen</button>
<button className="btn btn-primary" disabled={saving || !form.title.trim() || !form.html_content.trim()} onClick={save}>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
const labelStyle = {
display: 'block',
marginBottom: '6px',
fontSize: '13px',
color: 'var(--text-secondary)',
fontWeight: 600,
};

View File

@@ -0,0 +1,638 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import onboardingProcessService from '../services/onboardingProcessService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const TAG_OPTIONS = [
{ value: 'normal', label: 'Standard', color: '#64748b' },
{ value: 'important', label: 'Wichtig', color: '#f59e0b' },
{ value: 'critical', label: 'Kritisch', color: '#ef4444' },
];
const APPLIES_OPTIONS = [
{ value: 'both', label: 'Onboarding & Offboarding' },
{ value: 'onboarding', label: 'Nur Onboarding' },
{ value: 'offboarding', label: 'Nur Offboarding' },
];
const TEAM_OPTIONS = [
{ value: 'it', label: 'IT', color: '#3b82f6', icon: '💻' },
{ value: 'hr', label: 'HR / Personal', color: '#10b981', icon: '🧑‍💼' },
{ value: 'buchhaltung', label: 'Buchhaltung', color: '#8b5cf6', icon: '💶' },
];
const ICON_SUGGESTIONS = ['🔧','🛠️','💼','📊','📦','💻','🏢','🏭','🎓','📞','📋','🏪','🚗','🔑'];
// Rolle → responsible_team Mapping
const ROLE_TO_TEAM = {
hr_personal: 'hr',
buchhaltung: 'buchhaltung',
support: 'it',
admin: null, // alle
super_admin: null, // alle
};
const TagBadge = ({ tag }) => {
const t = TAG_OPTIONS.find(o => o.value === tag) || TAG_OPTIONS[0];
return (
<span style={{
display: 'inline-block', padding: '1px 7px', borderRadius: 4,
fontSize: '0.7rem', fontWeight: 700,
background: t.color + '22', color: t.color,
}}>{t.label}</span>
);
};
const TeamBadge = ({ team }) => {
const t = TEAM_OPTIONS.find(o => o.value === team) || TEAM_OPTIONS[0];
return (
<span style={{
display: 'inline-block', padding: '1px 7px', borderRadius: 4,
fontSize: '0.7rem', fontWeight: 700,
background: t.color + '22', color: t.color,
}}>{t.icon} {t.label}</span>
);
};
const AppliesBadge = ({ val }) => {
const colors = { both: '#64748b', onboarding: '#10b981', offboarding: '#ef4444' };
const labels = { both: 'Beides', onboarding: 'Onboarding', offboarding: 'Offboarding' };
return (
<span style={{
display: 'inline-block', padding: '1px 7px', borderRadius: 4,
fontSize: '0.7rem', fontWeight: 600,
background: (colors[val] || '#64748b') + '20',
color: colors[val] || '#64748b',
}}>{labels[val] || val}</span>
);
};
const ProcessManagementPage = ({ embedded = false }) => {
const { user, isAdmin, isSuperAdmin } = useAuth();
const canManageDepts = isAdmin() || isSuperAdmin();
const userTeam = ROLE_TO_TEAM[user?.role_name] !== undefined ? ROLE_TO_TEAM[user?.role_name] : null;
const isAdminUser = canManageDepts;
const [loading, setLoading] = useState(true);
const [departments, setDepartments] = useState([]);
const [processes, setProcesses] = useState([]);
const [selectedDeptId, setSelectedDeptId] = useState(null);
const [filterApplies, setFilterApplies] = useState('all');
const [filterTeam, setFilterTeam] = useState(userTeam || 'all');
// Department form
const [showDeptForm, setShowDeptForm] = useState(false);
const [editingDept, setEditingDept] = useState(null);
const [deptForm, setDeptForm] = useState({ name: '', icon: '🏢' });
// Process form
const [showProcForm, setShowProcForm] = useState(false);
const [editingProc, setEditingProc] = useState(null);
const [procForm, setProcForm] = useState({
title: '', description: '', applies_to: 'both', tag: 'normal',
department_id: '', responsible_team: userTeam || 'it',
});
// Copy / Paste (bulk)
const [copiedProcs, setCopiedProcs] = useState(null); // { sourceDeptId, items: [] }
const copyTeamProcs = (teamValue, teamLabel) => {
const teamProcs = processes.filter(p => p.department_id === selectedDeptId && p.responsible_team === teamValue);
if (teamProcs.length === 0) { toast.warn('Keine Prozesse zum Kopieren'); return; }
setCopiedProcs({ sourceDeptId: selectedDeptId, sourceTeam: teamValue, sourceTeamLabel: teamLabel, items: teamProcs });
toast.info(`📋 ${teamProcs.length} ${teamLabel}-Prozesse kopiert`);
};
const pasteAllProcs = async () => {
if (!copiedProcs) return;
const targetDept = departments.find(d => d.id === selectedDeptId);
if (!window.confirm(`${copiedProcs.items.length} Prozesse in "${targetDept?.name}" einfügen?`)) return;
try {
for (const proc of copiedProcs.items) {
await onboardingProcessService.createProcess({
title: proc.title,
description: proc.description || '',
applies_to: proc.applies_to,
tag: proc.tag,
department_id: selectedDeptId,
responsible_team: proc.responsible_team,
});
}
toast.success(`${copiedProcs.items.length} Prozesse eingefügt`);
loadAll();
} catch (err) {
toast.error(err.message || 'Fehler beim Einfügen');
}
};
// Drag & drop
const dragItem = useRef(null);
const dragOverItem = useRef(null);
useEffect(() => {
loadAll();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadAll = async () => {
try {
const [depts, procs] = await Promise.all([
onboardingProcessService.getDepartments(),
onboardingProcessService.getProcesses(),
]);
setDepartments(depts);
setProcesses(procs);
if (!selectedDeptId && depts.length > 0) {
setSelectedDeptId(depts[0].id);
}
} catch {
toast.error('Fehler beim Laden');
} finally {
setLoading(false);
}
};
// ── Department handlers ──────────────────────────────────────────────────
const openDeptCreate = () => {
setEditingDept(null);
setDeptForm({ name: '', icon: '🏢' });
setShowDeptForm(true);
};
const openDeptEdit = (dept) => {
setEditingDept(dept);
setDeptForm({ name: dept.name, icon: dept.icon });
setShowDeptForm(true);
};
const submitDept = async (e) => {
e.preventDefault();
try {
if (editingDept) {
await onboardingProcessService.updateDepartment(editingDept.id, deptForm);
toast.success('Abteilung aktualisiert');
} else {
const newDept = await onboardingProcessService.createDepartment(deptForm);
setSelectedDeptId(newDept.id);
toast.success('Abteilung erstellt');
}
setShowDeptForm(false);
loadAll();
} catch (err) {
toast.error(err.message || 'Fehler beim Speichern');
}
};
const deleteDept = async (dept) => {
const procs = processes.filter(p => p.department_id === dept.id);
const msg = procs.length > 0
? `Abteilung "${dept.name}" mit ${procs.length} Prozessen wirklich löschen?`
: `Abteilung "${dept.name}" wirklich löschen?`;
if (!window.confirm(msg)) return;
try {
await onboardingProcessService.deleteDepartment(dept.id);
toast.success('Abteilung gelöscht');
if (selectedDeptId === dept.id) setSelectedDeptId(null);
loadAll();
} catch (err) {
toast.error(err.message || 'Fehler beim Löschen');
}
};
// ── Process handlers ─────────────────────────────────────────────────────
const openProcCreate = () => {
setEditingProc(null);
setProcForm({
title: '', description: '', applies_to: 'both', tag: 'normal',
department_id: selectedDeptId || '',
responsible_team: userTeam || 'it',
});
setShowProcForm(true);
};
const openProcEdit = (proc) => {
setEditingProc(proc);
setProcForm({
title: proc.title,
description: proc.description || '',
applies_to: proc.applies_to,
tag: proc.tag,
department_id: proc.department_id,
responsible_team: proc.responsible_team,
});
setShowProcForm(true);
};
const submitProc = async (e) => {
e.preventDefault();
try {
if (editingProc) {
await onboardingProcessService.updateProcess(editingProc.id, procForm);
toast.success('Prozess aktualisiert');
} else {
await onboardingProcessService.createProcess(procForm);
toast.success('Prozess erstellt');
}
setShowProcForm(false);
loadAll();
} catch (err) {
toast.error(err.message || 'Fehler beim Speichern');
}
};
const deleteProc = async (proc) => {
if (!window.confirm(`Prozess "${proc.title}" wirklich löschen?`)) return;
try {
await onboardingProcessService.deleteProcess(proc.id);
toast.success('Prozess gelöscht');
loadAll();
} catch (err) {
toast.error(err.message || 'Fehler');
}
};
const canEditProc = (proc) => {
if (isAdminUser) return true;
return proc.responsible_team === userTeam;
};
// ── Drag & drop ──────────────────────────────────────────────────────────
const handleDragStart = (e, idx) => {
dragItem.current = idx;
e.dataTransfer.effectAllowed = 'move';
};
const handleDragEnter = (e, idx) => {
dragOverItem.current = idx;
e.preventDefault();
};
const handleDrop = async (e) => {
e.preventDefault();
if (dragItem.current === null || dragOverItem.current === null) return;
if (dragItem.current === dragOverItem.current) return;
const deptProcs = visibleProcesses.slice();
const dragged = deptProcs.splice(dragItem.current, 1)[0];
deptProcs.splice(dragOverItem.current, 0, dragged);
dragItem.current = null;
dragOverItem.current = null;
const updatedProcs = processes.map(p => {
const idx = deptProcs.findIndex(dp => dp.id === p.id);
return idx >= 0 ? { ...p, sort_order: idx } : p;
});
setProcesses(updatedProcs);
try {
await onboardingProcessService.reorderProcesses(selectedDeptId, deptProcs.map(p => p.id));
} catch {
toast.error('Fehler beim Speichern der Reihenfolge');
loadAll();
}
};
if (loading) return embedded ? <LoadingSpinner /> : <div className="main-content"><LoadingSpinner /></div>;
const selectedDept = departments.find(d => d.id === selectedDeptId);
const visibleProcesses = processes
.filter(p => {
if (p.department_id !== selectedDeptId) return false;
// Nicht-Admins sehen nur ihr eigenes Team
if (!isAdminUser && userTeam && p.responsible_team !== userTeam) return false;
if (filterTeam !== 'all' && p.responsible_team !== filterTeam) return false;
if (filterApplies === 'all') return true;
return p.applies_to === filterApplies || p.applies_to === 'both';
})
.sort((a, b) => {
// Erst nach Team gruppieren, dann sort_order
const teamOrder = ['it', 'hr', 'buchhaltung'];
const ta = teamOrder.indexOf(a.responsible_team);
const tb = teamOrder.indexOf(b.responsible_team);
if (ta !== tb) return ta - tb;
return a.sort_order - b.sort_order || a.title.localeCompare(b.title);
});
// Prozesse nach Team gruppieren
const processGroups = TEAM_OPTIONS
.filter(t => !userTeam || t.value === userTeam || isAdminUser)
.map(t => ({
team: t,
items: visibleProcesses.filter(p => p.responsible_team === t.value),
}))
.filter(g => g.items.length > 0 || (isAdminUser && filterTeam === 'all'));
const content = (
<>
{/* Header nur wenn nicht eingebettet */}
{!embedded && (
<div style={{ marginBottom: 24 }}>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}> Prozess-Verwaltung</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
Onboarding & Offboarding Checklisten konfigurieren
</p>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 20, alignItems: 'start' }}>
{/* ── Links: Firmenabteilungen ── */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{ fontWeight: 700, fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)' }}>
Abteilungen
</span>
{canManageDepts && (
<button onClick={openDeptCreate} className="btn btn-primary btn-small">+ Neu</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{departments.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Keine Abteilungen vorhanden</p>
)}
{departments.map(dept => {
const procCount = processes.filter(p =>
p.department_id === dept.id &&
(!userTeam || isAdminUser || p.responsible_team === userTeam)
).length;
const isSelected = dept.id === selectedDeptId;
return (
<div
key={dept.id}
onClick={() => setSelectedDeptId(dept.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', borderRadius: 8, cursor: 'pointer',
background: isSelected ? 'var(--cereda-primary)' : 'var(--bg-card)',
color: isSelected ? '#fff' : 'var(--text-primary)',
border: `1px solid ${isSelected ? 'var(--cereda-primary)' : 'var(--border-color)'}`,
transition: 'all 0.15s',
}}
>
<span style={{ fontSize: '1.1rem' }}>{dept.icon}</span>
<span style={{ flex: 1, fontWeight: 600, fontSize: '0.88rem' }}>{dept.name}</span>
<span style={{
fontSize: '0.7rem', fontWeight: 700, padding: '1px 6px', borderRadius: 10,
background: isSelected ? 'rgba(255,255,255,0.25)' : 'var(--bg-secondary)',
color: isSelected ? '#fff' : 'var(--text-muted)',
}}>{procCount}</span>
{canManageDepts && (
<div style={{ display: 'flex', gap: 2 }} onClick={e => e.stopPropagation()}>
<button onClick={() => openDeptEdit(dept)} title="Bearbeiten"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 3px', opacity: 0.7, color: isSelected ? '#fff' : 'var(--text-muted)', fontSize: '0.8rem' }}></button>
<button onClick={() => deleteDept(dept)} title="Löschen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 3px', opacity: 0.7, color: isSelected ? '#fff' : '#ef4444', fontSize: '0.8rem' }}>🗑</button>
</div>
)}
</div>
);
})}
</div>
</div>
{/* ── Rechts: Prozesse ── */}
<div>
{!selectedDept ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
Abteilung auswählen
</div>
) : (
<>
{/* Toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '1.3rem' }}>{selectedDept.icon}</span>
<h2 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700 }}>{selectedDept.name}</h2>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<select className="form-select" style={{ padding: '5px 10px', fontSize: '0.82rem', width: 'auto' }}
value={filterApplies} onChange={e => setFilterApplies(e.target.value)}>
<option value="all">Alle Typen</option>
<option value="onboarding">Onboarding</option>
<option value="offboarding">Offboarding</option>
</select>
{isAdminUser && (
<select className="form-select" style={{ padding: '5px 10px', fontSize: '0.82rem', width: 'auto' }}
value={filterTeam} onChange={e => setFilterTeam(e.target.value)}>
<option value="all">Alle Teams</option>
{TEAM_OPTIONS.map(t => <option key={t.value} value={t.value}>{t.icon} {t.label}</option>)}
</select>
)}
{copiedProcs && copiedProcs.sourceDeptId !== selectedDeptId && (
<button onClick={pasteAllProcs} className="btn btn-secondary btn-small" title="Kopierte Prozesse einfügen">
📋 {copiedProcs.items.length} {copiedProcs.sourceTeamLabel} einfügen
</button>
)}
<button onClick={openProcCreate} className="btn btn-primary btn-small">+ Prozess hinzufügen</button>
</div>
</div>
{/* Prozesse nach Team gruppiert */}
{visibleProcesses.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
Noch keine Prozesse. Klicke auf + Prozess hinzufügen".
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{processGroups.map(({ team, items }) => items.length === 0 ? null : (
<div key={team.value}>
{/* Team-Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, paddingBottom: 6, borderBottom: `2px solid ${team.color}33` }}>
<span>{team.icon}</span>
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: team.color }}>{team.label}</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{items.length} Aufgaben</span>
<button
onClick={() => copyTeamProcs(team.value, team.label)}
className="btn btn-secondary btn-small"
title={`${team.label}-Prozesse kopieren`}
style={{
marginLeft: 'auto', fontSize: '0.72rem', padding: '2px 8px',
background: copiedProcs?.sourceDeptId === selectedDeptId && copiedProcs?.sourceTeam === team.value ? team.color + '22' : undefined,
color: copiedProcs?.sourceDeptId === selectedDeptId && copiedProcs?.sourceTeam === team.value ? team.color : undefined,
}}>
📋 Kopieren
</button>
</div>
{/* Prozessliste */}
<div
style={{ display: 'flex', flexDirection: 'column', gap: 5 }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
{items.map((proc, idx) => (
<div
key={proc.id}
draggable={canEditProc(proc)}
onDragStart={e => handleDragStart(e, idx)}
onDragEnter={e => handleDragEnter(e, idx)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '10px 12px', borderRadius: 8,
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
cursor: canEditProc(proc) ? 'grab' : 'default',
borderLeft: `3px solid ${team.color}`,
}}
>
{canEditProc(proc) && (
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', lineHeight: '1.5', userSelect: 'none', flexShrink: 0 }}>⋮⋮</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 2 }}>
<span style={{ fontWeight: 600, fontSize: '0.88rem' }}>{proc.title}</span>
<TagBadge tag={proc.tag} />
<AppliesBadge val={proc.applies_to} />
</div>
{proc.description && (
<p style={{ margin: 0, fontSize: '0.78rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>
{proc.description}
</p>
)}
</div>
{canEditProc(proc) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button onClick={() => openProcEdit(proc)} className="btn btn-secondary btn-small" title="Bearbeiten">✏️</button>
<button onClick={() => deleteProc(proc)} className="btn btn-danger btn-small" title="Löschen">🗑️</button>
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
{/* ── Department Modal ── */}
{showDeptForm && (
<div className="modal-overlay" onClick={() => setShowDeptForm(false)}>
<div className="modal-content" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingDept ? 'Abteilung bearbeiten' : 'Neue Abteilung'}</h2>
<button className="modal-close" onClick={() => setShowDeptForm(false)}>×</button>
</div>
<form onSubmit={submitDept} style={{ padding: '0 24px 24px' }}>
<div className="form-group">
<label className="form-label">Name *</label>
<input className="form-input" type="text" placeholder="z.B. Wartung, Vertrieb AD"
value={deptForm.name}
onChange={e => setDeptForm({ ...deptForm, name: e.target.value })}
required />
</div>
<div className="form-group">
<label className="form-label">Icon</label>
<input className="form-input" type="text"
value={deptForm.icon}
onChange={e => setDeptForm({ ...deptForm, icon: e.target.value })}
style={{ fontSize: '1.3rem', width: 60 }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{ICON_SUGGESTIONS.map(ic => (
<button key={ic} type="button"
onClick={() => setDeptForm({ ...deptForm, icon: ic })}
style={{
fontSize: '1.2rem', padding: '4px 8px', borderRadius: 6, cursor: 'pointer',
border: `2px solid ${deptForm.icon === ic ? 'var(--cereda-primary)' : 'var(--border-color)'}`,
background: deptForm.icon === ic ? 'var(--cereda-primary)10' : 'var(--bg-secondary)',
}}>{ic}</button>
))}
</div>
</div>
<div className="card-footer" style={{ padding: 0 }}>
<button type="button" onClick={() => setShowDeptForm(false)} className="btn btn-secondary">Abbrechen</button>
<button type="submit" className="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
)}
{/* ── Process Modal ── */}
{showProcForm && (
<div className="modal-overlay" onClick={() => setShowProcForm(false)}>
<div className="modal-content" style={{ maxWidth: 540 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingProc ? 'Prozess bearbeiten' : 'Neuer Prozess'}</h2>
<button className="modal-close" onClick={() => setShowProcForm(false)}>×</button>
</div>
<form onSubmit={submitProc} style={{ padding: '0 24px 24px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">Abteilung *</label>
<select className="form-select"
value={procForm.department_id}
onChange={e => setProcForm({ ...procForm, department_id: Number(e.target.value) })}
required>
<option value="">— Bitte wählen —</option>
{departments.map(d => (
<option key={d.id} value={d.id}>{d.icon} {d.name}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Zuständig *</label>
{isAdminUser ? (
<select className="form-select"
value={procForm.responsible_team}
onChange={e => setProcForm({ ...procForm, responsible_team: e.target.value })}>
{TEAM_OPTIONS.map(t => <option key={t.value} value={t.value}>{t.icon} {t.label}</option>)}
</select>
) : (
<input className="form-input" readOnly
value={TEAM_OPTIONS.find(t => t.value === userTeam)?.label || userTeam} />
)}
</div>
</div>
<div className="form-group">
<label className="form-label">Titel *</label>
<input className="form-input" type="text" placeholder="z.B. AD-Account erstellen"
value={procForm.title}
onChange={e => setProcForm({ ...procForm, title: e.target.value })}
required />
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea className="form-textarea" rows={3} placeholder="Optionale Details / Hinweise..."
value={procForm.description}
onChange={e => setProcForm({ ...procForm, description: e.target.value })} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">Gilt für</label>
<select className="form-select" value={procForm.applies_to}
onChange={e => setProcForm({ ...procForm, applies_to: e.target.value })}>
{APPLIES_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Priorität</label>
<select className="form-select" value={procForm.tag}
onChange={e => setProcForm({ ...procForm, tag: e.target.value })}>
{TAG_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div className="card-footer" style={{ padding: 0 }}>
<button type="button" onClick={() => setShowProcForm(false)} className="btn btn-secondary">Abbrechen</button>
<button type="submit" className="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
)}
</>
);
if (embedded) return content;
return (
<div className="main-content">
<div className="container">{content}</div>
</div>
);
};
export default ProcessManagementPage;

View File

@@ -0,0 +1,282 @@
import React, { useState, useEffect, useCallback } from 'react';
import api from '../services/api';
const fmt = (n, d = 1) => n != null ? Number(n).toFixed(d) : '—';
const fmtGiB = (bytes) => bytes != null ? (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB' : '—';
const fmtUptime = (s) => {
if (!s) return '—';
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
return d > 0 ? `${d}d ${h}h ${m}m` : `${h}h ${m}m`;
};
const fmtDate = (ts) => ts ? new Date(ts * 1000).toLocaleString('de-DE') : '—';
const STATUS_COLOR = { running: '#10B981', stopped: '#EF4444', paused: '#F59E0B', unknown: '#6B7280' };
function UsageBar({ used, total, label }) {
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
const color = pct >= 90 ? '#EF4444' : pct >= 70 ? '#F59E0B' : '#10B981';
return (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4, fontSize: 12 }}>
<span style={{ color: 'var(--text-secondary)', fontWeight: 600 }}>{label}</span>
<span style={{ color, fontWeight: 700 }}>{fmt(pct, 1)}%</span>
</div>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 4, height: 8, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 4, transition: 'width .3s' }} />
</div>
</div>
);
}
function StatusDot({ status }) {
return <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: STATUS_COLOR[status] || '#6B7280', marginRight: 6 }} />;
}
function StatBox({ label, value, color }) {
return (
<div style={{ background: 'var(--bg-secondary)', borderRadius: 10, padding: '12px 16px', textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 800, color: color || 'var(--text-primary)', fontFamily: 'monospace' }}>{value}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{label}</div>
</div>
);
}
export default function ProxmoxPage() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [tab, setTab] = useState('overview');
const load = useCallback(async () => {
try {
const r = await api.get('/proxmox/overview');
setData(r.data);
setError(null);
} catch (e) {
setError(e.response?.data?.error || 'Proxmox nicht erreichbar');
}
setLoading(false);
}, []);
useEffect(() => { load(); const t = setInterval(load, 30000); return () => clearInterval(t); }, [load]);
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Verbinde mit Proxmox...</div>;
if (error) return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontSize: 48, marginBottom: 12 }}></div>
<div style={{ color: '#EF4444', fontWeight: 700, marginBottom: 8 }}>Proxmox nicht erreichbar</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{error}</div>
<button className="btn btn-secondary" style={{ marginTop: 16 }} onClick={load}>🔄 Erneut versuchen</button>
</div>
);
const { node, vms, lxcs, storage, tasks, disks, network, node_name, fetched_at } = data;
const allGuests = [
...(vms || []).map(v => ({ ...v, type: 'VM' })),
...(lxcs || []).map(c => ({ ...c, type: 'CT' })),
].sort((a, b) => (a.vmid || 0) - (b.vmid || 0));
const runningCount = allGuests.filter(g => g.status === 'running').length;
const stoppedCount = allGuests.filter(g => g.status === 'stopped').length;
const cpuPct = node ? (node.cpu || 0) * 100 : 0;
const ramPct = node ? (node.memory?.used / node.memory?.total) * 100 : 0;
const diskPct = node ? (node.rootfs?.used / node.rootfs?.total) * 100 : 0;
return (
<div style={{ padding: '28px' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 24, fontWeight: 800 }}>
🖥 Proxmox {node_name}
</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Uptime: {fmtUptime(node?.uptime)} · Kernel: {node?.kversion?.split(' ')[0] || '—'} · Aktualisiert: {fetched_at ? new Date(fetched_at).toLocaleTimeString('de-DE') : '—'}
</p>
</div>
<button className="btn btn-secondary" onClick={load}>🔄 Aktualisieren</button>
</div>
{/* Node Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10, marginBottom: 24 }}>
<StatBox label="CPU Usage" value={`${fmt(cpuPct)}%`} color={cpuPct >= 90 ? '#EF4444' : cpuPct >= 70 ? '#F59E0B' : '#10B981'} />
<StatBox label="RAM Usage" value={`${fmt(ramPct)}%`} color={ramPct >= 90 ? '#EF4444' : ramPct >= 70 ? '#F59E0B' : '#10B981'} />
<StatBox label="Root Disk" value={`${fmt(diskPct)}%`} color={diskPct >= 90 ? '#EF4444' : diskPct >= 70 ? '#F59E0B' : '#10B981'} />
<StatBox label="VMs + CTs" value={allGuests.length} />
<StatBox label="Laufend" value={runningCount} color="#10B981" />
<StatBox label="Gestoppt" value={stoppedCount} color="#EF4444" />
<StatBox label="Load Avg" value={node?.loadavg?.[0] ? fmt(node.loadavg[0]) : '—'} color={node?.loadavg?.[0] > 16 ? '#EF4444' : node?.loadavg?.[0] > 8 ? '#F59E0B' : '#10B981'} />
</div>
{/* Node Resource Bars */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 20, marginBottom: 24 }}>
<h3 style={{ margin: '0 0 16px', color: 'var(--text-primary)', fontSize: 15 }}>Node Ressourcen</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<div>
<UsageBar used={node?.cpu || 0} total={1} label={`CPU — ${node?.cpuinfo?.cpus || '?'} Kerne (${node?.cpuinfo?.model || ''})`} />
<UsageBar used={node?.memory?.used || 0} total={node?.memory?.total || 1} label={`RAM — ${fmtGiB(node?.memory?.used)} / ${fmtGiB(node?.memory?.total)}`} />
<UsageBar used={node?.rootfs?.used || 0} total={node?.rootfs?.total || 1} label={`Root FS — ${fmtGiB(node?.rootfs?.used)} / ${fmtGiB(node?.rootfs?.total)}`} />
</div>
<div>
{node?.swap?.total > 0 && <UsageBar used={node?.swap?.used || 0} total={node?.swap?.total || 1} label={`Swap — ${fmtGiB(node?.swap?.used)} / ${fmtGiB(node?.swap?.total)}`} />}
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 8 }}>
<div>Load: {node?.loadavg?.map(l => fmt(l)).join(' / ') || '—'}</div>
<div>CPUs: {node?.cpuinfo?.cpus || '?'} ({node?.cpuinfo?.cores || '?'} Kerne × {node?.cpuinfo?.sockets || '?'} Sockets)</div>
<div>IO Delay: {node?.wait != null ? `${fmt(node.wait * 100)}%` : '—'}</div>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border-color)', marginBottom: 24 }}>
{[['overview', `🖥️ VMs & Container (${allGuests.length})`], ['storage', `💾 Storage (${(storage || []).length})`], ['tasks', `📋 Tasks`], ['network', `🌐 Netzwerk`]].map(([k, l]) => (
<button key={k} onClick={() => setTab(k)} style={{
background: 'none', border: 'none', borderBottom: tab === k ? '2px solid var(--cereda-primary)' : '2px solid transparent',
color: tab === k ? 'var(--cereda-primary)' : 'var(--text-secondary)',
padding: '10px 18px', cursor: 'pointer', fontWeight: tab === k ? 700 : 400, fontSize: 14, marginBottom: -1,
}}>{l}</button>
))}
</div>
{/* VMs & Container */}
{tab === 'overview' && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-color)' }}>
{['ID', 'Typ', 'Name', 'Status', 'CPU', 'RAM', 'Disk', 'Uptime'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{allGuests.map(g => {
const cpuU = (g.cpu || 0) * 100;
const ramU = g.mem && g.maxmem ? (g.mem / g.maxmem) * 100 : 0;
const diskU = g.disk && g.maxdisk ? (g.disk / g.maxdisk) * 100 : 0;
return (
<tr key={`${g.type}-${g.vmid}`} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '10px 14px', fontFamily: 'monospace', color: 'var(--text-muted)', fontSize: 12 }}>{g.vmid}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 700, background: g.type === 'VM' ? 'rgba(59,130,246,0.12)' : 'rgba(139,92,246,0.12)', color: g.type === 'VM' ? '#3B82F6' : '#8B5CF6' }}>{g.type}</span>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600, color: 'var(--text-primary)', fontSize: 13 }}>{g.name}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', fontSize: 12, fontWeight: 700, color: STATUS_COLOR[g.status] || '#6B7280' }}>
<StatusDot status={g.status} />{g.status}
</span>
</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: cpuU > 80 ? '#EF4444' : 'var(--text-secondary)' }}>
{g.status === 'running' ? `${fmt(cpuU)}%` : '—'}
</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: ramU > 80 ? '#EF4444' : 'var(--text-secondary)' }}>
{g.status === 'running' && g.maxmem ? `${fmtGiB(g.mem)} / ${fmtGiB(g.maxmem)}` : '—'}
</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text-muted)' }}>
{g.maxdisk ? fmtGiB(g.maxdisk) : '—'}
</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text-muted)' }}>
{g.status === 'running' ? fmtUptime(g.uptime) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Storage */}
{tab === 'storage' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 14 }}>
{(storage || []).filter(s => s.active).map(s => {
const pct = s.total > 0 ? (s.used / s.total) * 100 : 0;
const color = pct >= 90 ? '#EF4444' : pct >= 70 ? '#F59E0B' : '#10B981';
return (
<div key={s.storage} style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: 14 }}>{s.storage}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{s.type} · {(s.content || '').replace(/,/g, ', ')}</div>
</div>
<span style={{ fontSize: 12, fontWeight: 700, color }}>{fmt(pct)}%</span>
</div>
{s.total > 0 ? (
<>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 4, height: 8, overflow: 'hidden', marginBottom: 8 }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 4 }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{fmtGiB(s.used)} belegt / {fmtGiB(s.total)} gesamt · {fmtGiB(s.avail)} frei
</div>
</>
) : (
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>Keine Kapazitätsdaten</div>
)}
</div>
);
})}
</div>
)}
{/* Tasks */}
{tab === 'tasks' && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-color)' }}>
{['Typ', 'ID', 'Benutzer', 'Status', 'Gestartet', 'Dauer'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{(tasks || []).map((t, i) => {
const ok = t.status === 'OK';
const running = !t.endtime;
const dur = t.endtime && t.starttime ? `${t.endtime - t.starttime}s` : running ? 'läuft...' : '—';
return (
<tr key={i} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '10px 14px', fontFamily: 'monospace', fontSize: 12, color: 'var(--text-secondary)' }}>{t.type}</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text-muted)' }}>{t.id || '—'}</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text-muted)' }}>{t.user}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 700, background: running ? 'rgba(59,130,246,0.12)' : ok ? 'rgba(16,185,129,0.12)' : 'rgba(239,68,68,0.12)', color: running ? '#3B82F6' : ok ? '#10B981' : '#EF4444' }}>
{running ? 'Läuft' : t.status}
</span>
</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(t.starttime)}</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text-muted)' }}>{dur}</td>
</tr>
);
})}
{(!tasks || tasks.length === 0) && (
<tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Keine Tasks</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* Network */}
{tab === 'network' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 14 }}>
{(network || []).filter(n => n.type === 'bridge' || n.type === 'eth' || n.type === 'bond').map(n => (
<div key={n.iface} style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: 14 }}>{n.iface}</span>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: n.active ? 'rgba(16,185,129,0.12)' : 'rgba(107,114,128,0.12)', color: n.active ? '#10B981' : '#6B7280', fontWeight: 700 }}>{n.active ? 'Aktiv' : 'Inaktiv'}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.8 }}>
<div>Typ: {n.type}</div>
{n.address && <div>IP: {n.address}/{n.netmask || ''}</div>}
{n.gateway && <div>Gateway: {n.gateway}</div>}
{n.bridge_ports && <div>Ports: {n.bridge_ports}</div>}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,201 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getPublicShare, accessShare, getFileDownloadUrl } from '../services/shareService';
export default function PublicSharePage() {
const { token } = useParams();
const [share, setShare] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [unlocked, setUnlocked] = useState(false);
const [textContent, setTextContent] = useState('');
const [accessError, setAccessError] = useState('');
const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false);
useEffect(() => {
getPublicShare(token)
.then(data => {
setShare(data);
if (!data.has_password && data.type === 'text') {
setTextContent(data.text_content || '');
setUnlocked(true);
} else if (!data.has_password && data.type === 'file') {
setUnlocked(true);
}
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [token]);
const handleAccess = async (e) => {
e.preventDefault();
setAccessError('');
setChecking(true);
try {
const result = await accessShare(token, password);
if (result.text_content !== undefined) setTextContent(result.text_content);
setUnlocked(true);
} catch (e) {
setAccessError(e.message);
} finally {
setChecking(false);
}
};
const copyText = () => {
navigator.clipboard.writeText(textContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const containerStyle = {
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
};
const cardStyle = {
background: '#fff',
borderRadius: 16,
padding: 32,
width: '100%',
maxWidth: 520,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
};
if (loading) return (
<div style={containerStyle}>
<div style={cardStyle}>
<p style={{ textAlign: 'center', color: '#6B7280' }}>Link wird geladen</p>
</div>
</div>
);
if (error) return (
<div style={containerStyle}>
<div style={cardStyle}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<h2 style={{ color: '#DC2626', margin: '0 0 8px' }}>Link nicht verfügbar</h2>
<p style={{ color: '#6B7280', margin: 0 }}>{error}</p>
</div>
</div>
</div>
);
return (
<div style={containerStyle}>
<div style={cardStyle}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 40, marginBottom: 8 }}>
{share.type === 'file' ? '📄' : '📝'}
</div>
<h1 style={{ margin: '0 0 4px', fontSize: 22, color: '#111827' }}>
{share.type === 'file' ? share.filename : 'Sichere Nachricht'}
</h1>
<p style={{ margin: 0, fontSize: 13, color: '#9CA3AF' }}>
Sicherer IT Nexus Share
{share.expires_at && ` · Läuft ab: ${new Date(share.expires_at + 'Z').toLocaleString('de-DE')}`}
</p>
</div>
{!unlocked ? (
/* Passwort-Eingabe */
<form onSubmit={handleAccess}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontWeight: 600, fontSize: 14, marginBottom: 8, color: '#374151' }}>
🔒 Passwort erforderlich
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Passwort eingeben…"
autoFocus
style={{
width: '100%', borderRadius: 10, border: '2px solid #E5E7EB',
padding: '12px 14px', fontSize: 15, boxSizing: 'border-box',
outline: 'none',
}}
onFocus={e => e.target.style.borderColor = '#6366F1'}
onBlur={e => e.target.style.borderColor = '#E5E7EB'}
/>
</div>
{accessError && (
<div style={{
background: '#FEF2F2', color: '#DC2626', borderRadius: 8,
padding: '10px 14px', marginBottom: 14, fontSize: 13, textAlign: 'center',
}}>
{accessError}
</div>
)}
<button type="submit" disabled={checking || !password}
style={{
width: '100%', background: checking ? '#A5B4FC' : '#6366F1',
color: '#fff', border: 'none', borderRadius: 10,
padding: '12px', fontSize: 15, fontWeight: 700,
cursor: checking || !password ? 'not-allowed' : 'pointer',
}}>
{checking ? 'Prüfen…' : 'Entsperren'}
</button>
</form>
) : share.type === 'text' ? (
/* Text anzeigen */
<div>
<div style={{
background: '#F8FAFC', border: '1px solid #E2E8F0',
borderRadius: 10, padding: 16, marginBottom: 16,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontSize: 14, lineHeight: 1.6, maxHeight: 300, overflowY: 'auto',
fontFamily: 'ui-monospace, monospace',
}}>
{textContent}
</div>
<button onClick={copyText} style={{
width: '100%', background: copied ? '#10B981' : '#6366F1',
color: '#fff', border: 'none', borderRadius: 10,
padding: '12px', fontSize: 15, fontWeight: 700, cursor: 'pointer',
transition: 'background 0.2s',
}}>
{copied ? '✓ Kopiert!' : '📋 Inhalt kopieren'}
</button>
</div>
) : (
/* Datei-Download */
<div style={{ textAlign: 'center' }}>
<p style={{ color: '#4B5563', marginBottom: 20, fontSize: 14 }}>
Deine Datei ist bereit zum Download.
{share.max_downloads && (
<><br /><span style={{ color: '#6B7280' }}>
Downloads: {share.download_count}/{share.max_downloads}
</span></>
)}
</p>
<a
href={getFileDownloadUrl(token, share.has_password ? password : undefined)}
download={share.filename}
style={{
display: 'inline-block', background: '#6366F1', color: '#fff',
borderRadius: 10, padding: '12px 32px', fontSize: 15,
fontWeight: 700, textDecoration: 'none',
}}
>
{share.filename} herunterladen
</a>
</div>
)}
<p style={{ textAlign: 'center', marginTop: 24, marginBottom: 0, fontSize: 12, color: '#D1D5DB' }}>
Bereitgestellt via IT Nexus · Cereda Systems GmbH
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,467 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import riskService from '../services/riskService';
import { toast } from 'react-toastify';
const CATEGORIES = ['Identität & Zugriff', 'Geräte & Endpunkte', 'Incident / Bedrohung', 'IT-Sicherheit', 'Infrastruktur', 'Datenschutz', 'Sonstiges'];
const STATUSES = ['offen', 'in_bearbeitung', 'akzeptiert', 'behoben'];
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', akzeptiert: 'Akzeptiert', behoben: 'Behoben' };
const STATUS_COLOR = { offen: '#ef4444', in_bearbeitung: '#f59e0b', akzeptiert: '#6b7280', behoben: '#22c55e' };
const SOURCE_LABEL = {
manual: '✏️ Manuell',
secure_score: '🔒 Secure Score',
risky_user: '👤 Risky User',
defender_alert: '🛡️ Defender',
noncompliant_device: '💻 Intune',
no_mfa: '🔑 MFA',
};
function riskColor(score) {
if (score >= 20) return '#ef4444';
if (score >= 12) return '#f97316';
if (score >= 6) return '#f59e0b';
return '#22c55e';
}
function riskLabel(score) {
if (score >= 20) return 'Kritisch';
if (score >= 12) return 'Hoch';
if (score >= 6) return 'Mittel';
return 'Niedrig';
}
const EMPTY_FORM = {
title: '', description: '', category: 'Sonstiges',
probability: 3, impact: 3, status: 'offen',
responsible: '', mitigation: '', due_date: '',
};
export default function RiskPage() {
const { isAdmin } = useAuth();
const canEdit = isAdmin();
const [risks, setRisks] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [expandedId, setExpandedId] = useState(null);
const [filterStatus, setFilterStatus] = useState('');
const [filterCat, setFilterCat] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState(null);
const load = useCallback(async () => {
try {
const params = {};
if (filterStatus) params.status = filterStatus;
if (filterCat) params.category = filterCat;
const [data, s] = await Promise.all([riskService.getAll(params), riskService.getStats()]);
setRisks(data);
setStats(s);
} catch { toast.error('Fehler beim Laden'); }
finally { setLoading(false); }
}, [filterStatus, filterCat]);
useEffect(() => { load(); }, [load]);
const doSync = async () => {
setSyncing(true);
try {
const result = await riskService.sync();
toast.success(`Sync abgeschlossen: ${result.created} erfasst, ${result.errors.length} Fehler`);
if (result.errors.length) result.errors.forEach(e => toast.warn(e, { autoClose: 8000 }));
load();
} catch (e) { toast.error(`Sync fehlgeschlagen: ${e.response?.data?.message || e.message}`); }
finally { setSyncing(false); }
};
const openNew = () => { setEditing(null); setForm(EMPTY_FORM); setShowModal(true); };
const openEdit = (r) => {
setEditing(r);
setForm({
title: r.title, description: r.description || '', category: r.category,
probability: r.probability, impact: r.impact, status: r.status,
responsible: r.responsible || '', mitigation: r.mitigation || '',
due_date: r.due_date || '',
});
setShowModal(true);
};
const save = async () => {
if (!form.title.trim()) return;
setSaving(true);
try {
if (editing) { await riskService.update(editing.id, form); toast.success('Gespeichert'); }
else { await riskService.create(form); toast.success('Risiko erstellt'); }
setShowModal(false);
load();
} catch { toast.error('Fehler beim Speichern'); }
finally { setSaving(false); }
};
const doDelete = async () => {
try { await riskService.delete(deleteConfirm.id); toast.success('Gelöscht'); setDeleteConfirm(null); load(); }
catch { toast.error('Fehler beim Löschen'); }
};
const ProbImpactBtn = ({ field, val }) => (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{[1,2,3,4,5].map(n => (
<button key={n} onClick={() => setForm(f => ({ ...f, [field]: n }))} style={{
width: 34, height: 34, borderRadius: 8, border: '1px solid',
borderColor: form[field] === n ? riskColor(n * (field === 'probability' ? form.impact : form.probability)) : '#334155',
background: form[field] === n ? riskColor(n * (field === 'probability' ? form.impact : form.probability)) + '22' : 'transparent',
color: form[field] === n ? riskColor(n * (field === 'probability' ? form.impact : form.probability)) : '#64748b',
fontWeight: 700, cursor: 'pointer', fontSize: 13,
}}>{n}</button>
))}
</div>
);
if (loading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>;
return (
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700, color: '#f1f5f9' }}> Risikoanalyse</h1>
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: 14 }}>Automatisch aus M365 + manuell erfasste Risiken</p>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={doSync} disabled={syncing} style={{
background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.3)',
color: '#3b82f6', borderRadius: 8, padding: '8px 16px', cursor: 'pointer', fontWeight: 600, fontSize: 14,
}}>{syncing ? '⟳ Synchronisiere...' : '🔄 M365 Sync'}</button>
<button onClick={openNew} style={{
background: 'rgba(13,148,136,0.12)', border: '1px solid rgba(13,148,136,0.3)',
color: '#0d9488', borderRadius: 8, padding: '8px 16px', cursor: 'pointer', fontWeight: 600, fontSize: 14,
}}>+ Risiko erfassen</button>
</div>
)}
</div>
{/* Stats */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, marginBottom: 24 }}>
{[
{ label: 'Gesamt', value: stats.total, color: '#94a3b8' },
{ label: 'Kritisch', value: stats.kritisch, color: '#ef4444' },
{ label: 'Hoch', value: stats.hoch, color: '#f97316' },
{ label: 'Mittel', value: stats.mittel, color: '#f59e0b' },
{ label: 'Behoben', value: stats.behoben, color: '#22c55e' },
].map(s => (
<div key={s.label} className="card" style={{ padding: '1rem', textAlign: 'center' }}>
<div style={{ fontSize: '1.75rem', fontWeight: 700, color: s.color }}>{s.value}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Risikomatrix (3x3 visual) */}
<div className="card" style={{ padding: '1.25rem', marginBottom: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#94a3b8', marginBottom: 12 }}>Risikomatrix</div>
<div style={{ display: 'grid', gridTemplateColumns: '24px repeat(5,1fr)', gap: 4, fontSize: 11 }}>
{[5,4,3,2,1].map(prob => (
<React.Fragment key={prob}>
<div style={{ color: '#475569', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600 }}>{prob}</div>
{[1,2,3,4,5].map(imp => {
const score = prob * imp;
const col = riskColor(score);
const count = risks.filter(r => r.probability === prob && r.impact === imp && r.status !== 'behoben').length;
return (
<div key={imp} style={{
background: col + '20', border: `1px solid ${col}30`,
borderRadius: 6, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: count > 0 ? col : '#334155', fontWeight: count > 0 ? 700 : 400, fontSize: 13,
}}>{count > 0 ? count : ''}</div>
);
})}
</React.Fragment>
))}
<div />
{[1,2,3,4,5].map(n => (
<div key={n} style={{ color: '#475569', textAlign: 'center', fontWeight: 600, paddingTop: 4 }}>{n}</div>
))}
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 8, fontSize: 11, color: '#64748b' }}>
<span> Wahrscheinlichkeit</span><span> Auswirkung</span>
</div>
</div>
{/* Filter */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{['', ...STATUSES].map(s => (
<button key={s} onClick={() => setFilterStatus(s)} style={{
padding: '5px 14px', borderRadius: 20, border: '1px solid',
borderColor: filterStatus === s ? '#0d9488' : '#334155',
background: filterStatus === s ? 'rgba(13,148,136,0.12)' : 'transparent',
color: filterStatus === s ? '#0d9488' : '#64748b',
cursor: 'pointer', fontSize: 13, fontWeight: 500,
}}>{s === '' ? 'Alle' : STATUS_LABEL[s]}</button>
))}
<select value={filterCat} onChange={e => setFilterCat(e.target.value)} className="form-select" style={{ fontSize: 13, padding: '4px 10px', minWidth: 160 }}>
<option value="">Alle Kategorien</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
{/* Risk list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{risks.length === 0 && (
<div className="card" style={{ padding: '2rem', textAlign: 'center', color: '#475569' }}>
Keine Risiken erfasst. Klicke auf "M365 Sync" um automatisch Risiken zu importieren.
</div>
)}
{risks.map(risk => {
const score = risk.probability * risk.impact;
const col = riskColor(score);
const isExpanded = expandedId === risk.id;
return (
<div key={risk.id} className="card" style={{
padding: 0, overflow: 'hidden', cursor: 'pointer',
borderLeft: `3px solid ${col}`,
}} onClick={() => setExpandedId(isExpanded ? null : risk.id)}>
<div style={{ padding: '0.875rem 1.125rem', display: 'flex', alignItems: 'center', gap: 12 }}>
{/* Score badge */}
<div style={{
flexShrink: 0, width: 44, height: 44, borderRadius: 10,
background: col + '18', border: `1px solid ${col}40`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
color: col, fontWeight: 700, fontSize: 16,
}}>
{score}
<span style={{ fontSize: 9, fontWeight: 600, marginTop: 1 }}>{riskLabel(score)}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, color: '#e2e8f0', fontSize: 14 }}>{risk.title}</span>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 20, background: STATUS_COLOR[risk.status] + '18', color: STATUS_COLOR[risk.status], border: `1px solid ${STATUS_COLOR[risk.status]}40`, whiteSpace: 'nowrap' }}>
{STATUS_LABEL[risk.status]}
</span>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 20, background: 'rgba(255,255,255,0.06)', color: '#64748b', border: '1px solid rgba(255,255,255,0.08)', whiteSpace: 'nowrap' }}>
{SOURCE_LABEL[risk.source_type] || risk.source_type}
</span>
<span style={{ fontSize: 11, color: '#475569' }}>{risk.category}</span>
</div>
{!isExpanded && risk.description && (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{risk.description}
</div>
)}
</div>
<div style={{ flexShrink: 0, textAlign: 'right', fontSize: 11, color: '#475569' }}>
<div>W:{risk.probability} × A:{risk.impact}</div>
{risk.responsible && <div style={{ marginTop: 2 }}>👤 {risk.responsible}</div>}
</div>
</div>
{isExpanded && (
<div style={{ padding: '0 1.125rem 1rem', borderTop: '1px solid rgba(255,255,255,0.05)' }} onClick={e => e.stopPropagation()}>
{risk.description && <p style={{ fontSize: 13, color: '#94a3b8', margin: '12px 0 8px' }}>{risk.description}</p>}
{/* MFA: Benutzerliste */}
{risk.source_type === 'no_mfa' && risk.metadata?.users?.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748b', marginBottom: 6 }}>
Benutzer ohne MFA ({risk.metadata.users.length}):
</div>
<div style={{ maxHeight: 200, overflowY: 'auto', borderRadius: 8, border: '1px solid rgba(255,255,255,0.06)', background: 'rgba(0,0,0,0.15)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th style={{ padding: '6px 10px', textAlign: 'left', color: '#64748b', fontWeight: 600 }}>Name</th>
<th style={{ padding: '6px 10px', textAlign: 'left', color: '#64748b', fontWeight: 600 }}>E-Mail</th>
</tr>
</thead>
<tbody>
{risk.metadata.users.map((u, i) => (
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<td style={{ padding: '5px 10px', color: '#e2e8f0' }}>{u.name || ''}</td>
<td style={{ padding: '5px 10px', color: '#94a3b8' }}>{u.email}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Intune: Gerätedetails */}
{risk.source_type === 'noncompliant_device' && risk.metadata && (
<div style={{ marginBottom: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12 }}>
{[
['Gerät', risk.metadata.deviceName],
['Benutzer', risk.metadata.user],
['Betriebssystem', risk.metadata.os],
['Seriennummer', risk.metadata.serialNumber],
['Letzter Sync', risk.metadata.lastSync ? new Date(risk.metadata.lastSync).toLocaleDateString('de-DE') : null],
].filter(([,v]) => v).map(([k,v]) => (
<div key={k} style={{ padding: '6px 10px', borderRadius: 6, background: 'rgba(0,0,0,0.15)', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ color: '#475569', fontSize: 11 }}>{k}</div>
<div style={{ color: '#e2e8f0', marginTop: 2 }}>{v}</div>
</div>
))}
</div>
)}
{/* Defender: Alert-Details */}
{risk.source_type === 'defender_alert' && risk.metadata && (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, fontSize: 12, marginBottom: 8 }}>
{[
['Schweregrad', risk.metadata.severity],
['Kategorie', risk.metadata.category],
['Erkannt', risk.metadata.detected ? new Date(risk.metadata.detected).toLocaleDateString('de-DE') : null],
].filter(([,v]) => v).map(([k,v]) => (
<div key={k} style={{ padding: '6px 10px', borderRadius: 6, background: 'rgba(0,0,0,0.15)', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ color: '#475569', fontSize: 11 }}>{k}</div>
<div style={{ color: '#e2e8f0', marginTop: 2 }}>{v}</div>
</div>
))}
</div>
{risk.metadata.entities?.length > 0 && (
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>
<strong style={{ color: '#94a3b8' }}>Betroffene Entitäten:</strong> {risk.metadata.entities.join(', ')}
</div>
)}
{risk.metadata.recommendation && (
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>
<strong style={{ color: '#94a3b8' }}>Empfehlung:</strong> {risk.metadata.recommendation}
</div>
)}
{risk.metadata.alertWebUrl && (
<a href={risk.metadata.alertWebUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: '#0d9488' }}>
Im Defender Portal öffnen
</a>
)}
</div>
)}
{risk.mitigation && (
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 6 }}>
<strong style={{ color: '#94a3b8' }}>Maßnahme:</strong> {risk.mitigation}
</div>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: '#475569', marginBottom: 10 }}>
{risk.due_date && <span>📅 Fällig: {new Date(risk.due_date).toLocaleDateString('de-DE')}</span>}
{risk.last_synced && <span>🔄 Sync: {new Date(risk.last_synced).toLocaleDateString('de-DE')}</span>}
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => openEdit(risk)} style={{
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.1)',
color: '#94a3b8', borderRadius: 6, padding: '4px 12px', cursor: 'pointer', fontSize: 12,
}}> Bearbeiten</button>
<button onClick={() => setDeleteConfirm(risk)} style={{
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
color: '#ef4444', borderRadius: 6, padding: '4px 12px', cursor: 'pointer', fontSize: 12,
}}>🗑 Löschen</button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{editing ? 'Risiko bearbeiten' : 'Neues Risiko'}</h3>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Titel *</label>
<input className="form-input" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="Risikobeschreibung" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-group">
<label className="form-label">Kategorie</label>
<select className="form-select" value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Status</label>
<select className="form-select" value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
{STATUSES.map(s => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-group">
<label className="form-label">Wahrscheinlichkeit (1-5) aktuell: <strong style={{ color: riskColor(form.probability * form.impact) }}>{form.probability}</strong></label>
<ProbImpactBtn field="probability" val={form.probability} />
</div>
<div className="form-group">
<label className="form-label">Auswirkung (1-5) aktuell: <strong style={{ color: riskColor(form.probability * form.impact) }}>{form.impact}</strong></label>
<ProbImpactBtn field="impact" val={form.impact} />
</div>
</div>
<div style={{ marginBottom: 12, padding: '8px 14px', borderRadius: 8, background: riskColor(form.probability * form.impact) + '18', border: `1px solid ${riskColor(form.probability * form.impact)}30` }}>
<span style={{ color: riskColor(form.probability * form.impact), fontWeight: 700 }}>
Risikoscore: {form.probability * form.impact} {riskLabel(form.probability * form.impact)}
</span>
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea className="form-input" rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="Details zum Risiko..." />
</div>
<div className="form-group">
<label className="form-label">Maßnahme / Mitigation</label>
<textarea className="form-input" rows={2} value={form.mitigation} onChange={e => setForm(f => ({ ...f, mitigation: e.target.value }))} placeholder="Was wird dagegen unternommen?" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-group">
<label className="form-label">Verantwortlicher</label>
<input className="form-input" value={form.responsible} onChange={e => setForm(f => ({ ...f, responsible: e.target.value }))} placeholder="Name" />
</div>
<div className="form-group">
<label className="form-label">Fälligkeitsdatum</label>
<input type="date" className="form-input" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Abbrechen</button>
<button className="btn btn-primary" onClick={save} disabled={saving || !form.title.trim()}>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* Delete confirm */}
{deleteConfirm && (
<div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
<div className="modal-header">
<h3>Risiko löschen</h3>
<button className="modal-close" onClick={() => setDeleteConfirm(null)}>×</button>
</div>
<div className="modal-body">
<p style={{ color: '#94a3b8' }}><strong style={{ color: '#f1f5f9' }}>{deleteConfirm.title}</strong>" wirklich löschen?</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setDeleteConfirm(null)}>Abbrechen</button>
<button className="btn btn-danger" onClick={doDelete}>Löschen</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,933 @@
/* ── Scanner Page — scoped styles ───────────────────────────────────
All classes prefixed with .ns- to avoid conflicts with IT Nexus CSS.
Teal accent: #14b8a8 | Dark bg: #07101a | Surface: #0f1a25
─────────────────────────────────────────────────────────────────── */
/* CSS variables scoped to the scanner page.
IT Nexus dark mode = default (:root), light mode = html[data-theme="light"]
IT Nexus variables: --bg-card, --bg-secondary, --bg-tertiary,
--text-primary, --text-secondary, --text-muted,
--border-color
*/
.ns-page {
--ns-teal: #14b8a8;
--ns-teal-2: #2dd2c2;
--ns-teal-dim: rgba(20,184,168,0.14);
--ns-teal-glow: 0 0 0 1px rgba(20,184,168,0.18), 0 30px 60px -20px rgba(20,184,168,0.22);
--ns-success: #22c55e;
--ns-warning: #f59e0b;
--ns-danger: #ef4444;
--ns-info: #3b82f6;
--ns-purple: #7c3aed;
--ns-r-sm: 8px;
--ns-r-md: 12px;
--ns-r-lg: 18px;
/* Reference IT Nexus actual variable names (dark = default) */
--ns-surface: var(--bg-card, #1e293b);
--ns-surface-2: var(--bg-secondary, #1e293b);
--ns-surface-3: var(--bg-tertiary, #334155);
--ns-text: var(--text-primary, #f1f5f9);
--ns-text-2: var(--text-secondary, #94a3b8);
--ns-text-3: var(--text-muted, #64748b);
--ns-border: var(--border-color, #334155);
--ns-border-strong: var(--border-color, #475569);
--ns-shadow-sm: 0 1px 3px rgba(0,0,0,0.35);
}
/* Light mode — IT Nexus sets html[data-theme="light"] */
html[data-theme="light"] .ns-page {
--ns-surface: var(--bg-card, #ffffff);
--ns-surface-2: var(--bg-secondary, #ffffff);
--ns-surface-3: var(--bg-tertiary, #f5f5f7);
--ns-text: var(--text-primary, #1d1d1f);
--ns-text-2: var(--text-secondary, #424245);
--ns-text-3: var(--text-muted, #6e6e73);
--ns-border: var(--border-color, #e5e5e7);
--ns-border-strong: var(--border-color, #d1d1d6);
--ns-shadow-sm: 0 1px 2px rgba(15,23,42,0.06), 0 1px 1px rgba(15,23,42,0.04);
--ns-success: #16a34a;
--ns-warning: #d97706;
--ns-danger: #dc2626;
}
/* ── Layout ─────────────────────────────────────────────────────── */
.ns-page {
max-width: 1480px;
margin: 0 auto;
padding-bottom: 80px;
}
/* ── Hero ───────────────────────────────────────────────────────── */
.ns-hero {
display: grid;
grid-template-columns: 1fr auto;
gap: 28px;
align-items: flex-end;
margin-bottom: 32px;
}
.ns-eyebrow {
font-size: 11.5px;
font-weight: 600;
color: var(--ns-teal);
text-transform: uppercase;
letter-spacing: 0.12em;
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ns-live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ns-success);
box-shadow: 0 0 0 3px rgba(34,197,94,0.18);
display: inline-block;
animation: ns-pulse 1.6s ease-out infinite;
}
@keyframes ns-pulse {
0% { box-shadow: 0 0 0 0 rgba(34,197,94,0.5); }
70% { box-shadow: 0 0 0 6px rgba(34,197,94,0); }
100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
}
.ns-h1 {
margin: 0 0 8px;
font-size: 36px;
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--ns-text);
/* Force visibility regardless of parent color inheritance */
-webkit-text-fill-color: var(--ns-text);
}
.ns-lead {
font-size: 14.5px;
color: var(--ns-text-3);
max-width: 660px;
line-height: 1.55;
}
.ns-lead strong {
color: var(--ns-text-2);
font-weight: 500;
}
.ns-hero-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ── Buttons ────────────────────────────────────────────────────── */
.ns-btn {
appearance: none;
border: 1px solid var(--ns-border-strong);
background: var(--ns-surface);
color: var(--ns-text);
border-radius: 10px;
padding: 9px 16px;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
font-family: inherit;
transition: background .14s, transform .08s, border-color .14s;
}
.ns-btn:hover { background: var(--ns-surface-3); border-color: var(--ns-border-strong); }
.ns-btn:active { transform: scale(0.98); }
.ns-btn svg { width: 14px; height: 14px; }
.ns-btn-primary {
background: var(--ns-teal);
color: #fff;
border-color: var(--ns-teal);
}
.ns-btn-primary:hover {
background: var(--ns-teal-2);
border-color: var(--ns-teal-2);
}
.ns-btn-sm {
padding: 6px 12px;
font-size: 12.5px;
border-radius: 8px;
}
/* ── Stats grid ──────────────────────────────────────────────────── */
.ns-stats {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1fr;
gap: 14px;
margin-bottom: 28px;
}
@media (max-width: 1080px) {
.ns-stats { grid-template-columns: 1fr 1fr; }
}
.ns-stat {
position: relative;
overflow: hidden;
background: var(--ns-surface);
border: 1px solid var(--ns-border);
border-radius: var(--ns-r-lg);
padding: 22px 24px;
box-shadow: var(--ns-shadow-sm);
transition: border-color .15s;
}
.ns-stat:hover { border-color: var(--ns-border-strong); }
.ns-stat-feature {
background:
linear-gradient(155deg, rgba(20,184,168,0.18), rgba(20,184,168,0.04) 60%),
var(--ns-surface);
border-color: rgba(20,184,168,0.22);
box-shadow: var(--ns-teal-glow);
}
.ns-stat-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.10em;
color: var(--ns-text-3);
margin: 0 0 14px;
}
.ns-stat-tag {
background: var(--ns-teal-dim);
color: var(--ns-teal);
padding: 2px 7px;
border-radius: 5px;
font-size: 9.5px;
letter-spacing: 0.04em;
}
.ns-stat-value {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 42px;
font-weight: 600;
letter-spacing: -0.035em;
line-height: 1;
font-variant-numeric: tabular-nums;
color: var(--ns-text);
}
.ns-stat-value-danger { color: var(--ns-danger) !important; }
.ns-stat-unit {
font-size: 16px;
color: var(--ns-text-3);
font-weight: 500;
letter-spacing: -0.01em;
}
.ns-delta-pill {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11.5px;
color: var(--ns-success);
font-weight: 600;
background: rgba(34,197,94,0.10);
padding: 3px 8px;
border-radius: 999px;
margin-top: 10px;
width: fit-content;
}
.ns-stat-meta {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
font-size: 12.5px;
color: var(--ns-text-2);
margin-top: 14px;
}
.ns-chip {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ns-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ns-text-3);
display: inline-block;
}
.ns-dot-green { background: var(--ns-success); box-shadow: 0 0 6px rgba(34,197,94,0.4); }
.ns-dot-red { background: var(--ns-danger); box-shadow: 0 0 6px rgba(239,68,68,0.4); }
.ns-dot-yellow { background: var(--ns-warning); }
.ns-stat-desc {
font-size: 12.5px;
color: var(--ns-text-3);
margin-top: 10px;
}
.ns-sparkline {
position: absolute;
right: 24px;
top: 22px;
display: flex;
align-items: flex-end;
gap: 3px;
height: 32px;
}
.ns-sparkline span {
width: 4px;
border-radius: 1.5px;
background: var(--ns-teal);
opacity: 0.5;
}
/* ── Section titles ──────────────────────────────────────────────── */
.ns-section-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin: 12px 4px 14px;
}
.ns-section-title h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--ns-text);
}
.ns-section-title .ns-meta {
font-size: 12.5px;
color: var(--ns-text-3);
}
/* ── Attention cards ─────────────────────────────────────────────── */
.ns-attn-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 36px;
}
.ns-attn-grid.ns-attn-1 { grid-template-columns: minmax(0,480px) 1fr 1fr; }
.ns-attn-grid.ns-attn-2 { grid-template-columns: 1fr 1fr 1fr; }
@media (max-width: 1080px) { .ns-attn-grid { grid-template-columns: 1fr !important; } }
.ns-attn {
background: var(--ns-surface);
border: 1px solid var(--ns-border);
border-radius: var(--ns-r-lg);
padding: 22px;
box-shadow: var(--ns-shadow-sm);
display: flex;
flex-direction: column;
gap: 16px;
}
.ns-attn-danger { border-color: rgba(239,68,68,0.28); }
.ns-attn-warn { border-color: rgba(245,158,11,0.28); }
.ns-attn-head {
display: flex;
align-items: flex-start;
gap: 12px;
}
.ns-attn-icn {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ns-attn-icn-danger { background: rgba(239,68,68,0.14); color: var(--ns-danger); }
.ns-attn-icn-warn { background: rgba(245,158,11,0.16); color: var(--ns-warning); }
.ns-attn-icn svg { width: 18px; height: 18px; }
.ns-attn-info { flex: 1; min-width: 0; }
.ns-attn-title {
font-size: 14.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ns-text);
}
.ns-attn-sub {
font-size: 12px;
color: var(--ns-text-3);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ns-attn-sev {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 3px 8px;
border-radius: 999px;
text-transform: uppercase;
}
.ns-attn-sev-danger { background: rgba(239,68,68,0.14); color: var(--ns-danger); }
.ns-attn-sev-warn { background: rgba(245,158,11,0.16); color: var(--ns-warning); }
.ns-attn-metric {
display: flex;
align-items: baseline;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.ns-attn-num {
font-size: 30px;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1;
}
.ns-attn-num-danger { color: var(--ns-danger); }
.ns-attn-num-warn { color: var(--ns-warning); }
.ns-attn-unit { font-size: 13.5px; color: var(--ns-text-3); font-weight: 500; }
.ns-attn-of { font-size: 13.5px; color: var(--ns-text-3); margin-left: 2px; }
.ns-attn-bar {
height: 6px;
border-radius: 99px;
background: var(--ns-surface-3);
overflow: hidden;
margin-top: 12px;
}
.ns-attn-bar span { display: block; height: 100%; border-radius: 99px; }
.ns-attn-bar-danger span { background: var(--ns-danger); }
.ns-attn-bar-warn span { background: var(--ns-warning); }
.ns-attn-body {
font-size: 13px;
color: var(--ns-text-2);
line-height: 1.55;
}
.ns-attn-actions {
display: flex;
gap: 8px;
margin-top: auto;
}
/* ── Main grid ───────────────────────────────────────────────────── */
.ns-main-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
align-items: start;
}
@media (max-width: 1180px) { .ns-main-grid { grid-template-columns: 1fr; } }
/* ── Segmented type filter ───────────────────────────────────────── */
.ns-seg {
display: inline-flex;
background: var(--ns-surface);
border: 1px solid var(--ns-border);
border-radius: 12px;
padding: 4px;
gap: 2px;
margin-bottom: 14px;
overflow-x: auto;
max-width: 100%;
}
.ns-seg-btn {
appearance: none;
background: transparent;
border: 0;
color: var(--ns-text-3);
padding: 7px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
transition: background .14s, color .14s;
}
.ns-seg-btn:hover { color: var(--ns-text); }
.ns-seg-btn svg { width: 14px; height: 14px; opacity: 0.85; }
.ns-seg-btn-active {
background: var(--ns-teal-dim);
color: var(--ns-teal);
}
.ns-seg-num {
background: var(--ns-surface-3);
color: var(--ns-text-3);
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
min-width: 22px;
text-align: center;
}
.ns-seg-num-active {
background: rgba(20,184,168,0.20);
color: var(--ns-teal);
}
/* ── Toolbar ─────────────────────────────────────────────────────── */
.ns-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.ns-search {
position: relative;
display: inline-flex;
align-items: center;
flex: 1;
min-width: 280px;
max-width: 460px;
}
.ns-search svg {
position: absolute;
left: 13px;
width: 15px;
height: 15px;
color: var(--ns-text-3);
pointer-events: none;
}
.ns-search input {
width: 100%;
background: var(--ns-surface);
color: var(--ns-text);
border: 1px solid var(--ns-border-strong);
border-radius: 11px;
padding: 10px 14px 10px 38px;
font-size: 13.5px;
font-family: inherit;
outline: none;
transition: border-color .14s, box-shadow .14s;
}
.ns-search input::placeholder { color: var(--ns-text-3); }
.ns-search input:focus {
border-color: var(--ns-teal);
box-shadow: 0 0 0 3px rgba(20,184,168,0.18);
}
.ns-select-wrap { position: relative; }
.ns-select {
appearance: none;
-webkit-appearance: none;
background: var(--ns-surface);
color: var(--ns-text);
border: 1px solid var(--ns-border-strong);
border-radius: 11px;
padding: 10px 32px 10px 14px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
outline: none;
transition: border-color .14s, box-shadow .14s;
}
.ns-select:focus {
border-color: var(--ns-teal);
box-shadow: 0 0 0 3px rgba(20,184,168,0.18);
}
.ns-select-chevron {
position: absolute;
right: 11px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
color: var(--ns-text-3);
pointer-events: none;
}
.ns-toolbar-count {
font-size: 12.5px;
color: var(--ns-text-3);
margin-left: auto;
}
/* ── Table card ──────────────────────────────────────────────────── */
.ns-card {
background: var(--ns-surface);
border: 1px solid var(--ns-border);
border-radius: var(--ns-r-lg);
box-shadow: var(--ns-shadow-sm);
overflow: hidden;
}
.ns-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.ns-table th {
text-align: left;
font-size: 10.5px;
font-weight: 600;
color: var(--ns-text-3);
letter-spacing: 0.10em;
text-transform: uppercase;
padding: 14px 18px;
border-bottom: 1px solid var(--ns-border);
background: var(--ns-surface-2);
white-space: nowrap;
}
.ns-table th:first-child { padding-left: 22px; }
.ns-table th:last-child { padding-right: 22px; text-align: right; }
.ns-table td {
padding: 16px 18px;
border-bottom: 1px solid var(--ns-border);
vertical-align: middle;
}
.ns-table td:first-child { padding-left: 22px; }
.ns-table td:last-child { padding-right: 22px; text-align: right; }
.ns-table tbody tr:last-child td { border-bottom: 0; }
.ns-table tbody tr {
transition: background .12s;
cursor: pointer;
}
.ns-table tbody tr:hover { background: var(--ns-surface-2); }
.ns-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 22px;
background: var(--ns-surface-2);
border-top: 1px solid var(--ns-border);
font-size: 12.5px;
color: var(--ns-text-3);
}
/* ── Device cell ─────────────────────────────────────────────────── */
.ns-device-cell {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.ns-device-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--ns-surface-3);
color: var(--ns-text-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ns-device-icon svg { width: 17px; height: 17px; }
.ns-device-icon-teal { background: var(--ns-teal-dim); color: var(--ns-teal); }
.ns-device-icon-blue { background: rgba(59,130,246,0.14); color: var(--ns-info); }
.ns-device-icon-orange { background: rgba(245,158,11,0.16); color: var(--ns-warning); }
.ns-device-icon-purple { background: rgba(124,58,237,0.14); color: var(--ns-purple); }
.ns-device-icon-green { background: rgba(34,197,94,0.14); color: var(--ns-success); }
.ns-device-info { min-width: 0; flex: 1; }
.ns-device-name {
font-weight: 600;
color: var(--ns-text);
font-size: 13.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
letter-spacing: -0.005em;
}
.ns-device-sub {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
color: var(--ns-text-3);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Telemetry ───────────────────────────────────────────────────── */
.ns-telemetry {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 160px;
max-width: 240px;
}
.ns-telemetry-head {
font-size: 10.5px;
color: var(--ns-text-3);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.ns-telemetry-val {
font-size: 12.5px;
color: var(--ns-text-2);
line-height: 1.4;
}
.ns-telemetry-val strong { color: var(--ns-text); font-weight: 600; }
.ns-telemetry-empty { font-size: 11.5px; color: var(--ns-text-3); font-style: italic; }
.ns-micro-bar {
width: 140px;
height: 5px;
border-radius: 99px;
background: var(--ns-surface-3);
overflow: hidden;
margin-top: 2px;
}
.ns-micro-bar span { display: block; height: 100%; background: var(--ns-teal); border-radius: 99px; }
.ns-micro-bar-warn span { background: var(--ns-warning); }
.ns-micro-bar-danger span { background: var(--ns-danger); }
/* ── Source chips ────────────────────────────────────────────────── */
.ns-sources { display: flex; gap: 4px; flex-wrap: wrap; }
.ns-source {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 3px 7px;
border-radius: 5px;
background: var(--ns-surface-3);
color: var(--ns-text-3);
text-transform: uppercase;
}
.ns-source-snmp { background: rgba(20,184,168,0.14); color: var(--ns-teal); }
.ns-source-dns { background: rgba(245,158,11,0.16); color: var(--ns-warning); }
.ns-source-mdns { background: rgba(245,158,11,0.16); color: var(--ns-warning); }
.ns-source-unifi { background: rgba(59,130,246,0.14); color: var(--ns-info); }
.ns-source-ad { background: rgba(124,58,237,0.14); color: var(--ns-purple); }
/* ── Status badges ───────────────────────────────────────────────── */
.ns-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
}
.ns-badge-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.ns-badge-online { background: rgba(34,197,94,0.12); color: var(--ns-success); }
.ns-badge-online .ns-badge-dot { background: var(--ns-success); box-shadow: 0 0 6px rgba(34,197,94,0.5); }
.ns-badge-offline { background: rgba(239,68,68,0.14); color: var(--ns-danger); }
.ns-badge-offline .ns-badge-dot { background: var(--ns-danger); }
.ns-badge-warn { background: rgba(245,158,11,0.14); color: var(--ns-warning); }
.ns-badge-warn .ns-badge-dot { background: var(--ns-warning); }
.ns-badge-site {
background: transparent;
border: 1px solid var(--ns-border-strong);
color: var(--ns-text-2);
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 8px;
}
/* ── Side panel ──────────────────────────────────────────────────── */
.ns-side {
display: flex;
flex-direction: column;
gap: 20px;
position: sticky;
top: 100px;
}
.ns-panel {
background: var(--ns-surface);
border: 1px solid var(--ns-border);
border-radius: var(--ns-r-lg);
box-shadow: var(--ns-shadow-sm);
overflow: hidden;
}
.ns-panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 18px 20px 0;
}
.ns-panel-head h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.10em;
color: var(--ns-text-3);
}
.ns-panel-head .ns-meta { font-size: 11.5px; color: var(--ns-text-3); }
.ns-panel-head a { color: var(--ns-teal); font-size: 12px; text-decoration: none; cursor: pointer; }
.ns-panel-head a:hover { text-decoration: underline; }
.ns-panel-body { padding: 14px 20px 20px; }
/* ── Scan card ───────────────────────────────────────────────────── */
.ns-scan-card {
background: linear-gradient(155deg, rgba(20,184,168,0.16), rgba(20,184,168,0.02) 80%), var(--ns-surface);
border: 1px solid rgba(20,184,168,0.22);
border-radius: var(--ns-r-lg);
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ns-scan-card-head { font-size: 11px; color: var(--ns-teal); text-transform: uppercase; letter-spacing: 0.10em; font-weight: 700; }
.ns-scan-card-title { font-size: 15.5px; font-weight: 600; letter-spacing: -0.01em; color: var(--ns-text); }
.ns-scan-card-body { font-size: 12.5px; color: var(--ns-text-3); }
.ns-scan-card-body strong { color: var(--ns-text-2); font-weight: 500; }
.ns-scan-progress {
height: 5px;
background: rgba(20,184,168,0.15);
border-radius: 99px;
overflow: hidden;
}
.ns-scan-progress span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--ns-teal), var(--ns-teal-2));
border-radius: 99px;
}
/* ── Module list ─────────────────────────────────────────────────── */
.ns-mod-list { display: flex; flex-direction: column; }
.ns-mod-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--ns-border);
}
.ns-mod-row:last-child { border-bottom: 0; padding-bottom: 0; }
.ns-mod-row:first-child { padding-top: 6px; }
.ns-mod-ico {
width: 28px;
height: 28px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--ns-surface-3);
color: var(--ns-text-3);
}
.ns-mod-ico-on { background: var(--ns-teal-dim); color: var(--ns-teal); }
.ns-mod-ico svg { width: 14px; height: 14px; }
.ns-mod-meta { flex: 1; min-width: 0; }
.ns-mod-name { font-size: 13px; font-weight: 600; color: var(--ns-text); }
.ns-mod-detail { font-size: 11.5px; color: var(--ns-text-3); margin-top: 1px; }
.ns-mod-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ns-success);
flex-shrink: 0;
}
.ns-mod-status-warn { background: var(--ns-warning); }
.ns-mod-status-off { background: var(--ns-text-3); }
/* ── Alert list (sidebar) ────────────────────────────────────────── */
.ns-alert-list { display: flex; flex-direction: column; gap: 10px; }
.ns-alert-row {
display: flex;
gap: 12px;
padding: 14px;
background: var(--ns-surface-2);
border: 1px solid var(--ns-border);
border-radius: 12px;
}
.ns-alert-ico {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ns-alert-ico-warn { background: rgba(245,158,11,0.18); color: var(--ns-warning); }
.ns-alert-ico-err { background: rgba(239,68,68,0.16); color: var(--ns-danger); }
.ns-alert-ico svg { width: 14px; height: 14px; }
.ns-alert-body { flex: 1; min-width: 0; }
.ns-alert-title { font-size: 12.5px; font-weight: 600; line-height: 1.3; color: var(--ns-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ns-alert-desc { font-size: 11.5px; color: var(--ns-text-3); margin-top: 3px; line-height: 1.45; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ns-alert-when { font-size: 11px; color: var(--ns-text-3); margin-top: 6px; }
/* ── FAB ─────────────────────────────────────────────────────────── */
.ns-fab {
position: fixed;
right: 28px;
bottom: 28px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(140deg, var(--ns-teal), #0a7a78);
color: #fff;
border: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 12px 28px rgba(20,184,168,0.45), 0 2px 6px rgba(0,0,0,0.3);
transition: transform .15s;
z-index: 50;
}
.ns-fab:hover { transform: scale(1.06); }
.ns-fab svg { width: 22px; height: 22px; }
/* ── Empty state ─────────────────────────────────────────────────── */
.ns-empty {
text-align: center;
padding: 60px;
color: var(--ns-text-3);
font-size: 13px;
}

View File

@@ -0,0 +1,473 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import './ScannerPage.css';
import scannerService from '../services/scannerService';
import { toast } from 'react-toastify';
/* ─── helpers ───────────────────────────────────────────────────── */
const timeAgo = (iso) => {
if (!iso) return '';
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min`;
if (m < 1440) return `vor ${Math.floor(m / 60)} Std`;
return `vor ${Math.floor(m / 1440)} Tagen`;
};
const inferType = (vendor = '', hostname = '') => {
const v = vendor.toLowerCase(), h = hostname.toLowerCase();
if (v.includes('kyocera') || v.includes('canon') || v.includes('ricoh') || v.includes('brother')) return 'printer';
if (v.includes('ubiquiti') || v.includes('unifi') || v.includes('cisco') || v.includes('lancom') || v.includes('netgear') || h.match(/sw-\d|usw/)) return 'switch';
if (v.includes('synology') || v.includes('qnap') || h.includes('nas') || h.includes('backup')) return 'nas';
if (v.includes('vmware') || v.includes('proxmox') || v.includes('supermicro') || h.match(/(server|dc0|srv|-dc)/i)) return 'server';
if (v.includes('apple') || v.includes('dell') || v.includes('lenovo') || v.includes('acer') || h.match(/(nb|pc|wks|laptop|desktop)/i)) return 'workstation';
return 'iot';
};
const TYPE_CONFIG = {
server: { label:'Server', tone:'blue', icon:'<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/>' },
switch: { label:'Switch', tone:'teal', icon:'<circle cx="12" cy="12" r="3"/><circle cx="12" cy="3" r="2"/><circle cx="3" cy="12" r="2"/><circle cx="21" cy="12" r="2"/><circle cx="12" cy="21" r="2"/><path d="M12 9V5M9 12H5M19 12h-4M12 19v-4"/>' },
printer: { label:'Drucker', tone:'orange', icon:'<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>' },
workstation: { label:'Workstation', tone:'purple', icon:'<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
nas: { label:'NAS', tone:'', icon:'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>' },
ap: { label:'Access Point', tone:'green', icon:'<path d="M5 12.55a11 11 0 0 1 14 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/>' },
iot: { label:'IoT', tone:'', icon:'<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2h-4v-7H9v7H5a2 2 0 0 1-2-2z"/>' },
};
const MODULES = [
{ name:'ARP-Discovery', detail:'Active ARP scan · /24', icon:'<circle cx="12" cy="12" r="3"/><circle cx="12" cy="3" r="2"/><circle cx="3" cy="12" r="2"/><circle cx="21" cy="12" r="2"/><circle cx="12" cy="21" r="2"/><path d="M12 9V5M9 12H5M19 12h-4M12 19v-4"/>',on:true, status:'ok' },
{ name:'DNS-Lookup', detail:'Reverse PTR lookups', icon:'<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>', on:true, status:'ok' },
{ name:'SNMP', detail:'Switches · Drucker', icon:'<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/>', on:true, status:'ok' },
{ name:'MAC-Vendor', detail:'OUI-Lookup Hersteller', icon:'<path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 0-2-2V9m0 0h18"/>', on:true, status:'ok' },
{ name:'Site Monitoring', detail:'Ping-Checks · Dienste', icon:'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>', on:true, status:'ok' },
{ name:'Port-Scan', detail:'TCP top-ports', icon:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>', on:true, status:'ok' },
{ name:'Nexus Reporter', detail:'Sync → IT Nexus', icon:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>', on:true, status:'ok' },
{ name:'AD-Sync', detail:'Deaktiviert', icon:'<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"/>', on:false, status:'off' },
];
const SPARKLINE = [30,38,34,48,54,60,68,74,82,88,92];
const SITE_NAMES = { LUD:'Lüdenscheid', BAR:'Barleben' };
const Svg = ({ d, size=16 }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round"
style={{width:size, height:size, flexShrink:0}}
dangerouslySetInnerHTML={{__html:d}}/>
);
const SrcChip = ({ src }) => {
const cls = ['snmp','dns','mdns','unifi','ad'].includes(src) ? `ns-source ns-source-${src}` : 'ns-source';
return <span className={cls}>{src}</span>;
};
export default function ScannerPage() {
const [sites, setSites] = useState([]);
const [assets, setAssets] = useState([]);
const [alerts, setAlerts] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [typeF, setTypeF] = useState('all');
const [statusF, setStatusF] = useState('all');
const [srcF, setSrcF] = useState('all');
const [q, setQ] = useState('');
const load = useCallback(async () => {
try {
const [s,st,al,a] = await Promise.all([
scannerService.getSites(),
scannerService.getStats(),
scannerService.getAlerts({limit:50}),
scannerService.getAssets({}),
]);
setSites(s||[]); setStats(st); setAlerts(al||[]); setAssets(a||[]);
} catch { toast.error('Fehler beim Laden'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const aug = useMemo(() => assets.map(a => ({
...a,
type: inferType(a.vendor, a.hostname),
sources: [
'arp',
...(a.hostname ? ['dns'] : []),
...(['kyocera','ubiquiti','unifi','cisco','lancom'].some(v=>(a.vendor||'').toLowerCase().includes(v)) ? ['snmp'] : []),
],
})), [assets]);
const typeCounts = useMemo(() => {
const c = { all: aug.length };
aug.forEach(a => { c[a.type] = (c[a.type]||0)+1; });
return c;
}, [aug]);
const filtered = useMemo(() => aug.filter(a => {
if (typeF !== 'all' && a.type !== typeF) return false;
if (statusF !== 'all' && a.status !== statusF) return false;
if (srcF !== 'all' && !a.sources.includes(srcF)) return false;
if (q) {
const b = `${a.ip} ${a.mac||''} ${a.hostname||''} ${a.vendor||''}`.toLowerCase();
if (!b.includes(q.toLowerCase())) return false;
}
return true;
}), [aug, typeF, statusF, srcF, q]);
const offlineAlerts = useMemo(() => alerts.filter(a => a.status==='offline'), [alerts]);
const site0 = sites[0];
const siteName = site0 ? (SITE_NAMES[site0.site_id] || site0.site_id) : '';
const syncMins = site0 ? Math.floor((Date.now() - new Date(site0.last_seen).getTime()) / 60000) : null;
if (loading) return (
<div style={{display:'flex',alignItems:'center',justifyContent:'center',height:400,color:'var(--text-3)'}}>
Lädt
</div>
);
const exportCsv = () => {
const rows = aug.map(a=>`${a.ip};${a.mac||''};${a.hostname||''};${a.vendor||''};${a.site_id};${a.status}`);
const csv = ['IP;MAC;Hostname;Hersteller;Standort;Status',...rows].join('\n');
const url = URL.createObjectURL(new Blob([''+csv],{type:'text/csv;charset=utf-8'}));
const el = document.createElement('a'); el.href=url; el.download='scanner-assets.csv'; el.click();
};
return (
<div className="ns-page">
{/* ── HERO ── */}
<div className="ns-hero">
<div>
<div className="ns-eyebrow">
<span className="ns-live-dot"/>
Live · Standort {siteName}
</div>
<h1 className="ns-h1">Nexus Scanner</h1>
<p className="ns-lead">
Automatisches Netzwerk-Inventar über ARP, DNS, SNMP und Active Directory.
Zuletzt synchronisiert <strong>{site0 ? timeAgo(site0.last_seen) : ''}</strong>
{' '}· {MODULES.filter(m=>m.on).length} Module aktiv
{site0?.scanner_version ? <> · Scanner-Version <strong>v{site0.scanner_version}</strong></> : null}.
</p>
</div>
<div className="ns-hero-actions">
<button className="ns-btn" onClick={exportCsv}>
<Svg d='<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>'/>
Export
</button>
<button className="ns-btn ns-btn-primary" onClick={load}>
<Svg d='<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/>'/>
Jetzt scannen
</button>
</div>
</div>
{/* ── STATS ── */}
<div className="ns-stats">
{/* Feature card */}
<div className="ns-stat ns-stat-feature">
<div className="ns-sparkline" aria-hidden="true">
{SPARKLINE.map((h,i) => <span key={i} style={{height:`${h}%`}}/>)}
</div>
<div className="ns-stat-label">
<span>Geräte erfasst</span>
{site0 && <span className="ns-stat-tag">{site0.site_id}</span>}
</div>
<div className="ns-stat-value">{stats?.total ?? ''}</div>
<div className="ns-delta-pill"> {stats?.online ?? 0} online heute</div>
<div className="ns-stat-meta">
<span className="ns-chip"><span className="ns-dot ns-dot-green"/>{stats?.online ?? 0} online</span>
<span className="ns-chip"><span className="ns-dot ns-dot-red"/>{stats?.offline ?? 0} offline</span>
</div>
</div>
{/* Module */}
<div className="ns-stat">
<div className="ns-stat-label"><span>Module</span></div>
<div className="ns-stat-value">
{MODULES.filter(m=>m.on).length}
<span className="ns-stat-unit">/ {MODULES.length}</span>
</div>
<div className="ns-stat-meta">
<span className="ns-chip"><span className="ns-dot ns-dot-green"/>{MODULES.filter(m=>m.on&&m.status==='ok').length} OK</span>
<span className="ns-chip"><span className="ns-dot ns-dot-yellow"/>{MODULES.filter(m=>m.on&&m.status==='warn').length} Warn</span>
</div>
</div>
{/* Letzter Scan */}
<div className="ns-stat">
<div className="ns-stat-label"><span>Letzter Scan</span></div>
<div className="ns-stat-value">
{syncMins ?? ''}
{syncMins !== null && <span className="ns-stat-unit">Min</span>}
</div>
<div className="ns-stat-desc">
{stats?.total ?? 0} Hosts erfasst · {siteName}
</div>
</div>
{/* Alerts */}
<div className="ns-stat">
<div className="ns-stat-label"><span>Alerts (24 h)</span></div>
<div className={`ns-stat-value${(stats?.alerts24h||0)>0?' ns-stat-value-danger':''}`}>
{stats?.alerts24h ?? 0}
</div>
<div className="ns-stat-meta">
{(stats?.alerts24h||0)>0 ? <>
<span className="ns-chip"><span className="ns-dot ns-dot-red"/>{offlineAlerts.length} kritisch</span>
</> : <span style={{fontSize:12.5,color:'var(--ns-text-3,#94a3b8)'}}>Alles OK</span>}
</div>
</div>
</div>
{/* ── ATTENTION ── */}
{offlineAlerts.length > 0 && (<>
<div className="ns-section-title">
<h2>Aufmerksamkeit erforderlich</h2>
<span className="ns-meta">{offlineAlerts.length} Themen · automatisch erkannt</span>
</div>
<div className={`ns-attn-grid ns-attn-${Math.min(offlineAlerts.length,3)}`}>
{offlineAlerts.slice(0,3).map((a,i) => (
<div key={i} className="ns-attn ns-attn-danger">
<div className="ns-attn-head">
<div className="ns-attn-icn ns-attn-icn-danger">
<Svg size={18} d='<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/>'/>
</div>
<div className="ns-attn-info">
<div className="ns-attn-title">{a.check_name} offline</div>
<div className="ns-attn-sub"><span style={{fontFamily:'ui-monospace,monospace',fontSize:11.5}}>{a.target}</span></div>
</div>
<span className="ns-attn-sev ns-attn-sev-danger">Offline</span>
</div>
<div className="ns-attn-body">
Zuletzt gesehen: <strong style={{color:'var(--ns-text)'}}>{timeAgo(a.triggered_at)}</strong>
{a.error_msg ? ` · ${a.error_msg}` : ''}
</div>
<div className="ns-attn-actions">
<button className="ns-btn ns-btn-sm">Details</button>
</div>
</div>
))}
</div>
</>)}
{/* ── SECTION TITLE ── */}
<div className="ns-section-title">
<h2>Assets</h2>
<span className="ns-meta">automatisch erkannt · sortiert nach letztem Kontakt</span>
</div>
{/* ── MAIN GRID ── */}
<div className="ns-main-grid">
{/* LEFT: assets table */}
<div>
{/* Segmented type filter */}
<div className="ns-seg">
{[['all','Alle',null], ...Object.entries(TYPE_CONFIG).filter(([k])=>typeCounts[k]).map(([k,v])=>[k,v.label,v.icon])].map(([key,label,icon]) => {
if (!typeCounts[key] && key!=='all') return null;
const active = typeF === key;
return (
<button key={key} onClick={()=>setTypeF(key)}
className={`ns-seg-btn${active?' ns-seg-btn-active':''}`}>
{icon && <Svg size={14} d={icon}/>}
{label}
<span className={`ns-seg-num${active?' ns-seg-num-active':''}`}>
{typeCounts[key]||0}
</span>
</button>
);
})}
</div>
{/* Toolbar */}
<div className="ns-toolbar">
<div className="ns-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>
</svg>
<input value={q} onChange={e=>setQ(e.target.value)}
placeholder="Nach IP, MAC, Hostname oder Hersteller suchen…"/>
</div>
<div className="ns-select-wrap">
<select className="ns-select" value={statusF} onChange={e=>setStatusF(e.target.value)}>
<option value="all">Alle Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
<svg className="ns-select-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div className="ns-select-wrap">
<select className="ns-select" value={srcF} onChange={e=>setSrcF(e.target.value)}>
<option value="all">Alle Quellen</option>
<option value="arp">ARP</option>
<option value="dns">DNS</option>
<option value="snmp">SNMP</option>
</select>
<svg className="ns-select-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<span className="ns-toolbar-count">{filtered.length} von {aug.length}</span>
</div>
{/* Table */}
<div className="ns-card">
<table className="ns-table">
<thead>
<tr>
<th style={{minWidth:270}}>Gerät</th>
<th>Telemetrie</th>
<th>Quellen</th>
<th>Zuletzt</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={5} className="ns-empty">
{aug.length===0?'Noch kein Scanner verbunden.':'Keine Geräte passen zum Filter.'}
</td></tr>
) : filtered.map(a => {
const tc = TYPE_CONFIG[a.type] || TYPE_CONFIG.iot;
const iconClass = `ns-device-icon${tc.tone ? ' ns-device-icon-'+tc.tone : ''}`;
const isOnline = a.status === 'online';
return (
<tr key={`${a.site_id}-${a.ip}`}>
<td>
<div className="ns-device-cell">
<div className={iconClass}>
<Svg size={17} d={tc.icon}/>
</div>
<div className="ns-device-info">
<div className="ns-device-name">{a.hostname || a.ip}</div>
<div className="ns-device-sub">
{a.ip}{a.mac?` · ${a.mac}`:''}{a.vendor?` · ${a.vendor}`:''}
</div>
</div>
</div>
</td>
<td>
{a.hostname ? (
<div className="ns-telemetry">
<div className="ns-telemetry-head">Hostname · Hersteller</div>
<div className="ns-telemetry-val">
<strong>{a.hostname}</strong>
</div>
{a.vendor && <div className="ns-telemetry-val">{a.vendor}</div>}
</div>
) : (
<span className="ns-telemetry-empty"></span>
)}
</td>
<td>
<div className="ns-sources">
{a.sources.map(s => <SrcChip key={s} src={s}/>)}
</div>
</td>
<td style={{fontSize:12,color:'var(--ns-text-3,#94a3b8)',whiteSpace:'nowrap'}}>
{timeAgo(a.last_seen)}
</td>
<td>
<span className={`ns-badge ${isOnline?'ns-badge-online':'ns-badge-offline'}`}>
<span className="ns-badge-dot"/>
{isOnline ? 'Online' : 'Offline'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="ns-table-footer">
<span>Zeige {filtered.length} von {aug.length} Geräten</span>
</div>
</div>
</div>
{/* RIGHT: side panel */}
<aside className="ns-side">
{/* Scan card */}
<div className="ns-scan-card">
<div className="ns-scan-card-head">Letzter Sync</div>
<div className="ns-scan-card-title">
{site0 ? timeAgo(site0.last_seen) : 'Kein Scanner'}
</div>
<div className="ns-scan-progress">
<span style={{width: syncMins !== null ? `${Math.max(5, 100 - syncMins*20)}%` : '5%'}}/>
</div>
<div className="ns-scan-card-body">
<strong>5-Minuten-Intervall</strong> · ARP 192.168.0.0/24 · SNMP aktiv
</div>
</div>
{/* Modules */}
<div className="ns-panel">
<div className="ns-panel-head">
<h3>Module</h3>
<span className="ns-meta">{MODULES.filter(m=>m.on).length} / {MODULES.length} aktiv</span>
</div>
<div className="ns-panel-body">
<div className="ns-mod-list">
{MODULES.map((m,i) => (
<div key={i} className="ns-mod-row">
<div className={`ns-mod-ico${m.on?' ns-mod-ico-on':''}`}>
<Svg size={14} d={m.icon}/>
</div>
<div className="ns-mod-meta">
<div className="ns-mod-name">{m.name}</div>
<div className="ns-mod-detail">{m.detail}</div>
</div>
<span className={`ns-mod-status${m.status==='warn'?' ns-mod-status-warn':m.status==='off'?' ns-mod-status-off':''}`}/>
</div>
))}
</div>
</div>
</div>
{/* Alerts */}
<div className="ns-panel">
<div className="ns-panel-head">
<h3>Offene Alerts</h3>
<a>Alle </a>
</div>
<div className="ns-panel-body">
{alerts.length === 0 ? (
<div className="ns-telemetry-empty" style={{textAlign:'center',padding:'16px 0'}}>
Keine offenen Alerts
</div>
) : (
<div className="ns-alert-list">
{alerts.slice(0,4).map((a,i) => (
<div key={i} className={`ns-alert-row${a.status==='offline'?' ns-alert-row-err':' ns-alert-row-warn'}`}>
<div className={`ns-alert-ico${a.status==='offline'?' ns-alert-ico-err':' ns-alert-ico-warn'}`}>
<Svg size={14} d={a.status==='offline'
? '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/>'
: '<path d="M20 6 9 17l-5-5"/>'}/>
</div>
<div className="ns-alert-body">
<div className="ns-alert-title">{a.check_name}</div>
<div className="ns-alert-desc">{a.target}{a.error_msg?` · ${a.error_msg}`:''}</div>
<div className="ns-alert-when">{timeAgo(a.triggered_at)}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</aside>
</div>
{/* FAB */}
<button className="ns-fab" onClick={load} title="Jetzt aktualisieren">
<Svg size={22} d='<polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/>'/>
</button>
</div>
);
}

View File

@@ -0,0 +1,329 @@
import React, { useEffect, useState, useRef } from 'react';
const C = {
primary: '#3fa3a3',
primaryDark: '#008487',
text: '#333333',
textMid: '#4B4F58',
textLight: '#808285',
bg: '#F6F7F8',
border: '#e4e0df',
white: '#FFFFFF',
};
const API = process.env.REACT_APP_API_URL || '/api';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}` });
const riskConfig = {
hoch: { color: '#DC2626', bg: '#FEF2F2', border: '#FECACA', label: 'Hohes Risiko', icon: '🔴' },
mittel: { color: '#D97706', bg: '#FFFBEB', border: '#FDE68A', label: 'Mittleres Risiko', icon: '🟡' },
niedrig: { color: '#059669', bg: '#ECFDF5', border: '#A7F3D0', label: 'Niedrig', icon: '🟢' },
unbekannt:{ color: '#6B7280', bg: '#F9FAFB', border: '#E5E7EB', label: 'Unbekannt', icon: '⚪' },
};
const fmt = (d) => d ? new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
export default function SecurityReportsPage() {
const [reports, setReports] = useState([]);
const [selected, setSelected] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [dragging, setDragging] = useState(false);
const [loading, setLoading] = useState(true);
const fileRef = useRef();
const load = async () => {
try {
setLoading(true);
const res = await fetch(`${API}/security-reports`, { headers: headers() });
const data = await res.json();
setReports(data);
if (data.length > 0 && !selected) setSelected(data[0]);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const handleFile = async (file) => {
if (!file || file.type !== 'application/pdf') {
setUploadError('Nur PDF-Dateien erlaubt'); return;
}
setUploadError('');
setUploading(true);
try {
const fd = new FormData();
fd.append('pdf', file);
const res = await fetch(`${API}/security-reports/upload`, {
method: 'POST', headers: headers(), body: fd,
});
const data = await res.json();
if (!res.ok) { setUploadError(data.error || 'Fehler'); return; }
await load();
setSelected(data);
} catch (e) { setUploadError('Verbindungsfehler'); }
finally { setUploading(false); }
};
const handleDelete = async (id) => {
if (!window.confirm('Report löschen?')) return;
await fetch(`${API}/security-reports/${id}`, { method: 'DELETE', headers: headers() });
const updated = reports.filter(r => r.id !== id);
setReports(updated);
if (selected?.id === id) setSelected(updated[0] || null);
};
const risk = riskConfig[selected?.risk_level] || riskConfig.unbekannt;
const a = selected?.analysis || {};
return (
<div style={{ display: 'flex', height: 'calc(100vh - 60px)', background: C.bg, fontFamily: 'Lato, sans-serif' }}>
{/* ── Linke Spalte: Liste + Upload ── */}
<div style={{ width: 320, flexShrink: 0, background: C.white, borderRight: `1px solid ${C.border}`, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '20px 16px 12px', borderBottom: `1px solid ${C.border}` }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: C.text }}>Security Reports</h2>
<p style={{ margin: '4px 0 0', fontSize: 12, color: C.textLight }}>Arctic Wolf M365 Analysen</p>
</div>
{/* Upload-Bereich */}
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${C.border}` }}>
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files[0]); }}
onClick={() => fileRef.current?.click()}
style={{
border: `2px dashed ${dragging ? C.primary : C.border}`,
borderRadius: 10,
padding: '16px 12px',
textAlign: 'center',
cursor: uploading ? 'not-allowed' : 'pointer',
background: dragging ? '#f0fafa' : C.bg,
transition: 'all .15s',
}}
>
<div style={{ fontSize: 24, marginBottom: 6 }}>{uploading ? '⏳' : '📄'}</div>
<div style={{ fontSize: 12, fontWeight: 600, color: C.primary }}>
{uploading ? 'Wird analysiert…' : 'PDF hochladen'}
</div>
<div style={{ fontSize: 11, color: C.textLight, marginTop: 2 }}>
{uploading ? 'Claude analysiert den Report…' : 'Drag & Drop oder klicken'}
</div>
</div>
<input ref={fileRef} type="file" accept=".pdf" style={{ display: 'none' }}
onChange={e => handleFile(e.target.files[0])} />
{uploadError && (
<div style={{ marginTop: 8, fontSize: 12, color: '#DC2626', background: '#FEF2F2', borderRadius: 6, padding: '6px 10px' }}>
{uploadError}
</div>
)}
</div>
{/* Report-Liste */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading ? (
<div style={{ padding: 24, textAlign: 'center', color: C.textLight, fontSize: 13 }}>Lädt</div>
) : reports.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: C.textLight, fontSize: 13 }}>
Noch keine Reports.<br />PDF hochladen um zu starten.
</div>
) : reports.map(r => {
const rc = riskConfig[r.risk_level] || riskConfig.unbekannt;
const isActive = selected?.id === r.id;
return (
<div key={r.id} onClick={() => setSelected(r)}
style={{
padding: '12px 16px', cursor: 'pointer',
borderLeft: `3px solid ${isActive ? C.primary : 'transparent'}`,
background: isActive ? '#f0fafa' : 'transparent',
borderBottom: `1px solid ${C.border}`,
transition: 'all .1s',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: C.text, marginBottom: 2 }}>
{rc.icon} {r.report_period}
</div>
<div style={{ fontSize: 11, color: C.textLight, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{r.original_filename}
</div>
<div style={{ fontSize: 10, color: C.textLight, marginTop: 2 }}>{fmt(r.created_at)}</div>
</div>
<button onClick={e => { e.stopPropagation(); handleDelete(r.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9CA3AF', fontSize: 14, padding: '0 0 0 8px' }}>
🗑
</button>
</div>
</div>
);
})}
</div>
</div>
{/* ── Rechte Spalte: Detail ── */}
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px' }}>
{!selected ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: C.textLight }}>
<div style={{ fontSize: 56, marginBottom: 16 }}>🛡</div>
<p style={{ fontSize: 16, fontWeight: 600 }}>Noch kein Report ausgewählt</p>
<p style={{ fontSize: 13 }}>Lade einen Arctic Wolf PDF Report hoch</p>
</div>
) : (
<div style={{ maxWidth: 900 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: C.text }}>
M365 Security Report
</h1>
<p style={{ margin: '4px 0 0', fontSize: 14, color: C.textLight }}>
{selected.report_period} · Hochgeladen von {selected.created_by_username || '—'}
</p>
</div>
<div style={{
background: risk.bg, border: `1px solid ${risk.border}`,
borderRadius: 20, padding: '6px 14px',
fontSize: 13, fontWeight: 700, color: risk.color,
}}>
{risk.icon} {risk.label}
</div>
</div>
{/* Summary */}
{a.summary && (
<div style={{
background: C.white, border: `1px solid ${C.border}`,
borderLeft: `4px solid ${C.primary}`,
borderRadius: 10, padding: '16px 20px', marginBottom: 20,
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: C.primary, marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
KI-Zusammenfassung
</div>
<p style={{ margin: 0, fontSize: 14, color: C.text, lineHeight: 1.6 }}>{a.summary}</p>
</div>
)}
{/* Stats-Kacheln */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 20 }}>
{[
{ label: 'Gesamt Logins', value: (a.login_stats?.total || 0).toLocaleString('de-DE'), icon: '🔑', color: C.primary },
{ label: 'Erfolgreich', value: (a.login_stats?.successful || 0).toLocaleString('de-DE'), icon: '✅', color: '#059669' },
{ label: 'Fehlgeschlagen', value: (a.login_stats?.failed || 0).toLocaleString('de-DE'), icon: '❌', color: '#DC2626' },
{ label: 'Erfolgsrate', value: `${(a.login_stats?.success_rate || 0).toFixed(1)}%`, icon: '📊', color: C.primaryDark },
].map(stat => (
<div key={stat.label} style={{
background: C.white, border: `1px solid ${C.border}`,
borderRadius: 10, padding: '16px 18px',
}}>
<div style={{ fontSize: 22, marginBottom: 8 }}>{stat.icon}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
<div style={{ fontSize: 12, color: C.textLight, marginTop: 2 }}>{stat.label}</div>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
{/* Top Login Fehler */}
<Section title="Top Login-Fehler" icon="⚠️">
{(a.top_login_failures || []).length === 0 ? (
<Empty text="Keine Daten" />
) : (a.top_login_failures || []).map((f, i) => (
<Row key={i}
left={<><span style={{ fontWeight: 700, color: C.text, fontSize: 13 }}>{f.user}</span><br /><span style={{ fontSize: 11, color: C.textLight }}>{f.type}</span></>}
right={<span style={{ fontWeight: 700, color: '#DC2626', fontSize: 14 }}>{f.count?.toLocaleString('de-DE')}×</span>}
/>
))}
</Section>
{/* Nicht-europäische Logins */}
<Section title="Logins außerhalb Europa" icon="🌍">
{(a.non_european_logins || []).length === 0 ? (
<Empty text="✅ Keine auffälligen Logins" green />
) : (a.non_european_logins || []).map((l, i) => (
<Row key={i}
left={<>
<span style={{ fontWeight: 700, color: C.text, fontSize: 12 }}>{l.user}</span><br />
<span style={{ fontSize: 11, color: C.textLight }}>{l.country} · {l.ip}</span>
</>}
right={l.flagged
? <span style={{ fontSize: 11, background: '#FEF2F2', color: '#DC2626', padding: '2px 7px', borderRadius: 10, fontWeight: 600 }}> Auffällig</span>
: <span style={{ fontSize: 11, color: C.textLight }}>{l.count}×</span>
}
/>
))}
</Section>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
{/* Wichtige Erkenntnisse */}
<Section title="Wichtige Erkenntnisse" icon="🔍">
{(a.key_findings || []).length === 0 ? <Empty text="Keine" /> :
(a.key_findings || []).map((f, i) => (
<div key={i} style={{ display: 'flex', gap: 8, padding: '6px 0', borderBottom: i < a.key_findings.length - 1 ? `1px solid ${C.border}` : 'none' }}>
<span style={{ color: C.primary, flexShrink: 0 }}></span>
<span style={{ fontSize: 13, color: C.text, lineHeight: 1.5 }}>{f}</span>
</div>
))
}
</Section>
{/* Empfehlungen */}
<Section title="Empfehlungen" icon="💡">
{(a.recommendations || []).length === 0 ? <Empty text="Keine" /> :
(a.recommendations || []).map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 8, padding: '6px 0', borderBottom: i < a.recommendations.length - 1 ? `1px solid ${C.border}` : 'none' }}>
<span style={{ color: C.primaryDark, flexShrink: 0 }}></span>
<span style={{ fontSize: 13, color: C.text, lineHeight: 1.5 }}>{r}</span>
</div>
))
}
</Section>
</div>
{/* Weitere Metriken */}
<Section title="Weitere Ereignisse" icon="📋">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<Metric label="SharePoint anonyme Links" value={a.sharepoint_anonymous_links || 0} warn={(a.sharepoint_anonymous_links || 0) > 10} />
<Metric label="Identity Protection Events" value={a.identity_protection_events || 0} warn={(a.identity_protection_events || 0) > 0} />
<Metric label="Neue Gruppen erstellt" value={(a.new_groups_created || []).length} />
</div>
</Section>
</div>
)}
</div>
</div>
);
}
const Section = ({ title, icon, children }) => (
<div style={{ background: '#FFFFFF', border: `1px solid #e4e0df`, borderRadius: 10, overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', borderBottom: `1px solid #e4e0df`, background: '#F6F7F8', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>{icon}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#333333' }}>{title}</span>
</div>
<div style={{ padding: '8px 16px' }}>{children}</div>
</div>
);
const Row = ({ left, right }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', borderBottom: `1px solid #e4e0df` }}>
<div style={{ flex: 1, minWidth: 0 }}>{left}</div>
<div style={{ flexShrink: 0, marginLeft: 8 }}>{right}</div>
</div>
);
const Empty = ({ text, green }) => (
<p style={{ margin: '8px 0', fontSize: 13, color: green ? '#059669' : '#808285' }}>{text}</p>
);
const Metric = ({ label, value, warn }) => (
<div style={{ textAlign: 'center', padding: '12px 8px', background: warn ? '#FEF2F2' : '#F6F7F8', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: warn ? '#DC2626' : '#3fa3a3' }}>{value}</div>
<div style={{ fontSize: 11, color: '#808285', marginTop: 4 }}>{label}</div>
</div>
);

View File

@@ -0,0 +1,901 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import api from '../services/api';
import assetService from '../services/assetService';
const EMAIL_TEMPLATE_LABELS = {
comment_added: { label: '💬 Neuer Kommentar', desc: 'Gesendet an den Ersteller wenn Support antwortet' },
escalation: { label: '⚠️ Eskalation', desc: 'An Admins bei überfälligen kritischen/hohen Tickets' },
satisfaction: { label: '⭐ Zufriedenheits-Feedback', desc: 'Gesendet nach Ticketschließung' },
status_changed: { label: '🔄 Statusänderung', desc: 'An den Ersteller bei Statuswechsel' },
ticket_assigned: { label: '👤 Ticket-Zuweisung', desc: 'An den Bearbeiter bei Ticket-Zuweisung' },
ticket_created: { label: '✅ Ticket-Bestätigung', desc: 'An den Ersteller nach Ticket-Erstellung' },
};
const EMAIL_TEMPLATE_VARS = {
ticket_created: ['ticket_number', 'ticket_title', 'requester_name', 'category', 'priority'],
ticket_assigned: ['ticket_number', 'ticket_title', 'assignee_name', 'requester_name', 'requester_email', 'category', 'priority'],
comment_added: ['ticket_number', 'ticket_title', 'author_name'],
status_changed: ['ticket_number', 'ticket_title', 'old_status', 'new_status'],
escalation: ['ticket_number', 'ticket_title', 'priority', 'requester_name', 'age_hours'],
satisfaction: ['ticket_number', 'ticket_title', 'requester_name'],
};
const PRIORITIES = ['niedrig', 'mittel', 'hoch', 'kritisch'];
const PRIORITY_ICONS = { niedrig: '🟢', mittel: '🔵', hoch: '🟠', kritisch: '🔴' };
const COMMON_ICONS = ['📋', '💻', '🖥️', '🌐', '📊', '🖨️', '📧', '🔐', '📱', '🔧', '⚡', '🏢', '👤', '🗂️', '🔑'];
export default function SettingsPage() {
const [tab, setTab] = useState('categories');
// ── Categories state ──────────────────────────────────────────────────────
const [categories, setCategories] = useState([]);
const [catModal, setCatModal] = useState(null); // null | { id?, name, icon }
const [catLoading, setCatLoading] = useState(false);
// ── Templates state ───────────────────────────────────────────────────────
const [templates, setTemplates] = useState([]);
const [tplModal, setTplModal] = useState(null); // null | template object
const [tplLoading, setTplLoading] = useState(false);
// ── Email templates state ─────────────────────────────────────────────────
const [emailTpls, setEmailTpls] = useState([]);
const [emailModal, setEmailModal] = useState(null); // null | { type, label, subject, intro }
const [emailLoading, setEmailLoading] = useState(false);
const [lastFocused, setLastFocused] = useState('intro');
const emailSubjectRef = useRef(null);
const emailIntroRef = useRef(null);
// ── Email design state ────────────────────────────────────────────────────
const [emailDesign, setEmailDesign] = useState(null);
const [designDraft, setDesignDraft] = useState(null);
const [designSaving, setDesignSaving] = useState(false);
const [previewType, setPreviewType] = useState('ticket_created');
const [previewDark, setPreviewDark] = useState(false);
const [previewHtml, setPreviewHtml] = useState('');
const [previewLoading, setPreviewLoading] = useState(false);
const previewDebounce = useRef(null);
const loadCategories = useCallback(async () => {
const res = await api.get('/settings/categories');
setCategories(res.data);
}, []);
const loadTemplates = useCallback(async () => {
const res = await api.get('/settings/templates');
setTemplates(res.data);
}, []);
const loadEmailTpls = useCallback(async () => {
const res = await api.get('/settings/email-templates');
setEmailTpls(res.data);
}, []);
const loadEmailDesign = useCallback(async () => {
const res = await api.get('/settings/email-design');
setEmailDesign(res.data);
setDesignDraft(res.data);
}, []);
useEffect(() => { loadCategories(); loadTemplates(); loadEmailTpls(); loadEmailDesign(); }, [loadCategories, loadTemplates, loadEmailTpls, loadEmailDesign]);
// Fetch preview when designDraft or previewType changes
useEffect(() => {
if (!designDraft) return;
clearTimeout(previewDebounce.current);
previewDebounce.current = setTimeout(async () => {
setPreviewLoading(true);
try {
const apiUrl = process.env.REACT_APP_API_URL || '/api';
const token = localStorage.getItem('token');
const res = await fetch(`${apiUrl}/settings/email-preview/${previewType}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ design: designDraft, darkMode: previewDark }),
});
const html = await res.text();
setPreviewHtml(html);
} catch (_) {}
setPreviewLoading(false);
}, 600);
}, [designDraft, previewType, previewDark]); // eslint-disable-line
// ── Category CRUD ──────────────────────────────────────────────────────────
const saveCategory = async () => {
if (!catModal?.name?.trim()) return;
setCatLoading(true);
try {
if (catModal.id) {
await api.put(`/settings/categories/${catModal.id}`, { name: catModal.name, icon: catModal.icon });
} else {
await api.post('/settings/categories', { name: catModal.name, icon: catModal.icon });
}
await loadCategories();
setCatModal(null);
} catch (e) {
alert(e.response?.data?.message || 'Fehler beim Speichern');
} finally {
setCatLoading(false);
}
};
const deleteCategory = async (cat) => {
if (!window.confirm(`Kategorie „${cat.name}" wirklich löschen?`)) return;
await api.delete(`/settings/categories/${cat.id}`);
await loadCategories();
};
// ── Template CRUD ──────────────────────────────────────────────────────────
const saveTemplate = async () => {
if (!tplModal?.label?.trim()) return;
setTplLoading(true);
try {
if (tplModal.id) {
await api.put(`/settings/templates/${tplModal.id}`, tplModal);
} else {
await api.post('/settings/templates', tplModal);
}
await loadTemplates();
setTplModal(null);
} catch (e) {
alert(e.response?.data?.message || 'Fehler beim Speichern');
} finally {
setTplLoading(false);
}
};
const deleteTemplate = async (tpl) => {
if (!window.confirm(`Vorlage „${tpl.label}" wirklich löschen?`)) return;
await api.delete(`/settings/templates/${tpl.id}`);
await loadTemplates();
};
// ── Email design CRUD ─────────────────────────────────────────────────────
const saveDesign = async () => {
setDesignSaving(true);
try {
const res = await api.put('/settings/email-design', designDraft);
setEmailDesign(res.data);
setDesignDraft(res.data);
} catch (e) {
alert(e.response?.data?.message || 'Fehler beim Speichern');
} finally {
setDesignSaving(false);
}
};
const resetDesign = async () => {
if (!window.confirm('Design auf Standardwerte zurücksetzen?')) return;
try {
const res = await api.post('/settings/email-design/reset');
setEmailDesign(res.data);
setDesignDraft(res.data);
} catch (e) {
alert(e.response?.data?.message || 'Fehler');
}
};
const setDraft = (key, val) => setDesignDraft(d => ({ ...d, [key]: val }));
// ── Email template CRUD ───────────────────────────────────────────────────
const saveEmailTpl = async () => {
if (!emailModal?.subject?.trim() || !emailModal?.intro?.trim()) return;
setEmailLoading(true);
try {
await api.put(`/settings/email-templates/${emailModal.type}`, {
subject: emailModal.subject,
intro: emailModal.intro,
});
await loadEmailTpls();
setEmailModal(null);
} catch (e) {
alert(e.response?.data?.message || 'Fehler beim Speichern');
} finally {
setEmailLoading(false);
}
};
const resetEmailTpl = async (type) => {
if (!window.confirm('Vorlage auf Standardwerte zurücksetzen?')) return;
try {
await api.post(`/settings/email-templates/${type}/reset`);
await loadEmailTpls();
if (emailModal?.type === type) {
const res = await api.get('/settings/email-templates');
const updated = res.data.find(t => t.type === type);
if (updated) setEmailModal(m => ({ ...m, subject: updated.subject, intro: updated.intro }));
}
} catch (e) {
alert(e.response?.data?.message || 'Fehler beim Zurücksetzen');
}
};
const insertVar = (varName) => {
const isSubject = lastFocused === 'subject';
const el = isSubject ? emailSubjectRef.current : emailIntroRef.current;
const field = isSubject ? 'subject' : 'intro';
const token = `{{${varName}}}`;
if (el) {
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? el.value.length;
const newVal = el.value.substring(0, start) + token + el.value.substring(end);
setEmailModal(m => ({ ...m, [field]: newVal }));
setTimeout(() => {
el.focus();
el.selectionStart = el.selectionEnd = start + token.length;
}, 0);
} else {
setEmailModal(m => ({ ...m, [field]: (m[field] || '') + token }));
}
};
// ── Styles ─────────────────────────────────────────────────────────────────
const card = {
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-xl)',
padding: '24px',
marginBottom: '16px',
};
const rowStyle = {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 14px',
borderRadius: 'var(--radius-md)',
background: 'var(--bg-secondary)',
marginBottom: '8px',
};
const btnPrimary = {
background: 'var(--cereda-primary)',
color: '#fff',
border: 'none',
borderRadius: 'var(--radius-md)',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
};
const btnOutline = {
background: 'transparent',
color: 'var(--text-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-md)',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '13px',
};
const btnDanger = {
background: 'transparent',
color: 'var(--danger)',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md)',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '13px',
};
return (
<div className="main-content" style={{ padding: '40px 24px' }}>
{/* ── Constrained header + tabs ─────────────────────────────────── */}
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '28px' }}>
<h1 style={{ fontSize: '22px', fontWeight: 700, color: 'var(--text-primary)', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
Einstellungen
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', marginTop: '6px' }}>
Ticket-Kategorien, Vorlagen, E-Mail-Texte und E-Mail-Design verwalten
</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--border-color)', paddingBottom: '0' }}>
{[
{ key: 'categories', label: '📂 Kategorien' },
{ key: 'templates', label: '📝 Vorlagen' },
{ key: 'asset-types', label: '🖥️ Asset-Typen' },
{ key: 'email-templates', label: '📧 E-Mail-Vorlagen' },
{ key: 'email-design', label: '🎨 E-Mail-Design' },
].map(t => (
<button key={t.key} onClick={() => setTab(t.key)} style={{
background: 'none',
border: 'none',
borderBottom: tab === t.key ? '2px solid var(--cereda-primary)' : '2px solid transparent',
color: tab === t.key ? 'var(--cereda-primary)' : 'var(--text-secondary)',
padding: '10px 18px',
cursor: 'pointer',
fontWeight: tab === t.key ? 700 : 400,
fontSize: '14px',
marginBottom: '-1px',
transition: 'var(--transition)',
}}>
{t.label}
</button>
))}
</div>
</div>{/* end constrained header/tabs */}
{/* ── Constrained tab content (all except email-design) ─────────── */}
<div style={{ maxWidth: '860px', margin: '0 auto', display: tab === 'email-design' ? 'none' : 'block' }}>
{/* ── CATEGORIES TAB ────────────────────────────────────────────── */}
{tab === 'categories' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
{categories.length} Kategorien · werden beim Ticket-Erstellen angezeigt
</p>
<button style={btnPrimary} onClick={() => setCatModal({ name: '', icon: '📁' })}>
+ Kategorie hinzufügen
</button>
</div>
<div style={card}>
{categories.length === 0 && (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', margin: '32px 0' }}>Keine Kategorien vorhanden</p>
)}
{categories.map(cat => (
<div key={cat.id} style={rowStyle}>
<span style={{ fontSize: '20px', minWidth: '28px', textAlign: 'center' }}>{cat.icon}</span>
<span style={{ flex: 1, fontWeight: 600, color: 'var(--text-primary)' }}>{cat.name}</span>
<button style={btnOutline} onClick={() => setCatModal({ id: cat.id, name: cat.name, icon: cat.icon })}>
Bearbeiten
</button>
<button style={btnDanger} onClick={() => deleteCategory(cat)}>
Löschen
</button>
</div>
))}
</div>
</div>
)}
{/* ── TEMPLATES TAB ─────────────────────────────────────────────── */}
{tab === 'templates' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
{templates.length} Vorlagen · erscheinen im Ticket-Erstellungs-Dropdown
</p>
<button style={btnPrimary} onClick={() => setTplModal({ label: '', title: '', description: '', category: categories[0]?.name || 'Allgemein', priority: 'mittel' })}>
+ Vorlage hinzufügen
</button>
</div>
<div style={card}>
{templates.length === 0 && (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', margin: '32px 0' }}>Keine Vorlagen vorhanden</p>
)}
{templates.map(tpl => (
<div key={tpl.id} style={rowStyle}>
<span style={{ fontSize: '16px' }}>{PRIORITY_ICONS[tpl.priority]}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: '14px' }}>{tpl.label}</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '2px' }}>
{tpl.category} · {tpl.title || <em>Kein Titel</em>}
</div>
</div>
<button style={btnOutline} onClick={() => setTplModal({ ...tpl })}>
Bearbeiten
</button>
<button style={btnDanger} onClick={() => deleteTemplate(tpl)}>
Löschen
</button>
</div>
))}
</div>
</div>
)}
{/* ── ASSET TYPES TAB ──────────────────────────────────────────── */}
{tab === 'asset-types' && <AssetTypesTab />}
{/* ── EMAIL TEMPLATES TAB ───────────────────────────────────────── */}
{tab === 'email-templates' && (
<div>
<p style={{ margin: '0 0 16px', color: 'var(--text-muted)', fontSize: '13px' }}>
Betreff und Einleitungstext der automatischen E-Mails anpassen. Variablen wie <code>{'{{ticket_number}}'}</code> werden beim Versand ersetzt.
</p>
<div style={card}>
{emailTpls.map(tpl => {
const meta = EMAIL_TEMPLATE_LABELS[tpl.type] || { label: tpl.type, desc: '' };
return (
<div key={tpl.type} style={rowStyle}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: '14px' }}>{meta.label}</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '2px' }}>{meta.desc}</div>
</div>
<button style={btnOutline} onClick={() => setEmailModal({ ...tpl })}>Bearbeiten</button>
<button style={btnDanger} onClick={() => resetEmailTpl(tpl.type)}>Zurücksetzen</button>
</div>
);
})}
</div>
</div>
)}
</div>{/* end constrained tab content */}
{/* ── EMAIL DESIGN TAB full width ─────────────────────────────── */}
{tab === 'email-design' && designDraft && (
<div style={{ display: 'grid', gridTemplateColumns: '400px 1fr', gap: '28px', alignItems: 'start' }}>
{/* Left: settings (sticky) */}
<div style={{ position: 'sticky', top: '16px', maxHeight: 'calc(100vh - 80px)', overflowY: 'auto' }}>
<div style={card}>
<h3 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 700, color: 'var(--text-primary)' }}>🏷 Marke</h3>
<div className="form-group">
<label className="form-label">Logo-URL</label>
<input className="form-input" value={designDraft.logo_url} onChange={e => setDraft('logo_url', e.target.value)} placeholder="https://... (leer = Emoji-Icon)" />
<p style={{ color: 'var(--text-muted)', fontSize: '11px', margin: '3px 0 0' }}>Bild-URL (PNG/SVG/JPG). Wenn leer wird das Emoji-Icon verwendet.</p>
</div>
<div className="form-group">
<label className="form-label">Emoji-Icon (Fallback)</label>
<input className="form-input" value={designDraft.brand_icon} onChange={e => setDraft('brand_icon', e.target.value)} placeholder="💻" style={{ fontSize: '18px', width: '80px' }} />
</div>
<div className="form-group">
<label className="form-label">Markenname</label>
<input className="form-input" value={designDraft.brand_name} onChange={e => setDraft('brand_name', e.target.value)} placeholder="CEREDA SYSTEMS" />
<p style={{ color: 'var(--text-muted)', fontSize: '11px', margin: '3px 0 0' }}>Erstes Wort dunkel, Rest in Primärfarbe.</p>
</div>
<div className="form-group">
<label className="form-label">Untertitel</label>
<input className="form-input" value={designDraft.brand_subtitle} onChange={e => setDraft('brand_subtitle', e.target.value)} placeholder="IT Support" />
</div>
<h3 style={{ margin: '20px 0 16px', fontSize: '14px', fontWeight: 700, color: 'var(--text-primary)' }}>🎨 Farben</h3>
{[
{ key: 'primary_color', label: 'Primärfarbe', desc: 'Akzentleiste, Links, Badge' },
{ key: 'button_color', label: 'Button-Farbe', desc: 'Hintergrund des Haupt-Buttons' },
{ key: 'bg_color', label: 'Hintergrundfarbe', desc: 'Äußerer E-Mail-Hintergrund' },
].map(({ key, label, desc }) => (
<div key={key} className="form-group">
<label className="form-label">{label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="color"
value={designDraft[key]}
onChange={e => setDraft(key, e.target.value)}
style={{ width: '44px', height: '36px', border: '1px solid var(--border-color)', borderRadius: '8px', cursor: 'pointer', padding: '2px' }}
/>
<input
className="form-input"
value={designDraft[key]}
onChange={e => setDraft(key, e.target.value)}
placeholder="#0d9488"
style={{ flex: 1, fontFamily: 'monospace', fontSize: '13px' }}
/>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '11px', margin: '3px 0 0' }}>{desc}</p>
</div>
))}
<h3 style={{ margin: '20px 0 16px', fontSize: '14px', fontWeight: 700, color: 'var(--text-primary)' }}>📝 Footer</h3>
<div className="form-group">
<label className="form-label">Firmenname</label>
<input className="form-input" value={designDraft.company_name} onChange={e => setDraft('company_name', e.target.value)} placeholder="Cereda Systems GmbH" />
</div>
<div className="form-group">
<label className="form-label">Footer-Text</label>
<textarea className="form-input" value={designDraft.footer_text} onChange={e => setDraft('footer_text', e.target.value)} rows={2} style={{ resize: 'vertical', fontSize: '13px' }} />
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'space-between', marginTop: '8px' }}>
<button style={btnDanger} onClick={resetDesign}>Zurücksetzen</button>
<button style={btnPrimary} onClick={saveDesign} disabled={designSaving}>
{designSaving ? 'Speichern...' : 'Design speichern'}
</button>
</div>
</div>
</div>
{/* Right: live preview */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>Vorschau:</span>
<select
className="form-select"
value={previewType}
onChange={e => setPreviewType(e.target.value)}
style={{ flex: 1, fontSize: '13px' }}
>
{Object.entries(EMAIL_TEMPLATE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
{/* Dark/Light mode toggle */}
<button
onClick={() => setPreviewDark(v => !v)}
title={previewDark ? 'Zu Hell-Modus wechseln' : 'Zu Dunkel-Modus wechseln'}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-color)',
background: previewDark ? '#1e293b' : 'var(--bg-secondary)',
color: previewDark ? '#e2e8f0' : 'var(--text-secondary)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
whiteSpace: 'nowrap',
transition: 'var(--transition)',
}}
>
{previewDark ? '🌙 Dark' : '☀️ Light'}
</button>
{previewLoading && <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Lädt...</span>}
</div>
<div style={{ border: '1px solid var(--border-color)', borderRadius: 'var(--radius-xl)', overflow: 'hidden', background: previewDark ? '#0f172a' : '#f1f5f9' }}>
<iframe
srcDoc={previewHtml}
style={{ width: '100%', height: 'calc(100vh - 260px)', minHeight: '600px', border: 'none', display: 'block' }}
title="E-Mail Vorschau"
sandbox="allow-same-origin"
/>
</div>
</div>
</div>
)}
{/* ── CATEGORY MODAL ────────────────────────────────────────────── */}
{catModal && (
<div className="modal-overlay" onClick={() => setCatModal(null)}>
<div className="modal-content" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{catModal.id ? '✏️ Kategorie bearbeiten' : ' Neue Kategorie'}</h2>
<button className="modal-close" onClick={() => setCatModal(null)}>×</button>
</div>
<div className="form-group">
<label className="form-label">Name*</label>
<input
className="form-input"
value={catModal.name}
onChange={e => setCatModal(p => ({ ...p, name: e.target.value }))}
placeholder="z.B. Microsoft 365"
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">Icon (Emoji)</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '10px' }}>
{COMMON_ICONS.map(ic => (
<button
key={ic}
onClick={() => setCatModal(p => ({ ...p, icon: ic }))}
style={{
fontSize: '22px',
background: catModal.icon === ic ? 'var(--cereda-primary)20' : 'var(--bg-secondary)',
border: catModal.icon === ic ? '2px solid var(--cereda-primary)' : '2px solid transparent',
borderRadius: '8px',
width: '40px',
height: '40px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{ic}
</button>
))}
</div>
<input
className="form-input"
value={catModal.icon}
onChange={e => setCatModal(p => ({ ...p, icon: e.target.value }))}
placeholder="Oder eigenes Emoji eingeben"
style={{ fontSize: '18px' }}
/>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button style={btnOutline} onClick={() => setCatModal(null)}>Abbrechen</button>
<button style={btnPrimary} onClick={saveCategory} disabled={catLoading || !catModal.name?.trim()}>
{catLoading ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* ── TEMPLATE MODAL ────────────────────────────────────────────── */}
{tplModal && (
<div className="modal-overlay" onClick={() => setTplModal(null)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{tplModal.id ? '✏️ Vorlage bearbeiten' : ' Neue Vorlage'}</h2>
<button className="modal-close" onClick={() => setTplModal(null)}>×</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group">
<label className="form-label">Anzeige-Name (Dropdown)*</label>
<input
className="form-input"
value={tplModal.label}
onChange={e => setTplModal(p => ({ ...p, label: e.target.value }))}
placeholder="z.B. Passwort-Reset"
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">Ticket-Titel</label>
<input
className="form-input"
value={tplModal.title}
onChange={e => setTplModal(p => ({ ...p, title: e.target.value }))}
placeholder="z.B. Passwort zurücksetzen"
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group">
<label className="form-label">Kategorie</label>
<select
className="form-select"
value={tplModal.category}
onChange={e => setTplModal(p => ({ ...p, category: e.target.value }))}
>
{categories.map(c => (
<option key={c.id} value={c.name}>{c.icon} {c.name}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Priorität</label>
<select
className="form-select"
value={tplModal.priority}
onChange={e => setTplModal(p => ({ ...p, priority: e.target.value }))}
>
{PRIORITIES.map(p => (
<option key={p} value={p}>{PRIORITY_ICONS[p]} {p.charAt(0).toUpperCase() + p.slice(1)}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Beschreibungs-Vorlage</label>
<textarea
className="form-input"
value={tplModal.description}
onChange={e => setTplModal(p => ({ ...p, description: e.target.value }))}
rows={6}
placeholder="Vorlage-Text... (Leerzeilen als Platzhalter für den Nutzer)"
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '13px' }}
/>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '8px' }}>
<button style={btnOutline} onClick={() => setTplModal(null)}>Abbrechen</button>
<button style={btnPrimary} onClick={saveTemplate} disabled={tplLoading || !tplModal.label?.trim()}>
{tplLoading ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* ── EMAIL TEMPLATE MODAL ──────────────────────────────────────── */}
{emailModal && (() => {
const vars = EMAIL_TEMPLATE_VARS[emailModal.type] || [];
const meta = EMAIL_TEMPLATE_LABELS[emailModal.type] || { label: emailModal.type };
return (
<div className="modal-overlay" onClick={() => setEmailModal(null)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{meta.label} bearbeiten</h2>
<button className="modal-close" onClick={() => setEmailModal(null)}>×</button>
</div>
{/* Variable chips */}
{vars.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<label className="form-label" style={{ marginBottom: '6px', display: 'block' }}>
Verfügbare Variablen <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(klicken zum Einfügen)</span>
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{vars.map(v => (
<button
key={v}
onClick={() => insertVar(v)}
style={{
background: 'var(--cereda-primary)15',
border: '1px solid var(--cereda-primary)40',
borderRadius: '6px',
padding: '3px 10px',
fontSize: '12px',
color: 'var(--cereda-primary)',
cursor: 'pointer',
fontFamily: 'monospace',
}}
>
{`{{${v}}}`}
</button>
))}
</div>
</div>
)}
<div className="form-group">
<label className="form-label">Betreff</label>
<input
ref={emailSubjectRef}
className="form-input"
value={emailModal.subject}
onChange={e => setEmailModal(m => ({ ...m, subject: e.target.value }))}
onFocus={() => setLastFocused('subject')}
placeholder="E-Mail Betreff"
/>
</div>
<div className="form-group">
<label className="form-label">Einleitungstext</label>
<textarea
ref={emailIntroRef}
className="form-input"
value={emailModal.intro}
onChange={e => setEmailModal(m => ({ ...m, intro: e.target.value }))}
onFocus={() => setLastFocused('intro')}
rows={5}
placeholder="Einleitungstext der E-Mail..."
style={{ resize: 'vertical', fontSize: '13px', lineHeight: 1.6 }}
/>
<p style={{ color: 'var(--text-muted)', fontSize: '12px', margin: '4px 0 0' }}>
Zeilenumbrüche werden in der E-Mail übernommen. Kein HTML nötig.
</p>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'space-between', marginTop: '8px' }}>
<button style={btnDanger} onClick={() => resetEmailTpl(emailModal.type)}>
Auf Standard zurücksetzen
</button>
<div style={{ display: 'flex', gap: '12px' }}>
<button style={btnOutline} onClick={() => setEmailModal(null)}>Abbrechen</button>
<button
style={btnPrimary}
onClick={saveEmailTpl}
disabled={emailLoading || !emailModal.subject?.trim() || !emailModal.intro?.trim()}
>
{emailLoading ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
</div>
);
})()}
</div>
);
}
// ── Asset Types Tab ───────────────────────────────────────────────────────────
function AssetTypesTab() {
const [types, setTypes] = useState([]);
const [modal, setModal] = useState(null); // null | { id?, name, icon }
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(null);
const load = useCallback(async () => {
const data = await assetService.getTypes();
setTypes(data);
}, []);
useEffect(() => { load(); }, [load]);
const openNew = () => setModal({ name: '', icon: 'devices_other' });
const openEdit = (t) => setModal({ ...t });
const save = async () => {
if (!modal?.name?.trim()) return;
setSaving(true);
try {
if (modal.id) {
await assetService.updateType(modal.id, { name: modal.name.trim(), icon: modal.icon });
} else {
await assetService.createType({ name: modal.name.trim(), icon: modal.icon });
}
setModal(null);
await load();
} catch (e) {
alert(e.response?.data?.error || 'Fehler beim Speichern');
} finally {
setSaving(false);
}
};
const remove = async (id) => {
if (!window.confirm('Typ wirklich löschen?')) return;
setDeleting(id);
try {
await assetService.deleteType(id);
await load();
} finally {
setDeleting(null);
}
};
const card = { background: 'var(--bg-card)', borderRadius: '12px', border: '1px solid var(--border-color)', overflow: 'hidden' };
const row = { display: 'flex', alignItems: 'center', padding: '12px 16px', borderBottom: '1px solid var(--border-color)', gap: '12px' };
const btnPrimary = { background: 'var(--cereda-primary)', color: '#fff', border: 'none', borderRadius: '8px', padding: '8px 16px', cursor: 'pointer', fontSize: '13px', fontWeight: 600 };
const btnDanger = { background: 'rgba(239,68,68,0.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '8px', padding: '6px 12px', cursor: 'pointer', fontSize: '12px' };
const btnOutline = { background: 'transparent', color: 'var(--cereda-primary)', border: '1px solid var(--cereda-primary)', borderRadius: '8px', padding: '6px 12px', cursor: 'pointer', fontSize: '12px' };
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
Asset-Typen verwalten werden in allen Dropdowns angezeigt.
</p>
<button style={btnPrimary} onClick={openNew}>+ Neuer Typ</button>
</div>
<div style={card}>
{types.length === 0 && (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-muted)' }}>Keine Typen vorhanden</div>
)}
{types.map((t, i) => (
<div key={t.id} style={{ ...row, borderBottom: i < types.length - 1 ? '1px solid var(--border-color)' : 'none' }}>
<span style={{ fontSize: '20px', width: '28px', textAlign: 'center' }}>🖥</span>
<span style={{ flex: 1, fontWeight: 600, color: 'var(--text-primary)', fontSize: '14px' }}>{t.name}</span>
<button style={btnOutline} onClick={() => openEdit(t)}>Bearbeiten</button>
<button style={btnDanger} disabled={deleting === t.id} onClick={() => remove(t.id)}>
{deleting === t.id ? '...' : 'Löschen'}
</button>
</div>
))}
</div>
{modal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: 'var(--bg-modal)', borderRadius: '16px', padding: '24px', width: '360px', border: '1px solid var(--border-color)' }}>
<h3 style={{ margin: '0 0 20px', color: 'var(--text-primary)' }}>{modal.id ? 'Typ bearbeiten' : 'Neuer Asset-Typ'}</h3>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: 'var(--text-secondary)', fontWeight: 600 }}>Name *</label>
<input
className="form-input"
value={modal.name}
onChange={e => setModal(m => ({ ...m, name: e.target.value }))}
placeholder="z.B. Notebook"
autoFocus
style={{ marginBottom: '20px' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button style={btnOutline} onClick={() => setModal(null)}>Abbrechen</button>
<button style={btnPrimary} disabled={saving || !modal.name?.trim()} onClick={save}>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,360 @@
import React, { useEffect, useState, useRef } from 'react';
import { createShare, getAllShares, deleteShare } from '../services/shareService';
const EXPIRY_OPTIONS = [
{ label: 'Kein Ablauf', value: '' },
{ label: '1 Stunde', value: '1' },
{ label: '4 Stunden', value: '4' },
{ label: '24 Stunden', value: '24' },
{ label: '3 Tage', value: '72' },
{ label: '7 Tage', value: '168' },
{ label: '30 Tage', value: '720' },
];
const DOWNLOAD_OPTIONS = [
{ label: 'Unbegrenzt', value: '' },
{ label: '1×', value: '1' },
{ label: '5×', value: '5' },
{ label: '10×', value: '10' },
{ label: '25×', value: '25' },
];
const formatDate = (d) => d ? new Date(d + 'Z').toLocaleString('de-DE') : '—';
const isExpired = (s) => {
if (s.expires_at && new Date(s.expires_at + 'Z') < new Date()) return true;
if (s.max_downloads && s.download_count >= s.max_downloads) return true;
return false;
};
export default function SharesPage() {
const [shares, setShares] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [copiedId, setCopiedId] = useState(null);
const [form, setForm] = useState({
type: 'text',
text_content: '',
password: '',
expires_in_hours: '',
max_downloads: '',
});
const [file, setFile] = useState(null);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState('');
const [newLink, setNewLink] = useState('');
const fileRef = useRef();
const load = async () => {
try {
setLoading(true);
setShares(await getAllShares());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleCreate = async (e) => {
e.preventDefault();
setCreateError('');
setCreating(true);
try {
const fd = new FormData();
fd.append('type', form.type);
if (form.type === 'text') fd.append('text_content', form.text_content);
if (form.type === 'file' && file) fd.append('file', file);
if (form.password) fd.append('password', form.password);
if (form.expires_in_hours) fd.append('expires_in_hours', form.expires_in_hours);
if (form.max_downloads) fd.append('max_downloads', form.max_downloads);
const result = await createShare(fd);
setNewLink(result.public_url);
await load();
} catch (e) {
setCreateError(e.message);
} finally {
setCreating(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Share wirklich löschen?')) return;
await deleteShare(id);
setShares(prev => prev.filter(s => s.id !== id));
};
const copyLink = (url, id) => {
navigator.clipboard.writeText(url);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const resetModal = () => {
setShowModal(false);
setForm({ type: 'text', text_content: '', password: '', expires_in_hours: '', max_downloads: '' });
setFile(null);
setNewLink('');
setCreateError('');
if (fileRef.current) fileRef.current.value = '';
};
return (
<div style={{ padding: '24px', maxWidth: 1000, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>🔗 Sichere Links</h1>
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 14 }}>
Dateien und Texte sicher teilen mit Ablauf und Passwortschutz
</p>
</div>
<button
onClick={() => setShowModal(true)}
style={{
background: '#3B82F6', color: '#fff', border: 'none',
borderRadius: 8, padding: '10px 18px', cursor: 'pointer', fontWeight: 600,
}}
>
+ Neuer Link
</button>
</div>
{loading ? (
<p style={{ color: '#6B7280' }}>Lädt</p>
) : shares.length === 0 ? (
<div style={{ textAlign: 'center', padding: 60, color: '#9CA3AF' }}>
<div style={{ fontSize: 48 }}>🔗</div>
<p>Noch keine Links erstellt.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{shares.map(s => {
const expired = isExpired(s);
return (
<div key={s.id} style={{
background: 'var(--bg-secondary, #F9FAFB)',
border: '1px solid var(--border, #E5E7EB)',
borderRadius: 10,
padding: '14px 18px',
display: 'flex',
alignItems: 'center',
gap: 16,
opacity: expired ? 0.6 : 1,
}}>
<span style={{ fontSize: 24 }}>{s.type === 'file' ? '📄' : '📝'}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>
{s.type === 'file' ? s.filename : 'Text-Share'}
{expired && <span style={{ marginLeft: 8, fontSize: 11, color: '#EF4444', fontWeight: 700 }}>ABGELAUFEN</span>}
{s.password_hash && <span style={{ marginLeft: 6, fontSize: 11, color: '#6B7280' }}>🔒</span>}
</div>
<div style={{ fontSize: 12, color: '#6B7280' }}>
Erstellt: {formatDate(s.created_at)}
{s.expires_at && ` · Läuft ab: ${formatDate(s.expires_at)}`}
{s.max_downloads && ` · Downloads: ${s.download_count}/${s.max_downloads}`}
{s.created_by_username && ` · Von: ${s.created_by_username}`}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => copyLink(s.public_url, s.id)}
style={{
background: copiedId === s.id ? '#10B981' : '#E5E7EB',
color: copiedId === s.id ? '#fff' : '#374151',
border: 'none', borderRadius: 6, padding: '6px 12px',
cursor: 'pointer', fontSize: 13, fontWeight: 500,
}}
>
{copiedId === s.id ? '✓ Kopiert' : '🔗 Link kopieren'}
</button>
<button
onClick={() => handleDelete(s.id)}
style={{
background: '#FEE2E2', color: '#DC2626',
border: 'none', borderRadius: 6, padding: '6px 10px',
cursor: 'pointer', fontSize: 13,
}}
>
🗑
</button>
</div>
</div>
);
})}
</div>
)}
{showModal && (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000, padding: 20,
}}>
<div style={{
background: 'var(--bg-primary, #fff)', borderRadius: 12,
padding: 28, width: '100%', maxWidth: 480,
maxHeight: '90vh', overflowY: 'auto',
}}>
{newLink ? (
<>
<h2 style={{ margin: '0 0 16px', fontSize: 18 }}> Link erstellt</h2>
<p style={{ fontSize: 13, color: '#6B7280', marginBottom: 8 }}>
Kopiere diesen Link und teile ihn mit der Zielperson:
</p>
<div style={{
background: '#F3F4F6', borderRadius: 8, padding: '10px 14px',
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
marginBottom: 16,
}}>
{newLink}
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={() => { navigator.clipboard.writeText(newLink); }}
style={{
flex: 1, background: '#3B82F6', color: '#fff',
border: 'none', borderRadius: 8, padding: '10px',
cursor: 'pointer', fontWeight: 600,
}}
>
🔗 Link kopieren
</button>
<button
onClick={resetModal}
style={{
flex: 1, background: '#E5E7EB', color: '#374151',
border: 'none', borderRadius: 8, padding: '10px',
cursor: 'pointer', fontWeight: 600,
}}
>
Schließen
</button>
</div>
</>
) : (
<form onSubmit={handleCreate}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Neuer sicherer Link</h2>
<button type="button" onClick={resetModal}
style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#6B7280' }}>×</button>
</div>
{/* Typ */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{['text', 'file'].map(t => (
<button key={t} type="button"
onClick={() => setForm(f => ({ ...f, type: t }))}
style={{
flex: 1, padding: '8px',
borderRadius: 8, border: '2px solid',
borderColor: form.type === t ? '#3B82F6' : '#E5E7EB',
background: form.type === t ? '#EFF6FF' : 'transparent',
color: form.type === t ? '#1D4ED8' : '#374151',
cursor: 'pointer', fontWeight: 600, fontSize: 14,
}}>
{t === 'text' ? '📝 Text' : '📄 Datei'}
</button>
))}
</div>
{/* Inhalt */}
{form.type === 'text' ? (
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>Text / Inhalt *</label>
<textarea
value={form.text_content}
onChange={e => setForm(f => ({ ...f, text_content: e.target.value }))}
required rows={5}
style={{
width: '100%', borderRadius: 8, border: '1px solid #D1D5DB',
padding: '8px 12px', fontSize: 14, resize: 'vertical',
boxSizing: 'border-box', fontFamily: 'inherit',
}}
placeholder="Passwort, Zugangsdaten, Notiz…"
/>
</div>
) : (
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>Datei *</label>
<input ref={fileRef} type="file" required
onChange={e => setFile(e.target.files[0])}
style={{ width: '100%' }} />
<div style={{ fontSize: 12, color: '#9CA3AF', marginTop: 4 }}>Max. 100 MB</div>
</div>
)}
{/* Passwort */}
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
Passwortschutz <span style={{ color: '#9CA3AF', fontWeight: 400 }}>(optional)</span>
</label>
<input type="password" value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
placeholder="Leer = kein Passwort"
style={{
width: '100%', borderRadius: 8, border: '1px solid #D1D5DB',
padding: '8px 12px', fontSize: 14, boxSizing: 'border-box',
}} />
</div>
{/* Ablauf + Downloads */}
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>Ablauf</label>
<select value={form.expires_in_hours}
onChange={e => setForm(f => ({ ...f, expires_in_hours: e.target.value }))}
style={{
width: '100%', borderRadius: 8, border: '1px solid #D1D5DB',
padding: '8px 12px', fontSize: 14, boxSizing: 'border-box',
}}>
{EXPIRY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>Max. Downloads</label>
<select value={form.max_downloads}
onChange={e => setForm(f => ({ ...f, max_downloads: e.target.value }))}
style={{
width: '100%', borderRadius: 8, border: '1px solid #D1D5DB',
padding: '8px 12px', fontSize: 14, boxSizing: 'border-box',
}}>
{DOWNLOAD_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
{createError && (
<div style={{ background: '#FEF2F2', color: '#DC2626', borderRadius: 8, padding: '10px 14px', marginBottom: 14, fontSize: 13 }}>
{createError}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}>
<button type="submit" disabled={creating}
style={{
flex: 1, background: creating ? '#93C5FD' : '#3B82F6',
color: '#fff', border: 'none', borderRadius: 8,
padding: '10px', cursor: creating ? 'not-allowed' : 'pointer',
fontWeight: 600,
}}>
{creating ? 'Erstellt…' : '🔗 Link erstellen'}
</button>
<button type="button" onClick={resetModal}
style={{
background: '#E5E7EB', color: '#374151',
border: 'none', borderRadius: 8, padding: '10px 16px',
cursor: 'pointer', fontWeight: 600,
}}>
Abbrechen
</button>
</div>
</form>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
const SystemPage = () => {
const navigate = useNavigate();
const cards = [
{
icon: '🩺',
title: 'Server Health',
description: 'Echtzeit-Status aller Services — API, Datenbank, Auth und Antwortzeit',
path: '/health',
color: 'var(--success)',
},
{
icon: '🔧',
title: 'Wartungskalender',
description: 'Überfällige und anstehende Asset-Prüfungen gruppiert nach Dringlichkeit',
path: '/maintenance',
color: 'var(--warning)',
},
{
icon: '⚙️',
title: 'Einstellungen',
description: 'Ticket-Kategorien und Vorlagen verwalten — anpassen, hinzufügen, löschen',
path: '/settings',
color: 'var(--cereda-accent)',
},
{
icon: '📖',
title: 'API Dokumentation',
description: '78 Endpunkte in 9 Kategorien — durchsuchen, aufklappen, testen',
path: '/api-docs',
color: 'var(--cereda-primary)',
},
{
icon: '📄',
title: 'Dokumentation',
description: 'Vollständige IT Nexus Systemdokumentation — Rollen, Features, Workflows',
path: '/docs',
color: 'var(--warning)',
},
];
return (
<div style={{ padding: '40px 24px', maxWidth: '800px', margin: '0 auto' }}>
<div style={{ marginBottom: '32px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '700', color: 'var(--text-primary)', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
System
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', marginTop: '6px' }}>
Administration und Systeminformationen
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '16px' }}>
{cards.map(card => (
<button
key={card.path}
onClick={() => navigate(card.path)}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-xl)',
padding: '28px 24px',
textAlign: 'left',
cursor: 'pointer',
transition: 'var(--transition)',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = card.color;
e.currentTarget.style.boxShadow = `0 4px 20px ${card.color}22`;
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--border-color)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div style={{
width: '52px',
height: '52px',
borderRadius: '14px',
background: `${card.color}20`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
}}>
{card.icon}
</div>
<div>
<div style={{ fontWeight: '700', fontSize: '16px', color: 'var(--text-primary)', marginBottom: '4px' }}>
{card.title}
</div>
<div style={{ fontSize: '13px', color: 'var(--text-muted)', lineHeight: '1.5' }}>
{card.description}
</div>
</div>
<div style={{ marginTop: 'auto', color: card.color, fontSize: '13px', fontWeight: '600' }}>
Öffnen
</div>
</button>
))}
</div>
</div>
);
};
export default SystemPage;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import ticketService from '../services/ticketService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const PRIO_COLOR = {
niedrig: 'var(--success)',
mittel: 'var(--info)',
hoch: 'var(--warning)',
kritisch:'var(--danger)',
};
const PRIO_LABEL = { niedrig: 'Niedrig', mittel: 'Mittel', hoch: 'Hoch', kritisch: 'Kritisch' };
const MetricCard = ({ icon, value, label, sub, color }) => (
<div style={{
background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: '12px',
padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: '4px',
}}>
<div style={{ fontSize: '1.5rem', marginBottom: '4px' }}>{icon}</div>
<div style={{ fontSize: '2rem', fontWeight: 800, color: color || 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--text-primary)' }}>{label}</div>
{sub && <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{sub}</div>}
</div>
);
const BarChart = ({ data, labelKey, valueKey, color = 'var(--cereda-primary)', maxItems = 30 }) => {
if (!data || data.length === 0) return <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>Keine Daten</div>;
const max = Math.max(...data.map(d => d[valueKey] || 0), 1);
const items = data.slice(-maxItems);
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '4px', height: '120px', padding: '0 4px' }}>
{items.map((d, i) => {
const h = Math.round(((d[valueKey] || 0) / max) * 100);
return (
<div key={i} title={`${d[labelKey]}: ${d[valueKey]}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px', height: '100%', justifyContent: 'flex-end', minWidth: 0 }}>
<div style={{ width: '100%', height: `${h}%`, background: color, borderRadius: '3px 3px 0 0', minHeight: d[valueKey] ? '4px' : 0, opacity: 0.85 }} />
{items.length <= 10 && (
<div style={{ fontSize: '9px', color: 'var(--text-muted)', textAlign: 'center', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', width: '100%' }}>
{String(d[labelKey]).slice(-5)}
</div>
)}
</div>
);
})}
</div>
);
};
const TicketMetricsPage = () => {
const navigate = useNavigate();
const [metrics, setMetrics] = useState(null);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState(30);
useEffect(() => {
loadMetrics();
}, [days]);
const loadMetrics = async () => {
setLoading(true);
try {
const data = await ticketService.getMetrics(days);
setMetrics(data);
} catch {
toast.error('Metriken konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
if (loading) return <LoadingSpinner />;
if (!metrics) return null;
const totalCreated = metrics.perDay.reduce((s, d) => s + d.created, 0);
const totalResolved = metrics.perDay.reduce((s, d) => s + d.resolved, 0);
const resolutionRate = totalCreated > 0 ? Math.round((totalResolved / totalCreated) * 100) : 0;
const totalSlaBreaches = metrics.slaBreaches.reduce((s, d) => s + d.count, 0);
const avgHours = metrics.avgResolution.find(r => r.priority === 'mittel')?.avg_hours || '';
return (
<div className="main-content">
<div className="container">
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<button className="btn btn-secondary btn-small" onClick={() => navigate('/tickets')} style={{ fontSize: '0.8125rem' }}>
Tickets
</button>
</div>
<h1 style={{ margin: 0, fontSize: '1.75rem', fontWeight: 800, color: 'var(--text-primary)' }}>📊 Ticket-Metriken</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Analyse der letzten {days} Tage
</p>
</div>
<div style={{ display: 'flex', border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden' }}>
{[7, 14, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)} style={{
padding: '6px 14px', border: 'none', cursor: 'pointer', fontSize: '0.8125rem',
background: days === d ? 'var(--cereda-primary)' : 'var(--bg-secondary)',
color: days === d ? 'white' : 'var(--text-secondary)',
}}>
{d}T
</button>
))}
</div>
</div>
{/* KPI-Karten */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '2rem' }}>
<MetricCard icon="📋" value={totalCreated} label="Erstellt" sub={`letzte ${days} Tage`} />
<MetricCard icon="✅" value={totalResolved} label="Gelöst" sub={`letzte ${days} Tage`} color="var(--success)" />
<MetricCard icon="📈" value={`${resolutionRate}%`} label="Lösungsrate" color={resolutionRate >= 70 ? 'var(--success)' : resolutionRate >= 40 ? 'var(--warning)' : 'var(--danger)'} />
<MetricCard icon="🚨" value={totalSlaBreaches} label="SLA-Verletzungen" sub="offen & überfällig" color={totalSlaBreaches > 0 ? 'var(--danger)' : 'var(--success)'} />
</div>
{/* Tickets pro Tag */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '16px', marginBottom: '2rem' }}>
<div className="card" style={{ padding: '20px 24px' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
Tickets pro Tag
</h3>
{metrics.perDay.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>Keine Daten für diesen Zeitraum</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '3px', height: '120px', marginBottom: '8px' }}>
{metrics.perDay.slice(-days).map((d, i) => {
const maxVal = Math.max(...metrics.perDay.map(x => x.created), 1);
const hC = Math.round((d.created / maxVal) * 100);
const hR = Math.round((d.resolved / maxVal) * 100);
return (
<div key={i} title={`${d.date}: ${d.created} erstellt, ${d.resolved} gelöst`}
style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: '1px', height: '100%', justifyContent: 'center', minWidth: 0 }}>
<div style={{ flex: 1, height: `${hC}%`, background: 'var(--cereda-primary)', borderRadius: '2px 2px 0 0', opacity: 0.8, minHeight: d.created ? '3px' : 0 }} />
<div style={{ flex: 1, height: `${hR}%`, background: 'var(--success)', borderRadius: '2px 2px 0 0', opacity: 0.8, minHeight: d.resolved ? '3px' : 0 }} />
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: '16px', justifyContent: 'flex-end', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
<span><span style={{ display: 'inline-block', width: '10px', height: '10px', borderRadius: '2px', background: 'var(--cereda-primary)', marginRight: '4px', verticalAlign: 'middle' }} />Erstellt</span>
<span><span style={{ display: 'inline-block', width: '10px', height: '10px', borderRadius: '2px', background: 'var(--success)', marginRight: '4px', verticalAlign: 'middle' }} />Gelöst</span>
</div>
</>
)}
</div>
{/* Nach Kategorie */}
<div className="card" style={{ padding: '20px 24px' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
Nach Kategorie
</h3>
{metrics.byCategory.map(cat => {
const pct = cat.count > 0 ? Math.round((cat.open / cat.count) * 100) : 0;
const total = metrics.byCategory.reduce((s, c) => s + c.count, 0);
const barW = total > 0 ? Math.round((cat.count / total) * 100) : 0;
return (
<div key={cat.category} style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', fontSize: '0.8125rem' }}>
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{cat.category}</span>
<span style={{ color: 'var(--text-muted)' }}>{cat.count} ({pct}% offen)</span>
</div>
<div style={{ height: '6px', background: 'var(--bg-tertiary)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${barW}%`, background: 'var(--cereda-primary)', borderRadius: '3px', transition: 'width 0.3s' }} />
</div>
</div>
);
})}
</div>
</div>
{/* Kundenfeedback */}
{metrics.satisfactionTotal && (
<div style={{ marginBottom: '2rem' }}>
<h2 style={{ margin: '0 0 16px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Kundenfeedback</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 2fr', gap: '16px' }}>
<MetricCard icon="👍" value={metrics.satisfactionTotal.gut} label="Positiv" color="var(--success)" />
<MetricCard icon="👎" value={metrics.satisfactionTotal.schlecht} label="Negativ" color="var(--danger)" />
<MetricCard
icon="📊"
value={metrics.satisfactionTotal.total > 0
? `${Math.round((metrics.satisfactionTotal.gut / metrics.satisfactionTotal.total) * 100)}%`
: ''}
label="Zufriedenheitsrate"
color={metrics.satisfactionTotal.total > 0
? (metrics.satisfactionTotal.gut / metrics.satisfactionTotal.total) >= 0.7 ? 'var(--success)' : 'var(--warning)'
: 'var(--text-muted)'}
sub={`${metrics.satisfactionTotal.total} Bewertungen gesamt`}
/>
{/* Letzte Kommentare */}
<div className="card" style={{ padding: '20px 24px' }}>
<h3 style={{ margin: '0 0 14px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
Letzte Kommentare
</h3>
{metrics.recentFeedback.filter(f => f.satisfaction_comment).length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem', textAlign: 'center', padding: '1.5rem 0' }}>
Noch keine Kommentare
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '200px', overflowY: 'auto' }}>
{metrics.recentFeedback.filter(f => f.satisfaction_comment).map((f, i) => (
<div key={i} style={{
padding: '10px 14px',
borderRadius: '8px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderLeft: `3px solid ${f.satisfaction_rating === 'gut' ? 'var(--success)' : 'var(--danger)'}`,
fontSize: '0.8125rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '5px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 600, color: 'var(--text-primary)' }}>
<span style={{ fontSize: '0.9rem' }}>{f.satisfaction_rating === 'gut' ? '👍' : '👎'}</span>
{f.requester_name || f.requester_email || ''}
</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', fontFamily: 'monospace', background: 'var(--bg-secondary)', padding: '2px 6px', borderRadius: '4px' }}>
{f.ticket_number}
</span>
</div>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5, fontSize: '0.8rem' }}>{f.satisfaction_comment}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* Untere Reihe */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px' }}>
{/* Durchschnittliche Lösungszeit */}
<div className="card" style={{ padding: '20px 24px' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
Ø Lösungszeit
</h3>
{metrics.avgResolution.length === 0 ? (
<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>Noch keine gelösten Tickets</div>
) : (
metrics.avgResolution.map(r => (
<div key={r.priority} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px', padding: '8px 12px', background: 'var(--bg-tertiary)', borderRadius: '8px', border: `1px solid ${PRIO_COLOR[r.priority]}33` }}>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, color: PRIO_COLOR[r.priority] }}>
{PRIO_LABEL[r.priority] || r.priority}
</span>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '1rem', fontWeight: 800, color: 'var(--text-primary)' }}>
{r.avg_hours !== null ? `${r.avg_hours}h` : ''}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)' }}>{r.count} Tickets</div>
</div>
</div>
))
)}
</div>
{/* Top-Bearbeiter */}
<div className="card" style={{ padding: '20px 24px' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
Top-Bearbeiter
</h3>
{metrics.topAssignees.length === 0 ? (
<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>Keine zugewiesenen Tickets</div>
) : (
metrics.topAssignees.map((a, i) => {
const name = a.first_name ? `${a.first_name} ${a.last_name || ''}`.trim() : a.username;
const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().substring(0, 2);
const rate = a.total > 0 ? Math.round((a.resolved / a.total) * 100) : 0;
return (
<div key={a.username} style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px' }}>
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', width: '16px' }}>#{i + 1}</span>
<div style={{ width: '32px', height: '32px', borderRadius: '50%', background: 'linear-gradient(135deg, var(--cereda-primary), var(--cereda-accent))', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px', fontWeight: 700, color: 'white', flexShrink: 0 }}>
{initials}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--text-primary)' }}>{name}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{a.resolved}/{a.total} gelöst ({rate}%)</div>
</div>
</div>
);
})
)}
</div>
{/* SLA-Verletzungen */}
<div className="card" style={{ padding: '20px 24px' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
SLA-Verletzungen (offen)
</h3>
{metrics.slaBreaches.length === 0 ? (
<div style={{ textAlign: 'center', padding: '1rem' }}>
<div style={{ fontSize: '2rem', marginBottom: '8px' }}></div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Keine SLA-Verletzungen!</div>
</div>
) : (
metrics.slaBreaches.map(b => (
<div key={b.priority} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px', padding: '10px 12px', background: 'rgba(239,68,68,0.06)', borderRadius: '8px', border: '1px solid rgba(239,68,68,0.2)' }}>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, color: PRIO_COLOR[b.priority] }}>
{PRIO_LABEL[b.priority] || b.priority}
</span>
<span style={{ fontSize: '1.125rem', fontWeight: 800, color: 'var(--danger)' }}>{b.count}</span>
</div>
))
)}
</div>
</div>
</div>
</div>
);
};
export default TicketMetricsPage;

View File

@@ -0,0 +1,786 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import ticketService from '../services/ticketService';
import userService from '../services/userService';
import assetService from '../services/assetService';
import api from '../services/api';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const PRIORITY_ICONS = { niedrig: '🟢', mittel: '🔵', hoch: '🟠', kritisch: '🔴' };
const STATUS_CONFIG = {
offen: { label: 'Offen', css: 'status-pending' },
in_bearbeitung: { label: 'In Bearbeitung', css: 'status-in_progress' },
warten_auf_mitarbeiter: { label: 'Warten auf Mitarbeiter', css: 'status-completed' },
warten_auf_support: { label: 'Warten auf Support', css: 'status-warning' },
geschlossen: { label: 'Geschlossen', css: 'status-inaktiv' },
};
const PRIORITY_CONFIG = {
niedrig: { label: 'Niedrig', color: 'var(--success)' },
mittel: { label: 'Mittel', color: 'var(--info)' },
hoch: { label: 'Hoch', color: 'var(--warning)' },
kritisch:{ label: 'Kritisch',color: 'var(--danger)' },
};
const StatCard = ({ icon, value, label, variant }) => (
<div className={`stat-card ${variant}`} style={{ cursor: 'default' }}>
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
</div>
</div>
);
// SQLite gibt Timestamps ohne Timezone zurück → als UTC parsen
const toUTC = (d) => d ? new Date(typeof d === 'string' && !d.endsWith('Z') && !d.includes('+') ? d.replace(' ', 'T') + 'Z' : d) : null;
const formatDate = (d) => {
if (!d) return '';
const date = toUTC(d);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
const SLA_WARN = { kritisch: 4, hoch: 24, mittel: 72, niedrig: 168 };
const SLA_CRIT = { kritisch: 24, hoch: 48, mittel: 168, niedrig: 336 };
const getSlaInfo = (ticket) => {
if (ticket.status === 'geschlossen') return null;
const ageH = (Date.now() - toUTC(ticket.created_at)) / 3600000;
const warn = SLA_WARN[ticket.priority] || 72;
const crit = SLA_CRIT[ticket.priority] || 168;
if (ageH >= crit) return { color: '#ef4444', bg: 'rgba(239,68,68,0.12)', label: ageH < 48 ? `${Math.round(ageH)}h` : `${Math.floor(ageH / 24)}T` };
if (ageH >= warn) return { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', label: ageH < 48 ? `${Math.round(ageH)}h` : `${Math.floor(ageH / 24)}T` };
return null;
};
// Kanban-Spalten (4 Spalten geschlossen nur in Wissensdatenbank)
const KANBAN_COLS = [
{ key: 'offen', label: 'Offen', css: 'status-pending' },
{ key: 'in_bearbeitung', label: 'In Bearbeitung', css: 'status-in_progress' },
{ key: 'warten_auf_mitarbeiter', label: 'Warten auf Mitarbeiter', css: 'status-completed' },
{ key: 'warten_auf_support', label: 'Warten auf Support', css: 'status-warning' },
];
// Kanban-Karte
const KanbanCard = ({ ticket, onClick, onStatusChange, onClose, onDragStart, onDragEnd, isDragging }) => {
const prioConf = PRIORITY_CONFIG[ticket.priority] || {};
const slaInfo = getSlaInfo(ticket);
const assignee = ticket.assigned_to_first_name
? `${ticket.assigned_to_first_name} ${ticket.assigned_to_last_name || ''}`.trim()
: ticket.assigned_to_username;
return (
<div
draggable
onDragStart={(e) => { e.dataTransfer.effectAllowed = 'move'; onDragStart(ticket.id); }}
onDragEnd={onDragEnd}
onClick={() => onClick(ticket.id)}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderLeft: `3px solid ${prioConf.color}`,
borderRadius: '8px',
padding: '12px',
marginBottom: '8px',
cursor: 'grab',
transition: 'box-shadow 0.15s, opacity 0.15s',
opacity: isDragging ? 0.4 : 1,
userSelect: 'none',
}}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: 700, color: 'var(--cereda-primary)' }}>
{ticket.ticket_number}
</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{slaInfo && (
<span style={{ fontSize: '10px', fontWeight: 700, padding: '1px 5px', borderRadius: '4px', background: slaInfo.bg, color: slaInfo.color }}>
{slaInfo.label}
</span>
)}
<span style={{ fontSize: '14px' }}>{PRIORITY_ICONS[ticket.priority]}</span>
</div>
</div>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '8px', lineHeight: 1.4 }}>
{ticket.title}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
<span>{ticket.category}</span>
{assignee && (
<div style={{ width: '22px', height: '22px', borderRadius: '50%', background: 'linear-gradient(135deg, var(--cereda-primary), var(--cereda-accent))', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '9px', fontWeight: 700, color: 'white' }}>
{assignee.split(' ').map(n => n[0]).join('').toUpperCase().substring(0, 2)}
</div>
)}
</div>
{/* Schnell-Status-Änderung */}
<div style={{ display: 'flex', gap: '4px', marginTop: '8px', flexWrap: 'wrap' }} onClick={e => e.stopPropagation()}>
{KANBAN_COLS.filter(c => c.key !== ticket.status).map(c => (
<button
key={c.key}
onClick={() => onStatusChange(ticket.id, c.key)}
style={{
fontSize: '10px', padding: '2px 6px', borderRadius: '4px',
border: '1px solid var(--border-color)', background: 'var(--bg-tertiary)',
color: 'var(--text-muted)', cursor: 'pointer',
}}
onMouseEnter={e => { e.target.style.background = 'var(--bg-secondary)'; e.target.style.color = 'var(--text-primary)'; }}
onMouseLeave={e => { e.target.style.background = 'var(--bg-tertiary)'; e.target.style.color = 'var(--text-muted)'; }}
>
{c.label}
</button>
))}
<button
onClick={() => onClose(ticket.id)}
style={{
fontSize: '10px', padding: '2px 6px', borderRadius: '4px',
border: '1px solid rgba(239,68,68,0.4)', background: 'rgba(239,68,68,0.08)',
color: 'var(--danger)', cursor: 'pointer',
}}
onMouseEnter={e => { e.target.style.background = 'rgba(239,68,68,0.18)'; }}
onMouseLeave={e => { e.target.style.background = 'rgba(239,68,68,0.08)'; }}
>
Schließen
</button>
</div>
</div>
);
};
const TicketsPage = () => {
const navigate = useNavigate();
const [tickets, setTickets] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [assets, setAssets] = useState([]);
const [users, setUsers] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [categories, setCategories] = useState([]);
const [templates, setTemplates] = useState([]);
const [statusFilter, setStatusFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [viewMode, setViewMode] = useState(() => localStorage.getItem('ticketsViewMode') || 'table');
// Drag & Drop
const [draggedId, setDraggedId] = useState(null);
const [dragOverCol, setDragOverCol] = useState(null);
// Schließen-Modal
const [closeModal, setCloseModal] = useState({ open: false, ticketId: null });
const [closeText, setCloseText] = useState('');
const [closeLoading,setCloseLoading] = useState(false);
// Bulk-Aktionen
const [selectedIds, setSelectedIds] = useState(new Set());
const [bulkAction, setBulkAction] = useState('');
const [bulkAssignee,setBulkAssignee] = useState('');
const [bulkLoading, setBulkLoading] = useState(false);
const [formData, setFormData] = useState({
title: '', description: '', category: 'Allgemein',
priority: 'mittel', asset_id: '', requester_name: '', requester_email: '',
});
const [formLoading, setFormLoading] = useState(false);
useEffect(() => { loadAll(); }, [statusFilter, priorityFilter, categoryFilter, searchTerm]);
// Auto-refresh alle 30 Sekunden
useEffect(() => {
const interval = setInterval(() => { loadAll(); }, 30000);
return () => clearInterval(interval);
}, [statusFilter, priorityFilter, categoryFilter, searchTerm]);
useEffect(() => {
if (showModal && assets.length === 0) {
assetService.getAll().then(setAssets).catch(() => {});
}
}, [showModal]);
useEffect(() => {
if (users.length === 0) {
userService.getAll().then(u => setUsers(u.filter(x => ['super_admin','admin','support'].includes(x.role_name)))).catch(() => {});
}
}, []);
useEffect(() => {
Promise.all([
api.get('/settings/categories'),
api.get('/settings/templates'),
]).then(([catRes, tplRes]) => {
setCategories(catRes.data);
setTemplates(tplRes.data);
}).catch(() => {});
}, []);
const loadAll = async () => {
try {
const [ticketData, statsData] = await Promise.all([
ticketService.getAll({
status: statusFilter || undefined,
priority: priorityFilter || undefined,
category: categoryFilter || undefined,
search: searchTerm || undefined,
}),
ticketService.getStats(),
]);
setTickets(ticketData);
setStats(statsData);
} catch {
toast.error('Fehler beim Laden der Tickets');
} finally {
setLoading(false);
}
};
const handleCreate = async (e) => {
e.preventDefault();
setFormLoading(true);
try {
const ticket = await ticketService.create({
...formData,
asset_id: formData.asset_id || undefined,
});
toast.success(`Ticket ${ticket.ticket_number} erstellt`);
setShowModal(false);
resetForm();
loadAll();
} catch (err) {
toast.error(err.message || 'Fehler beim Erstellen');
} finally {
setFormLoading(false);
}
};
const resetForm = () => setFormData({
title: '', description: '', category: 'Allgemein',
priority: 'mittel', asset_id: '', requester_name: '', requester_email: '',
});
const toggleFilter = (type, val) => {
if (type === 'status') setStatusFilter(v => v === val ? '' : val);
if (type === 'priority') setPriorityFilter(v => v === val ? '' : val);
};
// Selektion
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === tickets.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(tickets.map(t => t.id)));
}
};
const handleBulkSubmit = async () => {
if (!bulkAction || selectedIds.size === 0) return;
setBulkLoading(true);
try {
const data = {};
if (bulkAction === 'assign') data.assigned_to_user_id = bulkAssignee || null;
else data.status = bulkAction;
await ticketService.bulkUpdate([...selectedIds], data);
toast.success(`${selectedIds.size} Ticket(s) aktualisiert`);
setSelectedIds(new Set());
setBulkAction('');
setBulkAssignee('');
loadAll();
} catch (err) {
toast.error(err.message || 'Fehler bei Bulk-Aktion');
} finally {
setBulkLoading(false);
}
};
const handleKanbanStatusChange = async (ticketId, newStatus) => {
if (newStatus === 'geschlossen') { requestClose(ticketId); return; }
try {
await ticketService.update(ticketId, { status: newStatus });
setTickets(prev => prev.map(t => t.id === ticketId ? { ...t, status: newStatus } : t));
toast.success('Status geändert');
} catch {
toast.error('Fehler beim Ändern');
}
};
const requestClose = (ticketId) => {
setCloseModal({ open: true, ticketId });
setCloseText('');
};
const handleClose = async () => {
setCloseLoading(true);
try {
await ticketService.update(closeModal.ticketId, { status: 'geschlossen' });
if (closeText.trim()) {
await ticketService.addComment(closeModal.ticketId, `Lösung: ${closeText.trim()}`, false);
}
setTickets(prev => prev.filter(t => t.id !== closeModal.ticketId));
setCloseModal({ open: false, ticketId: null });
toast.success('Ticket geschlossen & in Wissensdatenbank archiviert');
} catch {
toast.error('Fehler beim Schließen');
} finally {
setCloseLoading(false);
}
};
const handleDrop = (targetColKey) => {
if (!draggedId) return;
const ticket = tickets.find(t => t.id === draggedId);
if (!ticket || ticket.status === targetColKey) { setDraggedId(null); setDragOverCol(null); return; }
handleKanbanStatusChange(draggedId, targetColKey);
setDraggedId(null);
setDragOverCol(null);
};
if (loading) return <LoadingSpinner />;
const allSelected = selectedIds.size > 0 && selectedIds.size === tickets.length;
const someSelected = selectedIds.size > 0;
return (
<div className="main-content">
<div className="container">
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.75rem', fontWeight: 800, color: 'var(--text-primary)' }}>🎫 Tickets</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
{tickets.length} Ticket{tickets.length !== 1 ? 's' : ''}{statusFilter ? ` · Filter: ${STATUS_CONFIG[statusFilter]?.label}` : ''}
</p>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button onClick={() => navigate('/ticket-metrics')} className="btn btn-secondary">
📊 Metriken
</button>
{/* View toggle */}
<div style={{ display: 'flex', border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden' }}>
<button
onClick={() => { setViewMode('table'); localStorage.setItem('ticketsViewMode', 'table'); }}
style={{ padding: '6px 12px', border: 'none', cursor: 'pointer', background: viewMode === 'table' ? 'var(--cereda-primary)' : 'var(--bg-secondary)', color: viewMode === 'table' ? 'white' : 'var(--text-secondary)', fontSize: '0.8125rem' }}
> Tabelle</button>
<button
onClick={() => { setViewMode('kanban'); localStorage.setItem('ticketsViewMode', 'kanban'); }}
style={{ padding: '6px 12px', border: 'none', cursor: 'pointer', background: viewMode === 'kanban' ? 'var(--cereda-primary)' : 'var(--bg-secondary)', color: viewMode === 'kanban' ? 'white' : 'var(--text-secondary)', fontSize: '0.8125rem' }}
> Kanban</button>
</div>
<button className="btn btn-primary" onClick={() => setShowModal(true)}>+ Neues Ticket</button>
</div>
</div>
{/* Stats */}
{stats && (
<div className="dashboard-grid" style={{ marginBottom: '2rem' }}>
<StatCard icon="📋" value={stats.total} label="Gesamt" variant="stat-card-primary" />
<StatCard icon="🔓" value={stats.open} label="Offen" variant="stat-card-warning" />
<StatCard icon="⚙️" value={stats.inProgress} label="In Bearbeitung" variant="stat-card-info" />
<StatCard icon="🚨" value={stats.critical} label="Kritisch offen" variant="stat-card-danger" />
</div>
)}
{/* Filter Bar */}
<div className="card" style={{ padding: '1rem 1.25rem', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: '200px', position: 'relative' }}>
<span style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}>🔍</span>
<input type="text" className="form-input" placeholder="Ticket suchen..."
value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
style={{ paddingLeft: '32px' }} />
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{Object.entries(STATUS_CONFIG).filter(([key]) => key !== 'geschlossen').map(([key, cfg]) => (
<button key={key} onClick={() => toggleFilter('status', key)}
className={statusFilter === key ? 'btn btn-primary btn-small' : 'btn btn-secondary btn-small'}
style={{ fontSize: '0.75rem' }}>
{cfg.label}
</button>
))}
</div>
<select className="form-select" value={priorityFilter} onChange={(e) => setPriorityFilter(e.target.value)} style={{ width: 'auto', minWidth: '130px' }}>
<option value="">Priorität (alle)</option>
<option value="kritisch">🔴 Kritisch</option>
<option value="hoch">🟠 Hoch</option>
<option value="mittel">🔵 Mittel</option>
<option value="niedrig">🟢 Niedrig</option>
</select>
<select className="form-select" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={{ width: 'auto', minWidth: '130px' }}>
<option value="">Kategorie (alle)</option>
{categories.map(c => <option key={c.id} value={c.name}>{c.icon} {c.name}</option>)}
</select>
</div>
</div>
{/* Bulk-Aktionen Bar */}
{someSelected && viewMode === 'table' && (
<div style={{
background: 'var(--cereda-primary)', color: 'white',
borderRadius: '10px', padding: '12px 16px', marginBottom: '12px',
display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap',
}}>
<span style={{ fontWeight: 700, fontSize: '0.875rem' }}>
{selectedIds.size} ausgewählt
</span>
<select
value={bulkAction}
onChange={e => setBulkAction(e.target.value)}
style={{ padding: '4px 8px', borderRadius: '6px', border: 'none', fontSize: '0.8125rem', minWidth: '160px' }}
>
<option value="">Aktion wählen</option>
<option value="offen"> Offen</option>
<option value="in_bearbeitung"> In Bearbeitung</option>
<option value="warten_auf_mitarbeiter"> Warten auf Mitarbeiter</option>
<option value="warten_auf_support"> Warten auf Support</option>
<option value="geschlossen"> Schließen</option>
<option value="assign"> Zuweisen an</option>
</select>
{bulkAction === 'assign' && (
<select
value={bulkAssignee}
onChange={e => setBulkAssignee(e.target.value)}
style={{ padding: '4px 8px', borderRadius: '6px', border: 'none', fontSize: '0.8125rem', minWidth: '140px' }}
>
<option value=""> Nicht zugewiesen</option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.first_name ? `${u.first_name} ${u.last_name}` : u.username}
</option>
))}
</select>
)}
<button
onClick={handleBulkSubmit}
disabled={!bulkAction || bulkLoading}
style={{ padding: '4px 14px', borderRadius: '6px', border: 'none', background: 'white', color: 'var(--cereda-primary)', fontWeight: 700, cursor: 'pointer', fontSize: '0.8125rem' }}
>
{bulkLoading ? '⏳' : '✓ Anwenden'}
</button>
<button onClick={() => setSelectedIds(new Set())} style={{ marginLeft: 'auto', background: 'none', border: '1px solid rgba(255,255,255,0.4)', color: 'white', borderRadius: '6px', padding: '4px 10px', cursor: 'pointer', fontSize: '0.8125rem' }}>
Abbrechen
</button>
</div>
)}
{tickets.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🎫</div>
<h3 style={{ color: 'var(--text-secondary)', margin: '0 0 8px' }}>Keine Tickets gefunden</h3>
<p style={{ margin: 0, fontSize: '0.875rem' }}>
{statusFilter || priorityFilter || categoryFilter || searchTerm ? 'Versuche die Filter anzupassen' : 'Erstelle das erste Ticket'}
</p>
</div>
) : viewMode === 'kanban' ? (
/* ── KANBAN VIEW ── */
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '16px', alignItems: 'start' }}>
{KANBAN_COLS.map(col => {
const colTickets = tickets.filter(t => t.status === col.key);
const isOver = dragOverCol === col.key;
return (
<div
key={col.key}
onDragOver={(e) => { e.preventDefault(); setDragOverCol(col.key); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null); }}
onDrop={() => handleDrop(col.key)}
style={{
background: isOver ? 'rgba(99,102,241,0.08)' : 'var(--bg-secondary)',
borderRadius: '10px', padding: '12px',
border: isOver ? '2px dashed var(--cereda-primary)' : '2px solid transparent',
transition: 'background 0.15s, border 0.15s',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<span className={`status-badge ${col.css}`}>{col.label}</span>
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: '10px', padding: '1px 7px' }}>
{colTickets.length}
</span>
</div>
<div style={{ minHeight: '60px' }}>
{colTickets.map(ticket => (
<KanbanCard
key={ticket.id}
ticket={ticket}
isDragging={draggedId === ticket.id}
onClick={(id) => navigate(`/tickets/${id}`)}
onStatusChange={handleKanbanStatusChange}
onClose={requestClose}
onDragStart={setDraggedId}
onDragEnd={() => { setDraggedId(null); setDragOverCol(null); }}
/>
))}
{colTickets.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px 0', color: 'var(--text-muted)', fontSize: '0.8125rem' }}>
{isOver ? '📥 Hier ablegen' : 'Leer'}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
/* ── TABLE VIEW ── */
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: '36px' }}>
<input type="checkbox" checked={allSelected} onChange={toggleSelectAll}
style={{ cursor: 'pointer', accentColor: 'var(--cereda-primary)' }} />
</th>
<th style={{ width: '40px' }}></th>
<th style={{ width: '130px' }}>Ticket-Nr.</th>
<th>Titel</th>
<th style={{ width: '110px' }}>Kategorie</th>
<th style={{ width: '120px' }}>Priorität</th>
<th style={{ width: '140px' }}>Status</th>
<th style={{ width: '150px' }}>Zugewiesen</th>
<th style={{ width: '110px' }}>Erstellt</th>
<th style={{ width: '100px' }}></th>
</tr>
</thead>
<tbody>
{tickets.filter(t => t.status !== 'geschlossen').map(ticket => {
const prioConf = PRIORITY_CONFIG[ticket.priority] || {};
const statusConf = STATUS_CONFIG[ticket.status] || {};
const slaInfo = getSlaInfo(ticket);
const assignee = ticket.assigned_to_first_name
? `${ticket.assigned_to_first_name} ${ticket.assigned_to_last_name || ''}`.trim()
: ticket.assigned_to_username;
const initials = assignee
? assignee.split(' ').map(n => n[0]).join('').toUpperCase().substring(0, 2)
: null;
const isSelected = selectedIds.has(ticket.id);
return (
<tr key={ticket.id} style={{ cursor: 'pointer', background: isSelected ? 'rgba(var(--cereda-primary-rgb, 99,102,241), 0.06)' : undefined }}
onClick={() => navigate(`/tickets/${ticket.id}`)}>
<td onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={isSelected} onChange={() => toggleSelect(ticket.id)}
style={{ cursor: 'pointer', accentColor: 'var(--cereda-primary)' }} />
</td>
<td style={{ padding: '0', width: '4px' }}>
<div style={{ width: '4px', height: '100%', minHeight: '48px', background: prioConf.color, borderRadius: '2px' }} />
</td>
<td>
<span style={{ fontFamily: 'monospace', fontSize: '0.8125rem', fontWeight: 700, color: 'var(--cereda-primary)' }}>
{ticket.ticket_number}
</span>
{ticket.source === 'email' && (
<span title="Via E-Mail" style={{ marginLeft: '5px', fontSize: '11px', opacity: 0.7 }}>📧</span>
)}
</td>
<td>
<span style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{ticket.title}</span>
{ticket.asset_name && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '2px' }}>📦 {ticket.asset_name}</div>
)}
</td>
<td>
<span style={{ fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: '4px', background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', border: '1px solid var(--border-color)' }}>
{ticket.category}
</span>
</td>
<td>
<span style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '0.8125rem', fontWeight: 600, color: prioConf.color }}>
{PRIORITY_ICONS[ticket.priority]} {prioConf.label}
</span>
</td>
<td><span className={`status-badge ${statusConf.css}`}>{statusConf.label}</span></td>
<td>
{initials ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'linear-gradient(135deg, var(--cereda-primary), var(--cereda-accent))', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '11px', fontWeight: 700, color: 'white', flexShrink: 0 }}>
{initials}
</div>
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{assignee}</span>
</div>
) : (
<span style={{ fontSize: '0.8125rem', color: 'var(--text-muted)' }}> nicht zugewiesen</span>
)}
</td>
<td style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{formatDate(ticket.created_at)}
{slaInfo && (
<span style={{ fontSize: '10px', fontWeight: 700, padding: '1px 5px', borderRadius: '4px', background: slaInfo.bg, color: slaInfo.color, border: `1px solid ${slaInfo.color}44`, whiteSpace: 'nowrap' }}>
{slaInfo.label}
</span>
)}
</div>
</td>
<td className="table-actions" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-secondary btn-small" onClick={() => navigate(`/tickets/${ticket.id}`)}>Öffnen</button>
<a
href={ticketService.getPdfUrl(ticket.id)}
target="_blank"
rel="noreferrer"
className="btn btn-secondary btn-small"
style={{ textDecoration: 'none' }}
title="PDF exportieren"
>
📄
</a>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Schließen-Modal */}
{closeModal.open && (
<div className="modal-overlay" onClick={() => setCloseModal({ open: false, ticketId: null })}>
<div className="modal-content" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title"> Ticket schließen</h2>
<button className="modal-close" onClick={() => setCloseModal({ open: false, ticketId: null })}>×</button>
</div>
<div style={{ padding: '20px 24px' }}>
<p style={{ margin: '0 0 16px', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Das Ticket wird geschlossen und in die Wissensdatenbank übertragen. Eine Lösungsbeschreibung ist optional.
</p>
<div className="form-group">
<label className="form-label">Lösungsbeschreibung (optional)</label>
<textarea
className="form-textarea"
rows={4}
placeholder="Was hat das Problem gelöst? (kann leer gelassen werden)"
value={closeText}
onChange={e => setCloseText(e.target.value)}
autoFocus
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button className="btn btn-secondary" onClick={() => setCloseModal({ open: false, ticketId: null })} disabled={closeLoading}>
Abbrechen
</button>
<button className="btn btn-secondary" onClick={handleClose} disabled={closeLoading} style={{ opacity: 0.7 }}>
{closeLoading ? '⏳' : 'Schließen ohne Notiz'}
</button>
<button
className="btn btn-primary"
onClick={handleClose}
disabled={closeLoading || !closeText.trim()}
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
>
{closeLoading ? '⏳' : '✅ Lösung speichern & schließen'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Create Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => { setShowModal(false); resetForm(); }}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">🎫 Neues Ticket erstellen</h2>
<button className="modal-close" onClick={() => { setShowModal(false); resetForm(); }}>×</button>
</div>
<form onSubmit={handleCreate}>
<div className="form-group">
<label className="form-label">Vorlage (optional)</label>
<select className="form-select" defaultValue="" onChange={(e) => {
const t = templates.find(x => String(x.id) === e.target.value);
if (t) setFormData({ ...formData, title: t.title, description: t.description, category: t.category, priority: t.priority });
}}>
<option value=""> Vorlage wählen </option>
{templates.map(t => <option key={t.id} value={String(t.id)}>{t.label}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Titel*</label>
<input type="text" className="form-input" required value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Kurze Beschreibung des Problems" autoFocus />
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea className="form-textarea" rows="4" value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Detaillierte Beschreibung…" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="form-group">
<label className="form-label">Kategorie</label>
<select className="form-select" value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}>
{categories.map(c => <option key={c.id} value={c.name}>{c.icon} {c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Priorität</label>
<select className="form-select" value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}>
<option value="niedrig">🟢 Niedrig</option>
<option value="mittel">🔵 Mittel</option>
<option value="hoch">🟠 Hoch</option>
<option value="kritisch">🔴 Kritisch</option>
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Betroffenes Asset (optional)</label>
<select className="form-select" value={formData.asset_id}
onChange={(e) => setFormData({ ...formData, asset_id: e.target.value })}>
<option value=""> Kein Asset ausgewählt</option>
{assets.map(a => <option key={a.id} value={a.id}>{a.name} · {a.serial_number}</option>)}
</select>
</div>
<div style={{ borderTop: '1px solid var(--border-color)', margin: '16px 0 12px', paddingTop: '12px' }}>
<p style={{ margin: '0 0 12px', fontSize: '0.8125rem', color: 'var(--text-muted)' }}>Anfragender (wird automatisch aus dem Login-Account befüllt)</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="form-group">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={formData.requester_name}
onChange={(e) => setFormData({ ...formData, requester_name: e.target.value })}
placeholder="Automatisch befüllt" />
</div>
<div className="form-group">
<label className="form-label">E-Mail</label>
<input type="email" className="form-input" value={formData.requester_email}
onChange={(e) => setFormData({ ...formData, requester_email: e.target.value })}
placeholder="Automatisch befüllt" />
</div>
</div>
<div className="card-footer">
<button type="button" className="btn btn-secondary" onClick={() => { setShowModal(false); resetForm(); }} disabled={formLoading}>Abbrechen</button>
<button type="submit" className="btn btn-primary" disabled={formLoading}>
{formLoading ? '⏳ Erstellen…' : '🎫 Ticket erstellen'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
};
export default TicketsPage;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,643 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import userService from '../services/userService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const UsersPage = () => {
const { isSuperAdmin } = useAuth();
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [showImportModal, setShowImportModal] = useState(false);
const [azureGroups, setAzureGroups] = useState([]);
const [azureGroupsLoading, setAzureGroupsLoading] = useState(false);
const [importGroupId, setImportGroupId] = useState('');
const [importRoleId, setImportRoleId] = useState('');
const [importing, setImporting] = useState(false);
const [groupSearch, setGroupSearch] = useState('');
const [search, setSearch] = useState('');
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
role_id: '',
first_name: '',
last_name: '',
is_active: true,
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [usersData, rolesData] = await Promise.all([
userService.getAll(),
userService.getRoles(),
]);
setUsers(usersData);
setRoles(rolesData);
} catch (error) {
toast.error('Fehler beim Laden der Daten');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingUser(null);
setFormData({
username: '',
email: '',
password: '',
role_id: roles[0]?.id || '',
first_name: '',
last_name: '',
is_active: true,
});
setShowModal(true);
};
const handleEdit = (user) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
role_id: user.role_id,
first_name: user.first_name || '',
last_name: user.last_name || '',
is_active: user.is_active,
});
setShowModal(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const submitData = { ...formData };
// Remove password if empty (for edit)
if (editingUser && !submitData.password) {
delete submitData.password;
}
if (editingUser) {
await userService.update(editingUser.id, submitData);
toast.success('Benutzer erfolgreich aktualisiert');
} else {
await userService.create(submitData);
toast.success('Benutzer erfolgreich erstellt');
}
setShowModal(false);
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Speichern');
}
};
const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
return;
}
try {
await userService.delete(id);
toast.success('Benutzer erfolgreich gelöscht');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Löschen');
}
};
const handleToggleStatus = async (user) => {
try {
await userService.toggleStatus(user.id, !user.is_active);
toast.success('Status erfolgreich geändert');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Ändern des Status');
}
};
const handleOpenImportModal = async () => {
setImportGroupId('');
setImportRoleId(roles[0]?.id || '');
setGroupSearch('');
setShowImportModal(true);
setAzureGroupsLoading(true);
try {
const groups = await userService.getAzureGroups();
setAzureGroups(groups);
} catch (error) {
toast.error(error.message || 'Fehler beim Laden der Azure-Gruppen');
setShowImportModal(false);
} finally {
setAzureGroupsLoading(false);
}
};
const handleImport = async () => {
if (!importGroupId || !importRoleId) return;
setImporting(true);
try {
const result = await userService.importFromAzure(importGroupId, importRoleId);
toast.success(
`Import abgeschlossen: ${result.imported} importiert, ${result.skipped} übersprungen` +
(result.errors.length > 0 ? `, ${result.errors.length} Fehler` : '')
);
setShowImportModal(false);
if (result.imported > 0) loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Importieren');
} finally {
setImporting(false);
}
};
const handleRoleChange = async (userId, roleId) => {
try {
await userService.assignRole(userId, roleId);
toast.success('Rolle erfolgreich zugewiesen');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Zuweisen der Rolle');
}
};
if (loading) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
return (
<div className="main-content">
<div className="container">
<div className="flex justify-between items-center mb-3">
<h1>Benutzerverwaltung</h1>
{isSuperAdmin() && (
<div className="flex" style={{ gap: '0.5rem' }}>
<button onClick={handleOpenImportModal} className="btn btn-secondary">
Aus Azure importieren
</button>
<button onClick={handleCreate} className="btn btn-primary">
+ Neuer Benutzer
</button>
</div>
)}
</div>
{/* Search */}
<div style={{ marginBottom: 12 }}>
<input
className="form-input"
type="text"
placeholder="🔍 Suche nach Name, Benutzername, E-Mail oder Rolle…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{ maxWidth: 420 }}
/>
</div>
{/* Table */}
<div className="card">
<table className="table">
<thead>
<tr>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Name</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzter Login</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{(() => {
const q = search.toLowerCase();
const filtered = q
? users.filter(u =>
u.username?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q) ||
u.first_name?.toLowerCase().includes(q) ||
u.last_name?.toLowerCase().includes(q) ||
u.role_name?.toLowerCase().includes(q)
)
: users;
if (filtered.length === 0) return (
<tr><td colSpan="7" className="text-center">Keine Benutzer gefunden</td></tr>
);
return filtered.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.email}</td>
<td>
{user.first_name || user.last_name
? `${user.first_name || ''} ${
user.last_name || ''
}`.trim()
: '-'}
</td>
<td>
<span className="role-badge">{user.role_name}</span>
</td>
<td>
<span
className={`status-badge ${
user.is_active
? 'status-aktiv'
: 'status-inaktiv'
}`}
>
{user.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td>
{user.last_login
? new Date(user.last_login).toLocaleString('de-DE')
: 'Nie'}
</td>
<td>
{isSuperAdmin() && (
<div className="table-actions">
<button
onClick={() => handleEdit(user)}
className="btn btn-primary btn-small"
>
Bearbeiten
</button>
<button
onClick={() => handleToggleStatus(user)}
className="btn btn-secondary btn-small"
>
{user.is_active
? 'Deaktivieren'
: 'Aktivieren'}
</button>
<button
onClick={() => handleDelete(user.id)}
className="btn btn-danger btn-small"
>
Löschen
</button>
</div>
)}
</td>
</tr>
));
})()}
</tbody>
</table>
</div>
{/* Azure Import Modal */}
{showImportModal && (
<div className="modal-overlay" onClick={() => setShowImportModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Benutzer aus Azure AD importieren</h2>
<button className="modal-close" onClick={() => setShowImportModal(false)}>×</button>
</div>
<div className="modal-body">
{azureGroupsLoading ? (
<p style={{ textAlign: 'center', padding: '1rem' }}>Gruppen werden geladen...</p>
) : (
<>
<div className="form-group">
<label className="form-label">Azure AD Gruppe*</label>
<input
type="text"
className="form-input"
placeholder="Gruppe suchen..."
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
style={{ marginBottom: '0.4rem' }}
/>
<div style={{
border: '1px solid var(--border-color)',
borderRadius: '6px',
maxHeight: '220px',
overflowY: 'auto',
background: 'var(--bg-secondary)',
}}>
{azureGroups
.filter(g => g.displayName.toLowerCase().includes(groupSearch.toLowerCase()))
.map((g) => (
<div
key={g.id}
onClick={() => setImportGroupId(g.id)}
style={{
padding: '8px 12px',
cursor: 'pointer',
borderBottom: '1px solid var(--border-color)',
background: importGroupId === g.id ? 'var(--primary)' : 'transparent',
color: importGroupId === g.id ? '#fff' : 'var(--text-primary)',
}}
>
<div style={{ fontWeight: 500, fontSize: '14px' }}>{g.displayName}</div>
{g.description && (
<div style={{
fontSize: '12px',
opacity: 0.7,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}>
{g.description}
</div>
)}
</div>
))
}
{azureGroups.filter(g => g.displayName.toLowerCase().includes(groupSearch.toLowerCase())).length === 0 && (
<div style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: '13px' }}>
Keine Gruppen gefunden
</div>
)}
</div>
{importGroupId && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
Ausgewählt: <strong>{azureGroups.find(g => g.id === importGroupId)?.displayName}</strong>
</div>
)}
</div>
<div className="form-group">
<label className="form-label">Rolle für importierte Benutzer *</label>
{(() => {
const ROLE_META = {
super_admin: { icon: '👑', color: '#ef4444', bg: '#fef2f2', label: 'Super Admin' },
admin: { icon: '🛡️', color: '#f97316', bg: '#fff7ed', label: 'Admin' },
support: { icon: '🎧', color: '#3b82f6', bg: '#eff6ff', label: 'IT-Support' },
bearbeiter: { icon: '🔧', color: '#8b5cf6', bg: '#f5f3ff', label: 'Bearbeiter' },
benutzer: { icon: '👤', color: '#6b7280', bg: '#f9fafb', label: 'Benutzer' },
hr_personal: { icon: '🧑‍💼', color: '#10b981', bg: '#f0fdf4', label: 'HR / Personal' },
buchhaltung: { icon: '💶', color: '#a78bfa', bg: '#faf5ff', label: 'Buchhaltung' },
};
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{roles.map(role => {
const meta = ROLE_META[role.name] || { icon: '🔵', color: '#6b7280', bg: '#f9fafb', label: role.name };
const selected = String(importRoleId) === String(role.id);
return (
<button
key={role.id}
type="button"
onClick={() => setImportRoleId(role.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 8, cursor: 'pointer',
border: `2px solid ${selected ? meta.color : 'var(--border-color)'}`,
background: selected ? meta.bg : 'var(--bg-secondary)',
textAlign: 'left', transition: 'all 0.15s',
boxShadow: selected ? `0 0 0 3px ${meta.color}22` : 'none',
}}
>
<div style={{
width: 32, height: 32, borderRadius: '50%', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: 16,
background: selected ? meta.color + '22' : 'var(--bg-tertiary)',
flexShrink: 0,
}}>{meta.icon}</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: selected ? meta.color : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{meta.label}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.3, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{role.description}
</div>
</div>
{selected && (
<div style={{ marginLeft: 'auto', color: meta.color, fontSize: 16, flexShrink: 0 }}></div>
)}
</button>
);
})}
</div>
);
})()}
</div>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '0.5rem' }}>
Bereits vorhandene Benutzer (gleiche E-Mail oder Azure ID) werden übersprungen.
</p>
</>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowImportModal(false)}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handleImport}
disabled={importing || azureGroupsLoading || !importGroupId || !importRoleId}
>
{importing ? 'Importiere...' : 'Importieren'}
</button>
</div>
</div>
</div>
)}
{/* Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">
{editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
</h2>
<button
className="modal-close"
onClick={() => setShowModal(false)}
>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Benutzername*</label>
<input
type="text"
className="form-input"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label className="form-label">E-Mail*</label>
<input
type="email"
className="form-input"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label className="form-label">
Passwort{editingUser ? '' : '*'}
</label>
<input
type="password"
className="form-input"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
placeholder={
editingUser
? 'Leer lassen, um nicht zu ändern'
: ''
}
/>
</div>
<div className="form-group">
<label className="form-label">Vorname</label>
<input
type="text"
className="form-input"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
/>
</div>
<div className="form-group">
<label className="form-label">Nachname</label>
<input
type="text"
className="form-input"
value={formData.last_name}
onChange={(e) =>
setFormData({ ...formData, last_name: e.target.value })
}
/>
</div>
<div className="form-group">
<label className="form-label">Rolle *</label>
{(() => {
const ROLE_META = {
super_admin: { icon: '👑', color: '#ef4444', bg: '#fef2f2', label: 'Super Admin' },
admin: { icon: '🛡️', color: '#f97316', bg: '#fff7ed', label: 'Admin' },
support: { icon: '🎧', color: '#3b82f6', bg: '#eff6ff', label: 'IT-Support' },
bearbeiter: { icon: '🔧', color: '#8b5cf6', bg: '#f5f3ff', label: 'Bearbeiter' },
benutzer: { icon: '👤', color: '#6b7280', bg: '#f9fafb', label: 'Benutzer' },
hr_personal: { icon: '🧑‍💼', color: '#10b981', bg: '#f0fdf4', label: 'HR / Personal' },
buchhaltung: { icon: '💶', color: '#a78bfa', bg: '#faf5ff', label: 'Buchhaltung' },
};
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{roles.map(role => {
const meta = ROLE_META[role.name] || { icon: '🔵', color: '#6b7280', bg: '#f9fafb', label: role.name };
const selected = formData.role_id === role.id;
return (
<button
key={role.id}
type="button"
onClick={() => setFormData({ ...formData, role_id: role.id })}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 8, cursor: 'pointer',
border: `2px solid ${selected ? meta.color : 'var(--border-color)'}`,
background: selected ? meta.bg : 'var(--bg-secondary)',
textAlign: 'left', transition: 'all 0.15s',
boxShadow: selected ? `0 0 0 3px ${meta.color}22` : 'none',
}}
>
<div style={{
width: 32, height: 32, borderRadius: '50%', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: 16,
background: selected ? meta.color + '22' : 'var(--bg-tertiary)',
flexShrink: 0,
}}>{meta.icon}</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: selected ? meta.color : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{meta.label}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.3, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{role.description}
</div>
</div>
{selected && (
<div style={{ marginLeft: 'auto', color: meta.color, fontSize: 16, flexShrink: 0 }}></div>
)}
</button>
);
})}
</div>
);
})()}
{!formData.role_id && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>Bitte eine Rolle auswählen</div>
)}
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
style={{ marginRight: '0.5rem' }}
/>
Benutzer ist aktiv
</label>
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Speichern
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
};
export default UsersPage;

View File

@@ -0,0 +1,546 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'react-toastify';
import warehouseService from '../services/warehouseService';
import assetService from '../services/assetService';
import userService from '../services/userService';
const ASSET_TYPES = ['Notebook', 'Monitor', 'Headset', 'Other', 'Maschine', 'Werkzeug', 'Sonstiges'];
const STATUS_LABELS = { verfuegbar: 'Verfügbar', zugewiesen: 'In Verwendung', inaktiv: 'Inaktiv', beschaedigt: 'Defekt', bestellt: 'Bestellt' };
const STATUS_COLORS = { verfuegbar: '#34d399', zugewiesen: '#60a5fa', inaktiv: '#6b7280', beschaedigt: '#ef4444', bestellt: '#f59e0b' };
const timeAgo = (iso) => { if (!iso) return ''; const m = Math.floor((Date.now() - new Date(iso)) / 60000); if (m < 1) return 'gerade'; if (m < 60) return `vor ${m} Min.`; const h = Math.floor(m / 60); if (h < 24) return `vor ${h} Std.`; return `vor ${Math.floor(h / 24)} Tag(en)`; };
// ─── QR Scanner Modal ────────────────────────────────────────────────────────
const QrScannerModal = ({ onClose, onResult }) => {
const videoRef = useRef(null);
const [error, setError] = useState('');
const scannerRef = useRef(null);
useEffect(() => {
let html5QrCode;
import('html5-qrcode').then(({ Html5Qrcode }) => {
html5QrCode = new Html5Qrcode('qr-reader');
scannerRef.current = html5QrCode;
html5QrCode.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
html5QrCode.stop().catch(() => {});
onResult(decodedText);
onClose();
},
() => {}
).catch(err => setError('Kamera konnte nicht gestartet werden: ' + err));
}).catch(() => setError('QR-Scanner konnte nicht geladen werden.'));
return () => {
if (scannerRef.current) {
scannerRef.current.stop().catch(() => {});
}
};
}, [onClose, onResult]);
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 14, padding: 24, width: 360, maxWidth: '95vw' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>QR-Code scannen</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: 'var(--text-muted)' }}>×</button>
</div>
{error ? (
<div style={{ color: '#ef4444', fontSize: 13, padding: 12 }}>{error}</div>
) : (
<div id="qr-reader" style={{ width: '100%', borderRadius: 8, overflow: 'hidden' }} />
)}
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>Kamera auf QR-Code richten</div>
</div>
</div>
);
};
// ─── Movement Dialog ──────────────────────────────────────────────────────────
const MovementDialog = ({ asset, locations, users, onClose, onSave }) => {
const [form, setForm] = useState({ type: 'out', to_location_id: '', assigned_user_id: '', ticket_id: '', reason: '', notes: '', new_status: '' });
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const handleSubmit = async (e) => {
e.preventDefault();
try {
await warehouseService.createMovement({ asset_id: asset.id, ...form });
toast.success('Bewegung gespeichert');
onSave();
onClose();
} catch { toast.error('Fehler beim Speichern'); }
};
const inp = { padding: '7px 10px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: 13, width: '100%', boxSizing: 'border-box' };
const lbl = { fontSize: 12, color: 'var(--text-muted)', marginBottom: 4, display: 'block' };
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 900, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 14, padding: 24, width: 440, maxWidth: '95vw', maxHeight: '90vh', overflowY: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>Ein-/Ausbuchen: {asset.name}</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: 'var(--text-muted)' }}>×</button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={lbl}>Typ *</label>
<select style={inp} value={form.type} onChange={e => set('type', e.target.value)}>
<option value="out">Ausbuchen (Ausgabe)</option>
<option value="in">Einbuchen (Rücknahme)</option>
<option value="transfer">Umlagern</option>
<option value="status_change">Statusänderung</option>
</select>
</div>
<div>
<label style={lbl}>Ziel-Lagerort</label>
<select style={inp} value={form.to_location_id} onChange={e => set('to_location_id', e.target.value)}>
<option value=""> kein Lagerort </option>
{locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
{(form.type === 'out') && (
<div>
<label style={lbl}>Mitarbeiter</label>
<select style={inp} value={form.assigned_user_id} onChange={e => set('assigned_user_id', e.target.value)}>
<option value=""> kein Mitarbeiter </option>
{users.map(u => <option key={u.id} value={u.id}>{u.first_name} {u.last_name}</option>)}
</select>
</div>
)}
<div>
<label style={lbl}>Ticket-ID (optional)</label>
<input style={inp} type="number" placeholder="z.B. 1023" value={form.ticket_id} onChange={e => set('ticket_id', e.target.value)} />
</div>
<div>
<label style={lbl}>Neuer Status (optional)</label>
<select style={inp} value={form.new_status} onChange={e => set('new_status', e.target.value)}>
<option value=""> unverändert </option>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label style={lbl}>Grund *</label>
<input style={inp} required placeholder="z.B. Ausgabe an Mitarbeiter" value={form.reason} onChange={e => set('reason', e.target.value)} />
</div>
<div>
<label style={lbl}>Notizen</label>
<textarea style={{ ...inp, resize: 'vertical', minHeight: 60 }} value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 6 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 18px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>Abbrechen</button>
<button type="submit" style={{ padding: '8px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#fff', fontWeight: 700, cursor: 'pointer' }}>Speichern</button>
</div>
</form>
</div>
</div>
);
};
// ─── Main Page ────────────────────────────────────────────────────────────────
const WarehousePage = () => {
const [tab, setTab] = useState('overview');
const [summary, setSummary] = useState(null);
const [locations, setLocations] = useState([]);
const [movements, setMovements] = useState([]);
const [thresholds, setThresholds] = useState([]);
const [orders, setOrders] = useState([]);
const [assets, setAssets] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [showScanner, setShowScanner] = useState(false);
const [movementAsset, setMovementAsset] = useState(null);
const [qrAsset, setQrAsset] = useState(null);
const [assetSearch, setAssetSearch] = useState('');
const [locationFilter, setLocationFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
// Threshold form
const [thresholdForm, setThresholdForm] = useState({ category: '', min_stock: 1, notify_email: '' });
// Order form
const [orderForm, setOrderForm] = useState({ category: '', item_name: '', quantity: 1, notes: '' });
const load = useCallback(async () => {
try {
const [sum, locs, movs, thresh, ords, assetList, userList] = await Promise.all([
warehouseService.getSummary(),
warehouseService.getLocations(),
warehouseService.getMovements(),
warehouseService.getThresholds(),
warehouseService.getOrders(),
assetService.getAll(),
userService.getAll(),
]);
setSummary(sum);
setLocations(locs);
setMovements(movs);
setThresholds(thresh);
setOrders(ords);
setAssets(assetList);
setUsers(userList);
} catch { toast.error('Fehler beim Laden'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleQrResult = (text) => {
// Extract asset ID from URL: /assets?scan=42
const m = text.match(/[?&]scan=(\d+)/);
if (m) {
const found = assets.find(a => a.id === parseInt(m[1]));
if (found) { setMovementAsset(found); return; }
}
toast.error('Kein gültiges Asset-QR erkannt: ' + text);
};
const filteredAssets = assets.filter(a => {
if (assetSearch && !`${a.name} ${a.inventory_number} ${a.serial_number}`.toLowerCase().includes(assetSearch.toLowerCase())) return false;
if (statusFilter && a.status !== statusFilter) return false;
if (locationFilter && String(a.location_id) !== locationFilter) return false;
return true;
});
// ── Styles ──
const card = { background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '14px 18px' };
const inp = { padding: '7px 10px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: 13 };
const btn = (color = 'var(--accent)') => ({ padding: '7px 16px', borderRadius: 8, border: 'none', background: color, color: '#fff', fontSize: 13, cursor: 'pointer', fontWeight: 600 });
const tabBtn = (id) => ({ padding: '10px 20px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, fontWeight: tab === id ? 700 : 400, color: tab === id ? 'var(--accent)' : 'var(--text-muted)', borderBottom: tab === id ? '2px solid var(--accent)' : '2px solid transparent', marginBottom: -1 });
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Laden</div>;
return (
<div style={{ padding: 24 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>📦 Lager</h1>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>Lager- und Inventarverwaltung</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={{ ...btn('#6b7280') }} onClick={() => setShowScanner(true)}>📷 QR scannen</button>
<button style={btn()} onClick={load}> Aktualisieren</button>
</div>
</div>
{/* Violation banner */}
{summary?.violations?.length > 0 && (
<div style={{ marginBottom: 18, padding: '12px 18px', borderRadius: 10, background: '#ef444420', border: '1px solid #ef444444', display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 18 }}></span>
<div>
<strong style={{ color: '#ef4444' }}>Mindestbestand unterschritten!</strong>
<span style={{ color: 'var(--text-secondary)', fontSize: 13, marginLeft: 8 }}>
{summary.violations.map(v => `${v.category}: ${v.current_stock}/${v.min_stock}`).join(' · ')}
</span>
</div>
<button onClick={() => setTab('thresholds')} style={{ marginLeft: 'auto', ...btn('#ef4444'), padding: '5px 12px', fontSize: 12 }}>Anzeigen </button>
</div>
)}
{/* Tab bar */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', marginBottom: 20 }}>
{[['overview','Übersicht'],['assets','Assets'],['movements','Bewegungen'],['thresholds','Mindestbestand'],['orders','Bestellungen']].map(([id, label]) => (
<button key={id} style={tabBtn(id)} onClick={() => setTab(id)}>{label}</button>
))}
</div>
{/* ── ÜBERSICHT ── */}
{tab === 'overview' && summary && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
{summary.stockByLocation.map(loc => (
<div key={loc.location} style={{ ...card, textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--accent)' }}>{loc.count}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>📍 {loc.location}</div>
</div>
))}
<div style={{ ...card, textAlign: 'center', borderColor: '#f59e0b44' }}>
<div style={{ fontSize: 22, fontWeight: 700, color: '#f59e0b' }}>{summary.openOrders}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>🛒 Offene Bestellungen</div>
</div>
<div style={{ ...card, textAlign: 'center', borderColor: summary.violations.length > 0 ? '#ef444455' : 'var(--border-color)' }}>
<div style={{ fontSize: 22, fontWeight: 700, color: summary.violations.length > 0 ? '#ef4444' : '#34d399' }}>{summary.violations.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}> Mindestbestand-Warnings</div>
</div>
</div>
{/* Stock by category */}
<div style={card}>
<div style={{ fontWeight: 700, marginBottom: 12 }}>Bestand nach Kategorie</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{ASSET_TYPES.map(type => {
const rows = summary.stockByCategory.filter(r => r.category === type);
if (rows.length === 0) return null;
const total = rows.reduce((s, r) => s + r.count, 0);
return (
<div key={type} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--border-color)' }}>
<span style={{ minWidth: 100, fontSize: 13, fontWeight: 600 }}>{type}</span>
<span style={{ fontSize: 13, color: 'var(--text-muted)', marginRight: 4 }}>Gesamt: {total}</span>
{rows.map(r => (
<span key={r.status} style={{ fontSize: 11, padding: '2px 8px', borderRadius: 5, background: (STATUS_COLORS[r.status] || '#888') + '22', color: STATUS_COLORS[r.status] || '#888', border: `1px solid ${STATUS_COLORS[r.status] || '#888'}44` }}>
{STATUS_LABELS[r.status] || r.status}: {r.count}
</span>
))}
</div>
);
})}
</div>
</div>
{/* Recent movements */}
<div style={card}>
<div style={{ fontWeight: 700, marginBottom: 12 }}>Letzte Bewegungen</div>
{summary.recentMovements.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>Noch keine Bewegungen</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{summary.recentMovements.map(m => (
<div key={m.id} style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: 13, padding: '5px 0', borderBottom: '1px solid var(--border-color)' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 90 }}>{timeAgo(m.created_at)}</span>
<span style={{ fontWeight: 600 }}>{m.asset_name}</span>
<span style={{ color: 'var(--text-muted)' }}>{m.type === 'in' ? '⬇ Eingebucht' : m.type === 'out' ? '⬆ Ausgebucht' : m.type === 'transfer' ? '↔ Umgelagert' : '✏ Status'}</span>
{m.to_location_name && <span> {m.to_location_name}</span>}
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)' }}>{m.performed_by_name}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* ── ASSETS ── */}
{tab === 'assets' && (
<div>
<div style={{ display: 'flex', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
<input style={{ ...inp, flex: 1, minWidth: 200 }} placeholder="Suche (Name, Inv-Nr, Seriennr.)" value={assetSearch} onChange={e => setAssetSearch(e.target.value)} />
<select style={inp} value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="">Alle Status</option>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select style={inp} value={locationFilter} onChange={e => setLocationFilter(e.target.value)}>
<option value="">Alle Lagerorte</option>
{locations.map(l => <option key={l.id} value={String(l.id)}>{l.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredAssets.length === 0 && <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Keine Assets gefunden</div>}
{filteredAssets.map(asset => {
const loc = locations.find(l => l.id === asset.location_id);
const sc = STATUS_COLORS[asset.status] || '#888';
return (
<div key={asset.id} style={{ ...card, display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>{asset.name}</span>
<span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 5, background: sc + '22', color: sc, border: `1px solid ${sc}44` }}>{STATUS_LABELS[asset.status] || asset.status}</span>
{loc && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📍 {loc.name}</span>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', gap: 14 }}>
<span>{asset.type}</span>
{asset.inventory_number && <span>#{asset.inventory_number}</span>}
{asset.serial_number && <span>S/N: {asset.serial_number}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={() => setMovementAsset(asset)} style={{ ...btn(), padding: '5px 12px', fontSize: 12 }}>Ein-/Ausbuchen</button>
<button onClick={() => setQrAsset(asset)} style={{ padding: '5px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>🔲 QR</button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── BEWEGUNGEN ── */}
{tab === 'movements' && (
<div style={card}>
<div style={{ fontWeight: 700, marginBottom: 14 }}>Bewegungshistorie ({movements.length})</div>
{movements.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Noch keine Bewegungen erfasst</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{movements.map(m => (
<div key={m.id} style={{ display: 'grid', gridTemplateColumns: '100px 1fr 1fr 1fr 100px', gap: 10, padding: '8px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13, alignItems: 'center' }}>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>{timeAgo(m.created_at)}</span>
<span style={{ fontWeight: 600 }}>{m.asset_name} {m.inventory_number ? `(${m.inventory_number})` : ''}</span>
<span style={{ color: 'var(--text-muted)' }}>
{m.type === 'in' ? '⬇ Eingebucht' : m.type === 'out' ? '⬆ Ausgebucht' : m.type === 'transfer' ? '↔ Umgelagert' : '✏ Statusänderung'}
{m.from_location_name && ` von ${m.from_location_name}`}
{m.to_location_name && `${m.to_location_name}`}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>{m.reason}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>{m.performed_by_name}</span>
</div>
))}
</div>
)}
</div>
)}
{/* ── MINDESTBESTAND ── */}
{tab === 'thresholds' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Add form */}
<div style={card}>
<div style={{ fontWeight: 700, marginBottom: 12 }}>Mindestbestand konfigurieren</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<label style={{ fontSize: 11, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Kategorie</label>
<select style={inp} value={thresholdForm.category} onChange={e => setThresholdForm(p => ({ ...p, category: e.target.value }))}>
<option value=""> wählen </option>
{ASSET_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Mindestbestand</label>
<input style={{ ...inp, width: 80 }} type="number" min={0} value={thresholdForm.min_stock} onChange={e => setThresholdForm(p => ({ ...p, min_stock: parseInt(e.target.value) || 0 }))} />
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>E-Mail (kommagetrennt)</label>
<input style={{ ...inp, width: 240 }} placeholder="admin@firma.de" value={thresholdForm.notify_email} onChange={e => setThresholdForm(p => ({ ...p, notify_email: e.target.value }))} />
</div>
<button style={btn()} onClick={async () => {
if (!thresholdForm.category) return toast.error('Kategorie wählen');
await warehouseService.upsertThreshold(thresholdForm);
toast.success('Gespeichert');
setThresholdForm({ category: '', min_stock: 1, notify_email: '' });
load();
}}>Speichern</button>
</div>
</div>
{/* Violations */}
{summary?.violations?.length > 0 && (
<div style={{ ...card, borderColor: '#ef444444' }}>
<div style={{ fontWeight: 700, marginBottom: 10, color: '#ef4444' }}> Unterschrittene Mindestbestände</div>
{summary.violations.map(v => (
<div key={v.category} style={{ display: 'flex', gap: 12, padding: '6px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13 }}>
<span style={{ fontWeight: 600, minWidth: 120 }}>{v.category}</span>
<span style={{ color: '#ef4444' }}>Bestand: {v.current_stock}</span>
<span style={{ color: 'var(--text-muted)' }}>Minimum: {v.min_stock}</span>
<button onClick={async () => {
await warehouseService.createOrder({ category: v.category, item_name: v.category, quantity: v.min_stock - v.current_stock });
toast.success('Bestellvorschlag erstellt');
load();
}} style={{ marginLeft: 'auto', ...btn('#f59e0b'), padding: '3px 10px', fontSize: 11 }}>🛒 Bestellvorschlag</button>
</div>
))}
</div>
)}
{/* Configured thresholds */}
<div style={card}>
<div style={{ fontWeight: 700, marginBottom: 10 }}>Konfigurierte Schwellwerte</div>
{thresholds.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>Noch keine Schwellwerte konfiguriert</div>
) : (
thresholds.map(t => (
<div key={t.category} style={{ display: 'flex', gap: 12, padding: '6px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13, alignItems: 'center' }}>
<span style={{ fontWeight: 600, minWidth: 120 }}>{t.category}</span>
<span style={{ color: 'var(--text-muted)' }}>Min: {t.min_stock}</span>
{t.notify_email && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📧 {t.notify_email}</span>}
<button onClick={async () => { await warehouseService.deleteThreshold(t.category); toast.success('Gelöscht'); load(); }}
style={{ marginLeft: 'auto', padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>🗑</button>
</div>
))
)}
</div>
</div>
)}
{/* ── BESTELLUNGEN ── */}
{tab === 'orders' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Add order */}
<div style={card}>
<div style={{ fontWeight: 700, marginBottom: 12 }}>Neue Bestellung / Bestellvorschlag</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<label style={{ fontSize: 11, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Kategorie</label>
<select style={inp} value={orderForm.category} onChange={e => setOrderForm(p => ({ ...p, category: e.target.value }))}>
<option value=""> wählen </option>
{ASSET_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Artikel</label>
<input style={{ ...inp, width: 200 }} placeholder="z.B. ThinkPad L15" value={orderForm.item_name} onChange={e => setOrderForm(p => ({ ...p, item_name: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Menge</label>
<input style={{ ...inp, width: 70 }} type="number" min={1} value={orderForm.quantity} onChange={e => setOrderForm(p => ({ ...p, quantity: parseInt(e.target.value) || 1 }))} />
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Notizen</label>
<input style={{ ...inp, width: 200 }} value={orderForm.notes} onChange={e => setOrderForm(p => ({ ...p, notes: e.target.value }))} />
</div>
<button style={btn()} onClick={async () => {
if (!orderForm.category || !orderForm.item_name) return toast.error('Kategorie und Artikel erforderlich');
await warehouseService.createOrder(orderForm);
toast.success('Bestellung erstellt');
setOrderForm({ category: '', item_name: '', quantity: 1, notes: '' });
load();
}}>Erstellen</button>
</div>
</div>
{/* Order list */}
{['offen', 'bestellt', 'erledigt'].map(status => {
const statusOrders = orders.filter(o => o.status === status);
if (statusOrders.length === 0) return null;
const statusColor = status === 'offen' ? '#f59e0b' : status === 'bestellt' ? '#60a5fa' : '#34d399';
const statusLabel = status === 'offen' ? 'Offen' : status === 'bestellt' ? 'Bestellt' : 'Erledigt';
return (
<div key={status} style={{ ...card, borderColor: statusColor + '44' }}>
<div style={{ fontWeight: 700, marginBottom: 10, color: statusColor }}>{statusLabel} ({statusOrders.length})</div>
{statusOrders.map(o => (
<div key={o.id} style={{ display: 'flex', gap: 10, padding: '7px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13, alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<span style={{ fontWeight: 600 }}>{o.item_name}</span>
<span style={{ color: 'var(--text-muted)', marginLeft: 8 }}>×{o.quantity}</span>
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--text-muted)' }}>[{o.category}]</span>
{o.notes && <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--text-muted)' }}> {o.notes}</span>}
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>von {o.created_by_name} · {timeAgo(o.created_at)}</div>
</div>
<div style={{ display: 'flex', gap: 5 }}>
{o.status === 'offen' && <button onClick={async () => { await warehouseService.updateOrder(o.id, { status: 'bestellt' }); load(); }} style={{ ...btn('#60a5fa'), padding: '4px 10px', fontSize: 11 }}>Bestellt</button>}
{o.status === 'bestellt' && <button onClick={async () => { await warehouseService.updateOrder(o.id, { status: 'erledigt' }); load(); }} style={{ ...btn('#34d399'), padding: '4px 10px', fontSize: 11 }}>Erledigt</button>}
<button onClick={async () => { await warehouseService.deleteOrder(o.id); load(); }} style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 11, cursor: 'pointer' }}>🗑</button>
</div>
</div>
))}
</div>
);
})}
{orders.length === 0 && <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Keine Bestellungen vorhanden</div>}
</div>
)}
{/* ── Modals ── */}
{showScanner && <QrScannerModal onClose={() => setShowScanner(false)} onResult={handleQrResult} />}
{movementAsset && <MovementDialog asset={movementAsset} locations={locations} users={users} onClose={() => setMovementAsset(null)} onSave={load} />}
{/* QR Code Modal */}
{qrAsset && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 900, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: 'var(--bg-secondary)', borderRadius: 14, padding: 24, textAlign: 'center', minWidth: 280 }}>
<div style={{ fontWeight: 700, marginBottom: 12, fontSize: 16 }}>QR-Code: {qrAsset.name}</div>
<img src={warehouseService.getQrUrl(qrAsset.id)} alt="QR Code" style={{ width: 200, height: 200, display: 'block', margin: '0 auto 12px' }} />
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 16 }}>{qrAsset.inventory_number || `ID: ${qrAsset.id}`}</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<a href={warehouseService.getQrUrl(qrAsset.id)} download={`qr-${qrAsset.inventory_number || qrAsset.id}.svg`}
style={{ ...btn(), textDecoration: 'none', display: 'inline-block', padding: '7px 16px' }}> Herunterladen</a>
<button onClick={() => setQrAsset(null)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>Schließen</button>
</div>
</div>
</div>
)}
</div>
);
};
export default WarehousePage;

148
frontend/src/routes.jsx Normal file
View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import ProtectedRoute from './components/common/ProtectedRoute';
import AppLayout from './components/common/AppLayout';
// Pages
import LoginPage from './pages/LoginPage';
import ChangePasswordPage from './pages/ChangePasswordPage';
import DashboardPage from './pages/DashboardPage';
import UserPortalPage from './pages/UserPortalPage';
import FidoKeysPage from './pages/FidoKeysPage';
import AssetsPage from './pages/AssetsPage';
import OnOffboardingPage from './pages/OnOffboardingPage';
import UsersPage from './pages/UsersPage';
import TicketsPage from './pages/TicketsPage';
import TicketDetailPage from './pages/TicketDetailPage';
import LicensesPage from './pages/LicensesPage';
import HealthPage from './pages/HealthPage';
import ApiDocsPage from './pages/ApiDocsPage';
import SystemPage from './pages/SystemPage';
import MaintenancePage from './pages/MaintenancePage';
import TicketMetricsPage from './pages/TicketMetricsPage';
import AiPage from './pages/AiPage';
import SettingsPage from './pages/SettingsPage';
import KnowledgeBasePage from './pages/KnowledgeBasePage';
import DefectReportPage from './pages/DefectReportPage';
import DocsPage from './pages/DocsPage';
import ProcessManagementPage from './pages/ProcessManagementPage';
import ItOverviewPage from './pages/ItOverviewPage';
import OnboardingConfirmPage from './pages/OnboardingConfirmPage';
import MyAccountPage from './pages/MyAccountPage';
import AnlagevermoegenPage from './pages/AnlagevermoegenPage';
import IsoPage from './pages/IsoPage';
import RiskPage from './pages/RiskPage';
import EntraPage from './pages/EntraPage';
import MonitoringPage from './pages/MonitoringPage';
import AgentDetailPage from './pages/AgentDetailPage';
import DefenderPage from './pages/DefenderPage';
import WarehousePage from './pages/WarehousePage';
import PortalPage from './pages/PortalPage';
import PatchManagementPage from './pages/PatchManagementPage';
import ProxmoxPage from './pages/ProxmoxPage';
import DockerPage from './pages/DockerPage';
import KnowledgeAiPage from './pages/KnowledgeAiPage';
import SharesPage from './pages/SharesPage';
import PublicSharePage from './pages/PublicSharePage';
import SecurityReportsPage from './pages/SecurityReportsPage';
import ScannerPage from './pages/ScannerPage';
import TVDashboardPage from './pages/TVDashboardPage';
import UserManagementPage from './pages/UserManagementPage';
const L = ({ children, roles }) => (
<ProtectedRoute allowedRoles={roles}>
<AppLayout>{children}</AppLayout>
</ProtectedRoute>
);
const AppRoutes = () => {
const { isAuthenticated, isSupport } = useAuth();
const isStaff = isSupport();
return (
<Routes>
{/* TV Dashboard kein Layout, fullscreen */}
<Route path="/tv" element={<TVDashboardPage />} />
{/* Public routes */}
<Route path="/health" element={<HealthPage />} />
<Route path="/defect" element={<DefectReportPage />} />
<Route path="/s/:token" element={<PublicSharePage />} />
<Route path="/onboarding-confirm/:token" element={<OnboardingConfirmPage />} />
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />}
/>
{/* Password change - kein Layout (Erst-Login) */}
<Route
path="/change-password"
element={<ProtectedRoute><ChangePasswordPage /></ProtectedRoute>}
/>
{/* Root redirect */}
<Route
path="/"
element={
<ProtectedRoute>
<Navigate to={isStaff ? '/dashboard' : '/portal'} replace />
</ProtectedRoute>
}
/>
{/* Protected routes mit Sidebar-Layout */}
<Route path="/dashboard" element={<L>{isStaff ? <DashboardPage /> : <Navigate to="/portal" replace />}</L>} />
<Route path="/portal" element={<L><UserPortalPage /></L>} />
<Route path="/fido-keys" element={<L><FidoKeysPage /></L>} />
<Route path="/assets" element={<L roles={['super_admin', 'admin', 'support', 'bearbeiter', 'produktion']}><AssetsPage /></L>} />
<Route path="/onboarding" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} />
<Route path="/offboarding" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} />
<Route path="/lifecycle" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} />
<Route path="/users" element={<L roles={['super_admin', 'admin']}><UsersPage /></L>} />
<Route path="/benutzerverwaltung" element={<L roles={['super_admin', 'admin']}><UserManagementPage /></L>} />
<Route path="/tickets" element={<L>{isStaff ? <TicketsPage /> : <Navigate to="/portal" replace />}</L>} />
<Route path="/tickets/:id" element={<L><TicketDetailPage /></L>} />
<Route path="/ticket-metrics" element={<L roles={['super_admin', 'admin', 'support', 'bearbeiter']}><TicketMetricsPage /></L>} />
<Route path="/licenses" element={<L roles={['super_admin', 'admin', 'bearbeiter']}><LicensesPage /></L>} />
<Route path="/api-docs" element={<L roles={['super_admin', 'admin']}><ApiDocsPage /></L>} />
<Route path="/system" element={<L roles={['super_admin', 'admin']}><SystemPage /></L>} />
<Route path="/maintenance" element={<L roles={['super_admin', 'admin', 'bearbeiter']}><MaintenancePage /></L>} />
<Route path="/ai" element={<L roles={['super_admin', 'admin', 'support', 'bearbeiter']}><AiPage /></L>} />
<Route path="/knowledge-base" element={<L roles={['super_admin', 'admin', 'support', 'bearbeiter']}><KnowledgeBasePage /></L>} />
<Route path="/settings" element={<L roles={['super_admin', 'admin']}><SettingsPage /></L>} />
<Route path="/docs" element={<L roles={['super_admin', 'admin']}><DocsPage /></L>} />
<Route path="/process-management" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><ProcessManagementPage /></L>} />
<Route path="/it-overview" element={<L roles={['super_admin', 'admin', 'support', 'bearbeiter']}><ItOverviewPage /></L>} />
<Route path="/mein-konto" element={<L><MyAccountPage /></L>} />
<Route path="/anlagevermoegen" element={<L roles={['super_admin', 'admin', 'buchhaltung']}><AnlagevermoegenPage /></L>} />
<Route path="/iso" element={<L roles={['super_admin', 'admin']}><IsoPage /></L>} />
<Route path="/risk" element={<L roles={['super_admin', 'admin']}><RiskPage /></L>} />
<Route path="/entra" element={<L roles={['super_admin', 'admin']}><EntraPage /></L>} />
<Route path="/monitoring" element={<L roles={['super_admin', 'admin']}><MonitoringPage /></L>} />
<Route path="/monitoring/device/:id" element={<L roles={['super_admin', 'admin']}><AgentDetailPage /></L>} />
<Route path="/monitoring/device/host/:hostname" element={<L roles={['super_admin', 'admin']}><AgentDetailPage /></L>} />
<Route path="/defender" element={<L roles={['super_admin', 'admin']}><DefenderPage /></L>} />
<Route path="/warehouse" element={<L roles={['super_admin', 'admin', 'support', 'bearbeiter']}><WarehousePage /></L>} />
<Route path="/anleitungen" element={<L roles={['super_admin', 'admin', 'support', 'bearbeiter', 'benutzer', 'hr_personal', 'buchhaltung', 'produktion']}><PortalPage /></L>} />
<Route path="/patch-management" element={<L roles={['super_admin', 'admin']}><PatchManagementPage /></L>} />
<Route path="/proxmox" element={<L roles={['super_admin', 'admin']}><ProxmoxPage /></L>} />
<Route path="/docker" element={<L roles={['super_admin', 'admin']}><DockerPage /></L>} />
<Route path="/ki-wissen" element={<L roles={['super_admin', 'admin']}><KnowledgeAiPage /></L>} />
<Route path="/security-reports" element={<L roles={['super_admin', 'admin']}><SecurityReportsPage /></L>} />
<Route path="/network-monitor" element={<Navigate to="/monitoring" replace />} />
<Route path="/nexus-scanner" element={<L roles={['super_admin', 'admin']}><ScannerPage /></L>} />
{/* 404 */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
};
export default AppRoutes;

View File

@@ -0,0 +1,89 @@
import api from './api';
const aiService = {
getStatus: async () => {
const response = await api.get('/ai/status');
return response.data.data;
},
chat: async (messages, userMode = false) => {
const response = await api.post('/ai/chat', { messages, userMode });
return response.data.data.reply;
},
getKnowledgeBase: async () => {
const response = await api.get('/ai/knowledge-base');
return response.data.data;
},
addKnowledgeEntry: async (data) => {
const response = await api.post('/ai/knowledge-base', data);
return response.data.data;
},
importTextToKb: async (text) => {
const response = await api.post('/ai/knowledge-base/import-text', { text });
return response.data.data;
},
importUrlToKb: async (url) => {
const response = await api.post('/ai/knowledge-base/import-url', { url });
return response.data.data;
},
importCrawlToKb: async (url, maxPages) => {
const response = await api.post('/ai/knowledge-base/import-crawl', { url, maxPages }, { timeout: 300000 });
return response.data.data;
},
importFileToKb: async (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const response = await api.post('/ai/knowledge-base/import-text', {
file: reader.result,
filename: file.name,
});
resolve(response.data.data);
} catch (err) { reject(err); }
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
uploadKbImage: async (id, file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const response = await api.post(`/ai/knowledge-base/${id}/images`, {
image: reader.result,
filename: file.name,
});
resolve(response.data.data);
} catch (err) { reject(err); }
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
deleteKbImage: async (id, filename) => {
const response = await api.delete(`/ai/knowledge-base/${id}/images/${encodeURIComponent(filename)}`);
return response.data;
},
updateKnowledgeEntry: async (id, data) => {
const response = await api.put(`/ai/knowledge-base/${id}`, data);
return response.data.data;
},
deleteKnowledgeEntry: async (id) => {
const response = await api.delete(`/ai/knowledge-base/${id}`);
return response.data;
},
};
export default aiService;

View File

@@ -0,0 +1,62 @@
import axios from 'axios';
const API_URL = process.env.REACT_APP_API_URL || '/api';
// Create axios instance
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add token to requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
if (typeof config.headers?.set === 'function') {
config.headers.set('Authorization', `Bearer ${token}`);
} else if (config.headers) {
config.headers['Authorization'] = `Bearer ${token}`;
} else {
config.headers = { 'Authorization': `Bearer ${token}` };
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle errors globally
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response) {
// Handle 401 Unauthorized - token expired or invalid
if (error.response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
// Return the error response for handling in components
return Promise.reject(error.response.data);
} else if (error.request) {
// Network error
return Promise.reject({
status: 'error',
message: 'Network error. Please check your connection.',
});
} else {
// Re-throw original error
return Promise.reject(error);
}
}
);
export default api;

View File

@@ -0,0 +1,173 @@
import api from './api';
const assetService = {
/**
* Get all assets
*/
getAll: async () => {
const response = await api.get('/assets');
return response.data.data;
},
/**
* Get asset by ID
*/
getById: async (id) => {
const response = await api.get(`/assets/${id}`);
return response.data.data;
},
/**
* Get asset by serial number
*/
getBySerial: async (serialNumber) => {
const response = await api.get(`/assets/serial/${serialNumber}`);
return response.data.data;
},
/**
* Get assets by status
*/
getByStatus: async (status) => {
const response = await api.get(`/assets/status/${status}`);
return response.data.data;
},
/**
* Get assets by type
*/
getByType: async (type) => {
const response = await api.get(`/assets/type/${type}`);
return response.data.data;
},
/**
* Create new asset
*/
create: async (assetData) => {
const response = await api.post('/assets', assetData);
return response.data.data;
},
/**
* Update asset
*/
update: async (id, assetData) => {
const response = await api.put(`/assets/${id}`, assetData);
return response.data.data;
},
/**
* Delete asset
*/
delete: async (id) => {
const response = await api.delete(`/assets/${id}`);
return response.data;
},
/**
* Assign asset to user
*/
assign: async (id, userId, notes) => {
const response = await api.post(`/assets/${id}/assign`, {
user_id: userId,
notes: notes
});
return response.data.data;
},
/**
* Unassign asset from user
*/
unassign: async (id, newStatus) => {
const response = await api.post(`/assets/${id}/unassign`, {
new_status: newStatus
});
return response.data.data;
},
/**
* Get assignment history for an asset
*/
getAssignmentHistory: async (id) => {
const response = await api.get(`/assets/${id}/history`);
return response.data.data;
},
/**
* Get statistics
*/
getStatistics: async () => {
const response = await api.get('/assets/stats');
return response.data.data;
},
/**
* Open printable label PDF in new tab
*/
printLabel: async (id, copies = 1, size = 'medium') => {
const response = await api.get(`/assets/${id}/label`, {
params: { copies, size },
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60000);
},
/**
* Import managed devices from Microsoft Intune
* Returns { imported, skipped, errors }
*/
importFromIntune: async () => {
const response = await api.post('/assets/import/intune');
return response.data.data;
},
/**
* Get all inspections for an asset
*/
getInspections: (assetId) => api.get(`/assets/${assetId}/inspections`).then(r => r.data.data),
/**
* Create an inspection for an asset
*/
createInspection: (assetId, data) => api.post(`/assets/${assetId}/inspections`, data).then(r => r.data.data),
/**
* Open handover protocol PDF in new tab
*/
printHandoverProtocol: async (id) => {
const response = await api.get(`/assets/${id}/handover-protocol`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60000);
},
// Asset Types
getTypes: async () => {
const response = await api.get('/asset-types');
return response.data;
},
createType: async (data) => {
const response = await api.post('/asset-types', data);
return response.data;
},
updateType: async (id, data) => {
const response = await api.put(`/asset-types/${id}`, data);
return response.data;
},
deleteType: async (id) => {
await api.delete(`/asset-types/${id}`);
},
syncFromAgent: async (id) => {
const response = await api.post(`/assets/${id}/sync-agent`);
return response.data;
},
};
export default assetService;

View File

@@ -0,0 +1,88 @@
import api from './api';
const authService = {
/**
* Login user
*/
login: async (username, password) => {
const response = await api.post('/auth/login', { username, password });
if (response.data.data.token) {
localStorage.setItem('token', response.data.data.token);
localStorage.setItem('user', JSON.stringify(response.data.data.user));
}
return response.data.data;
},
/**
* Logout user
*/
logout: async () => {
try {
await api.post('/auth/logout');
} catch (error) {
// Ignore errors on logout
} finally {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
},
/**
* Get current user info
*/
getCurrentUser: async () => {
const response = await api.get('/auth/me');
return response.data.data;
},
/**
* Change password
*/
changePassword: async (currentPassword, newPassword) => {
const response = await api.post('/auth/change-password', {
currentPassword,
newPassword,
});
return response.data.data;
},
/**
* Get user from localStorage
*/
getStoredUser: () => {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
},
/**
* Get token from localStorage
*/
getToken: () => {
return localStorage.getItem('token');
},
/**
* Check if user is authenticated
*/
isAuthenticated: () => {
return !!localStorage.getItem('token');
},
/**
* Fetch user data using a token (for kiosk URL-token login)
*/
fetchUserFromToken: async (token) => {
try {
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return null;
const data = await response.json();
return data.data || data.user || data;
} catch {
return null;
}
},
};
export default authService;

View File

@@ -0,0 +1,15 @@
import api from './api';
const BASE = '/entra';
const entraService = {
getUsers: async () => { const r = await api.get(`${BASE}/users`); return r.data.data; },
getUserGroups: async (userId) => { const r = await api.get(`${BASE}/users/${userId}/groups`); return r.data.data; },
getGroups: async () => { const r = await api.get(`${BASE}/groups`); return r.data.data; },
getGroupMembers: async (groupId) => { const r = await api.get(`${BASE}/groups/${groupId}/members`); return r.data.data; },
addGroupMember: async (groupId, userId) => { const r = await api.post(`${BASE}/groups/${groupId}/members`, { userId }); return r.data; },
removeGroupMember: async (groupId, userId) => { const r = await api.delete(`${BASE}/groups/${groupId}/members/${userId}`); return r.data; },
getRoles: async () => { const r = await api.get(`${BASE}/roles`); return r.data.data; },
};
export default entraService;

View File

@@ -0,0 +1,12 @@
import api from './api';
const base = '/external-alerts';
const externalAlertService = {
getAll: (params) => api.get(base, { params }).then(r => r.data.data),
acknowledge: (id) => api.post(`${base}/${id}/acknowledge`).then(r => r.data.data),
createTicket: (id) => api.post(`${base}/${id}/create-ticket`).then(r => r.data.data),
remove: (id) => api.delete(`${base}/${id}`).then(r => r.data),
};
export default externalAlertService;

View File

@@ -0,0 +1,77 @@
import api from './api';
const fidoKeyService = {
/**
* Get all FIDO keys
*/
getAll: async () => {
const response = await api.get('/fido-keys');
return response.data.data;
},
/**
* Get FIDO key by ID
*/
getById: async (id) => {
const response = await api.get(`/fido-keys/${id}`);
return response.data.data;
},
/**
* Get FIDO key by serial number
*/
getBySerial: async (serialNumber) => {
const response = await api.get(`/fido-keys/serial/${serialNumber}`);
return response.data.data;
},
/**
* Get FIDO keys by status
*/
getByStatus: async (status) => {
const response = await api.get(`/fido-keys/status/${status}`);
return response.data.data;
},
/**
* Create new FIDO key
*/
create: async (keyData) => {
const response = await api.post('/fido-keys', keyData);
return response.data.data;
},
/**
* Update FIDO key
*/
update: async (id, keyData) => {
const response = await api.put(`/fido-keys/${id}`, keyData);
return response.data.data;
},
/**
* Update FIDO key status
*/
updateStatus: async (id, status) => {
const response = await api.put(`/fido-keys/${id}/status`, { status });
return response.data.data;
},
/**
* Delete FIDO key
*/
delete: async (id) => {
const response = await api.delete(`/fido-keys/${id}`);
return response.data;
},
/**
* Get statistics
*/
getStatistics: async () => {
const response = await api.get('/fido-keys/stats');
return response.data.data;
},
};
export default fidoKeyService;

View File

@@ -0,0 +1,23 @@
import api from './api';
const BASE = '/iso-tasks';
const isoService = {
getAll: async (params = {}) => {
const res = await api.get(BASE, { params });
return res.data.data;
},
create: async (data) => {
const res = await api.post(BASE, data);
return res.data.data;
},
update: async (id, data) => {
const res = await api.put(`${BASE}/${id}`, data);
return res.data.data;
},
delete: async (id) => {
await api.delete(`${BASE}/${id}`);
},
};
export default isoService;

View File

@@ -0,0 +1,31 @@
import api from './api';
const BASE = '/it-topics';
const itTopicService = {
getAll: async (params = {}) => {
const res = await api.get(BASE, { params });
return res.data.data;
},
getOne: async (id) => {
const res = await api.get(`${BASE}/${id}`);
return res.data.data;
},
create: async (data) => {
const res = await api.post(BASE, data);
return res.data.data;
},
update: async (id, data) => {
const res = await api.put(`${BASE}/${id}`, data);
return res.data.data;
},
delete: async (id) => {
await api.delete(`${BASE}/${id}`);
},
syncToPlanner: async () => {
const res = await api.post(`${BASE}/planner/sync`);
return res.data.data;
},
};
export default itTopicService;

View File

@@ -0,0 +1,45 @@
import api from './api';
const licenseService = {
getAll: async () => {
const response = await api.get('/licenses');
return response.data.data;
},
getById: async (id) => {
const response = await api.get(`/licenses/${id}`);
return response.data.data;
},
create: async (data) => {
const response = await api.post('/licenses', data);
return response.data.data;
},
update: async (id, data) => {
const response = await api.put(`/licenses/${id}`, data);
return response.data.data;
},
delete: async (id) => {
const response = await api.delete(`/licenses/${id}`);
return response.data;
},
getStatistics: async () => {
const response = await api.get('/licenses/statistics');
return response.data.data;
},
importFromEntra: async () => {
const response = await api.post('/licenses/import/entra');
return response.data.data;
},
getLicenseUsers: async (id) => {
const response = await api.get(`/licenses/${id}/users`);
return response.data.data;
},
};
export default licenseService;

View File

@@ -0,0 +1,10 @@
import api from './api';
const monitoringService = {
getAll: () => api.get('/monitoring').then(r => r.data.data),
getStatistics: () => api.get('/monitoring/statistics').then(r => r.data.data),
getById: (id) => api.get(`/monitoring/${id}`).then(r => r.data.data),
delete: (id) => api.delete(`/monitoring/${id}`).then(r => r.data),
};
export default monitoringService;

View File

@@ -0,0 +1,24 @@
import api from './api';
const base = '/network-monitor';
const networkMonitorService = {
getAll: () => api.get(base).then(r => r.data.data),
getStats: () => api.get(`${base}/statistics`).then(r => r.data.data),
getChecks: (id, h) => api.get(`${base}/${id}/checks?hours=${h}`).then(r => r.data.data),
create: (data) => api.post(base, data).then(r => r.data.data),
update: (id, d) => api.put(`${base}/${id}`, d).then(r => r.data.data),
delete: (id) => api.delete(`${base}/${id}`),
checkNow: (id) => api.post(`${base}/${id}/check-now`).then(r => r.data.data),
discover: (subnet) => api.post(`${base}/discover`, { subnet }, { timeout: 120000 }).then(r => r.data),
getUptimeStats: () => api.get(`${base}/uptime-stats`).then(r => r.data.data),
createSSE: () => {
const token = localStorage.getItem('token');
const apiBase = process.env.REACT_APP_API_URL || '/api';
return new EventSource(`${apiBase}/network-monitor/sse?token=${token}`);
},
};
export default networkMonitorService;

View File

@@ -0,0 +1,78 @@
import api from './api';
const offboardingService = {
/**
* Get all offboarding protocols
*/
getAll: async () => {
const response = await api.get('/offboarding');
return response.data.data;
},
/**
* Get offboarding protocol by ID
*/
getById: async (id) => {
const response = await api.get(`/offboarding/${id}`);
return response.data.data;
},
/**
* Create new offboarding protocol
*/
create: async (protocolData) => {
const response = await api.post('/offboarding', protocolData);
return response.data.data;
},
/**
* Update offboarding protocol
*/
update: async (id, protocolData) => {
const response = await api.put(`/offboarding/${id}`, protocolData);
return response.data.data;
},
/**
* Return assets for offboarding
*/
returnAssets: async (id, assetReturns) => {
const response = await api.post(`/offboarding/${id}/return-assets`, {
asset_returns: assetReturns
});
return response.data.data;
},
/**
* Regenerate PDF for offboarding protocol
*/
regeneratePdf: async (id) => {
const response = await api.post(`/offboarding/${id}/regenerate-pdf`);
return response.data.data;
},
/**
* Delete offboarding protocol
*/
delete: async (id) => {
const response = await api.delete(`/offboarding/${id}`);
return response.data;
},
/**
* Get statistics
*/
getStatistics: async () => {
const response = await api.get('/offboarding/stats');
return response.data.data;
},
/**
* Download PDF
*/
downloadPdf: (pdfPath) => {
return `/${pdfPath}`;
},
};
export default offboardingService;

View File

@@ -0,0 +1,54 @@
import api from './api';
const BASE = '/onboarding-processes';
const onboardingProcessService = {
// Departments
getDepartments: async () => {
const res = await api.get(`${BASE}/departments`);
return res.data.data;
},
createDepartment: async (data) => {
const res = await api.post(`${BASE}/departments`, data);
return res.data.data;
},
updateDepartment: async (id, data) => {
const res = await api.put(`${BASE}/departments/${id}`, data);
return res.data.data;
},
deleteDepartment: async (id) => {
await api.delete(`${BASE}/departments/${id}`);
},
reorderDepartments: async (ids) => {
await api.put(`${BASE}/departments/reorder`, { ids });
},
// Processes
getProcesses: async (params = {}) => {
const res = await api.get(`${BASE}/processes`, { params });
return res.data.data;
},
getChecklistProcesses: async (appliesTo, departmentId) => {
// appliesTo: 'onboarding' or 'offboarding'; departmentId: optional filter
const params = { applies_to: appliesTo };
if (departmentId) params.department_id = departmentId;
const res = await api.get(`${BASE}/processes`, { params });
return res.data.data;
},
createProcess: async (data) => {
const res = await api.post(`${BASE}/processes`, data);
return res.data.data;
},
updateProcess: async (id, data) => {
const res = await api.put(`${BASE}/processes/${id}`, data);
return res.data.data;
},
deleteProcess: async (id) => {
await api.delete(`${BASE}/processes/${id}`);
},
reorderProcesses: async (department_id, ids) => {
await api.put(`${BASE}/processes/reorder`, { department_id, ids });
},
};
export default onboardingProcessService;

View File

@@ -0,0 +1,76 @@
import api from './api';
const onboardingService = {
/**
* Get all onboarding protocols
*/
getAll: async () => {
const response = await api.get('/onboarding');
return response.data.data;
},
/**
* Get onboarding protocol by ID
*/
getById: async (id) => {
const response = await api.get(`/onboarding/${id}`);
return response.data.data;
},
/**
* Create new onboarding protocol
*/
create: async (protocolData) => {
const response = await api.post('/onboarding', protocolData);
return response.data.data;
},
/**
* Update onboarding protocol
*/
update: async (id, protocolData) => {
const response = await api.put(`/onboarding/${id}`, protocolData);
return response.data.data;
},
/**
* Regenerate PDF for onboarding protocol
*/
regeneratePdf: async (id) => {
const response = await api.post(`/onboarding/${id}/regenerate-pdf`);
return response.data.data;
},
/**
* Send confirmation email with PDF to employee
*/
sendConfirmationEmail: async (id) => {
const response = await api.post(`/onboarding/${id}/send-confirmation`);
return response.data;
},
/**
* Delete onboarding protocol
*/
delete: async (id) => {
const response = await api.delete(`/onboarding/${id}`);
return response.data;
},
/**
* Get statistics
*/
getStatistics: async () => {
const response = await api.get('/onboarding/stats');
return response.data.data;
},
/**
* Download PDF
*/
downloadPdf: (pdfPath) => {
return `/${pdfPath}`;
},
};
export default onboardingService;

View File

@@ -0,0 +1,26 @@
import api from './api';
const portalGuideService = {
getAll: async () => {
const res = await api.get('/portal-guides');
return res.data;
},
getById: async (id) => {
const res = await api.get(`/portal-guides/${id}`);
return res.data;
},
getHtmlUrl: (id) => `/api/portal-guides/${id}/html`,
create: async (data) => {
const res = await api.post('/portal-guides', data);
return res.data;
},
update: async (id, data) => {
const res = await api.put(`/portal-guides/${id}`, data);
return res.data;
},
delete: async (id) => {
await api.delete(`/portal-guides/${id}`);
},
};
export default portalGuideService;

View File

@@ -0,0 +1,14 @@
import api from './api';
const BASE = '/risks';
const riskService = {
getAll: async (params = {}) => { const r = await api.get(BASE, { params }); return r.data.data; },
getStats: async () => { const r = await api.get(`${BASE}/stats`); return r.data.data; },
sync: async () => { const r = await api.post(`${BASE}/sync`); return r.data.data; },
create: async (data) => { const r = await api.post(BASE, data); return r.data.data; },
update: async (id, data) => { const r = await api.put(`${BASE}/${id}`, data); return r.data.data; },
delete: async (id) => { await api.delete(`${BASE}/${id}`); },
};
export default riskService;

View File

@@ -0,0 +1,10 @@
import api from './api';
const scannerService = {
getSites: () => api.get('/scanner/sites').then(r => r.data.data),
getAssets: (params) => api.get('/scanner/assets', { params }).then(r => r.data.data),
getAlerts: (params) => api.get('/scanner/alerts', { params }).then(r => r.data.data),
getStats: () => api.get('/scanner/stats').then(r => r.data.data),
};
export default scannerService;

View File

@@ -0,0 +1,60 @@
const API = process.env.REACT_APP_API_URL || '/api';
const getHeaders = () => ({
Authorization: `Bearer ${localStorage.getItem('token')}`,
});
export const createShare = async (formData) => {
const res = await fetch(`${API}/shares`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
body: formData, // FormData — kein Content-Type Header setzen!
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Erstellen');
}
return res.json();
};
export const getAllShares = async () => {
const res = await fetch(`${API}/shares`, { headers: getHeaders() });
if (!res.ok) throw new Error('Fehler beim Laden');
return res.json();
};
export const deleteShare = async (id) => {
const res = await fetch(`${API}/shares/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) throw new Error('Fehler beim Löschen');
return res.json();
};
export const getPublicShare = async (token) => {
const res = await fetch(`${API}/shares/public/${token}`);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Nicht gefunden');
}
return res.json();
};
export const accessShare = async (token, password) => {
const res = await fetch(`${API}/shares/public/${token}/access`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Zugriff verweigert');
}
return res.json();
};
export const getFileDownloadUrl = (token, password) => {
const base = `${API}/shares/public/${token}/file`;
return password ? `${base}?password=${encodeURIComponent(password)}` : base;
};

View File

@@ -0,0 +1,8 @@
import api from './api';
const teamsActivityService = {
getNewChannels: () => api.get('/teams-activity/new-channels').then(r => r.data.data),
markRead: () => api.post('/teams-activity/mark-read'),
};
export default teamsActivityService;

View File

@@ -0,0 +1,106 @@
import api from './api';
const ticketService = {
getAll: async (filters = {}) => {
const params = {};
if (filters.status) params.status = filters.status;
if (filters.priority) params.priority = filters.priority;
if (filters.category) params.category = filters.category;
if (filters.assigned_to) params.assigned_to = filters.assigned_to;
if (filters.search) params.search = filters.search;
const response = await api.get('/tickets', { params });
return response.data.data;
},
getById: async (id) => {
const response = await api.get(`/tickets/${id}`);
return response.data.data;
},
getStats: async () => {
const response = await api.get('/tickets/stats');
return response.data.data;
},
getMetrics: async (days = 30) => {
const response = await api.get('/tickets/metrics', { params: { days } });
return response.data.data;
},
create: async (data) => {
const response = await api.post('/tickets', data);
return response.data.data;
},
update: async (id, data) => {
const response = await api.put(`/tickets/${id}`, data);
return response.data.data;
},
bulkUpdate: async (ids, data) => {
const response = await api.put('/tickets/bulk', { ids, data });
return response.data;
},
delete: async (id) => {
const response = await api.delete(`/tickets/${id}`);
return response.data;
},
addComment: async (id, comment, isInternal = false) => {
const response = await api.post(`/tickets/${id}/comments`, {
comment,
is_internal: isInternal,
});
return response.data.data;
},
aiReply: async (id, message, history = []) => {
const response = await api.post(`/tickets/${id}/ai-reply`, { message, history });
return response.data.data;
},
deleteComment: async (ticketId, commentId) => {
const response = await api.delete(`/tickets/${ticketId}/comments/${commentId}`);
return response.data;
},
getHistory: async (id) => {
const response = await api.get(`/tickets/${id}/history`);
return response.data.data;
},
getPdfUrl: (id) => `/api/tickets/${id}/pdf`,
getLinks: async (id) => {
const response = await api.get(`/tickets/${id}/links`);
return response.data.data;
},
addLink: async (id, linked_ticket_number, link_type = 'related') => {
const response = await api.post(`/tickets/${id}/links`, { linked_ticket_number, link_type });
return response.data.data;
},
removeLink: async (ticketId, linkId) => {
const response = await api.delete(`/tickets/${ticketId}/links/${linkId}`);
return response.data;
},
snooze: async (id, snoozed_until) => {
const response = await api.put(`/tickets/${id}/snooze`, { snoozed_until });
return response.data.data;
},
addAssignee: async (ticketId, userId) => {
const response = await api.post(`/tickets/${ticketId}/assignees`, { user_id: userId });
return response.data.data;
},
removeAssignee: async (ticketId, userId) => {
const response = await api.delete(`/tickets/${ticketId}/assignees/${userId}`);
return response.data.data;
},
};
export default ticketService;

View File

@@ -0,0 +1,9 @@
import api from './api';
const base = '/unifi';
const unifiService = {
getConfig: () => api.get(`${base}/config`).then(r => r.data.data),
saveConfig: (cfg) => api.post(`${base}/config`, cfg).then(r => r.data.data),
getDevices: () => api.get(`${base}/devices`).then(r => r.data.data),
sync: () => api.post(`${base}/sync`).then(r => r.data.data),
};
export default unifiService;

View File

@@ -0,0 +1,94 @@
import api from './api';
const userService = {
/**
* Get all users
*/
getAll: async () => {
const response = await api.get('/users');
return response.data.data;
},
/**
* Get user by ID
*/
getById: async (id) => {
const response = await api.get(`/users/${id}`);
return response.data.data;
},
/**
* Create new user
*/
create: async (userData) => {
const response = await api.post('/users', userData);
return response.data.data;
},
/**
* Update user
*/
update: async (id, userData) => {
const response = await api.put(`/users/${id}`, userData);
return response.data.data;
},
/**
* Delete user
*/
delete: async (id) => {
const response = await api.delete(`/users/${id}`);
return response.data;
},
/**
* Assign role to user
*/
assignRole: async (id, roleId) => {
const response = await api.put(`/users/${id}/role`, { role_id: roleId });
return response.data.data;
},
/**
* Activate/Deactivate user
*/
toggleStatus: async (id, isActive) => {
const response = await api.put(`/users/${id}/activate`, { is_active: isActive });
return response.data.data;
},
/**
* Get all roles
*/
getRoles: async () => {
const response = await api.get('/users/roles');
return response.data.data;
},
/**
* Get available Azure AD groups for import
*/
getAzureGroups: async () => {
const response = await api.get('/users/import/azure/groups');
return response.data.data;
},
/**
* Import users from an Azure AD group
* Returns { imported, skipped, errors }
*/
importFromAzure: async (groupId, roleId) => {
const response = await api.post('/users/import/azure', { group_id: groupId, role_id: roleId });
return response.data.data;
},
/**
* Get audit logs
*/
getAuditLogs: async (limit = 100, offset = 0) => {
const response = await api.get(`/users/audit-logs?limit=${limit}&offset=${offset}`);
return response.data.data;
},
};
export default userService;

View File

@@ -0,0 +1,35 @@
import api from './api';
const base = '/warehouse';
const warehouseService = {
// Summary
getSummary: () => api.get(`${base}/summary`).then(r => r.data),
getViolations: () => api.get(`${base}/violations`).then(r => r.data),
// Locations
getLocations: () => api.get(`${base}/locations`).then(r => r.data),
createLocation: (data) => api.post(`${base}/locations`, data).then(r => r.data),
updateLocation: (id, d) => api.put(`${base}/locations/${id}`, d).then(r => r.data),
deleteLocation: (id) => api.delete(`${base}/locations/${id}`).then(r => r.data),
// Movements
getMovements: (params) => api.get(`${base}/movements`, { params }).then(r => r.data),
createMovement: (data) => api.post(`${base}/movements`, data).then(r => r.data),
// Stock thresholds
getThresholds: () => api.get(`${base}/thresholds`).then(r => r.data),
upsertThreshold: (data) => api.post(`${base}/thresholds`, data).then(r => r.data),
deleteThreshold: (cat) => api.delete(`${base}/thresholds/${encodeURIComponent(cat)}`).then(r => r.data),
// Purchase orders
getOrders: (params) => api.get(`${base}/orders`, { params }).then(r => r.data),
createOrder: (data) => api.post(`${base}/orders`, data).then(r => r.data),
updateOrder: (id, d) => api.put(`${base}/orders/${id}`, d).then(r => r.data),
deleteOrder: (id) => api.delete(`${base}/orders/${id}`).then(r => r.data),
// QR Code URL
getQrUrl: (assetId) => `${api.defaults.baseURL}${base}/qr/${assetId}`,
};
export default warehouseService;

3211
frontend/src/styles/App.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
/**
* AfA (Abschreibung) Utility-Funktionen
* Lineare Abschreibungsberechnung für das Anlagevermögen-Modul
*/
/**
* Berechnet AfA-Werte für ein Asset
* @param {number} purchasePrice - Anschaffungswert in €
* @param {number} usefulLifeYears - Nutzungsdauer in Jahren
* @param {number} residualValue - Restwert nach Abschreibung (Standard: 0)
* @param {string} purchaseDateStr - Kaufdatum als ISO-String (YYYY-MM-DD)
* @returns {object|null} AfA-Werte oder null bei fehlenden Pflichtfeldern
*/
export function calculateAfa(purchasePrice, usefulLifeYears, residualValue = 0, purchaseDateStr) {
if (!purchasePrice || !usefulLifeYears || !purchaseDateStr) return null;
const price = parseFloat(purchasePrice);
const years = parseInt(usefulLifeYears);
const residual = parseFloat(residualValue) || 0;
if (price <= 0 || years <= 0) return null;
const purchaseDate = new Date(purchaseDateStr);
const now = new Date();
const yearsSincePurchase = (now - purchaseDate) / (1000 * 60 * 60 * 24 * 365.25);
const annualDepreciation = (price - residual) / years;
const cumulativeDepreciation = Math.min(price - residual, annualDepreciation * yearsSincePurchase);
const currentBookValue = Math.max(residual, price - cumulativeDepreciation);
const fullyDepreciatedDate = new Date(purchaseDate);
fullyDepreciatedDate.setFullYear(fullyDepreciatedDate.getFullYear() + years);
const isFullyDepreciated = now >= fullyDepreciatedDate;
return {
annualDepreciation,
cumulativeDepreciation,
currentBookValue,
fullyDepreciatedDate,
isFullyDepreciated,
};
}
/**
* Formatiert einen Wert als Euro-String (deutsche Notation)
* @param {number} value
* @returns {string}
*/
export function formatEuro(value) {
if (value === null || value === undefined || isNaN(value)) return '';
return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
}
/**
* Gibt die empfohlene Standard-Nutzungsdauer für einen Asset-Typ zurück
* @param {string} type - Asset-Typ
* @returns {number} Nutzungsdauer in Jahren
*/
export function getDefaultUsefulLife(type) {
const defaults = {
'Notebook': 3,
'Monitor': 3,
'Headset': 3,
'Maschine': 8,
'Werkzeug': 5,
'Sonstiges': 5,
'Other': 5,
};
return defaults[type] || 5;
}