new features
This commit is contained in:
@@ -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> {
|
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
||||||
const { wartungId } = req.params as Record<string, string>;
|
const { wartungId } = req.params as Record<string, string>;
|
||||||
const id = parseInt(wartungId, 10);
|
const id = parseInt(wartungId, 10);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -12,6 +12,9 @@ router.get('/', authenticate, requirePermission('ausruestung:
|
|||||||
router.get('/stats', authenticate, requirePermission('ausruestung:view'), equipmentController.getStats.bind(equipmentController));
|
router.get('/stats', authenticate, requirePermission('ausruestung:view'), equipmentController.getStats.bind(equipmentController));
|
||||||
router.get('/alerts', authenticate, requirePermission('ausruestung:view'), equipmentController.getAlerts.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.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-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController));
|
||||||
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
|
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
|
||||||
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
|
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
|
||||||
|
|||||||
@@ -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
|
// CRUD
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControlLabel,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Switch,
|
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -153,17 +151,6 @@ function PermissionMatrixTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── 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({
|
const permissionMutation = useMutation({
|
||||||
mutationFn: (updates: { group: string; permissions: string[] }[]) =>
|
mutationFn: (updates: { group: string; permissions: string[] }[]) =>
|
||||||
permissionsApi.setBulkPermissions(updates),
|
permissionsApi.setBulkPermissions(updates),
|
||||||
@@ -325,28 +312,7 @@ function PermissionMatrixTab() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Section 1: Maintenance Toggles */}
|
{/* Section 1: Dependency Configuration */}
|
||||||
<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 */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Card, CardContent, Typography, Switch, FormControlLabel,
|
Box, Card, CardContent, Typography, Switch, FormControlLabel,
|
||||||
TextField, Button, Alert, CircularProgress,
|
TextField, Button, Alert, CircularProgress, Chip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { settingsApi } from '../../services/settings';
|
import { settingsApi } from '../../services/settings';
|
||||||
|
import { permissionsApi } from '../../services/permissions';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import type { PermissionMatrix, FeatureGroup } from '../../types/permissions.types';
|
||||||
|
|
||||||
export default function ServiceModeTab() {
|
export default function ServiceModeTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -17,6 +19,11 @@ export default function ServiceModeTab() {
|
|||||||
queryFn: () => settingsApi.get('service_mode'),
|
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 currentValue = setting?.value ?? { active: false, message: '' };
|
||||||
const [active, setActive] = useState<boolean>(currentValue.active ?? false);
|
const [active, setActive] = useState<boolean>(currentValue.active ?? false);
|
||||||
const [message, setMessage] = useState<string>(currentValue.message ?? '');
|
const [message, setMessage] = useState<string>(currentValue.message ?? '');
|
||||||
@@ -46,17 +53,28 @@ export default function ServiceModeTab() {
|
|||||||
mutation.mutate({ active, message, ends_at: endsAt || null });
|
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) {
|
if (isLoading) {
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 600 }}>
|
<Box sx={{ maxWidth: 600, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<BuildIcon color={active ? 'error' : 'action'} />
|
<BuildIcon color={active ? 'error' : 'action'} />
|
||||||
<Typography variant="h6">Wartungsmodus</Typography>
|
<Typography variant="h6">Globaler Wartungsmodus</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{active && (
|
{active && (
|
||||||
@@ -117,6 +135,39 @@ export default function ServiceModeTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,14 +9,25 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Grid,
|
Grid,
|
||||||
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -25,11 +36,16 @@ import {
|
|||||||
Add,
|
Add,
|
||||||
Build,
|
Build,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Close,
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
LinkRounded,
|
LinkRounded,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
RemoveCircle,
|
RemoveCircle,
|
||||||
|
Save,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
Star,
|
Star,
|
||||||
Warning,
|
Warning,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
@@ -44,6 +60,7 @@ import {
|
|||||||
EquipmentStats,
|
EquipmentStats,
|
||||||
} from '../types/equipment.types';
|
} from '../types/equipment.types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
|
|
||||||
// ── Status chip config ────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Ausruestung() {
|
function Ausruestung() {
|
||||||
const navigate = useNavigate();
|
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
|
// Data state
|
||||||
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
|
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
|
||||||
@@ -310,9 +502,18 @@ function Ausruestung() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
<Box>
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||||
Ausrüstungsverwaltung
|
Ausrüstungsverwaltung
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{canManageCategories && (
|
||||||
|
<Tooltip title="Kategorien verwalten">
|
||||||
|
<IconButton onClick={() => setCategoryDialogOpen(true)} size="small">
|
||||||
|
<Settings />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
{!loading && stats && (
|
{!loading && stats && (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
@@ -472,6 +673,15 @@ function Ausruestung() {
|
|||||||
<Add />
|
<Add />
|
||||||
</ChatAwareFab>
|
</ChatAwareFab>
|
||||||
)}
|
)}
|
||||||
|
{/* Category management dialog */}
|
||||||
|
{canManageCategories && (
|
||||||
|
<CategoryManagementDialog
|
||||||
|
open={categoryDialogOpen}
|
||||||
|
onClose={() => setCategoryDialogOpen(false)}
|
||||||
|
categories={categories}
|
||||||
|
onRefresh={fetchData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -137,4 +137,30 @@ export const equipmentApi = {
|
|||||||
}
|
}
|
||||||
return response.data.data;
|
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}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user