rework from modal to page

This commit is contained in:
Matthias Hochmeister
2026-03-25 10:23:28 +01:00
parent 4ad260ce66
commit feb39d234f
14 changed files with 698 additions and 280 deletions

View File

@@ -167,6 +167,30 @@ class IssueController {
return; return;
} }
// Log history entries for detected changes
const fieldLabels: Record<string, string> = {
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<string, unknown> = { 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) // Handle reopen comment (owner reopen flow)
if (isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt' && req.body.kommentar) { if (isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt' && req.body.kommentar) {
await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`); await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`);
@@ -280,6 +304,21 @@ class IssueController {
// --- Type management --- // --- Type management ---
async getHistory(req: Request, res: Response): Promise<void> {
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<void> { async getTypes(_req: Request, res: Response): Promise<void> {
try { try {
const types = await issueService.getTypes(); const types = await issueService.getTypes();

View File

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

View File

@@ -80,6 +80,12 @@ router.get(
issueController.getComments.bind(issueController) issueController.getComments.bind(issueController)
); );
router.get(
'/:id/history',
authenticate,
issueController.getHistory.bind(issueController)
);
router.post( router.post(
'/:id/comments', '/:id/comments',
authenticate, authenticate,

View File

@@ -318,7 +318,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
async function getMyRequests(userId: string) { async function getMyRequests(userId: string) {
const result = await pool.query( const result = await pool.query(
`SELECT a.*, `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 FROM ausruestung_anfragen a
WHERE a.anfrager_id = $1 WHERE a.anfrager_id = $1
ORDER BY a.erstellt_am DESC`, ORDER BY a.erstellt_am DESC`,

View File

@@ -188,6 +188,40 @@ async function updateIssue(
} }
} }
async function addHistoryEntry(
issueId: number,
aktion: string,
details: Record<string, unknown> | 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) { async function deleteIssue(id: number) {
try { try {
const result = await pool.query( const result = await pool.query(
@@ -575,6 +609,8 @@ export default {
getAssignableMembers, getAssignableMembers,
getIssueCounts, getIssueCounts,
getIssueStatuses, getIssueStatuses,
addHistoryEntry,
getHistory,
createIssueStatus, createIssueStatus,
updateIssueStatus, updateIssueStatus,
deleteIssueStatus, deleteIssueStatus,

View File

@@ -31,6 +31,7 @@ import BestellungDetail from './pages/BestellungDetail';
import BestellungNeu from './pages/BestellungNeu'; import BestellungNeu from './pages/BestellungNeu';
import Ausruestungsanfrage from './pages/Ausruestungsanfrage'; import Ausruestungsanfrage from './pages/Ausruestungsanfrage';
import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail'; import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail';
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Issues from './pages/Issues'; import Issues from './pages/Issues';
import IssueDetail from './pages/IssueDetail'; import IssueDetail from './pages/IssueDetail';
@@ -257,6 +258,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/ausruestungsanfrage/artikel/neu"
element={
<ProtectedRoute>
<AusruestungsanfrageArtikelDetail />
</ProtectedRoute>
}
/>
<Route
path="/ausruestungsanfrage/artikel/:id"
element={
<ProtectedRoute>
<AusruestungsanfrageArtikelDetail />
</ProtectedRoute>
}
/>
<Route <Route
path="/ausruestungsanfrage/neu" path="/ausruestungsanfrage/neu"
element={ element={

View File

@@ -3,7 +3,7 @@ import {
Box, Tab, Tabs, Typography, Grid, Button, Chip, Box, Tab, Tabs, Typography, Grid, Button, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton,
MenuItem, Divider, Checkbox, FormControlLabel, Tooltip, MenuItem, Divider, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
@@ -18,7 +18,6 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type { import type {
AusruestungArtikel, AusruestungArtikelFormData,
AusruestungAnfrageStatus, AusruestungAnfrage, AusruestungAnfrageStatus, AusruestungAnfrage,
AusruestungOverview, AusruestungOverview,
} from '../types/ausruestungsanfrage.types'; } from '../types/ausruestungsanfrage.types';
@@ -154,125 +153,18 @@ function KategorieDialog({ open, onClose }: KategorieDialogProps) {
); );
} }
// ─── Eigenschaften Editor (in Artikel dialog) ────────────────────────────────
interface EigenschaftenEditorProps {
artikelId: number | null;
}
function EigenschaftenEditor({ artikelId }: EigenschaftenEditorProps) {
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 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 <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufügen.</Typography>;
return (
<Box sx={{ mt: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften</Typography>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} />
<TextField
select
size="small"
label="Typ"
value={newTyp}
onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}
sx={{ minWidth: 120 }}
>
<MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem>
</TextField>
<FormControlLabel
control={<Checkbox size="small" checked={newPflicht} onChange={e => setNewPflicht(e.target.checked)} />}
label="Pflicht"
/>
</Box>
{newTyp === 'options' && (
<TextField
size="small"
label="Optionen (kommagetrennt)"
value={newOptionen}
onChange={e => setNewOptionen(e.target.value)}
placeholder="S, M, L, XL"
fullWidth
/>
)}
<Button
size="small"
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={!newName.trim() || upsertMut.isPending}
>
Eigenschaft hinzufügen
</Button>
</Box>
</Box>
);
}
// ─── Catalog Tab ──────────────────────────────────────────────────────────── // ─── Catalog Tab ────────────────────────────────────────────────────────────
function KatalogTab() { function KatalogTab() {
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); const canManage = hasPermission('ausruestungsanfrage:manage_catalog');
const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories'); const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories');
const [filterKategorie, setFilterKategorie] = useState<number | ''>(''); const [filterKategorie, setFilterKategorie] = useState<number | ''>('');
const [artikelDialogOpen, setArtikelDialogOpen] = useState(false);
const [editArtikel, setEditArtikel] = useState<AusruestungArtikel | null>(null);
const [artikelForm, setArtikelForm] = useState<AusruestungArtikelFormData>({ bezeichnung: '' });
const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false); const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false);
const { data: items = [], isLoading } = useQuery({ const { data: items = [], isLoading } = useQuery({
@@ -285,9 +177,6 @@ function KatalogTab() {
queryFn: () => ausruestungsanfrageApi.getKategorien(), 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 kategorieOptions = useMemo(() => {
const map = new Map(kategorien.map(k => [k.id, k])); const map = new Map(kategorien.map(k => [k.id, k]));
const getDisplayName = (k: { id: number; name: string; parent_id?: number | null }): string => { 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 })); return kategorien.map(k => ({ id: k.id, name: getDisplayName(k), isChild: !!k.parent_id }));
}, [kategorien]); }, [kategorien]);
const [artikelMainKat, setArtikelMainKat] = useState<number | ''>('');
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<AusruestungArtikelFormData> }) => ausruestungsanfrageApi.updateItem(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteItemMut = useMutation({ const deleteItemMut = useMutation({
mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id), mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel gelöscht'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel geloescht'); },
onError: () => showError('Fehler beim Löschen'), 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 ( return (
<Box> <Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
@@ -382,7 +235,12 @@ function KatalogTab() {
</TableHead> </TableHead>
<TableBody> <TableBody>
{items.map(item => ( {items.map(item => (
<TableRow key={item.id} hover> <TableRow
key={item.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/ausruestungsanfrage/artikel/${item.id}`)}
>
<TableCell> <TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{item.bezeichnung} {item.bezeichnung}
@@ -397,8 +255,7 @@ function KatalogTab() {
</TableCell> </TableCell>
{canManage && ( {canManage && (
<TableCell align="right"> <TableCell align="right">
<IconButton size="small" onClick={() => openEditArtikel(item)}><EditIcon fontSize="small" /></IconButton> <IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); deleteItemMut.mutate(item.id); }}><DeleteIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteItemMut.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@@ -408,56 +265,10 @@ function KatalogTab() {
</TableContainer> </TableContainer>
)} )}
<Dialog open={artikelDialogOpen} onClose={() => setArtikelDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth />
<TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
<TextField
select
label="Hauptkategorie"
value={artikelMainKat}
onChange={e => {
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
>
<MenuItem value="">Keine</MenuItem>
{topKategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</TextField>
{artikelMainKat && artikelSubKats.length > 0 && (
<TextField
select
label="Unterkategorie"
value={artikelForm.kategorie_id ?? ''}
onChange={e => setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : (artikelMainKat as number) }))}
fullWidth
>
<MenuItem value={artikelMainKat as number}>Keine (nur Hauptkategorie)</MenuItem>
{artikelSubKats.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</TextField>
)}
{canManage && <EigenschaftenEditor artikelId={editArtikel?.id ?? null} />}
</DialogContent>
<DialogActions>
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={saveArtikel} disabled={!artikelForm.bezeichnung.trim() || createItemMut.isPending || updateItemMut.isPending}>
Speichern
</Button>
</DialogActions>
</Dialog>
<KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} /> <KategorieDialog open={kategorieDialogOpen} onClose={() => setKategorieDialogOpen(false)} />
{canManage && ( {canManage && (
<ChatAwareFab onClick={openNewArtikel} aria-label="Artikel hinzufügen"> <ChatAwareFab onClick={() => navigate('/ausruestungsanfrage/artikel/neu')} aria-label="Artikel hinzufuegen">
<AddIcon /> <AddIcon />
</ChatAwareFab> </ChatAwareFab>
)} )}
@@ -533,6 +344,7 @@ function MeineAnfragenTab() {
<TableCell>Bezeichnung</TableCell> <TableCell>Bezeichnung</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Positionen</TableCell> <TableCell>Positionen</TableCell>
<TableCell>Geliefert</TableCell>
<TableCell>Erstellt am</TableCell> <TableCell>Erstellt am</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -543,6 +355,11 @@ function MeineAnfragenTab() {
<TableCell>{r.bezeichnung || '-'}</TableCell> <TableCell>{r.bezeichnung || '-'}</TableCell>
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell> <TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell> <TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell>
{r.positionen_count != null && r.positionen_count > 0
? `${r.geliefert_count ?? 0}/${r.positionen_count}`
: '-'}
</TableCell>
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell> <TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -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 <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufuegen.</Typography>;
return (
<Box sx={{ mt: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften</Typography>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(e.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} />
<TextField
select
size="small"
label="Typ"
value={newTyp}
onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}
sx={{ minWidth: 120 }}
>
<MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem>
</TextField>
<FormControlLabel
control={<Checkbox size="small" checked={newPflicht} onChange={e => setNewPflicht(e.target.checked)} />}
label="Pflicht"
/>
</Box>
{newTyp === 'options' && (
<TextField
size="small"
label="Optionen (kommagetrennt)"
value={newOptionen}
onChange={e => setNewOptionen(e.target.value)}
placeholder="S, M, L, XL"
fullWidth
/>
)}
<Button
size="small"
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={!newName.trim() || upsertMut.isPending}
>
Eigenschaft hinzufuegen
</Button>
</Box>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// 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<AusruestungArtikelFormData>({ bezeichnung: '' });
const [mainKat, setMainKat] = useState<number | ''>('');
// ── Queries ──
const { data: artikel, isLoading, isError } = useQuery<AusruestungArtikel>({
queryKey: ['ausruestungsanfrage', 'item', artikelId],
queryFn: () => ausruestungsanfrageApi.getItem(artikelId!),
enabled: artikelId != null,
retry: 1,
});
const { data: kategorien = [] } = useQuery<AusruestungKategorie[]>({
queryKey: ['ausruestungsanfrage', 'kategorien'],
queryFn: () => ausruestungsanfrageApi.getKategorien(),
});
const { data: eigenschaften = [] } = useQuery<AusruestungEigenschaft[]>({
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<AusruestungArtikelFormData> }) =>
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 (
<DashboardLayout>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/ausruestungsanfrage?tab=2')}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{isCreate ? 'Neuer Katalogartikel' : (artikel?.bezeichnung ?? '...')}
</Typography>
{!isCreate && canManage && !editing && (
<>
<IconButton onClick={startEditing}><EditIcon /></IconButton>
<IconButton color="error" onClick={() => { if (artikelId) deleteMut.mutate(artikelId); }}>
<DeleteIcon />
</IconButton>
</>
)}
</Box>
{!isCreate && isLoading ? (
<LinearProgress />
) : !isCreate && isError ? (
<Typography color="error">Fehler beim Laden des Artikels.</Typography>
) : editing ? (
/* ── Edit / Create Mode ── */
<Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Bezeichnung"
required
value={form.bezeichnung}
onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))}
fullWidth
autoFocus
/>
<TextField
label="Beschreibung"
multiline
rows={3}
value={form.beschreibung ?? ''}
onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))}
fullWidth
/>
<TextField
select
label="Hauptkategorie"
value={mainKat}
onChange={e => {
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
>
<MenuItem value="">Keine</MenuItem>
{topKategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</TextField>
{mainKat && subKats.length > 0 && (
<TextField
select
label="Unterkategorie"
value={form.kategorie_id ?? ''}
onChange={e => setForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : (mainKat as number) }))}
fullWidth
>
<MenuItem value={mainKat as number}>Keine (nur Hauptkategorie)</MenuItem>
{subKats.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</TextField>
)}
{canManage && <EigenschaftenEditor artikelId={artikelId} />}
<Divider />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button onClick={handleCancel}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!form.bezeichnung.trim() || createMut.isPending || updateMut.isPending}
>
{isCreate ? 'Erstellen' : 'Speichern'}
</Button>
</Box>
</Box>
</Paper>
) : artikel ? (
/* ── View Mode ── */
<>
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<Box>
<Typography variant="caption" color="text.secondary">Bezeichnung</Typography>
<Typography variant="body1" fontWeight={500}>{artikel.bezeichnung}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Kategorie</Typography>
<Typography variant="body1">{getKategorieName(artikel.kategorie_id)}</Typography>
</Box>
{artikel.beschreibung && (
<Box sx={{ gridColumn: '1 / -1' }}>
<Typography variant="caption" color="text.secondary">Beschreibung</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{artikel.beschreibung}</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">Status</Typography>
<Box><Chip label={artikel.aktiv ? 'Aktiv' : 'Inaktiv'} size="small" color={artikel.aktiv ? 'success' : 'default'} /></Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography variant="body2">{new Date(artikel.erstellt_am).toLocaleDateString('de-AT')}</Typography>
</Box>
</Box>
</Paper>
{eigenschaften.length > 0 && (
<Paper sx={{ p: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Eigenschaften ({eigenschaften.length})</Typography>
{eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 0.5, pl: 1, borderLeft: '2px solid', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'})
{e.pflicht && <Chip label="Pflicht" size="small" sx={{ ml: 0.5 }} />}
</Typography>
</Box>
))}
</Paper>
)}
</>
) : null}
</DashboardLayout>
);
}

View File

@@ -22,6 +22,8 @@ import {
CardContent, CardContent,
LinearProgress, LinearProgress,
Checkbox, Checkbox,
Menu,
MenuItem,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack, ArrowBack,
@@ -34,6 +36,7 @@ import {
Alarm, Alarm,
History, History,
Upload as UploadIcon, Upload as UploadIcon,
ArrowDropDown,
} 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 { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@@ -62,13 +65,15 @@ const formatFileSize = (bytes?: number) => {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}; };
// Status flow // Valid status transitions (must match backend VALID_STATUS_TRANSITIONS)
const STATUS_FLOW: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen']; const STATUS_TRANSITIONS: Record<BestellungStatus, BestellungStatus[]> = {
entwurf: ['erstellt', 'bestellt'],
function getNextStatus(current: BestellungStatus): BestellungStatus | null { erstellt: ['bestellt'],
const idx = STATUS_FLOW.indexOf(current); bestellt: ['teillieferung', 'vollstaendig'],
return idx >= 0 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null; teillieferung: ['vollstaendig'],
} vollstaendig: ['abgeschlossen'],
abgeschlossen: [],
};
// Empty line item form // Empty line item form
const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined }; const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined };
@@ -91,7 +96,8 @@ export default function BestellungDetail() {
const [newItem, setNewItem] = useState<BestellpositionFormData>({ ...emptyItem }); const [newItem, setNewItem] = useState<BestellpositionFormData>({ ...emptyItem });
const [editingItemId, setEditingItemId] = useState<number | null>(null); const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [editingItemData, setEditingItemData] = useState<Partial<BestellpositionFormData>>({}); const [editingItemData, setEditingItemData] = useState<Partial<BestellpositionFormData>>({});
const [statusConfirmOpen, setStatusConfirmOpen] = useState(false); const [statusConfirmTarget, setStatusConfirmTarget] = useState<BestellungStatus | null>(null);
const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null);
const [deleteItemTarget, setDeleteItemTarget] = useState<number | null>(null); const [deleteItemTarget, setDeleteItemTarget] = useState<number | null>(null);
const [deleteFileTarget, setDeleteFileTarget] = useState<number | null>(null); const [deleteFileTarget, setDeleteFileTarget] = useState<number | null>(null);
@@ -113,7 +119,7 @@ export default function BestellungDetail() {
const historie = data?.historie ?? []; const historie = data?.historie ?? [];
const canEdit = hasPermission('bestellungen:create'); const canEdit = hasPermission('bestellungen:create');
const nextStatus = bestellung ? getNextStatus(bestellung.status) : null; const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
// ── Mutations ── // ── Mutations ──
@@ -122,7 +128,7 @@ export default function BestellungDetail() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
showSuccess('Status aktualisiert'); showSuccess('Status aktualisiert');
setStatusConfirmOpen(false); setStatusConfirmTarget(null);
}, },
onError: () => showError('Fehler beim Aktualisieren des Status'), onError: () => showError('Fehler beim Aktualisieren des Status'),
}); });
@@ -322,11 +328,40 @@ export default function BestellungDetail() {
</Grid> </Grid>
{/* ── Status Action ── */} {/* ── Status Action ── */}
{canEdit && nextStatus && ( {canEdit && validTransitions.length > 0 && (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Button variant="contained" onClick={() => setStatusConfirmOpen(true)}> {validTransitions.length === 1 ? (
Status ändern: {BESTELLUNG_STATUS_LABELS[nextStatus]} <Button variant="contained" onClick={() => setStatusConfirmTarget(validTransitions[0])}>
Status ändern: {BESTELLUNG_STATUS_LABELS[validTransitions[0]]}
</Button> </Button>
) : (
<>
<Button
variant="contained"
endIcon={<ArrowDropDown />}
onClick={(e) => setStatusMenuAnchor(e.currentTarget)}
>
Status ändern
</Button>
<Menu
anchorEl={statusMenuAnchor}
open={Boolean(statusMenuAnchor)}
onClose={() => setStatusMenuAnchor(null)}
>
{validTransitions.map((s) => (
<MenuItem
key={s}
onClick={() => {
setStatusMenuAnchor(null);
setStatusConfirmTarget(s);
}}
>
{BESTELLUNG_STATUS_LABELS[s]}
</MenuItem>
))}
</Menu>
</>
)}
</Box> </Box>
)} )}
@@ -630,17 +665,17 @@ export default function BestellungDetail() {
{/* ══════════════════════════════════════════════════════════════════════ */} {/* ══════════════════════════════════════════════════════════════════════ */}
{/* Status Confirmation */} {/* Status Confirmation */}
<Dialog open={statusConfirmOpen} onClose={() => setStatusConfirmOpen(false)}> <Dialog open={statusConfirmTarget != null} onClose={() => setStatusConfirmTarget(null)}>
<DialogTitle>Status ändern</DialogTitle> <DialogTitle>Status ändern</DialogTitle>
<DialogContent> <DialogContent>
<Typography> <Typography>
Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '} Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '}
<strong>{nextStatus ? BESTELLUNG_STATUS_LABELS[nextStatus] : ''}</strong> ändern? <strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern?
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setStatusConfirmOpen(false)}>Abbrechen</Button> <Button onClick={() => setStatusConfirmTarget(null)}>Abbrechen</Button>
<Button variant="contained" onClick={() => nextStatus && updateStatus.mutate(nextStatus)} disabled={updateStatus.isPending}> <Button variant="contained" onClick={() => statusConfirmTarget && updateStatus.mutate(statusConfirmTarget)} disabled={updateStatus.isPending}>
Bestätigen Bestätigen
</Button> </Button>
</DialogActions> </DialogActions>

View File

@@ -7,7 +7,7 @@ import {
import { import {
ArrowBack, Delete as DeleteIcon, ArrowBack, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline, Send as SendIcon, BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Refresh as RefreshIcon, Circle as CircleIcon, Refresh as RefreshIcon, History,
} 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 { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@@ -16,7 +16,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues'; 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) ── // ── Helpers (copied from Issues.tsx) ──
@@ -117,6 +117,12 @@ export default function IssueDetail() {
enabled: hasEdit, enabled: hasEdit,
}); });
const { data: historie = [] } = useQuery<IssueHistorie[]>({
queryKey: ['issues', issueId, 'history'],
queryFn: () => issuesApi.getHistory(issueId),
enabled: !isNaN(issueId),
});
// ── Permissions ── // ── Permissions ──
const isOwner = issue?.erstellt_von === userId; const isOwner = issue?.erstellt_von === userId;
const isAssignee = issue?.zugewiesen_an === userId; const isAssignee = issue?.zugewiesen_an === userId;
@@ -144,6 +150,7 @@ export default function IssueDetail() {
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issueId, data), mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issueId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] }); queryClient.invalidateQueries({ queryKey: ['issues'] });
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'history'] });
showSuccess('Issue aktualisiert'); showSuccess('Issue aktualisiert');
}, },
onError: () => showError('Fehler beim Aktualisieren'), onError: () => showError('Fehler beim Aktualisieren'),
@@ -393,6 +400,35 @@ export default function IssueDetail() {
</IconButton> </IconButton>
</Box> </Box>
)} )}
{/* History section */}
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<History fontSize="small" />
<Typography variant="subtitle2">Historie</Typography>
</Box>
{historie.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Einträge</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{historie.map((h) => (
<Box key={h.id} sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ width: 6, minHeight: '100%', borderRadius: 3, bgcolor: 'divider', flexShrink: 0 }} />
<Box>
<Typography variant="body2">{h.aktion}</Typography>
<Typography variant="caption" color="text.secondary">
{h.erstellt_von_name || 'System'} &middot; {formatDate(h.erstellt_am)}
</Typography>
{h.details && (
<Typography variant="caption" display="block" color="text.secondary">
{Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')}
</Typography>
)}
</Box>
</Box>
))}
</Box>
)}
</Box> </Box>
{/* Reopen Dialog */} {/* Reopen Dialog */}

View File

@@ -1,9 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Box, Typography, Paper, Button, TextField, MenuItem, Select, FormControl, Box, Typography, Paper, Button, TextField, MenuItem, Select, FormControl,
InputLabel, IconButton, InputLabel, IconButton, Grid, Collapse,
} from '@mui/material'; } 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -17,6 +17,7 @@ export default function IssueNeu() {
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', prioritaet: '' }); const [form, setForm] = useState<CreateIssuePayload>({ titel: '', prioritaet: '' });
const [showDescription, setShowDescription] = useState(false);
const { data: types = [] } = useQuery({ const { data: types = [] } = useQuery({
queryKey: ['issue-types'], queryKey: ['issue-types'],
@@ -51,16 +52,15 @@ export default function IssueNeu() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Box sx={{ p: 3 }}>
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/issues')}> <IconButton onClick={() => navigate('/issues')}>
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Typography variant="h5">Neues Issue</Typography> <Typography variant="h5">Neues Issue</Typography>
</Box> </Box>
<Paper variant="outlined" sx={{ p: 3, maxWidth: 600 }}> <Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
label="Titel" label="Titel"
@@ -70,6 +70,8 @@ export default function IssueNeu() {
onChange={(e) => setForm({ ...form, titel: e.target.value })} onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus autoFocus
/> />
<Collapse in={showDescription} unmountOnExit>
<TextField <TextField
label="Beschreibung" label="Beschreibung"
multiline multiline
@@ -78,6 +80,20 @@ export default function IssueNeu() {
value={form.beschreibung || ''} value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })} onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
/> />
</Collapse>
{!showDescription && (
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => setShowDescription(true)}
sx={{ alignSelf: 'flex-start' }}
>
Beschreibung hinzufuegen
</Button>
)}
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Typ</InputLabel> <InputLabel>Typ</InputLabel>
<Select <Select
@@ -90,11 +106,13 @@ export default function IssueNeu() {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Priorität</InputLabel> <InputLabel>Prioritaet</InputLabel>
<Select <Select
value={form.prioritaet || defaultPriority} value={form.prioritaet || defaultPriority}
label="Priorität" label="Prioritaet"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })} onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
> >
{priorities.filter(p => p.aktiv).map(p => ( {priorities.filter(p => p.aktiv).map(p => (
@@ -102,6 +120,9 @@ export default function IssueNeu() {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
</Grid>
</Grid>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button onClick={() => navigate('/issues')}>Abbrechen</Button> <Button onClick={() => navigate('/issues')}>Abbrechen</Button>
<Button <Button
@@ -114,7 +135,6 @@ export default function IssueNeu() {
</Box> </Box>
</Box> </Box>
</Paper> </Paper>
</Box>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -76,7 +76,7 @@ export const bestellungApi = {
await api.delete(`/api/bestellungen/items/${itemId}`); await api.delete(`/api/bestellungen/items/${itemId}`);
}, },
updateReceivedQty: async (itemId: number, menge: number): Promise<Bestellposition> => { updateReceivedQty: async (itemId: number, menge: number): Promise<Bestellposition> => {
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; return r.data.data;
}, },

View File

@@ -1,5 +1,5 @@
import { api } from './api'; 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 = { export const issuesApi = {
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => { getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
@@ -36,6 +36,10 @@ export const issuesApi = {
const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt }); const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt });
return r.data.data; return r.data.data;
}, },
getHistory: async (issueId: number): Promise<IssueHistorie[]> => {
const r = await api.get(`/api/issues/${issueId}/history`);
return r.data.data;
},
// Types CRUD // Types CRUD
getTypes: async (): Promise<IssueTyp[]> => { getTypes: async (): Promise<IssueTyp[]> => {
const r = await api.get('/api/issues/typen'); const r = await api.get('/api/issues/typen');

View File

@@ -38,6 +38,16 @@ export interface IssueComment {
created_at: string; created_at: string;
} }
export interface IssueHistorie {
id: number;
issue_id: number;
aktion: string;
details: Record<string, unknown> | null;
erstellt_von: string | null;
erstellt_von_name: string | null;
erstellt_am: string;
}
export interface CreateIssuePayload { export interface CreateIssuePayload {
titel: string; titel: string;
beschreibung?: string; beschreibung?: string;