Files
dashboard/frontend/src/components/shared/KatalogTab.tsx

311 lines
13 KiB
TypeScript

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