Files
dashboard/frontend/src/pages/AdminSettings.tsx
2026-03-12 17:20:32 +01:00

552 lines
20 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Container,
Typography,
Card,
CardContent,
Box,
Divider,
TextField,
Button,
IconButton,
MenuItem,
Select,
FormControl,
InputLabel,
Stack,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
Delete,
Add,
Link as LinkIcon,
Timer,
Info,
ExpandMore,
PictureAsPdf as PdfIcon,
} 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 LinkCollection {
id: string;
name: string;
links: Array<{ 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 link collections
const [linkCollections, setLinkCollections] = useState<LinkCollection[]>([]);
// State for refresh intervals
const [refreshIntervals, setRefreshIntervals] = useState<RefreshIntervals>({
dashboardWidgets: 60,
adminServices: 15,
});
// State for PDF header/footer/logo/org
const [pdfHeader, setPdfHeader] = useState('');
const [pdfFooter, setPdfFooter] = useState('');
const [pdfLogo, setPdfLogo] = useState('');
const [pdfOrgName, setPdfOrgName] = useState('');
// 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) {
const val = linksSetting.value;
if (Array.isArray(val) && val.length > 0 && 'links' in val[0]) {
setLinkCollections(val);
} else if (Array.isArray(val)) {
// Old flat format — wrap in single collection
setLinkCollections([{ id: 'default', name: 'Links', links: val }]);
}
}
const intervalsSetting = settings.find((s) => s.key === 'refresh_intervals');
if (intervalsSetting?.value) {
setRefreshIntervals({
dashboardWidgets: intervalsSetting.value.dashboardWidgets ?? 60,
adminServices: intervalsSetting.value.adminServices ?? 15,
});
}
const pdfHeaderSetting = settings.find((s) => s.key === 'pdf_header');
if (pdfHeaderSetting?.value != null) setPdfHeader(pdfHeaderSetting.value);
const pdfFooterSetting = settings.find((s) => s.key === 'pdf_footer');
if (pdfFooterSetting?.value != null) setPdfFooter(pdfFooterSetting.value);
const pdfLogoSetting = settings.find((s) => s.key === 'pdf_logo');
if (pdfLogoSetting?.value != null) setPdfLogo(pdfLogoSetting.value);
const pdfOrgNameSetting = settings.find((s) => s.key === 'pdf_org_name');
if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value);
}
}, [settings]);
// Mutation for saving link collections
const linksMutation = useMutation({
mutationFn: (collections: LinkCollection[]) => settingsApi.update('external_links', collections),
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');
},
});
// Mutation for saving PDF settings
const pdfHeaderMutation = useMutation({
mutationFn: (value: string) => settingsApi.update('pdf_header', value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] });
},
});
const pdfFooterMutation = useMutation({
mutationFn: (value: string) => settingsApi.update('pdf_footer', value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] });
},
});
const pdfLogoMutation = useMutation({
mutationFn: (value: string) => settingsApi.update('pdf_logo', value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] });
},
});
const pdfOrgNameMutation = useMutation({
mutationFn: (value: string) => settingsApi.update('pdf_org_name', value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] });
},
});
const handleSavePdfSettings = async () => {
try {
await Promise.all([
pdfHeaderMutation.mutateAsync(pdfHeader),
pdfFooterMutation.mutateAsync(pdfFooter),
pdfLogoMutation.mutateAsync(pdfLogo),
pdfOrgNameMutation.mutateAsync(pdfOrgName),
]);
showSuccess('PDF-Einstellungen gespeichert');
} catch {
showError('Fehler beim Speichern der PDF-Einstellungen');
}
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setPdfLogo(ev.target?.result as string);
reader.readAsDataURL(file);
};
if (!isAdmin) {
return <Navigate to="/dashboard" replace />;
}
const handleAddCollection = () => {
setLinkCollections([...linkCollections, { id: crypto.randomUUID(), name: '', links: [] }]);
};
const handleRemoveCollection = (id: string) => {
setLinkCollections(linkCollections.filter((c) => c.id !== id));
};
const handleCollectionNameChange = (id: string, name: string) => {
setLinkCollections(linkCollections.map((c) => (c.id === id ? { ...c, name } : c)));
};
const handleAddLink = (collectionId: string) => {
setLinkCollections(
linkCollections.map((c) =>
c.id === collectionId ? { ...c, links: [...c.links, { name: '', url: '' }] } : c
)
);
};
const handleRemoveLink = (collectionId: string, linkIndex: number) => {
setLinkCollections(
linkCollections.map((c) =>
c.id === collectionId ? { ...c, links: c.links.filter((_, i) => i !== linkIndex) } : c
)
);
};
const handleLinkChange = (collectionId: string, linkIndex: number, field: 'name' | 'url', value: string) => {
setLinkCollections(
linkCollections.map((c) =>
c.id === collectionId
? {
...c,
links: c.links.map((l, i) => (i === linkIndex ? { ...l, [field]: value } : l)),
}
: c
)
);
};
const handleSaveLinks = () => {
linksMutation.mutate(linkCollections);
};
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: Link Collections */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<LinkIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">FF Rems Tools Link-Sammlungen</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Stack spacing={1}>
{linkCollections.map((collection) => (
<Accordion key={collection.id} defaultExpanded>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1, mr: 2 }}>
<TextField
label="Sammlungsname"
value={collection.name}
onChange={(e) => handleCollectionNameChange(collection.id, e.target.value)}
size="small"
sx={{ flex: 1 }}
onClick={(e) => e.stopPropagation()}
/>
<IconButton
color="error"
onClick={(e) => {
e.stopPropagation();
handleRemoveCollection(collection.id);
}}
aria-label="Sammlung entfernen"
size="small"
>
<Delete />
</IconButton>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
{collection.links.map((link, linkIndex) => (
<Box key={linkIndex} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="Name"
value={link.name}
onChange={(e) => handleLinkChange(collection.id, linkIndex, 'name', e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="URL"
value={link.url}
onChange={(e) => handleLinkChange(collection.id, linkIndex, 'url', e.target.value)}
size="small"
sx={{ flex: 2 }}
/>
<IconButton
color="error"
onClick={() => handleRemoveLink(collection.id, linkIndex)}
aria-label="Link entfernen"
>
<Delete />
</IconButton>
</Box>
))}
<Box>
<Button
startIcon={<Add />}
onClick={() => handleAddLink(collection.id)}
variant="outlined"
size="small"
>
Link hinzufügen
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
))}
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
<Button
startIcon={<Add />}
onClick={handleAddCollection}
variant="outlined"
size="small"
>
Neue Sammlung
</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: PDF Settings */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<PdfIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">PDF-Einstellungen</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Kopf- und Fußzeile für Kalender-PDF-Exporte. Zeilenumbrüche werden übernommen, <strong>**fett**</strong> erzeugt fettgedruckten Text.
</Typography>
<Stack spacing={2}>
<TextField
label="Organisationsname (rechts im PDF-Header)"
value={pdfOrgName}
onChange={(e) => setPdfOrgName(e.target.value)}
fullWidth
size="small"
placeholder="FREIWILLIGE FEUERWEHR REMS"
/>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Logo (erscheint rechts im PDF-Header, neben dem Organisationsnamen)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button component="label" variant="outlined" size="small">
Logo hochladen
<input
type="file"
hidden
accept="image/*"
onChange={handleLogoUpload}
/>
</Button>
{pdfLogo && (
<>
<Box
component="img"
src={pdfLogo}
alt="Logo Vorschau"
sx={{ height: 40, maxWidth: 120, objectFit: 'contain', borderRadius: 1 }}
/>
<IconButton size="small" onClick={() => setPdfLogo('')} title="Logo entfernen">
<Delete fontSize="small" />
</IconButton>
</>
)}
</Box>
</Box>
<TextField
label="PDF Kopfzeile (links, unter dem Header-Banner)"
value={pdfHeader}
onChange={(e) => setPdfHeader(e.target.value)}
multiline
minRows={2}
maxRows={6}
fullWidth
size="small"
/>
<TextField
label="PDF Fußzeile"
value={pdfFooter}
onChange={(e) => setPdfFooter(e.target.value)}
multiline
minRows={2}
maxRows={6}
fullWidth
size="small"
/>
<Box>
<Button
onClick={handleSavePdfSettings}
variant="contained"
size="small"
disabled={
pdfHeaderMutation.isPending ||
pdfFooterMutation.isPending ||
pdfLogoMutation.isPending ||
pdfOrgNameMutation.isPending
}
>
Speichern
</Button>
</Box>
</Stack>
</CardContent>
</Card>
{/* Section 4: 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;