fix(issues): allow priority+status change for assignees, dynamic owner transitions, kanban droppable columns; feat(persoenliche-ausruestung): configurable zustand
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user