Initial commit: IT Nexus Web-App
This commit is contained in:
39
frontend/src/App.js
Normal file
39
frontend/src/App.js
Normal 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;
|
||||
189
frontend/src/components/assets/AssetAssignModal.jsx
Normal file
189
frontend/src/components/assets/AssetAssignModal.jsx
Normal 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;
|
||||
380
frontend/src/components/assets/AssetModal.jsx
Normal file
380
frontend/src/components/assets/AssetModal.jsx
Normal 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 & 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;
|
||||
121
frontend/src/components/assets/InspectionSection.jsx
Normal file
121
frontend/src/components/assets/InspectionSection.jsx
Normal 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;
|
||||
333
frontend/src/components/assets/ProduktionAssetWizard.jsx
Normal file
333
frontend/src/components/assets/ProduktionAssetWizard.jsx
Normal 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;
|
||||
296
frontend/src/components/common/AiChatWidget.jsx
Normal file
296
frontend/src/components/common/AiChatWidget.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/common/AppLayout.jsx
Normal file
19
frontend/src/components/common/AppLayout.jsx
Normal 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;
|
||||
157
frontend/src/components/common/ChecklistEditor.jsx
Normal file
157
frontend/src/components/common/ChecklistEditor.jsx
Normal 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;
|
||||
11
frontend/src/components/common/LoadingSpinner.jsx
Normal file
11
frontend/src/components/common/LoadingSpinner.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const LoadingSpinner = () => {
|
||||
return (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
16
frontend/src/components/common/Logo.jsx
Normal file
16
frontend/src/components/common/Logo.jsx
Normal 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;
|
||||
400
frontend/src/components/common/Navbar.jsx
Normal file
400
frontend/src/components/common/Navbar.jsx
Normal 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;
|
||||
32
frontend/src/components/common/ProtectedRoute.jsx
Normal file
32
frontend/src/components/common/ProtectedRoute.jsx
Normal 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;
|
||||
172
frontend/src/components/common/Sidebar.jsx
Normal file
172
frontend/src/components/common/Sidebar.jsx
Normal 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;
|
||||
237
frontend/src/components/common/Topbar.jsx
Normal file
237
frontend/src/components/common/Topbar.jsx
Normal 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;
|
||||
101
frontend/src/context/AuthContext.jsx
Normal file
101
frontend/src/context/AuthContext.jsx
Normal 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;
|
||||
};
|
||||
164
frontend/src/context/NotificationContext.jsx
Normal file
164
frontend/src/context/NotificationContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
frontend/src/context/ThemeContext.jsx
Normal file
42
frontend/src/context/ThemeContext.jsx
Normal 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
11
frontend/src/index.js
Normal 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>
|
||||
);
|
||||
660
frontend/src/pages/AgentDetailPage.jsx
Normal file
660
frontend/src/pages/AgentDetailPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
375
frontend/src/pages/AiPage.jsx
Normal file
375
frontend/src/pages/AiPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
frontend/src/pages/AnlagevermoegenPage.jsx
Normal file
244
frontend/src/pages/AnlagevermoegenPage.jsx
Normal 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­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;
|
||||
632
frontend/src/pages/ApiDocsPage.jsx
Normal file
632
frontend/src/pages/ApiDocsPage.jsx
Normal 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-Austrittsprozess 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 <JWT></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;
|
||||
674
frontend/src/pages/AssetsPage.jsx
Normal file
674
frontend/src/pages/AssetsPage.jsx
Normal 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;
|
||||
215
frontend/src/pages/ChangePasswordPage.jsx
Normal file
215
frontend/src/pages/ChangePasswordPage.jsx
Normal 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 (!@#$%^&*(),.?":{}|<>)
|
||||
</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;
|
||||
757
frontend/src/pages/DashboardPage.jsx
Normal file
757
frontend/src/pages/DashboardPage.jsx
Normal 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;
|
||||
174
frontend/src/pages/DefectReportPage.jsx
Normal file
174
frontend/src/pages/DefectReportPage.jsx
Normal 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;
|
||||
211
frontend/src/pages/DefenderPage.jsx
Normal file
211
frontend/src/pages/DefenderPage.jsx
Normal 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;
|
||||
245
frontend/src/pages/DockerPage.jsx
Normal file
245
frontend/src/pages/DockerPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
frontend/src/pages/DocsPage.jsx
Normal file
138
frontend/src/pages/DocsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
665
frontend/src/pages/EntraPage.jsx
Normal file
665
frontend/src/pages/EntraPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
352
frontend/src/pages/FidoKeysPage.jsx
Normal file
352
frontend/src/pages/FidoKeysPage.jsx
Normal 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;
|
||||
460
frontend/src/pages/HealthPage.jsx
Normal file
460
frontend/src/pages/HealthPage.jsx
Normal 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;
|
||||
358
frontend/src/pages/IsoPage.jsx
Normal file
358
frontend/src/pages/IsoPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
804
frontend/src/pages/ItOverviewPage.jsx
Normal file
804
frontend/src/pages/ItOverviewPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
frontend/src/pages/KnowledgeAiPage.jsx
Normal file
230
frontend/src/pages/KnowledgeAiPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
592
frontend/src/pages/KnowledgeBasePage.jsx
Normal file
592
frontend/src/pages/KnowledgeBasePage.jsx
Normal 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 & 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 1–2 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>
|
||||
);
|
||||
}
|
||||
746
frontend/src/pages/LicensesPage.jsx
Normal file
746
frontend/src/pages/LicensesPage.jsx
Normal 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)} {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;
|
||||
313
frontend/src/pages/LoginPage.jsx
Normal file
313
frontend/src/pages/LoginPage.jsx
Normal 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 Cereda‑IT‑Team.
|
||||
</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" /> 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;
|
||||
194
frontend/src/pages/MaintenancePage.jsx
Normal file
194
frontend/src/pages/MaintenancePage.jsx
Normal 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;
|
||||
1974
frontend/src/pages/MonitoringPage.jsx
Normal file
1974
frontend/src/pages/MonitoringPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
243
frontend/src/pages/MyAccountPage.jsx
Normal file
243
frontend/src/pages/MyAccountPage.jsx
Normal 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;
|
||||
802
frontend/src/pages/NetworkMonitorPage.jsx
Normal file
802
frontend/src/pages/NetworkMonitorPage.jsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
534
frontend/src/pages/OffboardingPage.jsx
Normal file
534
frontend/src/pages/OffboardingPage.jsx
Normal 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;
|
||||
1098
frontend/src/pages/OnOffboardingPage.jsx
Normal file
1098
frontend/src/pages/OnOffboardingPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
95
frontend/src/pages/OnboardingConfirmPage.jsx
Normal file
95
frontend/src/pages/OnboardingConfirmPage.jsx
Normal 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;
|
||||
466
frontend/src/pages/OnboardingPage.jsx
Normal file
466
frontend/src/pages/OnboardingPage.jsx
Normal 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;
|
||||
1012
frontend/src/pages/PatchManagementPage.jsx
Normal file
1012
frontend/src/pages/PatchManagementPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
410
frontend/src/pages/PortalPage.jsx
Normal file
410
frontend/src/pages/PortalPage.jsx
Normal 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,
|
||||
};
|
||||
638
frontend/src/pages/ProcessManagementPage.jsx
Normal file
638
frontend/src/pages/ProcessManagementPage.jsx
Normal 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;
|
||||
282
frontend/src/pages/ProxmoxPage.jsx
Normal file
282
frontend/src/pages/ProxmoxPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/src/pages/PublicSharePage.jsx
Normal file
201
frontend/src/pages/PublicSharePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
467
frontend/src/pages/RiskPage.jsx
Normal file
467
frontend/src/pages/RiskPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
933
frontend/src/pages/ScannerPage.css
Normal file
933
frontend/src/pages/ScannerPage.css
Normal 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;
|
||||
}
|
||||
473
frontend/src/pages/ScannerPage.jsx
Normal file
473
frontend/src/pages/ScannerPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
329
frontend/src/pages/SecurityReportsPage.jsx
Normal file
329
frontend/src/pages/SecurityReportsPage.jsx
Normal 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>
|
||||
);
|
||||
901
frontend/src/pages/SettingsPage.jsx
Normal file
901
frontend/src/pages/SettingsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
360
frontend/src/pages/SharesPage.jsx
Normal file
360
frontend/src/pages/SharesPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/pages/SystemPage.jsx
Normal file
114
frontend/src/pages/SystemPage.jsx
Normal 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;
|
||||
1027
frontend/src/pages/TVDashboardPage.jsx
Normal file
1027
frontend/src/pages/TVDashboardPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1249
frontend/src/pages/TicketDetailPage.jsx
Normal file
1249
frontend/src/pages/TicketDetailPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
316
frontend/src/pages/TicketMetricsPage.jsx
Normal file
316
frontend/src/pages/TicketMetricsPage.jsx
Normal 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;
|
||||
786
frontend/src/pages/TicketsPage.jsx
Normal file
786
frontend/src/pages/TicketsPage.jsx
Normal 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;
|
||||
1294
frontend/src/pages/UserManagementPage.jsx
Normal file
1294
frontend/src/pages/UserManagementPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1216
frontend/src/pages/UserPortalPage.jsx
Normal file
1216
frontend/src/pages/UserPortalPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
643
frontend/src/pages/UsersPage.jsx
Normal file
643
frontend/src/pages/UsersPage.jsx
Normal 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;
|
||||
546
frontend/src/pages/WarehousePage.jsx
Normal file
546
frontend/src/pages/WarehousePage.jsx
Normal 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
148
frontend/src/routes.jsx
Normal 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;
|
||||
89
frontend/src/services/aiService.js
Normal file
89
frontend/src/services/aiService.js
Normal 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;
|
||||
62
frontend/src/services/api.js
Normal file
62
frontend/src/services/api.js
Normal 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;
|
||||
173
frontend/src/services/assetService.js
Normal file
173
frontend/src/services/assetService.js
Normal 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;
|
||||
88
frontend/src/services/authService.js
Normal file
88
frontend/src/services/authService.js
Normal 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;
|
||||
15
frontend/src/services/entraService.js
Normal file
15
frontend/src/services/entraService.js
Normal 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;
|
||||
12
frontend/src/services/externalAlertService.js
Normal file
12
frontend/src/services/externalAlertService.js
Normal 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;
|
||||
77
frontend/src/services/fidoKeyService.js
Normal file
77
frontend/src/services/fidoKeyService.js
Normal 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;
|
||||
23
frontend/src/services/isoService.js
Normal file
23
frontend/src/services/isoService.js
Normal 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;
|
||||
31
frontend/src/services/itTopicService.js
Normal file
31
frontend/src/services/itTopicService.js
Normal 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;
|
||||
45
frontend/src/services/licenseService.js
Normal file
45
frontend/src/services/licenseService.js
Normal 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;
|
||||
10
frontend/src/services/monitoringService.js
Normal file
10
frontend/src/services/monitoringService.js
Normal 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;
|
||||
24
frontend/src/services/networkMonitorService.js
Normal file
24
frontend/src/services/networkMonitorService.js
Normal 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;
|
||||
78
frontend/src/services/offboardingService.js
Normal file
78
frontend/src/services/offboardingService.js
Normal 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;
|
||||
54
frontend/src/services/onboardingProcessService.js
Normal file
54
frontend/src/services/onboardingProcessService.js
Normal 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;
|
||||
76
frontend/src/services/onboardingService.js
Normal file
76
frontend/src/services/onboardingService.js
Normal 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;
|
||||
26
frontend/src/services/portalGuideService.js
Normal file
26
frontend/src/services/portalGuideService.js
Normal 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;
|
||||
14
frontend/src/services/riskService.js
Normal file
14
frontend/src/services/riskService.js
Normal 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;
|
||||
10
frontend/src/services/scannerService.js
Normal file
10
frontend/src/services/scannerService.js
Normal 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;
|
||||
60
frontend/src/services/shareService.js
Normal file
60
frontend/src/services/shareService.js
Normal 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;
|
||||
};
|
||||
8
frontend/src/services/teamsActivityService.js
Normal file
8
frontend/src/services/teamsActivityService.js
Normal 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;
|
||||
106
frontend/src/services/ticketService.js
Normal file
106
frontend/src/services/ticketService.js
Normal 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;
|
||||
9
frontend/src/services/unifiService.js
Normal file
9
frontend/src/services/unifiService.js
Normal 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;
|
||||
94
frontend/src/services/userService.js
Normal file
94
frontend/src/services/userService.js
Normal 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;
|
||||
35
frontend/src/services/warehouseService.js
Normal file
35
frontend/src/services/warehouseService.js
Normal 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
3211
frontend/src/styles/App.css
Normal file
File diff suppressed because it is too large
Load Diff
2059
frontend/src/styles/App.css.backup
Normal file
2059
frontend/src/styles/App.css.backup
Normal file
File diff suppressed because it is too large
Load Diff
71
frontend/src/utils/afaUtils.js
Normal file
71
frontend/src/utils/afaUtils.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user