Files
IT-Nexus/frontend/src/pages/AssetsPage.jsx

1328 lines
73 KiB
React
Raw Normal View History

import React, { useState, useEffect, useCallback, useRef } from 'react';
2026-06-01 20:49:07 +02:00
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',
},
});
2026-06-01 20:49:07 +02:00
};
const API = process.env.REACT_APP_API_URL || '/api';
2026-06-01 20:49:07 +02:00
const TYPE_ICONS = {
notebook: '💻',
server: '🖧',
monitor: '🖥️',
headset: '🎧',
drucker: '🖨️',
sonstiges: '📦',
};
2026-06-01 20:49:07 +02:00
const getTypeIcon = (type) => {
if (!type) return '📦';
const t = type.toLowerCase();
return TYPE_ICONS[t] || '📦';
};
2026-06-01 20:49:07 +02:00
const STATUS_LABELS = {
verfuegbar: 'Verfügbar',
zugewiesen: 'Zugewiesen',
inaktiv: 'Inaktiv',
beschaedigt: 'Beschädigt',
};
2026-06-01 20:49:07 +02:00
const STATUS_CSS = {
verfuegbar: 'ap-badge-available',
zugewiesen: 'ap-badge-assigned',
inaktiv: 'ap-badge-inactive',
beschaedigt: 'ap-badge-damaged',
};
2026-06-01 20:49:07 +02:00
const fmtDate = (d) => {
if (!d) return '—';
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
};
2026-06-01 20:49:07 +02:00
const fmtCurrency = (n) => {
if (n === null || n === undefined || n === '') return '—';
return parseFloat(n).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
};
2026-06-01 20:49:07 +02:00
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);
};
2026-06-01 20:49:07 +02:00
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);
};
2026-06-01 20:49:07 +02:00
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);
2026-06-01 20:49:07 +02:00
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);
2026-06-01 20:49:07 +02:00
} finally {
setSaving(false);
2026-06-01 20:49:07 +02:00
}
};
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>
);
2026-06-01 20:49:07 +02:00
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>
);
};
2026-06-01 20:49:07 +02:00
/*
ASSIGN MODAL
*/
const AssignModal = ({ asset, onClose, onAssigned }) => {
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState('');
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
2026-06-01 20:49:07 +02:00
useEffect(() => {
authFetch(`${API}/users`)
.then(r => r.json())
.then(data => setUsers(data.data || data.users || (Array.isArray(data) ? data : [])))
.catch(() => {});
}, []);
2026-06-01 20:49:07 +02:00
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);
2026-06-01 20:49:07 +02:00
}
};
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>
);
};
2026-06-01 20:49:07 +02:00
/*
MAIN PAGE
*/
const AssetsPage = () => {
const { user, isAdmin, isSuperAdmin, isSupport } = useAuth();
2026-06-01 20:49:07 +02:00
/* ── 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');
2026-06-01 20:49:07 +02:00
/* ── 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');
2026-06-01 20:49:07 +02:00
/* ── State: Modals ── */
const [showEdit, setShowEdit] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const moreMenuRef = useRef(null);
2026-06-01 20:49:07 +02:00
/* ── State: Wartungsformular ── */
const [maintForm, setMaintForm] = useState({ type: 'Jährliche Inspektion', date: '', notes: '', result: '' });
const [maintSaving, setMaintSaving] = useState(false);
/* ── Permissions ── */
const canWrite = () => isAdmin() || isSuperAdmin() || isSupport();
2026-06-01 20:49:07 +02:00
/* ── Load ── */
const loadAssets = useCallback(async () => {
setLoading(true);
2026-06-01 20:49:07 +02:00
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);
2026-06-01 20:49:07 +02:00
}
}, []);
2026-06-01 20:49:07 +02:00
useEffect(() => { loadAssets(); }, [loadAssets]);
2026-06-01 20:49:07 +02:00
/* ── 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]);
2026-06-01 20:49:07 +02:00
/* ── 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;
2026-06-01 20:49:07 +02:00
};
/* ── 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;
2026-06-01 20:49:07 +02:00
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);
2026-06-01 20:49:07 +02:00
}
};
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);
}
2026-06-01 20:49:07 +02:00
};
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);
}
2026-06-01 20:49:07 +02:00
};
const copyToClipboard = (text, btn) => {
navigator.clipboard.writeText(text || '').then(() => {
if (btn) { const o = btn.textContent; btn.textContent = '✓'; setTimeout(() => btn.textContent = o, 1500); }
});
2026-06-01 20:49:07 +02:00
};
/*
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>
);
2026-06-01 20:49:07 +02:00
}
if (detailLoading) {
return <div className="ap-empty-state"><div className="ap-empty-icon"></div><div className="ap-empty-text">Laden</div></div>;
2026-06-01 20:49:07 +02:00
}
if (!detail) return null;
2026-06-01 20:49:07 +02:00
const agent = getAgent(detail);
const online = isOnline(agent);
const bookValue = calcBookValue(detail);
const depPercent = calcDepPercent(detail);
const monthlyDep = calcMonthlyDep(detail);
2026-06-01 20:49:07 +02:00
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();
2026-06-01 20:49:07 +02:00
return (
<>
{/* Header */}
<div className="ap-detail-header">
<div className="ap-detail-icon-box">
{getTypeIcon(detail.type)}
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
)}
{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>
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
{[
['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>
2026-06-01 20:49:07 +02:00
))}
<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>
2026-06-01 20:49:07 +02:00
)}
</span>
</div>
</div>
2026-06-01 20:49:07 +02:00
{/* 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>
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
{/* 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>
</>
2026-06-01 20:49:07 +02:00
)}
{/* ── 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>
))}
2026-06-01 20:49:07 +02:00
</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 }))}
/>
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
</div>
</div>
)}
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
</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>}
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
</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>
))}
2026-06-01 20:49:07 +02:00
</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>
2026-06-01 20:49:07 +02:00
</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()}
2026-06-01 20:49:07 +02:00
</div>
</div>
);
};
export default AssetsPage;