rework from modal to page

This commit is contained in:
Matthias Hochmeister
2026-03-25 09:37:16 +01:00
parent 4ed76fe20d
commit 4ad260ce66
9 changed files with 1714 additions and 1389 deletions

View File

@@ -28,8 +28,13 @@ import VeranstaltungKategorien from './pages/VeranstaltungKategorien';
import Wissen from './pages/Wissen';
import Bestellungen from './pages/Bestellungen';
import BestellungDetail from './pages/BestellungDetail';
import BestellungNeu from './pages/BestellungNeu';
import Ausruestungsanfrage from './pages/Ausruestungsanfrage';
import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail';
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Issues from './pages/Issues';
import IssueDetail from './pages/IssueDetail';
import IssueNeu from './pages/IssueNeu';
import AdminDashboard from './pages/AdminDashboard';
import AdminSettings from './pages/AdminSettings';
import NotFound from './pages/NotFound';
@@ -228,6 +233,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/bestellungen/neu"
element={
<ProtectedRoute>
<BestellungNeu />
</ProtectedRoute>
}
/>
<Route
path="/bestellungen/:id"
element={
@@ -244,6 +257,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/ausruestungsanfrage/neu"
element={
<ProtectedRoute>
<AusruestungsanfrageNeu />
</ProtectedRoute>
}
/>
<Route
path="/ausruestungsanfrage/:id"
element={
<ProtectedRoute>
<AusruestungsanfrageDetail />
</ProtectedRoute>
}
/>
<Route
path="/issues"
element={
@@ -252,6 +281,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/issues/neu"
element={
<ProtectedRoute>
<IssueNeu />
</ProtectedRoute>
}
/>
<Route
path="/issues/:id"
element={
<ProtectedRoute>
<IssueDetail />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={

View File

@@ -1,31 +1,27 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useState, useMemo, useEffect, useCallback } 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,
MenuItem, 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,
Check as CheckIcon, Close as CloseIcon, Settings as SettingsIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } 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,
AusruestungArtikel, AusruestungArtikelFormData,
AusruestungAnfrageStatus, AusruestungAnfrage,
AusruestungOverview,
} from '../types/ausruestungsanfrage.types';
import type { Bestellung } from '../types/bestellung.types';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -38,51 +34,6 @@ function formatOrderId(r: AusruestungAnfrage): string {
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: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
{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 {
@@ -308,456 +259,6 @@ function EigenschaftenEditor({ artikelId }: EigenschaftenEditorProps) {
);
}
// ─── 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 geliefertMut = useMutation({
mutationFn: ({ positionId, geliefert }: { positionId: number; geliefert: boolean }) =>
ausruestungsanfrageApi.updatePositionGeliefert(positionId, geliefert),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
},
onError: () => showError('Fehler beim Aktualisieren'),
});
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 ── */
<>
{/* Meta info */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{(anfrage!.anfrager_name || anfrage!.fuer_benutzer_name) && (
<Box>
<Typography variant="caption" color="text.secondary">Anfrage für</Typography>
<Typography variant="body2" fontWeight={500}>
{anfrage!.fuer_benutzer_name
? `${anfrage!.fuer_benutzer_name} (erstellt von ${anfrage!.anfrager_name || 'Unbekannt'})`
: anfrage!.anfrager_name}
</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography variant="body2" fontWeight={500}>{new Date(anfrage!.erstellt_am).toLocaleDateString('de-AT')}</Typography>
</Box>
{anfrage!.bearbeitet_von_name && (
<Box>
<Typography variant="caption" color="text.secondary">Bearbeitet von</Typography>
<Typography variant="body2" fontWeight={500}>{anfrage!.bearbeitet_von_name}</Typography>
</Box>
)}
</Box>
{anfrage!.notizen && (
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover' }}>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{anfrage!.notizen}</Typography>
</Paper>
)}
{anfrage!.admin_notizen && (
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'warning.main', color: 'warning.contrastText', borderColor: 'warning.dark' }}>
<Typography variant="caption" display="block" sx={{ mb: 0.5, opacity: 0.8 }}>Admin Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{anfrage!.admin_notizen}</Typography>
</Paper>
)}
<Divider />
{/* Positionen */}
<Typography variant="subtitle2">Positionen ({detail.positionen.length})</Typography>
<Table size="small">
<TableHead>
<TableRow>
{showAdminActions && <TableCell padding="checkbox">Geliefert</TableCell>}
<TableCell>Artikel</TableCell>
<TableCell align="right">Menge</TableCell>
<TableCell>Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
{detail.positionen.map(p => (
<TableRow key={p.id} sx={p.geliefert ? { opacity: 0.5 } : undefined}>
{showAdminActions && (
<TableCell padding="checkbox">
<Checkbox
size="small"
checked={p.geliefert}
disabled={geliefertMut.isPending}
onChange={(_, checked) => geliefertMut.mutate({ positionId: p.id, geliefert: checked })}
/>
</TableCell>
)}
<TableCell>
<Typography variant="body2" fontWeight={500} sx={p.geliefert ? { textDecoration: 'line-through' } : undefined}>{p.bezeichnung}</Typography>
{p.eigenschaften && p.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{p.eigenschaften.map(e => (
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box>
)}
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight={600}>{p.menge}x</Typography>
</TableCell>
<TableCell>
{p.notizen && <Typography variant="caption" color="text.secondary">{p.notizen}</Typography>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{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() {
@@ -784,11 +285,9 @@ function KatalogTab() {
queryFn: () => ausruestungsanfrageApi.getKategorien(),
});
// Split categories into top-level and children
const topKategorien = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]);
const subKategorienOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]);
// 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 => {
@@ -801,7 +300,6 @@ function KatalogTab() {
return kategorien.map(k => ({ id: k.id, name: getDisplayName(k), isChild: !!k.parent_id }));
}, [kategorien]);
// For artikel dialog: track main + sub category separately
const [artikelMainKat, setArtikelMainKat] = useState<number | ''>('');
const artikelSubKats = useMemo(() => artikelMainKat ? subKategorienOf(artikelMainKat as number) : [], [artikelMainKat, subKategorienOf]);
@@ -830,7 +328,6 @@ function KatalogTab() {
const openEditArtikel = (a: AusruestungArtikel) => {
setEditArtikel(a);
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie_id: a.kategorie_id ?? null });
// Determine main category (could be the item's category or its parent)
const kat = kategorien.find(k => k.id === a.kategorie_id);
if (kat?.parent_id) {
setArtikelMainKat(kat.parent_id);
@@ -847,7 +344,6 @@ function KatalogTab() {
return (
<Box>
{/* Filter */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
select
@@ -869,7 +365,6 @@ function KatalogTab() {
)}
</Box>
{/* Catalog table */}
{isLoading ? (
<Typography color="text.secondary">Lade Katalog...</Typography>
) : items.length === 0 ? (
@@ -913,7 +408,6 @@ function KatalogTab() {
</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' }}>
@@ -926,7 +420,6 @@ function KatalogTab() {
onChange={e => {
const val = e.target.value ? Number(e.target.value) : '';
setArtikelMainKat(val);
// If no subcategories, set kategorie_id to main; otherwise clear it
if (val) {
const subs = subKategorienOf(val as number);
setArtikelForm(f => ({ ...f, kategorie_id: subs.length === 0 ? (val as number) : null }));
@@ -961,10 +454,8 @@ function KatalogTab() {
</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 />
@@ -978,122 +469,17 @@ function KatalogTab() {
function MeineAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const navigate = useNavigate();
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 } | 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; fuer_benutzer_name?: string }) =>
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name),
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: typeof newFuerBenutzer === 'object' && newFuerBenutzer ? newFuerBenutzer.id : undefined,
fuer_benutzer_name: typeof newFuerBenutzer === 'string' ? newFuerBenutzer : undefined,
});
};
const filteredRequests = useMemo(() => {
if (statusFilter.length === 0) return requests;
return requests.filter(r => statusFilter.includes(r.status));
@@ -1128,11 +514,11 @@ function MeineAnfragenTab() {
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>
))}
<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>
@@ -1152,7 +538,7 @@ function MeineAnfragenTab() {
</TableHead>
<TableBody>
{filteredRequests.map(r => (
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setDetailId(r.id)}>
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate('/ausruestungsanfrage/' + 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>
@@ -1165,157 +551,8 @@ function MeineAnfragenTab() {
</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
freeSolo
options={orderUsers}
getOptionLabel={o => typeof o === 'string' ? o : o.name}
value={newFuerBenutzer}
onChange={(_, v) => setNewFuerBenutzer(v)}
onInputChange={(_, value, reason) => {
if (reason === 'input') {
// If user types a custom value that doesn't match any option, store as string
const match = orderUsers.find(u => u.name === value);
if (!match && value) {
setNewFuerBenutzer(value);
} else if (!value) {
setNewFuerBenutzer(null);
}
}
}}
isOptionEqualToValue={(option, value) => {
if (typeof option === 'string' || typeof value === 'string') return option === value;
return option.id === value.id;
}}
renderInput={params => <TextField {...params} label="Für wen (optional)" InputLabelProps={{ ...params.InputLabelProps, shrink: true }} placeholder="Mitglied auswählen oder Name eingeben..." />}
/>
)}
<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">
<ChatAwareFab onClick={() => navigate('/ausruestungsanfrage/neu')} aria-label="Neue Anfrage erstellen">
<AddIcon />
</ChatAwareFab>
)}
@@ -1326,12 +563,8 @@ function MeineAnfragenTab() {
// ─── Admin All Requests Tab (merged with overview) ──────────────────────────
function AlleAnfragenTab() {
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState<string>('alle');
const [detailId, setDetailId] = useState<number | null>(null);
const canEditAny = hasPermission('ausruestungsanfrage:edit');
const { data: requests = [], isLoading: requestsLoading, isError: requestsError } = useQuery({
queryKey: ['ausruestungsanfrage', 'allRequests', statusFilter],
@@ -1345,7 +578,6 @@ function AlleAnfragenTab() {
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' }}>
@@ -1409,7 +641,7 @@ function AlleAnfragenTab() {
</TableHead>
<TableBody>
{requests.map(r => (
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setDetailId(r.id)}>
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate('/ausruestungsanfrage/' + r.id)}>
<TableCell>{formatOrderId(r)}</TableCell>
<TableCell>{r.bezeichnung || '-'}</TableCell>
<TableCell>{r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id}</TableCell>
@@ -1427,15 +659,6 @@ function AlleAnfragenTab() {
</Table>
</TableContainer>
)}
<DetailModal
requestId={detailId}
onClose={() => setDetailId(null)}
showAdminActions
showEditButton
canEditAny={canEditAny}
currentUserId={user?.id}
/>
</Box>
);
}
@@ -1457,7 +680,6 @@ export default function Ausruestungsanfrage() {
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);

View File

@@ -0,0 +1,487 @@
import { useState } from 'react';
import {
Box, Typography, Paper, Button, Chip, IconButton,
Table, TableBody, TableCell, TableHead, TableRow,
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
MenuItem, Select, FormControl, InputLabel, Autocomplete,
Checkbox, LinearProgress,
} from '@mui/material';
import {
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
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 {
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
} 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}`;
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function AusruestungsanfrageDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const requestId = Number(id);
// ── State ──
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);
// Permissions
const showAdminActions = hasPermission('ausruestungsanfrage:approve');
const canEditAny = hasPermission('ausruestungsanfrage:edit');
const canLink = hasPermission('ausruestungsanfrage:link_orders');
// ── Queries ──
const { data: detail, isLoading, isError } = useQuery<AusruestungAnfrageDetailResponse>({
queryKey: ['ausruestungsanfrage', 'request', requestId],
queryFn: () => ausruestungsanfrageApi.getRequest(requestId),
enabled: !isNaN(requestId),
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,
});
// ── Mutations ──
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 geliefertMut = useMutation({
mutationFn: ({ positionId, geliefert }: { positionId: number; geliefert: boolean }) =>
ausruestungsanfrageApi.updatePositionGeliefert(positionId, geliefert),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
},
onError: () => showError('Fehler beim Aktualisieren'),
});
// ── Edit helpers ──
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));
};
const anfrage = detail?.anfrage;
const canEdit = anfrage && (
canEditAny ||
(anfrage.anfrager_id === user?.id && anfrage.status === 'offen')
);
return (
<DashboardLayout>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
Anfrage {anfrage ? formatOrderId(anfrage) : '...'}
{anfrage?.bezeichnung && `${anfrage.bezeichnung}`}
</Typography>
{anfrage && (
<Chip
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
color={AUSRUESTUNG_STATUS_COLORS[anfrage.status]}
/>
)}
</Box>
{isLoading ? (
<LinearProgress />
) : isError ? (
<Typography color="error">Fehler beim Laden der Anfrage.</Typography>
) : !detail ? (
<Typography color="text.secondary">Anfrage nicht gefunden.</Typography>
) : editing ? (
/* ── Edit Mode ── */
<Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
<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>
</Box>
</Box>
</Paper>
) : (
/* ── View Mode ── */
<>
<Paper sx={{ p: 3, mb: 3 }}>
{/* Meta info */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
{(anfrage!.anfrager_name || anfrage!.fuer_benutzer_name) && (
<Box>
<Typography variant="caption" color="text.secondary">Anfrage für</Typography>
<Typography variant="body2" fontWeight={500}>
{anfrage!.fuer_benutzer_name
? `${anfrage!.fuer_benutzer_name} (erstellt von ${anfrage!.anfrager_name || 'Unbekannt'})`
: anfrage!.anfrager_name}
</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography variant="body2" fontWeight={500}>{new Date(anfrage!.erstellt_am).toLocaleDateString('de-AT')}</Typography>
</Box>
{anfrage!.bearbeitet_von_name && (
<Box>
<Typography variant="caption" color="text.secondary">Bearbeitet von</Typography>
<Typography variant="body2" fontWeight={500}>{anfrage!.bearbeitet_von_name}</Typography>
</Box>
)}
</Box>
{anfrage!.notizen && (
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover', mb: 1.5 }}>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{anfrage!.notizen}</Typography>
</Paper>
)}
{anfrage!.admin_notizen && (
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'warning.main', color: 'warning.contrastText', borderColor: 'warning.dark', mb: 1.5 }}>
<Typography variant="caption" display="block" sx={{ mb: 0.5, opacity: 0.8 }}>Admin Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{anfrage!.admin_notizen}</Typography>
</Paper>
)}
</Paper>
{/* Positionen */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Positionen ({detail.positionen.length})</Typography>
<Table size="small">
<TableHead>
<TableRow>
{showAdminActions && <TableCell padding="checkbox">Geliefert</TableCell>}
<TableCell>Artikel</TableCell>
<TableCell align="right">Menge</TableCell>
<TableCell>Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
{detail.positionen.map(p => (
<TableRow key={p.id} sx={p.geliefert ? { opacity: 0.5 } : undefined}>
{showAdminActions && (
<TableCell padding="checkbox">
<Checkbox
size="small"
checked={p.geliefert}
disabled={geliefertMut.isPending}
onChange={(_, checked) => geliefertMut.mutate({ positionId: p.id, geliefert: checked })}
/>
</TableCell>
)}
<TableCell>
<Typography variant="body2" fontWeight={500} sx={p.geliefert ? { textDecoration: 'line-through' } : undefined}>{p.bezeichnung}</Typography>
{p.eigenschaften && p.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{p.eigenschaften.map(e => (
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box>
)}
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight={600}>{p.menge}x</Typography>
</TableCell>
<TableCell>
{p.notizen && <Typography variant="caption" color="text.secondary">{p.notizen}</Typography>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
{/* Linked Bestellungen */}
{detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>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>
</Paper>
)}
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{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 && (
<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>
)}
{showAdminActions && anfrage && anfrage.status === 'genehmigt' && canLink && (
<Button
variant="outlined"
startIcon={<LinkIcon />}
onClick={() => setLinkDialog(true)}
>
Verknüpfen
</Button>
)}
{canEdit && !editing && (
<Button startIcon={<EditIcon />} onClick={startEditing}>
Bearbeiten
</Button>
)}
</Box>
</>
)}
{/* 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>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,318 @@
import { useState, useCallback, useRef } from 'react';
import {
Box, Typography, Paper, Button, TextField, IconButton,
Autocomplete, Divider, MenuItem,
} from '@mui/material';
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import type {
AusruestungAnfrageFormItem,
AusruestungEigenschaft,
} from '../types/ausruestungsanfrage.types';
// ── EigenschaftFields ──
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: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
{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>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function AusruestungsanfrageNeu() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const canOrderForUser = hasPermission('ausruestungsanfrage:order_for_user');
// ── Form state ──
const [bezeichnung, setBezeichnung] = useState('');
const [notizen, setNotizen] = useState('');
const [fuerBenutzer, setFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null);
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
// Eigenschaften state
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
const itemEigenschaftenRef = useRef(itemEigenschaften);
itemEigenschaftenRef.current = itemEigenschaften;
const [itemEigenschaftValues, setItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
// ── Queries ──
const { data: katalogArtikel = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'items-for-create'],
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
});
const { data: orderUsers = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'orderUsers'],
queryFn: () => ausruestungsanfrageApi.getOrderUsers(),
enabled: canOrderForUser,
});
// ── Mutations ──
const createMut = useMutation({
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string }) =>
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Anfrage erstellt');
navigate('/ausruestungsanfrage');
},
onError: () => showError('Fehler beim Erstellen der Anfrage'),
});
// ── Eigenschaft loading ──
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);
}
}, []);
// ── Submit ──
const handleSubmit = () => {
// Catalog items with eigenschaften
const catalogValidItems = catalogItems.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 = freeItems
.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 < catalogItems.length; idx++) {
const item = catalogItems[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: notizen || undefined,
bezeichnung: bezeichnung || undefined,
fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined,
fuer_benutzer_name: typeof fuerBenutzer === 'string' ? fuerBenutzer : undefined,
});
};
const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim());
return (
<DashboardLayout>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
<ArrowBack />
</IconButton>
<Typography variant="h5">Neue Bestellung</Typography>
</Box>
<Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Bezeichnung (optional)"
value={bezeichnung}
onChange={e => setBezeichnung(e.target.value)}
fullWidth
/>
{canOrderForUser && (
<Autocomplete
freeSolo
options={orderUsers}
getOptionLabel={o => typeof o === 'string' ? o : o.name}
value={fuerBenutzer}
onChange={(_, v) => setFuerBenutzer(v)}
onInputChange={(_, value, reason) => {
if (reason === 'input') {
const match = orderUsers.find(u => u.name === value);
if (!match && value) {
setFuerBenutzer(value);
} else if (!value) {
setFuerBenutzer(null);
}
}
}}
isOptionEqualToValue={(option, value) => {
if (typeof option === 'string' || typeof value === 'string') return option === value;
return option.id === value.id;
}}
renderInput={params => <TextField {...params} label="Für wen (optional)" InputLabelProps={{ ...params.InputLabelProps, shrink: true }} placeholder="Mitglied auswählen oder Name eingeben..." />}
/>
)}
<TextField
label="Notizen (optional)"
value={notizen}
onChange={e => setNotizen(e.target.value)}
multiline
rows={2}
fullWidth
/>
<Divider />
<Typography variant="subtitle2">Aus Katalog</Typography>
{catalogItems.map((item, idx) => (
<Box key={`cat-${idx}`}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Autocomplete
options={katalogArtikel}
getOptionLabel={o => typeof o === 'string' ? o : o.bezeichnung}
value={item.artikel_id ? katalogArtikel.find(c => c.id === item.artikel_id) || null : null}
onChange={(_, v) => {
if (v && typeof v !== 'string') {
setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
loadEigenschaften(v.id);
} else {
setCatalogItems(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 => setCatalogItems(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={() => setCatalogItems(prev => prev.filter((_, i) => i !== idx))} disabled={catalogItems.length <= 1}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{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={() => setCatalogItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
Katalogartikel hinzufügen
</Button>
<Divider />
<Typography variant="subtitle2">Freitext-Positionen</Typography>
{freeItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Freitext-Positionen.</Typography>
) : (
freeItems.map((item, idx) => (
<Box key={`free-${idx}`} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
label="Bezeichnung"
value={item.bezeichnung}
onChange={e => setFreeItems(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 => setFreeItems(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={() => setFreeItems(prev => prev.filter((_, i) => i !== idx))}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))
)}
<Button size="small" startIcon={<AddIcon />} onClick={() => setFreeItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
Freitext-Position hinzufügen
</Button>
<Divider />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button onClick={() => navigate('/ausruestungsanfrage')}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={createMut.isPending || !hasValidItems}
>
Anfrage erstellen
</Button>
</Box>
</Box>
</Paper>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,232 @@
import { useState } from 'react';
import {
Box,
Typography,
Paper,
Button,
TextField,
IconButton,
Autocomplete,
Tooltip,
} from '@mui/material';
import {
ArrowBack,
Add as AddIcon,
RemoveCircleOutline as RemoveIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { bestellungApi } from '../services/bestellung';
import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', budget: undefined, notizen: '', positionen: [] };
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
const emptyPosition: BestellpositionFormData = { bezeichnung: '', menge: 1, einheit: 'Stk' };
export default function BestellungNeu() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const [orderForm, setOrderForm] = useState<BestellungFormData>({ ...emptyOrderForm });
const [inlineVendorOpen, setInlineVendorOpen] = useState(false);
const [inlineVendorForm, setInlineVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
// ── Queries ──
const { data: vendors = [] } = useQuery({
queryKey: ['lieferanten'],
queryFn: bestellungApi.getVendors,
});
const { data: orderUsers = [] } = useQuery({
queryKey: ['bestellungen', 'order-users'],
queryFn: bestellungApi.getOrderUsers,
});
// ── Mutations ──
const createOrder = useMutation({
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
showSuccess('Bestellung erstellt');
navigate(`/bestellungen/${created.id}`);
},
onError: (error: any) => showError(error?.response?.data?.message || 'Fehler beim Erstellen der Bestellung'),
});
const createVendor = useMutation({
mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data),
onSuccess: (newVendor: Lieferant) => {
queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
showSuccess('Lieferant erstellt');
setOrderForm((f) => ({ ...f, lieferant_id: newVendor.id }));
setInlineVendorOpen(false);
setInlineVendorForm({ ...emptyVendorForm });
},
onError: () => showError('Fehler beim Erstellen des Lieferanten'),
});
const handleSubmit = () => {
if (!orderForm.bezeichnung.trim()) return;
createOrder.mutate(orderForm);
};
return (
<DashboardLayout>
{/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/bestellungen')}>
<ArrowBack />
</IconButton>
<Typography variant="h5" fontWeight={700}>Neue Bestellung</Typography>
</Box>
{/* ── Form ── */}
<Paper sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<TextField
label="Bezeichnung"
required
InputLabelProps={{ shrink: true }}
value={orderForm.bezeichnung}
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
/>
{/* Lieferant + inline create */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Autocomplete
options={vendors}
getOptionLabel={(o) => o.name}
value={vendors.find((v) => v.id === orderForm.lieferant_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Lieferant" />}
sx={{ flexGrow: 1 }}
/>
<Tooltip title="Neuen Lieferant anlegen">
<IconButton
onClick={() => setInlineVendorOpen(!inlineVendorOpen)}
color={inlineVendorOpen ? 'primary' : 'default'}
sx={{ mt: 1 }}
>
<AddIcon />
</IconButton>
</Tooltip>
</Box>
{inlineVendorOpen && (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2">Neuer Lieferant</Typography>
<TextField size="small" label="Name *" value={inlineVendorForm.name} onChange={(e) => setInlineVendorForm((f) => ({ ...f, name: e.target.value }))} />
<TextField size="small" label="Kontakt-Name" value={inlineVendorForm.kontakt_name || ''} onChange={(e) => setInlineVendorForm((f) => ({ ...f, kontakt_name: e.target.value }))} />
<TextField size="small" label="E-Mail" value={inlineVendorForm.email || ''} onChange={(e) => setInlineVendorForm((f) => ({ ...f, email: e.target.value }))} />
<TextField size="small" label="Telefon" value={inlineVendorForm.telefon || ''} onChange={(e) => setInlineVendorForm((f) => ({ ...f, telefon: e.target.value }))} />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button size="small" onClick={() => { setInlineVendorOpen(false); setInlineVendorForm({ ...emptyVendorForm }); }}>Abbrechen</Button>
<Button size="small" variant="contained" onClick={() => createVendor.mutate(inlineVendorForm)} disabled={!inlineVendorForm.name.trim() || createVendor.isPending}>Anlegen</Button>
</Box>
</Paper>
)}
<Autocomplete
options={orderUsers}
getOptionLabel={(o) => o.name}
value={orderUsers.find((u) => u.id === orderForm.besteller_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))}
renderInput={(params) => <TextField {...params} label="Besteller" />}
/>
<TextField
label="Budget"
type="number"
InputLabelProps={{ shrink: true }}
value={orderForm.budget ?? ''}
onChange={(e) => setOrderForm((f) => ({ ...f, budget: e.target.value ? Number(e.target.value) : undefined }))}
inputProps={{ min: 0, step: 0.01 }}
/>
<TextField
label="Notizen"
multiline
rows={3}
value={orderForm.notizen || ''}
onChange={(e) => setOrderForm((f) => ({ ...f, notizen: e.target.value }))}
/>
{/* ── Positionen ── */}
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
{(orderForm.positionen || []).map((pos, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
label="Bezeichnung"
size="small"
required
InputLabelProps={{ shrink: true }}
value={pos.bezeichnung}
onChange={(e) => {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], bezeichnung: e.target.value };
setOrderForm((f) => ({ ...f, positionen: next }));
}}
sx={{ flexGrow: 1 }}
/>
<TextField
label="Menge"
size="small"
type="number"
InputLabelProps={{ shrink: true }}
value={pos.menge}
onChange={(e) => {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], menge: Math.max(1, Number(e.target.value) || 1) };
setOrderForm((f) => ({ ...f, positionen: next }));
}}
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<TextField
label="Einheit"
size="small"
InputLabelProps={{ shrink: true }}
value={pos.einheit || 'Stk'}
onChange={(e) => {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], einheit: e.target.value };
setOrderForm((f) => ({ ...f, positionen: next }));
}}
sx={{ width: 100 }}
/>
<IconButton
size="small"
color="error"
onClick={() => {
const next = (orderForm.positionen || []).filter((_, i) => i !== idx);
setOrderForm((f) => ({ ...f, positionen: next }));
}}
>
<RemoveIcon />
</IconButton>
</Box>
))}
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => setOrderForm((f) => ({ ...f, positionen: [...(f.positionen || []), { ...emptyPosition }] }))}
>
Position hinzufügen
</Button>
{/* ── Submit ── */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
<Button onClick={() => navigate('/bestellungen')}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={!orderForm.bezeichnung.trim() || createOrder.isPending}
>
Erstellen
</Button>
</Box>
</Paper>
</DashboardLayout>
);
}

View File

@@ -24,9 +24,8 @@ import {
FormControl,
InputLabel,
Tooltip,
Autocomplete,
} from '@mui/material';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, RemoveCircleOutline as RemoveIcon } from '@mui/icons-material';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -35,7 +34,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { bestellungApi } from '../services/bestellung';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
import type { BestellungStatus, LieferantFormData, Lieferant } from '../types/bestellung.types';
// ── Helpers ──
@@ -61,9 +60,7 @@ const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'te
// ── Empty form data ──
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', budget: undefined, notizen: '', positionen: [] };
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
const emptyPosition: BestellpositionFormData = { bezeichnung: '', menge: 1, einheit: 'Stk' };
// ══════════════════════════════════════════════════════════════════════════════
// Component
@@ -88,10 +85,6 @@ export default function Bestellungen() {
// ── State ──
const [statusFilter, setStatusFilter] = useState<string>('');
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const [orderForm, setOrderForm] = useState<BestellungFormData>({ ...emptyOrderForm });
const [inlineVendorOpen, setInlineVendorOpen] = useState(false);
const [inlineVendorForm, setInlineVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
const [vendorDialogOpen, setVendorDialogOpen] = useState(false);
const [vendorForm, setVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
@@ -110,37 +103,13 @@ export default function Bestellungen() {
queryFn: bestellungApi.getVendors,
});
const { data: orderUsers = [] } = useQuery({
queryKey: ['bestellungen', 'order-users'],
queryFn: bestellungApi.getOrderUsers,
});
// ── Mutations ──
const createOrder = useMutation({
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ['bestellungen'] });
showSuccess('Bestellung erstellt');
setOrderDialogOpen(false);
setOrderForm({ ...emptyOrderForm });
navigate(`/bestellungen/${created.id}`);
},
onError: (error: any) => showError(error?.response?.data?.message || 'Fehler beim Erstellen der Bestellung'),
});
const createVendor = useMutation({
mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data),
onSuccess: (newVendor) => {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
showSuccess('Lieferant erstellt');
// If inline vendor creation during order creation, auto-select the new vendor
if (inlineVendorOpen) {
setOrderForm((f) => ({ ...f, lieferant_id: newVendor.id }));
setInlineVendorOpen(false);
setInlineVendorForm({ ...emptyVendorForm });
} else {
closeVendorDialog();
}
closeVendorDialog();
},
onError: () => showError('Fehler beim Erstellen des Lieferanten'),
});
@@ -188,11 +157,6 @@ export default function Bestellungen() {
}
}
function handleOrderSave() {
if (!orderForm.bezeichnung.trim()) return;
createOrder.mutate(orderForm);
}
// ── Render ──
return (
@@ -271,7 +235,7 @@ export default function Bestellungen() {
</TableContainer>
{hasPermission('bestellungen:create') && (
<ChatAwareFab onClick={() => setOrderDialogOpen(true)} aria-label="Neue Bestellung">
<ChatAwareFab onClick={() => navigate('/bestellungen/neu')} aria-label="Neue Bestellung">
<AddIcon />
</ChatAwareFab>
)}
@@ -330,135 +294,6 @@ export default function Bestellungen() {
)}
</TabPanel>
{/* ── Create Order Dialog ── */}
<Dialog open={orderDialogOpen} onClose={() => setOrderDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Neue Bestellung</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
<TextField
label="Bezeichnung"
required
InputLabelProps={{ shrink: true }}
value={orderForm.bezeichnung}
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Autocomplete
options={vendors}
getOptionLabel={(o) => o.name}
value={vendors.find((v) => v.id === orderForm.lieferant_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Lieferant" />}
sx={{ flexGrow: 1 }}
/>
<Tooltip title="Neuen Lieferant anlegen">
<IconButton
onClick={() => setInlineVendorOpen(!inlineVendorOpen)}
color={inlineVendorOpen ? 'primary' : 'default'}
sx={{ mt: 1 }}
>
<AddIcon />
</IconButton>
</Tooltip>
</Box>
{inlineVendorOpen && (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2">Neuer Lieferant</Typography>
<TextField size="small" label="Name *" value={inlineVendorForm.name} onChange={(e) => setInlineVendorForm(f => ({ ...f, name: e.target.value }))} />
<TextField size="small" label="Kontakt-Name" value={inlineVendorForm.kontakt_name || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, kontakt_name: e.target.value }))} />
<TextField size="small" label="E-Mail" value={inlineVendorForm.email || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, email: e.target.value }))} />
<TextField size="small" label="Telefon" value={inlineVendorForm.telefon || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, telefon: e.target.value }))} />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button size="small" onClick={() => { setInlineVendorOpen(false); setInlineVendorForm({ ...emptyVendorForm }); }}>Abbrechen</Button>
<Button size="small" variant="contained" onClick={() => createVendor.mutate(inlineVendorForm)} disabled={!inlineVendorForm.name.trim() || createVendor.isPending}>Anlegen</Button>
</Box>
</Paper>
)}
<Autocomplete
options={orderUsers}
getOptionLabel={(o) => o.name}
value={orderUsers.find((u) => u.id === orderForm.besteller_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))}
renderInput={(params) => <TextField {...params} label="Besteller" />}
/>
<TextField
label="Notizen"
multiline
rows={3}
value={orderForm.notizen || ''}
onChange={(e) => setOrderForm((f) => ({ ...f, notizen: e.target.value }))}
/>
{/* ── Dynamic Items List ── */}
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
{(orderForm.positionen || []).map((pos, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
label="Bezeichnung"
size="small"
required
InputLabelProps={{ shrink: true }}
value={pos.bezeichnung}
onChange={(e) => {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], bezeichnung: e.target.value };
setOrderForm((f) => ({ ...f, positionen: next }));
}}
sx={{ flexGrow: 1 }}
/>
<TextField
label="Menge"
size="small"
type="number"
InputLabelProps={{ shrink: true }}
value={pos.menge}
onChange={(e) => {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], menge: Math.max(1, Number(e.target.value) || 1) };
setOrderForm((f) => ({ ...f, positionen: next }));
}}
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<TextField
label="Einheit"
size="small"
InputLabelProps={{ shrink: true }}
value={pos.einheit || 'Stk'}
onChange={(e) => {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], einheit: e.target.value };
setOrderForm((f) => ({ ...f, positionen: next }));
}}
sx={{ width: 100 }}
/>
<IconButton
size="small"
color="error"
onClick={() => {
const next = (orderForm.positionen || []).filter((_, i) => i !== idx);
setOrderForm((f) => ({ ...f, positionen: next }));
}}
>
<RemoveIcon />
</IconButton>
</Box>
))}
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => setOrderForm((f) => ({ ...f, positionen: [...(f.positionen || []), { ...emptyPosition }] }))}
>
Position hinzufügen
</Button>
</DialogContent>
<DialogActions>
<Button onClick={() => setOrderDialogOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleOrderSave} disabled={!orderForm.bezeichnung.trim() || createOrder.isPending}>
Erstellen
</Button>
</DialogActions>
</Dialog>
{/* ── Create/Edit Vendor Dialog ── */}
<Dialog open={vendorDialogOpen} onClose={closeVendorDialog} maxWidth="sm" fullWidth>
<DialogTitle>{editingVendor ? 'Lieferant bearbeiten' : 'Neuer Lieferant'}</DialogTitle>

View File

@@ -0,0 +1,445 @@
import { useState, useMemo } from 'react';
import {
Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent,
} from '@mui/material';
import {
ArrowBack, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Refresh as RefreshIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues';
import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
// ── Helpers (copied from Issues.tsx) ──
const formatDate = (iso?: string) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
const formatIssueId = (issue: Issue) =>
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
const STATUS_COLORS: Record<string, string> = {
offen: 'info', in_bearbeitung: 'warning', erledigt: 'success', abgelehnt: 'error',
};
const STATUS_LABELS: Record<string, string> = {
offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', abgelehnt: 'Abgelehnt',
};
const PRIO_COLORS: Record<string, string> = {
hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e',
};
const PRIO_LABELS: Record<string, string> = {
hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig',
};
function getStatusLabel(statuses: IssueStatusDef[], key: string) {
return statuses.find(s => s.schluessel === key)?.bezeichnung ?? STATUS_LABELS[key] ?? key;
}
function getStatusColor(statuses: IssueStatusDef[], key: string): any {
return statuses.find(s => s.schluessel === key)?.farbe ?? STATUS_COLORS[key] ?? 'default';
}
function getPrioColor(priorities: IssuePriorityDef[], key: string) {
return priorities.find(p => p.schluessel === key)?.farbe ?? PRIO_COLORS[key] ?? '#9e9e9e';
}
function getPrioLabel(priorities: IssuePriorityDef[], key: string) {
return priorities.find(p => p.schluessel === key)?.bezeichnung ?? PRIO_LABELS[key] ?? key;
}
const ICON_MAP: Record<string, JSX.Element> = {
BugReport: <BugReport fontSize="small" />,
FiberNew: <FiberNew fontSize="small" />,
HelpOutline: <HelpOutline fontSize="small" />,
};
function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element {
const icon = ICON_MAP[iconName || ''] || <HelpOutline fontSize="small" />;
const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action';
return <Box component="span" sx={{ display: 'inline-flex', color: `${colorProp}.main` }}>{icon}</Box>;
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function IssueDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const issueId = Number(id);
const userId = user?.id || '';
const hasEdit = hasPermission('issues:edit');
const hasChangeStatus = hasPermission('issues:change_status');
const hasDeletePerm = hasPermission('issues:delete');
// ── State ──
const [reopenOpen, setReopenOpen] = useState(false);
const [reopenComment, setReopenComment] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [commentText, setCommentText] = useState('');
// ── Queries ──
const { data: issue, isLoading, isError } = useQuery({
queryKey: ['issues', issueId],
queryFn: () => issuesApi.getIssue(issueId),
enabled: !isNaN(issueId),
});
const { data: comments = [], isLoading: commentsLoading } = useQuery({
queryKey: ['issues', issueId, 'comments'],
queryFn: () => issuesApi.getComments(issueId),
enabled: !isNaN(issueId),
});
const { data: statuses = [] } = useQuery({
queryKey: ['issue-statuses'],
queryFn: issuesApi.getStatuses,
});
const { data: priorities = [] } = useQuery({
queryKey: ['issue-priorities'],
queryFn: issuesApi.getPriorities,
});
const { data: members = [] } = useQuery({
queryKey: ['issue-members'],
queryFn: issuesApi.getMembers,
enabled: hasEdit,
});
// ── Permissions ──
const isOwner = issue?.erstellt_von === userId;
const isAssignee = issue?.zugewiesen_an === userId;
const canDelete = hasDeletePerm || isOwner;
const canComment = isOwner || isAssignee || hasChangeStatus || hasEdit;
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
const ownerOnlyErledigt = isOwner && !canChangeStatus;
const allowedStatuses = useMemo(() => {
if (!issue) return [];
const active = statuses.filter(s => s.aktiv);
if (hasEdit) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (hasChangeStatus || isAssignee) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (isOwner) return active.filter(s => s.schluessel === issue.status || s.ist_abschluss);
return active.filter(s => s.schluessel === issue.status);
}, [statuses, hasEdit, hasChangeStatus, isAssignee, isOwner, issue]);
const currentStatusDef = statuses.find(s => s.schluessel === issue?.status);
const isTerminal = currentStatusDef?.ist_abschluss ?? (issue?.status === 'erledigt');
const showReopenButton = ownerOnlyErledigt && isTerminal;
const initialStatusKey = statuses.find(s => s.ist_initial)?.schluessel ?? 'offen';
// ── Mutations ──
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issueId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMut = useMutation({
mutationFn: () => issuesApi.deleteIssue(issueId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue gelöscht');
navigate('/issues');
},
onError: () => showError('Fehler beim Löschen'),
});
const addCommentMut = useMutation({
mutationFn: (inhalt: string) => issuesApi.addComment(issueId, inhalt),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'comments'] });
setCommentText('');
},
onError: () => showError('Kommentar konnte nicht erstellt werden'),
});
const handleReopen = () => {
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
onSuccess: () => {
setReopenOpen(false);
setReopenComment('');
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue wiedereröffnet');
},
});
};
// ── Loading / Error / 404 ──
if (isLoading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
</DashboardLayout>
);
}
if (isError || !issue) {
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/issues')} sx={{ mb: 2 }}>
Zurück
</Button>
<Typography color="error">Issue nicht gefunden.</Typography>
</Box>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<IconButton onClick={() => navigate('/issues')}>
<ArrowBack />
</IconButton>
<Box sx={{ flex: 1 }}>
<Typography variant="h5">
{formatIssueId(issue)} {issue.titel}
</Typography>
</Box>
<Chip
label={getStatusLabel(statuses, issue.status)}
color={getStatusColor(statuses, issue.status)}
/>
</Box>
{/* Info cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Typ</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.typ_name}</Typography>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Priorität</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Erstellt von</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{issue.erstellt_von_name || '-'}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Zugewiesen an</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{issue.zugewiesen_an_name || '-'}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{formatDate(issue.created_at)}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Description */}
{issue.beschreibung && (
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>Beschreibung</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{issue.beschreibung}</Typography>
</Paper>
)}
{/* Controls row */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Status control */}
{showReopenButton ? (
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => setReopenOpen(true)}
>
Wiedereröffnen
</Button>
) : canChangeStatus || isOwner ? (
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
value={issue.status}
label="Status"
onChange={(e) => updateMut.mutate({ status: e.target.value })}
>
{allowedStatuses.map(s => (
<MenuItem key={s.schluessel} value={s.schluessel}>{s.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
) : null}
{/* Priority control */}
{hasEdit && (
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Priorität</InputLabel>
<Select
value={issue.prioritaet}
label="Priorität"
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value })}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
)}
{/* Assignment */}
{hasEdit && (
<Autocomplete
size="small"
sx={{ minWidth: 200 }}
options={members}
getOptionLabel={(o: AssignableMember) => o.name}
value={members.find((m: AssignableMember) => m.id === issue.zugewiesen_an) || null}
onChange={(_e, val) => updateMut.mutate({ zugewiesen_an: val?.id || null })}
renderInput={(params) => <TextField {...params} label="Zugewiesen an" size="small" />}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
)}
</Box>
{/* Delete button */}
{canDelete && (
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => setDeleteOpen(true)}
sx={{ mb: 2 }}
>
Löschen
</Button>
)}
<Divider sx={{ my: 2 }} />
{/* Comments section */}
<Typography variant="subtitle2" gutterBottom>Kommentare</Typography>
{commentsLoading ? (
<CircularProgress size={20} />
) : comments.length === 0 ? (
<Typography variant="body2" color="text.secondary">Noch keine Kommentare</Typography>
) : (
comments.map((c: IssueComment) => (
<Box key={c.id} sx={{ mb: 1.5, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
{c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: 'pre-wrap' }}>{c.inhalt}</Typography>
</Box>
))
)}
{canComment && (
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Kommentar schreiben..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && commentText.trim()) {
e.preventDefault();
addCommentMut.mutate(commentText.trim());
}
}}
multiline
maxRows={4}
/>
<IconButton
color="primary"
disabled={!commentText.trim() || addCommentMut.isPending}
onClick={() => addCommentMut.mutate(commentText.trim())}
>
<SendIcon />
</IconButton>
</Box>
)}
</Box>
{/* Reopen Dialog */}
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Issue wiedereröffnen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
label="Kommentar (Pflicht)"
required
multiline
rows={3}
fullWidth
value={reopenComment}
onChange={(e) => setReopenComment(e.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!reopenComment.trim() || updateMut.isPending}
onClick={handleReopen}
>
Wiedereröffnen
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Issue löschen</DialogTitle>
<DialogContent>
<Typography>Soll dieses Issue wirklich gelöscht werden?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
color="error"
disabled={deleteMut.isPending}
onClick={() => deleteMut.mutate()}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react';
import {
Box, Typography, Paper, Button, TextField, MenuItem, Select, FormControl,
InputLabel, IconButton,
} from '@mui/material';
import { ArrowBack } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { issuesApi } from '../services/issues';
import type { CreateIssuePayload } from '../types/issue.types';
export default function IssueNeu() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', prioritaet: '' });
const { data: types = [] } = useQuery({
queryKey: ['issue-types'],
queryFn: issuesApi.getTypes,
});
const { data: priorities = [] } = useQuery({
queryKey: ['issue-priorities'],
queryFn: issuesApi.getPriorities,
});
const defaultTypId = types.find(t => t.aktiv)?.id;
const defaultPriority = priorities.find(p => p.aktiv)?.schluessel ?? 'mittel';
const createMut = useMutation({
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue erstellt');
navigate('/issues');
},
onError: () => showError('Fehler beim Erstellen'),
});
const handleSubmit = () => {
createMut.mutate({
...form,
typ_id: form.typ_id ?? defaultTypId,
prioritaet: form.prioritaet || defaultPriority,
});
};
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<IconButton onClick={() => navigate('/issues')}>
<ArrowBack />
</IconButton>
<Typography variant="h5">Neues Issue</Typography>
</Box>
<Paper variant="outlined" sx={{ p: 3, maxWidth: 600 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Titel"
required
fullWidth
value={form.titel}
onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus
/>
<TextField
label="Beschreibung"
multiline
rows={4}
fullWidth
value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
/>
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Priorität</InputLabel>
<Select
value={form.prioritaet || defaultPriority}
label="Priorität"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button onClick={() => navigate('/issues')}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={handleSubmit}
>
Erstellen
</Button>
</Box>
</Box>
</Paper>
</Box>
</DashboardLayout>
);
}

View File

@@ -3,24 +3,24 @@ import {
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, Collapse, Divider, CircularProgress, FormControlLabel, Switch,
InputLabel, CircularProgress, FormControlLabel, Switch,
Autocomplete,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
Add as AddIcon, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline,
Circle as CircleIcon, Edit as EditIcon,
DragIndicator, Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } 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 { issuesApi } from '../services/issues';
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
import type { Issue, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
// ── Helpers ──
@@ -76,338 +76,18 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
// ── Comment Section ──
function CommentSection({ issueId, canComment }: { issueId: number; canComment: boolean }) {
const queryClient = useQueryClient();
const { showError } = useNotification();
const [text, setText] = useState('');
const { data: comments = [], isLoading } = useQuery({
queryKey: ['issues', issueId, 'comments'],
queryFn: () => issuesApi.getComments(issueId),
});
const addMut = useMutation({
mutationFn: (inhalt: string) => issuesApi.addComment(issueId, inhalt),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'comments'] });
setText('');
},
onError: () => showError('Kommentar konnte nicht erstellt werden'),
});
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Kommentare</Typography>
{isLoading ? (
<CircularProgress size={20} />
) : comments.length === 0 ? (
<Typography variant="body2" color="text.secondary">Noch keine Kommentare</Typography>
) : (
comments.map((c: IssueComment) => (
<Box key={c.id} sx={{ mb: 1.5, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
{c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: 'pre-wrap' }}>{c.inhalt}</Typography>
</Box>
))
)}
{canComment && (
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Kommentar schreiben..."
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && text.trim()) {
e.preventDefault();
addMut.mutate(text.trim());
}
}}
multiline
maxRows={4}
/>
<IconButton
color="primary"
disabled={!text.trim() || addMut.isPending}
onClick={() => addMut.mutate(text.trim())}
>
<SendIcon />
</IconButton>
</Box>
)}
</Box>
);
}
// ── Issue Row ──
function IssueRow({
issue,
userId,
hasEdit,
hasChangeStatus,
hasDelete,
members,
statuses,
priorities,
onDelete,
}: {
issue: Issue;
userId: string;
hasEdit: boolean;
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
statuses: IssueStatusDef[];
priorities: IssuePriorityDef[];
onDelete: (id: number) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [reopenOpen, setReopenOpen] = useState(false);
const [reopenComment, setReopenComment] = useState('');
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const isOwner = issue.erstellt_von === userId;
const isAssignee = issue.zugewiesen_an === userId;
const canDelete = hasDelete || isOwner;
const canComment = isOwner || isAssignee || hasChangeStatus || hasEdit;
// Determine status change capability
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
const ownerOnlyErledigt = isOwner && !canChangeStatus;
// Build allowed statuses from dynamic list
const allowedStatuses = useMemo(() => {
const active = statuses.filter(s => s.aktiv);
if (hasEdit) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (hasChangeStatus || isAssignee) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (isOwner) return active.filter(s => s.schluessel === issue.status || s.ist_abschluss);
return active.filter(s => s.schluessel === issue.status);
}, [statuses, hasEdit, hasChangeStatus, isAssignee, isOwner, issue.status, issue.typ_erlaubt_abgelehnt]);
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
// Owner on erledigt issue: show reopen button instead of status select
const currentStatusDef = statuses.find(s => s.schluessel === issue.status);
const isTerminal = currentStatusDef?.ist_abschluss ?? (issue.status === 'erledigt');
const showReopenButton = ownerOnlyErledigt && isTerminal;
const initialStatusKey = statuses.find(s => s.ist_initial)?.schluessel ?? 'offen';
const handleReopen = () => {
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
onSuccess: () => {
setReopenOpen(false);
setReopenComment('');
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue wiedereröffnet');
},
});
};
return (
<>
<TableRow
hover
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'unset' : undefined } }}
onClick={() => setExpanded(!expanded)}
>
<TableCell sx={{ width: 80 }}>{formatIssueId(issue)}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={getStatusLabel(statuses, issue.status)}
size="small"
color={getStatusColor(statuses, issue.status)}
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
<TableCell>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}>
{expanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={9} sx={{ py: 0 }}>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}>
{issue.beschreibung && (
<Typography variant="body2" sx={{ mb: 2, whiteSpace: 'pre-wrap' }}>
{issue.beschreibung}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Status control */}
{showReopenButton ? (
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={(e) => { e.stopPropagation(); setReopenOpen(true); }}
>
Wiedereröffnen
</Button>
) : canChangeStatus || isOwner ? (
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
value={issue.status}
label="Status"
onChange={(e) => updateMut.mutate({ status: e.target.value })}
onClick={(e) => e.stopPropagation()}
>
{allowedStatuses.map(s => (
<MenuItem key={s.schluessel} value={s.schluessel}>{s.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
) : null}
{/* Priority control — only with issues:edit */}
{hasEdit && (
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Priorität</InputLabel>
<Select
value={issue.prioritaet}
label="Priorität"
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value })}
onClick={(e) => e.stopPropagation()}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
)}
{/* Assignment — only with issues:edit */}
{hasEdit && (
<Autocomplete
size="small"
sx={{ minWidth: 200 }}
options={members}
getOptionLabel={(o) => o.name}
value={members.find(m => m.id === issue.zugewiesen_an) || null}
onChange={(_e, val) => updateMut.mutate({ zugewiesen_an: val?.id || null })}
renderInput={(params) => <TextField {...params} label="Zugewiesen an" size="small" />}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
)}
</Box>
{canDelete && (
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={(e) => { e.stopPropagation(); onDelete(issue.id); }}
sx={{ mb: 1 }}
>
Löschen
</Button>
)}
<Divider sx={{ my: 1 }} />
<CommentSection issueId={issue.id} canComment={canComment} />
</Box>
</Collapse>
</TableCell>
</TableRow>
{/* Reopen Dialog */}
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Issue wiedereröffnen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
label="Kommentar (Pflicht)"
required
multiline
rows={3}
fullWidth
value={reopenComment}
onChange={(e) => setReopenComment(e.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!reopenComment.trim() || updateMut.isPending}
onClick={handleReopen}
>
Wiedereröffnen
</Button>
</DialogActions>
</Dialog>
</>
);
}
// ── Issue Table ──
function IssueTable({
issues,
userId,
hasEdit,
hasChangeStatus,
hasDelete,
members,
statuses,
priorities,
}: {
issues: Issue[];
userId: string;
hasEdit: boolean;
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
statuses: IssueStatusDef[];
priorities: IssuePriorityDef[];
}) {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const deleteMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteIssue(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue gelöscht');
},
onError: () => showError('Fehler beim Löschen'),
});
const navigate = useNavigate();
if (issues.length === 0) {
return (
@@ -430,23 +110,43 @@ function IssueTable({
<TableCell>Erstellt von</TableCell>
<TableCell>Zugewiesen an</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{issues.map((issue) => (
<IssueRow
<TableRow
key={issue.id}
issue={issue}
userId={userId}
hasEdit={hasEdit}
hasChangeStatus={hasChangeStatus}
hasDelete={hasDelete}
members={members}
statuses={statuses}
priorities={priorities}
onDelete={(id) => deleteMut.mutate(id)}
/>
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/issues/${issue.id}`)}
>
<TableCell sx={{ width: 80 }}>{formatIssueId(issue)}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={getStatusLabel(statuses, issue.status)}
size="small"
color={getStatusColor(statuses, issue.status)}
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
@@ -854,15 +554,12 @@ function IssueSettings() {
export default function Issues() {
const [searchParams, setSearchParams] = useSearchParams();
const { showSuccess, showError } = useNotification();
const navigate = useNavigate();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const canViewAll = hasPermission('issues:view_all');
const hasEdit = hasPermission('issues:edit');
const hasChangeStatus = hasPermission('issues:change_status');
const hasDeletePerm = hasPermission('issues:delete');
const hasEditSettings = hasPermission('issues:edit_settings');
const canCreate = hasPermission('issues:create');
const userId = user?.id || '';
@@ -884,8 +581,6 @@ export default function Issues() {
const [showDoneMine, setShowDoneMine] = useState(false);
const [showDoneAssigned, setShowDoneAssigned] = useState(false);
const [filters, setFilters] = useState<IssueFilters>({});
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', prioritaet: 'mittel' });
// Fetch all issues for mine/assigned tabs
const { data: issues = [], isLoading } = useQuery({
@@ -922,20 +617,6 @@ export default function Issues() {
enabled: hasEdit,
});
// Default priority: first active, sorted by sort_order
const defaultPriority = issuePriorities.find(p => p.aktiv)?.schluessel ?? 'mittel';
const createMut = useMutation({
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue erstellt');
setCreateOpen(false);
setForm({ titel: '', prioritaet: defaultPriority });
},
onError: () => showError('Fehler beim Erstellen'),
});
const handleTabChange = (_: unknown, newValue: number) => {
setSearchParams({ tab: String(newValue) });
};
@@ -950,9 +631,6 @@ export default function Issues() {
const assignedIssues = issues.filter((i: Issue) => i.zugewiesen_an === userId);
const assignedFiltered = showDoneAssigned ? assignedIssues : assignedIssues.filter((i: Issue) => !isDone(i));
// Default typ_id to first active type
const defaultTypId = types.find(t => t.aktiv)?.id;
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
@@ -972,7 +650,7 @@ export default function Issues() {
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
<IssueTable issues={myIssuesFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
@@ -986,7 +664,7 @@ export default function Issues() {
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
<IssueTable issues={assignedFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
@@ -997,7 +675,7 @@ export default function Issues() {
{isFilteredLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
<IssueTable issues={filteredIssues} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
)}
@@ -1010,69 +688,12 @@ export default function Issues() {
)}
</Box>
{/* Create Issue Dialog */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neues Issue erstellen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
label="Titel"
required
fullWidth
value={form.titel}
onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus
/>
<TextField
label="Beschreibung"
multiline
rows={4}
fullWidth
value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
/>
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Priorität</InputLabel>
<Select
value={form.prioritaet || defaultPriority}
label="Priorität"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
>
{issuePriorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={() => createMut.mutate({ ...form, typ_id: form.typ_id ?? defaultTypId })}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
{/* FAB */}
{canCreate && activeTab === 'mine' && (
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Neues Issue"
onClick={() => setCreateOpen(true)}
onClick={() => navigate('/issues/neu')}
>
<AddIcon />
</ChatAwareFab>