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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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>
|
||||
|
||||
402
frontend/src/pages/PersoenlicheAusruestung.tsx
Normal file
402
frontend/src/pages/PersoenlicheAusruestung.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user