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