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

@@ -43,6 +43,9 @@ import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members';
import { atemschutzApi } from '../services/atemschutz';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types';
import type { PersoenlicheAusruestung } from '../types/personalEquipment.types';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import {
MemberWithProfile,
@@ -208,6 +211,7 @@ function MitgliedDetail() {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const canWrite = useCanWrite();
const { hasPermission } = usePermissionContext();
const currentUserId = useCurrentUserId();
const isOwnProfile = currentUserId === userId;
const canEdit = canWrite || isOwnProfile;
@@ -225,6 +229,10 @@ function MitgliedDetail() {
const [atemschutz, setAtemschutz] = useState<AtemschutzUebersicht | null>(null);
const [atemschutzLoading, setAtemschutzLoading] = useState(false);
// Personal equipment data
const [personalEquipment, setPersonalEquipment] = useState<PersoenlicheAusruestung[]>([]);
const [personalEquipmentLoading, setPersonalEquipmentLoading] = useState(false);
// FDISK-synced sub-section data
const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]);
const [untersuchungen, setUntersuchungen] = useState<Untersuchung[]>([]);
@@ -272,6 +280,18 @@ function MitgliedDetail() {
.finally(() => setAtemschutzLoading(false));
}, [userId]);
// Load personal equipment for this user
useEffect(() => {
if (!userId) return;
const canView = hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all');
if (!canView) return;
setPersonalEquipmentLoading(true);
personalEquipmentApi.getByUserId(userId)
.then(setPersonalEquipment)
.catch(() => setPersonalEquipment([]))
.finally(() => setPersonalEquipmentLoading(false));
}, [userId]);
// Load FDISK-synced sub-section data
useEffect(() => {
if (!userId) return;
@@ -842,6 +862,44 @@ function MitgliedDetail() {
</Card>
</Grid>
{/* Personal equipment */}
{(hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all')) && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<SecurityIcon color="primary" />}
title="Persönliche Ausrüstung"
/>
<CardContent>
{personalEquipmentLoading ? (
<CircularProgress size={24} />
) : personalEquipment.length === 0 ? (
<Typography color="text.secondary" variant="body2">
Keine persönlichen Gegenstände erfasst
</Typography>
) : (
personalEquipment.map((item) => (
<Box key={item.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box>
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
{item.kategorie && (
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
)}
</Box>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
size="small"
variant="outlined"
/>
</Box>
))
)}
</CardContent>
</Card>
</Grid>
)}
{/* Driving licenses */}
<Grid item xs={12} md={6}>
<Card>