rework internal order system

This commit is contained in:
Matthias Hochmeister
2026-03-24 08:59:46 +01:00
parent 3c0a8a6832
commit 6ff5cc89ad
8 changed files with 240 additions and 154 deletions

View File

@@ -1,17 +1,16 @@
import { Card, CardContent, Typography, Box, Chip, List, ListItem, ListItemText, Divider, Skeleton } from '@mui/material';
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
import { Build } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../../types/ausruestungsanfrage.types';
import type { AusruestungAnfrageStatus } from '../../types/ausruestungsanfrage.types';
import type { AusruestungWidgetOverview } from '../../types/ausruestungsanfrage.types';
function AusruestungsanfrageWidget() {
const navigate = useNavigate();
const { data: requests, isLoading, isError } = useQuery({
queryKey: ['ausruestungsanfrage-widget-requests'],
queryFn: () => ausruestungsanfrageApi.getRequests({ status: 'offen' }),
const { data: overview, isLoading, isError } = useQuery<AusruestungWidgetOverview>({
queryKey: ['ausruestungsanfrage-widget-overview'],
queryFn: () => ausruestungsanfrageApi.getWidgetOverview(),
refetchInterval: 5 * 60 * 1000,
retry: 1,
});
@@ -27,76 +26,42 @@ function AusruestungsanfrageWidget() {
);
}
if (isError) {
if (isError || !overview) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Interne Bestellungen</Typography>
<Typography variant="body2" color="text.secondary">
Anfragen konnten nicht geladen werden.
Daten konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
const pendingCount = requests?.length ?? 0;
if (pendingCount === 0) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Interne Bestellungen</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<Build fontSize="small" />
<Typography variant="body2">Keine offenen Anfragen</Typography>
</Box>
</CardContent>
</Card>
);
}
const hasAny = overview.total_count > 0;
return (
<Card>
<Card
sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
onClick={() => navigate('/ausruestungsanfrage')}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography variant="h6">Interne Bestellungen</Typography>
<Chip label={`${pendingCount} offen`} size="small" color="warning" />
<Build fontSize="small" color="action" />
</Box>
<List dense disablePadding>
{(requests ?? []).slice(0, 5).map((req, idx) => (
<Box key={req.id}>
{idx > 0 && <Divider />}
<ListItem
disablePadding
sx={{ cursor: 'pointer', py: 0.5, '&:hover': { bgcolor: 'action.hover' } }}
onClick={() => navigate('/ausruestungsanfrage?tab=2')}
>
<ListItemText
primary={`Anfrage #${req.id}`}
secondary={req.anfrager_name || 'Unbekannt'}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
<Chip
label={AUSRUESTUNG_STATUS_LABELS[req.status as AusruestungAnfrageStatus]}
color={AUSRUESTUNG_STATUS_COLORS[req.status as AusruestungAnfrageStatus]}
size="small"
sx={{ ml: 1 }}
/>
</ListItem>
</Box>
))}
</List>
{pendingCount > 5 && (
<Typography
variant="caption"
color="primary"
sx={{ cursor: 'pointer', mt: 1, display: 'block' }}
onClick={() => navigate('/ausruestungsanfrage?tab=2')}
>
Alle {pendingCount} Anfragen anzeigen
</Typography>
{!hasAny ? (
<Typography variant="body2" color="text.secondary">Keine Anfragen vorhanden.</Typography>
) : (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`${overview.pending_count} Offen`} size="small" color={overview.pending_count > 0 ? 'warning' : 'default'} variant="outlined" />
<Chip label={`${overview.approved_count} Genehmigt`} size="small" color={overview.approved_count > 0 ? 'info' : 'default'} variant="outlined" />
{overview.unhandled_count > 0 && (
<Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" />
)}
<Chip label={`${overview.total_count} Gesamt`} size="small" variant="outlined" />
</Box>
)}
</CardContent>
</Card>

View File

@@ -93,6 +93,7 @@ function KategorieDialog({ open, onClose }: KategorieDialogProps) {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [newName, setNewName] = useState('');
const [newParentId, setNewParentId] = useState<number | null>(null);
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
@@ -102,14 +103,17 @@ function KategorieDialog({ open, onClose }: KategorieDialogProps) {
enabled: open,
});
const topLevel = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]);
const childrenOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]);
const createMut = useMutation({
mutationFn: (name: string) => ausruestungsanfrageApi.createKategorie(name),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie erstellt'); setNewName(''); },
mutationFn: ({ name, parentId }: { name: string; parentId?: number | null }) => ausruestungsanfrageApi.createKategorie(name, parentId),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie erstellt'); setNewName(''); setNewParentId(null); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateMut = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) => ausruestungsanfrageApi.updateKategorie(id, name),
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'),
});
@@ -120,55 +124,75 @@ function KategorieDialog({ open, onClose }: KategorieDialogProps) {
onError: () => showError('Fehler beim Löschen'),
});
const renderKategorie = (k: { id: number; name: string; parent_id?: number | null }, indent: number) => {
const children = childrenOf(k.id);
return (
<Box key={k.id}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', ml: indent * 3 }}>
{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 variant={indent === 0 ? 'body1' : 'body2'} sx={{ flexGrow: 1, fontWeight: indent === 0 ? 600 : 400 }}>
{indent > 0 && '└ '}{k.name}
</Typography>
<Tooltip title="Unterkategorie hinzufügen">
<IconButton size="small" onClick={() => setNewParentId(k.id)}><AddIcon fontSize="small" /></IconButton>
</Tooltip>
<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>
{children.map(c => renderKategorie(c, indent + 1))}
</Box>
);
};
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 }}>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: '20px !important' }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
label="Neue Kategorie"
label={newParentId ? 'Neue Unterkategorie' : '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()); }}
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) createMut.mutate({ name: newName.trim(), parentId: newParentId }); }}
/>
{newParentId && (
<Chip
label={`Unter: ${kategorien.find(k => k.id === newParentId)?.name}`}
size="small"
onDelete={() => setNewParentId(null)}
/>
)}
<Button
variant="contained"
size="small"
onClick={() => { if (newName.trim()) createMut.mutate(newName.trim()); }}
onClick={() => { if (newName.trim()) createMut.mutate({ name: newName.trim(), parentId: newParentId }); }}
disabled={!newName.trim() || createMut.isPending}
>
Erstellen
</Button>
</Box>
<Divider />
{kategorien.length === 0 ? (
{topLevel.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>
))
topLevel.map(k => renderKategorie(k, 0))
)}
</DialogContent>
<DialogActions>
@@ -419,7 +443,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
/>
)}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
{isLoading ? (
<Typography color="text.secondary">Lade Details...</Typography>
) : !detail ? (
@@ -614,7 +638,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
{/* Approve/Reject sub-dialog */}
<Dialog open={actionDialog != null} onClose={() => setActionDialog(null)} maxWidth="sm" fullWidth>
<DialogTitle>{actionDialog?.action === 'genehmigt' ? 'Anfrage genehmigen' : 'Anfrage ablehnen'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
fullWidth
label="Admin Notizen (optional)"
@@ -643,7 +667,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
{/* Link to order sub-dialog */}
<Dialog open={linkDialog} onClose={() => { setLinkDialog(false); setSelectedBestellung(null); }} maxWidth="sm" fullWidth>
<DialogTitle>Mit Bestellung verknüpfen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<Autocomplete
options={bestellungen}
getOptionLabel={(o) => `#${o.id} ${o.bezeichnung}`}
@@ -693,6 +717,19 @@ function KatalogTab() {
queryFn: () => ausruestungsanfrageApi.getKategorien(),
});
// Build display names for hierarchical categories (e.g. "Kleidung > A-Uniform")
const kategorieOptions = useMemo(() => {
const map = new Map(kategorien.map(k => [k.id, k]));
const getDisplayName = (k: { id: number; name: string; parent_id?: number | null }): 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), isChild: !!k.parent_id }));
}, [kategorien]);
const createItemMut = useMutation({
mutationFn: (data: AusruestungArtikelFormData) => ausruestungsanfrageApi.createItem(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel erstellt'); setArtikelDialogOpen(false); },
@@ -733,7 +770,7 @@ function KatalogTab() {
<InputLabel>Kategorie</InputLabel>
<Select value={filterKategorie} label="Kategorie" onChange={e => setFilterKategorie(e.target.value as number | '')}>
<MenuItem value="">Alle</MenuItem>
{kategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
{canManageCategories && (
@@ -772,7 +809,7 @@ function KatalogTab() {
)}
</Box>
</TableCell>
<TableCell>{item.kategorie_name || item.kategorie || '-'}</TableCell>
<TableCell>{kategorieOptions.find(k => k.id === item.kategorie_id)?.name || item.kategorie_name || item.kategorie || '-'}</TableCell>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.beschreibung || '-'}
</TableCell>
@@ -792,7 +829,7 @@ function KatalogTab() {
{/* Artikel create/edit dialog */}
<Dialog open={artikelDialogOpen} onClose={() => setArtikelDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !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 }))} />
<FormControl fullWidth>
@@ -803,7 +840,7 @@ function KatalogTab() {
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>)}
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
{canManage && <EigenschaftenEditor artikelId={editArtikel?.id ?? null} />}
@@ -1010,7 +1047,7 @@ function MeineAnfragenTab() {
{/* Create Request Dialog */}
<Dialog open={createDialogOpen} onClose={() => { setCreateDialogOpen(false); resetCreateForm(); }} maxWidth="sm" fullWidth>
<DialogTitle>Neue Bestellung</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
label="Bezeichnung (optional)"
value={newBezeichnung}

View File

@@ -2,26 +2,27 @@ import { api } from './api';
import type {
AusruestungArtikel,
AusruestungArtikelFormData,
AusruestungAnfrage,
AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem,
AusruestungOverview,
AusruestungKategorie,
AusruestungEigenschaft,
AusruestungAnfrage,
AusruestungWidgetOverview,
} from '../types/ausruestungsanfrage.types';
export const ausruestungsanfrageApi = {
// ── Categories (DB-backed) ──
// ── Categories (DB-backed, hierarchical) ──
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 });
createKategorie: async (name: string, parentId?: number | null): Promise<AusruestungKategorie> => {
const r = await api.post('/api/ausruestungsanfragen/kategorien', { name, parent_id: parentId ?? null });
return r.data.data;
},
updateKategorie: async (id: number, name: string): Promise<AusruestungKategorie> => {
const r = await api.patch(`/api/ausruestungsanfragen/kategorien/${id}`, { name });
updateKategorie: async (id: number, data: { name?: string; parent_id?: number | null }): Promise<AusruestungKategorie> => {
const r = await api.patch(`/api/ausruestungsanfragen/kategorien/${id}`, data);
return r.data.data;
},
deleteKategorie: async (id: number): Promise<void> => {
@@ -126,6 +127,12 @@ export const ausruestungsanfrageApi = {
return r.data.data;
},
// ── Widget overview ──
getWidgetOverview: async (): Promise<AusruestungWidgetOverview> => {
const r = await api.get('/api/ausruestungsanfragen/widget-overview');
return r.data.data;
},
// ── Users ──
getOrderUsers: async (): Promise<Array<{ id: string; name: string }>> => {
const r = await api.get('/api/permissions/users-with', { params: { permission: 'ausruestungsanfrage:create_request' } });

View File

@@ -5,6 +5,7 @@
export interface AusruestungKategorie {
id: number;
name: string;
parent_id?: number | null;
erstellt_am?: string;
}
@@ -134,3 +135,10 @@ export interface AusruestungOverview {
unhandled_count: number;
total_items: number;
}
export interface AusruestungWidgetOverview {
total_count: number;
pending_count: number;
approved_count: number;
unhandled_count: number;
}