Initial commit: IT Nexus Web-App
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user