Initial commit: IT Nexus Web-App
This commit is contained in:
674
frontend/src/pages/AssetsPage.jsx
Normal file
674
frontend/src/pages/AssetsPage.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user