fix(issues): allow priority+status change for assignees, dynamic owner transitions, kanban droppable columns; feat(persoenliche-ausruestung): configurable zustand

This commit is contained in:
Matthias Hochmeister
2026-04-16 14:08:05 +02:00
parent 2fe0db6d9a
commit e56075f38a
9 changed files with 183 additions and 114 deletions

View File

@@ -116,18 +116,24 @@ class IssueController {
updateData = { ...req.body }; updateData = { ...req.body };
// Explicit null for unassign is handled by 'zugewiesen_an' in data check in service // Explicit null for unassign is handled by 'zugewiesen_an' in data check in service
} else if (canChangeStatus || isAssignee) { } else if (canChangeStatus || isAssignee) {
// Can only change status (+ kommentar is handled separately) // Can change status and priority (+ kommentar is handled separately)
updateData = {}; updateData = {};
if (req.body.status !== undefined) updateData.status = req.body.status; if (req.body.status !== undefined) updateData.status = req.body.status;
if (req.body.prioritaet !== undefined) updateData.prioritaet = req.body.prioritaet;
} else if (isOwner) { } 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 = {}; updateData = {};
if (req.body.status !== undefined) { if (req.body.status !== undefined) {
const newStatus = req.body.status; const newStatus = req.body.status;
if (newStatus === 'erledigt') { const allStatuses = await issueService.getIssueStatuses();
updateData.status = 'erledigt'; const targetDef = allStatuses.find((s: any) => s.schluessel === newStatus);
} else if (newStatus === 'offen' && existing.status === 'erledigt') { const currentDef = allStatuses.find((s: any) => s.schluessel === existing.status);
// Reopen: require kommentar
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) { if (!req.body.kommentar || typeof req.body.kommentar !== 'string' || req.body.kommentar.trim().length === 0) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -135,7 +141,7 @@ class IssueController {
}); });
return; return;
} }
updateData.status = 'offen'; updateData.status = newStatus;
} else { } else {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' }); res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' });
return; return;
@@ -192,13 +198,18 @@ class IssueController {
} }
} }
// Handle reopen comment (owner reopen flow) // Handle reopen comment (owner reopen flow: terminal → initial)
if (isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt' && req.body.kommentar) { 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()}`); 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 (not the reopen flow above) // If kommentar was provided alongside a status change (non-owner flow)
if (req.body.kommentar && updateData.status && !(isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt')) {
await issueService.addComment(id, userId, req.body.kommentar.trim()); await issueService.addComment(id, userId, req.body.kommentar.trim());
} }

View File

@@ -41,11 +41,13 @@ const BASE_SELECT = `
SELECT pa.*, SELECT pa.*,
COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name, COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name,
aa.bezeichnung AS artikel_bezeichnung, 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 FROM persoenliche_ausruestung pa
LEFT JOIN users u ON u.id = pa.user_id LEFT JOIN users u ON u.id = pa.user_id
LEFT JOIN ausruestung_artikel aa ON aa.id = pa.artikel_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 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 WHERE pa.geloescht_am IS NULL
`; `;

View File

@@ -52,7 +52,9 @@ function PersoenlicheAusruestungWidget() {
<ListItem key={item.id} disablePadding sx={{ py: 0.5 }}> <ListItem key={item.id} disablePadding sx={{ py: 0.5 }}>
<ListItemText <ListItemText
primary={item.bezeichnung} primary={item.bezeichnung}
secondary={item.artikel_kategorie_name ?? item.kategorie ?? undefined} secondary={item.artikel_kategorie_parent_name
? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}`
: item.artikel_kategorie_name ?? item.kategorie ?? undefined}
primaryTypographyProps={{ variant: 'body2', noWrap: true }} primaryTypographyProps={{ variant: 'body2', noWrap: true }}
secondaryTypographyProps={{ variant: 'caption' }} secondaryTypographyProps={{ variant: 'caption' }}
/> />

View File

@@ -13,6 +13,7 @@ import {
useSensor, useSensor,
useSensors, useSensors,
closestCorners, closestCorners,
useDroppable,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { import {
@@ -163,18 +164,21 @@ function KanbanColumn({
onClick: (id: number) => void; onClick: (id: number) => void;
}) { }) {
const chipColor = MUI_THEME_COLORS[statusDef.farbe] ?? statusDef.farbe ?? '#9e9e9e'; const chipColor = MUI_THEME_COLORS[statusDef.farbe] ?? statusDef.farbe ?? '#9e9e9e';
const { setNodeRef, isOver } = useDroppable({ id: statusDef.schluessel });
return ( return (
<Box <Box
ref={setNodeRef}
sx={{ sx={{
minWidth: 260, minWidth: 260,
maxWidth: 320, maxWidth: 320,
flex: '1 1 260px', flex: '1 1 260px',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
bgcolor: 'background.default', bgcolor: isOver ? 'action.hover' : 'background.default',
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
transition: 'background-color 0.15s',
}} }}
> >
{/* Column header */} {/* Column header */}

View File

@@ -18,6 +18,8 @@ import {
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
Tabs,
Tab,
} from '@mui/material'; } from '@mui/material';
import { import {
Delete, Delete,
@@ -31,7 +33,7 @@ import {
Checkroom as CheckroomIcon, Checkroom as CheckroomIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 DashboardLayout from '../components/dashboard/DashboardLayout';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
@@ -39,6 +41,19 @@ import { settingsApi } from '../services/settings';
import { personalEquipmentApi } from '../services/personalEquipment'; import { personalEquipmentApi } from '../services/personalEquipment';
import type { ZustandOption } from '../types/personalEquipment.types'; 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 <Box sx={{ pt: 3 }}>{children}</Box>;
}
const SETTINGS_TAB_COUNT = 2;
interface LinkCollection { interface LinkCollection {
id: string; id: string;
name: string; name: string;
@@ -65,6 +80,18 @@ const ADMIN_INTERVAL_OPTIONS = [
]; ];
function AdminSettings() { 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 { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -317,10 +344,23 @@ function AdminSettings() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}> <Typography variant="h4" gutterBottom sx={{ mb: 2 }}>
Admin-Einstellungen Admin-Einstellungen
</Typography> </Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs
value={tab}
onChange={(_e, v) => { setTab(v); navigate(`/admin/settings?tab=${v}`, { replace: true }); }}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Allgemein" />
<Tab label="Werkzeuge" />
</Tabs>
</Box>
<TabPanel value={tab} index={0}>
<Stack spacing={3}> <Stack spacing={3}>
{/* Section 1: General Settings (App Logo) */} {/* Section 1: General Settings (App Logo) */}
<Card> <Card>
@@ -596,7 +636,27 @@ function AdminSettings() {
</CardContent> </CardContent>
</Card> </Card>
{/* Section 5: Zustandsoptionen (Persönliche Ausrüstung) */} {/* Section 5: Info */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Info color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Info</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary">
{lastUpdated
? `Letzte Aktualisierung: ${new Date(lastUpdated).toLocaleString('de-DE')}`
: 'Noch keine Einstellungen gespeichert.'}
</Typography>
</CardContent>
</Card>
</Stack>
</TabPanel>
<TabPanel value={tab} index={1}>
<Stack spacing={3}>
{/* Zustandsoptionen (Persönliche Ausrüstung) */}
<Card> <Card>
<CardContent> <CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
@@ -686,23 +746,8 @@ function AdminSettings() {
</Stack> </Stack>
</CardContent> </CardContent>
</Card> </Card>
{/* Section 6: Info */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Info color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Info</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary">
{lastUpdated
? `Letzte Aktualisierung: ${new Date(lastUpdated).toLocaleString('de-DE')}`
: 'Noch keine Einstellungen gespeichert.'}
</Typography>
</CardContent>
</Card>
</Stack> </Stack>
</TabPanel>
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -423,7 +423,7 @@ export default function IssueDetail() {
) : null} ) : null}
{/* Priority control */} {/* Priority control */}
{hasEdit && ( {(hasEdit || canChangeStatus) && (
<FormControl size="small" sx={{ minWidth: 140 }}> <FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Priorität</InputLabel> <InputLabel>Priorität</InputLabel>
<Select <Select

View File

@@ -232,7 +232,9 @@ function PersoenlicheAusruestungPage() {
)} )}
</td> </td>
<td> <td>
<Typography variant="body2">{item.artikel_kategorie_name ?? item.kategorie ?? '—'}</Typography> <Typography variant="body2">{item.artikel_kategorie_parent_name
? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}`
: item.artikel_kategorie_name ?? item.kategorie ?? '—'}</Typography>
</td> </td>
{canSeeAll && ( {canSeeAll && (
<td> <td>

View File

@@ -69,7 +69,9 @@ export default function PersoenlicheAusruestungDetail() {
<PageHeader <PageHeader
title={item.bezeichnung} title={item.bezeichnung}
backTo="/persoenliche-ausruestung" backTo="/persoenliche-ausruestung"
subtitle={item.artikel_kategorie_name || item.kategorie || undefined} subtitle={item.artikel_kategorie_parent_name
? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}`
: item.artikel_kategorie_name || item.kategorie || undefined}
/> />
{/* Status + actions row */} {/* Status + actions row */}

View File

@@ -13,6 +13,7 @@ export interface PersoenlicheAusruestung {
artikel_id?: number; artikel_id?: number;
artikel_bezeichnung?: string; artikel_bezeichnung?: string;
artikel_kategorie_name?: string; artikel_kategorie_name?: string;
artikel_kategorie_parent_name?: string;
user_id?: string; user_id?: string;
user_display_name?: string; user_display_name?: string;
benutzer_name?: string; benutzer_name?: string;