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:
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user