440 lines
19 KiB
TypeScript
440 lines
19 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
import {
|
|
Box, Typography, Paper, Button, TextField, IconButton,
|
|
Chip, MenuItem, Divider, Checkbox, FormControlLabel, LinearProgress,
|
|
Autocomplete,
|
|
} from '@mui/material';
|
|
import {
|
|
ArrowBack, Delete as DeleteIcon,
|
|
Add as AddIcon, Edit as EditIcon,
|
|
} from '@mui/icons-material';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useParams, 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 { bestellungApi } from '../services/bestellung';
|
|
import type { Lieferant } from '../types/bestellung.types';
|
|
import type {
|
|
AusruestungArtikel, AusruestungArtikelFormData,
|
|
AusruestungEigenschaft, AusruestungKategorie,
|
|
} from '../types/ausruestungsanfrage.types';
|
|
|
|
// ── EigenschaftenEditor ──
|
|
|
|
function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) {
|
|
const { showSuccess, showError } = useNotification();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [newName, setNewName] = useState('');
|
|
const [newTyp, setNewTyp] = useState<'options' | 'freitext'>('options');
|
|
const [newOptionen, setNewOptionen] = useState('');
|
|
const [newPflicht, setNewPflicht] = useState(false);
|
|
|
|
// Per-eigenschaft edit state
|
|
const [rowState, setRowState] = useState<Record<number, { name: string; typ: 'options' | 'freitext'; optionen: string; pflicht: boolean }>>({});
|
|
|
|
const { data: eigenschaften = [] } = useQuery({
|
|
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
|
|
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!),
|
|
enabled: artikelId != null,
|
|
});
|
|
|
|
const getRow = (e: AusruestungEigenschaft) =>
|
|
rowState[e.id] ?? { name: e.name, typ: e.typ, optionen: e.optionen?.join(', ') || '', pflicht: e.pflicht };
|
|
|
|
const updateRow = (e: AusruestungEigenschaft, patch: Partial<{ name: string; typ: 'options' | 'freitext'; optionen: string; pflicht: boolean }>) => {
|
|
const current = rowState[e.id] ?? { name: e.name, typ: e.typ, optionen: e.optionen?.join(', ') || '', pflicht: e.pflicht };
|
|
setRowState(prev => ({ ...prev, [e.id]: { ...current, ...patch } }));
|
|
};
|
|
|
|
const upsertMut = useMutation({
|
|
mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) =>
|
|
ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] });
|
|
showSuccess('Eigenschaft gespeichert');
|
|
setNewName('');
|
|
setNewOptionen('');
|
|
setNewPflicht(false);
|
|
},
|
|
onError: () => showError('Fehler beim Speichern'),
|
|
});
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gelöscht'); },
|
|
onError: () => showError('Fehler beim Löschen'),
|
|
});
|
|
|
|
const handleAdd = () => {
|
|
if (!newName.trim()) return;
|
|
const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
upsertMut.mutate({ name: newName.trim(), typ: newTyp, optionen, pflicht: newPflicht, sort_order: eigenschaften.length });
|
|
};
|
|
|
|
const handleSaveRow = (e: AusruestungEigenschaft) => {
|
|
const row = getRow(e);
|
|
if (!row.name.trim()) return;
|
|
upsertMut.mutate({
|
|
eigenschaft_id: e.id,
|
|
name: row.name.trim(),
|
|
typ: row.typ,
|
|
optionen: row.typ === 'options' ? row.optionen.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
|
pflicht: row.pflicht,
|
|
sort_order: e.sort_order,
|
|
});
|
|
};
|
|
|
|
if (artikelId == null) return <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufügen.</Typography>;
|
|
|
|
return (
|
|
<Box sx={{ mt: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften</Typography>
|
|
{eigenschaften.map(e => {
|
|
const row = getRow(e);
|
|
return (
|
|
<Box key={e.id} sx={{ mb: 1, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 1 }}>
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
<TextField size="small" label="Name" value={row.name} onChange={ev => updateRow(e, { name: ev.target.value })} sx={{ flexGrow: 1 }} />
|
|
<TextField
|
|
select size="small" label="Typ" value={row.typ}
|
|
onChange={ev => updateRow(e, { typ: ev.target.value as 'options' | 'freitext' })}
|
|
sx={{ minWidth: 120 }}
|
|
>
|
|
<MenuItem value="options">Auswahl</MenuItem>
|
|
<MenuItem value="freitext">Freitext</MenuItem>
|
|
</TextField>
|
|
<FormControlLabel
|
|
control={<Checkbox size="small" checked={row.pflicht} onChange={ev => updateRow(e, { pflicht: ev.target.checked })} />}
|
|
label="Pflicht"
|
|
/>
|
|
<Button size="small" variant="outlined" onClick={() => handleSaveRow(e)} disabled={!row.name.trim() || upsertMut.isPending}>
|
|
Speichern
|
|
</Button>
|
|
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
|
|
</Box>
|
|
{row.typ === 'options' && (
|
|
<TextField size="small" label="Optionen (kommagetrennt)" value={row.optionen} onChange={ev => updateRow(e, { optionen: ev.target.value })} fullWidth />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
<TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} />
|
|
<TextField
|
|
select size="small" label="Typ" value={newTyp}
|
|
onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}
|
|
sx={{ minWidth: 120 }}
|
|
>
|
|
<MenuItem value="options">Auswahl</MenuItem>
|
|
<MenuItem value="freitext">Freitext</MenuItem>
|
|
</TextField>
|
|
<FormControlLabel
|
|
control={<Checkbox size="small" checked={newPflicht} onChange={e => setNewPflicht(e.target.checked)} />}
|
|
label="Pflicht"
|
|
/>
|
|
</Box>
|
|
{newTyp === 'options' && (
|
|
<TextField size="small" label="Optionen (kommagetrennt)" value={newOptionen} onChange={e => setNewOptionen(e.target.value)} placeholder="S, M, L, XL" fullWidth />
|
|
)}
|
|
<Button size="small" startIcon={<AddIcon />} onClick={handleAdd} disabled={!newName.trim() || upsertMut.isPending}>
|
|
Eigenschaft hinzufügen
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// Main Component
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
export default function AusruestungsanfrageArtikelDetail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { showSuccess, showError } = useNotification();
|
|
const { hasPermission } = usePermissionContext();
|
|
|
|
const isCreate = !id || id === 'neu';
|
|
const artikelId = isCreate ? null : Number(id);
|
|
const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
|
|
|
|
// ── State ──
|
|
const [editing, setEditing] = useState(isCreate);
|
|
const [form, setForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
|
|
const [mainKat, setMainKat] = useState<number | ''>('');
|
|
|
|
// ── Queries ──
|
|
const { data: artikel, isLoading, isError } = useQuery<AusruestungArtikel>({
|
|
queryKey: ['ausruestungsanfrage', 'item', artikelId],
|
|
queryFn: () => ausruestungsanfrageApi.getItem(artikelId!),
|
|
enabled: artikelId != null,
|
|
retry: 1,
|
|
});
|
|
|
|
const { data: kategorien = [] } = useQuery<AusruestungKategorie[]>({
|
|
queryKey: ['ausruestungsanfrage', 'kategorien'],
|
|
queryFn: () => ausruestungsanfrageApi.getKategorien(),
|
|
});
|
|
|
|
const { data: eigenschaften = [] } = useQuery<AusruestungEigenschaft[]>({
|
|
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
|
|
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!),
|
|
enabled: artikelId != null,
|
|
});
|
|
|
|
const { data: lieferanten = [] } = useQuery<Lieferant[]>({
|
|
queryKey: ['bestellungen', 'lieferanten'],
|
|
queryFn: () => bestellungApi.getVendors(),
|
|
enabled: editing || isCreate,
|
|
});
|
|
|
|
const topKategorien = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]);
|
|
const subKategorienOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]);
|
|
const subKats = useMemo(() => mainKat ? subKategorienOf(mainKat as number) : [], [mainKat, subKategorienOf]);
|
|
|
|
const kategorieOptions = useMemo(() => {
|
|
const map = new Map(kategorien.map(k => [k.id, k]));
|
|
const getDisplayName = (k: AusruestungKategorie): string => {
|
|
if (k.parent_id) {
|
|
const parent = map.get(k.parent_id);
|
|
if (parent) return `${parent.name} > ${k.name}`;
|
|
}
|
|
return k.name;
|
|
};
|
|
return kategorien.map(k => ({ id: k.id, name: getDisplayName(k) }));
|
|
}, [kategorien]);
|
|
|
|
// ── Mutations ──
|
|
const createMut = useMutation({
|
|
mutationFn: (data: AusruestungArtikelFormData) => ausruestungsanfrageApi.createItem(data),
|
|
onSuccess: (newItem) => {
|
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
|
showSuccess('Artikel erstellt');
|
|
navigate(`/ausruestungsanfrage/artikel/${newItem.id}`, { replace: true });
|
|
},
|
|
onError: () => showError('Fehler beim Erstellen'),
|
|
});
|
|
|
|
const updateMut = useMutation({
|
|
mutationFn: ({ itemId, data }: { itemId: number; data: Partial<AusruestungArtikelFormData> }) =>
|
|
ausruestungsanfrageApi.updateItem(itemId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
|
showSuccess('Artikel aktualisiert');
|
|
setEditing(false);
|
|
},
|
|
onError: () => showError('Fehler beim Aktualisieren'),
|
|
});
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (itemId: number) => ausruestungsanfrageApi.deleteItem(itemId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
|
showSuccess('Artikel geloescht');
|
|
navigate('/ausruestungsanfrage?tab=2');
|
|
},
|
|
onError: () => showError('Fehler beim Loeschen'),
|
|
});
|
|
|
|
// ── Edit helpers ──
|
|
const startEditing = () => {
|
|
if (!artikel) return;
|
|
setForm({
|
|
bezeichnung: artikel.bezeichnung,
|
|
beschreibung: artikel.beschreibung,
|
|
kategorie_id: artikel.kategorie_id ?? null,
|
|
bevorzugter_lieferant_id: artikel.bevorzugter_lieferant_id ?? null,
|
|
});
|
|
const kat = kategorien.find(k => k.id === artikel.kategorie_id);
|
|
if (kat?.parent_id) {
|
|
setMainKat(kat.parent_id);
|
|
} else {
|
|
setMainKat(artikel.kategorie_id || '');
|
|
}
|
|
setEditing(true);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!form.bezeichnung.trim()) return;
|
|
if (isCreate) {
|
|
createMut.mutate(form);
|
|
} else if (artikelId) {
|
|
updateMut.mutate({ itemId: artikelId, data: form });
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (isCreate) {
|
|
navigate('/ausruestungsanfrage?tab=2');
|
|
} else {
|
|
setEditing(false);
|
|
}
|
|
};
|
|
|
|
const getKategorieName = (katId?: number) => {
|
|
if (!katId) return '-';
|
|
return kategorieOptions.find(k => k.id === katId)?.name || '-';
|
|
};
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
{/* Header */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
|
<IconButton onClick={() => navigate('/ausruestungsanfrage?tab=2')}>
|
|
<ArrowBack />
|
|
</IconButton>
|
|
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
|
{isCreate ? 'Neuer Katalogartikel' : (artikel?.bezeichnung ?? '...')}
|
|
</Typography>
|
|
{!isCreate && canManage && !editing && (
|
|
<>
|
|
<IconButton onClick={startEditing}><EditIcon /></IconButton>
|
|
<IconButton color="error" onClick={() => { if (artikelId) deleteMut.mutate(artikelId); }}>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</>
|
|
)}
|
|
</Box>
|
|
|
|
{!isCreate && isLoading ? (
|
|
<LinearProgress />
|
|
) : !isCreate && isError ? (
|
|
<Typography color="error">Fehler beim Laden des Artikels.</Typography>
|
|
) : editing ? (
|
|
/* ── Edit / Create Mode ── */
|
|
<Paper sx={{ p: 3 }}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<TextField
|
|
label="Bezeichnung"
|
|
required
|
|
value={form.bezeichnung}
|
|
onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))}
|
|
fullWidth
|
|
autoFocus
|
|
/>
|
|
<TextField
|
|
label="Beschreibung"
|
|
multiline
|
|
rows={3}
|
|
value={form.beschreibung ?? ''}
|
|
onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
select
|
|
label="Hauptkategorie"
|
|
value={mainKat}
|
|
onChange={e => {
|
|
const val = e.target.value ? Number(e.target.value) : '';
|
|
setMainKat(val);
|
|
if (val) {
|
|
subKategorienOf(val as number);
|
|
setForm(f => ({ ...f, kategorie_id: val as number }));
|
|
} else {
|
|
setForm(f => ({ ...f, kategorie_id: null }));
|
|
}
|
|
}}
|
|
fullWidth
|
|
>
|
|
<MenuItem value="">Keine</MenuItem>
|
|
{topKategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
|
|
</TextField>
|
|
{mainKat && subKats.length > 0 && (
|
|
<TextField
|
|
select
|
|
label="Unterkategorie"
|
|
value={form.kategorie_id ?? ''}
|
|
onChange={e => setForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : (mainKat as number) }))}
|
|
fullWidth
|
|
>
|
|
<MenuItem value={mainKat as number}>Keine (nur Hauptkategorie)</MenuItem>
|
|
{subKats.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
|
|
</TextField>
|
|
)}
|
|
|
|
<Autocomplete
|
|
options={lieferanten}
|
|
getOptionLabel={l => l.name}
|
|
value={lieferanten.find(l => l.id === form.bevorzugter_lieferant_id) || null}
|
|
onChange={(_, v) => setForm(f => ({ ...f, bevorzugter_lieferant_id: v?.id ?? null }))}
|
|
renderInput={params => <TextField {...params} label="Bevorzugter Lieferant (optional)" />}
|
|
fullWidth
|
|
/>
|
|
|
|
{canManage && <EigenschaftenEditor artikelId={artikelId} />}
|
|
|
|
<Divider />
|
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
|
<Button onClick={handleCancel}>Abbrechen</Button>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSave}
|
|
disabled={!form.bezeichnung.trim() || createMut.isPending || updateMut.isPending}
|
|
>
|
|
{isCreate ? 'Erstellen' : 'Speichern'}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
) : artikel ? (
|
|
/* ── View Mode ── */
|
|
<>
|
|
<Paper sx={{ p: 3, mb: 3 }}>
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Bezeichnung</Typography>
|
|
<Typography variant="body1" fontWeight={500}>{artikel.bezeichnung}</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Kategorie</Typography>
|
|
<Typography variant="body1">{getKategorieName(artikel.kategorie_id)}</Typography>
|
|
</Box>
|
|
{artikel.beschreibung && (
|
|
<Box sx={{ gridColumn: '1 / -1' }}>
|
|
<Typography variant="caption" color="text.secondary">Beschreibung</Typography>
|
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{artikel.beschreibung}</Typography>
|
|
</Box>
|
|
)}
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Status</Typography>
|
|
<Box><Chip label={artikel.aktiv ? 'Aktiv' : 'Inaktiv'} size="small" color={artikel.aktiv ? 'success' : 'default'} /></Box>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
|
|
<Typography variant="body2">{new Date(artikel.erstellt_am).toLocaleDateString('de-AT')}</Typography>
|
|
</Box>
|
|
{artikel.bevorzugter_lieferant_name && (
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Bevorzugter Lieferant</Typography>
|
|
<Typography variant="body2">{artikel.bevorzugter_lieferant_name}</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
|
|
{eigenschaften.length > 0 && (
|
|
<Paper sx={{ p: 3 }}>
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften ({eigenschaften.length})</Typography>
|
|
{eigenschaften.map(e => (
|
|
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
|
|
<Typography variant="body2" sx={{ flexGrow: 1 }}>
|
|
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
|
|
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</Paper>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</DashboardLayout>
|
|
);
|
|
}
|