rework internal order system

This commit is contained in:
Matthias Hochmeister
2026-03-24 08:41:24 +01:00
parent f982fbb2b6
commit 3c0a8a6832
9 changed files with 910 additions and 205 deletions

View File

@@ -100,8 +100,8 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Widget': ['widget'],
},
ausruestungsanfrage: {
'Katalog': ['view', 'manage_catalog'],
'Anfragen': ['create_request', 'approve', 'link_orders', 'view_all', 'order_for_user', 'edit'],
'Katalog': ['view', 'manage_catalog', 'manage_categories'],
'Anfragen': ['create_request', 'approve', 'link_orders', 'order_for_user', 'edit'],
'Widget': ['widget'],
},
admin: {

View File

@@ -190,7 +190,6 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
let ausruestungTabIdx = 0;
if (hasPermission('ausruestungsanfrage:create_request')) { ausruestungSubItems.push({ text: 'Meine Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
if (hasPermission('ausruestungsanfrage:approve')) { ausruestungSubItems.push({ text: 'Alle Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
if (hasPermission('ausruestungsanfrage:view_all')) { ausruestungSubItems.push({ text: 'Übersicht', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; }
ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` });
// Build Issues sub-items dynamically (tab order must match Issues.tsx)

View File

@@ -1,14 +1,14 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import {
Box, Tab, Tabs, Typography, Card, CardContent, CardActions, Grid, Button, Chip,
Box, Tab, Tabs, Typography, Grid, Button, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
MenuItem, Select, FormControl, InputLabel, Autocomplete,
Divider,
Divider, Checkbox, FormControlLabel, Tooltip,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon,
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, Settings as SettingsIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
@@ -16,10 +16,15 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { bestellungApi } from '../services/bestellung';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type { AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem, AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage, AusruestungOverview } from '../types/ausruestungsanfrage.types';
import type {
AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem,
AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage,
AusruestungOverview, AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types';
import type { Bestellung } from '../types/bestellung.types';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -33,6 +38,247 @@ function formatOrderId(r: AusruestungAnfrage): string {
const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt'];
// ─── Eigenschaft Fields Component ────────────────────────────────────────────
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: 0.5 }}>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{e.typ === 'options' && e.optionen && e.optionen.length > 0 ? (
<FormControl size="small" sx={{ minWidth: 160 }} required={e.pflicht}>
<InputLabel>{e.name}</InputLabel>
<Select
value={values[e.id] || ''}
label={e.name}
onChange={ev => onChange(e.id, ev.target.value)}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
) : (
<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>
);
}
// ─── Category Management Dialog ──────────────────────────────────────────────
interface KategorieDialogProps {
open: boolean;
onClose: () => void;
}
function KategorieDialog({ open, onClose }: KategorieDialogProps) {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [newName, setNewName] = useState('');
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const { data: kategorien = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'kategorien'],
queryFn: () => ausruestungsanfrageApi.getKategorien(),
enabled: open,
});
const createMut = useMutation({
mutationFn: (name: string) => ausruestungsanfrageApi.createKategorie(name),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie erstellt'); setNewName(''); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateMut = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) => ausruestungsanfrageApi.updateKategorie(id, name),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie aktualisiert'); setEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => ausruestungsanfrageApi.deleteKategorie(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie gelöscht'); },
onError: () => showError('Fehler beim Löschen'),
});
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Kategorien verwalten</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
label="Neue Kategorie"
value={newName}
onChange={e => setNewName(e.target.value)}
sx={{ flexGrow: 1 }}
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) createMut.mutate(newName.trim()); }}
/>
<Button
variant="contained"
size="small"
onClick={() => { if (newName.trim()) createMut.mutate(newName.trim()); }}
disabled={!newName.trim() || createMut.isPending}
>
Erstellen
</Button>
</Box>
<Divider />
{kategorien.length === 0 ? (
<Typography color="text.secondary">Keine Kategorien vorhanden.</Typography>
) : (
kategorien.map(k => (
<Box key={k.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{editId === k.id ? (
<>
<TextField
size="small"
value={editName}
onChange={e => setEditName(e.target.value)}
sx={{ flexGrow: 1 }}
onKeyDown={e => { if (e.key === 'Enter' && editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }}
/>
<IconButton size="small" onClick={() => { if (editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }}><CheckIcon fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => setEditId(null)}><CloseIcon fontSize="small" /></IconButton>
</>
) : (
<>
<Typography sx={{ flexGrow: 1 }}>{k.name}</Typography>
<IconButton size="small" onClick={() => { setEditId(k.id); setEditName(k.name); }}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(k.id)}><DeleteIcon fontSize="small" /></IconButton>
</>
)}
</Box>
))
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);
}
// ─── Eigenschaften Editor (in Artikel dialog) ────────────────────────────────
interface EigenschaftenEditorProps {
artikelId: number | null;
}
function EigenschaftenEditor({ artikelId }: EigenschaftenEditorProps) {
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);
const { data: eigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!),
enabled: artikelId != null,
});
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'); },
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,
});
setNewName('');
setNewOptionen('');
setNewPflicht(false);
};
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 => (
<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>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
</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 }} />
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Typ</InputLabel>
<Select value={newTyp} label="Typ" onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}>
<MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem>
</Select>
</FormControl>
<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>
);
}
// ─── Detail Modal ─────────────────────────────────────────────────────────────
interface DetailModalProps {
@@ -123,6 +369,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
bezeichnung: p.bezeichnung,
menge: p.menge,
notizen: p.notizen,
eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })),
})));
setEditing(true);
};
@@ -257,9 +504,20 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
<Divider />
<Typography variant="subtitle2">Positionen</Typography>
{detail.positionen.map(p => (
<Typography key={p.id} variant="body2">
- {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''}
</Typography>
<Box key={p.id}>
<Typography variant="body2">
- {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''}
</Typography>
{p.eigenschaften && p.eigenschaften.length > 0 && (
<Box sx={{ ml: 2, mt: 0.25 }}>
{p.eigenschaften.map(e => (
<Typography key={e.eigenschaft_id} variant="caption" color="text.secondary" display="block">
{e.eigenschaft_name}: {e.wert}
</Typography>
))}
</Box>
)}
</Box>
))}
{detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && (
@@ -417,20 +675,22 @@ function KatalogTab() {
const queryClient = useQueryClient();
const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories');
const [filterKategorie, setFilterKategorie] = useState<string>('');
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null);
const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
const { data: items = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'items', filterKategorie],
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined),
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie_id: filterKategorie as number } : undefined),
});
const { data: categories = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'categories'],
queryFn: () => ausruestungsanfrageApi.getCategories(),
const { data: kategorien = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'kategorien'],
queryFn: () => ausruestungsanfrageApi.getKategorien(),
});
const createItemMut = useMutation({
@@ -456,7 +716,7 @@ function KatalogTab() {
};
const openEditArtikel = (a: AusruestungArtikel) => {
setEditArtikel(a);
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie });
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie_id: a.kategorie_id ?? null });
setArtikelDialogOpen(true);
};
const saveArtikel = () => {
@@ -471,49 +731,62 @@ function KatalogTab() {
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Kategorie</InputLabel>
<Select value={filterKategorie} label="Kategorie" onChange={e => setFilterKategorie(e.target.value)}>
<Select value={filterKategorie} label="Kategorie" onChange={e => setFilterKategorie(e.target.value as number | '')}>
<MenuItem value="">Alle</MenuItem>
{categories.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
{kategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
{canManageCategories && (
<Tooltip title="Kategorien verwalten">
<IconButton size="small" onClick={() => setKategorieDialogOpen(true)}>
<SettingsIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{/* Catalog grid */}
{/* Catalog table */}
{isLoading ? (
<Typography color="text.secondary">Lade Katalog...</Typography>
) : items.length === 0 ? (
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
) : (
<Grid container spacing={2}>
{items.map(item => (
<Grid item xs={12} sm={6} md={4} key={item.id}>
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{item.bild_pfad ? (
<Box sx={{ height: 160, backgroundImage: `url(${item.bild_pfad})`, backgroundSize: 'cover', backgroundPosition: 'center', borderBottom: '1px solid', borderColor: 'divider' }} />
) : (
<Box sx={{ height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: '1px solid', borderColor: 'divider' }}>
<ShoppingCart sx={{ fontSize: 48, color: 'text.disabled' }} />
</Box>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>{item.bezeichnung}</Typography>
{item.beschreibung && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{item.beschreibung}</Typography>}
{item.kategorie && (
<Box sx={{ mt: 1 }}>
<Chip label={item.kategorie} size="small" />
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Kategorie</TableCell>
<TableCell>Beschreibung</TableCell>
{canManage && <TableCell align="right">Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{items.map(item => (
<TableRow key={item.id} hover>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{item.bezeichnung}
{(item.eigenschaften_count ?? 0) > 0 && (
<Chip label={`${item.eigenschaften_count} Eig.`} size="small" variant="outlined" />
)}
</Box>
</TableCell>
<TableCell>{item.kategorie_name || item.kategorie || '-'}</TableCell>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.beschreibung || '-'}
</TableCell>
{canManage && (
<TableCell align="right">
<IconButton size="small" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
)}
</CardContent>
{canManage && (
<CardActions sx={{ justifyContent: 'flex-end' }}>
<IconButton size="small" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</CardActions>
)}
</Card>
</Grid>
))}
</Grid>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* Artikel create/edit dialog */}
@@ -522,13 +795,18 @@ function KatalogTab() {
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth />
<TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
<Autocomplete
freeSolo
options={categories}
value={artikelForm.kategorie ?? ''}
onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))}
renderInput={params => <TextField {...params} label="Kategorie" />}
/>
<FormControl fullWidth>
<InputLabel>Kategorie</InputLabel>
<Select
value={artikelForm.kategorie_id ?? ''}
label="Kategorie"
onChange={e => setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : null }))}
>
<MenuItem value="">Keine</MenuItem>
{kategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
{canManage && <EigenschaftenEditor artikelId={editArtikel?.id ?? null} />}
</DialogContent>
<DialogActions>
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
@@ -538,6 +816,9 @@ function KatalogTab() {
</DialogActions>
</Dialog>
{/* Kategorie management dialog */}
<KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} />
{/* FAB for new catalog item */}
{canManage && (
<ChatAwareFab onClick={openNewArtikel} aria-label="Artikel hinzufügen">
@@ -552,6 +833,7 @@ function KatalogTab() {
function MeineAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
@@ -566,6 +848,10 @@ function MeineAnfragenTab() {
const [newNotizen, setNewNotizen] = useState('');
const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null);
const [newItems, setNewItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
// Track loaded eigenschaften per item row (by artikel_id)
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
// Track eigenschaft values per item row index
const [itemEigenschaftValues, setItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
const { data: requests = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'myRequests'],
@@ -601,11 +887,42 @@ function MeineAnfragenTab() {
setNewNotizen('');
setNewFuerBenutzer(null);
setNewItems([{ bezeichnung: '', menge: 1 }]);
setItemEigenschaften({});
setItemEigenschaftValues({});
};
const loadEigenschaften = useCallback(async (artikelId: number) => {
if (itemEigenschaften[artikelId]) return;
try {
const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId);
setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs }));
} catch { /* ignore */ }
}, [itemEigenschaften]);
const handleCreateSubmit = () => {
const validItems = newItems.filter(i => i.bezeichnung.trim());
const validItems = newItems.filter(i => i.bezeichnung.trim()).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 };
});
if (validItems.length === 0) return;
// Check required eigenschaften
for (let idx = 0; idx < newItems.length; idx++) {
const item = newItems[idx];
if (!item.bezeichnung.trim()) continue;
if (item.artikel_id && 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: validItems,
notizen: newNotizen || undefined,
@@ -687,6 +1004,7 @@ function MeineAnfragenTab() {
onClose={() => setDetailId(null)}
showEditButton
canEditAny={canEditAny}
currentUserId={user?.id}
/>
{/* Create Request Dialog */}
@@ -719,39 +1037,55 @@ function MeineAnfragenTab() {
<Divider />
<Typography variant="subtitle2">Positionen</Typography>
{newItems.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Autocomplete
freeSolo
options={catalogItems}
getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung}
value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || item.bezeichnung : item.bezeichnung}
onChange={(_, v) => {
if (typeof v === 'string') {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it));
} else if (v) {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
}
}}
onInputChange={(_, val, reason) => {
if (reason === 'input') {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it));
}
}}
renderInput={params => <TextField {...params} label="Artikel" size="small" />}
sx={{ flexGrow: 1 }}
/>
<TextField
size="small"
type="number"
label="Menge"
value={item.menge}
onChange={e => setNewItems(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={() => setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}>
<DeleteIcon fontSize="small" />
</IconButton>
<Box key={idx}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Autocomplete
freeSolo
options={catalogItems}
getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung}
value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || item.bezeichnung : item.bezeichnung}
onChange={(_, v) => {
if (typeof v === 'string') {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it));
// Clear eigenschaften for this row
setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; });
} else if (v) {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
loadEigenschaften(v.id);
}
}}
onInputChange={(_, val, reason) => {
if (reason === 'input') {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it));
}
}}
renderInput={params => <TextField {...params} label="Artikel" size="small" />}
sx={{ flexGrow: 1 }}
/>
<TextField
size="small"
type="number"
label="Menge"
value={item.menge}
onChange={e => setNewItems(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={() => setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{/* Eigenschaft fields for this item */}
{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={() => setNewItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
@@ -780,10 +1114,11 @@ function MeineAnfragenTab() {
);
}
// ─── Admin All Requests Tab ─────────────────────────────────────────────────
// ─── Admin All Requests Tab (merged with overview) ──────────────────────────
function AlleAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const [statusFilter, setStatusFilter] = useState<string>('');
const [detailId, setDetailId] = useState<number | null>(null);
@@ -794,9 +1129,10 @@ function AlleAnfragenTab() {
queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
});
// Summary counts
const openCount = useMemo(() => requests.filter(r => r.status === 'offen').length, [requests]);
const approvedCount = useMemo(() => requests.filter(r => r.status === 'genehmigt').length, [requests]);
const { data: overview } = useQuery<AusruestungOverview>({
queryKey: ['ausruestungsanfrage', 'overview'],
queryFn: () => ausruestungsanfrageApi.getOverview(),
});
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
@@ -804,22 +1140,28 @@ function AlleAnfragenTab() {
<Box>
{/* Summary cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{openCount}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
<Typography variant="h4" fontWeight={700}>{overview?.pending_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Offene</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{approvedCount}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
<Typography variant="h4" fontWeight={700}>{overview?.approved_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{requests.length}</Typography>
<Typography variant="body2" color="text.secondary">Alle Anfragen</Typography>
<Typography variant="h4" fontWeight={700} color="warning.main">{overview?.unhandled_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Neue (unbearbeitet)</Typography>
</Paper>
</Grid>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview?.total_items ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Gesamt Artikel</Typography>
</Paper>
</Grid>
</Grid>
@@ -872,73 +1214,12 @@ function AlleAnfragenTab() {
showAdminActions
showEditButton
canEditAny={canEditAny}
currentUserId={user?.id}
/>
</Box>
);
}
// ─── Overview Tab ────────────────────────────────────────────────────────────
function UebersichtTab() {
const { data: overview, isLoading } = useQuery<AusruestungOverview>({
queryKey: ['ausruestungsanfrage', 'overview'],
queryFn: () => ausruestungsanfrageApi.getOverview(),
});
if (isLoading) return <Typography color="text.secondary">Lade Übersicht...</Typography>;
if (!overview) return <Typography color="text.secondary">Keine Daten verfügbar.</Typography>;
return (
<Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.pending_count}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.approved_count}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.total_items}</Typography>
<Typography variant="body2" color="text.secondary">Artikel insgesamt</Typography>
</Paper>
</Grid>
</Grid>
{overview.items.length === 0 ? (
<Typography color="text.secondary">Keine offenen/genehmigten Anfragen vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right">Gesamtmenge</TableCell>
<TableCell align="right">Anfragen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{overview.items.map(item => (
<TableRow key={item.bezeichnung}>
<TableCell>{item.bezeichnung}</TableCell>
<TableCell align="right">{item.total_menge}</TableCell>
<TableCell align="right">{item.anfrage_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function Ausruestungsanfrage() {
@@ -948,9 +1229,8 @@ export default function Ausruestungsanfrage() {
const canView = hasPermission('ausruestungsanfrage:view');
const canCreate = hasPermission('ausruestungsanfrage:create_request');
const canApprove = hasPermission('ausruestungsanfrage:approve');
const canViewAll = hasPermission('ausruestungsanfrage:view_all');
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewAll ? 1 : 0);
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0);
const [activeTab, setActiveTab] = useState(() => {
const t = Number(searchParams.get('tab'));
@@ -973,10 +1253,9 @@ export default function Ausruestungsanfrage() {
let next = 0;
if (canCreate) { map.meine = next; next++; }
if (canApprove) { map.alle = next; next++; }
if (canViewAll) { map.uebersicht = next; next++; }
map.katalog = next;
return map;
}, [canCreate, canApprove, canViewAll]);
}, [canCreate, canApprove]);
if (!canView) {
return (
@@ -994,14 +1273,12 @@ export default function Ausruestungsanfrage() {
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
{canCreate && <Tab label="Meine Anfragen" />}
{canApprove && <Tab label="Alle Anfragen" />}
{canViewAll && <Tab label="Übersicht" />}
<Tab label="Katalog" />
</Tabs>
</Box>
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{canViewAll && activeTab === tabIndex.uebersicht && <UebersichtTab />}
{activeTab === tabIndex.katalog && <KatalogTab />}
</DashboardLayout>
);

View File

@@ -6,13 +6,33 @@ import type {
AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem,
AusruestungOverview,
AusruestungKategorie,
AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types';
export const ausruestungsanfrageApi = {
// ── Categories (DB-backed) ──
getKategorien: async (): Promise<AusruestungKategorie[]> => {
const r = await api.get('/api/ausruestungsanfragen/kategorien');
return r.data.data;
},
createKategorie: async (name: string): Promise<AusruestungKategorie> => {
const r = await api.post('/api/ausruestungsanfragen/kategorien', { name });
return r.data.data;
},
updateKategorie: async (id: number, name: string): Promise<AusruestungKategorie> => {
const r = await api.patch(`/api/ausruestungsanfragen/kategorien/${id}`, { name });
return r.data.data;
},
deleteKategorie: async (id: number): Promise<void> => {
await api.delete(`/api/ausruestungsanfragen/kategorien/${id}`);
},
// ── Catalog Items ──
getItems: async (filters?: { kategorie?: string; aktiv?: boolean }): Promise<AusruestungArtikel[]> => {
getItems: async (filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean }): Promise<AusruestungArtikel[]> => {
const params = new URLSearchParams();
if (filters?.kategorie) params.set('kategorie', filters.kategorie);
if (filters?.kategorie_id) params.set('kategorie_id', String(filters.kategorie_id));
if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv));
const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`);
return r.data.data;
@@ -37,6 +57,22 @@ export const ausruestungsanfrageApi = {
return r.data.data;
},
// ── Item Eigenschaften (characteristics) ──
getArtikelEigenschaften: async (artikelId: number): Promise<AusruestungEigenschaft[]> => {
const r = await api.get(`/api/ausruestungsanfragen/items/${artikelId}/eigenschaften`);
return r.data.data;
},
upsertArtikelEigenschaft: async (
artikelId: number,
data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number },
): Promise<AusruestungEigenschaft> => {
const r = await api.post(`/api/ausruestungsanfragen/items/${artikelId}/eigenschaften`, data);
return r.data.data;
},
deleteArtikelEigenschaft: async (eigenschaftId: number): Promise<void> => {
await api.delete(`/api/ausruestungsanfragen/eigenschaften/${eigenschaftId}`);
},
// ── Requests ──
getRequests: async (filters?: { status?: string }): Promise<AusruestungAnfrage[]> => {
const params = new URLSearchParams();
@@ -57,7 +93,7 @@ export const ausruestungsanfrageApi = {
notizen?: string,
bezeichnung?: string,
fuer_benutzer_id?: string,
): Promise<AusruestungAnfrage> => {
): Promise<AusruestungAnfrageDetailResponse> => {
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id });
return r.data.data;
},

View File

@@ -1,5 +1,31 @@
// Ausrüstungsanfrage (Equipment Request) types
// ── Categories ──
export interface AusruestungKategorie {
id: number;
name: string;
erstellt_am?: string;
}
// ── Characteristics ──
export interface AusruestungEigenschaft {
id: number;
artikel_id: number;
name: string;
typ: 'options' | 'freitext';
optionen?: string[];
pflicht: boolean;
sort_order: number;
}
export interface AusruestungPositionEigenschaft {
eigenschaft_id: number;
eigenschaft_name: string;
wert: string;
}
// ── Catalog Items ──
export interface AusruestungArtikel {
@@ -7,18 +33,23 @@ export interface AusruestungArtikel {
bezeichnung: string;
beschreibung?: string;
kategorie?: string;
kategorie_id?: number;
kategorie_name?: string;
bild_pfad?: string;
geschaetzter_preis?: number;
aktiv: boolean;
erstellt_von?: string;
erstellt_am: string;
aktualisiert_am: string;
eigenschaften_count?: number;
eigenschaften?: AusruestungEigenschaft[];
}
export interface AusruestungArtikelFormData {
bezeichnung: string;
beschreibung?: string;
kategorie?: string;
kategorie_id?: number | null;
geschaetzter_preis?: number;
aktiv?: boolean;
}
@@ -69,6 +100,7 @@ export interface AusruestungAnfragePosition {
menge: number;
notizen?: string;
erstellt_am: string;
eigenschaften?: AusruestungPositionEigenschaft[];
}
export interface AusruestungAnfrageFormItem {
@@ -76,6 +108,7 @@ export interface AusruestungAnfrageFormItem {
bezeichnung: string;
menge: number;
notizen?: string;
eigenschaften?: { eigenschaft_id: number; wert: string }[];
}
// ── API Response Types ──
@@ -98,5 +131,6 @@ export interface AusruestungOverview {
items: AusruestungOverviewItem[];
pending_count: number;
approved_count: number;
unhandled_count: number;
total_items: number;
}