Files
dashboard/frontend/src/pages/PersoenlicheAusruestung.tsx

375 lines
14 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
Autocomplete,
Box,
Button,
Chip,
Container,
MenuItem,
Tab,
Tabs,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
import CheckroomIcon from '@mui/icons-material/Checkroom';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { membersService } from '../services/members';
import { usePermissionContext } from '../contexts/PermissionContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { PageHeader } from '../components/templates';
import { KatalogTab } from '../components/shared/KatalogTab';
import type { ZustandOption } from '../types/personalEquipment.types';
function PersoenlicheAusruestungPage() {
const navigate = useNavigate();
const { hasPermission, isLoading: permissionsLoading } = usePermissionContext();
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
const canCreate = hasPermission('persoenliche_ausruestung:create');
const canSeeAll = canViewAll || canCreate;
const canApprove = hasPermission('ausruestungsanfrage:approve');
const [activeTab, setActiveTab] = useState(0);
const [filterZustand, setFilterZustand] = useState<string>('');
const [filterUser, setFilterUser] = useState<string>('');
const [search, setSearch] = useState('');
// Data queries
const { data: items, isLoading } = useQuery({
queryKey: ['persoenliche-ausruestung', canSeeAll ? 'all' : 'my'],
queryFn: () => canSeeAll ? personalEquipmentApi.getAll() : personalEquipmentApi.getMy(),
staleTime: 2 * 60 * 1000,
enabled: !permissionsLoading,
});
const { data: zustandOptions = [] } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
staleTime: 5 * 60 * 1000,
});
const getZustandLabel = (key: string) => zustandOptions.find(o => o.key === key)?.label ?? key;
const getZustandColor = (key: string) => zustandOptions.find(o => o.key === key)?.color ?? 'default';
const { data: membersList } = useQuery({
queryKey: ['members-list-compact'],
queryFn: () => membersService.getMembers({ pageSize: 500 }),
staleTime: 5 * 60 * 1000,
enabled: canSeeAll,
});
const { data: unassignedPositions, isLoading: unassignedLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'nicht-zugewiesen'],
queryFn: () => ausruestungsanfrageApi.getUnassignedPositions(),
staleTime: 2 * 60 * 1000,
enabled: canApprove && activeTab === 2,
});
const memberOptions = useMemo(() => {
return (membersList?.items ?? []).map((m) => ({
id: m.id,
name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email,
}));
}, [membersList]);
// Filter logic
const filtered = useMemo(() => {
let result = items ?? [];
if (filterZustand) {
result = result.filter((i) => i.zustand === filterZustand);
}
if (filterUser) {
result = result.filter((i) => i.user_id === filterUser);
}
if (search.trim()) {
const s = search.toLowerCase();
result = result.filter((i) =>
i.bezeichnung.toLowerCase().includes(s) ||
(i.kategorie ?? '').toLowerCase().includes(s) ||
(i.user_display_name ?? i.benutzer_name ?? '').toLowerCase().includes(s)
);
}
return result;
}, [items, filterZustand, filterUser, search]);
return (
<DashboardLayout>
<Container maxWidth="lg">
<PageHeader
title="Persönliche Ausrüstung"
/>
<Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)} sx={{ mb: 3 }}>
<Tab label="Zuweisungen" />
<Tab label="Katalog" />
{canApprove && <Tab label="Nicht zugewiesen" />}
</Tabs>
{activeTab === 0 && (
<>
{/* Filters */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
placeholder="Suche…"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 200 }}
/>
<TextField
size="small"
select
label="Zustand"
value={filterZustand}
onChange={(e) => setFilterZustand(e.target.value)}
sx={{ minWidth: 140 }}
>
<MenuItem value="">Alle</MenuItem>
{zustandOptions.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
))}
</TextField>
{canSeeAll && (
<Autocomplete
size="small"
options={memberOptions}
getOptionLabel={(o) => o.name}
value={memberOptions.find((m) => m.id === filterUser) ?? null}
onChange={(_e, v) => setFilterUser(v?.id ?? '')}
renderInput={(params) => <TextField {...params} label="Benutzer" />}
sx={{ minWidth: 200 }}
/>
)}
<Typography variant="body2" color="text.secondary" sx={{ ml: 'auto' }}>
{filtered.length} {filtered.length === 1 ? 'Eintrag' : 'Einträge'}
</Typography>
</Box>
{/* Table */}
<Box sx={{ overflowX: 'auto' }}>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
'& th, & td': {
textAlign: 'left',
py: 1.5,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
},
'& th': {
fontWeight: 600,
fontSize: '0.75rem',
textTransform: 'uppercase',
color: 'text.secondary',
letterSpacing: '0.06em',
},
'& tbody tr': {
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
},
}}
>
<thead>
<tr>
<th>Bezeichnung</th>
<th>Kategorie</th>
<th>Menge</th>
{canSeeAll && <th>Benutzer</th>}
<th>Zustand</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={canSeeAll ? 5 : 4}>
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Lade Daten
</Typography>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={canSeeAll ? 5 : 4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
<CheckroomIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">
Keine Einträge gefunden
</Typography>
</Box>
</td>
</tr>
) : (
filtered.map((item) => (
<tr key={item.id} onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}>
<td>
<Typography variant="body2" fontWeight={500}>
{item.bezeichnung}
</Typography>
{item.artikel_bezeichnung && (
<Typography variant="caption" color="text.secondary">
{item.artikel_bezeichnung}
</Typography>
)}
{item.eigenschaften && item.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{item.eigenschaften.map((e) => (
<Chip
key={e.id}
label={`${e.name}: ${e.wert}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
))}
</Box>
)}
</td>
<td>
<Typography variant="body2">{item.artikel_kategorie_parent_name
? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}`
: item.artikel_kategorie_name ?? item.kategorie ?? '—'}</Typography>
</td>
<td>
{item.menge > 1 && <Chip label={`${item.menge}x`} size="small" variant="outlined" />}
</td>
{canSeeAll && (
<td>
<Typography variant="body2">
{item.user_display_name ?? item.benutzer_name ?? '—'}
</Typography>
</td>
)}
<td>
<Chip
label={getZustandLabel(item.zustand)}
color={getZustandColor(item.zustand) as any}
size="small"
variant="outlined"
/>
</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
</>
)}
{activeTab === 1 && <KatalogTab />}
{activeTab === 2 && canApprove && (
<Box sx={{ overflowX: 'auto' }}>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
'& th, & td': {
textAlign: 'left',
py: 1.5,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
},
'& th': {
fontWeight: 600,
fontSize: '0.75rem',
textTransform: 'uppercase',
color: 'text.secondary',
letterSpacing: '0.06em',
},
'& tbody tr': {
'&:hover': { bgcolor: 'action.hover' },
},
}}
>
<thead>
<tr>
<th>Bezeichnung</th>
<th>Anfrage</th>
<th>Für wen</th>
<th>Im Katalog</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{unassignedLoading ? (
<tr>
<td colSpan={5}>
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Lade Daten
</Typography>
</td>
</tr>
) : !unassignedPositions || unassignedPositions.length === 0 ? (
<tr>
<td colSpan={5}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
<CheckroomIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">
Alle Positionen sind zugewiesen
</Typography>
</Box>
</td>
</tr>
) : (
unassignedPositions.map((pos) => (
<tr key={pos.id}>
<td>
<Typography variant="body2" fontWeight={500}>{pos.bezeichnung}</Typography>
<Chip label={`${pos.menge}x`} size="small" variant="outlined" sx={{ ml: 1, height: 20 }} />
</td>
<td>
<Typography
variant="body2"
component="span"
sx={{ cursor: 'pointer', color: 'primary.main', textDecoration: 'underline' }}
onClick={() => navigate(`/ausruestungsanfrage/${pos.anfrage_id}`)}
>
{pos.anfrage_bezeichnung || (pos.bestell_jahr && pos.bestell_nummer ? `${pos.bestell_jahr}/${String(pos.bestell_nummer).padStart(3, '0')}` : `#${pos.anfrage_id}`)}
</Typography>
</td>
<td>
<Typography variant="body2" fontWeight={500}>{pos.fuer_wen || '—'}</Typography>
</td>
<td>
<Chip label={pos.artikel_id ? 'Ja' : 'Nein'} color={pos.artikel_id ? 'success' : 'warning'} size="small" variant="outlined" />
</td>
<td>
<Button size="small" variant="outlined" onClick={() => navigate(`/ausruestungsanfrage/${pos.anfrage_id}/zuweisung`)}>
Zuweisen
</Button>
</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
)}
</Container>
{/* FAB */}
{canCreate && activeTab === 0 && (
<ChatAwareFab
onClick={() => navigate('/persoenliche-ausruestung/neu')}
aria-label="Persönliche Ausrüstung hinzufügen"
>
<AddIcon />
</ChatAwareFab>
)}
</DashboardLayout>
);
}
export default PersoenlicheAusruestungPage;