From feb39d234fc5b63e6b2e35780a31423effef57b7 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Wed, 25 Mar 2026 10:23:28 +0100 Subject: [PATCH] rework from modal to page --- backend/src/controllers/issue.controller.ts | 39 ++ .../migrations/059_issue_historie.sql | 11 + backend/src/routes/issue.routes.ts | 6 + .../services/ausruestungsanfrage.service.ts | 3 +- backend/src/services/issue.service.ts | 36 ++ frontend/src/App.tsx | 17 + frontend/src/pages/Ausruestungsanfrage.tsx | 219 +--------- .../AusruestungsanfrageArtikelDetail.tsx | 386 ++++++++++++++++++ frontend/src/pages/BestellungDetail.tsx | 71 +++- frontend/src/pages/IssueDetail.tsx | 40 +- frontend/src/pages/IssueNeu.tsx | 132 +++--- frontend/src/services/bestellung.ts | 2 +- frontend/src/services/issues.ts | 6 +- frontend/src/types/issue.types.ts | 10 + 14 files changed, 698 insertions(+), 280 deletions(-) create mode 100644 backend/src/database/migrations/059_issue_historie.sql create mode 100644 frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index a4bb239..631b7f4 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -167,6 +167,30 @@ class IssueController { return; } + // Log history entries for detected changes + const fieldLabels: Record = { + status: 'Status geändert', + prioritaet: 'Priorität geändert', + zugewiesen_an: 'Zuweisung geändert', + titel: 'Titel geändert', + beschreibung: 'Beschreibung geändert', + typ_id: 'Typ geändert', + }; + for (const [field, label] of Object.entries(fieldLabels)) { + if (field in updateData && updateData[field] !== existing[field]) { + const details: Record = { von: existing[field], zu: updateData[field] }; + if (field === 'zugewiesen_an') { + details.von_name = existing.zugewiesen_an_name || null; + details.zu_name = issue.zugewiesen_an_name || null; + } + if (field === 'status') { + details.von_label = existing.status; + details.zu_label = issue.status; + } + issueService.addHistoryEntry(id, label, details, userId); + } + } + // Handle reopen comment (owner reopen flow) if (isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt' && req.body.kommentar) { await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`); @@ -280,6 +304,21 @@ class IssueController { // --- Type management --- + async getHistory(req: Request, res: Response): Promise { + const issueId = parseInt(param(req, 'id'), 10); + if (isNaN(issueId)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const history = await issueService.getHistory(issueId); + res.status(200).json({ success: true, data: history }); + } catch (error) { + logger.error('IssueController.getHistory error', { error }); + res.status(500).json({ success: false, message: 'Historie konnte nicht geladen werden' }); + } + } + async getTypes(_req: Request, res: Response): Promise { try { const types = await issueService.getTypes(); diff --git a/backend/src/database/migrations/059_issue_historie.sql b/backend/src/database/migrations/059_issue_historie.sql new file mode 100644 index 0000000..1fb1e8c --- /dev/null +++ b/backend/src/database/migrations/059_issue_historie.sql @@ -0,0 +1,11 @@ +-- Issue change history +CREATE TABLE IF NOT EXISTS issue_historie ( + id SERIAL PRIMARY KEY, + issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + aktion VARCHAR(100) NOT NULL, + details JSONB, + erstellt_von UUID REFERENCES users(id), + erstellt_am TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_issue_historie_issue_id ON issue_historie(issue_id); diff --git a/backend/src/routes/issue.routes.ts b/backend/src/routes/issue.routes.ts index fe7e25d..59806ca 100644 --- a/backend/src/routes/issue.routes.ts +++ b/backend/src/routes/issue.routes.ts @@ -80,6 +80,12 @@ router.get( issueController.getComments.bind(issueController) ); +router.get( + '/:id/history', + authenticate, + issueController.getHistory.bind(issueController) +); + router.post( '/:id/comments', authenticate, diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index f41e8ec..7aa1097 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -318,7 +318,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string }) async function getMyRequests(userId: string) { const result = await pool.query( `SELECT a.*, - (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count + (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count, + (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count FROM ausruestung_anfragen a WHERE a.anfrager_id = $1 ORDER BY a.erstellt_am DESC`, diff --git a/backend/src/services/issue.service.ts b/backend/src/services/issue.service.ts index 8b2dcca..b1c1fa5 100644 --- a/backend/src/services/issue.service.ts +++ b/backend/src/services/issue.service.ts @@ -188,6 +188,40 @@ async function updateIssue( } } +async function addHistoryEntry( + issueId: number, + aktion: string, + details: Record | null, + userId?: string, +) { + try { + await pool.query( + `INSERT INTO issue_historie (issue_id, aktion, details, erstellt_von) + VALUES ($1, $2, $3, $4)`, + [issueId, aktion, details ? JSON.stringify(details) : null, userId || null], + ); + } catch (error) { + logger.error('IssueService.addHistoryEntry failed', { error, issueId }); + } +} + +async function getHistory(issueId: number) { + try { + const result = await pool.query( + `SELECT h.*, u.name AS erstellt_von_name + FROM issue_historie h + LEFT JOIN users u ON u.id = h.erstellt_von + WHERE h.issue_id = $1 + ORDER BY h.erstellt_am DESC`, + [issueId], + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getHistory failed', { error, issueId }); + return []; + } +} + async function deleteIssue(id: number) { try { const result = await pool.query( @@ -575,6 +609,8 @@ export default { getAssignableMembers, getIssueCounts, getIssueStatuses, + addHistoryEntry, + getHistory, createIssueStatus, updateIssueStatus, deleteIssueStatus, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41ad05b..f079054 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import BestellungDetail from './pages/BestellungDetail'; import BestellungNeu from './pages/BestellungNeu'; import Ausruestungsanfrage from './pages/Ausruestungsanfrage'; import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail'; +import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import Issues from './pages/Issues'; import IssueDetail from './pages/IssueDetail'; @@ -257,6 +258,22 @@ function App() { } /> + + + + } + /> + + + + } + /> ('options'); - const [newOptionen, setNewOptionen] = useState(''); - const [newPflicht, setNewPflicht] = useState(false); - - const { data: eigenschaften = [] } = useQuery({ - queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId], - queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!), - enabled: artikelId != null, - }); - - const upsertMut = useMutation({ - mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) => - ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gespeichert'); }, - onError: () => showError('Fehler beim Speichern'), - }); - - const deleteMut = useMutation({ - mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gelöscht'); }, - onError: () => showError('Fehler beim Löschen'), - }); - - const handleAdd = () => { - if (!newName.trim()) return; - const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined; - upsertMut.mutate({ - name: newName.trim(), - typ: newTyp, - optionen, - pflicht: newPflicht, - sort_order: eigenschaften.length, - }); - setNewName(''); - setNewOptionen(''); - setNewPflicht(false); - }; - - if (artikelId == null) return Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufügen.; - - return ( - - Eigenschaften - {eigenschaften.map(e => ( - - - {e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'}) - {e.pflicht && } - - deleteMut.mutate(e.id)}> - - ))} - - - setNewName(e.target.value)} sx={{ flexGrow: 1 }} /> - setNewTyp(e.target.value as 'options' | 'freitext')} - sx={{ minWidth: 120 }} - > - Auswahl - Freitext - - setNewPflicht(e.target.checked)} />} - label="Pflicht" - /> - - {newTyp === 'options' && ( - setNewOptionen(e.target.value)} - placeholder="S, M, L, XL" - fullWidth - /> - )} - - - - ); -} - // ─── Catalog Tab ──────────────────────────────────────────────────────────── function KatalogTab() { const { showSuccess, showError } = useNotification(); const { hasPermission } = usePermissionContext(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories'); const [filterKategorie, setFilterKategorie] = useState(''); - const [artikelDialogOpen, setArtikelDialogOpen] = useState(false); - const [editArtikel, setEditArtikel] = useState(null); - const [artikelForm, setArtikelForm] = useState({ bezeichnung: '' }); const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false); const { data: items = [], isLoading } = useQuery({ @@ -285,9 +177,6 @@ function KatalogTab() { queryFn: () => ausruestungsanfrageApi.getKategorien(), }); - const topKategorien = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]); - const subKategorienOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]); - 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 => { @@ -300,48 +189,12 @@ function KatalogTab() { return kategorien.map(k => ({ id: k.id, name: getDisplayName(k), isChild: !!k.parent_id })); }, [kategorien]); - const [artikelMainKat, setArtikelMainKat] = useState(''); - const artikelSubKats = useMemo(() => artikelMainKat ? subKategorienOf(artikelMainKat as number) : [], [artikelMainKat, subKategorienOf]); - - const createItemMut = useMutation({ - mutationFn: (data: AusruestungArtikelFormData) => ausruestungsanfrageApi.createItem(data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel erstellt'); setArtikelDialogOpen(false); }, - onError: () => showError('Fehler beim Erstellen'), - }); - const updateItemMut = useMutation({ - mutationFn: ({ id, data }: { id: number; data: Partial }) => ausruestungsanfrageApi.updateItem(id, data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); }, - onError: () => showError('Fehler beim Aktualisieren'), - }); const deleteItemMut = useMutation({ mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel gelöscht'); }, - onError: () => showError('Fehler beim Löschen'), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel geloescht'); }, + onError: () => showError('Fehler beim Loeschen'), }); - const openNewArtikel = () => { - setEditArtikel(null); - setArtikelForm({ bezeichnung: '' }); - setArtikelMainKat(''); - setArtikelDialogOpen(true); - }; - const openEditArtikel = (a: AusruestungArtikel) => { - setEditArtikel(a); - setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie_id: a.kategorie_id ?? null }); - const kat = kategorien.find(k => k.id === a.kategorie_id); - if (kat?.parent_id) { - setArtikelMainKat(kat.parent_id); - } else { - setArtikelMainKat(a.kategorie_id || ''); - } - setArtikelDialogOpen(true); - }; - const saveArtikel = () => { - if (!artikelForm.bezeichnung.trim()) return; - if (editArtikel) updateItemMut.mutate({ id: editArtikel.id, data: artikelForm }); - else createItemMut.mutate(artikelForm); - }; - return ( @@ -382,7 +235,12 @@ function KatalogTab() { {items.map(item => ( - + navigate(`/ausruestungsanfrage/artikel/${item.id}`)} + > {item.bezeichnung} @@ -397,8 +255,7 @@ function KatalogTab() { {canManage && ( - openEditArtikel(item)}> - deleteItemMut.mutate(item.id)}> + { e.stopPropagation(); deleteItemMut.mutate(item.id); }}> )} @@ -408,56 +265,10 @@ function KatalogTab() { )} - setArtikelDialogOpen(false)} maxWidth="sm" fullWidth> - {editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'} - - setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth /> - setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} /> - { - const val = e.target.value ? Number(e.target.value) : ''; - setArtikelMainKat(val); - if (val) { - const subs = subKategorienOf(val as number); - setArtikelForm(f => ({ ...f, kategorie_id: subs.length === 0 ? (val as number) : null })); - } else { - setArtikelForm(f => ({ ...f, kategorie_id: null })); - } - }} - fullWidth - > - Keine - {topKategorien.map(k => {k.name})} - - {artikelMainKat && artikelSubKats.length > 0 && ( - setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : (artikelMainKat as number) }))} - fullWidth - > - Keine (nur Hauptkategorie) - {artikelSubKats.map(k => {k.name})} - - )} - {canManage && } - - - - - - - setKategorieDialogOpen(false)} /> {canManage && ( - + navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Artikel hinzufuegen"> )} @@ -533,6 +344,7 @@ function MeineAnfragenTab() { Bezeichnung Status Positionen + Geliefert Erstellt am @@ -543,6 +355,11 @@ function MeineAnfragenTab() { {r.bezeichnung || '-'} {r.positionen_count ?? r.items_count ?? '-'} + + {r.positionen_count != null && r.positionen_count > 0 + ? `${r.geliefert_count ?? 0}/${r.positionen_count}` + : '-'} + {new Date(r.erstellt_am).toLocaleDateString('de-AT')} ))} diff --git a/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx new file mode 100644 index 0000000..be60c8c --- /dev/null +++ b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx @@ -0,0 +1,386 @@ +import { useState, useMemo, useCallback } from 'react'; +import { + Box, Typography, Paper, Button, TextField, IconButton, + Chip, MenuItem, Divider, Checkbox, FormControlLabel, LinearProgress, +} from '@mui/material'; +import { + ArrowBack, Edit as EditIcon, Delete as DeleteIcon, + Add as AddIcon, +} 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 { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; +import type { + AusruestungArtikel, AusruestungArtikelFormData, + AusruestungEigenschaft, AusruestungKategorie, +} from '../types/ausruestungsanfrage.types'; + +// ── EigenschaftenEditor ── + +function EigenschaftenEditor({ artikelId }: { artikelId: number | null }) { + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + + const [newName, setNewName] = useState(''); + const [newTyp, setNewTyp] = useState<'options' | 'freitext'>('options'); + const [newOptionen, setNewOptionen] = useState(''); + const [newPflicht, setNewPflicht] = useState(false); + + const { data: eigenschaften = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId], + queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!), + enabled: artikelId != null, + }); + + const upsertMut = useMutation({ + mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) => + ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gespeichert'); }, + onError: () => showError('Fehler beim Speichern'), + }); + + const deleteMut = useMutation({ + mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft geloescht'); }, + onError: () => showError('Fehler beim Loeschen'), + }); + + const handleAdd = () => { + if (!newName.trim()) return; + const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined; + upsertMut.mutate({ + name: newName.trim(), + typ: newTyp, + optionen, + pflicht: newPflicht, + sort_order: eigenschaften.length, + }); + setNewName(''); + setNewOptionen(''); + setNewPflicht(false); + }; + + if (artikelId == null) return Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufuegen.; + + return ( + + Eigenschaften + {eigenschaften.map(e => ( + + + {e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'}) + {e.pflicht && } + + deleteMut.mutate(e.id)}> + + ))} + + + setNewName(e.target.value)} sx={{ flexGrow: 1 }} /> + setNewTyp(e.target.value as 'options' | 'freitext')} + sx={{ minWidth: 120 }} + > + Auswahl + Freitext + + setNewPflicht(e.target.checked)} />} + label="Pflicht" + /> + + {newTyp === 'options' && ( + setNewOptionen(e.target.value)} + placeholder="S, M, L, XL" + fullWidth + /> + )} + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Main Component +// ══════════════════════════════════════════════════════════════════════════════ + +export default function AusruestungsanfrageArtikelDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + + const isCreate = !id || id === 'neu'; + const artikelId = isCreate ? null : Number(id); + const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); + + // ── State ── + const [editing, setEditing] = useState(isCreate); + const [form, setForm] = useState({ bezeichnung: '' }); + const [mainKat, setMainKat] = useState(''); + + // ── Queries ── + const { data: artikel, isLoading, isError } = useQuery({ + queryKey: ['ausruestungsanfrage', 'item', artikelId], + queryFn: () => ausruestungsanfrageApi.getItem(artikelId!), + enabled: artikelId != null, + retry: 1, + }); + + const { data: kategorien = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'kategorien'], + queryFn: () => ausruestungsanfrageApi.getKategorien(), + }); + + const { data: eigenschaften = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId], + queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!), + enabled: artikelId != null, + }); + + const topKategorien = useMemo(() => kategorien.filter(k => !k.parent_id), [kategorien]); + const subKategorienOf = useCallback((parentId: number) => kategorien.filter(k => k.parent_id === parentId), [kategorien]); + const subKats = useMemo(() => mainKat ? subKategorienOf(mainKat as number) : [], [mainKat, subKategorienOf]); + + const kategorieOptions = useMemo(() => { + const map = new Map(kategorien.map(k => [k.id, k])); + const getDisplayName = (k: AusruestungKategorie): string => { + if (k.parent_id) { + const parent = map.get(k.parent_id); + if (parent) return `${parent.name} > ${k.name}`; + } + return k.name; + }; + return kategorien.map(k => ({ id: k.id, name: getDisplayName(k) })); + }, [kategorien]); + + // ── Mutations ── + const createMut = useMutation({ + mutationFn: (data: AusruestungArtikelFormData) => ausruestungsanfrageApi.createItem(data), + onSuccess: (newItem) => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + showSuccess('Artikel erstellt'); + navigate(`/ausruestungsanfrage/artikel/${newItem.id}`, { replace: true }); + }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const updateMut = useMutation({ + mutationFn: ({ itemId, data }: { itemId: number; data: Partial }) => + ausruestungsanfrageApi.updateItem(itemId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + showSuccess('Artikel aktualisiert'); + setEditing(false); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteMut = useMutation({ + mutationFn: (itemId: number) => ausruestungsanfrageApi.deleteItem(itemId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + showSuccess('Artikel geloescht'); + navigate('/ausruestungsanfrage?tab=2'); + }, + onError: () => showError('Fehler beim Loeschen'), + }); + + // ── Edit helpers ── + const startEditing = () => { + if (!artikel) return; + setForm({ + bezeichnung: artikel.bezeichnung, + beschreibung: artikel.beschreibung, + kategorie_id: artikel.kategorie_id ?? null, + }); + const kat = kategorien.find(k => k.id === artikel.kategorie_id); + if (kat?.parent_id) { + setMainKat(kat.parent_id); + } else { + setMainKat(artikel.kategorie_id || ''); + } + setEditing(true); + }; + + const handleSave = () => { + if (!form.bezeichnung.trim()) return; + if (isCreate) { + createMut.mutate(form); + } else if (artikelId) { + updateMut.mutate({ itemId: artikelId, data: form }); + } + }; + + const handleCancel = () => { + if (isCreate) { + navigate('/ausruestungsanfrage?tab=2'); + } else { + setEditing(false); + } + }; + + const getKategorieName = (katId?: number) => { + if (!katId) return '-'; + return kategorieOptions.find(k => k.id === katId)?.name || '-'; + }; + + return ( + + {/* Header */} + + navigate('/ausruestungsanfrage?tab=2')}> + + + + {isCreate ? 'Neuer Katalogartikel' : (artikel?.bezeichnung ?? '...')} + + {!isCreate && canManage && !editing && ( + <> + + { if (artikelId) deleteMut.mutate(artikelId); }}> + + + + )} + + + {!isCreate && isLoading ? ( + + ) : !isCreate && isError ? ( + Fehler beim Laden des Artikels. + ) : editing ? ( + /* ── Edit / Create Mode ── */ + + + setForm(f => ({ ...f, bezeichnung: e.target.value }))} + fullWidth + autoFocus + /> + setForm(f => ({ ...f, beschreibung: e.target.value }))} + fullWidth + /> + { + const val = e.target.value ? Number(e.target.value) : ''; + setMainKat(val); + if (val) { + const subs = subKategorienOf(val as number); + setForm(f => ({ ...f, kategorie_id: subs.length === 0 ? (val as number) : null })); + } else { + setForm(f => ({ ...f, kategorie_id: null })); + } + }} + fullWidth + > + Keine + {topKategorien.map(k => {k.name})} + + {mainKat && subKats.length > 0 && ( + setForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : (mainKat as number) }))} + fullWidth + > + Keine (nur Hauptkategorie) + {subKats.map(k => {k.name})} + + )} + + {canManage && } + + + + + + + + + ) : artikel ? ( + /* ── View Mode ── */ + <> + + + + Bezeichnung + {artikel.bezeichnung} + + + Kategorie + {getKategorieName(artikel.kategorie_id)} + + {artikel.beschreibung && ( + + Beschreibung + {artikel.beschreibung} + + )} + + Status + + + + Erstellt am + {new Date(artikel.erstellt_am).toLocaleDateString('de-AT')} + + + + + {eigenschaften.length > 0 && ( + + Eigenschaften ({eigenschaften.length}) + {eigenschaften.map(e => ( + + + {e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'}) + {e.pflicht && } + + + ))} + + )} + + ) : null} + + ); +} diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index e1f58e6..c6d802e 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -22,6 +22,8 @@ import { CardContent, LinearProgress, Checkbox, + Menu, + MenuItem, } from '@mui/material'; import { ArrowBack, @@ -34,6 +36,7 @@ import { Alarm, History, Upload as UploadIcon, + ArrowDropDown, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -62,13 +65,15 @@ const formatFileSize = (bytes?: number) => { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; -// Status flow -const STATUS_FLOW: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen']; - -function getNextStatus(current: BestellungStatus): BestellungStatus | null { - const idx = STATUS_FLOW.indexOf(current); - return idx >= 0 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null; -} +// Valid status transitions (must match backend VALID_STATUS_TRANSITIONS) +const STATUS_TRANSITIONS: Record = { + entwurf: ['erstellt', 'bestellt'], + erstellt: ['bestellt'], + bestellt: ['teillieferung', 'vollstaendig'], + teillieferung: ['vollstaendig'], + vollstaendig: ['abgeschlossen'], + abgeschlossen: [], +}; // Empty line item form const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined }; @@ -91,7 +96,8 @@ export default function BestellungDetail() { const [newItem, setNewItem] = useState({ ...emptyItem }); const [editingItemId, setEditingItemId] = useState(null); const [editingItemData, setEditingItemData] = useState>({}); - const [statusConfirmOpen, setStatusConfirmOpen] = useState(false); + const [statusConfirmTarget, setStatusConfirmTarget] = useState(null); + const [statusMenuAnchor, setStatusMenuAnchor] = useState(null); const [deleteItemTarget, setDeleteItemTarget] = useState(null); const [deleteFileTarget, setDeleteFileTarget] = useState(null); @@ -113,7 +119,7 @@ export default function BestellungDetail() { const historie = data?.historie ?? []; const canEdit = hasPermission('bestellungen:create'); - const nextStatus = bestellung ? getNextStatus(bestellung.status) : null; + const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : []; // ── Mutations ── @@ -122,7 +128,7 @@ export default function BestellungDetail() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); showSuccess('Status aktualisiert'); - setStatusConfirmOpen(false); + setStatusConfirmTarget(null); }, onError: () => showError('Fehler beim Aktualisieren des Status'), }); @@ -322,11 +328,40 @@ export default function BestellungDetail() { {/* ── Status Action ── */} - {canEdit && nextStatus && ( + {canEdit && validTransitions.length > 0 && ( - + {validTransitions.length === 1 ? ( + + ) : ( + <> + + setStatusMenuAnchor(null)} + > + {validTransitions.map((s) => ( + { + setStatusMenuAnchor(null); + setStatusConfirmTarget(s); + }} + > + {BESTELLUNG_STATUS_LABELS[s]} + + ))} + + + )} )} @@ -630,17 +665,17 @@ export default function BestellungDetail() { {/* ══════════════════════════════════════════════════════════════════════ */} {/* Status Confirmation */} - setStatusConfirmOpen(false)}> + setStatusConfirmTarget(null)}> Status ändern Status von {BESTELLUNG_STATUS_LABELS[bestellung.status]} auf{' '} - {nextStatus ? BESTELLUNG_STATUS_LABELS[nextStatus] : ''} ändern? + {statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''} ändern? - - + diff --git a/frontend/src/pages/IssueDetail.tsx b/frontend/src/pages/IssueDetail.tsx index 1c44f62..88a9077 100644 --- a/frontend/src/pages/IssueDetail.tsx +++ b/frontend/src/pages/IssueDetail.tsx @@ -7,7 +7,7 @@ import { import { ArrowBack, Delete as DeleteIcon, BugReport, FiberNew, HelpOutline, Send as SendIcon, - Circle as CircleIcon, Refresh as RefreshIcon, + Circle as CircleIcon, Refresh as RefreshIcon, History, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -16,7 +16,7 @@ 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'; +import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie } from '../types/issue.types'; // ── Helpers (copied from Issues.tsx) ── @@ -117,6 +117,12 @@ export default function IssueDetail() { enabled: hasEdit, }); + const { data: historie = [] } = useQuery({ + queryKey: ['issues', issueId, 'history'], + queryFn: () => issuesApi.getHistory(issueId), + enabled: !isNaN(issueId), + }); + // ── Permissions ── const isOwner = issue?.erstellt_von === userId; const isAssignee = issue?.zugewiesen_an === userId; @@ -144,6 +150,7 @@ export default function IssueDetail() { mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issueId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issues'] }); + queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'history'] }); showSuccess('Issue aktualisiert'); }, onError: () => showError('Fehler beim Aktualisieren'), @@ -393,6 +400,35 @@ export default function IssueDetail() { )} + + {/* History section */} + + + + Historie + + {historie.length === 0 ? ( + Keine Einträge + ) : ( + + {historie.map((h) => ( + + + + {h.aktion} + + {h.erstellt_von_name || 'System'} · {formatDate(h.erstellt_am)} + + {h.details && ( + + {Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')} + + )} + + + ))} + + )} {/* Reopen Dialog */} diff --git a/frontend/src/pages/IssueNeu.tsx b/frontend/src/pages/IssueNeu.tsx index 26b51f6..feebc5f 100644 --- a/frontend/src/pages/IssueNeu.tsx +++ b/frontend/src/pages/IssueNeu.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; import { Box, Typography, Paper, Button, TextField, MenuItem, Select, FormControl, - InputLabel, IconButton, + InputLabel, IconButton, Grid, Collapse, } from '@mui/material'; -import { ArrowBack } from '@mui/icons-material'; +import { ArrowBack, Add as AddIcon } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; @@ -17,6 +17,7 @@ export default function IssueNeu() { const { showSuccess, showError } = useNotification(); const [form, setForm] = useState({ titel: '', prioritaet: '' }); + const [showDescription, setShowDescription] = useState(false); const { data: types = [] } = useQuery({ queryKey: ['issue-types'], @@ -51,25 +52,26 @@ export default function IssueNeu() { return ( - - {/* Header */} - - navigate('/issues')}> - - - Neues Issue - + {/* Header */} + + navigate('/issues')}> + + + Neues Issue + - - - setForm({ ...form, titel: e.target.value })} - autoFocus - /> + + + setForm({ ...form, titel: e.target.value })} + autoFocus + /> + + setForm({ ...form, beschreibung: e.target.value })} /> - - Typ - - - - Priorität - - - - - - + + {!showDescription && ( + + )} + + + + + Typ + + + + + + Prioritaet + + + + + + + + - - + + ); } diff --git a/frontend/src/services/bestellung.ts b/frontend/src/services/bestellung.ts index e7b8c55..1fe163d 100644 --- a/frontend/src/services/bestellung.ts +++ b/frontend/src/services/bestellung.ts @@ -76,7 +76,7 @@ export const bestellungApi = { await api.delete(`/api/bestellungen/items/${itemId}`); }, updateReceivedQty: async (itemId: number, menge: number): Promise => { - const r = await api.patch(`/api/bestellungen/items/${itemId}/received`, { erhalten_menge: menge }); + const r = await api.patch(`/api/bestellungen/items/${itemId}/received`, { menge }); return r.data.data; }, diff --git a/frontend/src/services/issues.ts b/frontend/src/services/issues.ts index f951248..d8319e6 100644 --- a/frontend/src/services/issues.ts +++ b/frontend/src/services/issues.ts @@ -1,5 +1,5 @@ import { api } from './api'; -import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary } from '../types/issue.types'; +import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary, IssueHistorie } from '../types/issue.types'; export const issuesApi = { getIssues: async (filters?: IssueFilters): Promise => { @@ -36,6 +36,10 @@ export const issuesApi = { const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt }); return r.data.data; }, + getHistory: async (issueId: number): Promise => { + const r = await api.get(`/api/issues/${issueId}/history`); + return r.data.data; + }, // Types CRUD getTypes: async (): Promise => { const r = await api.get('/api/issues/typen'); diff --git a/frontend/src/types/issue.types.ts b/frontend/src/types/issue.types.ts index 4841316..c80854f 100644 --- a/frontend/src/types/issue.types.ts +++ b/frontend/src/types/issue.types.ts @@ -38,6 +38,16 @@ export interface IssueComment { created_at: string; } +export interface IssueHistorie { + id: number; + issue_id: number; + aktion: string; + details: Record | null; + erstellt_von: string | null; + erstellt_von_name: string | null; + erstellt_am: string; +} + export interface CreateIssuePayload { titel: string; beschreibung?: string;