Files
IT-Nexus/frontend/src/pages/AssetsPage.jsx
Simon Grüssing c23091fa6e Add: Agent v2.1.2, WebSocket Live Shell, WebRTC Remote Desktop (Beta), Announcement Push
- Agent v2.1.2: WebSocket ShellService, ACK nach WS-Ankündigungen, shown_announcements Fix
- Backend: shellServer.js mit WebSocket-Server (Shell + Announcement Push + RTC Signaling)
- Backend: patch.controller.js Fix (command_id=0 Falsy-Bug beim Auto-Update)
- Frontend: Remote Desktop Tab (WebRTC Beta) in AgentDetailPage für super_admin/admin
- Frontend: PatchManagementPage auf v2.1.2 aktualisiert
- WPF Notification: AllowsTransparency=False + kein DropShadowEffect (Dispatcher-Crash Fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:31:11 +02:00

1328 lines
73 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import { toast } from 'react-toastify';
/* ── helpers ── */
const authFetch = (url, opts = {}) => {
const token = localStorage.getItem('token');
return fetch(url, {
...opts,
headers: {
...(opts.headers || {}),
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
};
const API = process.env.REACT_APP_API_URL || '/api';
const TYPE_ICONS = {
notebook: '💻',
server: '🖧',
monitor: '🖥️',
headset: '🎧',
drucker: '🖨️',
sonstiges: '📦',
};
const getTypeIcon = (type) => {
if (!type) return '📦';
const t = type.toLowerCase();
return TYPE_ICONS[t] || '📦';
};
const STATUS_LABELS = {
verfuegbar: 'Verfügbar',
zugewiesen: 'Zugewiesen',
inaktiv: 'Inaktiv',
beschaedigt: 'Beschädigt',
};
const STATUS_CSS = {
verfuegbar: 'ap-badge-available',
zugewiesen: 'ap-badge-assigned',
inaktiv: 'ap-badge-inactive',
beschaedigt: 'ap-badge-damaged',
};
const fmtDate = (d) => {
if (!d) return '—';
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
};
const fmtCurrency = (n) => {
if (n === null || n === undefined || n === '') return '—';
return parseFloat(n).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
};
const calcBookValue = (asset) => {
if (!asset.purchase_price || !asset.purchase_date || !asset.useful_life_years) return null;
const price = parseFloat(asset.purchase_price);
const residual = parseFloat(asset.residual_value || 0);
const lifeMonths = parseFloat(asset.useful_life_years) * 12;
const depPerMonth = (price - residual) / lifeMonths;
const months = Math.max(0, (Date.now() - new Date(asset.purchase_date).getTime()) / (1000 * 60 * 60 * 24 * 30.44));
const depreciated = Math.min(price - residual, depPerMonth * months);
return Math.max(residual, price - depreciated);
};
const calcDepPercent = (asset) => {
if (!asset.purchase_price || !asset.purchase_date || !asset.useful_life_years) return 0;
const price = parseFloat(asset.purchase_price);
const residual = parseFloat(asset.residual_value || 0);
const lifeMonths = parseFloat(asset.useful_life_years) * 12;
const depPerMonth = (price - residual) / lifeMonths;
const months = Math.max(0, (Date.now() - new Date(asset.purchase_date).getTime()) / (1000 * 60 * 60 * 24 * 30.44));
const depreciated = Math.min(price - residual, depPerMonth * months);
return Math.round((depreciated / (price - residual || 1)) * 100);
};
const calcMonthlyDep = (asset) => {
if (!asset.purchase_price || !asset.useful_life_years) return null;
const price = parseFloat(asset.purchase_price);
const residual = parseFloat(asset.residual_value || 0);
const lifeMonths = parseFloat(asset.useful_life_years) * 12;
return (price - residual) / lifeMonths;
};
const relativeTime = (d) => {
if (!d) return '—';
const diff = Date.now() - new Date(d).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 2) return 'gerade eben';
if (mins < 60) return `vor ${mins} Min`;
const h = Math.floor(mins / 60);
if (h < 24) return `vor ${h} Std`;
return `vor ${Math.floor(h / 24)} Tag(en)`;
};
const isOnline = (agent) => {
if (!agent || !agent.last_checkin) return false;
const diff = Date.now() - new Date(agent.last_checkin).getTime();
return diff < 5 * 60 * 1000;
};
const initials = (name) => {
if (!name) return '??';
const parts = name.trim().split(' ');
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
};
/* ═══════════════════════════════════════════════════════════════
EDIT MODAL
═══════════════════════════════════════════════════════════════ */
const EditModal = ({ asset, onClose, onSaved }) => {
const [form, setForm] = useState({
name: asset.name || '',
type: asset.type || '',
model: asset.model || '',
serial_number: asset.serial_number || '',
manufacturer: asset.manufacturer || '',
os: asset.os || '',
ip_address: asset.ip_address || '',
inventory_number: asset.inventory_number || '',
department: asset.department || '',
purchase_date: asset.purchase_date ? asset.purchase_date.slice(0, 10) : '',
purchase_price: asset.purchase_price || '',
useful_life_years: asset.useful_life_years || '',
residual_value: asset.residual_value || '',
next_maintenance_date: asset.next_maintenance_date ? asset.next_maintenance_date.slice(0, 10) : '',
maintenance_interval_months: asset.maintenance_interval_months || '',
maintenance_notes: asset.maintenance_notes || '',
teamviewer_id: asset.teamviewer_id || '',
description: asset.description || '',
});
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const save = async () => {
setSaving(true);
try {
const res = await authFetch(`${API}/assets/${asset.id}`, {
method: 'PUT',
body: JSON.stringify(form),
});
if (!res.ok) throw new Error(await res.text());
toast.success('Asset gespeichert');
onSaved();
} catch (e) {
toast.error('Fehler beim Speichern: ' + e.message);
} finally {
setSaving(false);
}
};
const Field = ({ label, k, type = 'text', placeholder }) => (
<div className="ap-form-row">
<label className="ap-form-label">{label}</label>
<input
className="ap-form-input"
type={type}
placeholder={placeholder || ''}
value={form[k]}
onChange={e => set(k, e.target.value)}
/>
</div>
);
return (
<div className="ap-modal-overlay" onClick={onClose}>
<div className="ap-modal" style={{ maxWidth: 640 }} onClick={e => e.stopPropagation()}>
<div className="ap-modal-head">
<span className="ap-modal-title"> Asset bearbeiten {asset.name}</span>
<button className="ap-modal-close" onClick={onClose}>×</button>
</div>
<div className="ap-modal-body">
<div className="ap-modal-section-title">Gerätedaten</div>
<div className="ap-form-grid2">
<Field label="Name" k="name" />
<div className="ap-form-row">
<label className="ap-form-label">Typ</label>
<select className="ap-form-input" value={form.type} onChange={e => set('type', e.target.value)}>
<option value=""> Typ wählen </option>
<option value="notebook">Notebook</option>
<option value="server">Server</option>
<option value="monitor">Monitor</option>
<option value="headset">Headset</option>
<option value="drucker">Drucker</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<Field label="Hersteller" k="manufacturer" />
<Field label="Modell" k="model" />
<Field label="Seriennummer" k="serial_number" />
<Field label="Betriebssystem" k="os" />
<Field label="IP-Adresse" k="ip_address" />
<Field label="Inventarnummer" k="inventory_number" />
<Field label="Abteilung" k="department" />
<Field label="TeamViewer-ID" k="teamviewer_id" />
</div>
<div className="ap-modal-section-title" style={{ marginTop: 20 }}>Finanzen & Wartung</div>
<div className="ap-form-grid2">
<Field label="Kaufdatum" k="purchase_date" type="date" />
<Field label="Kaufpreis (€)" k="purchase_price" type="number" placeholder="0.00" />
<Field label="Nutzungsdauer (Jahre)" k="useful_life_years" type="number" placeholder="3" />
<Field label="Restwert (€)" k="residual_value" type="number" placeholder="0.00" />
<Field label="Nächste Wartung" k="next_maintenance_date" type="date" />
<Field label="Wartungsintervall (Monate)" k="maintenance_interval_months" type="number" placeholder="12" />
</div>
<div className="ap-form-row" style={{ marginTop: 10 }}>
<label className="ap-form-label">Wartungsnotizen</label>
<textarea
className="ap-form-input"
rows={3}
value={form.maintenance_notes}
onChange={e => set('maintenance_notes', e.target.value)}
/>
</div>
</div>
<div className="ap-modal-foot">
<button className="ap-btn ap-btn-outline" onClick={onClose}>Abbrechen</button>
<button className="ap-btn ap-btn-teal" onClick={save} disabled={saving}>
{saving ? 'Speichern…' : '✓ Speichern'}
</button>
</div>
</div>
</div>
);
};
/* ═══════════════════════════════════════════════════════════════
ASSIGN MODAL
═══════════════════════════════════════════════════════════════ */
const AssignModal = ({ asset, onClose, onAssigned }) => {
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState('');
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
authFetch(`${API}/users`)
.then(r => r.json())
.then(data => setUsers(data.data || data.users || (Array.isArray(data) ? data : [])))
.catch(() => {});
}, []);
const assign = async () => {
if (!selectedUser) { toast.error('Bitte Benutzer wählen'); return; }
setSaving(true);
try {
const res = await authFetch(`${API}/assets/${asset.id}/assign`, {
method: 'POST',
body: JSON.stringify({ user_id: selectedUser, notes }),
});
if (!res.ok) throw new Error(await res.text());
toast.success('Asset zugewiesen');
onAssigned();
} catch (e) {
toast.error('Fehler: ' + e.message);
} finally {
setSaving(false);
}
};
return (
<div className="ap-modal-overlay" onClick={onClose}>
<div className="ap-modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div className="ap-modal-head">
<span className="ap-modal-title">👤 Asset zuweisen {asset.name}</span>
<button className="ap-modal-close" onClick={onClose}>×</button>
</div>
<div className="ap-modal-body">
<div className="ap-form-row">
<label className="ap-form-label">Benutzer</label>
<select className="ap-form-input" value={selectedUser} onChange={e => setSelectedUser(e.target.value)}>
<option value=""> Benutzer wählen </option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.display_name || u.username} {u.email ? `(${u.email})` : ''}
</option>
))}
</select>
</div>
<div className="ap-form-row">
<label className="ap-form-label">Notizen (optional)</label>
<textarea
className="ap-form-input"
rows={3}
placeholder="z.B. Übergabe am Empfang"
value={notes}
onChange={e => setNotes(e.target.value)}
/>
</div>
</div>
<div className="ap-modal-foot">
<button className="ap-btn ap-btn-outline" onClick={onClose}>Abbrechen</button>
<button className="ap-btn ap-btn-teal" onClick={assign} disabled={saving}>
{saving ? 'Zuweisen…' : '✓ Zuweisen'}
</button>
</div>
</div>
</div>
);
};
/* ═══════════════════════════════════════════════════════════════
MAIN PAGE
═══════════════════════════════════════════════════════════════ */
const AssetsPage = () => {
const { user, isAdmin, isSuperAdmin, isSupport } = useAuth();
/* ── State: Liste ── */
const [assets, setAssets] = useState([]);
const [agents, setAgents] = useState([]);
const [stats, setStats] = useState({ total: 0, assigned: 0, available: 0, maintenance: 0 });
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [searchMode, setSearchMode] = useState('asset'); // 'asset' | 'user'
const [typeFilter, setTypeFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
/* ── State: Auswahl ── */
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [history, setHistory] = useState([]);
const [inspections, setInspections] = useState([]);
const [activeTab, setActiveTab] = useState('uebersicht');
/* ── State: Modals ── */
const [showEdit, setShowEdit] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const moreMenuRef = useRef(null);
/* ── State: Wartungsformular ── */
const [maintForm, setMaintForm] = useState({ type: 'Jährliche Inspektion', date: '', notes: '', result: '' });
const [maintSaving, setMaintSaving] = useState(false);
/* ── Permissions ── */
const canWrite = () => isAdmin() || isSuperAdmin() || isSupport();
/* ── Load ── */
const loadAssets = useCallback(async () => {
setLoading(true);
try {
const [aRes, agRes, stRes] = await Promise.all([
authFetch(`${API}/assets`),
authFetch(`${API}/monitoring`),
authFetch(`${API}/assets/stats`),
]);
const aData = aRes.ok ? await aRes.json() : {};
const agData = agRes.ok ? await agRes.json() : {};
const stData = stRes.ok ? await stRes.json() : {};
const aList = aData.data ?? aData.assets ?? (Array.isArray(aData) ? aData : []);
const agList = agData.data ?? agData.agents ?? (Array.isArray(agData) ? agData : []);
const st = stData.data ?? stData;
setAssets(aList);
setAgents(agList);
setStats({
total: st.total ?? aList.length,
assigned: st.assigned ?? aList.filter(a => a.status === 'zugewiesen').length,
available: st.available ?? aList.filter(a => a.status === 'verfuegbar').length,
maintenance: st.maintenance ?? aList.filter(a => a.status === 'inaktiv').length,
});
} catch {
toast.error('Fehler beim Laden der Assets');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadAssets(); }, [loadAssets]);
/* ── Load Detail ── */
useEffect(() => {
if (!selectedId) { setDetail(null); return; }
setDetailLoading(true);
setHistory([]);
setInspections([]);
setActiveTab('uebersicht');
Promise.all([
authFetch(`${API}/assets/${selectedId}`).then(r => r.ok ? r.json() : null),
authFetch(`${API}/assets/${selectedId}/history`).then(r => r.ok ? r.json() : []),
authFetch(`${API}/assets/${selectedId}/inspections`).then(r => r.ok ? r.json() : []),
]).then(([d, h, ins]) => {
setDetail(d?.data ?? d);
setHistory(Array.isArray(h) ? h : (h?.data || h?.history || []));
setInspections(Array.isArray(ins) ? ins : (ins?.data || ins?.inspections || []));
}).catch(() => toast.error('Fehler beim Laden der Details'))
.finally(() => setDetailLoading(false));
}, [selectedId]);
/* ── Close more-menu on outside click ── */
useEffect(() => {
if (!showMoreMenu) return;
const handler = (e) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target)) {
setShowMoreMenu(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showMoreMenu]);
/* ── Filtered list ── */
const filteredAssets = assets.filter(a => {
if (typeFilter !== 'all' && a.type?.toLowerCase() !== typeFilter) return false;
if (statusFilter !== 'all' && a.status !== statusFilter) return false;
if (!searchTerm) return true;
const q = searchTerm.toLowerCase();
return (
a.name?.toLowerCase().includes(q) ||
a.serial_number?.toLowerCase().includes(q) ||
a.inventory_number?.toLowerCase().includes(q) ||
a.model?.toLowerCase().includes(q) ||
a.assigned_to_username?.toLowerCase().includes(q) ||
a.assigned_to_name?.toLowerCase().includes(q) ||
a.manufacturer?.toLowerCase().includes(q)
);
});
/* ── Agent lookup: by serial (primary) or hostname (fallback) — NOT by IP (not unique with DHCP) ── */
const getAgent = (asset) => {
if (!asset || !agents.length) return null;
// Seriennummer ist eindeutig — erste Priorität
if (asset.serial_number) {
const bySerial = agents.find(ag => ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase());
if (bySerial) return bySerial;
}
// Hostname als Fallback
if (asset.name) {
const byHost = agents.find(ag => ag.hostname && ag.hostname.toLowerCase() === asset.name.toLowerCase());
if (byHost) return byHost;
}
return null;
};
/* ── PDF öffnen mit Auth-Token ── */
const openPdf = async (url) => {
try {
const res = await authFetch(url);
if (!res.ok) throw new Error('Fehler beim Laden');
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl, '_blank');
} catch (e) {
toast.error('PDF konnte nicht geöffnet werden: ' + e.message);
}
};
/* ── Actions ── */
const handleUnassign = async () => {
if (!detail) return;
if (!window.confirm('Zuweisung wirklich aufheben?')) return;
try {
const res = await authFetch(`${API}/assets/${detail.id}/unassign`, { method: 'POST', body: JSON.stringify({ new_status: 'verfuegbar' }) });
if (!res.ok) throw new Error(await res.text());
toast.success('Zuweisung aufgehoben');
loadAssets();
setSelectedId(null);
} catch (e) {
toast.error('Fehler: ' + e.message);
}
};
const handleDelete = async () => {
if (!detail) return;
if (!window.confirm(`Asset "${detail.name}" wirklich löschen?`)) return;
setShowMoreMenu(false);
try {
const res = await authFetch(`${API}/assets/${detail.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
toast.success('Asset gelöscht');
setSelectedId(null);
loadAssets();
} catch (e) {
toast.error('Fehler: ' + e.message);
}
};
const handleMaintSubmit = async () => {
if (!maintForm.date) { toast.error('Bitte Datum angeben'); return; }
setMaintSaving(true);
try {
const res = await authFetch(`${API}/assets/${detail.id}/inspections`, {
method: 'POST',
body: JSON.stringify(maintForm),
});
if (!res.ok) throw new Error(await res.text());
toast.success('Wartung eingetragen');
const ins = await authFetch(`${API}/assets/${detail.id}/inspections`).then(r => r.json());
setInspections(Array.isArray(ins) ? ins : (ins?.inspections || []));
setMaintForm({ type: 'Jährliche Inspektion', date: '', notes: '', result: '' });
} catch (e) {
toast.error('Fehler: ' + e.message);
} finally {
setMaintSaving(false);
}
};
const copyToClipboard = (text, btn) => {
navigator.clipboard.writeText(text || '').then(() => {
if (btn) { const o = btn.textContent; btn.textContent = '✓'; setTimeout(() => btn.textContent = o, 1500); }
});
};
/* ─────────────────────────────────────────────────────────
RENDER DETAIL
───────────────────────────────────────────────────────── */
const renderDetail = () => {
if (!selectedId) {
return (
<div className="ap-empty-state">
<div className="ap-empty-icon">📦</div>
<div className="ap-empty-text">Asset auswählen</div>
<div className="ap-empty-sub">Wähle ein Asset aus der Liste</div>
</div>
);
}
if (detailLoading) {
return <div className="ap-empty-state"><div className="ap-empty-icon"></div><div className="ap-empty-text">Laden</div></div>;
}
if (!detail) return null;
const agent = getAgent(detail);
const online = isOnline(agent);
const bookValue = calcBookValue(detail);
const depPercent = calcDepPercent(detail);
const monthlyDep = calcMonthlyDep(detail);
const nextMaint = detail.next_maintenance_date;
const maintSoon = nextMaint && (new Date(nextMaint) - Date.now()) < 30 * 24 * 60 * 60 * 1000;
const maintOverdue = nextMaint && new Date(nextMaint) < new Date();
return (
<>
{/* Header */}
<div className="ap-detail-header">
<div className="ap-detail-icon-box">
{getTypeIcon(detail.type)}
</div>
<div className="ap-detail-title-area">
<div className="ap-detail-name">{detail.name}</div>
<div className="ap-detail-sub">
{detail.inventory_number && <>{detail.inventory_number} &nbsp;·&nbsp;</>}
{detail.serial_number && <>SN: {detail.serial_number} &nbsp;·&nbsp;</>}
{detail.manufacturer || '—'}
</div>
<div className="ap-detail-badges">
{detail.type && (
<span className="ap-type-badge">{detail.type}</span>
)}
<span className={`ap-status-badge ${STATUS_CSS[detail.status] || ''}`}>
{STATUS_LABELS[detail.status] || detail.status}
</span>
{agent && (
<span className={`ap-online-badge ${online ? 'ap-online-badge--on' : 'ap-online-badge--off'}`}>
<span className={`ap-online-dot ${online ? 'ap-online-dot--on' : ''}`} />
{online ? 'Online' : 'Offline'}
</span>
)}
</div>
</div>
<div className="ap-detail-actions">
{canWrite() && (
<button className="ap-btn ap-btn-outline" onClick={() => setShowEdit(true)}>
Bearbeiten
</button>
)}
{canWrite() && detail.status === 'verfuegbar' && (
<button className="ap-btn ap-btn-teal" onClick={() => setShowAssign(true)}>
👤 Zuweisen
</button>
)}
{canWrite() && detail.status === 'zugewiesen' && (
<button className="ap-btn ap-btn-outline" onClick={() => setShowAssign(true)}>
🔄 Neu zuweisen
</button>
)}
<div className="ap-more-wrap" ref={moreMenuRef}>
<button className="ap-btn-icon" onClick={() => setShowMoreMenu(v => !v)}>···</button>
{showMoreMenu && (
<div className="ap-more-menu">
<button className="ap-more-item" onClick={() => {
openPdf(`${API}/assets/${detail.id}/label`);
setShowMoreMenu(false);
}}>🏷 Label drucken</button>
{detail.status === 'zugewiesen' && (
<button className="ap-more-item" onClick={() => {
openPdf(`${API}/assets/${detail.id}/handover-protocol`);
setShowMoreMenu(false);
}}>📄 Übergabeprotokoll</button>
)}
{detail.status === 'zugewiesen' && canWrite() && (
<button className="ap-more-item ap-more-item--danger" onClick={() => { setShowMoreMenu(false); handleUnassign(); }}>
🔓 Zuweisung aufheben
</button>
)}
{canWrite() && (
<button className="ap-more-item ap-more-item--danger" onClick={handleDelete}>
🗑 Löschen
</button>
)}
</div>
)}
</div>
</div>
</div>
{/* KPI-Karten */}
<div className="ap-kpi-grid">
<div className="ap-kpi-card">
<div className="ap-kpi-label">💰 Buchwert</div>
<div className="ap-kpi-value">{bookValue !== null ? fmtCurrency(bookValue) : '—'}</div>
<div className="ap-kpi-sub">
{detail.purchase_price ? `Kauf: ${fmtCurrency(detail.purchase_price)}` : 'Kein Kaufpreis'}
{detail.purchase_date ? ` (${fmtDate(detail.purchase_date)})` : ''}
</div>
</div>
<div className={`ap-kpi-card ${maintOverdue ? 'ap-kpi-card--red' : maintSoon ? 'ap-kpi-card--amber' : ''}`}>
<div className="ap-kpi-label">🔧 Nächste Wartung</div>
<div className="ap-kpi-value">
{nextMaint ? fmtDate(nextMaint) : '—'}
</div>
<div className="ap-kpi-sub">
{maintOverdue ? '⚠️ Überfällig' : maintSoon ? '⚠️ In < 30 Tagen' : nextMaint ? 'Geplant' : 'Kein Termin'}
</div>
</div>
<div className={`ap-kpi-card ${detail.assigned_to_name || detail.assigned_to_username ? 'ap-kpi-card--blue' : ''}`}>
<div className="ap-kpi-label">👤 Zugewiesen an</div>
<div className="ap-kpi-value" style={{ fontSize: 15 }}>
{detail.assigned_to_name || detail.assigned_to_username || '—'}
</div>
<div className="ap-kpi-sub">
{detail.assigned_since ? `seit ${fmtDate(detail.assigned_since)}` : detail.status === 'zugewiesen' ? 'Zugewiesen' : 'Nicht zugewiesen'}
</div>
</div>
<div className={`ap-kpi-card ${online ? 'ap-kpi-card--green' : ''}`}>
<div className="ap-kpi-label">🤖 Agent-Sync</div>
<div className="ap-kpi-value" style={{ fontSize: 15 }}>
{agent ? relativeTime(agent.last_checkin) : '—'}
</div>
<div className="ap-kpi-sub">
{agent ? `v${agent.agent_version || '?'} · ${online ? 'aktiv' : 'offline'}` : 'Kein Agent'}
</div>
</div>
</div>
{/* Tab-Bar */}
<div className="ap-tab-bar">
{['uebersicht', 'wartung', 'verlauf', 'agent', 'dokumente'].map(tab => (
<div
key={tab}
className={`ap-tab ${activeTab === tab ? 'ap-tab--active' : ''}`}
onClick={() => setActiveTab(tab)}
>
{{ uebersicht: 'Übersicht', wartung: 'Wartung', verlauf: 'Verlauf', agent: 'Agent-Daten', dokumente: 'Dokumente' }[tab]}
</div>
))}
</div>
{/* ── TAB: ÜBERSICHT ── */}
{activeTab === 'uebersicht' && (
<>
<div className="ap-content-grid2">
{/* Geräteinformationen */}
<div className="ap-card">
<div className="ap-card-title">🖥 Geräteinformationen</div>
{[
['Typ', detail.type],
['Hersteller', detail.manufacturer],
['Modell', detail.model],
['Seriennummer', detail.serial_number],
['Betriebssystem', detail.os],
['Inventarnummer', detail.inventory_number],
['Abteilung', detail.department],
].map(([label, val]) => (
<div key={label} className="ap-info-row">
<span className="ap-info-label">{label}</span>
<span className="ap-info-value">{val || '—'}</span>
</div>
))}
<div className="ap-info-row">
<span className="ap-info-label">IP-Adresse</span>
<span className="ap-info-value">
{detail.ip_address || '—'}
{detail.ip_address && (
<button className="ap-copy-btn" onClick={e => copyToClipboard(detail.ip_address, e.target)}>📋</button>
)}
</span>
</div>
</div>
{/* Finanzen & AfA */}
<div className="ap-card">
<div className="ap-card-title">📊 Finanzdaten &amp; AfA</div>
{[
['Kaufpreis', fmtCurrency(detail.purchase_price)],
['Kaufdatum', fmtDate(detail.purchase_date)],
['Nutzungsdauer', detail.useful_life_years ? `${detail.useful_life_years} Jahre` : '—'],
['Buchwert aktuell', bookValue !== null ? fmtCurrency(bookValue) : '—', bookValue !== null ? '#34d399' : null],
['Restwert', fmtCurrency(detail.residual_value)],
['Abschreibung/Monat', monthlyDep !== null ? fmtCurrency(monthlyDep) : '—'],
].map(([label, val, color]) => (
<div key={label} className="ap-info-row">
<span className="ap-info-label">{label}</span>
<span className="ap-info-value" style={color ? { color } : {}}>{val}</span>
</div>
))}
{detail.purchase_price && detail.useful_life_years && (
<div className="ap-afa-bar-wrap">
<div className="ap-afa-bar-label">
<span>Abgeschrieben</span>
<span>{depPercent} %</span>
</div>
<div className="ap-progress-bar">
<div
className={`ap-progress-fill ${depPercent > 80 ? 'ap-fill-red' : depPercent > 50 ? 'ap-fill-amber' : 'ap-fill-teal'}`}
style={{ width: `${Math.min(100, depPercent)}%` }}
/>
</div>
<div className="ap-afa-dates">
<span>Kauf: {fmtDate(detail.purchase_date)}</span>
{detail.purchase_date && detail.useful_life_years && (
<span>Ende: {fmtDate(new Date(new Date(detail.purchase_date).setFullYear(new Date(detail.purchase_date).getFullYear() + parseInt(detail.useful_life_years))))}</span>
)}
</div>
</div>
)}
</div>
</div>
<div className="ap-content-grid2">
{/* Zuweisung */}
<div className="ap-card">
<div className="ap-card-title">👤 Zuweisung</div>
{(detail.assigned_to_name || detail.assigned_to_username) ? (
<>
<div className="ap-user-card-inner">
<div className="ap-user-avatar">
{initials(detail.assigned_to_name || detail.assigned_to_username)}
</div>
<div>
<div className="ap-user-name">{detail.assigned_to_name || detail.assigned_to_username}</div>
{detail.assigned_to_email && (
<div className="ap-user-pos">{detail.assigned_to_email}</div>
)}
</div>
</div>
{detail.assigned_since && (
<div className="ap-info-row">
<span className="ap-info-label">Zugewiesen seit</span>
<span className="ap-info-value">{fmtDate(detail.assigned_since)}</span>
</div>
)}
{canWrite() && (
<div className="ap-info-row" style={{ borderBottom: 'none', marginTop: 6 }}>
<span />
<button className="ap-btn ap-btn-sm ap-btn-danger-outline" onClick={handleUnassign}>
Zuweisung aufheben
</button>
</div>
)}
</>
) : (
<div style={{ color: '#64748b', fontSize: 13, padding: '12px 0' }}>Kein Benutzer zugewiesen</div>
)}
</div>
{/* Agent-Status */}
<div className="ap-card">
<div className="ap-card-title">🤖 Agent-Status</div>
{agent ? (
<>
{agent.cpu_usage !== undefined && agent.cpu_usage !== null && (
<div className="ap-agent-metric">
<span className="ap-agent-metric-label">CPU</span>
<div className="ap-agent-metric-bar">
<div className="ap-progress-fill ap-fill-green" style={{ height: '100%', width: `${agent.cpu_usage}%` }} />
</div>
<span className="ap-agent-metric-value">{agent.cpu_usage}%</span>
</div>
)}
{agent.ram_used !== undefined && agent.ram_total !== undefined && (
<div className="ap-agent-metric">
<span className="ap-agent-metric-label">RAM</span>
<div className="ap-agent-metric-bar">
<div className="ap-progress-fill ap-fill-teal" style={{ height: '100%', width: `${Math.round((agent.ram_used / agent.ram_total) * 100)}%` }} />
</div>
<span className="ap-agent-metric-value">{(agent.ram_used / 1024).toFixed(1)} / {(agent.ram_total / 1024).toFixed(0)} GB</span>
</div>
)}
{agent.disk_used !== undefined && agent.disk_total !== undefined && (
<div className="ap-agent-metric">
<span className="ap-agent-metric-label">Disk</span>
<div className="ap-agent-metric-bar">
<div className="ap-progress-fill ap-fill-amber" style={{ height: '100%', width: `${Math.round((agent.disk_used / agent.disk_total) * 100)}%` }} />
</div>
<span className="ap-agent-metric-value">{Math.round(agent.disk_used)} / {Math.round(agent.disk_total)} GB</span>
</div>
)}
<div style={{ height: 8 }} />
{[
['Letzter Check-in', relativeTime(agent.last_checkin), online ? '#34d399' : null],
['Agent-Version', agent.agent_version ? `v${agent.agent_version}` : '—'],
['Hostname', agent.hostname || agent.computer_name || '—'],
].map(([label, val, color]) => (
<div key={label} className="ap-info-row">
<span className="ap-info-label">{label}</span>
<span className="ap-info-value" style={color ? { color } : {}}>{val}</span>
</div>
))}
</>
) : (
<div style={{ color: '#64748b', fontSize: 13, padding: '12px 0' }}>Kein Agent-Datensatz gefunden</div>
)}
</div>
</div>
</>
)}
{/* ── TAB: WARTUNG ── */}
{activeTab === 'wartung' && (
<div className="ap-content-grid2">
<div className="ap-card">
<div className="ap-card-title">📅 Wartungsplan</div>
{inspections.length === 0 ? (
<div style={{ color: '#64748b', fontSize: 13 }}>Noch keine Wartungen erfasst</div>
) : (
<div className="ap-timeline">
{inspections.map((ins, i) => (
<div key={i} className="ap-timeline-item">
<div className={`ap-timeline-dot ${ins.result === 'geplant' ? 'ap-timeline-dot--planned' : ''}`} />
<div className="ap-timeline-date">
{fmtDate(ins.date || ins.inspection_date)} · {ins.result || 'Abgeschlossen'}
</div>
<div className="ap-timeline-title">{ins.type || ins.inspection_type || 'Wartung'}</div>
{ins.notes && <div className="ap-timeline-desc">{ins.notes}</div>}
</div>
))}
</div>
)}
</div>
{canWrite() && (
<div className="ap-card">
<div className="ap-card-title">🔧 Wartung eintragen</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<div className="ap-form-label" style={{ marginBottom: 5 }}>Wartungstyp</div>
<select className="ap-form-input" value={maintForm.type} onChange={e => setMaintForm(f => ({ ...f, type: e.target.value }))}>
<option>Jährliche Inspektion</option>
<option>Software-Update</option>
<option>Hardware-Reparatur</option>
<option>Akku-Austausch</option>
<option>Reinigung</option>
<option>Sonstiges</option>
</select>
</div>
<div>
<div className="ap-form-label" style={{ marginBottom: 5 }}>Ergebnis</div>
<select className="ap-form-input" value={maintForm.result} onChange={e => setMaintForm(f => ({ ...f, result: e.target.value }))}>
<option value="">Abgeschlossen</option>
<option value="geplant">Geplant</option>
<option value="in_arbeit">In Arbeit</option>
<option value="abgeschlossen">Abgeschlossen</option>
</select>
</div>
<div>
<div className="ap-form-label" style={{ marginBottom: 5 }}>Datum</div>
<input
type="date"
className="ap-form-input"
value={maintForm.date}
onChange={e => setMaintForm(f => ({ ...f, date: e.target.value }))}
/>
</div>
<div>
<div className="ap-form-label" style={{ marginBottom: 5 }}>Notizen</div>
<textarea
rows={3}
className="ap-form-input"
placeholder="Optionale Notizen…"
value={maintForm.notes}
onChange={e => setMaintForm(f => ({ ...f, notes: e.target.value }))}
/>
</div>
<button
className="ap-btn ap-btn-teal"
style={{ alignSelf: 'flex-end' }}
onClick={handleMaintSubmit}
disabled={maintSaving}
>
{maintSaving ? 'Speichern…' : 'Wartung eintragen'}
</button>
</div>
</div>
)}
</div>
)}
{/* ── TAB: VERLAUF ── */}
{activeTab === 'verlauf' && (
<div className="ap-card">
<div className="ap-card-title">🕐 Zuweisungshistorie</div>
{history.length === 0 ? (
<div style={{ color: '#64748b', fontSize: 13 }}>Keine Historiendaten vorhanden</div>
) : (
<table className="ap-history-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Von</th>
<th>Bis</th>
<th>Notizen</th>
</tr>
</thead>
<tbody>
{history.map((h, i) => (
<tr key={i}>
<td>{h.username || h.user_name || h.assigned_to_username || '—'}</td>
<td>{fmtDate(h.assigned_at || h.from_date)}</td>
<td>{h.unassigned_at || h.to_date ? fmtDate(h.unassigned_at || h.to_date) : <span style={{ color: '#64748b' }}>Aktiv</span>}</td>
<td style={{ color: '#64748b' }}>{h.notes || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── TAB: AGENT-DATEN ── */}
{activeTab === 'agent' && (
<div className="ap-agent-data-grid">
{/* System */}
<div className="ap-card">
<div className="ap-card-title">💻 System</div>
{agent ? (
[
['Hostname', agent.hostname || agent.computer_name],
['OS', agent.os_version || agent.os],
['Build', agent.os_build],
['Architektur', agent.architecture],
['Prozessor', agent.cpu_model],
['Kerne', agent.cpu_cores ? `${agent.cpu_cores} Kerne` : undefined],
['RAM gesamt', agent.ram_total ? `${(agent.ram_total / 1024).toFixed(0)} GB` : undefined],
].map(([label, val]) => val ? (
<div key={label} className="ap-info-row">
<span className="ap-info-label">{label}</span>
<span className="ap-info-value">{val}</span>
</div>
) : null)
) : <div style={{ color: '#64748b', fontSize: 13 }}>Kein Agent verbunden</div>}
</div>
{/* Netzwerk */}
<div className="ap-card">
<div className="ap-card-title">🌐 Netzwerk</div>
{agent ? (
[
['IPv4', agent.ip_address],
['IPv6', agent.ipv6_address],
['MAC', agent.mac_address],
['Interface', agent.network_interface],
['Gateway', agent.gateway],
['DNS', agent.dns_server],
].map(([label, val]) => val ? (
<div key={label} className="ap-info-row">
<span className="ap-info-label">{label}</span>
<span className="ap-info-value" style={{ fontSize: 12 }}>{val}</span>
</div>
) : null)
) : <div style={{ color: '#64748b', fontSize: 13 }}>Kein Agent verbunden</div>}
</div>
{/* Sicherheit */}
<div className="ap-card">
<div className="ap-card-title">🛡 Sicherheit</div>
{agent ? (
<>
<div className="ap-info-row">
<span className="ap-info-label">Windows Defender</span>
<span className="ap-info-value" style={{ color: agent.defender_enabled ? '#34d399' : '#ef4444' }}>
{agent.defender_enabled ? '✓ Aktiv' : '✗ Inaktiv'}
</span>
</div>
{agent.defender_signatures && (
<div className="ap-info-row">
<span className="ap-info-label">Signaturen</span>
<span className="ap-info-value">{agent.defender_signatures}</span>
</div>
)}
<div className="ap-info-row">
<span className="ap-info-label">BitLocker</span>
<span className="ap-info-value" style={{ color: agent.bitlocker_enabled ? '#34d399' : '#94a3b8' }}>
{agent.bitlocker_enabled ? '✓ Aktiv' : '— Nicht aktiv'}
</span>
</div>
<div className="ap-info-row">
<span className="ap-info-label">Firewall</span>
<span className="ap-info-value" style={{ color: agent.firewall_enabled ? '#34d399' : '#ef4444' }}>
{agent.firewall_enabled ? '✓ Aktiv' : '✗ Inaktiv'}
</span>
</div>
{agent.pending_reboot !== undefined && (
<div className="ap-info-row">
<span className="ap-info-label">Pending Reboot</span>
<span className="ap-info-value" style={{ color: agent.pending_reboot ? '#f59e0b' : '#34d399' }}>
{agent.pending_reboot ? '⚠️ Ja' : 'Keine'}
</span>
</div>
)}
</>
) : <div style={{ color: '#64748b', fontSize: 13 }}>Kein Agent verbunden</div>}
</div>
{/* Updates */}
<div className="ap-card">
<div className="ap-card-title">📦 Updates</div>
{agent ? (
[
['Ausstehend', agent.pending_updates !== undefined ? `${agent.pending_updates} Updates` : undefined, agent.pending_updates > 0 ? '#f59e0b' : '#34d399'],
['Letztes Update', agent.last_update_date ? fmtDate(agent.last_update_date) : undefined],
['Agent-Version', agent.agent_version ? `v${agent.agent_version}` : undefined],
['Letzter Check-in', relativeTime(agent.last_checkin), online ? '#34d399' : null],
].map(([label, val, color]) => val !== undefined ? (
<div key={label} className="ap-info-row">
<span className="ap-info-label">{label}</span>
<span className="ap-info-value" style={color ? { color } : {}}>{val}</span>
</div>
) : null)
) : <div style={{ color: '#64748b', fontSize: 13 }}>Kein Agent verbunden</div>}
</div>
{/* Auslastung */}
<div className="ap-card">
<div className="ap-card-title">📊 Auslastung</div>
{agent ? (() => {
const cpuPct = agent.cpu_usage_percent ?? null;
const ramUsed = agent.ram_used_gb ?? null;
const ramTotal = agent.ram_total_gb ?? null;
const ramPct = ramTotal > 0 ? Math.round((ramUsed / ramTotal) * 100) : null;
const diskTotal = agent.disk_total_gb ?? null;
const diskFree = agent.disk_free_gb ?? null;
const diskUsed = diskTotal != null && diskFree != null ? diskTotal - diskFree : null;
const diskPct = diskTotal > 0 ? Math.round((diskUsed / diskTotal) * 100) : null;
const uptimeH = agent.uptime_hours ?? null;
const fmtUptime = (h) => {
if (h == null) return '—';
if (h < 24) return `${Math.floor(h)} Std.`;
return `${Math.floor(h / 24)} Tag(e)`;
};
const barColor = (p) => p > 80 ? '#ef4444' : p > 60 ? '#f59e0b' : '#34d399';
const Bar = ({ pct: p }) => (
<div style={{ background: 'rgba(255,255,255,0.08)', borderRadius: 4, height: 6, marginTop: 4 }}>
<div style={{ background: barColor(p), width: `${Math.min(100, p)}%`, height: 6, borderRadius: 4, transition: 'width 0.3s' }} />
</div>
);
return (
<>
{cpuPct != null && (
<div className="ap-info-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className="ap-info-label">CPU-Auslastung</span>
<span className="ap-info-value" style={{ color: barColor(cpuPct) }}>{cpuPct.toFixed(1)} %</span>
</div>
<Bar pct={cpuPct} />
</div>
)}
{ramUsed != null && ramTotal != null && (
<div className="ap-info-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className="ap-info-label">RAM</span>
<span className="ap-info-value" style={{ color: barColor(ramPct) }}>{ramUsed.toFixed(1)} / {ramTotal.toFixed(0)} GB</span>
</div>
<Bar pct={ramPct} />
</div>
)}
{diskUsed != null && diskTotal != null && (
<div className="ap-info-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className="ap-info-label">Disk</span>
<span className="ap-info-value" style={{ color: barColor(diskPct) }}>{diskUsed.toFixed(0)} / {diskTotal.toFixed(0)} GB</span>
</div>
<Bar pct={diskPct} />
</div>
)}
{uptimeH != null && (
<div className="ap-info-row">
<span className="ap-info-label">Laufzeit</span>
<span className="ap-info-value">{fmtUptime(uptimeH)}</span>
</div>
)}
{cpuPct == null && ramUsed == null && diskUsed == null && uptimeH == null && (
<div style={{ color: '#64748b', fontSize: 13 }}>Keine Auslastungsdaten</div>
)}
</>
);
})() : <div style={{ color: '#64748b', fontSize: 13 }}>Kein Agent verbunden</div>}
</div>
</div>
)}
{/* ── TAB: DOKUMENTE ── */}
{activeTab === 'dokumente' && (
<div className="ap-card">
<div className="ap-card-title">📁 Dokumente &amp; Aktionen</div>
<div className="ap-doc-item" style={{cursor:'default', flexDirection:'column', alignItems:'flex-start', gap:10}}>
<div style={{display:'flex', alignItems:'center', gap:10}}>
<div className="ap-doc-icon">🏷</div>
<div>
<div className="ap-doc-name">Asset-Label drucken</div>
<div className="ap-doc-meta">Format wählen und als PDF öffnen</div>
</div>
</div>
<div style={{display:'flex', gap:8, flexWrap:'wrap', paddingLeft:4}}>
{[
{ key: 'small', label: 'Klein (50×25mm)' },
{ key: 'medium', label: 'Mittel (80×40mm)' },
{ key: 'large', label: 'Groß (A5)' },
{ key: 'ptouch12', label: 'P-Touch 700 (12mm)' },
].map(f => (
<button key={f.key}
className="ap-btn ap-btn-sm"
style={{fontSize:11, padding:'4px 10px', background:'rgba(13,148,136,0.12)', border:'1px solid rgba(13,148,136,0.3)', color:'#0d9488', borderRadius:6, cursor:'pointer'}}
onClick={() => openPdf(`${API}/assets/${detail.id}/label?size=${f.key}`)}
>{f.label}</button>
))}
</div>
</div>
{detail.status === 'zugewiesen' && (
<div
className="ap-doc-item"
onClick={() => openPdf(`${API}/assets/${detail.id}/handover-protocol`)}
>
<div className="ap-doc-icon" style={{ background: 'rgba(52,211,153,0.1)' }}>📋</div>
<div>
<div className="ap-doc-name">Übergabeprotokoll</div>
<div className="ap-doc-meta">PDF mit Gerätedaten und Unterschriftenfeld</div>
</div>
</div>
)}
</div>
)}
{/* Modals */}
{showEdit && (
<EditModal
asset={detail}
onClose={() => setShowEdit(false)}
onSaved={() => {
setShowEdit(false);
loadAssets();
// Reload detail
authFetch(`${API}/assets/${detail.id}`).then(r => r.json()).then(setDetail).catch(() => {});
}}
/>
)}
{showAssign && (
<AssignModal
asset={detail}
onClose={() => setShowAssign(false)}
onAssigned={() => {
setShowAssign(false);
loadAssets();
authFetch(`${API}/assets/${detail.id}`).then(r => r.json()).then(setDetail).catch(() => {});
}}
/>
)}
</>
);
};
/* ─────────────────────────────────────────────────────────
MAIN RENDER
───────────────────────────────────────────────────────── */
if (loading) {
return (
<div className="ap-page">
<div className="ap-empty-state" style={{ margin: 'auto' }}>
<div className="ap-empty-icon"></div>
<div className="ap-empty-text">Laden</div>
</div>
</div>
);
}
const userFilterActive = searchMode === 'user' && searchTerm;
const userFilterMatches = userFilterActive
? [...new Set(filteredAssets.map(a => a.assigned_to_name || a.assigned_to_username).filter(Boolean))]
: [];
return (
<div className="ap-page">
{/* ── LEFT PANEL ── */}
<div className="ap-left-panel">
<div className="ap-left-header">
<div className="ap-panel-title-row">
<span className="ap-panel-title">Assets</span>
<span className="ap-panel-badge">{assets.length}</span>
</div>
{/* Suchfeld */}
<div className="ap-search-box">
<span className="ap-search-icon">🔍</span>
<input
className="ap-search-input"
type="text"
placeholder="Asset, Benutzer, Seriennummer…"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
{/* Typ-Pills */}
<div className="ap-pills-row">
{[
['all', 'Alle'],
['notebook', 'Notebook'],
['server', 'Server'],
['monitor', 'Monitor'],
['headset', 'Headset'],
['drucker', 'Drucker'],
['sonstiges', 'Sonstiges'],
].map(([val, label]) => (
<span
key={val}
className={`ap-pill ${typeFilter === val ? 'ap-pill--active' : ''}`}
onClick={() => setTypeFilter(val)}
>{label}</span>
))}
</div>
{/* Status-Pills */}
<div className="ap-pills-row">
{[
['all', 'Alle'],
['verfuegbar', 'Verfügbar'],
['zugewiesen', 'Zugewiesen'],
['inaktiv', 'Inaktiv'],
['beschaedigt', 'Beschädigt'],
].map(([val, label]) => (
<span
key={val}
className={`ap-pill ${statusFilter === val ? 'ap-pill--active' : ''}`}
onClick={() => setStatusFilter(val)}
>{label}</span>
))}
</div>
</div>
{/* User-Filter-Banner */}
{userFilterActive && (
<div className="ap-user-filter-banner">
<span>👤</span>
<span className="ap-user-filter-text">
{userFilterMatches.length > 0
? <>Assets von: <strong>{userFilterMatches.join(', ')}</strong> ({filteredAssets.length})</>
: <>Kein Treffer für <strong>"{searchTerm}"</strong></>}
</span>
<button className="ap-user-filter-clear" onClick={() => setSearchTerm('')}></button>
</div>
)}
{/* Stat-Grid */}
<div className="ap-stat-grid">
<div className="ap-stat-card ap-stat-card--teal">
<div className="ap-stat-value">{stats.total || assets.length}</div>
<div className="ap-stat-label">Gesamt</div>
</div>
<div className="ap-stat-card">
<div className="ap-stat-value" style={{ color: '#60a5fa' }}>{stats.assigned}</div>
<div className="ap-stat-label">Zugewiesen</div>
</div>
<div className="ap-stat-card ap-stat-card--green">
<div className="ap-stat-value">{stats.available}</div>
<div className="ap-stat-label">Verfügbar</div>
</div>
<div className="ap-stat-card ap-stat-card--amber">
<div className="ap-stat-value">{stats.maintenance}</div>
<div className="ap-stat-label">In Wartung</div>
</div>
</div>
{/* Asset-Liste */}
<div className="ap-asset-list">
{filteredAssets.length === 0 && (
<div style={{ padding: '20px 12px', textAlign: 'center', color: '#475569', fontSize: 13 }}>
Keine Assets gefunden
</div>
)}
{filteredAssets.map(asset => {
const agent = getAgent(asset);
const online = isOnline(agent);
return (
<div
key={asset.id}
className={`ap-asset-item ${selectedId === asset.id ? 'ap-asset-item--active' : ''}`}
onClick={() => setSelectedId(asset.id)}
>
<div className="ap-asset-icon-wrap">{getTypeIcon(asset.type)}</div>
<div className="ap-asset-info">
<div className="ap-asset-name">{asset.name}</div>
<div className="ap-asset-sub">
{asset.inventory_number && `${asset.inventory_number} · `}
{asset.serial_number ? `SN: ${asset.serial_number}` : asset.model || ''}
</div>
</div>
<div className="ap-asset-meta">
<span className={`ap-status-badge ${STATUS_CSS[asset.status] || ''}`}>
{STATUS_LABELS[asset.status] || asset.status}
</span>
<div className={`ap-online-dot ${online ? 'ap-online-dot--on' : ''}`} />
</div>
</div>
);
})}
</div>
</div>
{/* ── RIGHT PANEL ── */}
<div className="ap-right-panel">
{renderDetail()}
</div>
</div>
);
};
export default AssetsPage;