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 };
// 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());
}

View File

@@ -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
`;

View File

@@ -52,7 +52,9 @@ function PersoenlicheAusruestungWidget() {
<ListItem key={item.id} disablePadding sx={{ py: 0.5 }}>
<ListItemText
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 }}
secondaryTypographyProps={{ variant: 'caption' }}
/>

View File

@@ -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 (
<Box
ref={setNodeRef}
sx={{
minWidth: 260,
maxWidth: 320,
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
bgcolor: isOver ? 'action.hover' : 'background.default',
borderRadius: 1,
overflow: 'hidden',
transition: 'background-color 0.15s',
}}
>
{/* Column header */}

View File

@@ -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 <Box sx={{ pt: 3 }}>{children}</Box>;
}
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 (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
<Typography variant="h4" gutterBottom sx={{ mb: 2 }}>
Admin-Einstellungen
</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}>
{/* Section 1: General Settings (App Logo) */}
<Card>
@@ -596,98 +636,7 @@ function AdminSettings() {
</CardContent>
</Card>
{/* Section 5: Zustandsoptionen (Persönliche Ausrüstung) */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckroomIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Zustandsoptionen Persönliche Ausrüstung</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt.
</Typography>
<Stack spacing={2}>
{zustandOptions.map((opt, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="Schlüssel"
value={opt.key}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Label"
value={opt.label}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>Farbe</InputLabel>
<Select
value={opt.color}
label="Farbe"
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o))
)
}
>
<MenuItem value="success">Grün</MenuItem>
<MenuItem value="warning">Gelb</MenuItem>
<MenuItem value="error">Rot</MenuItem>
<MenuItem value="default">Grau</MenuItem>
<MenuItem value="info">Blau</MenuItem>
<MenuItem value="primary">Primär</MenuItem>
</Select>
</FormControl>
<IconButton
color="error"
onClick={() =>
setZustandOptions((prev) => prev.filter((_, i) => i !== idx))
}
aria-label="Option entfernen"
size="small"
>
<Delete />
</IconButton>
</Box>
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
startIcon={<Add />}
onClick={() =>
setZustandOptions((prev) => [...prev, { key: '', label: '', color: 'default' }])
}
variant="outlined"
size="small"
>
Option hinzufügen
</Button>
<Button
onClick={() => zustandMutation.mutate(zustandOptions)}
variant="contained"
size="small"
disabled={zustandMutation.isPending}
>
Speichern
</Button>
</Box>
</Stack>
</CardContent>
</Card>
{/* Section 6: Info */}
{/* Section 5: Info */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
@@ -703,6 +652,102 @@ function AdminSettings() {
</CardContent>
</Card>
</Stack>
</TabPanel>
<TabPanel value={tab} index={1}>
<Stack spacing={3}>
{/* Zustandsoptionen (Persönliche Ausrüstung) */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckroomIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Zustandsoptionen Persönliche Ausrüstung</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt.
</Typography>
<Stack spacing={2}>
{zustandOptions.map((opt, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="Schlüssel"
value={opt.key}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Label"
value={opt.label}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>Farbe</InputLabel>
<Select
value={opt.color}
label="Farbe"
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o))
)
}
>
<MenuItem value="success">Grün</MenuItem>
<MenuItem value="warning">Gelb</MenuItem>
<MenuItem value="error">Rot</MenuItem>
<MenuItem value="default">Grau</MenuItem>
<MenuItem value="info">Blau</MenuItem>
<MenuItem value="primary">Primär</MenuItem>
</Select>
</FormControl>
<IconButton
color="error"
onClick={() =>
setZustandOptions((prev) => prev.filter((_, i) => i !== idx))
}
aria-label="Option entfernen"
size="small"
>
<Delete />
</IconButton>
</Box>
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
startIcon={<Add />}
onClick={() =>
setZustandOptions((prev) => [...prev, { key: '', label: '', color: 'default' }])
}
variant="outlined"
size="small"
>
Option hinzufügen
</Button>
<Button
onClick={() => zustandMutation.mutate(zustandOptions)}
variant="contained"
size="small"
disabled={zustandMutation.isPending}
>
Speichern
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</TabPanel>
</Container>
</DashboardLayout>
);

View File

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

View File

@@ -232,7 +232,9 @@ function PersoenlicheAusruestungPage() {
)}
</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>
{canSeeAll && (
<td>

View File

@@ -69,7 +69,9 @@ export default function PersoenlicheAusruestungDetail() {
<PageHeader
title={item.bezeichnung}
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 */}

View File

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