Files
dashboard/frontend/src/pages/Ausruestungsanfrage.tsx
2026-03-24 08:11:32 +01:00

1009 lines
43 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 } from 'react';
import {
Box, Tab, Tabs, Typography, Card, CardContent, CardActions, Grid, Button, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
MenuItem, Select, FormControl, InputLabel, Autocomplete,
Divider,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon,
} 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 { 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 } 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'];
// ─── 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 } = useQuery<AusruestungAnfrageDetailResponse>({
queryKey: ['ausruestungsanfrage', 'request', requestId],
queryFn: () => ausruestungsanfrageApi.getRequest(requestId!),
enabled: requestId != null,
});
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,
})));
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: '16px !important' }}>
{isLoading ? (
<Typography color="text.secondary">Lade Details...</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 => (
<Typography key={p.id} variant="body2">
- {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''}
</Typography>
))}
{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: '16px !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: '16px !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 [filterKategorie, setFilterKategorie] = useState<string>('');
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null);
const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
const { data: items = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'items', filterKategorie],
queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined),
});
const { data: categories = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'categories'],
queryFn: () => ausruestungsanfrageApi.getCategories(),
});
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: a.kategorie });
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' }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Kategorie</InputLabel>
<Select value={filterKategorie} label="Kategorie" onChange={e => setFilterKategorie(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{categories.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
</Select>
</FormControl>
</Box>
{/* Catalog grid */}
{isLoading ? (
<Typography color="text.secondary">Lade Katalog...</Typography>
) : items.length === 0 ? (
<Typography color="text.secondary">Keine Artikel vorhanden.</Typography>
) : (
<Grid container spacing={2}>
{items.map(item => (
<Grid item xs={12} sm={6} md={4} key={item.id}>
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{item.bild_pfad ? (
<Box sx={{ height: 160, backgroundImage: `url(${item.bild_pfad})`, backgroundSize: 'cover', backgroundPosition: 'center', borderBottom: '1px solid', borderColor: 'divider' }} />
) : (
<Box sx={{ height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: '1px solid', borderColor: 'divider' }}>
<ShoppingCart sx={{ fontSize: 48, color: 'text.disabled' }} />
</Box>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>{item.bezeichnung}</Typography>
{item.beschreibung && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{item.beschreibung}</Typography>}
{item.kategorie && (
<Box sx={{ mt: 1 }}>
<Chip label={item.kategorie} size="small" />
</Box>
)}
</CardContent>
{canManage && (
<CardActions sx={{ justifyContent: 'flex-end' }}>
<IconButton size="small" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</CardActions>
)}
</Card>
</Grid>
))}
</Grid>
)}
{/* 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' }}>
<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 }))} />
<Autocomplete
freeSolo
options={categories}
value={artikelForm.kategorie ?? ''}
onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))}
renderInput={params => <TextField {...params} label="Kategorie" />}
/>
</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>
{/* 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 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 }]);
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 }]);
};
const handleCreateSubmit = () => {
const validItems = newItems.filter(i => i.bezeichnung.trim());
if (validItems.length === 0) return;
createMut.mutate({
items: validItems,
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' }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Status</InputLabel>
<Select value={currentFilterValue} label="Status" onChange={e => handleStatusFilterChange(e.target.value)}>
<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>
))}
</Select>
</FormControl>
</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}
/>
{/* 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' }}>
<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">Positionen</Typography>
{newItems.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') {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it));
} else if (v) {
setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
}
}}
onInputChange={(_, val, reason) => {
if (reason === 'input') {
setNewItems(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 => 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>
))}
<Button size="small" startIcon={<AddIcon />} onClick={() => setNewItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
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.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 ─────────────────────────────────────────────────
function AlleAnfragenTab() {
const { hasPermission } = usePermissionContext();
const [statusFilter, setStatusFilter] = useState<string>('');
const [detailId, setDetailId] = useState<number | null>(null);
const canEditAny = hasPermission('ausruestungsanfrage:edit');
const { data: requests = [], isLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'requests', statusFilter],
queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
});
// Summary counts
const openCount = useMemo(() => requests.filter(r => r.status === 'offen').length, [requests]);
const approvedCount = useMemo(() => requests.filter(r => r.status === 'genehmigt').length, [requests]);
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
return (
<Box>
{/* Summary cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{openCount}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{approvedCount}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{requests.length}</Typography>
<Typography variant="body2" color="text.secondary">Alle Anfragen</Typography>
</Paper>
</Grid>
</Grid>
<FormControl size="small" sx={{ minWidth: 200, mb: 2 }}>
<InputLabel>Status Filter</InputLabel>
<Select value={statusFilter} label="Status Filter" onChange={e => setStatusFilter(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => (
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</FormControl>
{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>
)}
{/* Detail Modal with admin actions */}
<DetailModal
requestId={detailId}
onClose={() => setDetailId(null)}
showAdminActions
showEditButton
canEditAny={canEditAny}
/>
</Box>
);
}
// ─── Overview Tab ────────────────────────────────────────────────────────────
function UebersichtTab() {
const { data: overview, isLoading } = useQuery<AusruestungOverview>({
queryKey: ['ausruestungsanfrage', 'overview'],
queryFn: () => ausruestungsanfrageApi.getOverview(),
});
if (isLoading) return <Typography color="text.secondary">Lade Übersicht...</Typography>;
if (!overview) return <Typography color="text.secondary">Keine Daten verfügbar.</Typography>;
return (
<Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.pending_count}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.approved_count}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.total_items}</Typography>
<Typography variant="body2" color="text.secondary">Artikel insgesamt</Typography>
</Paper>
</Grid>
</Grid>
{overview.items.length === 0 ? (
<Typography color="text.secondary">Keine offenen/genehmigten Anfragen vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right">Gesamtmenge</TableCell>
<TableCell align="right">Anfragen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{overview.items.map(item => (
<TableRow key={item.bezeichnung}>
<TableCell>{item.bezeichnung}</TableCell>
<TableCell align="right">{item.total_menge}</TableCell>
<TableCell align="right">{item.anfrage_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</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 canViewAll = hasPermission('ausruestungsanfrage:view_all');
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewAll ? 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++; }
if (canViewAll) { map.uebersicht = next; next++; }
map.katalog = next;
return map;
}, [canCreate, canApprove, canViewAll]);
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" />}
{canViewAll && <Tab label="Übersicht" />}
<Tab label="Katalog" />
</Tabs>
</Box>
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{canViewAll && activeTab === tabIndex.uebersicht && <UebersichtTab />}
{activeTab === tabIndex.katalog && <KatalogTab />}
</DashboardLayout>
);
}