1322 lines
73 KiB
JavaScript
1322 lines
73 KiB
JavaScript
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, IP or hostname ── */
|
||
const getAgent = (asset) => {
|
||
if (!asset || !agents.length) return null;
|
||
return agents.find(ag =>
|
||
(asset.serial_number && ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase()) ||
|
||
(asset.ip_address && ag.ip_address && ag.ip_address === asset.ip_address) ||
|
||
(asset.name && ag.hostname && ag.hostname.toLowerCase() === asset.name.toLowerCase())
|
||
) || 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} · </>}
|
||
{detail.serial_number && <>SN: {detail.serial_number} · </>}
|
||
{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 & 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 & 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;
|