feat: personal equipment tracking, order assignment, purge fix, widget consolidation

- Migration 084: new persoenliche_ausruestung table with catalog link, user
  assignment, soft delete; adds zuweisung_typ/ausruestung_id/persoenlich_id
  columns to ausruestung_anfrage_positionen; seeds feature group + 5 permissions

- Fix user data purge: table was shop_anfragen, renamed to ausruestung_anfragen
  in mig 046 — caused full transaction rollback. Also keep mitglieder_profile
  row but NULL FDISK-synced fields (dienstgrad, geburtsdatum, etc.) instead of
  deleting the profile

- Personal equipment CRUD: backend service/controller/routes at
  /api/persoenliche-ausruestung; frontend page with DataTable, user filter,
  catalog Autocomplete, FAB create dialog; widget in Status group; sidebar
  entry (Checkroom icon); card in MitgliedDetail Tab 0

- Ausruestungsanfrage item assignment: when a request reaches erledigt,
  auto-opens ItemAssignmentDialog listing all delivered positions; each item
  can be assigned as general equipment (vehicle/storage), personal item (user,
  prefilled with requester), or not tracked; POST /requests/:id/assign backend

- StatCard refactored to use WidgetCard as outer shell for consistent header
  styling across all dashboard widget templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-04-13 19:19:35 +02:00
parent b477e5dbe0
commit 1215e9ea70
23 changed files with 1700 additions and 63 deletions

View File

@@ -0,0 +1,402 @@
import { useState, useMemo } from 'react';
import {
Autocomplete,
Box,
Chip,
Container,
MenuItem,
Stack,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
import CheckroomIcon from '@mui/icons-material/Checkroom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { FormDialog, PageHeader } from '../components/templates';
import {
ZUSTAND_LABELS,
ZUSTAND_COLORS,
} from '../types/personalEquipment.types';
import type {
PersoenlicheAusruestungZustand,
CreatePersoenlicheAusruestungPayload,
} from '../types/personalEquipment.types';
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
function PersoenlicheAusruestungPage() {
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
const canCreate = hasPermission('persoenliche_ausruestung:create');
const [dialogOpen, setDialogOpen] = useState(false);
const [filterZustand, setFilterZustand] = useState<string>('');
const [filterUser, setFilterUser] = useState<string>('');
const [search, setSearch] = useState('');
// Form state
const [formBezeichnung, setFormBezeichnung] = useState<string | AusruestungArtikel | null>(null);
const [formKategorie, setFormKategorie] = useState('');
const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null);
const [formBenutzerName, setFormBenutzerName] = useState('');
const [formGroesse, setFormGroesse] = useState('');
const [formZustand, setFormZustand] = useState<PersoenlicheAusruestungZustand>('gut');
const [formNotizen, setFormNotizen] = useState('');
// Data queries
const { data: items, isLoading } = useQuery({
queryKey: ['persoenliche-ausruestung', 'all'],
queryFn: () => canViewAll ? personalEquipmentApi.getAll() : personalEquipmentApi.getMy(),
staleTime: 2 * 60 * 1000,
});
const { data: catalogItems } = useQuery({
queryKey: ['ausruestungsanfrage-items-catalog'],
queryFn: () => ausruestungsanfrageApi.getItems(),
staleTime: 10 * 60 * 1000,
});
const { data: membersList } = useQuery({
queryKey: ['members-list-compact'],
queryFn: () => membersService.getMembers({ pageSize: 500 }),
staleTime: 5 * 60 * 1000,
enabled: canViewAll,
});
const memberOptions = useMemo(() => {
return (membersList?.items ?? []).map((m) => ({
id: m.id,
name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email,
}));
}, [membersList]);
const createMutation = useMutation({
mutationFn: (data: CreatePersoenlicheAusruestungPayload) => personalEquipmentApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
showSuccess('Persönliche Ausrüstung erstellt');
setDialogOpen(false);
resetForm();
},
onError: () => {
showError('Fehler beim Erstellen');
},
});
const resetForm = () => {
setFormBezeichnung(null);
setFormKategorie('');
setFormUserId(null);
setFormBenutzerName('');
setFormGroesse('');
setFormZustand('gut');
setFormNotizen('');
};
const handleCreate = () => {
const bezeichnung = typeof formBezeichnung === 'string'
? formBezeichnung
: formBezeichnung?.bezeichnung ?? '';
if (!bezeichnung.trim()) return;
const payload: CreatePersoenlicheAusruestungPayload = {
bezeichnung: bezeichnung.trim(),
kategorie: formKategorie || undefined,
artikel_id: typeof formBezeichnung === 'object' && formBezeichnung ? formBezeichnung.id : undefined,
user_id: formUserId?.id || undefined,
benutzer_name: formBenutzerName || undefined,
groesse: formGroesse || undefined,
zustand: formZustand,
notizen: formNotizen || undefined,
};
createMutation.mutate(payload);
};
// 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"
breadcrumbs={[{ label: 'Persönliche Ausrüstung' }]}
/>
{/* 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>
{ZUSTAND_OPTIONS.map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</TextField>
{canViewAll && (
<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>
{canViewAll && <th>Benutzer</th>}
<th>Größe</th>
<th>Zustand</th>
<th>Anschaffung</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={canViewAll ? 6 : 5}>
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Lade Daten
</Typography>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={canViewAll ? 6 : 5}>
<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}>
<td>
<Typography variant="body2" fontWeight={500}>
{item.bezeichnung}
</Typography>
{item.artikel_bezeichnung && (
<Typography variant="caption" color="text.secondary">
{item.artikel_bezeichnung}
</Typography>
)}
</td>
<td>
<Typography variant="body2">{item.kategorie ?? '—'}</Typography>
</td>
{canViewAll && (
<td>
<Typography variant="body2">
{item.user_display_name ?? item.benutzer_name ?? '—'}
</Typography>
</td>
)}
<td>
<Typography variant="body2">{item.groesse ?? '—'}</Typography>
</td>
<td>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
size="small"
variant="outlined"
/>
</td>
<td>
<Typography variant="body2">
{item.anschaffung_datum
? new Date(item.anschaffung_datum).toLocaleDateString('de-AT')
: '—'}
</Typography>
</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
</Container>
{/* FAB */}
{canCreate && (
<ChatAwareFab
onClick={() => setDialogOpen(true)}
aria-label="Persönliche Ausrüstung hinzufügen"
>
<AddIcon />
</ChatAwareFab>
)}
{/* Create Dialog */}
<FormDialog
open={dialogOpen}
onClose={() => { setDialogOpen(false); resetForm(); }}
title="Persönliche Ausrüstung hinzufügen"
onSubmit={handleCreate}
submitLabel="Erstellen"
isSubmitting={createMutation.isPending}
>
<Stack spacing={2} sx={{ mt: 1 }}>
<Autocomplete
freeSolo
options={catalogItems ?? []}
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
value={formBezeichnung}
onChange={(_e, v) => {
setFormBezeichnung(v);
if (v && typeof v !== 'string' && v.kategorie) {
setFormKategorie(v.kategorie);
}
}}
onInputChange={(_e, v) => {
if (typeof formBezeichnung === 'string' || formBezeichnung === null) {
setFormBezeichnung(v);
}
}}
renderInput={(params) => (
<TextField {...params} label="Bezeichnung" required size="small" />
)}
size="small"
/>
{canViewAll && (
<Autocomplete
options={memberOptions}
getOptionLabel={(o) => o.name}
value={formUserId}
onChange={(_e, v) => setFormUserId(v)}
renderInput={(params) => (
<TextField {...params} label="Benutzer" size="small" />
)}
size="small"
/>
)}
{!canViewAll && (
<TextField
label="Benutzer (Name)"
size="small"
value={formBenutzerName}
onChange={(e) => setFormBenutzerName(e.target.value)}
/>
)}
<TextField
label="Kategorie"
size="small"
value={formKategorie}
onChange={(e) => setFormKategorie(e.target.value)}
/>
<TextField
label="Größe"
size="small"
value={formGroesse}
onChange={(e) => setFormGroesse(e.target.value)}
/>
<TextField
label="Zustand"
select
size="small"
value={formZustand}
onChange={(e) => setFormZustand(e.target.value as PersoenlicheAusruestungZustand)}
>
{ZUSTAND_OPTIONS.map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</TextField>
<TextField
label="Notizen"
size="small"
multiline
rows={2}
value={formNotizen}
onChange={(e) => setFormNotizen(e.target.value)}
/>
</Stack>
</FormDialog>
</DashboardLayout>
);
}
export default PersoenlicheAusruestungPage;