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

@@ -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}`);
},
};