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

@@ -20,6 +20,7 @@ import Ausruestung from './pages/Ausruestung';
import AusruestungForm from './pages/AusruestungForm';
import AusruestungDetail from './pages/AusruestungDetail';
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung';
import Atemschutz from './pages/Atemschutz';
import Mitglieder from './pages/Mitglieder';
import MitgliedDetail from './pages/MitgliedDetail';
@@ -192,6 +193,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/persoenliche-ausruestung"
element={
<ProtectedRoute>
<PersoenlicheAusruestung />
</ProtectedRoute>
}
/>
<Route
path="/atemschutz"
element={

View File

@@ -0,0 +1,64 @@
import { List, ListItem, ListItemText, Chip, Typography } from '@mui/material';
import CheckroomIcon from '@mui/icons-material/Checkroom';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { personalEquipmentApi } from '../../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../../types/personalEquipment.types';
import { WidgetCard } from '../templates/WidgetCard';
import { ItemListSkeleton } from '../templates/SkeletonPresets';
function PersoenlicheAusruestungWidget() {
const navigate = useNavigate();
const { data: items, isLoading, isError } = useQuery({
queryKey: ['persoenliche-ausruestung', 'my'],
queryFn: () => personalEquipmentApi.getMy(),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const displayItems = (items ?? []).slice(0, 5);
return (
<WidgetCard
title="Meine Ausrüstung"
icon={<CheckroomIcon fontSize="small" />}
isLoading={isLoading}
skeleton={<ItemListSkeleton count={3} />}
isError={isError}
errorMessage="Ausrüstung konnte nicht geladen werden."
isEmpty={!isLoading && !isError && (items ?? []).length === 0}
emptyMessage="Keine persönlichen Gegenstände erfasst"
onClick={() => navigate('/persoenliche-ausruestung')}
footer={
items && items.length > 5 ? (
<Typography variant="caption" color="text.secondary">
+{items.length - 5} weitere
</Typography>
) : undefined
}
>
<List dense disablePadding>
{displayItems.map((item) => (
<ListItem key={item.id} disablePadding sx={{ py: 0.5 }}>
<ListItemText
primary={item.bezeichnung}
secondary={[item.kategorie, item.groesse].filter(Boolean).join(' · ') || undefined}
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
size="small"
variant="outlined"
sx={{ ml: 1, flexShrink: 0 }}
/>
</ListItem>
))}
</List>
</WidgetCard>
);
}
export default PersoenlicheAusruestungWidget;

View File

@@ -25,3 +25,4 @@ export { default as IssueOverviewWidget } from './IssueOverviewWidget';
export { default as ChecklistWidget } from './ChecklistWidget';
export { default as SortableWidget } from './SortableWidget';
export { default as BuchhaltungWidget } from './BuchhaltungWidget';
export { default as PersoenlicheAusruestungWidget } from './PersoenlicheAusruestungWidget';

View File

@@ -31,6 +31,7 @@ import {
Forum,
AssignmentTurnedIn,
AccountBalance as AccountBalanceIcon,
Checkroom as CheckroomIcon,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
@@ -102,6 +103,12 @@ const baseNavigationItems: NavigationItem[] = [
path: '/ausruestung',
permission: 'ausruestung:view',
},
{
text: 'Pers. Ausrüstung',
icon: <CheckroomIcon />,
path: '/persoenliche-ausruestung',
permission: 'persoenliche_ausruestung:view',
},
{
text: 'Mitglieder',
icon: <People />,

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Box, Card, CardActionArea, CardContent, Typography } from '@mui/material';
import { Box, Typography } from '@mui/material';
import { GOLDEN_RATIO } from '../../theme/theme';
import { WidgetCard } from './WidgetCard';
import { StatSkeleton } from './SkeletonPresets';
export interface StatCardProps {
@@ -22,63 +23,48 @@ export const StatCard: React.FC<StatCardProps> = ({
trend,
isLoading = false,
onClick,
}) => {
const content = (
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
{isLoading ? (
<StatSkeleton />
) : (
<Box display="flex" alignItems="center">
<Box sx={{ flex: GOLDEN_RATIO }}>
<Typography variant="caption" textTransform="uppercase" color="text.secondary" sx={{ letterSpacing: '0.06em', fontWeight: 600 }}>
{title}
</Typography>
<Typography variant="h3" fontWeight={800} sx={{ lineHeight: 1.1, mt: 0.5, letterSpacing: '-0.02em' }}>
{value}
</Typography>
{trend && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', mt: 1, px: 1, py: 0.25, borderRadius: 1, bgcolor: trend.value >= 0 ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)' }}>
<Typography
variant="caption"
fontWeight={600}
color={trend.value >= 0 ? 'success.main' : 'error.main'}
>
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
{trend.label && ` ${trend.label}`}
</Typography>
</Box>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
bgcolor: `${color}12`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color,
}}
}) => (
<WidgetCard
title={title}
isLoading={isLoading}
skeleton={<StatSkeleton />}
onClick={onClick}
noPadding
>
<Box display="flex" alignItems="center" sx={{ px: 2.5, pb: 2.5 }}>
<Box sx={{ flex: GOLDEN_RATIO }}>
<Typography variant="h3" fontWeight={800} sx={{ lineHeight: 1.1, letterSpacing: '-0.02em' }}>
{value}
</Typography>
{trend && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', mt: 1, px: 1, py: 0.25, borderRadius: 1, bgcolor: trend.value >= 0 ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)' }}>
<Typography
variant="caption"
fontWeight={600}
color={trend.value >= 0 ? 'success.main' : 'error.main'}
>
{icon}
</Box>
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
{trend.label && ` ${trend.label}`}
</Typography>
</Box>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
bgcolor: `${color}12`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color,
}}
>
{icon}
</Box>
)}
</CardContent>
);
return (
<Card sx={{ height: '100%' }}>
{onClick ? (
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
{content}
</CardActionArea>
) : (
content
)}
</Card>
);
};
</Box>
</Box>
</WidgetCard>
);

View File

@@ -19,6 +19,7 @@ export const WIDGETS = [
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
{ key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true },
{ key: 'persoenlicheAusruestung', label: 'Pers. Ausrüstung', defaultVisible: true },
] as const;
export type WidgetKey = typeof WIDGETS[number]['key'];

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>
);
}

View File

@@ -62,6 +62,7 @@ import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget';
import PersoenlicheAusruestungWidget from '../components/dashboard/PersoenlicheAusruestungWidget';
import { preferencesApi } from '../services/settings';
import { configApi } from '../services/config';
import { WidgetKey } from '../constants/widgets';
@@ -85,7 +86,7 @@ const BUILTIN_GROUPS: { name: string; title: string }[] = [
// Default widget order per group (used when no preference is set)
const DEFAULT_ORDER: Record<string, string[]> = {
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung'],
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung', 'persoenlicheAusruestung'],
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
information: ['links', 'bannerWidget'],
@@ -138,6 +139,7 @@ function Dashboard() {
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
{ key: 'buchhaltung', widgetKey: 'buchhaltung', permission: 'buchhaltung:widget', component: <BuchhaltungWidget /> },
{ key: 'persoenlicheAusruestung', widgetKey: 'persoenlicheAusruestung', permission: 'persoenliche_ausruestung:view', component: <PersoenlicheAusruestungWidget /> },
],
kalender: [
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },

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>

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;

View File

@@ -122,6 +122,23 @@ export const ausruestungsanfrageApi = {
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/geliefert`, { geliefert });
},
// ── Item assignment ──
assignItems: async (
anfrageId: number,
assignments: Array<{
positionId: number;
typ: 'ausruestung' | 'persoenlich' | 'keine';
fahrzeugId?: string;
standort?: string;
userId?: string;
benutzerName?: string;
groesse?: string;
kategorie?: string;
}>,
): Promise<void> => {
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/assign`, { assignments });
},
updatePositionZurueckgegeben: async (positionId: number, altes_geraet_zurueckgegeben: boolean): Promise<void> => {
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/zurueckgegeben`, { altes_geraet_zurueckgegeben });
},

View File

@@ -0,0 +1,64 @@
import { api } from './api';
import type {
PersoenlicheAusruestung,
CreatePersoenlicheAusruestungPayload,
UpdatePersoenlicheAusruestungPayload,
} from '../types/personalEquipment.types';
async function unwrap<T>(
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
): Promise<T> {
const response = await promise;
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
}
export const personalEquipmentApi = {
async getAll(params?: { user_id?: string; kategorie?: string; zustand?: string }): Promise<PersoenlicheAusruestung[]> {
const qs = new URLSearchParams();
if (params?.user_id) qs.set('user_id', params.user_id);
if (params?.kategorie) qs.set('kategorie', params.kategorie);
if (params?.zustand) qs.set('zustand', params.zustand);
return unwrap(api.get(`/api/persoenliche-ausruestung?${qs.toString()}`));
},
async getMy(): Promise<PersoenlicheAusruestung[]> {
return unwrap(api.get('/api/persoenliche-ausruestung/my'));
},
async getByUserId(userId: string): Promise<PersoenlicheAusruestung[]> {
return unwrap(api.get(`/api/persoenliche-ausruestung/user/${userId}`));
},
async getById(id: string): Promise<PersoenlicheAusruestung> {
return unwrap(api.get(`/api/persoenliche-ausruestung/${id}`));
},
async create(data: CreatePersoenlicheAusruestungPayload): Promise<PersoenlicheAusruestung> {
const response = await api.post<{ success: boolean; data: PersoenlicheAusruestung }>(
'/api/persoenliche-ausruestung',
data,
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
async update(id: string, data: UpdatePersoenlicheAusruestungPayload): Promise<PersoenlicheAusruestung> {
const response = await api.patch<{ success: boolean; data: PersoenlicheAusruestung }>(
`/api/persoenliche-ausruestung/${id}`,
data,
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
async delete(id: string): Promise<void> {
await api.delete(`/api/persoenliche-ausruestung/${id}`);
},
};

View File

@@ -111,6 +111,9 @@ export interface AusruestungAnfragePosition {
eigenschaften?: AusruestungPositionEigenschaft[];
ist_ersatz: boolean;
altes_geraet_zurueckgegeben: boolean;
zuweisung_typ?: 'ausruestung' | 'persoenlich' | 'keine' | null;
zuweisung_ausruestung_id?: string | null;
zuweisung_persoenlich_id?: string | null;
}
export interface AusruestungAnfrageFormItem {

View File

@@ -0,0 +1,65 @@
// Personal Equipment (Persönliche Ausrüstung) — Frontend Types
export type PersoenlicheAusruestungZustand = 'gut' | 'beschaedigt' | 'abgaengig' | 'verloren';
export const ZUSTAND_LABELS: Record<PersoenlicheAusruestungZustand, string> = {
gut: 'Gut',
beschaedigt: 'Beschädigt',
abgaengig: 'Abgängig',
verloren: 'Verloren',
};
export const ZUSTAND_COLORS: Record<PersoenlicheAusruestungZustand, 'success' | 'warning' | 'error' | 'default'> = {
gut: 'success',
beschaedigt: 'warning',
abgaengig: 'error',
verloren: 'default',
};
export interface PersoenlicheAusruestung {
id: string;
bezeichnung: string;
kategorie?: string;
artikel_id?: number;
artikel_bezeichnung?: string;
user_id?: string;
user_display_name?: string;
benutzer_name?: string;
groesse?: string;
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand: PersoenlicheAusruestungZustand;
notizen?: string;
anfrage_id?: number;
erstellt_am: string;
aktualisiert_am: string;
}
export interface CreatePersoenlicheAusruestungPayload {
bezeichnung: string;
kategorie?: string;
artikel_id?: number;
user_id?: string;
benutzer_name?: string;
groesse?: string;
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand?: PersoenlicheAusruestungZustand;
notizen?: string;
}
export interface UpdatePersoenlicheAusruestungPayload {
bezeichnung?: string;
kategorie?: string | null;
artikel_id?: number | null;
user_id?: string | null;
benutzer_name?: string | null;
groesse?: string | null;
seriennummer?: string | null;
inventarnummer?: string | null;
anschaffung_datum?: string | null;
zustand?: PersoenlicheAusruestungZustand;
notizen?: string | null;
}