refactor: extract shared KatalogTab component, use it in both Bestellungen and Ausruestungsanfrage

This commit is contained in:
Matthias Hochmeister
2026-03-27 15:05:10 +01:00
parent 3f55990212
commit ae3f0c825b
3 changed files with 318 additions and 396 deletions

View 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>
);
}

View File

@@ -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() {

View File

@@ -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>
);