rework from modal to page
This commit is contained in:
318
frontend/src/pages/AusruestungsanfrageNeu.tsx
Normal file
318
frontend/src/pages/AusruestungsanfrageNeu.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, TextField, IconButton,
|
||||
Autocomplete, Divider, MenuItem,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||
import type {
|
||||
AusruestungAnfrageFormItem,
|
||||
AusruestungEigenschaft,
|
||||
} from '../types/ausruestungsanfrage.types';
|
||||
|
||||
// ── EigenschaftFields ──
|
||||
|
||||
interface EigenschaftFieldsProps {
|
||||
eigenschaften: AusruestungEigenschaft[];
|
||||
values: Record<number, string>;
|
||||
onChange: (eigenschaftId: number, wert: string) => void;
|
||||
}
|
||||
|
||||
function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) {
|
||||
if (eigenschaften.length === 0) return null;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ml: 2, mt: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
{eigenschaften.map(e => (
|
||||
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{e.typ === 'options' && e.optionen && e.optionen.length > 0 ? (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={e.name}
|
||||
value={values[e.id] || ''}
|
||||
onChange={ev => onChange(e.id, ev.target.value)}
|
||||
required={e.pflicht}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<MenuItem value="">—</MenuItem>
|
||||
{e.optionen.map(opt => (
|
||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
label={e.name}
|
||||
value={values[e.id] || ''}
|
||||
onChange={ev => onChange(e.id, ev.target.value)}
|
||||
required={e.pflicht}
|
||||
sx={{ minWidth: 160 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Component
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function AusruestungsanfrageNeu() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
|
||||
const canOrderForUser = hasPermission('ausruestungsanfrage:order_for_user');
|
||||
|
||||
// ── Form state ──
|
||||
const [bezeichnung, setBezeichnung] = useState('');
|
||||
const [notizen, setNotizen] = useState('');
|
||||
const [fuerBenutzer, setFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null);
|
||||
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
|
||||
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
|
||||
|
||||
// Eigenschaften state
|
||||
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
||||
const itemEigenschaftenRef = useRef(itemEigenschaften);
|
||||
itemEigenschaftenRef.current = itemEigenschaften;
|
||||
const [itemEigenschaftValues, setItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
|
||||
|
||||
// ── Queries ──
|
||||
const { data: katalogArtikel = [] } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'items-for-create'],
|
||||
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
|
||||
});
|
||||
|
||||
const { data: orderUsers = [] } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'orderUsers'],
|
||||
queryFn: () => ausruestungsanfrageApi.getOrderUsers(),
|
||||
enabled: canOrderForUser,
|
||||
});
|
||||
|
||||
// ── Mutations ──
|
||||
const createMut = useMutation({
|
||||
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string }) =>
|
||||
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
||||
showSuccess('Anfrage erstellt');
|
||||
navigate('/ausruestungsanfrage');
|
||||
},
|
||||
onError: () => showError('Fehler beim Erstellen der Anfrage'),
|
||||
});
|
||||
|
||||
// ── Eigenschaft loading ──
|
||||
const loadEigenschaften = useCallback(async (artikelId: number) => {
|
||||
if (itemEigenschaftenRef.current[artikelId]) return;
|
||||
try {
|
||||
const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId);
|
||||
if (eigs && eigs.length > 0) {
|
||||
setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load eigenschaften for artikel', artikelId, err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Submit ──
|
||||
const handleSubmit = () => {
|
||||
// Catalog items with eigenschaften
|
||||
const catalogValidItems = catalogItems.filter(i => i.bezeichnung.trim() && i.artikel_id).map((item, idx) => {
|
||||
const vals = itemEigenschaftValues[idx] || {};
|
||||
const eigenschaften = Object.entries(vals)
|
||||
.filter(([, wert]) => wert.trim())
|
||||
.map(([eid, wert]) => ({ eigenschaft_id: Number(eid), wert }));
|
||||
return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined };
|
||||
});
|
||||
|
||||
// Free-text items
|
||||
const freeValidItems = freeItems
|
||||
.filter(i => i.bezeichnung.trim())
|
||||
.map(i => ({ bezeichnung: i.bezeichnung, menge: i.menge }));
|
||||
|
||||
const allItems = [...catalogValidItems, ...freeValidItems];
|
||||
if (allItems.length === 0) return;
|
||||
|
||||
// Check required eigenschaften for catalog items
|
||||
for (let idx = 0; idx < catalogItems.length; idx++) {
|
||||
const item = catalogItems[idx];
|
||||
if (!item.bezeichnung.trim() || !item.artikel_id) continue;
|
||||
if (itemEigenschaften[item.artikel_id]) {
|
||||
for (const e of itemEigenschaften[item.artikel_id]) {
|
||||
if (e.pflicht && !(itemEigenschaftValues[idx]?.[e.id]?.trim())) {
|
||||
showError(`Pflichtfeld "${e.name}" für "${item.bezeichnung}" fehlt`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createMut.mutate({
|
||||
items: allItems,
|
||||
notizen: notizen || undefined,
|
||||
bezeichnung: bezeichnung || undefined,
|
||||
fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined,
|
||||
fuer_benutzer_name: typeof fuerBenutzer === 'string' ? fuerBenutzer : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim());
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5">Neue Bestellung</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Bezeichnung (optional)"
|
||||
value={bezeichnung}
|
||||
onChange={e => setBezeichnung(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
{canOrderForUser && (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={orderUsers}
|
||||
getOptionLabel={o => typeof o === 'string' ? o : o.name}
|
||||
value={fuerBenutzer}
|
||||
onChange={(_, v) => setFuerBenutzer(v)}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === 'input') {
|
||||
const match = orderUsers.find(u => u.name === value);
|
||||
if (!match && value) {
|
||||
setFuerBenutzer(value);
|
||||
} else if (!value) {
|
||||
setFuerBenutzer(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
if (typeof option === 'string' || typeof value === 'string') return option === value;
|
||||
return option.id === value.id;
|
||||
}}
|
||||
renderInput={params => <TextField {...params} label="Für wen (optional)" InputLabelProps={{ ...params.InputLabelProps, shrink: true }} placeholder="Mitglied auswählen oder Name eingeben..." />}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
label="Notizen (optional)"
|
||||
value={notizen}
|
||||
onChange={e => setNotizen(e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Typography variant="subtitle2">Aus Katalog</Typography>
|
||||
{catalogItems.map((item, idx) => (
|
||||
<Box key={`cat-${idx}`}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Autocomplete
|
||||
options={katalogArtikel}
|
||||
getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung}
|
||||
value={item.artikel_id ? katalogArtikel.find(c => c.id === item.artikel_id) || null : null}
|
||||
onChange={(_, v) => {
|
||||
if (v && typeof v !== 'string') {
|
||||
setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
|
||||
loadEigenschaften(v.id);
|
||||
} else {
|
||||
setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: undefined, bezeichnung: '' } : it));
|
||||
setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; });
|
||||
}
|
||||
}}
|
||||
renderInput={params => <TextField {...params} label="Katalogartikel" size="small" />}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Menge"
|
||||
value={item.menge}
|
||||
onChange={e => setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))}
|
||||
sx={{ width: 90 }}
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
<IconButton size="small" onClick={() => setCatalogItems(prev => prev.filter((_, i) => i !== idx))} disabled={catalogItems.length <= 1}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{item.artikel_id && itemEigenschaften[item.artikel_id] && itemEigenschaften[item.artikel_id].length > 0 && (
|
||||
<EigenschaftFields
|
||||
eigenschaften={itemEigenschaften[item.artikel_id]}
|
||||
values={itemEigenschaftValues[idx] || {}}
|
||||
onChange={(eid, wert) => setItemEigenschaftValues(prev => ({
|
||||
...prev,
|
||||
[idx]: { ...(prev[idx] || {}), [eid]: wert },
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={() => setCatalogItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
|
||||
Katalogartikel hinzufügen
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
<Typography variant="subtitle2">Freitext-Positionen</Typography>
|
||||
{freeItems.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">Keine Freitext-Positionen.</Typography>
|
||||
) : (
|
||||
freeItems.map((item, idx) => (
|
||||
<Box key={`free-${idx}`} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Bezeichnung"
|
||||
value={item.bezeichnung}
|
||||
onChange={e => setFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: e.target.value } : it))}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Menge"
|
||||
value={item.menge}
|
||||
onChange={e => setFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))}
|
||||
sx={{ width: 90 }}
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
<IconButton size="small" onClick={() => setFreeItems(prev => prev.filter((_, i) => i !== idx))}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={() => setFreeItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
|
||||
Freitext-Position hinzufügen
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => navigate('/ausruestungsanfrage')}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={createMut.isPending || !hasValidItems}
|
||||
>
|
||||
Anfrage erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user