rework from modal to page
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
11
backend/src/database/migrations/059_issue_historie.sql
Normal file
11
backend/src/database/migrations/059_issue_historie.sql
Normal 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);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
386
frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx
Normal file
386
frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'} · {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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user