Files
dashboard/frontend/src/pages/Shop.tsx
Matthias Hochmeister 948b211f70 new features
2026-03-23 16:54:09 +01:00

693 lines
32 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 } 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,
Badge, MenuItem, Select, FormControl, InputLabel, Autocomplete, Collapse,
Divider, Tooltip,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon,
ExpandMore, ExpandLess,
} 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 { shopApi } from '../services/shop';
import { bestellungApi } from '../services/bestellung';
import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types';
import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus, ShopAnfrage, ShopOverview } from '../types/shop.types';
import type { Bestellung } from '../types/bestellung.types';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatOrderId(r: ShopAnfrage): string {
if (r.bestell_jahr && r.bestell_nummer) {
return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`;
}
return `#${r.id}`;
}
// ─── Catalog Tab ────────────────────────────────────────────────────────────
interface DraftItem {
artikel_id?: number;
bezeichnung: string;
menge: number;
notizen?: string;
}
function KatalogTab() {
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const queryClient = useQueryClient();
const canManage = hasPermission('shop:manage_catalog');
const canCreate = hasPermission('shop:create_request');
const [filterKategorie, setFilterKategorie] = useState<string>('');
const [draft, setDraft] = useState<DraftItem[]>([]);
const [customText, setCustomText] = useState('');
const [submitOpen, setSubmitOpen] = useState(false);
const [submitNotizen, setSubmitNotizen] = useState('');
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
const [editArtikel, setEditArtikel] = useState<ShopArtikel | null>(null);
const [artikelForm, setArtikelForm] = useState<ShopArtikelFormData>({ bezeichnung: '' });
const { data: items = [], isLoading } = useQuery({
queryKey: ['shop', 'items', filterKategorie],
queryFn: () => shopApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined),
});
const { data: categories = [] } = useQuery({
queryKey: ['shop', 'categories'],
queryFn: () => shopApi.getCategories(),
});
const createItemMut = useMutation({
mutationFn: (data: ShopArtikelFormData) => shopApi.createItem(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel erstellt'); setArtikelDialogOpen(false); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateItemMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ShopArtikelFormData> }) => shopApi.updateItem(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteItemMut = useMutation({
mutationFn: (id: number) => shopApi.deleteItem(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel gelöscht'); },
onError: () => showError('Fehler beim Löschen'),
});
const createRequestMut = useMutation({
mutationFn: ({ items, notizen }: { items: ShopAnfrageFormItem[]; notizen?: string }) => shopApi.createRequest(items, notizen),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shop'] });
showSuccess('Anfrage gesendet');
setDraft([]);
setSubmitOpen(false);
setSubmitNotizen('');
},
onError: () => showError('Fehler beim Senden der Anfrage'),
});
const addToDraft = (item: ShopArtikel) => {
setDraft(prev => {
const existing = prev.find(d => d.artikel_id === item.id);
if (existing) return prev.map(d => d.artikel_id === item.id ? { ...d, menge: d.menge + 1 } : d);
return [...prev, { artikel_id: item.id, bezeichnung: item.bezeichnung, menge: 1 }];
});
};
const addCustomToDraft = () => {
const text = customText.trim();
if (!text) return;
setDraft(prev => [...prev, { bezeichnung: text, menge: 1 }]);
setCustomText('');
};
const removeDraftItem = (idx: number) => setDraft(prev => prev.filter((_, i) => i !== idx));
const updateDraftMenge = (idx: number, menge: number) => {
if (menge < 1) return;
setDraft(prev => prev.map((d, i) => i === idx ? { ...d, menge } : d));
};
const openNewArtikel = () => {
setEditArtikel(null);
setArtikelForm({ bezeichnung: '' });
setArtikelDialogOpen(true);
};
const openEditArtikel = (a: ShopArtikel) => {
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);
};
const handleSubmitRequest = () => {
if (draft.length === 0) return;
createRequestMut.mutate({
items: draft.map(d => ({ artikel_id: d.artikel_id, bezeichnung: d.bezeichnung, menge: d.menge, notizen: d.notizen })),
notizen: submitNotizen || undefined,
});
};
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>
{canCreate && (
<Badge badgeContent={draft.length} color="primary">
<Button startIcon={<ShoppingCart />} variant="outlined" onClick={() => draft.length > 0 && setSubmitOpen(true)} disabled={draft.length === 0}>
Anfrage ({draft.length})
</Button>
</Badge>
)}
</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>
<CardActions sx={{ justifyContent: 'space-between' }}>
{canCreate && (
<Button size="small" startIcon={<AddIcon />} onClick={() => addToDraft(item)}>Hinzufügen</Button>
)}
{canManage && (
<Box>
<IconButton size="small" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
)}
</CardActions>
</Card>
</Grid>
))}
</Grid>
)}
{/* Free-text item + draft summary */}
{canCreate && (
<Paper variant="outlined" sx={{ mt: 3, p: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{draft.length > 0 ? 'Anfrage-Entwurf' : 'Freitext-Position hinzufügen'}
</Typography>
{draft.map((d, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>{d.bezeichnung}</Typography>
<TextField size="small" type="number" value={d.menge} onChange={e => updateDraftMenge(idx, Number(e.target.value))} sx={{ width: 70 }} inputProps={{ min: 1 }} />
<IconButton size="small" onClick={() => removeDraftItem(idx)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
{draft.length > 0 && <Divider sx={{ my: 1 }} />}
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField size="small" placeholder="Eigener Artikel (Freitext)" value={customText} onChange={e => setCustomText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCustomToDraft(); }} sx={{ flexGrow: 1 }} />
<Button size="small" onClick={addCustomToDraft} disabled={!customText.trim()}>Hinzufügen</Button>
</Box>
{draft.length > 0 && (
<Button variant="contained" sx={{ mt: 1.5 }} startIcon={<ShoppingCart />} onClick={() => setSubmitOpen(true)}>Anfrage absenden</Button>
)}
</Paper>
)}
{/* Submit dialog */}
<Dialog open={submitOpen} onClose={() => setSubmitOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Anfrage absenden</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 1 }}>Folgende Positionen werden angefragt:</Typography>
{draft.map((d, idx) => (
<Typography key={idx} variant="body2">- {d.menge}x {d.bezeichnung}</Typography>
))}
<TextField fullWidth label="Notizen (optional)" value={submitNotizen} onChange={e => setSubmitNotizen(e.target.value)} multiline rows={2} sx={{ mt: 2 }} />
</DialogContent>
<DialogActions>
<Button onClick={() => setSubmitOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleSubmitRequest} disabled={createRequestMut.isPending}>Absenden</Button>
</DialogActions>
</Dialog>
{/* 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: 2 }}>
<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 [expandedId, setExpandedId] = useState<number | null>(null);
const { data: requests = [], isLoading } = useQuery({
queryKey: ['shop', 'myRequests'],
queryFn: () => shopApi.getMyRequests(),
});
const { data: detail } = useQuery<ShopAnfrageDetailResponse>({
queryKey: ['shop', 'request', expandedId],
queryFn: () => shopApi.getRequest(expandedId!),
enabled: expandedId != null,
});
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
if (requests.length === 0) return <Typography color="text.secondary">Keine Anfragen vorhanden.</Typography>;
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={40} />
<TableCell>Anfrage</TableCell>
<TableCell>Status</TableCell>
<TableCell>Positionen</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell>Admin Notizen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{requests.map(r => (
<>
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedId(prev => prev === r.id ? null : r.id)}>
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
<TableCell>{formatOrderId(r)}</TableCell>
<TableCell><Chip label={SHOP_STATUS_LABELS[r.status]} color={SHOP_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>
<TableCell>{r.admin_notizen || '-'}</TableCell>
</TableRow>
{expandedId === r.id && (
<TableRow key={`${r.id}-detail`}>
<TableCell colSpan={6} sx={{ py: 0 }}>
<Collapse in timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}>
{r.notizen && <Typography variant="body2" sx={{ mb: 1 }}>Notizen: {r.notizen}</Typography>}
{detail ? (
<>
{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 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">Verknüpfte Bestellungen:</Typography>
{detail.linked_bestellungen.map(b => (
<Chip key={b.id} label={`#${b.id} ${b.bezeichnung}`} size="small" sx={{ ml: 0.5 }} />
))}
</Box>
)}
</>
) : (
<Typography variant="body2" color="text.secondary">Lade Details...</Typography>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</>
))}
</TableBody>
</Table>
</TableContainer>
);
}
// ─── Admin All Requests Tab ─────────────────────────────────────────────────
function AlleAnfragenTab() {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<string>('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [actionDialog, setActionDialog] = useState<{ id: number; action: 'genehmigt' | 'abgelehnt' } | null>(null);
const [adminNotizen, setAdminNotizen] = useState('');
const [linkDialog, setLinkDialog] = useState<number | null>(null);
const [selectedBestellung, setSelectedBestellung] = useState<Bestellung | null>(null);
const { data: requests = [], isLoading } = useQuery({
queryKey: ['shop', 'requests', statusFilter],
queryFn: () => shopApi.getRequests(statusFilter ? { status: statusFilter } : undefined),
});
const { data: detail } = useQuery<ShopAnfrageDetailResponse>({
queryKey: ['shop', 'request', expandedId],
queryFn: () => shopApi.getRequest(expandedId!),
enabled: expandedId != null,
});
const { data: bestellungen = [] } = useQuery({
queryKey: ['bestellungen'],
queryFn: () => bestellungApi.getOrders(),
enabled: linkDialog != null,
});
const statusMut = useMutation({
mutationFn: ({ id, status, notes }: { id: number; status: string; notes?: string }) => shopApi.updateRequestStatus(id, status, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shop'] });
showSuccess('Status aktualisiert');
setActionDialog(null);
setAdminNotizen('');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const linkMut = useMutation({
mutationFn: ({ anfrageId, bestellungId }: { anfrageId: number; bestellungId: number }) => shopApi.linkToOrder(anfrageId, bestellungId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shop'] });
showSuccess('Verknüpfung erstellt');
setLinkDialog(null);
setSelectedBestellung(null);
},
onError: () => showError('Fehler beim Verknüpfen'),
});
const handleAction = () => {
if (!actionDialog) return;
statusMut.mutate({ id: actionDialog.id, status: actionDialog.action, notes: adminNotizen || undefined });
};
if (isLoading) return <Typography color="text.secondary">Lade Anfragen...</Typography>;
return (
<Box>
<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(SHOP_STATUS_LABELS) as ShopAnfrageStatus[]).map(s => (
<MenuItem key={s} value={s}>{SHOP_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 width={40} />
<TableCell>Anfrage</TableCell>
<TableCell>Anfrager</TableCell>
<TableCell>Status</TableCell>
<TableCell>Positionen</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{requests.map(r => (
<>
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedId(prev => prev === r.id ? null : r.id)}>
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
<TableCell>{formatOrderId(r)}</TableCell>
<TableCell>{r.anfrager_name || r.anfrager_id}</TableCell>
<TableCell><Chip label={SHOP_STATUS_LABELS[r.status]} color={SHOP_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>
<TableCell onClick={e => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{r.status === 'offen' && (
<>
<Tooltip title="Genehmigen">
<IconButton size="small" color="success" onClick={() => { setActionDialog({ id: r.id, action: 'genehmigt' }); setAdminNotizen(''); }}>
<CheckIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Ablehnen">
<IconButton size="small" color="error" onClick={() => { setActionDialog({ id: r.id, action: 'abgelehnt' }); setAdminNotizen(''); }}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</>
)}
{r.status === 'genehmigt' && (
<Tooltip title="Mit Bestellung verknüpfen">
<IconButton size="small" color="primary" onClick={() => setLinkDialog(r.id)}>
<LinkIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
{expandedId === r.id && (
<TableRow key={`${r.id}-detail`}>
<TableCell colSpan={7} sx={{ py: 0 }}>
<Collapse in timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}>
{r.notizen && <Typography variant="body2" sx={{ mb: 1 }}>Notizen: {r.notizen}</Typography>}
{r.admin_notizen && <Typography variant="body2" sx={{ mb: 1 }}>Admin Notizen: {r.admin_notizen}</Typography>}
{detail && detail.anfrage.id === r.id ? (
<>
{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 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">Verknüpfte Bestellungen:</Typography>
{detail.linked_bestellungen.map(b => (
<Chip key={b.id} label={`#${b.id} ${b.bezeichnung}`} size="small" sx={{ ml: 0.5 }} />
))}
</Box>
)}
</>
) : (
<Typography variant="body2" color="text.secondary">Lade Details...</Typography>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* Approve/Reject dialog */}
<Dialog open={actionDialog != null} onClose={() => setActionDialog(null)} maxWidth="sm" fullWidth>
<DialogTitle>{actionDialog?.action === 'genehmigt' ? 'Anfrage genehmigen' : 'Anfrage ablehnen'}</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="Admin Notizen (optional)"
value={adminNotizen}
onChange={e => setAdminNotizen(e.target.value)}
multiline
rows={2}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setActionDialog(null)}>Abbrechen</Button>
<Button
variant="contained"
color={actionDialog?.action === 'genehmigt' ? 'success' : 'error'}
onClick={handleAction}
disabled={statusMut.isPending}
>
{actionDialog?.action === 'genehmigt' ? 'Genehmigen' : 'Ablehnen'}
</Button>
</DialogActions>
</Dialog>
{/* Link to order dialog */}
<Dialog open={linkDialog != null} onClose={() => { setLinkDialog(null); setSelectedBestellung(null); }} maxWidth="sm" fullWidth>
<DialogTitle>Mit Bestellung verknüpfen</DialogTitle>
<DialogContent>
<Autocomplete
options={bestellungen}
getOptionLabel={(o) => `#${o.id} ${o.bezeichnung}`}
value={selectedBestellung}
onChange={(_, v) => setSelectedBestellung(v)}
renderInput={params => <TextField {...params} label="Bestellung auswählen" sx={{ mt: 1 }} />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setLinkDialog(null); setSelectedBestellung(null); }}>Abbrechen</Button>
<Button
variant="contained"
disabled={!selectedBestellung || linkMut.isPending}
onClick={() => { if (linkDialog && selectedBestellung) linkMut.mutate({ anfrageId: linkDialog, bestellungId: selectedBestellung.id }); }}
>
Verknüpfen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ─── Overview Tab ────────────────────────────────────────────────────────────
function UebersichtTab() {
const { data: overview, isLoading } = useQuery<ShopOverview>({
queryKey: ['shop', 'overview'],
queryFn: () => shopApi.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 Shop() {
const [searchParams, setSearchParams] = useSearchParams();
const { hasPermission } = usePermissionContext();
const canView = hasPermission('shop:view');
const canCreate = hasPermission('shop:create_request');
const canApprove = hasPermission('shop:approve_requests');
const canViewOverview = hasPermission('shop:view_overview');
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewOverview ? 1 : 0);
const [activeTab, setActiveTab] = useState(() => {
const t = Number(searchParams.get('tab'));
return t >= 0 && t < tabCount ? t : 0;
});
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 (canViewOverview) { map.uebersicht = next; next++; }
map.katalog = next;
return map;
}, [canCreate, canApprove, canViewOverview]);
if (!canView) {
return (
<DashboardLayout>
<Typography>Keine Berechtigung.</Typography>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Typography variant="h5" fontWeight={700} sx={{ mb: 2 }}>Shop</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" />}
{canViewOverview && <Tab label="Übersicht" />}
<Tab label="Katalog" />
</Tabs>
</Box>
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{canViewOverview && activeTab === tabIndex.uebersicht && <UebersichtTab />}
{activeTab === tabIndex.katalog && <KatalogTab />}
</DashboardLayout>
);
}