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

@@ -5,10 +5,12 @@ import {
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
MenuItem, Select, FormControl, InputLabel, Autocomplete,
Checkbox, LinearProgress, Switch, FormControlLabel, Alert,
ToggleButton, ToggleButtonGroup, Stack, Divider,
} from '@mui/material';
import {
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, ShoppingCart as ShoppingCartIcon,
Assignment as AssignmentIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
@@ -18,11 +20,13 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { vehiclesApi } from '../services/vehicles';
import { membersService } from '../services/members';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type {
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
AusruestungEigenschaft,
AusruestungAnfragePosition, AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types';
// ── Helpers ──
@@ -34,6 +38,225 @@ function formatOrderId(r: AusruestungAnfrage): string {
return `#${r.id}`;
}
// ── Helpers ──
type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine';
interface PositionAssignment {
typ: AssignmentTyp;
fahrzeugId?: string;
standort?: string;
userId?: string;
benutzerName?: string;
groesse?: string;
kategorie?: string;
}
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
return positions.filter((p) => p.geliefert && !p.zuweisung_typ);
}
// ══════════════════════════════════════════════════════════════════════════════
// ItemAssignmentDialog
// ══════════════════════════════════════════════════════════════════════════════
interface ItemAssignmentDialogProps {
open: boolean;
onClose: () => void;
anfrage: AusruestungAnfrage;
positions: AusruestungAnfragePosition[];
onSuccess: () => void;
}
function ItemAssignmentDialog({ open, onClose, anfrage, positions, onSuccess }: ItemAssignmentDialogProps) {
const { showSuccess, showError } = useNotification();
const unassigned = getUnassignedPositions(positions);
const [assignments, setAssignments] = useState<Record<number, PositionAssignment>>(() => {
const init: Record<number, PositionAssignment> = {};
for (const p of unassigned) {
init[p.id] = { typ: 'persoenlich' };
}
return init;
});
const { data: vehicleList } = useQuery({
queryKey: ['vehicles', 'sidebar'],
queryFn: () => vehiclesApi.getAll(),
staleTime: 2 * 60 * 1000,
enabled: open,
});
const { data: membersList } = useQuery({
queryKey: ['members-list-compact'],
queryFn: () => membersService.getMembers({ pageSize: 500 }),
staleTime: 5 * 60 * 1000,
enabled: open,
});
const memberOptions = (membersList?.items ?? []).map((m) => ({
id: m.id,
name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email,
}));
const vehicleOptions = (vehicleList ?? []).map((v) => ({
id: v.id,
name: v.bezeichnung ?? v.kurzname,
}));
const [submitting, setSubmitting] = useState(false);
const updateAssignment = (posId: number, patch: Partial<PositionAssignment>) => {
setAssignments((prev) => ({
...prev,
[posId]: { ...prev[posId], ...patch },
}));
};
const handleSkipAll = () => {
const updated: Record<number, PositionAssignment> = {};
for (const p of unassigned) {
updated[p.id] = { typ: 'keine' };
}
setAssignments(updated);
};
const handleSubmit = async () => {
setSubmitting(true);
try {
const payload = Object.entries(assignments).map(([posId, a]) => ({
positionId: Number(posId),
typ: a.typ,
fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined,
standort: a.typ === 'ausruestung' ? a.standort : undefined,
userId: a.typ === 'persoenlich' ? a.userId : undefined,
benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined,
groesse: a.typ === 'persoenlich' ? a.groesse : undefined,
kategorie: a.typ === 'persoenlich' ? a.kategorie : undefined,
}));
await ausruestungsanfrageApi.assignItems(anfrage.id, payload);
showSuccess('Gegenstände zugewiesen');
onSuccess();
onClose();
} catch {
showError('Fehler beim Zuweisen');
} finally {
setSubmitting(false);
}
};
if (unassigned.length === 0) return null;
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Gegenstände zuweisen</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wähle für jeden gelieferten Gegenstand, wie er erfasst werden soll.
</Typography>
<Stack spacing={3} divider={<Divider />}>
{unassigned.map((pos) => {
const a = assignments[pos.id] ?? { typ: 'persoenlich' as const };
return (
<Box key={pos.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="body2" fontWeight={600}>
{pos.bezeichnung}
</Typography>
<Chip label={`${pos.menge}x`} size="small" variant="outlined" />
</Box>
<ToggleButtonGroup
value={a.typ}
exclusive
size="small"
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
sx={{ mb: 1.5 }}
>
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton>
<ToggleButton value="persoenlich">Persönlich</ToggleButton>
<ToggleButton value="keine">Nicht erfassen</ToggleButton>
</ToggleButtonGroup>
{a.typ === 'ausruestung' && (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Autocomplete
size="small"
options={vehicleOptions}
getOptionLabel={(o) => o.name}
value={vehicleOptions.find((v) => v.id === a.fahrzeugId) ?? null}
onChange={(_e, v) => updateAssignment(pos.id, { fahrzeugId: v?.id })}
renderInput={(params) => <TextField {...params} label="Fahrzeug" />}
sx={{ minWidth: 200, flex: 1 }}
/>
<TextField
size="small"
label="Standort"
value={a.standort ?? ''}
onChange={(e) => updateAssignment(pos.id, { standort: e.target.value })}
sx={{ minWidth: 160, flex: 1 }}
/>
</Box>
)}
{a.typ === 'persoenlich' && (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Autocomplete
size="small"
options={memberOptions}
getOptionLabel={(o) => o.name}
value={memberOptions.find((m) => m.id === a.userId) ?? null}
onChange={(_e, v) => updateAssignment(pos.id, { userId: v?.id, benutzerName: v?.name })}
renderInput={(params) => (
<TextField
{...params}
label="Benutzer"
placeholder={anfrage.fuer_benutzer_name || anfrage.anfrager_name || ''}
/>
)}
sx={{ minWidth: 200, flex: 1 }}
/>
<TextField
size="small"
label="Größe"
value={a.groesse ?? ''}
onChange={(e) => updateAssignment(pos.id, { groesse: e.target.value })}
sx={{ minWidth: 100 }}
/>
<TextField
size="small"
label="Kategorie"
value={a.kategorie ?? ''}
onChange={(e) => updateAssignment(pos.id, { kategorie: e.target.value })}
sx={{ minWidth: 140 }}
/>
</Box>
)}
</Box>
);
})}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleSkipAll} color="inherit">
Alle überspringen
</Button>
<Box sx={{ flex: 1 }} />
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={submitting}
startIcon={<AssignmentIcon />}
>
Zuweisen
</Button>
</DialogActions>
</Dialog>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
@@ -59,6 +282,9 @@ export default function AusruestungsanfrageDetail() {
const [adminNotizen, setAdminNotizen] = useState('');
const [statusChangeValue, setStatusChangeValue] = useState('');
// Assignment dialog state
const [assignmentOpen, setAssignmentOpen] = useState(false);
// Eigenschaften state for edit mode
const [editItemEigenschaften, setEditItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
@@ -105,12 +331,19 @@ export default function AusruestungsanfrageDetail() {
const statusMut = useMutation({
mutationFn: ({ status, notes }: { status: string; notes?: string }) =>
ausruestungsanfrageApi.updateRequestStatus(requestId, status, notes),
onSuccess: () => {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Status aktualisiert');
setActionDialog(null);
setAdminNotizen('');
setStatusChangeValue('');
// Auto-open assignment dialog when status changes to 'erledigt' and unassigned positions exist
if (variables.status === 'erledigt' && detail) {
const unassigned = getUnassignedPositions(detail.positionen);
if (unassigned.length > 0) {
setAssignmentOpen(true);
}
}
},
onError: () => showError('Fehler beim Aktualisieren'),
});
@@ -506,6 +739,15 @@ export default function AusruestungsanfrageDetail() {
Bestellungen erstellen
</Button>
)}
{showAdminActions && anfrage && anfrage.status === 'erledigt' && detail && getUnassignedPositions(detail.positionen).length > 0 && (
<Button
variant="outlined"
startIcon={<AssignmentIcon />}
onClick={() => setAssignmentOpen(true)}
>
Zuweisen
</Button>
)}
{canEdit && !editing && (
<Button startIcon={<EditIcon />} onClick={startEditing}>
Bearbeiten
@@ -544,6 +786,20 @@ export default function AusruestungsanfrageDetail() {
</DialogActions>
</Dialog>
{/* Assignment dialog */}
{detail && anfrage && (
<ItemAssignmentDialog
open={assignmentOpen}
onClose={() => setAssignmentOpen(false)}
anfrage={anfrage}
positions={detail.positionen}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
}}
/>
)}
</DashboardLayout>
);
}