new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 16:58:46 +01:00
parent 948b211f70
commit 55ded22a6f
8 changed files with 452 additions and 43 deletions

View File

@@ -451,6 +451,86 @@ class EquipmentController {
}
}
async createCategory(req: Request, res: Response): Promise<void> {
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<void> {
try {
const { id } = req.params as Record<string, string>;
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<string, unknown> = {};
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<void> {
try {
const { id } = req.params as Record<string, string>;
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<void> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);

View File

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

View File

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

View File

@@ -132,6 +132,75 @@ class EquipmentService {
}
}
async createCategory(data: { name: string; kurzname: string; sortierung?: number; motorisiert?: boolean }): Promise<AusruestungKategorie> {
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<AusruestungKategorie | null> {
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
// =========================================================================

View File

@@ -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() {
</Alert>
)}
{/* Section 1: Maintenance Toggles */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Wartungsmodus</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet.
</Typography>
{featureGroups.map((fg: FeatureGroup) => (
<Box key={fg.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<FormControlLabel
control={<Switch checked={maintenance[fg.id] ?? false}
onChange={() => maintenanceMutation.mutate({ featureGroup: fg.id, active: !(maintenance[fg.id] ?? false) })}
disabled={maintenanceMutation.isPending} />}
label={fg.label}
/>
{maintenance[fg.id] && <Chip label="Wartungsmodus" color="warning" size="small" />}
</Box>
))}
</CardContent>
</Card>
{/* Section 2: Dependency Configuration */}
{/* Section 1: Dependency Configuration */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>

View File

@@ -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<PermissionMatrix>({
queryKey: ['admin-permission-matrix'],
queryFn: permissionsApi.getMatrix,
});
const currentValue = setting?.value ?? { active: false, message: '' };
const [active, setActive] = useState<boolean>(currentValue.active ?? false);
const [message, setMessage] = useState<string>(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 <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
return (
<Box sx={{ maxWidth: 600 }}>
<Box sx={{ maxWidth: 600, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<BuildIcon color={active ? 'error' : 'action'} />
<Typography variant="h6">Wartungsmodus</Typography>
<Typography variant="h6">Globaler Wartungsmodus</Typography>
</Box>
{active && (
@@ -117,6 +135,39 @@ export default function ServiceModeTab() {
</Button>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Feature Wartungsmodus</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Einzelne Funktionen in den Wartungsmodus versetzen. Betroffene Bereiche werden für alle Benutzer ausser Administratoren ausgeblendet.
</Typography>
{matrixLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : matrix ? (
matrix.featureGroups.map((fg: FeatureGroup) => (
<Box key={fg.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<FormControlLabel
control={
<Switch
checked={matrix.maintenance[fg.id] ?? false}
onChange={() => maintenanceMutation.mutate({
featureGroup: fg.id,
active: !(matrix.maintenance[fg.id] ?? false),
})}
disabled={maintenanceMutation.isPending}
/>
}
label={fg.label}
/>
{matrix.maintenance[fg.id] && <Chip label="Wartungsmodus" color="warning" size="small" />}
</Box>
))
) : null}
</CardContent>
</Card>
</Box>
);
}

View File

@@ -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<EquipmentCardProps> = ({ item, onClick }) => {
);
};
// ── Category Management Dialog ───────────────────────────────────────────────
interface CategoryDialogProps {
open: boolean;
onClose: () => void;
categories: AusruestungKategorie[];
onRefresh: () => void;
}
const CategoryManagementDialog: React.FC<CategoryDialogProps> = ({ open, onClose, categories, onRefresh }) => {
const { showSuccess, showError } = useNotification();
const [editingId, setEditingId] = useState<string | null>(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 (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Kategorien verwalten
<IconButton onClick={onClose} size="small"><Close /></IconButton>
</DialogTitle>
<DialogContent dividers>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Kurzname</TableCell>
<TableCell>Motorisiert</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{categories.map((cat) => (
<TableRow key={cat.id}>
{editingId === cat.id ? (
<>
<TableCell>
<TextField size="small" value={editName} onChange={(e) => setEditName(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<TextField size="small" value={editKurzname} onChange={(e) => setEditKurzname(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<Switch checked={editMotor} onChange={(e) => setEditMotor(e.target.checked)} size="small" />
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={saveEdit} disabled={saving || !editName.trim() || !editKurzname.trim()} color="primary">
<Save fontSize="small" />
</IconButton>
<IconButton size="small" onClick={cancelEdit} disabled={saving}>
<Close fontSize="small" />
</IconButton>
</TableCell>
</>
) : (
<>
<TableCell>{cat.name}</TableCell>
<TableCell>{cat.kurzname}</TableCell>
<TableCell>{cat.motorisiert ? 'Ja' : 'Nein'}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => startEdit(cat)} disabled={saving}>
<Edit fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(cat.id)} disabled={saving} color="error">
<Delete fontSize="small" />
</IconButton>
</TableCell>
</>
)}
</TableRow>
))}
{/* New category row */}
<TableRow>
<TableCell>
<TextField size="small" placeholder="Name" value={newName} onChange={(e) => setNewName(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<TextField size="small" placeholder="Kurzname" value={newKurzname} onChange={(e) => setNewKurzname(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<Switch checked={newMotor} onChange={(e) => setNewMotor(e.target.checked)} size="small" />
</TableCell>
<TableCell align="right">
<Button
variant="contained"
size="small"
startIcon={<Add />}
onClick={handleCreate}
disabled={saving || !newName.trim() || !newKurzname.trim()}
>
Hinzufügen
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);
};
// ── 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<AusruestungListItem[]>([]);
@@ -310,9 +502,18 @@ function Ausruestung() {
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung
</Typography>
{canManageCategories && (
<Tooltip title="Kategorien verwalten">
<IconButton onClick={() => setCategoryDialogOpen(true)} size="small">
<Settings />
</IconButton>
</Tooltip>
)}
</Box>
{!loading && stats && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
<Typography variant="body2" color="text.secondary">
@@ -472,6 +673,15 @@ function Ausruestung() {
<Add />
</ChatAwareFab>
)}
{/* Category management dialog */}
{canManageCategories && (
<CategoryManagementDialog
open={categoryDialogOpen}
onClose={() => setCategoryDialogOpen(false)}
categories={categories}
onRefresh={fetchData}
/>
)}
</Container>
</DashboardLayout>
);

View File

@@ -137,4 +137,30 @@ export const equipmentApi = {
}
return response.data.data;
},
async createCategory(payload: { name: string; kurzname: string; sortierung?: number; motorisiert?: boolean }): Promise<AusruestungKategorie> {
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<AusruestungKategorie> {
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<void> {
await api.delete(`/api/equipment/categories/${id}`);
},
};