Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

View File

@@ -0,0 +1,674 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import assetService from '../services/assetService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import AssetModal from '../components/assets/AssetModal';
import AssetAssignModal from '../components/assets/AssetAssignModal';
import InspectionSection from '../components/assets/InspectionSection';
import { toast } from 'react-toastify';
const DEPT_COLORS = {
'IT': { bg: 'rgba(59,130,246,0.12)', text: '#3b82f6', border: '#3b82f6' },
'Produktion': { bg: 'rgba(245,158,11,0.12)', text: '#f59e0b', border: '#f59e0b' },
'Techniker': { bg: 'rgba(34,197,94,0.12)', text: '#22c55e', border: '#22c55e' },
};
const PAGE_SIZE = 25;
const AssetsPage = () => {
const { isAdmin, isSuperAdmin, isTechniker } = useAuth();
const [assets, setAssets] = useState([]);
const [filteredAssets, setFilteredAssets] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [deptFilter, setDeptFilter] = useState('all');
const [maintenanceFilter, setMaintenanceFilter] = useState(false);
const [sortField, setSortField] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const [currentPage, setCurrentPage] = useState(1);
const [assetTypes, setAssetTypes] = useState([]);
const [showAssetModal, setShowAssetModal] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
const [editingAsset, setEditingAsset] = useState(null);
const [assigningAsset, setAssigningAsset] = useState(null);
const [labelModal, setLabelModal] = useState(null);
const [labelCopies, setLabelCopies] = useState(1);
const [labelSize, setLabelSize] = useState('medium');
const [labelLoading, setLabelLoading] = useState(false);
const [intuneLoading, setIntuneLoading] = useState(false);
const [showCsvImport, setShowCsvImport] = useState(false);
const [csvFile, setCsvFile] = useState(null);
const [csvPreview, setCsvPreview] = useState(null);
const [csvImporting, setCsvImporting] = useState(false);
const [inspectionAsset, setInspectionAsset] = useState(null);
const [openMenuId, setOpenMenuId] = useState(null);
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
useEffect(() => {
loadAssets();
assetService.getTypes().then(setAssetTypes).catch(() => {});
}, []);
useEffect(() => {
if (openMenuId === null) return;
const close = () => setOpenMenuId(null);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [openMenuId]);
useEffect(() => {
filterAssets();
setCurrentPage(1);
}, [assets, searchTerm, statusFilter, typeFilter, deptFilter, maintenanceFilter]);
const canModifyAssets = () => {
return isSuperAdmin() || isAdmin() || isTechniker();
};
const handleSort = (field) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
setCurrentPage(1);
};
const SortIcon = ({ field }) => {
if (sortField !== field) return <span style={{ opacity: 0.3, marginLeft: 4 }}></span>;
return <span style={{ marginLeft: 4, color: 'var(--cereda-primary)' }}>{sortDir === 'asc' ? '↑' : '↓'}</span>;
};
const loadAssets = async () => {
try {
const data = await assetService.getAll();
setAssets(data);
} catch (error) {
toast.error('Fehler beim Laden der Assets');
} finally {
setLoading(false);
}
};
const filterAssets = () => {
let filtered = [...assets];
// Search filter
if (searchTerm) {
filtered = filtered.filter(
(asset) =>
asset.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.serial_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.inventory_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.model?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Status filter
if (statusFilter !== 'all') {
filtered = filtered.filter((asset) => asset.status === statusFilter);
}
// Type filter
if (typeFilter !== 'all') {
filtered = filtered.filter((asset) => asset.type === typeFilter);
}
// Department filter
if (deptFilter !== 'all') {
filtered = filtered.filter((asset) => (asset.department || 'IT') === deptFilter);
}
// Maintenance filter
if (maintenanceFilter) {
const today = new Date().toISOString().slice(0, 10);
filtered = filtered.filter(
(asset) => asset.next_maintenance_date && asset.next_maintenance_date <= today
);
}
setFilteredAssets(filtered);
};
const handleCreate = () => {
setEditingAsset(null);
setShowAssetModal(true);
};
const handleEdit = (asset) => {
setEditingAsset(asset);
setShowAssetModal(true);
};
const handleAssetModalClose = () => {
setShowAssetModal(false);
setEditingAsset(null);
};
const handleAssetSaved = () => {
loadAssets();
handleAssetModalClose();
};
const handleAssign = (asset) => {
setAssigningAsset(asset);
setShowAssignModal(true);
};
const handleUnassign = async (asset) => {
if (!window.confirm('Möchten Sie die Zuweisung dieses Assets wirklich aufheben?')) {
return;
}
try {
await assetService.unassign(asset.id, 'verfuegbar');
toast.success('Zuweisung erfolgreich aufgehoben');
loadAssets();
} catch (error) {
toast.error(error.message || 'Fehler beim Aufheben der Zuweisung');
}
};
const handleAssignModalClose = () => {
setShowAssignModal(false);
setAssigningAsset(null);
};
const handleAssigned = () => {
loadAssets();
handleAssignModalClose();
};
const handlePrintLabel = async () => {
setLabelLoading(true);
try {
await assetService.printLabel(labelModal.id, labelCopies, labelSize);
setLabelModal(null);
} catch (error) {
toast.error('Fehler beim Erstellen des Labels');
} finally {
setLabelLoading(false);
}
};
const handleIntuneImport = async () => {
setIntuneLoading(true);
try {
const result = await assetService.importFromIntune();
toast.success(
`Intune-Import abgeschlossen: ${result.imported} importiert, ${result.skipped} übersprungen` +
(result.errors.length > 0 ? `, ${result.errors.length} Fehler` : '')
);
if (result.imported > 0) loadAssets();
} catch (error) {
toast.error(error.message || 'Fehler beim Intune-Import');
} finally {
setIntuneLoading(false);
}
};
const handleExportCsv = () => {
const headers = ['Name', 'Typ', 'Seriennummer', 'Modell', 'Status', 'Zugewiesen an', 'Kaufdatum', 'Nächste Prüfung', 'TeamViewer-ID'];
const rows = filteredAssets.map(a => [
a.name, a.type, a.serial_number, a.model || '', a.status,
a.assigned_to_username || '', a.purchase_date || '', a.next_maintenance_date || '', a.teamviewer_id || '',
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'));
const csv = [headers.join(';'), ...rows].join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `assets_${new Date().toISOString().slice(0,10)}.csv`;
a.click(); URL.revokeObjectURL(url);
toast.success(`${filteredAssets.length} Assets exportiert`);
};
const handleCsvFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
setCsvFile(file);
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target.result.replace(/^\uFEFF/, '');
const lines = text.split(/\r?\n/).filter(l => l.trim());
const sep = lines[0].includes(';') ? ';' : ',';
const headers = lines[0].split(sep).map(h => h.replace(/^"|"$/g, '').trim().toLowerCase());
const rows = lines.slice(1).map(line => {
const vals = line.split(sep).map(v => v.replace(/^"|"$/g, '').trim());
const obj = {};
headers.forEach((h, i) => { obj[h] = vals[i] || ''; });
return obj;
}).filter(r => r.name || r['name']);
setCsvPreview({ headers, rows: rows.slice(0, 5), total: rows.length, allRows: rows });
};
reader.readAsText(file, 'UTF-8');
};
const handleCsvImport = async () => {
if (!csvPreview) return;
setCsvImporting(true);
let imported = 0, errors = 0;
for (const row of csvPreview.allRows) {
try {
await assetService.create({
name: row.name || row['name'] || '',
type: row.typ || row.type || 'Other',
serial_number: row.seriennummer || row['serial_number'] || row['serialnumber'] || '',
model: row.modell || row.model || '',
status: row.status || 'verfuegbar',
purchase_date: row.kaufdatum || row['purchase_date'] || '',
teamviewer_id: row['teamviewer-id'] || row.teamviewer_id || '',
});
imported++;
} catch { errors++; }
}
toast.success(`CSV-Import: ${imported} importiert${errors > 0 ? `, ${errors} Fehler` : ''}`);
setShowCsvImport(false); setCsvFile(null); setCsvPreview(null);
loadAssets();
setCsvImporting(false);
};
const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie dieses Asset wirklich löschen?')) {
return;
}
try {
await assetService.delete(id);
toast.success('Asset erfolgreich gelöscht');
loadAssets();
} catch (error) {
toast.error(error.message || 'Fehler beim Löschen');
}
};
if (loading) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
// Sort + paginate in render
const sorted = [...filteredAssets].sort((a, b) => {
let va = (a[sortField] || '').toString().toLowerCase();
let vb = (b[sortField] || '').toString().toLowerCase();
return sortDir === 'asc' ? va.localeCompare(vb, 'de') : vb.localeCompare(va, 'de');
});
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const pagedAssets = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const hasAnyMaintenanceDate = assets.some(a => a.next_maintenance_date);
return (
<div className="main-content">
<div className="container">
<div className="flex justify-between items-center mb-3">
<div>
<h1 style={{ marginBottom: 0 }}>Assets</h1>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{filteredAssets.length} von {assets.length}</span>
</div>
<div className="flex" style={{ gap: '0.5rem' }}>
<button onClick={handleExportCsv} className="btn btn-secondary" title="Aktuell gefilterte Assets als CSV exportieren">
CSV Export
</button>
{canModifyAssets() && (
<>
<button onClick={() => setShowCsvImport(true)} className="btn btn-secondary" title="Assets aus CSV-Datei importieren">
CSV Import
</button>
{!isTechniker() && (
<button onClick={handleIntuneImport} className="btn btn-secondary" disabled={intuneLoading} title="Geräte aus Microsoft Intune importieren">
{intuneLoading ? 'Importiere...' : '☁️ Aus Intune importieren'}
</button>
)}
<button onClick={handleCreate} className="btn btn-primary">+ Neues Asset</button>
</>
)}
</div>
</div>
{/* Search and Filter — kompakte einzeilige Leiste */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input
type="text"
className="search-input"
placeholder="Suche nach Name, Seriennummer oder Modell..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ flex: '1 1 220px', minWidth: 180, maxWidth: 340 }}
/>
<select className="form-select" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={{ flex: '0 0 auto', width: 'auto' }}>
<option value="all">Alle Typen</option>
{assetTypes.map(t => (
<option key={t.id} value={t.name}>{t.name}</option>
))}
</select>
{(isAdmin() || isSuperAdmin()) && (
<select className="form-select" value={deptFilter} onChange={(e) => setDeptFilter(e.target.value)} style={{ flex: '0 0 auto', width: 'auto' }}>
<option value="all">Alle Bereiche</option>
<option value="IT">IT</option>
<option value="Produktion">Produktion</option>
<option value="Techniker">Techniker</option>
</select>
)}
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
{[
{ key: 'all', label: 'Alle', cls: statusFilter === 'all' ? 'btn-primary' : 'btn-secondary' },
{ key: 'verfuegbar', label: 'Verfügbar', cls: statusFilter === 'verfuegbar' ? 'btn-success' : 'btn-secondary' },
{ key: 'zugewiesen', label: 'Zugewiesen', cls: statusFilter === 'zugewiesen' ? 'btn-primary' : 'btn-secondary' },
{ key: 'inaktiv', label: 'Inaktiv', cls: statusFilter === 'inaktiv' ? 'btn-secondary active' : 'btn-secondary' },
{ key: 'beschaedigt', label: 'Beschädigt', cls: statusFilter === 'beschaedigt' ? 'btn-danger' : 'btn-secondary' },
].map(s => (
<button key={s.key} className={`btn ${s.cls} btn-small`} onClick={() => setStatusFilter(s.key)}>{s.label}</button>
))}
<button
className={`btn ${maintenanceFilter ? 'btn-warning' : 'btn-secondary'} btn-small`}
onClick={() => setMaintenanceFilter(!maintenanceFilter)}
title="Nur Assets anzeigen, deren Prüftermin überschritten ist"
> Wartung fällig</button>
</div>
</div>
{/* Table */}
<div className="card" style={{ overflowX: 'auto' }}>
<table className="table" style={{ tableLayout: 'fixed', width: '100%', minWidth: hasAnyMaintenanceDate ? '1020px' : '860px' }}>
<colgroup>
<col style={{ width: hasAnyMaintenanceDate ? '17%' : '19%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: hasAnyMaintenanceDate ? '13%' : '15%' }} />
<col style={{ width: hasAnyMaintenanceDate ? '12%' : '13%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: hasAnyMaintenanceDate ? '12%' : '15%' }} />
{hasAnyMaintenanceDate && <col style={{ width: '11%' }} />}
<col style={{ width: hasAnyMaintenanceDate ? '8%' : '11%' }} />
</colgroup>
<thead>
<tr>
{[
{ field: 'name', label: 'Name' },
{ field: 'type', label: 'Typ' },
{ field: 'department', label: 'Bereich' },
{ field: 'inventory_number', label: 'Inventarnummer' },
{ field: 'model', label: 'Modell' },
{ field: 'status', label: 'Status' },
{ field: 'assigned_to_username', label: 'Zugewiesen an' },
].map(col => (
<th key={col.field} onClick={() => handleSort(col.field)} style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
{col.label}<SortIcon field={col.field} />
</th>
))}
{hasAnyMaintenanceDate && (
<th onClick={() => handleSort('next_maintenance_date')} style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }} title="Nächste Prüfung">
Prüfung<SortIcon field="next_maintenance_date" />
</th>
)}
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{pagedAssets.length === 0 ? (
<tr>
<td colSpan={hasAnyMaintenanceDate ? 9 : 8} className="text-center">
Keine Assets gefunden
</td>
</tr>
) : (
pagedAssets.map((asset) => {
const today = new Date().toISOString().slice(0, 10);
const maintenanceOverdue = asset.next_maintenance_date && asset.next_maintenance_date <= today;
const dept = asset.department || 'IT';
const dc = DEPT_COLORS[dept] || DEPT_COLORS['IT'];
return (
<tr key={asset.id} style={maintenanceOverdue ? { backgroundColor: 'rgba(245,158,11,0.08)' } : {}}>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.name}>
<Link to={`/monitoring/device/host/${encodeURIComponent(asset.name)}`} style={{ color: 'inherit', textDecoration: 'none', borderBottom: '1px dotted var(--text-muted)' }} title="Agent-Details öffnen">
{asset.name}
</Link>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{asset.type}</td>
<td>
<span style={{ background: dc.bg, color: dc.text, border: `1px solid ${dc.border}`, borderRadius: '20px', padding: '2px 10px', fontSize: '11px', fontWeight: 600 }}>
{dept}
</span>
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.inventory_number || ''}>{asset.inventory_number || '—'}</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.model || ''}>{asset.model || '—'}</td>
<td>
<span className={`status-badge status-${asset.status}`}>
{{ verfuegbar: 'Verfügbar', zugewiesen: 'Zugewiesen', inaktiv: 'Inaktiv', beschaedigt: 'Beschädigt' }[asset.status] || asset.status}
</span>
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={asset.assigned_to_username || ''}>{asset.assigned_to_username || '—'}</td>
{hasAnyMaintenanceDate && (
<td>
{asset.next_maintenance_date ? (
<span style={{ color: maintenanceOverdue ? 'var(--warning)' : 'inherit', fontWeight: maintenanceOverdue ? 600 : 400 }}>
{maintenanceOverdue && '⚠️ '}
{new Date(asset.next_maintenance_date).toLocaleDateString('de-DE')}
</span>
) : '—'}
</td>
)}
<td>
<button
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right - 180 });
setOpenMenuId(openMenuId === asset.id ? null : asset.id);
}}
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 6, padding: '4px 10px', cursor: 'pointer', color: 'var(--text-primary)', fontSize: 18, lineHeight: 1 }}
title="Aktionen"
></button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.75rem', fontSize: 13, color: 'var(--text-muted)' }}>
<span>{((currentPage - 1) * PAGE_SIZE) + 1}{Math.min(currentPage * PAGE_SIZE, sorted.length)} von {sorted.length} Assets</span>
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>«</button>
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(p => p - 1)} disabled={currentPage === 1}></button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 1)
.reduce((acc, p, idx, arr) => {
if (idx > 0 && p - arr[idx - 1] > 1) acc.push('...');
acc.push(p);
return acc;
}, [])
.map((p, i) => p === '...'
? <span key={`e${i}`} style={{ padding: '0 4px' }}></span>
: <button key={p} className={`btn btn-small ${currentPage === p ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setCurrentPage(p)}>{p}</button>
)
}
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(p => p + 1)} disabled={currentPage === totalPages}></button>
<button className="btn btn-secondary btn-small" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>»</button>
</div>
</div>
)}
{/* Asset Modal */}
{showAssetModal && (
<AssetModal
asset={editingAsset}
onClose={handleAssetModalClose}
onSaved={handleAssetSaved}
/>
)}
{/* Assign Modal */}
{showAssignModal && (
<AssetAssignModal
asset={assigningAsset}
onClose={handleAssignModalClose}
onAssigned={handleAssigned}
/>
)}
{/* Label Modal */}
{labelModal && (
<div className="modal-overlay" onClick={() => setLabelModal(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Label drucken</h2>
<button className="modal-close" onClick={() => setLabelModal(null)}>×</button>
</div>
<div className="modal-body">
<p><strong>{labelModal.name}</strong> {labelModal.serial_number}</p>
<div className="form-group" style={{ marginTop: '1rem' }}>
<label className="form-label">Größe</label>
<select
className="form-select"
value={labelSize}
onChange={(e) => setLabelSize(e.target.value)}
>
<option value="small">Klein (50×25 mm) A4</option>
<option value="medium">Mittel (62×29 mm) A4</option>
<option value="large">Groß (90×38 mm) A4</option>
<option value="ptouch12">Brother P700 12 mm Band</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Anzahl Kopien</label>
<div className="flex items-center" style={{ gap: '0.5rem' }}>
<button
className="btn btn-secondary btn-small"
onClick={() => setLabelCopies(Math.max(1, labelCopies - 1))}
></button>
<span style={{ minWidth: '2rem', textAlign: 'center' }}>{labelCopies}</span>
<button
className="btn btn-secondary btn-small"
onClick={() => setLabelCopies(Math.min(100, labelCopies + 1))}
>+</button>
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setLabelModal(null)}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handlePrintLabel}
disabled={labelLoading}
>
{labelLoading ? 'Wird erstellt...' : 'PDF öffnen'}
</button>
</div>
</div>
</div>
)}
{/* CSV Import Modal */}
{showCsvImport && (
<div className="modal-overlay" onClick={() => { setShowCsvImport(false); setCsvFile(null); setCsvPreview(null); }}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title"> Assets per CSV importieren</h2>
<button className="modal-close" onClick={() => { setShowCsvImport(false); setCsvFile(null); setCsvPreview(null); }}>×</button>
</div>
<div style={{ padding: '20px 24px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '16px' }}>
CSV-Spalten (Semikolon oder Komma getrennt):<br />
<code style={{ background: 'var(--bg-secondary)', padding: '2px 6px', borderRadius: '4px', fontSize: '12px' }}>
Name;Typ;Seriennummer;Modell;Status;Kaufdatum;TeamViewer-ID
</code>
</p>
<input type="file" accept=".csv,.txt" onChange={handleCsvFileChange}
style={{ display: 'block', marginBottom: '16px', fontSize: '13px', color: 'var(--text-primary)' }} />
{csvPreview && (
<div>
<p style={{ fontWeight: 600, fontSize: '13px', color: 'var(--text-primary)', marginBottom: '8px' }}>
Vorschau ({csvPreview.total} Assets erkannt, erste 5):
</p>
<div style={{ overflowX: 'auto', border: '1px solid var(--border-color)', borderRadius: '8px' }}>
<table className="table" style={{ margin: 0, fontSize: '12px' }}>
<thead>
<tr>{csvPreview.headers.map(h => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{csvPreview.rows.map((row, i) => (
<tr key={i}>{csvPreview.headers.map(h => <td key={h}>{row[h]}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<div className="card-footer">
<button className="btn btn-secondary" onClick={() => { setShowCsvImport(false); setCsvFile(null); setCsvPreview(null); }}>
Abbrechen
</button>
<button className="btn btn-primary" onClick={handleCsvImport}
disabled={!csvPreview || csvImporting}>
{csvImporting ? '⏳ Importiere…' : `${csvPreview?.total || 0} Assets importieren`}
</button>
</div>
</div>
</div>
)}
{/* Actions Dropdown (fixed position to escape overflow) */}
{openMenuId !== null && (() => {
const asset = filteredAssets.find(a => a.id === openMenuId);
if (!asset) return null;
return (
<div
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, zIndex: 9999, background: 'var(--bg-card, #1e293b)', border: '1px solid var(--border-color)', borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.4)', minWidth: 200, overflow: 'hidden' }}
onClick={(e) => e.stopPropagation()}
>
{[
{ label: '🔍 Prüfprotokoll', action: () => { setInspectionAsset(asset); setOpenMenuId(null); } },
{ label: '🏷️ Label', action: () => { setLabelModal(asset); setLabelCopies(1); setLabelSize('medium'); setOpenMenuId(null); } },
canModifyAssets() && { label: '✏️ Bearbeiten', action: () => { handleEdit(asset); setOpenMenuId(null); } },
canModifyAssets() && asset.status === 'verfuegbar' && { label: '👤 Zuweisen', action: () => { handleAssign(asset); setOpenMenuId(null); } },
canModifyAssets() && asset.status === 'zugewiesen' && { label: '🔓 Freigeben', action: () => { handleUnassign(asset); setOpenMenuId(null); } },
canModifyAssets() && asset.status === 'zugewiesen' && { label: '📄 Übergabeprotokoll', action: () => { assetService.printHandoverProtocol(asset.id).catch(() => toast.error('Fehler')); setOpenMenuId(null); } },
canModifyAssets() && { label: '🗑️ Löschen', action: () => { handleDelete(asset.id); setOpenMenuId(null); }, danger: true },
].filter(Boolean).map((item, i) => (
<button key={i} onClick={item.action} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '10px 16px', background: 'none', border: 'none', borderBottom: '1px solid var(--border-color)', cursor: 'pointer', fontSize: 13, color: item.danger ? '#ef4444' : 'var(--text-primary)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
>{item.label}</button>
))}
</div>
);
})()}
{/* Inspection Modal */}
{inspectionAsset && (
<div className="modal-overlay" onClick={() => setInspectionAsset(null)}>
<div className="modal-content modal-large" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 640 }}>
<div className="modal-header">
<h2 className="modal-title">Prüfprotokoll {inspectionAsset.name}</h2>
<button className="modal-close" onClick={() => setInspectionAsset(null)}>×</button>
</div>
<div style={{ padding: '8px 24px 24px' }}>
<InspectionSection
assetId={inspectionAsset.id}
onUpdated={loadAssets}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default AssetsPage;