393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import {
|
|
Box, Typography, Container, Button, Chip,
|
|
TextField, Autocomplete, ToggleButton, ToggleButtonGroup,
|
|
Stack, Divider, LinearProgress, MenuItem,
|
|
} from '@mui/material';
|
|
import { Assignment as AssignmentIcon } from '@mui/icons-material';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|
import { PageHeader } from '../components/templates';
|
|
import { useNotification } from '../contexts/NotificationContext';
|
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
|
import { personalEquipmentApi } from '../services/personalEquipment';
|
|
import { vehiclesApi } from '../services/vehicles';
|
|
import { membersService } from '../services/members';
|
|
import type { AusruestungAnfragePosition, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
|
|
|
|
type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine';
|
|
|
|
interface PositionAssignment {
|
|
typ: AssignmentTyp;
|
|
fahrzeugId?: string;
|
|
standort?: string;
|
|
userId?: string;
|
|
benutzerName?: string;
|
|
eigenschaften?: Record<number, string>; // eigenschaft_id → wert
|
|
}
|
|
|
|
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
|
|
return positions.filter((p) => p.geliefert && (!p.zuweisung_typ || p.zuweisung_typ === 'keine'));
|
|
}
|
|
|
|
export default function AusruestungsanfrageZuweisung() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { showSuccess, showError } = useNotification();
|
|
const anfrageId = Number(id);
|
|
const queryClient = useQueryClient();
|
|
const { hasPermission } = usePermissionContext();
|
|
const canManageCatalog = hasPermission('ausruestungsanfrage:manage_catalog');
|
|
|
|
const [createArtikelFor, setCreateArtikelFor] = useState<number | null>(null);
|
|
const [newArtikelBezeichnung, setNewArtikelBezeichnung] = useState('');
|
|
const [newArtikelSubmitting, setNewArtikelSubmitting] = useState(false);
|
|
|
|
const { data: detail, isLoading, isError } = useQuery({
|
|
queryKey: ['ausruestungsanfrage', 'request', anfrageId],
|
|
queryFn: () => ausruestungsanfrageApi.getRequest(anfrageId),
|
|
enabled: !isNaN(anfrageId),
|
|
retry: 1,
|
|
});
|
|
|
|
const unassigned = useMemo(() => {
|
|
if (!detail) return [];
|
|
return getUnassignedPositions(detail.positionen);
|
|
}, [detail]);
|
|
|
|
const [assignments, setAssignments] = useState<Record<number, PositionAssignment>>({});
|
|
|
|
// Collect unique artikel_ids from unassigned positions to batch-load their eigenschaften
|
|
const uniqueArtikelIds = useMemo(
|
|
() => [...new Set(unassigned.filter(p => p.artikel_id).map(p => p.artikel_id!))],
|
|
[unassigned],
|
|
);
|
|
|
|
const { data: artikelEigenschaftenMap = {} } = useQuery<Record<number, AusruestungEigenschaft[]>>({
|
|
queryKey: ['ausruestungsanfrage', 'eigenschaften-batch', uniqueArtikelIds],
|
|
queryFn: async () => {
|
|
const results: Record<number, AusruestungEigenschaft[]> = {};
|
|
await Promise.all(
|
|
uniqueArtikelIds.map(async (aid) => {
|
|
results[aid] = await ausruestungsanfrageApi.getArtikelEigenschaften(aid);
|
|
}),
|
|
);
|
|
return results;
|
|
},
|
|
enabled: uniqueArtikelIds.length > 0,
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
// Initialize assignments when unassigned positions load
|
|
useMemo(() => {
|
|
if (unassigned.length > 0 && Object.keys(assignments).length === 0) {
|
|
const init: Record<number, PositionAssignment> = {};
|
|
for (const p of unassigned) {
|
|
// Pre-fill eigenschaften from position values
|
|
const prefilled: Record<number, string> = {};
|
|
for (const e of p.eigenschaften ?? []) {
|
|
prefilled[e.eigenschaft_id] = e.wert;
|
|
}
|
|
init[p.id] = { typ: 'persoenlich', eigenschaften: prefilled };
|
|
}
|
|
setAssignments(init);
|
|
}
|
|
}, [unassigned]);
|
|
|
|
const { data: vehicleList } = useQuery({
|
|
queryKey: ['vehicles', 'sidebar'],
|
|
queryFn: () => vehiclesApi.getAll(),
|
|
staleTime: 2 * 60 * 1000,
|
|
});
|
|
|
|
const { data: membersList } = useQuery({
|
|
queryKey: ['members-list-compact'],
|
|
queryFn: () => membersService.getMembers({ pageSize: 500 }),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
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 { data: allPersonalItems = [] } = useQuery({
|
|
queryKey: ['persoenliche-ausruestung', 'all-for-count'],
|
|
queryFn: () => personalEquipmentApi.getAll(),
|
|
staleTime: 2 * 60 * 1000,
|
|
});
|
|
|
|
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 handleCreateArtikel = async (posId: number) => {
|
|
if (!newArtikelBezeichnung.trim()) return;
|
|
setNewArtikelSubmitting(true);
|
|
try {
|
|
const newArtikel = await ausruestungsanfrageApi.createItem({ bezeichnung: newArtikelBezeichnung.trim(), aktiv: true });
|
|
await ausruestungsanfrageApi.linkPositionToArtikel(posId, newArtikel.id);
|
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'request', anfrageId] });
|
|
setCreateArtikelFor(null);
|
|
showSuccess('Katalogartikel erstellt und Position verknüpft');
|
|
} catch {
|
|
showError('Fehler beim Erstellen des Katalogartikels');
|
|
} finally {
|
|
setNewArtikelSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!detail) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const anfrage = detail.anfrage;
|
|
const posMap = Object.fromEntries(unassigned.map(p => [p.id, p]));
|
|
const payload = Object.entries(assignments).map(([posId, a]) => {
|
|
const pos = posMap[Number(posId)];
|
|
const artikelEigs: AusruestungEigenschaft[] = pos?.artikel_id
|
|
? (artikelEigenschaftenMap[pos.artikel_id] ?? [])
|
|
: [];
|
|
return {
|
|
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,
|
|
eigenschaften: a.typ === 'persoenlich' && a.eigenschaften
|
|
? Object.entries(a.eigenschaften)
|
|
.filter(([, v]) => v.trim())
|
|
.map(([eid, wert]) => ({
|
|
eigenschaft_id: Number(eid),
|
|
name: artikelEigs.find(e => e.id === Number(eid))?.name ?? '',
|
|
wert,
|
|
}))
|
|
: undefined,
|
|
};
|
|
});
|
|
await ausruestungsanfrageApi.assignItems(anfrageId, payload);
|
|
showSuccess('Gegenstände zugewiesen');
|
|
navigate(`/ausruestungsanfrage/${id}`);
|
|
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
|
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
|
} catch {
|
|
showError('Fehler beim Zuweisen');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const backPath = `/ausruestungsanfrage/${id}`;
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<Container maxWidth="md">
|
|
<PageHeader
|
|
title="Gegenstände zuweisen"
|
|
subtitle={detail?.anfrage.bezeichnung || undefined}
|
|
backTo={backPath}
|
|
/>
|
|
|
|
{isLoading ? (
|
|
<LinearProgress />
|
|
) : isError || !detail ? (
|
|
<Typography color="error">Fehler beim Laden der Anfrage.</Typography>
|
|
) : unassigned.length === 0 ? (
|
|
<Typography color="text.secondary">Keine unzugewiesenen Positionen vorhanden.</Typography>
|
|
) : (
|
|
<>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
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>
|
|
|
|
{!pos.artikel_id && (
|
|
<Box sx={{ mb: 1 }}>
|
|
<Chip label="Nicht im Katalog" color="warning" size="small" sx={{ mb: 1 }} />
|
|
{canManageCatalog && createArtikelFor !== pos.id && (
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="warning"
|
|
sx={{ ml: 1 }}
|
|
onClick={() => { setCreateArtikelFor(pos.id); setNewArtikelBezeichnung(pos.bezeichnung); }}
|
|
>
|
|
Als Katalogartikel anlegen
|
|
</Button>
|
|
)}
|
|
{createArtikelFor === pos.id && (
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
|
|
<TextField
|
|
size="small"
|
|
label="Bezeichnung"
|
|
value={newArtikelBezeichnung}
|
|
onChange={(e) => setNewArtikelBezeichnung(e.target.value)}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
disabled={newArtikelSubmitting || !newArtikelBezeichnung.trim()}
|
|
onClick={() => handleCreateArtikel(pos.id)}
|
|
>
|
|
Erstellen
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
onClick={() => setCreateArtikelFor(null)}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
<ToggleButtonGroup
|
|
value={a.typ}
|
|
exclusive
|
|
size="small"
|
|
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
|
|
sx={{ mb: 1.5 }}
|
|
>
|
|
<ToggleButton value="ausruestung" disabled={!pos.artikel_id}>Ausrüstung</ToggleButton>
|
|
<ToggleButton value="persoenlich" disabled={!pos.artikel_id}>Persönlich</ToggleButton>
|
|
<ToggleButton value="keine">Nicht zuweisen</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', flexDirection: 'column', gap: 1.5 }}>
|
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<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={detail.anfrage.fuer_benutzer_name || detail.anfrage.anfrager_name || ''}
|
|
/>
|
|
)}
|
|
sx={{ minWidth: 200, flex: 1 }}
|
|
/>
|
|
{(() => {
|
|
if (!a.userId || !pos.artikel_id) return null;
|
|
const count = allPersonalItems.filter(i => i.user_id === a.userId && i.artikel_id === pos.artikel_id).length;
|
|
if (count === 0) return null;
|
|
return <Typography variant="caption" color="text.secondary">Hat bereits {count} Stk.</Typography>;
|
|
})()}
|
|
</Box>
|
|
{/* Editable characteristic fields from article definitions */}
|
|
{pos.artikel_id && (artikelEigenschaftenMap[pos.artikel_id] ?? []).map(e =>
|
|
e.typ === 'options' && e.optionen?.length ? (
|
|
<TextField
|
|
key={e.id}
|
|
select
|
|
size="small"
|
|
label={e.name}
|
|
required={e.pflicht}
|
|
value={a.eigenschaften?.[e.id] ?? ''}
|
|
onChange={ev => updateAssignment(pos.id, {
|
|
eigenschaften: { ...a.eigenschaften, [e.id]: ev.target.value },
|
|
})}
|
|
>
|
|
<MenuItem value="">—</MenuItem>
|
|
{e.optionen.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
|
|
</TextField>
|
|
) : (
|
|
<TextField
|
|
key={e.id}
|
|
size="small"
|
|
label={e.name}
|
|
required={e.pflicht}
|
|
value={a.eigenschaften?.[e.id] ?? ''}
|
|
onChange={ev => updateAssignment(pos.id, {
|
|
eigenschaften: { ...a.eigenschaften, [e.id]: ev.target.value },
|
|
})}
|
|
/>
|
|
)
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
|
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 4 }}>
|
|
<Button onClick={handleSkipAll} color="inherit">
|
|
Alle überspringen
|
|
</Button>
|
|
<Button onClick={() => navigate(backPath)}>Abbrechen</Button>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSubmit}
|
|
disabled={submitting}
|
|
startIcon={<AssignmentIcon />}
|
|
>
|
|
Zuweisen
|
|
</Button>
|
|
</Box>
|
|
</>
|
|
)}
|
|
</Container>
|
|
</DashboardLayout>
|
|
);
|
|
}
|