597 lines
21 KiB
TypeScript
597 lines
21 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,
|
|
Settings as SettingsIcon,
|
|
} 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 { usePermissionContext } from '../contexts/PermissionContext';
|
|
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 { hasPermission } = usePermissionContext();
|
|
const { showSuccess, showError } = useNotification();
|
|
const queryClient = useQueryClient();
|
|
|
|
const canAccess = hasPermission('admin:write');
|
|
|
|
// 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('');
|
|
|
|
// State for app logo
|
|
const [appLogo, setAppLogo] = useState('');
|
|
|
|
// Fetch all settings
|
|
const { data: settings, isLoading } = useQuery({
|
|
queryKey: ['admin-settings'],
|
|
queryFn: () => settingsApi.getAll(),
|
|
enabled: canAccess,
|
|
});
|
|
|
|
// 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);
|
|
const appLogoSetting = settings.find((s) => s.key === 'app_logo');
|
|
if (appLogoSetting?.value != null) setAppLogo(appLogoSetting.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');
|
|
}
|
|
};
|
|
// App logo mutation + handlers
|
|
const appLogoMutation = useMutation({
|
|
mutationFn: (value: string) => settingsApi.update('app_logo', value),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
|
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] });
|
|
},
|
|
});
|
|
const handleAppLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => setAppLogo(ev.target?.result as string);
|
|
reader.readAsDataURL(file);
|
|
};
|
|
const handleSaveAppLogo = async () => {
|
|
try {
|
|
await appLogoMutation.mutateAsync(appLogo);
|
|
showSuccess('Logo gespeichert');
|
|
} catch {
|
|
showError('Fehler beim Speichern des Logos');
|
|
}
|
|
};
|
|
|
|
if (!canAccess) {
|
|
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: General Settings (App Logo) */}
|
|
<Card>
|
|
<CardContent>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
<SettingsIcon color="primary" sx={{ mr: 2 }} />
|
|
<Typography variant="h6">Allgemeine Einstellungen</Typography>
|
|
</Box>
|
|
<Divider sx={{ mb: 2 }} />
|
|
<Stack spacing={2}>
|
|
<Box>
|
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
Logo (wird im Header und in PDF-Exporten verwendet)
|
|
</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={handleAppLogoUpload}
|
|
/>
|
|
</Button>
|
|
{appLogo && (
|
|
<>
|
|
<Box
|
|
component="img"
|
|
src={appLogo}
|
|
alt="Logo Vorschau"
|
|
sx={{ height: 40, maxWidth: 120, objectFit: 'contain', borderRadius: 1 }}
|
|
/>
|
|
<IconButton size="small" onClick={() => setAppLogo('')} title="Logo entfernen">
|
|
<Delete fontSize="small" />
|
|
</IconButton>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
<Box>
|
|
<Button
|
|
onClick={handleSaveAppLogo}
|
|
variant="contained"
|
|
size="small"
|
|
disabled={appLogoMutation.isPending}
|
|
>
|
|
Speichern
|
|
</Button>
|
|
</Box>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Section 2: 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 3: 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 4: 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"
|
|
/>
|
|
<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 5: 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;
|