diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index fcb390b..bdf5117 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -116,18 +116,24 @@ class IssueController { updateData = { ...req.body }; // Explicit null for unassign is handled by 'zugewiesen_an' in data check in service } else if (canChangeStatus || isAssignee) { - // Can only change status (+ kommentar is handled separately) + // Can change status and priority (+ kommentar is handled separately) updateData = {}; if (req.body.status !== undefined) updateData.status = req.body.status; + if (req.body.prioritaet !== undefined) updateData.prioritaet = req.body.prioritaet; } else if (isOwner) { - // Owner without change_status: can only close own issue or reopen from erledigt + // Owner without change_status: can only close own issue or reopen from terminal status updateData = {}; if (req.body.status !== undefined) { const newStatus = req.body.status; - if (newStatus === 'erledigt') { - updateData.status = 'erledigt'; - } else if (newStatus === 'offen' && existing.status === 'erledigt') { - // Reopen: require kommentar + const allStatuses = await issueService.getIssueStatuses(); + const targetDef = allStatuses.find((s: any) => s.schluessel === newStatus); + const currentDef = allStatuses.find((s: any) => s.schluessel === existing.status); + + if (targetDef?.ist_abschluss) { + // Owner can close with any terminal status + updateData.status = newStatus; + } else if (targetDef?.ist_initial && currentDef?.ist_abschluss) { + // Owner can reopen from terminal → initial (requires kommentar) if (!req.body.kommentar || typeof req.body.kommentar !== 'string' || req.body.kommentar.trim().length === 0) { res.status(400).json({ success: false, @@ -135,7 +141,7 @@ class IssueController { }); return; } - updateData.status = 'offen'; + updateData.status = newStatus; } else { res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' }); return; @@ -192,13 +198,18 @@ class IssueController { } } - // 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()}`); - } - - // If kommentar was provided alongside a status change (not the reopen flow above) - if (req.body.kommentar && updateData.status && !(isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt')) { + // Handle reopen comment (owner reopen flow: terminal → initial) + if (isOwner && !canChangeStatus && updateData.status && req.body.kommentar) { + const allStatusesForComment = await issueService.getIssueStatuses(); + const targetForComment = allStatusesForComment.find((s: any) => s.schluessel === updateData.status); + const currentForComment = allStatusesForComment.find((s: any) => s.schluessel === existing.status); + if (targetForComment?.ist_initial && currentForComment?.ist_abschluss) { + await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`); + } else if (req.body.kommentar && updateData.status) { + await issueService.addComment(id, userId, req.body.kommentar.trim()); + } + } else if (req.body.kommentar && updateData.status) { + // If kommentar was provided alongside a status change (non-owner flow) await issueService.addComment(id, userId, req.body.kommentar.trim()); } diff --git a/backend/src/services/personalEquipment.service.ts b/backend/src/services/personalEquipment.service.ts index df8a897..b0f9169 100644 --- a/backend/src/services/personalEquipment.service.ts +++ b/backend/src/services/personalEquipment.service.ts @@ -41,11 +41,13 @@ const BASE_SELECT = ` SELECT pa.*, COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name, aa.bezeichnung AS artikel_bezeichnung, - akk.name AS artikel_kategorie_name + akk.name AS artikel_kategorie_name, + akk_parent.name AS artikel_kategorie_parent_name FROM persoenliche_ausruestung pa LEFT JOIN users u ON u.id = pa.user_id LEFT JOIN ausruestung_artikel aa ON aa.id = pa.artikel_id LEFT JOIN ausruestung_kategorien_katalog akk ON akk.id = aa.kategorie_id + LEFT JOIN ausruestung_kategorien_katalog akk_parent ON akk_parent.id = akk.parent_id WHERE pa.geloescht_am IS NULL `; diff --git a/frontend/src/components/dashboard/PersoenlicheAusruestungWidget.tsx b/frontend/src/components/dashboard/PersoenlicheAusruestungWidget.tsx index 1b3a0d4..bee0f7a 100644 --- a/frontend/src/components/dashboard/PersoenlicheAusruestungWidget.tsx +++ b/frontend/src/components/dashboard/PersoenlicheAusruestungWidget.tsx @@ -52,7 +52,9 @@ function PersoenlicheAusruestungWidget() { ${item.artikel_kategorie_name}` + : item.artikel_kategorie_name ?? item.kategorie ?? undefined} primaryTypographyProps={{ variant: 'body2', noWrap: true }} secondaryTypographyProps={{ variant: 'caption' }} /> diff --git a/frontend/src/components/issues/KanbanBoard.tsx b/frontend/src/components/issues/KanbanBoard.tsx index 4687465..d3567e8 100644 --- a/frontend/src/components/issues/KanbanBoard.tsx +++ b/frontend/src/components/issues/KanbanBoard.tsx @@ -13,6 +13,7 @@ import { useSensor, useSensors, closestCorners, + useDroppable, } from '@dnd-kit/core'; import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; import { @@ -163,18 +164,21 @@ function KanbanColumn({ onClick: (id: number) => void; }) { const chipColor = MUI_THEME_COLORS[statusDef.farbe] ?? statusDef.farbe ?? '#9e9e9e'; + const { setNodeRef, isOver } = useDroppable({ id: statusDef.schluessel }); return ( {/* Column header */} diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index c22f3fa..b0def15 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -18,6 +18,8 @@ import { Accordion, AccordionSummary, AccordionDetails, + Tabs, + Tab, } from '@mui/material'; import { Delete, @@ -31,7 +33,7 @@ import { Checkroom as CheckroomIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Navigate } from 'react-router-dom'; +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; @@ -39,6 +41,19 @@ import { settingsApi } from '../services/settings'; import { personalEquipmentApi } from '../services/personalEquipment'; import type { ZustandOption } from '../types/personalEquipment.types'; +interface TabPanelProps { + children: React.ReactNode; + index: number; + value: number; +} + +function TabPanel({ children, value, index }: TabPanelProps) { + if (value !== index) return null; + return {children}; +} + +const SETTINGS_TAB_COUNT = 2; + interface LinkCollection { id: string; name: string; @@ -65,6 +80,18 @@ const ADMIN_INTERVAL_OPTIONS = [ ]; function AdminSettings() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [tab, setTab] = useState(() => { + const t = Number(searchParams.get('tab')); + return t >= 0 && t < SETTINGS_TAB_COUNT ? t : 0; + }); + + useEffect(() => { + const t = Number(searchParams.get('tab')); + if (t >= 0 && t < SETTINGS_TAB_COUNT) setTab(t); + }, [searchParams]); + const { hasPermission } = usePermissionContext(); const { showSuccess, showError } = useNotification(); const queryClient = useQueryClient(); @@ -317,10 +344,23 @@ function AdminSettings() { return ( - + Admin-Einstellungen + + { setTab(v); navigate(`/admin/settings?tab=${v}`, { replace: true }); }} + variant="scrollable" + scrollButtons="auto" + > + + + + + + {/* Section 1: General Settings (App Logo) */} @@ -596,98 +636,7 @@ function AdminSettings() { - {/* Section 5: Zustandsoptionen (Persönliche Ausrüstung) */} - - - - - Zustandsoptionen — Persönliche Ausrüstung - - - - Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt. - - - {zustandOptions.map((opt, idx) => ( - - - setZustandOptions((prev) => - prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o)) - ) - } - size="small" - sx={{ flex: 1 }} - /> - - setZustandOptions((prev) => - prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o)) - ) - } - size="small" - sx={{ flex: 1 }} - /> - - Farbe - - - - setZustandOptions((prev) => prev.filter((_, i) => i !== idx)) - } - aria-label="Option entfernen" - size="small" - > - - - - ))} - - - - - - - - - {/* Section 6: Info */} + {/* Section 5: Info */} @@ -703,6 +652,102 @@ function AdminSettings() { + + + + + {/* Zustandsoptionen (Persönliche Ausrüstung) */} + + + + + Zustandsoptionen — Persönliche Ausrüstung + + + + Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt. + + + {zustandOptions.map((opt, idx) => ( + + + setZustandOptions((prev) => + prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o)) + ) + } + size="small" + sx={{ flex: 1 }} + /> + + setZustandOptions((prev) => + prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o)) + ) + } + size="small" + sx={{ flex: 1 }} + /> + + Farbe + + + + setZustandOptions((prev) => prev.filter((_, i) => i !== idx)) + } + aria-label="Option entfernen" + size="small" + > + + + + ))} + + + + + + + + + ); diff --git a/frontend/src/pages/IssueDetail.tsx b/frontend/src/pages/IssueDetail.tsx index 7839297..57b9b0b 100644 --- a/frontend/src/pages/IssueDetail.tsx +++ b/frontend/src/pages/IssueDetail.tsx @@ -423,7 +423,7 @@ export default function IssueDetail() { ) : null} {/* Priority control */} - {hasEdit && ( + {(hasEdit || canChangeStatus) && ( Priorität