refactor: extract shared KatalogTab component, use it in both Bestellungen and Ausruestungsanfrage
This commit is contained in:
310
frontend/src/components/shared/KatalogTab.tsx
Normal file
310
frontend/src/components/shared/KatalogTab.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, TextField, IconButton,
|
||||
Chip, MenuItem, Divider, Tooltip, TableSortLabel,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
||||
Check as CheckIcon, Close as CloseIcon, Settings as SettingsIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||
import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage';
|
||||
import ChatAwareFab from './ChatAwareFab';
|
||||
|
||||
// ─── Category Management Dialog ──────────────────────────────────────────────
|
||||
|
||||
interface KategorieDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export 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('');
|
||||
|
||||
const { data: kategorien = [] } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'kategorien'],
|
||||
queryFn: () => ausruestungsanfrageApi.getKategorien(),
|
||||
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, 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 }),
|
||||
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'),
|
||||
});
|
||||
|
||||
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: 1.5, pt: '20px !important' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
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({ 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({ name: newName.trim(), parentId: newParentId }); }}
|
||||
disabled={!newName.trim() || createMut.isPending}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider />
|
||||
{topLevel.length === 0 ? (
|
||||
<Typography color="text.secondary">Keine Kategorien vorhanden.</Typography>
|
||||
) : (
|
||||
topLevel.map(k => renderKategorie(k, 0))
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Schließen</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Catalog Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
export function KatalogTab() {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
|
||||
const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories');
|
||||
|
||||
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
|
||||
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const { data: items = [], isLoading, isFetching } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search],
|
||||
queryFn: () => ausruestungsanfrageApi.getItems({
|
||||
...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}),
|
||||
...(search.trim() ? { search: search.trim() } : {}),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: kategorien = [] } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'kategorien'],
|
||||
queryFn: () => ausruestungsanfrageApi.getKategorien(),
|
||||
});
|
||||
|
||||
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 deleteItemMut = useMutation({
|
||||
mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel gelöscht'); },
|
||||
onError: () => showError('Fehler beim Löschen'),
|
||||
});
|
||||
|
||||
const handleSort = (field: 'bezeichnung' | 'kategorie') => {
|
||||
if (sortField === field) {
|
||||
setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
const sorted = [...items];
|
||||
sorted.sort((a, b) => {
|
||||
let aVal: string, bVal: string;
|
||||
if (sortField === 'bezeichnung') {
|
||||
aVal = a.bezeichnung.toLowerCase();
|
||||
bVal = b.bezeichnung.toLowerCase();
|
||||
} else {
|
||||
aVal = (kategorieOptions.find(k => k.id === a.kategorie_id)?.name || a.kategorie_name || a.kategorie || '').toLowerCase();
|
||||
bVal = (kategorieOptions.find(k => k.id === b.kategorie_id)?.name || b.kategorie_name || b.kategorie || '').toLowerCase();
|
||||
}
|
||||
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}, [items, sortField, sortDir, kategorieOptions]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Suche"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 200 }}
|
||||
placeholder="Artikel suchen..."
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Kategorie"
|
||||
value={filterKategorie}
|
||||
onChange={e => setFilterKategorie(e.target.value as number | '')}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
<MenuItem value="">Alle</MenuItem>
|
||||
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
|
||||
</TextField>
|
||||
{canManageCategories && (
|
||||
<Tooltip title="Kategorien verwalten">
|
||||
<IconButton size="small" onClick={() => setKategorieDialogOpen(true)}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Typography color="text.secondary">Lade Katalog...</Typography>
|
||||
) : items.length === 0 && !isFetching ? (
|
||||
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TableSortLabel active={sortField === 'bezeichnung'} direction={sortField === 'bezeichnung' ? sortDir : 'asc'} onClick={() => handleSort('bezeichnung')}>
|
||||
Bezeichnung
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel active={sortField === 'kategorie'} direction={sortField === 'kategorie' ? sortDir : 'asc'} onClick={() => handleSort('kategorie')}>
|
||||
Kategorie
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
{canManage && <TableCell align="right">Aktionen</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedItems.map(item => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/ausruestungsanfrage/artikel/${item.id}`)}
|
||||
>
|
||||
<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>{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>
|
||||
{canManage && (
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); deleteItemMut.mutate(item.id); }}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} />
|
||||
|
||||
{canManage && (
|
||||
<ChatAwareFab onClick={() => navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Artikel hinzufügen">
|
||||
<AddIcon />
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Box, Tab, Tabs, Typography, Grid, Button, Chip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
|
||||
MenuItem, Divider, Tooltip, TableSortLabel,
|
||||
TextField, IconButton, MenuItem,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
||||
Check as CheckIcon, Close as CloseIcon, Settings as SettingsIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { Add as AddIcon } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||
import { KatalogTab } from '../components/shared/KatalogTab';
|
||||
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
|
||||
import type {
|
||||
AusruestungAnfrageStatus, AusruestungAnfrage,
|
||||
@@ -33,299 +29,6 @@ function formatOrderId(r: AusruestungAnfrage): string {
|
||||
|
||||
const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt'];
|
||||
|
||||
// ─── 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 [newParentId, setNewParentId] = useState<number | null>(null);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
|
||||
const { data: kategorien = [] } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'kategorien'],
|
||||
queryFn: () => ausruestungsanfrageApi.getKategorien(),
|
||||
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, 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 }),
|
||||
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'),
|
||||
});
|
||||
|
||||
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: 1.5, pt: '20px !important' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
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({ 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({ name: newName.trim(), parentId: newParentId }); }}
|
||||
disabled={!newName.trim() || createMut.isPending}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider />
|
||||
{topLevel.length === 0 ? (
|
||||
<Typography color="text.secondary">Keine Kategorien vorhanden.</Typography>
|
||||
) : (
|
||||
topLevel.map(k => renderKategorie(k, 0))
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Schließen</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Catalog Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function KatalogTab() {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
|
||||
const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories');
|
||||
|
||||
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
|
||||
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const { data: items = [], isLoading, isFetching } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search],
|
||||
queryFn: () => ausruestungsanfrageApi.getItems({
|
||||
...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}),
|
||||
...(search.trim() ? { search: search.trim() } : {}),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: kategorien = [] } = useQuery({
|
||||
queryKey: ['ausruestungsanfrage', 'kategorien'],
|
||||
queryFn: () => ausruestungsanfrageApi.getKategorien(),
|
||||
});
|
||||
|
||||
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 deleteItemMut = useMutation({
|
||||
mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel geloescht'); },
|
||||
onError: () => showError('Fehler beim Loeschen'),
|
||||
});
|
||||
|
||||
const handleSort = (field: 'bezeichnung' | 'kategorie') => {
|
||||
if (sortField === field) {
|
||||
setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
const sorted = [...items];
|
||||
sorted.sort((a, b) => {
|
||||
let aVal: string, bVal: string;
|
||||
if (sortField === 'bezeichnung') {
|
||||
aVal = a.bezeichnung.toLowerCase();
|
||||
bVal = b.bezeichnung.toLowerCase();
|
||||
} else {
|
||||
aVal = (kategorieOptions.find(k => k.id === a.kategorie_id)?.name || a.kategorie_name || a.kategorie || '').toLowerCase();
|
||||
bVal = (kategorieOptions.find(k => k.id === b.kategorie_id)?.name || b.kategorie_name || b.kategorie || '').toLowerCase();
|
||||
}
|
||||
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}, [items, sortField, sortDir, kategorieOptions]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Suche"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 200 }}
|
||||
placeholder="Artikel suchen..."
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Kategorie"
|
||||
value={filterKategorie}
|
||||
onChange={e => setFilterKategorie(e.target.value as number | '')}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
<MenuItem value="">Alle</MenuItem>
|
||||
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
|
||||
</TextField>
|
||||
{canManageCategories && (
|
||||
<Tooltip title="Kategorien verwalten">
|
||||
<IconButton size="small" onClick={() => setKategorieDialogOpen(true)}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Typography color="text.secondary">Lade Katalog...</Typography>
|
||||
) : items.length === 0 && !isFetching ? (
|
||||
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TableSortLabel active={sortField === 'bezeichnung'} direction={sortField === 'bezeichnung' ? sortDir : 'asc'} onClick={() => handleSort('bezeichnung')}>
|
||||
Bezeichnung
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel active={sortField === 'kategorie'} direction={sortField === 'kategorie' ? sortDir : 'asc'} onClick={() => handleSort('kategorie')}>
|
||||
Kategorie
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
{canManage && <TableCell align="right">Aktionen</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedItems.map(item => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/ausruestungsanfrage/artikel/${item.id}`)}
|
||||
>
|
||||
<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>{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>
|
||||
{canManage && (
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); deleteItemMut.mutate(item.id); }}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} />
|
||||
|
||||
{canManage && (
|
||||
<ChatAwareFab onClick={() => navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Artikel hinzufuegen">
|
||||
<AddIcon />
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── My Requests Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function MeineAnfragenTab() {
|
||||
|
||||
@@ -26,11 +26,12 @@ import {
|
||||
TextField,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon, Search as SearchIcon } from '@mui/icons-material';
|
||||
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { KatalogTab } from '../components/shared/KatalogTab';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { bestellungApi } from '../services/bestellung';
|
||||
import { configApi } from '../services/config';
|
||||
@@ -87,7 +88,6 @@ export default function Bestellungen() {
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canManageVendors = hasPermission('bestellungen:manage_vendors');
|
||||
const canExport = hasPermission('bestellungen:export');
|
||||
const canManageCatalog = hasPermission('ausruestungsanfrage:manage_catalog');
|
||||
|
||||
// Tab from URL
|
||||
const [tab, setTab] = useState(() => {
|
||||
@@ -116,26 +116,6 @@ export default function Bestellungen() {
|
||||
queryFn: bestellungApi.getVendors,
|
||||
});
|
||||
|
||||
// ── Katalog state ──
|
||||
const [katalogSearch, setKatalogSearch] = useState('');
|
||||
const [katalogKategorie, setKatalogKategorie] = useState('');
|
||||
|
||||
const { data: katalogItems = [], isLoading: katalogLoading } = useQuery({
|
||||
queryKey: ['katalogItems', katalogSearch, katalogKategorie],
|
||||
queryFn: () => bestellungApi.getKatalogItems({
|
||||
search: katalogSearch || undefined,
|
||||
kategorie: katalogKategorie || undefined,
|
||||
}),
|
||||
enabled: tab === 2,
|
||||
});
|
||||
|
||||
const { data: katalogKategorien = [] } = useQuery({
|
||||
queryKey: ['katalogKategorien'],
|
||||
queryFn: bestellungApi.getKatalogKategorien,
|
||||
enabled: tab === 2,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// ── Derive unique filter values from data ──
|
||||
const uniqueVendors = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -485,78 +465,7 @@ export default function Bestellungen() {
|
||||
|
||||
{/* ── Tab 2: Katalog ── */}
|
||||
<TabPanel value={tab} index={canManageVendors ? 2 : 1}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Suche..."
|
||||
value={katalogSearch}
|
||||
onChange={(e) => setKatalogSearch(e.target.value)}
|
||||
InputProps={{ startAdornment: <SearchIcon fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} /> }}
|
||||
sx={{ flex: 1, maxWidth: 400 }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Kategorie"
|
||||
value={katalogKategorie}
|
||||
onChange={(e) => setKatalogKategorie(e.target.value)}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">Alle Kategorien</MenuItem>
|
||||
{katalogKategorien.map((k) => (
|
||||
<MenuItem key={k} value={k}>{k}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Kategorie</TableCell>
|
||||
<TableCell align="right">Geschätzter Preis</TableCell>
|
||||
<TableCell>Bevorzugter Lieferant</TableCell>
|
||||
<TableCell align="right">Eigenschaften</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{katalogLoading ? (
|
||||
<TableRow><TableCell colSpan={5} align="center">Laden...</TableCell></TableRow>
|
||||
) : katalogItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} align="center">Keine Artikel gefunden</TableCell></TableRow>
|
||||
) : (
|
||||
katalogItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/ausruestungsanfrage/artikel/${item.id}`)}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
|
||||
{item.beschreibung && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.beschreibung}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.kategorie_name || item.kategorie || '–'}</TableCell>
|
||||
<TableCell align="right">{item.geschaetzter_preis != null ? formatCurrency(item.geschaetzter_preis) : '–'}</TableCell>
|
||||
<TableCell>{item.bevorzugter_lieferant_name || '–'}</TableCell>
|
||||
<TableCell align="right">{item.eigenschaften_count ?? 0}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{canManageCatalog && (
|
||||
<ChatAwareFab onClick={() => navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Neuer Katalogartikel">
|
||||
<AddIcon />
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
<KatalogTab />
|
||||
</TabPanel>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user