Files
dashboard/frontend/src/pages/Ausruestungsanfrage.tsx
2026-03-24 09:35:37 +01:00

1386 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
Box, Tab, Tabs, Typography, Grid, Button, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
MenuItem, Select, FormControl, InputLabel, Autocomplete,
Divider, Checkbox, FormControlLabel, Tooltip,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, Settings as SettingsIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } 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 { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { bestellungApi } from '../services/bestellung';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type {
AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem,
AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage,
AusruestungOverview, AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types';
import type { Bestellung } from '../types/bestellung.types';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatOrderId(r: AusruestungAnfrage): string {
if (r.bestell_jahr && r.bestell_nummer) {
return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`;
}
return `#${r.id}`;
}
const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt'];
// ─── Eigenschaft Fields Component ────────────────────────────────────────────
interface EigenschaftFieldsProps {
eigenschaften: AusruestungEigenschaft[];
values: Record<number, string>;
onChange: (eigenschaftId: number, wert: string) => void;
}
function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) {
if (eigenschaften.length === 0) return null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ml: 2, mt: 0.5 }}>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{e.typ === 'options' && e.optionen && e.optionen.length > 0 ? (
<TextField
select
size="small"
label={e.name}
value={values[e.id] || ''}
onChange={ev => onChange(e.id, ev.target.value)}
required={e.pflicht}
sx={{ minWidth: 160 }}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</TextField>
) : (
<TextField
size="small"
label={e.name}
value={values[e.id] || ''}
onChange={ev => onChange(e.id, ev.target.value)}
required={e.pflicht}
sx={{ minWidth: 160 }}
/>
)}
</Box>
))}
</Box>
);
}
// ─── 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>
);
}
// ─── Eigenschaften Editor (in Artikel dialog) ────────────────────────────────
interface EigenschaftenEditorProps {
artikelId: number | null;
}
function EigenschaftenEditor({ artikelId }: EigenschaftenEditorProps) {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [newName, setNewName] = useState('');
const [newTyp, setNewTyp] = useState<'options' | 'freitext'>('options');
const [newOptionen, setNewOptionen] = useState('');
const [newPflicht, setNewPflicht] = useState(false);
const { data: eigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId],
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!),
enabled: artikelId != null,
});
const upsertMut = useMutation({
mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) =>
ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gespeichert'); },
onError: () => showError('Fehler beim Speichern'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gelöscht'); },
onError: () => showError('Fehler beim Löschen'),
});
const handleAdd = () => {
if (!newName.trim()) return;
const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined;
upsertMut.mutate({
name: newName.trim(),
typ: newTyp,
optionen,
pflicht: newPflicht,
sort_order: eigenschaften.length,
});
setNewName('');
setNewOptionen('');
setNewPflicht(false);
};
if (artikelId == null) return <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufügen.</Typography>;
return (
<Box sx={{ mt: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften</Typography>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} />
<TextField
select
size="small"
label="Typ"
value={newTyp}
onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}
sx={{ minWidth: 120 }}
>
<MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem>
</TextField>
<FormControlLabel
control={<Checkbox size="small" checked={newPflicht} onChange={e => setNewPflicht(e.target.checked)} />}
label="Pflicht"
/>
</Box>
{newTyp === 'options' && (
<TextField
size="small"
label="Optionen (kommagetrennt)"
value={newOptionen}
onChange={e => setNewOptionen(e.target.value)}
placeholder="S, M, L, XL"
fullWidth
/>
)}
<Button
size="small"
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={!newName.trim() || upsertMut.isPending}
>
Eigenschaft hinzufügen
</Button>
</Box>
</Box>
);
}
// ─── Detail Modal ─────────────────────────────────────────────────────────────
interface DetailModalProps {
requestId: number | null;
onClose: () => void;
showAdminActions?: boolean;
showEditButton?: boolean;
canEditAny?: boolean;
currentUserId?: string;
}
function DetailModal({ requestId, onClose, showAdminActions, showEditButton, canEditAny, currentUserId }: DetailModalProps) {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const [editing, setEditing] = useState(false);
const [editBezeichnung, setEditBezeichnung] = useState('');
const [editNotizen, setEditNotizen] = useState('');
const [editItems, setEditItems] = useState<AusruestungAnfrageFormItem[]>([]);
// Admin action state
const [actionDialog, setActionDialog] = useState<{ action: 'genehmigt' | 'abgelehnt' } | null>(null);
const [adminNotizen, setAdminNotizen] = useState('');
const [statusChangeValue, setStatusChangeValue] = useState('');
const [linkDialog, setLinkDialog] = useState(false);
const [selectedBestellung, setSelectedBestellung] = useState<Bestellung | null>(null);
const { data: detail, isLoading, isError } = useQuery<AusruestungAnfrageDetailResponse>({
queryKey: ['ausruestungsanfrage', 'request', requestId],
queryFn: () => ausruestungsanfrageApi.getRequest(requestId!),
enabled: requestId != null,
retry: 1,
});
const { data: catalogItems = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'items-for-edit'],
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
enabled: editing,
});
const { data: bestellungen = [] } = useQuery({
queryKey: ['bestellungen'],
queryFn: () => bestellungApi.getOrders(),
enabled: linkDialog,
});
const updateMut = useMutation({
mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) =>
ausruestungsanfrageApi.updateRequest(requestId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Anfrage aktualisiert');
setEditing(false);
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const statusMut = useMutation({
mutationFn: ({ status, notes }: { status: string; notes?: string }) =>
ausruestungsanfrageApi.updateRequestStatus(requestId!, status, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Status aktualisiert');
setActionDialog(null);
setAdminNotizen('');
setStatusChangeValue('');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const linkMut = useMutation({
mutationFn: (bestellungId: number) => ausruestungsanfrageApi.linkToOrder(requestId!, bestellungId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Verknüpfung erstellt');
setLinkDialog(false);
setSelectedBestellung(null);
},
onError: () => showError('Fehler beim Verknüpfen'),
});
const startEditing = () => {
if (!detail) return;
setEditBezeichnung(detail.anfrage.bezeichnung || '');
setEditNotizen(detail.anfrage.notizen || '');
setEditItems(detail.positionen.map(p => ({
artikel_id: p.artikel_id,
bezeichnung: p.bezeichnung,
menge: p.menge,
notizen: p.notizen,
eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })),
})));
setEditing(true);
};
const handleSaveEdit = () => {
if (editItems.length === 0) return;
updateMut.mutate({
bezeichnung: editBezeichnung || undefined,
notizen: editNotizen || undefined,
items: editItems,
});
};
const addEditItem = () => {
setEditItems(prev => [...prev, { bezeichnung: '', menge: 1 }]);
};
const removeEditItem = (idx: number) => {
setEditItems(prev => prev.filter((_, i) => i !== idx));
};
const updateEditItem = (idx: number, field: string, value: unknown) => {
setEditItems(prev => prev.map((item, i) => i === idx ? { ...item, [field]: value } : item));
};
if (!requestId) return null;
const anfrage = detail?.anfrage;
const canEdit = anfrage && (
canEditAny ||
(anfrage.anfrager_id === currentUserId && anfrage.status === 'offen')
);
return (
<>
<Dialog open={requestId != null} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
Anfrage {anfrage ? formatOrderId(anfrage) : '...'}
{anfrage?.bezeichnung && `${anfrage.bezeichnung}`}
</span>
{anfrage && (
<Chip
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
color={AUSRUESTUNG_STATUS_COLORS[anfrage.status]}
size="small"
/>
)}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
{isLoading ? (
<Typography color="text.secondary">Lade Details...</Typography>
) : isError ? (
<Typography color="error">Fehler beim Laden der Anfrage.</Typography>
) : !detail ? (
<Typography color="text.secondary">Anfrage nicht gefunden.</Typography>
) : editing ? (
/* ── Edit Mode ── */
<>
<TextField
label="Bezeichnung (optional)"
value={editBezeichnung}
onChange={e => setEditBezeichnung(e.target.value)}
fullWidth
/>
<TextField
label="Notizen (optional)"
value={editNotizen}
onChange={e => setEditNotizen(e.target.value)}
multiline
rows={2}
fullWidth
/>
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
{editItems.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Autocomplete
freeSolo
options={catalogItems}
getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung}
value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || item.bezeichnung : item.bezeichnung}
onChange={(_, v) => {
if (typeof v === 'string') {
updateEditItem(idx, 'bezeichnung', v);
updateEditItem(idx, 'artikel_id', undefined);
} else if (v) {
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
}
}}
onInputChange={(_, val, reason) => {
if (reason === 'input') {
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it));
}
}}
renderInput={params => <TextField {...params} label="Artikel" size="small" />}
sx={{ flexGrow: 1 }}
/>
<TextField
size="small"
type="number"
label="Menge"
value={item.menge}
onChange={e => updateEditItem(idx, 'menge', Math.max(1, Number(e.target.value)))}
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<IconButton size="small" onClick={() => removeEditItem(idx)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
<Button size="small" startIcon={<AddIcon />} onClick={addEditItem}>
Position hinzufügen
</Button>
</>
) : (
/* ── View Mode ── */
<>
{anfrage!.anfrager_name && (
<Typography variant="body2" color="text.secondary">
Anfrager: {anfrage!.anfrager_name}
</Typography>
)}
{anfrage!.notizen && (
<Typography variant="body2">Notizen: {anfrage!.notizen}</Typography>
)}
{anfrage!.admin_notizen && (
<Typography variant="body2">Admin Notizen: {anfrage!.admin_notizen}</Typography>
)}
<Typography variant="body2" color="text.secondary">
Erstellt am: {new Date(anfrage!.erstellt_am).toLocaleDateString('de-AT')}
</Typography>
<Divider />
<Typography variant="subtitle2">Positionen</Typography>
{detail.positionen.map(p => (
<Box key={p.id}>
<Typography variant="body2">
- {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''}
</Typography>
{p.eigenschaften && p.eigenschaften.length > 0 && (
<Box sx={{ ml: 2, mt: 0.25 }}>
{p.eigenschaften.map(e => (
<Typography key={e.eigenschaft_id} variant="caption" color="text.secondary" display="block">
{e.eigenschaft_name}: {e.wert}
</Typography>
))}
</Box>
)}
</Box>
))}
{detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && (
<>
<Divider />
<Typography variant="subtitle2">Verknüpfte Bestellungen</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{detail.linked_bestellungen.map(b => (
<Chip key={b.id} label={`#${b.id} ${b.bezeichnung}`} size="small" />
))}
</Box>
</>
)}
</>
)}
</DialogContent>
<DialogActions>
{editing ? (
<>
<Button onClick={() => setEditing(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSaveEdit}
disabled={updateMut.isPending || editItems.length === 0 || editItems.some(i => !i.bezeichnung.trim())}
>
Speichern
</Button>
</>
) : (
<>
{/* Admin actions */}
{showAdminActions && anfrage && anfrage.status === 'offen' && (
<>
<Button
color="success"
variant="outlined"
startIcon={<CheckIcon />}
onClick={() => { setActionDialog({ action: 'genehmigt' }); setAdminNotizen(''); }}
>
Genehmigen
</Button>
<Button
color="error"
variant="outlined"
startIcon={<CloseIcon />}
onClick={() => { setActionDialog({ action: 'abgelehnt' }); setAdminNotizen(''); }}
>
Ablehnen
</Button>
</>
)}
{showAdminActions && anfrage && hasPermission('ausruestungsanfrage:approve') && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Status ändern</InputLabel>
<Select
value={statusChangeValue}
label="Status ändern"
onChange={e => {
const val = e.target.value;
setStatusChangeValue(val);
if (val) statusMut.mutate({ status: val });
}}
>
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[])
.filter(s => s !== anfrage.status)
.map(s => (
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</FormControl>
</Box>
)}
{showAdminActions && anfrage && anfrage.status === 'genehmigt' && hasPermission('ausruestungsanfrage:link_orders') && (
<Button
variant="outlined"
startIcon={<LinkIcon />}
onClick={() => setLinkDialog(true)}
>
Verknüpfen
</Button>
)}
{(showEditButton || canEditAny) && canEdit && !editing && (
<Button startIcon={<EditIcon />} onClick={startEditing}>
Bearbeiten
</Button>
)}
<Button onClick={onClose}>Schließen</Button>
</>
)}
</DialogActions>
</Dialog>
{/* 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: '20px !important' }}>
<TextField
fullWidth
label="Admin Notizen (optional)"
value={adminNotizen}
onChange={e => setAdminNotizen(e.target.value)}
multiline
rows={2}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setActionDialog(null)}>Abbrechen</Button>
<Button
variant="contained"
color={actionDialog?.action === 'genehmigt' ? 'success' : 'error'}
onClick={() => {
if (!actionDialog) return;
statusMut.mutate({ status: actionDialog.action, notes: adminNotizen || undefined });
}}
disabled={statusMut.isPending}
>
{actionDialog?.action === 'genehmigt' ? 'Genehmigen' : 'Ablehnen'}
</Button>
</DialogActions>
</Dialog>
{/* 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: '20px !important' }}>
<Autocomplete
options={bestellungen}
getOptionLabel={(o) => `#${o.id} ${o.bezeichnung}`}
value={selectedBestellung}
onChange={(_, v) => setSelectedBestellung(v)}
renderInput={params => <TextField {...params} label="Bestellung auswählen" />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setLinkDialog(false); setSelectedBestellung(null); }}>Abbrechen</Button>
<Button
variant="contained"
disabled={!selectedBestellung || linkMut.isPending}
onClick={() => { if (selectedBestellung) linkMut.mutate(selectedBestellung.id); }}
>
Verknüpfen
</Button>
</DialogActions>
</Dialog>
</>
);
}
// ─── Catalog Tab ────────────────────────────────────────────────────────────
function KatalogTab() {
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const queryClient = useQueryClient();
const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories');
const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null);
const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
const { data: items = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'items', filterKategorie],
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie_id: filterKategorie as number } : undefined),
});
const { data: kategorien = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'kategorien'],
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); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateItemMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<AusruestungArtikelFormData> }) => ausruestungsanfrageApi.updateItem(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); },
onError: () => showError('Fehler beim Aktualisieren'),
});
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 openNewArtikel = () => {
setEditArtikel(null);
setArtikelForm({ bezeichnung: '' });
setArtikelDialogOpen(true);
};
const openEditArtikel = (a: AusruestungArtikel) => {
setEditArtikel(a);
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie_id: a.kategorie_id ?? null });
setArtikelDialogOpen(true);
};
const saveArtikel = () => {
if (!artikelForm.bezeichnung.trim()) return;
if (editArtikel) updateItemMut.mutate({ id: editArtikel.id, data: artikelForm });
else createItemMut.mutate(artikelForm);
};
return (
<Box>
{/* Filter */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<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>
{/* Catalog table */}
{isLoading ? (
<Typography color="text.secondary">Lade Katalog...</Typography>
) : items.length === 0 ? (
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Kategorie</TableCell>
<TableCell>Beschreibung</TableCell>
{canManage && <TableCell align="right">Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{items.map(item => (
<TableRow key={item.id} hover>
<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" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* 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: '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 }))} />
<TextField
select
label="Kategorie"
value={artikelForm.kategorie_id ?? ''}
onChange={e => setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : null }))}
fullWidth
>
<MenuItem value="">Keine</MenuItem>
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</TextField>
{canManage && <EigenschaftenEditor artikelId={editArtikel?.id ?? null} />}
</DialogContent>
<DialogActions>
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={saveArtikel} disabled={!artikelForm.bezeichnung.trim() || createItemMut.isPending || updateItemMut.isPending}>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Kategorie management dialog */}
<KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} />
{/* FAB for new catalog item */}
{canManage && (
<ChatAwareFab onClick={openNewArtikel} aria-label="Artikel hinzufügen">
<AddIcon />
</ChatAwareFab>
)}
</Box>
);
}
// ─── My Requests Tab ────────────────────────────────────────────────────────
function MeineAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const canCreate = hasPermission('ausruestungsanfrage:create_request');
const canOrderForUser = hasPermission('ausruestungsanfrage:order_for_user');
const canEditAny = hasPermission('ausruestungsanfrage:edit');
const [detailId, setDetailId] = useState<number | null>(null);
const [statusFilter, setStatusFilter] = useState<AusruestungAnfrageStatus[]>(ACTIVE_STATUSES);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newBezeichnung, setNewBezeichnung] = useState('');
const [newNotizen, setNewNotizen] = useState('');
const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null);
const [newItems, setNewItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
// Track loaded eigenschaften per item row (by artikel_id)
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
const itemEigenschaftenRef = useRef(itemEigenschaften);
itemEigenschaftenRef.current = itemEigenschaften;
// Track eigenschaft values per item row index
const [itemEigenschaftValues, setItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
// Separate free-text items
const [newFreeItems, setNewFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
const { data: requests = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'myRequests'],
queryFn: () => ausruestungsanfrageApi.getMyRequests(),
});
const { data: catalogItems = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'items-for-create'],
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
enabled: createDialogOpen,
});
const { data: orderUsers = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'orderUsers'],
queryFn: () => ausruestungsanfrageApi.getOrderUsers(),
enabled: createDialogOpen && canOrderForUser,
});
const createMut = useMutation({
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string }) =>
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Anfrage erstellt');
setCreateDialogOpen(false);
resetCreateForm();
},
onError: () => showError('Fehler beim Erstellen der Anfrage'),
});
const resetCreateForm = () => {
setNewBezeichnung('');
setNewNotizen('');
setNewFuerBenutzer(null);
setNewItems([{ bezeichnung: '', menge: 1 }]);
setNewFreeItems([]);
setItemEigenschaften({});
setItemEigenschaftValues({});
};
const loadEigenschaften = useCallback(async (artikelId: number) => {
if (itemEigenschaftenRef.current[artikelId]) return;
try {
const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId);
if (eigs && eigs.length > 0) {
setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs }));
}
} catch (err) {
console.warn('Failed to load eigenschaften for artikel', artikelId, err);
}
}, []);
const handleCreateSubmit = () => {
// Catalog items with eigenschaften
const catalogValidItems = newItems.filter(i => i.bezeichnung.trim() && i.artikel_id).map((item, idx) => {
const vals = itemEigenschaftValues[idx] || {};
const eigenschaften = Object.entries(vals)
.filter(([, wert]) => wert.trim())
.map(([eid, wert]) => ({ eigenschaft_id: Number(eid), wert }));
return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined };
});
// Free-text items
const freeValidItems = newFreeItems
.filter(i => i.bezeichnung.trim())
.map(i => ({ bezeichnung: i.bezeichnung, menge: i.menge }));
const allItems = [...catalogValidItems, ...freeValidItems];
if (allItems.length === 0) return;
// Check required eigenschaften for catalog items
for (let idx = 0; idx < newItems.length; idx++) {
const item = newItems[idx];
if (!item.bezeichnung.trim() || !item.artikel_id) continue;
if (itemEigenschaften[item.artikel_id]) {
for (const e of itemEigenschaften[item.artikel_id]) {
if (e.pflicht && !(itemEigenschaftValues[idx]?.[e.id]?.trim())) {
showError(`Pflichtfeld "${e.name}" für "${item.bezeichnung}" fehlt`);
return;
}
}
}
}
createMut.mutate({
items: allItems,
notizen: newNotizen || undefined,
bezeichnung: newBezeichnung || undefined,
fuer_benutzer_id: newFuerBenutzer?.id,
});
};
const filteredRequests = useMemo(() => {
if (statusFilter.length === 0) return requests;
return requests.filter(r => statusFilter.includes(r.status));
}, [requests, statusFilter]);
const handleStatusFilterChange = (value: string) => {
if (value === 'all') {
setStatusFilter([]);
} else if (value === 'active') {
setStatusFilter(ACTIVE_STATUSES);
} else {
setStatusFilter([value as AusruestungAnfrageStatus]);
}
};
const currentFilterValue = useMemo(() => {
if (statusFilter.length === 0) return 'all';
if (statusFilter.length === ACTIVE_STATUSES.length && ACTIVE_STATUSES.every(s => statusFilter.includes(s))) return 'active';
return statusFilter[0] || 'all';
}, [statusFilter]);
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
return (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<TextField
select
size="small"
label="Status"
value={currentFilterValue}
onChange={e => handleStatusFilterChange(e.target.value)}
sx={{ minWidth: 200 }}
>
<MenuItem value="active">Aktive Anfragen</MenuItem>
<MenuItem value="all">Alle</MenuItem>
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => (
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
))}
</TextField>
</Box>
{filteredRequests.length === 0 ? (
<Typography color="text.secondary" sx={{ mb: 2 }}>Keine Anfragen vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Anfrage</TableCell>
<TableCell>Bezeichnung</TableCell>
<TableCell>Status</TableCell>
<TableCell>Positionen</TableCell>
<TableCell>Erstellt am</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredRequests.map(r => (
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setDetailId(r.id)}>
<TableCell>{formatOrderId(r)}</TableCell>
<TableCell>{r.bezeichnung || '-'}</TableCell>
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* Detail Modal */}
<DetailModal
requestId={detailId}
onClose={() => setDetailId(null)}
showEditButton
canEditAny={canEditAny}
currentUserId={user?.id}
/>
{/* 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: '20px !important' }}>
<TextField
label="Bezeichnung (optional)"
value={newBezeichnung}
onChange={e => setNewBezeichnung(e.target.value)}
fullWidth
/>
{canOrderForUser && (
<Autocomplete
options={orderUsers}
getOptionLabel={o => o.name}
value={newFuerBenutzer}
onChange={(_, v) => setNewFuerBenutzer(v)}
renderInput={params => <TextField {...params} label="Für wen (optional)" />}
/>
)}
<TextField
label="Notizen (optional)"
value={newNotizen}
onChange={e => setNewNotizen(e.target.value)}
multiline
rows={2}
fullWidth
/>
<Divider />
<Typography variant="subtitle2">Aus Katalog</Typography>
{newItems.map((item, idx) => (
<Box key={`cat-${idx}`}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Autocomplete
options={catalogItems}
getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung}
value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || null : null}
onChange={(_, v) => {
if (v && typeof v !== 'string') {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
loadEigenschaften(v.id);
} else {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: undefined, bezeichnung: '' } : it));
setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; });
}
}}
renderInput={params => <TextField {...params} label="Katalogartikel" size="small" />}
sx={{ flexGrow: 1 }}
/>
<TextField
size="small"
type="number"
label="Menge"
value={item.menge}
onChange={e => setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))}
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<IconButton size="small" onClick={() => setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{/* Eigenschaft fields for this item */}
{item.artikel_id && itemEigenschaften[item.artikel_id] && itemEigenschaften[item.artikel_id].length > 0 && (
<EigenschaftFields
eigenschaften={itemEigenschaften[item.artikel_id]}
values={itemEigenschaftValues[idx] || {}}
onChange={(eid, wert) => setItemEigenschaftValues(prev => ({
...prev,
[idx]: { ...(prev[idx] || {}), [eid]: wert },
}))}
/>
)}
</Box>
))}
<Button size="small" startIcon={<AddIcon />} onClick={() => setNewItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
Katalogartikel hinzufügen
</Button>
<Divider />
<Typography variant="subtitle2">Freitext-Positionen</Typography>
{newFreeItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Freitext-Positionen.</Typography>
) : (
newFreeItems.map((item, idx) => (
<Box key={`free-${idx}`} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
label="Bezeichnung"
value={item.bezeichnung}
onChange={e => setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: e.target.value } : it))}
sx={{ flexGrow: 1 }}
/>
<TextField
size="small"
type="number"
label="Menge"
value={item.menge}
onChange={e => setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))}
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<IconButton size="small" onClick={() => setNewFreeItems(prev => prev.filter((_, i) => i !== idx))}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))
)}
<Button size="small" startIcon={<AddIcon />} onClick={() => setNewFreeItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
Freitext-Position hinzufügen
</Button>
</DialogContent>
<DialogActions>
<Button onClick={() => { setCreateDialogOpen(false); resetCreateForm(); }}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleCreateSubmit}
disabled={createMut.isPending || (newItems.every(i => !i.artikel_id) && newFreeItems.every(i => !i.bezeichnung.trim()))}
>
Anfrage erstellen
</Button>
</DialogActions>
</Dialog>
{/* FAB for creating new request */}
{canCreate && (
<ChatAwareFab onClick={() => setCreateDialogOpen(true)} aria-label="Neue Anfrage erstellen">
<AddIcon />
</ChatAwareFab>
)}
</Box>
);
}
// ─── Admin All Requests Tab (merged with overview) ──────────────────────────
function AlleAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const [statusFilter, setStatusFilter] = useState<string>('');
const [detailId, setDetailId] = useState<number | null>(null);
const canEditAny = hasPermission('ausruestungsanfrage:edit');
const { data: requests = [], isLoading: requestsLoading, isError: requestsError } = useQuery({
queryKey: ['ausruestungsanfrage', 'allRequests', statusFilter],
queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
});
const { data: overview } = useQuery<AusruestungOverview>({
queryKey: ['ausruestungsanfrage', 'overview-cards'],
queryFn: () => ausruestungsanfrageApi.getOverview(),
});
return (
<Box>
{/* Summary cards — always visible */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview?.pending_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Offene</Typography>
</Paper>
</Grid>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview?.approved_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte</Typography>
</Paper>
</Grid>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700} color="warning.main">{overview?.unhandled_count ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Neue (unbearbeitet)</Typography>
</Paper>
</Grid>
<Grid item xs={6} sm={3}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview?.total_items ?? '-'}</Typography>
<Typography variant="body2" color="text.secondary">Gesamt Artikel</Typography>
</Paper>
</Grid>
</Grid>
<TextField
select
size="small"
label="Status Filter"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
sx={{ minWidth: 200, mb: 2 }}
>
<MenuItem value="">Alle</MenuItem>
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => (
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
))}
</TextField>
{requestsLoading ? (
<Typography color="text.secondary">Lade Anfragen...</Typography>
) : requestsError ? (
<Typography color="error">Fehler beim Laden der Anfragen.</Typography>
) : requests.length === 0 ? (
<Typography color="text.secondary">Keine Anfragen vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Anfrage</TableCell>
<TableCell>Bezeichnung</TableCell>
<TableCell>Anfrager</TableCell>
<TableCell>Status</TableCell>
<TableCell>Positionen</TableCell>
<TableCell>Erstellt am</TableCell>
</TableRow>
</TableHead>
<TableBody>
{requests.map(r => (
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setDetailId(r.id)}>
<TableCell>{formatOrderId(r)}</TableCell>
<TableCell>{r.bezeichnung || '-'}</TableCell>
<TableCell>{r.anfrager_name || r.anfrager_id}</TableCell>
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<DetailModal
requestId={detailId}
onClose={() => setDetailId(null)}
showAdminActions
showEditButton
canEditAny={canEditAny}
currentUserId={user?.id}
/>
</Box>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function Ausruestungsanfrage() {
const [searchParams, setSearchParams] = useSearchParams();
const { hasPermission } = usePermissionContext();
const canView = hasPermission('ausruestungsanfrage:view');
const canCreate = hasPermission('ausruestungsanfrage:create_request');
const canApprove = hasPermission('ausruestungsanfrage:approve');
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0);
const [activeTab, setActiveTab] = useState(() => {
const t = Number(searchParams.get('tab'));
return t >= 0 && t < tabCount ? t : 0;
});
// Sync tab from URL changes (e.g. sidebar navigation)
useEffect(() => {
const t = Number(searchParams.get('tab'));
if (t >= 0 && t < tabCount) setActiveTab(t);
}, [searchParams, tabCount]);
const handleTabChange = (_: React.SyntheticEvent, val: number) => {
setActiveTab(val);
setSearchParams({ tab: String(val) }, { replace: true });
};
const tabIndex = useMemo(() => {
const map: Record<string, number> = {};
let next = 0;
if (canCreate) { map.meine = next; next++; }
if (canApprove) { map.alle = next; next++; }
map.katalog = next;
return map;
}, [canCreate, canApprove]);
if (!canView) {
return (
<DashboardLayout>
<Typography>Keine Berechtigung.</Typography>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Typography variant="h5" fontWeight={700} sx={{ mb: 2 }}>Interne Bestellungen</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
{canCreate && <Tab label="Meine Anfragen" />}
{canApprove && <Tab label="Alle Anfragen" />}
<Tab label="Katalog" />
</Tabs>
</Box>
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{activeTab === tabIndex.katalog && <KatalogTab />}
</DashboardLayout>
);
}