resolve issues with new features
This commit is contained in:
318
frontend/src/pages/AdminSettings.tsx
Normal file
318
frontend/src/pages/AdminSettings.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Divider,
|
||||
TextField,
|
||||
Button,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
Add,
|
||||
Link as LinkIcon,
|
||||
Timer,
|
||||
Info,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { settingsApi } from '../services/settings';
|
||||
|
||||
interface ExternalLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface RefreshIntervals {
|
||||
dashboardWidgets: number;
|
||||
adminServices: number;
|
||||
}
|
||||
|
||||
const DASHBOARD_INTERVAL_OPTIONS = [
|
||||
{ value: 30, label: '30 Sekunden' },
|
||||
{ value: 60, label: '1 Minute' },
|
||||
{ value: 300, label: '5 Minuten' },
|
||||
{ value: 600, label: '10 Minuten' },
|
||||
];
|
||||
|
||||
const ADMIN_INTERVAL_OPTIONS = [
|
||||
{ value: 5, label: '5 Sekunden' },
|
||||
{ value: 15, label: '15 Sekunden' },
|
||||
{ value: 30, label: '30 Sekunden' },
|
||||
{ value: 60, label: '60 Sekunden' },
|
||||
];
|
||||
|
||||
function AdminSettings() {
|
||||
const { user } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
|
||||
// State for external links
|
||||
const [externalLinks, setExternalLinks] = useState<ExternalLink[]>([]);
|
||||
|
||||
// State for refresh intervals
|
||||
const [refreshIntervals, setRefreshIntervals] = useState<RefreshIntervals>({
|
||||
dashboardWidgets: 60,
|
||||
adminServices: 15,
|
||||
});
|
||||
|
||||
// Fetch all settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsApi.getAll(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
// Initialize state from fetched settings
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const linksSetting = settings.find((s) => s.key === 'external_links');
|
||||
if (linksSetting?.value) {
|
||||
setExternalLinks(linksSetting.value);
|
||||
}
|
||||
|
||||
const intervalsSetting = settings.find((s) => s.key === 'refresh_intervals');
|
||||
if (intervalsSetting?.value) {
|
||||
setRefreshIntervals({
|
||||
dashboardWidgets: intervalsSetting.value.dashboardWidgets ?? 60,
|
||||
adminServices: intervalsSetting.value.adminServices ?? 15,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Mutation for saving external links
|
||||
const linksMutation = useMutation({
|
||||
mutationFn: (links: ExternalLink[]) => settingsApi.update('external_links', links),
|
||||
onSuccess: () => {
|
||||
showSuccess('Externe Links gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['external-links'] });
|
||||
},
|
||||
onError: () => {
|
||||
showError('Fehler beim Speichern der externen Links');
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation for saving refresh intervals
|
||||
const intervalsMutation = useMutation({
|
||||
mutationFn: (intervals: RefreshIntervals) => settingsApi.update('refresh_intervals', intervals),
|
||||
onSuccess: () => {
|
||||
showSuccess('Aktualisierungsintervalle gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
showError('Fehler beim Speichern der Aktualisierungsintervalle');
|
||||
},
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const handleAddLink = () => {
|
||||
setExternalLinks([...externalLinks, { name: '', url: '' }]);
|
||||
};
|
||||
|
||||
const handleRemoveLink = (index: number) => {
|
||||
setExternalLinks(externalLinks.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleLinkChange = (index: number, field: keyof ExternalLink, value: string) => {
|
||||
const updated = [...externalLinks];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setExternalLinks(updated);
|
||||
};
|
||||
|
||||
const handleSaveLinks = () => {
|
||||
linksMutation.mutate(externalLinks);
|
||||
};
|
||||
|
||||
const handleSaveIntervals = () => {
|
||||
intervalsMutation.mutate(refreshIntervals);
|
||||
};
|
||||
|
||||
// Find the most recent updated_at
|
||||
const lastUpdated = settings?.reduce((latest, s) => {
|
||||
if (!latest) return s.updated_at;
|
||||
return new Date(s.updated_at) > new Date(latest) ? s.updated_at : latest;
|
||||
}, '' as string);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Admin-Einstellungen
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Section 1: External Links */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<LinkIcon color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">FF Rems Tools — Externe Links</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Stack spacing={2}>
|
||||
{externalLinks.map((link, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={link.name}
|
||||
onChange={(e) => handleLinkChange(index, 'name', e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="URL"
|
||||
value={link.url}
|
||||
onChange={(e) => handleLinkChange(index, 'url', e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 2 }}
|
||||
/>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleRemoveLink(index)}
|
||||
aria-label="Link entfernen"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
onClick={handleAddLink}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Link hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveLinks}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={linksMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Refresh Intervals */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Timer color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Aktualisierungsintervalle</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Stack spacing={3}>
|
||||
<FormControl size="small" sx={{ maxWidth: 300 }}>
|
||||
<InputLabel>Dashboard Widgets</InputLabel>
|
||||
<Select
|
||||
value={refreshIntervals.dashboardWidgets}
|
||||
label="Dashboard Widgets"
|
||||
onChange={(e) =>
|
||||
setRefreshIntervals((prev) => ({
|
||||
...prev,
|
||||
dashboardWidgets: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{DASHBOARD_INTERVAL_OPTIONS.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ maxWidth: 300 }}>
|
||||
<InputLabel>Admin Services</InputLabel>
|
||||
<Select
|
||||
value={refreshIntervals.adminServices}
|
||||
label="Admin Services"
|
||||
onChange={(e) =>
|
||||
setRefreshIntervals((prev) => ({
|
||||
...prev,
|
||||
adminServices: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{ADMIN_INTERVAL_OPTIONS.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
onClick={handleSaveIntervals}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={intervalsMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Info color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Info</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{lastUpdated
|
||||
? `Letzte Aktualisierung: ${new Date(lastUpdated).toLocaleString('de-DE')}`
|
||||
: 'Noch keine Einstellungen gespeichert.'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminSettings;
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Tooltip,
|
||||
Paper,
|
||||
TextField,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
@@ -37,7 +36,7 @@ import { de } from 'date-fns/locale';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import {
|
||||
incidentsApi,
|
||||
EinsatzDetail,
|
||||
EinsatzDetail as EinsatzDetailType,
|
||||
EinsatzStatus,
|
||||
EINSATZ_ART_LABELS,
|
||||
EINSATZ_STATUS_LABELS,
|
||||
@@ -99,7 +98,7 @@ function initials(givenName: string | null, familyName: string | null, name: str
|
||||
return '??';
|
||||
}
|
||||
|
||||
function displayName(p: EinsatzDetail['personal'][0]): string {
|
||||
function displayName(p: EinsatzDetailType['personal'][0]): string {
|
||||
if (p.given_name && p.family_name) return `${p.given_name} ${p.family_name}`;
|
||||
if (p.name) return p.name;
|
||||
return p.email;
|
||||
@@ -166,7 +165,7 @@ function EinsatzDetail() {
|
||||
const navigate = useNavigate();
|
||||
const notification = useNotification();
|
||||
|
||||
const [einsatz, setEinsatz] = useState<EinsatzDetail | null>(null);
|
||||
const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -398,7 +398,7 @@ function FahrzeugBuchungen() {
|
||||
<TableContainer component={Paper} elevation={1}>
|
||||
<Table size="small" sx={{ tableLayout: 'fixed' }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.100' }}>
|
||||
<TableRow sx={{ bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100' }}>
|
||||
<TableCell sx={{ width: 160, fontWeight: 700 }}>
|
||||
Fahrzeug
|
||||
</TableCell>
|
||||
@@ -409,7 +409,7 @@ function FahrzeugBuchungen() {
|
||||
sx={{
|
||||
fontWeight: isToday(day) ? 700 : 400,
|
||||
color: isToday(day) ? 'primary.main' : 'text.primary',
|
||||
bgcolor: isToday(day) ? 'primary.50' : undefined,
|
||||
bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" display="block">
|
||||
@@ -445,10 +445,10 @@ function FahrzeugBuchungen() {
|
||||
isFree ? handleCellClick(vehicle.id, day) : undefined
|
||||
}
|
||||
sx={{
|
||||
bgcolor: isFree ? 'success.50' : undefined,
|
||||
bgcolor: isFree ? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50' : undefined,
|
||||
cursor: isFree && canCreate ? 'pointer' : 'default',
|
||||
'&:hover': isFree && canCreate
|
||||
? { bgcolor: 'success.100' }
|
||||
? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' }
|
||||
: {},
|
||||
p: 0.5,
|
||||
verticalAlign: 'top',
|
||||
@@ -511,9 +511,9 @@ function FahrzeugBuchungen() {
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
bgcolor: 'success.50',
|
||||
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'success.300',
|
||||
borderColor: (theme) => theme.palette.mode === 'dark' ? 'success.700' : 'success.300',
|
||||
borderRadius: 0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -59,7 +59,7 @@ import { vehiclesApi } from '../services/vehicles';
|
||||
import { fromGermanDate } from '../utils/dateInput';
|
||||
import { equipmentApi } from '../services/equipment';
|
||||
import {
|
||||
FahrzeugDetail,
|
||||
FahrzeugDetail as FahrzeugDetailType,
|
||||
FahrzeugWartungslog,
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
@@ -121,7 +121,7 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err
|
||||
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface UebersichtTabProps {
|
||||
vehicle: FahrzeugDetail;
|
||||
vehicle: FahrzeugDetailType;
|
||||
onStatusUpdated: () => void;
|
||||
canChangeStatus: boolean;
|
||||
}
|
||||
@@ -523,7 +523,7 @@ interface AusruestungTabProps {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId }) => {
|
||||
const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId: _vehicleId }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const hasProblems = equipment.some(
|
||||
@@ -642,7 +642,7 @@ function FahrzeugDetail() {
|
||||
const { isAdmin, canChangeStatus } = usePermissions();
|
||||
const notification = useNotification();
|
||||
|
||||
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
|
||||
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Container,
|
||||
Fab,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Tooltip,
|
||||
|
||||
@@ -13,9 +13,6 @@ import {
|
||||
Tab,
|
||||
Grid,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
@@ -44,11 +41,9 @@ import {
|
||||
MemberWithProfile,
|
||||
StatusEnum,
|
||||
DienstgradEnum,
|
||||
FunktionEnum,
|
||||
TshirtGroesseEnum,
|
||||
DIENSTGRAD_VALUES,
|
||||
STATUS_VALUES,
|
||||
FUNKTION_VALUES,
|
||||
TSHIRT_GROESSE_VALUES,
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Avatar,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
@@ -301,7 +300,7 @@ export default function UebungDetail() {
|
||||
const rsvpMutation = useMutation({
|
||||
mutationFn: (status: 'zugesagt' | 'abgesagt') =>
|
||||
trainingApi.updateRsvp(id!, status),
|
||||
onSuccess: (_data, status) => {
|
||||
onSuccess: (_data, _status) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['training', 'event', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['training', 'upcoming'] });
|
||||
setRsvpLoading(null);
|
||||
@@ -342,7 +341,7 @@ export default function UebungDetail() {
|
||||
}
|
||||
|
||||
const isPast = new Date(event.datum_von) < new Date();
|
||||
const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt';
|
||||
// const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt';
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
|
||||
@@ -504,13 +504,13 @@ const AuditLog: React.FC = () => {
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setApplied(filters);
|
||||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
||||
setPaginationModel((prev: any) => ({ ...prev, page: 0 }));
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setApplied(DEFAULT_FILTERS);
|
||||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
||||
setPaginationModel((prev: any) => ({ ...prev, page: 0 }));
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -723,7 +723,7 @@ const AuditLog: React.FC = () => {
|
||||
noRowsLabel: 'Keine Eintraege gefunden',
|
||||
MuiTablePagination: {
|
||||
labelRowsPerPage: 'Eintraege pro Seite:',
|
||||
labelDisplayedRows: ({ from, to, count }) =>
|
||||
labelDisplayedRows: ({ from, to, count }: { from: any; to: any; count: any }) =>
|
||||
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
|
||||
},
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user