375 lines
14 KiB
TypeScript
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;
|