rework internal order system
This commit is contained in:
@@ -21,15 +21,15 @@ class AusruestungsanfrageController {
|
||||
|
||||
async createKategorie(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
const { name, parent_id } = req.body;
|
||||
if (!name || name.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
const kategorie = await ausruestungsanfrageService.createKategorie(name.trim());
|
||||
const kategorie = await ausruestungsanfrageService.createKategorie(name.trim(), parent_id ?? null);
|
||||
res.status(201).json({ success: true, data: kategorie });
|
||||
} catch (error: any) {
|
||||
if (error?.constraint === 'ausruestung_kategorien_katalog_name_key') {
|
||||
if (error?.constraint?.includes('unique') || error?.code === '23505') {
|
||||
res.status(409).json({ success: false, message: 'Kategorie existiert bereits' });
|
||||
return;
|
||||
}
|
||||
@@ -41,12 +41,15 @@ class AusruestungsanfrageController {
|
||||
async updateKategorie(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { name } = req.body;
|
||||
if (!name || name.trim().length === 0) {
|
||||
const { name, parent_id } = req.body;
|
||||
if (name !== undefined && (!name || name.trim().length === 0)) {
|
||||
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
const kategorie = await ausruestungsanfrageService.updateKategorie(id, name.trim());
|
||||
const kategorie = await ausruestungsanfrageService.updateKategorie(id, {
|
||||
name: name?.trim(),
|
||||
parent_id: parent_id !== undefined ? parent_id : undefined,
|
||||
});
|
||||
if (!kategorie) {
|
||||
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
|
||||
return;
|
||||
@@ -451,6 +454,20 @@ class AusruestungsanfrageController {
|
||||
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Widget overview (lightweight, for dashboard widget)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getWidgetOverview(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const overview = await ausruestungsanfrageService.getWidgetOverview();
|
||||
res.status(200).json({ success: true, data: overview });
|
||||
} catch (error) {
|
||||
logger.error('AusruestungsanfrageController.getWidgetOverview error', { error });
|
||||
res.status(500).json({ success: false, message: 'Widget-Daten konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AusruestungsanfrageController();
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
-- Migration 048: Catalog categories table + item characteristics
|
||||
-- - Admin-managed categories (replacing free-text kategorie)
|
||||
-- - Admin-managed categories with subcategories (replacing free-text kategorie)
|
||||
-- - Per-item characteristics (options or free-text)
|
||||
-- - Characteristic values per request position
|
||||
-- - Remove view_all permission (approve covers it)
|
||||
-- - Add manage_categories permission
|
||||
|
||||
-- 1. Categories table
|
||||
-- 1. Categories table (with parent_id for subcategories)
|
||||
CREATE TABLE IF NOT EXISTS ausruestung_kategorien_katalog (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
erstellt_am TIMESTAMPTZ DEFAULT NOW()
|
||||
name TEXT NOT NULL,
|
||||
parent_id INT REFERENCES ausruestung_kategorien_katalog(id) ON DELETE CASCADE,
|
||||
erstellt_am TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(name, parent_id)
|
||||
);
|
||||
|
||||
-- Add unique constraint for top-level categories (parent_id IS NULL)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kategorien_top_level_unique
|
||||
ON ausruestung_kategorien_katalog (name) WHERE parent_id IS NULL;
|
||||
|
||||
-- Migrate existing categories from free-text
|
||||
INSERT INTO ausruestung_kategorien_katalog (name)
|
||||
SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL AND kategorie != ''
|
||||
@@ -24,7 +30,7 @@ ALTER TABLE ausruestung_artikel ADD COLUMN IF NOT EXISTS kategorie_id INT REFERE
|
||||
UPDATE ausruestung_artikel a
|
||||
SET kategorie_id = k.id
|
||||
FROM ausruestung_kategorien_katalog k
|
||||
WHERE k.name = a.kategorie AND a.kategorie_id IS NULL;
|
||||
WHERE k.name = a.kategorie AND a.kategorie_id IS NULL AND k.parent_id IS NULL;
|
||||
|
||||
-- 2. Characteristics definitions per catalog item
|
||||
CREATE TABLE IF NOT EXISTS ausruestung_artikel_eigenschaften (
|
||||
|
||||
@@ -37,6 +37,7 @@ router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:v
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController));
|
||||
router.get('/widget-overview', authenticate, requirePermission('ausruestungsanfrage:widget'), ausruestungsanfrageController.getWidgetOverview.bind(ausruestungsanfrageController));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requests
|
||||
|
||||
@@ -2,28 +2,43 @@ import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categories (ausruestung_kategorien_katalog)
|
||||
// Categories (ausruestung_kategorien_katalog) — hierarchical with parent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getKategorien() {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM ausruestung_kategorien_katalog ORDER BY name',
|
||||
'SELECT * FROM ausruestung_kategorien_katalog ORDER BY parent_id NULLS FIRST, name',
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function createKategorie(name: string) {
|
||||
async function createKategorie(name: string, parentId?: number | null) {
|
||||
const result = await pool.query(
|
||||
'INSERT INTO ausruestung_kategorien_katalog (name) VALUES ($1) RETURNING *',
|
||||
[name],
|
||||
'INSERT INTO ausruestung_kategorien_katalog (name, parent_id) VALUES ($1, $2) RETURNING *',
|
||||
[name, parentId ?? null],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function updateKategorie(id: number, name: string) {
|
||||
async function updateKategorie(id: number, data: { name?: string; parent_id?: number | null }) {
|
||||
const fields: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (data.name !== undefined) {
|
||||
params.push(data.name);
|
||||
fields.push(`name = $${params.length}`);
|
||||
}
|
||||
if (data.parent_id !== undefined) {
|
||||
params.push(data.parent_id);
|
||||
fields.push(`parent_id = $${params.length}`);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
params.push(id);
|
||||
const result = await pool.query(
|
||||
'UPDATE ausruestung_kategorien_katalog SET name = $1 WHERE id = $2 RETURNING *',
|
||||
[name, id],
|
||||
`UPDATE ausruestung_kategorien_katalog SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
@@ -279,10 +294,11 @@ async function getRequestById(id: number) {
|
||||
[id],
|
||||
);
|
||||
|
||||
// Load eigenschaft values per position
|
||||
// Load eigenschaft values per position (gracefully handle missing table)
|
||||
const positionIds = positionen.rows.map((p: { id: number }) => p.id);
|
||||
let eigenschaftenMap: Record<number, { eigenschaft_id: number; eigenschaft_name: string; wert: string }[]> = {};
|
||||
if (positionIds.length > 0) {
|
||||
try {
|
||||
const eigenschaftenResult = await pool.query(
|
||||
`SELECT pe.position_id, pe.eigenschaft_id, ae.name AS eigenschaft_name, pe.wert
|
||||
FROM ausruestung_position_eigenschaften pe
|
||||
@@ -299,6 +315,10 @@ async function getRequestById(id: number) {
|
||||
wert: row.wert,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Table may not exist yet if migration hasn't run
|
||||
logger.debug('Position eigenschaften query failed (migration may not have run yet)', { error: err });
|
||||
}
|
||||
}
|
||||
|
||||
const positionenWithEigenschaften = positionen.rows.map((p: { id: number }) => ({
|
||||
@@ -373,12 +393,16 @@ async function createRequest(
|
||||
// Save eigenschaft values
|
||||
if (item.eigenschaften && item.eigenschaften.length > 0) {
|
||||
for (const e of item.eigenschaften) {
|
||||
try {
|
||||
await client.query(
|
||||
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
|
||||
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug('Position eigenschaft insert failed (migration may not have run yet)', { error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,12 +477,16 @@ async function updateRequest(
|
||||
// Save eigenschaft values
|
||||
if (item.eigenschaften && item.eigenschaften.length > 0) {
|
||||
for (const e of item.eigenschaften) {
|
||||
try {
|
||||
await client.query(
|
||||
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
|
||||
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug('Position eigenschaft insert failed', { error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,6 +594,22 @@ async function getOverview() {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget overview (no permission restriction — counts only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getWidgetOverview() {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
COUNT(*)::int AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'offen' AND bearbeitet_von IS NULL)::int AS unhandled_count
|
||||
FROM ausruestung_anfragen`,
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export default {
|
||||
getKategorien,
|
||||
createKategorie,
|
||||
@@ -591,4 +635,5 @@ export default {
|
||||
unlinkFromOrder,
|
||||
getLinkedOrders,
|
||||
getOverview,
|
||||
getWidgetOverview,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
{!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>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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,34 +124,11 @@ 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 (
|
||||
<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' }}>
|
||||
<Box key={k.id}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', ml: indent * 3 }}>
|
||||
{editId === k.id ? (
|
||||
<>
|
||||
<TextField
|
||||
@@ -162,13 +143,56 @@ function KategorieDialog({ open, onClose }: KategorieDialogProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ flexGrow: 1 }}>{k.name}</Typography>
|
||||
<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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user