diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts index fffcf51..75b3cac 100644 --- a/backend/src/controllers/equipment.controller.ts +++ b/backend/src/controllers/equipment.controller.ts @@ -451,6 +451,86 @@ class EquipmentController { } } + async createCategory(req: Request, res: Response): Promise { + try { + const { name, kurzname, sortierung, motorisiert } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ success: false, message: 'Name ist erforderlich' }); + return; + } + if (!kurzname || typeof kurzname !== 'string' || !kurzname.trim()) { + res.status(400).json({ success: false, message: 'Kurzname ist erforderlich' }); + return; + } + const category = await equipmentService.createCategory({ + name: name.trim(), + kurzname: kurzname.trim(), + sortierung: sortierung != null ? Number(sortierung) : undefined, + motorisiert: motorisiert != null ? Boolean(motorisiert) : undefined, + }); + res.status(201).json({ success: true, data: category }); + } catch (error) { + logger.error('createCategory error', { error }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' }); + } + } + + async updateCategory(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Kategorie-ID' }); + return; + } + const { name, kurzname, sortierung, motorisiert } = req.body; + const data: Record = {}; + if (name !== undefined) data.name = String(name).trim(); + if (kurzname !== undefined) data.kurzname = String(kurzname).trim(); + if (sortierung !== undefined) data.sortierung = Number(sortierung); + if (motorisiert !== undefined) data.motorisiert = Boolean(motorisiert); + + if (Object.keys(data).length === 0) { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } + const category = await equipmentService.updateCategory(id, data as any); + if (!category) { + res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: category }); + } catch (error: any) { + if (error?.message === 'No fields to update') { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } + logger.error('updateCategory error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' }); + } + } + + async deleteCategory(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Kategorie-ID' }); + return; + } + const result = await equipmentService.deleteCategory(id); + if (!result.deleted) { + res.status(result.error === 'Kategorie nicht gefunden' ? 404 : 409).json({ + success: false, + message: result.error, + }); + return; + } + res.status(200).json({ success: true, message: 'Kategorie gelöscht' }); + } catch (error) { + logger.error('deleteCategory error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' }); + } + } + async uploadWartungFile(req: Request, res: Response): Promise { const { wartungId } = req.params as Record; const id = parseInt(wartungId, 10); diff --git a/backend/src/database/migrations/044_add_manage_categories_permission.sql b/backend/src/database/migrations/044_add_manage_categories_permission.sql new file mode 100644 index 0000000..ffd3d0e --- /dev/null +++ b/backend/src/database/migrations/044_add_manage_categories_permission.sql @@ -0,0 +1,4 @@ +-- Add manage_categories permission for equipment +INSERT INTO permissions (feature_group, action, beschreibung) +VALUES ('ausruestung', 'manage_categories', 'Kategorien verwalten') +ON CONFLICT DO NOTHING; diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index cd02c2d..eefed03 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -12,6 +12,9 @@ router.get('/', authenticate, requirePermission('ausruestung: router.get('/stats', authenticate, requirePermission('ausruestung:view'), equipmentController.getStats.bind(equipmentController)); router.get('/alerts', authenticate, requirePermission('ausruestung:view'), equipmentController.getAlerts.bind(equipmentController)); router.get('/categories', authenticate, requirePermission('ausruestung:view'), equipmentController.getCategories.bind(equipmentController)); +router.post('/categories', authenticate, requirePermission('ausruestung:manage_categories'), equipmentController.createCategory.bind(equipmentController)); +router.patch('/categories/:id', authenticate, requirePermission('ausruestung:manage_categories'), equipmentController.updateCategory.bind(equipmentController)); +router.delete('/categories/:id', authenticate, requirePermission('ausruestung:manage_categories'), equipmentController.deleteCategory.bind(equipmentController)); router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController)); router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController)); router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController)); diff --git a/backend/src/services/equipment.service.ts b/backend/src/services/equipment.service.ts index 90f64cc..268998f 100644 --- a/backend/src/services/equipment.service.ts +++ b/backend/src/services/equipment.service.ts @@ -132,6 +132,75 @@ class EquipmentService { } } + async createCategory(data: { name: string; kurzname: string; sortierung?: number; motorisiert?: boolean }): Promise { + try { + const result = await pool.query( + `INSERT INTO ausruestung_kategorien (id, name, kurzname, sortierung, motorisiert) + VALUES (uuid_generate_v4(), $1, $2, COALESCE($3, (SELECT COALESCE(MAX(sortierung),0)+1 FROM ausruestung_kategorien)), COALESCE($4, false)) + RETURNING *`, + [data.name, data.kurzname, data.sortierung ?? null, data.motorisiert ?? null] + ); + logger.info('Equipment category created', { id: result.rows[0].id, name: data.name }); + return result.rows[0] as AusruestungKategorie; + } catch (error) { + logger.error('EquipmentService.createCategory failed', { error }); + throw new Error('Failed to create equipment category'); + } + } + + async updateCategory(id: string, data: { name?: string; kurzname?: string; sortierung?: number; motorisiert?: boolean }): Promise { + try { + const fields: string[] = []; + const values: unknown[] = []; + let p = 1; + + if (data.name !== undefined) { fields.push(`name = $${p++}`); values.push(data.name); } + if (data.kurzname !== undefined) { fields.push(`kurzname = $${p++}`); values.push(data.kurzname); } + if (data.sortierung !== undefined) { fields.push(`sortierung = $${p++}`); values.push(data.sortierung); } + if (data.motorisiert !== undefined) { fields.push(`motorisiert = $${p++}`); values.push(data.motorisiert); } + + if (fields.length === 0) throw new Error('No fields to update'); + + values.push(id); + const result = await pool.query( + `UPDATE ausruestung_kategorien SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`, + values + ); + if (result.rows.length === 0) return null; + logger.info('Equipment category updated', { id }); + return result.rows[0] as AusruestungKategorie; + } catch (error) { + logger.error('EquipmentService.updateCategory failed', { error, id }); + throw error; + } + } + + async deleteCategory(id: string): Promise<{ deleted: boolean; error?: string }> { + try { + // Check if any equipment items reference this category + const usage = await pool.query( + `SELECT COUNT(*) AS cnt FROM ausruestung WHERE kategorie_id = $1 AND deleted_at IS NULL`, + [id] + ); + const count = parseInt(usage.rows[0].cnt, 10); + if (count > 0) { + return { deleted: false, error: `Kategorie wird von ${count} Ausrüstungsgegenständen verwendet und kann nicht gelöscht werden.` }; + } + const result = await pool.query( + `DELETE FROM ausruestung_kategorien WHERE id = $1 RETURNING id`, + [id] + ); + if (result.rows.length === 0) { + return { deleted: false, error: 'Kategorie nicht gefunden' }; + } + logger.info('Equipment category deleted', { id }); + return { deleted: true }; + } catch (error) { + logger.error('EquipmentService.deleteCategory failed', { error, id }); + throw new Error('Failed to delete equipment category'); + } + } + // ========================================================================= // CRUD // ========================================================================= diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index b023260..9746d4d 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -15,9 +15,7 @@ import { DialogContent, DialogContentText, DialogTitle, - FormControlLabel, IconButton, - Switch, Table, TableBody, TableCell, @@ -153,17 +151,6 @@ function PermissionMatrixTab() { }; // ── Mutations ── - const maintenanceMutation = useMutation({ - mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) => - permissionsApi.setMaintenanceFlag(featureGroup, active), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); - queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); - showSuccess('Wartungsmodus aktualisiert'); - }, - onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'), - }); - const permissionMutation = useMutation({ mutationFn: (updates: { group: string; permissions: string[] }[]) => permissionsApi.setBulkPermissions(updates), @@ -325,28 +312,7 @@ function PermissionMatrixTab() { )} - {/* Section 1: Maintenance Toggles */} - - - Wartungsmodus - - Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet. - - {featureGroups.map((fg: FeatureGroup) => ( - - maintenanceMutation.mutate({ featureGroup: fg.id, active: !(maintenance[fg.id] ?? false) })} - disabled={maintenanceMutation.isPending} />} - label={fg.label} - /> - {maintenance[fg.id] && } - - ))} - - - - {/* Section 2: Dependency Configuration */} + {/* Section 1: Dependency Configuration */} diff --git a/frontend/src/components/admin/ServiceModeTab.tsx b/frontend/src/components/admin/ServiceModeTab.tsx index 9f2bdb4..a9a1fb0 100644 --- a/frontend/src/components/admin/ServiceModeTab.tsx +++ b/frontend/src/components/admin/ServiceModeTab.tsx @@ -1,12 +1,14 @@ import { useState, useEffect } from 'react'; import { Box, Card, CardContent, Typography, Switch, FormControlLabel, - TextField, Button, Alert, CircularProgress, + TextField, Button, Alert, CircularProgress, Chip, } from '@mui/material'; import BuildIcon from '@mui/icons-material/Build'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { settingsApi } from '../../services/settings'; +import { permissionsApi } from '../../services/permissions'; import { useNotification } from '../../contexts/NotificationContext'; +import type { PermissionMatrix, FeatureGroup } from '../../types/permissions.types'; export default function ServiceModeTab() { const queryClient = useQueryClient(); @@ -17,6 +19,11 @@ export default function ServiceModeTab() { queryFn: () => settingsApi.get('service_mode'), }); + const { data: matrix, isLoading: matrixLoading } = useQuery({ + queryKey: ['admin-permission-matrix'], + queryFn: permissionsApi.getMatrix, + }); + const currentValue = setting?.value ?? { active: false, message: '' }; const [active, setActive] = useState(currentValue.active ?? false); const [message, setMessage] = useState(currentValue.message ?? ''); @@ -46,17 +53,28 @@ export default function ServiceModeTab() { mutation.mutate({ active, message, ends_at: endsAt || null }); }; + const maintenanceMutation = useMutation({ + mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) => + permissionsApi.setMaintenanceFlag(featureGroup, active), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); + queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); + showSuccess('Feature-Wartungsmodus aktualisiert'); + }, + onError: () => showError('Fehler beim Aktualisieren des Feature-Wartungsmodus'), + }); + if (isLoading) { return ; } return ( - + - Wartungsmodus + Globaler Wartungsmodus {active && ( @@ -117,6 +135,39 @@ export default function ServiceModeTab() { + + + + Feature Wartungsmodus + + Einzelne Funktionen in den Wartungsmodus versetzen. Betroffene Bereiche werden für alle Benutzer ausser Administratoren ausgeblendet. + + {matrixLoading ? ( + + + + ) : matrix ? ( + matrix.featureGroups.map((fg: FeatureGroup) => ( + + maintenanceMutation.mutate({ + featureGroup: fg.id, + active: !(matrix.maintenance[fg.id] ?? false), + })} + disabled={maintenanceMutation.isPending} + /> + } + label={fg.label} + /> + {matrix.maintenance[fg.id] && } + + )) + ) : null} + + ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx index 649dab5..a5a0e69 100644 --- a/frontend/src/pages/Ausruestung.tsx +++ b/frontend/src/pages/Ausruestung.tsx @@ -9,14 +9,25 @@ import { Chip, CircularProgress, Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, FormControl, FormControlLabel, Grid, + IconButton, InputAdornment, InputLabel, MenuItem, Select, Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, TextField, Tooltip, Typography, @@ -25,11 +36,16 @@ import { Add, Build, CheckCircle, + Close, + Delete, + Edit, Error as ErrorIcon, LinkRounded, PauseCircle, RemoveCircle, + Save, Search, + Settings, Star, Warning, } from '@mui/icons-material'; @@ -44,6 +60,7 @@ import { EquipmentStats, } from '../types/equipment.types'; import { usePermissions } from '../hooks/usePermissions'; +import { useNotification } from '../contexts/NotificationContext'; import ChatAwareFab from '../components/shared/ChatAwareFab'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -219,11 +236,186 @@ const EquipmentCard: React.FC = ({ item, onClick }) => { ); }; +// ── Category Management Dialog ─────────────────────────────────────────────── + +interface CategoryDialogProps { + open: boolean; + onClose: () => void; + categories: AusruestungKategorie[]; + onRefresh: () => void; +} + +const CategoryManagementDialog: React.FC = ({ open, onClose, categories, onRefresh }) => { + const { showSuccess, showError } = useNotification(); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editKurzname, setEditKurzname] = useState(''); + const [editMotor, setEditMotor] = useState(false); + const [newName, setNewName] = useState(''); + const [newKurzname, setNewKurzname] = useState(''); + const [newMotor, setNewMotor] = useState(false); + const [saving, setSaving] = useState(false); + + const startEdit = (cat: AusruestungKategorie) => { + setEditingId(cat.id); + setEditName(cat.name); + setEditKurzname(cat.kurzname); + setEditMotor(cat.motorisiert); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditName(''); + setEditKurzname(''); + setEditMotor(false); + }; + + const saveEdit = async () => { + if (!editingId || !editName.trim() || !editKurzname.trim()) return; + setSaving(true); + try { + await equipmentApi.updateCategory(editingId, { name: editName.trim(), kurzname: editKurzname.trim(), motorisiert: editMotor }); + showSuccess('Kategorie aktualisiert'); + cancelEdit(); + onRefresh(); + } catch { + showError('Kategorie konnte nicht aktualisiert werden'); + } finally { + setSaving(false); + } + }; + + const handleCreate = async () => { + if (!newName.trim() || !newKurzname.trim()) return; + setSaving(true); + try { + await equipmentApi.createCategory({ name: newName.trim(), kurzname: newKurzname.trim(), motorisiert: newMotor }); + showSuccess('Kategorie erstellt'); + setNewName(''); + setNewKurzname(''); + setNewMotor(false); + onRefresh(); + } catch { + showError('Kategorie konnte nicht erstellt werden'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + setSaving(true); + try { + await equipmentApi.deleteCategory(id); + showSuccess('Kategorie gelöscht'); + onRefresh(); + } catch (err: any) { + const msg = err?.response?.data?.message || 'Kategorie konnte nicht gelöscht werden'; + showError(msg); + } finally { + setSaving(false); + } + }; + + return ( + + + Kategorien verwalten + + + + + + + + Name + Kurzname + Motorisiert + Aktionen + + + + {categories.map((cat) => ( + + {editingId === cat.id ? ( + <> + + setEditName(e.target.value)} fullWidth /> + + + setEditKurzname(e.target.value)} fullWidth /> + + + setEditMotor(e.target.checked)} size="small" /> + + + + + + + + + + + ) : ( + <> + {cat.name} + {cat.kurzname} + {cat.motorisiert ? 'Ja' : 'Nein'} + + startEdit(cat)} disabled={saving}> + + + handleDelete(cat.id)} disabled={saving} color="error"> + + + + + )} + + ))} + {/* New category row */} + + + setNewName(e.target.value)} fullWidth /> + + + setNewKurzname(e.target.value)} fullWidth /> + + + setNewMotor(e.target.checked)} size="small" /> + + + + + + +
+
+
+ + + +
+ ); +}; + // ── Main Page ───────────────────────────────────────────────────────────────── function Ausruestung() { const navigate = useNavigate(); - const { canManageEquipment } = usePermissions(); + const { canManageEquipment, hasPermission } = usePermissions(); + const canManageCategories = hasPermission('ausruestung:manage_categories'); + + // Category dialog state + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); // Data state const [equipment, setEquipment] = useState([]); @@ -310,9 +502,18 @@ function Ausruestung() { {/* Header */} - - Ausrüstungsverwaltung - + + + Ausrüstungsverwaltung + + {canManageCategories && ( + + setCategoryDialogOpen(true)} size="small"> + + + + )} + {!loading && stats && ( @@ -472,6 +673,15 @@ function Ausruestung() { )} + {/* Category management dialog */} + {canManageCategories && ( + setCategoryDialogOpen(false)} + categories={categories} + onRefresh={fetchData} + /> + )} ); diff --git a/frontend/src/services/equipment.ts b/frontend/src/services/equipment.ts index d0e9822..ae271c0 100644 --- a/frontend/src/services/equipment.ts +++ b/frontend/src/services/equipment.ts @@ -137,4 +137,30 @@ export const equipmentApi = { } return response.data.data; }, + + async createCategory(payload: { name: string; kurzname: string; sortierung?: number; motorisiert?: boolean }): Promise { + const response = await api.post<{ success: boolean; data: AusruestungKategorie }>( + '/api/equipment/categories', + payload + ); + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; + }, + + async updateCategory(id: string, payload: { name?: string; kurzname?: string; sortierung?: number; motorisiert?: boolean }): Promise { + const response = await api.patch<{ success: boolean; data: AusruestungKategorie }>( + `/api/equipment/categories/${id}`, + payload + ); + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; + }, + + async deleteCategory(id: string): Promise { + await api.delete(`/api/equipment/categories/${id}`); + }, };