feat: widget icons, dark theme tables, breadcrumb removal, bookkeeping rework, personal equipment pages, PDF/order improvements
This commit is contained in:
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -15,13 +14,10 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
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,
|
||||
@@ -40,223 +36,10 @@ function formatOrderId(r: AusruestungAnfrage): string {
|
||||
|
||||
// ── 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
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -282,9 +65,6 @@ 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>>>({});
|
||||
@@ -337,11 +117,11 @@ export default function AusruestungsanfrageDetail() {
|
||||
setActionDialog(null);
|
||||
setAdminNotizen('');
|
||||
setStatusChangeValue('');
|
||||
// Auto-open assignment dialog when status changes to 'erledigt' and unassigned positions exist
|
||||
// Auto-navigate to assignment page 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);
|
||||
navigate(`/ausruestungsanfrage/${requestId}/zuweisung`);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -427,10 +207,6 @@ export default function AusruestungsanfrageDetail() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
||||
{ label: anfrage ? `Anfrage ${formatOrderId(anfrage)}` : 'Anfrage' },
|
||||
]} />
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
||||
@@ -441,10 +217,15 @@ export default function AusruestungsanfrageDetail() {
|
||||
{anfrage?.bezeichnung && ` — ${anfrage.bezeichnung}`}
|
||||
</Typography>
|
||||
{anfrage && (
|
||||
<Chip
|
||||
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
|
||||
color={AUSRUESTUNG_STATUS_COLORS[anfrage.status]}
|
||||
/>
|
||||
<>
|
||||
{detail?.im_haus && (
|
||||
<Chip label="Im Haus" color="success" />
|
||||
)}
|
||||
<Chip
|
||||
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
|
||||
color={AUSRUESTUNG_STATUS_COLORS[anfrage.status]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -645,6 +426,9 @@ export default function AusruestungsanfrageDetail() {
|
||||
{p.ist_ersatz && (
|
||||
<Chip label="Ersatzbeschaffung" size="small" color="warning" variant="outlined" />
|
||||
)}
|
||||
{p.geliefert && detail?.im_haus && (
|
||||
<Chip label="Im Haus" size="small" color="success" />
|
||||
)}
|
||||
{p.eigenschaften && p.eigenschaften.length > 0 && p.eigenschaften.map(e => (
|
||||
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
|
||||
))}
|
||||
@@ -743,7 +527,7 @@ export default function AusruestungsanfrageDetail() {
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AssignmentIcon />}
|
||||
onClick={() => setAssignmentOpen(true)}
|
||||
onClick={() => navigate(`/ausruestungsanfrage/${requestId}/zuweisung`)}
|
||||
>
|
||||
Zuweisen
|
||||
</Button>
|
||||
@@ -786,20 +570,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user