diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4856a9b..41ad05b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> } /> + + + + } + /> + + + + } + /> ; - onChange: (eigenschaftId: number, wert: string) => void; -} - -function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) { - if (eigenschaften.length === 0) return null; - return ( - - {eigenschaften.map(e => ( - - {e.typ === 'options' && e.optionen && e.optionen.length > 0 ? ( - onChange(e.id, ev.target.value)} - required={e.pflicht} - sx={{ minWidth: 160 }} - > - - {e.optionen.map(opt => ( - {opt} - ))} - - ) : ( - onChange(e.id, ev.target.value)} - required={e.pflicht} - sx={{ minWidth: 160 }} - /> - )} - - ))} - - ); -} - // ─── 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([]); - - // 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(null); - - const { data: detail, isLoading, isError } = useQuery({ - 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 ( - <> - - - - Anfrage {anfrage ? formatOrderId(anfrage) : '...'} - {anfrage?.bezeichnung && ` — ${anfrage.bezeichnung}`} - - {anfrage && ( - - )} - - - {isLoading ? ( - Lade Details... - ) : isError ? ( - Fehler beim Laden der Anfrage. - ) : !detail ? ( - Anfrage nicht gefunden. - ) : editing ? ( - /* ── Edit Mode ── */ - <> - setEditBezeichnung(e.target.value)} - fullWidth - /> - setEditNotizen(e.target.value)} - multiline - rows={2} - fullWidth - /> - Positionen - {editItems.map((item, idx) => ( - - 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 => } - sx={{ flexGrow: 1 }} - /> - updateEditItem(idx, 'menge', Math.max(1, Number(e.target.value)))} - sx={{ width: 90 }} - inputProps={{ min: 1 }} - /> - removeEditItem(idx)}> - - - - ))} - - - ) : ( - /* ── View Mode ── */ - <> - {/* Meta info */} - - {(anfrage!.anfrager_name || anfrage!.fuer_benutzer_name) && ( - - Anfrage für - - {anfrage!.fuer_benutzer_name - ? `${anfrage!.fuer_benutzer_name} (erstellt von ${anfrage!.anfrager_name || 'Unbekannt'})` - : anfrage!.anfrager_name} - - - )} - - Erstellt am - {new Date(anfrage!.erstellt_am).toLocaleDateString('de-AT')} - - {anfrage!.bearbeitet_von_name && ( - - Bearbeitet von - {anfrage!.bearbeitet_von_name} - - )} - - - {anfrage!.notizen && ( - - Notizen - {anfrage!.notizen} - - )} - {anfrage!.admin_notizen && ( - - Admin Notizen - {anfrage!.admin_notizen} - - )} - - - - {/* Positionen */} - Positionen ({detail.positionen.length}) - - - - {showAdminActions && Geliefert} - Artikel - Menge - Details - - - - {detail.positionen.map(p => ( - - {showAdminActions && ( - - geliefertMut.mutate({ positionId: p.id, geliefert: checked })} - /> - - )} - - {p.bezeichnung} - {p.eigenschaften && p.eigenschaften.length > 0 && ( - - {p.eigenschaften.map(e => ( - - ))} - - )} - - - {p.menge}x - - - {p.notizen && {p.notizen}} - - - ))} - -
- - {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && ( - <> - - Verknüpfte Bestellungen - - {detail.linked_bestellungen.map(b => ( - - ))} - - - )} - - )} -
- - {editing ? ( - <> - - - - ) : ( - <> - {/* Admin actions */} - {showAdminActions && anfrage && anfrage.status === 'offen' && ( - <> - - - - )} - {showAdminActions && anfrage && hasPermission('ausruestungsanfrage:approve') && ( - - - Status ändern - - - - )} - {showAdminActions && anfrage && anfrage.status === 'genehmigt' && hasPermission('ausruestungsanfrage:link_orders') && ( - - )} - {(showEditButton || canEditAny) && canEdit && !editing && ( - - )} - - - )} - -
- - {/* Approve/Reject sub-dialog */} - setActionDialog(null)} maxWidth="sm" fullWidth> - {actionDialog?.action === 'genehmigt' ? 'Anfrage genehmigen' : 'Anfrage ablehnen'} - - setAdminNotizen(e.target.value)} - multiline - rows={2} - /> - - - - - - - - {/* Link to order sub-dialog */} - { setLinkDialog(false); setSelectedBestellung(null); }} maxWidth="sm" fullWidth> - Mit Bestellung verknüpfen - - `#${o.id} – ${o.bezeichnung}`} - value={selectedBestellung} - onChange={(_, v) => setSelectedBestellung(v)} - renderInput={params => } - /> - - - - - - - - ); -} - // ─── 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(''); 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 ( - {/* Filter */} - {/* Catalog table */} {isLoading ? ( Lade Katalog... ) : items.length === 0 ? ( @@ -913,7 +408,6 @@ function KatalogTab() { )} - {/* Artikel create/edit dialog */} setArtikelDialogOpen(false)} maxWidth="sm" fullWidth> {editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'} @@ -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() { - {/* Kategorie management dialog */} setKategorieDialogOpen(false)} /> - {/* FAB for new catalog item */} {canManage && ( @@ -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(null); const [statusFilter, setStatusFilter] = useState(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([{ bezeichnung: '', menge: 1 }]); - // Track loaded eigenschaften per item row (by artikel_id) - const [itemEigenschaften, setItemEigenschaften] = useState>({}); - const itemEigenschaftenRef = useRef(itemEigenschaften); - itemEigenschaftenRef.current = itemEigenschaften; - // Track eigenschaft values per item row index - const [itemEigenschaftValues, setItemEigenschaftValues] = useState>>({}); - // 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 }} > - Aktive Anfragen - Alle - {(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => ( - {AUSRUESTUNG_STATUS_LABELS[s]} - ))} + Aktive Anfragen + Alle + {(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => ( + {AUSRUESTUNG_STATUS_LABELS[s]} + ))} @@ -1152,7 +538,7 @@ function MeineAnfragenTab() { {filteredRequests.map(r => ( - setDetailId(r.id)}> + navigate('/ausruestungsanfrage/' + r.id)}> {formatOrderId(r)} {r.bezeichnung || '-'} @@ -1165,157 +551,8 @@ function MeineAnfragenTab() { )} - {/* Detail Modal */} - setDetailId(null)} - showEditButton - canEditAny={canEditAny} - currentUserId={user?.id} - /> - - {/* Create Request Dialog */} - { setCreateDialogOpen(false); resetCreateForm(); }} maxWidth="sm" fullWidth> - Neue Bestellung - - setNewBezeichnung(e.target.value)} - fullWidth - /> - {canOrderForUser && ( - 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 => } - /> - )} - setNewNotizen(e.target.value)} - multiline - rows={2} - fullWidth - /> - - Aus Katalog - {newItems.map((item, idx) => ( - - - 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 => } - sx={{ flexGrow: 1 }} - /> - setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} - sx={{ width: 90 }} - inputProps={{ min: 1 }} - /> - setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}> - - - - {/* Eigenschaft fields for this item */} - {item.artikel_id && itemEigenschaften[item.artikel_id] && itemEigenschaften[item.artikel_id].length > 0 && ( - setItemEigenschaftValues(prev => ({ - ...prev, - [idx]: { ...(prev[idx] || {}), [eid]: wert }, - }))} - /> - )} - - ))} - - - - Freitext-Positionen - {newFreeItems.length === 0 ? ( - Keine Freitext-Positionen. - ) : ( - newFreeItems.map((item, idx) => ( - - setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: e.target.value } : it))} - sx={{ flexGrow: 1 }} - /> - setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} - sx={{ width: 90 }} - inputProps={{ min: 1 }} - /> - setNewFreeItems(prev => prev.filter((_, i) => i !== idx))}> - - - - )) - )} - - - - - - - - - {/* FAB for creating new request */} {canCreate && ( - setCreateDialogOpen(true)} aria-label="Neue Anfrage erstellen"> + navigate('/ausruestungsanfrage/neu')} aria-label="Neue Anfrage erstellen"> )} @@ -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('alle'); - const [detailId, setDetailId] = useState(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 ( - {/* Summary cards — always visible */} @@ -1409,7 +641,7 @@ function AlleAnfragenTab() { {requests.map(r => ( - setDetailId(r.id)}> + navigate('/ausruestungsanfrage/' + r.id)}> {formatOrderId(r)} {r.bezeichnung || '-'} {r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id} @@ -1427,15 +659,6 @@ function AlleAnfragenTab() { )} - - setDetailId(null)} - showAdminActions - showEditButton - canEditAny={canEditAny} - currentUserId={user?.id} - /> ); } @@ -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); diff --git a/frontend/src/pages/AusruestungsanfrageDetail.tsx b/frontend/src/pages/AusruestungsanfrageDetail.tsx new file mode 100644 index 0000000..fdd4fd4 --- /dev/null +++ b/frontend/src/pages/AusruestungsanfrageDetail.tsx @@ -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([]); + + // 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(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({ + 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 ( + + {/* Header */} + + navigate('/ausruestungsanfrage')}> + + + + Anfrage {anfrage ? formatOrderId(anfrage) : '...'} + {anfrage?.bezeichnung && ` — ${anfrage.bezeichnung}`} + + {anfrage && ( + + )} + + + {isLoading ? ( + + ) : isError ? ( + Fehler beim Laden der Anfrage. + ) : !detail ? ( + Anfrage nicht gefunden. + ) : editing ? ( + /* ── Edit Mode ── */ + + + setEditBezeichnung(e.target.value)} + fullWidth + /> + setEditNotizen(e.target.value)} + multiline + rows={2} + fullWidth + /> + Positionen + {editItems.map((item, idx) => ( + + 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 => } + sx={{ flexGrow: 1 }} + /> + updateEditItem(idx, 'menge', Math.max(1, Number(e.target.value)))} + sx={{ width: 90 }} + inputProps={{ min: 1 }} + /> + removeEditItem(idx)}> + + + + ))} + + + + + + + + ) : ( + /* ── View Mode ── */ + <> + + {/* Meta info */} + + {(anfrage!.anfrager_name || anfrage!.fuer_benutzer_name) && ( + + Anfrage für + + {anfrage!.fuer_benutzer_name + ? `${anfrage!.fuer_benutzer_name} (erstellt von ${anfrage!.anfrager_name || 'Unbekannt'})` + : anfrage!.anfrager_name} + + + )} + + Erstellt am + {new Date(anfrage!.erstellt_am).toLocaleDateString('de-AT')} + + {anfrage!.bearbeitet_von_name && ( + + Bearbeitet von + {anfrage!.bearbeitet_von_name} + + )} + + + {anfrage!.notizen && ( + + Notizen + {anfrage!.notizen} + + )} + {anfrage!.admin_notizen && ( + + Admin Notizen + {anfrage!.admin_notizen} + + )} + + + {/* Positionen */} + + Positionen ({detail.positionen.length}) + + + + {showAdminActions && Geliefert} + Artikel + Menge + Details + + + + {detail.positionen.map(p => ( + + {showAdminActions && ( + + geliefertMut.mutate({ positionId: p.id, geliefert: checked })} + /> + + )} + + {p.bezeichnung} + {p.eigenschaften && p.eigenschaften.length > 0 && ( + + {p.eigenschaften.map(e => ( + + ))} + + )} + + + {p.menge}x + + + {p.notizen && {p.notizen}} + + + ))} + +
+
+ + {/* Linked Bestellungen */} + {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && ( + + Verknüpfte Bestellungen + + {detail.linked_bestellungen.map(b => ( + + ))} + + + )} + + {/* Action buttons */} + + {showAdminActions && anfrage && anfrage.status === 'offen' && ( + <> + + + + )} + {showAdminActions && anfrage && ( + + Status ändern + + + )} + {showAdminActions && anfrage && anfrage.status === 'genehmigt' && canLink && ( + + )} + {canEdit && !editing && ( + + )} + + + )} + + {/* Approve/Reject sub-dialog */} + setActionDialog(null)} maxWidth="sm" fullWidth> + {actionDialog?.action === 'genehmigt' ? 'Anfrage genehmigen' : 'Anfrage ablehnen'} + + setAdminNotizen(e.target.value)} + multiline + rows={2} + /> + + + + + + + + {/* Link to order sub-dialog */} + { setLinkDialog(false); setSelectedBestellung(null); }} maxWidth="sm" fullWidth> + Mit Bestellung verknüpfen + + `#${o.id} – ${o.bezeichnung}`} + value={selectedBestellung} + onChange={(_, v) => setSelectedBestellung(v)} + renderInput={params => } + /> + + + + + + +
+ ); +} diff --git a/frontend/src/pages/AusruestungsanfrageNeu.tsx b/frontend/src/pages/AusruestungsanfrageNeu.tsx new file mode 100644 index 0000000..5201112 --- /dev/null +++ b/frontend/src/pages/AusruestungsanfrageNeu.tsx @@ -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; + onChange: (eigenschaftId: number, wert: string) => void; +} + +function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) { + if (eigenschaften.length === 0) return null; + return ( + + {eigenschaften.map(e => ( + + {e.typ === 'options' && e.optionen && e.optionen.length > 0 ? ( + onChange(e.id, ev.target.value)} + required={e.pflicht} + sx={{ minWidth: 160 }} + > + + {e.optionen.map(opt => ( + {opt} + ))} + + ) : ( + onChange(e.id, ev.target.value)} + required={e.pflicht} + sx={{ minWidth: 160 }} + /> + )} + + ))} + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// 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([{ bezeichnung: '', menge: 1 }]); + const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]); + + // Eigenschaften state + const [itemEigenschaften, setItemEigenschaften] = useState>({}); + const itemEigenschaftenRef = useRef(itemEigenschaften); + itemEigenschaftenRef.current = itemEigenschaften; + const [itemEigenschaftValues, setItemEigenschaftValues] = useState>>({}); + + // ── 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 ( + + {/* Header */} + + navigate('/ausruestungsanfrage')}> + + + Neue Bestellung + + + + + setBezeichnung(e.target.value)} + fullWidth + /> + {canOrderForUser && ( + 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 => } + /> + )} + setNotizen(e.target.value)} + multiline + rows={2} + fullWidth + /> + + + Aus Katalog + {catalogItems.map((item, idx) => ( + + + 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 => } + sx={{ flexGrow: 1 }} + /> + setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} + sx={{ width: 90 }} + inputProps={{ min: 1 }} + /> + setCatalogItems(prev => prev.filter((_, i) => i !== idx))} disabled={catalogItems.length <= 1}> + + + + {item.artikel_id && itemEigenschaften[item.artikel_id] && itemEigenschaften[item.artikel_id].length > 0 && ( + setItemEigenschaftValues(prev => ({ + ...prev, + [idx]: { ...(prev[idx] || {}), [eid]: wert }, + }))} + /> + )} + + ))} + + + + Freitext-Positionen + {freeItems.length === 0 ? ( + Keine Freitext-Positionen. + ) : ( + freeItems.map((item, idx) => ( + + setFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: e.target.value } : it))} + sx={{ flexGrow: 1 }} + /> + setFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} + sx={{ width: 90 }} + inputProps={{ min: 1 }} + /> + setFreeItems(prev => prev.filter((_, i) => i !== idx))}> + + + + )) + )} + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/BestellungNeu.tsx b/frontend/src/pages/BestellungNeu.tsx new file mode 100644 index 0000000..377fbd4 --- /dev/null +++ b/frontend/src/pages/BestellungNeu.tsx @@ -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({ ...emptyOrderForm }); + const [inlineVendorOpen, setInlineVendorOpen] = useState(false); + const [inlineVendorForm, setInlineVendorForm] = useState({ ...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 ( + + {/* ── Header ── */} + + navigate('/bestellungen')}> + + + Neue Bestellung + + + {/* ── Form ── */} + + setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))} + /> + + {/* Lieferant + inline create */} + + o.name} + value={vendors.find((v) => v.id === orderForm.lieferant_id) || null} + onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))} + renderInput={(params) => } + sx={{ flexGrow: 1 }} + /> + + setInlineVendorOpen(!inlineVendorOpen)} + color={inlineVendorOpen ? 'primary' : 'default'} + sx={{ mt: 1 }} + > + + + + + {inlineVendorOpen && ( + + Neuer Lieferant + setInlineVendorForm((f) => ({ ...f, name: e.target.value }))} /> + setInlineVendorForm((f) => ({ ...f, kontakt_name: e.target.value }))} /> + setInlineVendorForm((f) => ({ ...f, email: e.target.value }))} /> + setInlineVendorForm((f) => ({ ...f, telefon: e.target.value }))} /> + + + + + + )} + + o.name} + value={orderUsers.find((u) => u.id === orderForm.besteller_id) || null} + onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))} + renderInput={(params) => } + /> + + setOrderForm((f) => ({ ...f, budget: e.target.value ? Number(e.target.value) : undefined }))} + inputProps={{ min: 0, step: 0.01 }} + /> + + setOrderForm((f) => ({ ...f, notizen: e.target.value }))} + /> + + {/* ── Positionen ── */} + Positionen + {(orderForm.positionen || []).map((pos, idx) => ( + + { + const next = [...(orderForm.positionen || [])]; + next[idx] = { ...next[idx], bezeichnung: e.target.value }; + setOrderForm((f) => ({ ...f, positionen: next })); + }} + sx={{ flexGrow: 1 }} + /> + { + 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 }} + /> + { + const next = [...(orderForm.positionen || [])]; + next[idx] = { ...next[idx], einheit: e.target.value }; + setOrderForm((f) => ({ ...f, positionen: next })); + }} + sx={{ width: 100 }} + /> + { + const next = (orderForm.positionen || []).filter((_, i) => i !== idx); + setOrderForm((f) => ({ ...f, positionen: next })); + }} + > + + + + ))} + + + {/* ── Submit ── */} + + + + + + + ); +} diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index ab19333..48307e9 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -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(''); - const [orderDialogOpen, setOrderDialogOpen] = useState(false); - const [orderForm, setOrderForm] = useState({ ...emptyOrderForm }); - const [inlineVendorOpen, setInlineVendorOpen] = useState(false); - const [inlineVendorForm, setInlineVendorForm] = useState({ ...emptyVendorForm }); const [vendorDialogOpen, setVendorDialogOpen] = useState(false); const [vendorForm, setVendorForm] = useState({ ...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() { {hasPermission('bestellungen:create') && ( - setOrderDialogOpen(true)} aria-label="Neue Bestellung"> + navigate('/bestellungen/neu')} aria-label="Neue Bestellung"> )} @@ -330,135 +294,6 @@ export default function Bestellungen() { )} - {/* ── Create Order Dialog ── */} - setOrderDialogOpen(false)} maxWidth="md" fullWidth> - Neue Bestellung - - setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))} - /> - - o.name} - value={vendors.find((v) => v.id === orderForm.lieferant_id) || null} - onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))} - renderInput={(params) => } - sx={{ flexGrow: 1 }} - /> - - setInlineVendorOpen(!inlineVendorOpen)} - color={inlineVendorOpen ? 'primary' : 'default'} - sx={{ mt: 1 }} - > - - - - - {inlineVendorOpen && ( - - Neuer Lieferant - setInlineVendorForm(f => ({ ...f, name: e.target.value }))} /> - setInlineVendorForm(f => ({ ...f, kontakt_name: e.target.value }))} /> - setInlineVendorForm(f => ({ ...f, email: e.target.value }))} /> - setInlineVendorForm(f => ({ ...f, telefon: e.target.value }))} /> - - - - - - )} - o.name} - value={orderUsers.find((u) => u.id === orderForm.besteller_id) || null} - onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))} - renderInput={(params) => } - /> - setOrderForm((f) => ({ ...f, notizen: e.target.value }))} - /> - - {/* ── Dynamic Items List ── */} - Positionen - {(orderForm.positionen || []).map((pos, idx) => ( - - { - const next = [...(orderForm.positionen || [])]; - next[idx] = { ...next[idx], bezeichnung: e.target.value }; - setOrderForm((f) => ({ ...f, positionen: next })); - }} - sx={{ flexGrow: 1 }} - /> - { - 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 }} - /> - { - const next = [...(orderForm.positionen || [])]; - next[idx] = { ...next[idx], einheit: e.target.value }; - setOrderForm((f) => ({ ...f, positionen: next })); - }} - sx={{ width: 100 }} - /> - { - const next = (orderForm.positionen || []).filter((_, i) => i !== idx); - setOrderForm((f) => ({ ...f, positionen: next })); - }} - > - - - - ))} - - - - - - - - {/* ── Create/Edit Vendor Dialog ── */} {editingVendor ? 'Lieferant bearbeiten' : 'Neuer Lieferant'} diff --git a/frontend/src/pages/IssueDetail.tsx b/frontend/src/pages/IssueDetail.tsx new file mode 100644 index 0000000..1c44f62 --- /dev/null +++ b/frontend/src/pages/IssueDetail.tsx @@ -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 = { + offen: 'info', in_bearbeitung: 'warning', erledigt: 'success', abgelehnt: 'error', +}; +const STATUS_LABELS: Record = { + offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', abgelehnt: 'Abgelehnt', +}; +const PRIO_COLORS: Record = { + hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e', +}; +const PRIO_LABELS: Record = { + 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 = { + BugReport: , + FiberNew: , + HelpOutline: , +}; + +function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element { + const icon = ICON_MAP[iconName || ''] || ; + const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action'; + return {icon}; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// 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 ( + + + + ); + } + + if (isError || !issue) { + return ( + + + + Issue nicht gefunden. + + + ); + } + + return ( + + + {/* Header */} + + navigate('/issues')}> + + + + + {formatIssueId(issue)} — {issue.titel} + + + + + + {/* Info cards */} + + + + + Typ + + {getTypIcon(issue.typ_icon, issue.typ_farbe)} + {issue.typ_name} + + + + + + + + Priorität + + + {getPrioLabel(priorities, issue.prioritaet)} + + + + + + + + Erstellt von + {issue.erstellt_von_name || '-'} + + + + + + + Zugewiesen an + {issue.zugewiesen_an_name || '-'} + + + + + + + Erstellt am + {formatDate(issue.created_at)} + + + + + + {/* Description */} + {issue.beschreibung && ( + + Beschreibung + {issue.beschreibung} + + )} + + {/* Controls row */} + + {/* Status control */} + {showReopenButton ? ( + + ) : canChangeStatus || isOwner ? ( + + Status + + + ) : null} + + {/* Priority control */} + {hasEdit && ( + + Priorität + + + )} + + {/* Assignment */} + {hasEdit && ( + 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) => } + isOptionEqualToValue={(o, v) => o.id === v.id} + /> + )} + + + {/* Delete button */} + {canDelete && ( + + )} + + + + {/* Comments section */} + Kommentare + {commentsLoading ? ( + + ) : comments.length === 0 ? ( + Noch keine Kommentare + ) : ( + comments.map((c: IssueComment) => ( + + + {c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)} + + {c.inhalt} + + )) + )} + {canComment && ( + + setCommentText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && commentText.trim()) { + e.preventDefault(); + addCommentMut.mutate(commentText.trim()); + } + }} + multiline + maxRows={4} + /> + addCommentMut.mutate(commentText.trim())} + > + + + + )} + + + {/* Reopen Dialog */} + setReopenOpen(false)} maxWidth="sm" fullWidth> + Issue wiedereröffnen + + setReopenComment(e.target.value)} + autoFocus + /> + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteOpen(false)} maxWidth="xs" fullWidth> + Issue löschen + + Soll dieses Issue wirklich gelöscht werden? + + + + + + + + ); +} diff --git a/frontend/src/pages/IssueNeu.tsx b/frontend/src/pages/IssueNeu.tsx new file mode 100644 index 0000000..26b51f6 --- /dev/null +++ b/frontend/src/pages/IssueNeu.tsx @@ -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({ 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 ( + + + {/* Header */} + + navigate('/issues')}> + + + Neues Issue + + + + + setForm({ ...form, titel: e.target.value })} + autoFocus + /> + setForm({ ...form, beschreibung: e.target.value })} + /> + + Typ + + + + Priorität + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index 85dbf67..6118e34 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -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 {children}; } -// ── 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 ( - - Kommentare - {isLoading ? ( - - ) : comments.length === 0 ? ( - Noch keine Kommentare - ) : ( - comments.map((c: IssueComment) => ( - - - {c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)} - - {c.inhalt} - - )) - )} - {canComment && ( - - setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && text.trim()) { - e.preventDefault(); - addMut.mutate(text.trim()); - } - }} - multiline - maxRows={4} - /> - addMut.mutate(text.trim())} - > - - - - )} - - ); -} - -// ── 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 ( - <> - *': { borderBottom: expanded ? 'unset' : undefined } }} - onClick={() => setExpanded(!expanded)} - > - {formatIssueId(issue)} - - - {getTypIcon(issue.typ_icon, issue.typ_farbe)} - {issue.titel} - - - - - - - - - {getPrioLabel(priorities, issue.prioritaet)} - - - - - - {issue.erstellt_von_name || '-'} - {issue.zugewiesen_an_name || '-'} - {formatDate(issue.created_at)} - - { e.stopPropagation(); setExpanded(!expanded); }}> - {expanded ? : } - - - - - - - - {issue.beschreibung && ( - - {issue.beschreibung} - - )} - - - {/* Status control */} - {showReopenButton ? ( - - ) : canChangeStatus || isOwner ? ( - - Status - - - ) : null} - - {/* Priority control — only with issues:edit */} - {hasEdit && ( - - Priorität - - - )} - - {/* Assignment — only with issues:edit */} - {hasEdit && ( - o.name} - value={members.find(m => m.id === issue.zugewiesen_an) || null} - onChange={(_e, val) => updateMut.mutate({ zugewiesen_an: val?.id || null })} - renderInput={(params) => } - onClick={(e: React.MouseEvent) => e.stopPropagation()} - isOptionEqualToValue={(o, v) => o.id === v.id} - /> - )} - - - {canDelete && ( - - )} - - - - - - - - - {/* Reopen Dialog */} - setReopenOpen(false)} maxWidth="sm" fullWidth> - Issue wiedereröffnen - - setReopenComment(e.target.value)} - autoFocus - /> - - - - - - - - ); -} - // ── 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({ Erstellt von Zugewiesen an Erstellt am -
{issues.map((issue) => ( - deleteMut.mutate(id)} - /> + hover + sx={{ cursor: 'pointer' }} + onClick={() => navigate(`/issues/${issue.id}`)} + > + {formatIssueId(issue)} + + + {getTypIcon(issue.typ_icon, issue.typ_farbe)} + {issue.titel} + + + + + + + + + {getPrioLabel(priorities, issue.prioritaet)} + + + + + + {issue.erstellt_von_name || '-'} + {issue.zugewiesen_an_name || '-'} + {formatDate(issue.created_at)} +
))}
@@ -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({}); - const [createOpen, setCreateOpen] = useState(false); - const [form, setForm] = useState({ 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 ( @@ -972,7 +650,7 @@ export default function Issues() { {isLoading ? ( ) : ( - + )} @@ -986,7 +664,7 @@ export default function Issues() { {isLoading ? ( ) : ( - + )} @@ -997,7 +675,7 @@ export default function Issues() { {isFilteredLoading ? ( ) : ( - + )} )} @@ -1010,69 +688,12 @@ export default function Issues() { )} - {/* Create Issue Dialog */} - setCreateOpen(false)} maxWidth="sm" fullWidth> - Neues Issue erstellen - - setForm({ ...form, titel: e.target.value })} - autoFocus - /> - setForm({ ...form, beschreibung: e.target.value })} - /> - - Typ - - - - Priorität - - - - - - - - - {/* FAB */} - {canCreate && activeTab === 'mine' && ( + {canCreate && ( setCreateOpen(true)} + onClick={() => navigate('/issues/neu')} >