Files
dashboard/frontend/src/pages/Settings.tsx
Matthias Hochmeister 215528a521 update
2026-03-16 14:41:08 +01:00

408 lines
14 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import {
Container,
Typography,
Card,
CardContent,
Grid,
FormGroup,
FormControlLabel,
Switch,
Divider,
Box,
ToggleButtonGroup,
ToggleButton,
CircularProgress,
Button,
Chip,
} from '@mui/material';
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useThemeMode } from '../contexts/ThemeContext';
import { preferencesApi } from '../services/settings';
import { WIDGETS, WidgetKey } from '../constants/widgets';
import { nextcloudApi } from '../services/nextcloud';
import { useNotification } from '../contexts/NotificationContext';
import { useAuth } from '../contexts/AuthContext';
const POLL_INTERVAL = 2000;
const POLL_TIMEOUT = 5 * 60 * 1000;
function Settings() {
const { themeMode, setThemeMode } = useThemeMode();
const queryClient = useQueryClient();
const { showInfo } = useNotification();
const { user } = useAuth();
const navigate = useNavigate();
const { data: preferences, isLoading: prefsLoading } = useQuery({
queryKey: ['user-preferences'],
queryFn: preferencesApi.get,
});
const mutation = useMutation({
mutationFn: preferencesApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
},
});
const isWidgetVisible = (key: WidgetKey) => {
return preferences?.widgets?.[key] !== false;
};
const toggleWidget = (key: WidgetKey) => {
const current = preferences ?? {};
const widgets = { ...(current.widgets ?? {}) };
widgets[key] = !isWidgetVisible(key);
mutation.mutate({ ...current, widgets });
};
// Nextcloud Talk connection
const { data: ncData, isLoading: ncLoading } = useQuery({
queryKey: ['nextcloud-talk-rooms'],
queryFn: () => nextcloudApi.getRooms(),
retry: 1,
});
const ncConnected = ncData?.connected ?? false;
const ncLoginName = ncData?.loginName;
const [isConnecting, setIsConnecting] = useState(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const popupRef = useRef<Window | null>(null);
// Show one-time info snackbar when not connected
useEffect(() => {
if (!ncLoading && !ncConnected && !sessionStorage.getItem('nextcloud-talk-notified')) {
sessionStorage.setItem('nextcloud-talk-notified', '1');
showInfo('Nextcloud Talk ist nicht verbunden. Verbinde dich in den Einstellungen.');
}
}, [ncLoading, ncConnected, showInfo]);
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
popupRef.current = null;
setIsConnecting(false);
}, []);
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
};
}, []);
const handleConnect = async () => {
try {
setIsConnecting(true);
const { loginUrl, pollToken, pollEndpoint } = await nextcloudApi.connect();
const popup = window.open(loginUrl, '_blank', 'width=600,height=700');
popupRef.current = popup;
const startTime = Date.now();
pollIntervalRef.current = setInterval(async () => {
if (Date.now() - startTime > POLL_TIMEOUT) {
stopPolling();
return;
}
if (popup && popup.closed) {
stopPolling();
return;
}
try {
const result = await nextcloudApi.poll(pollToken, pollEndpoint);
if (result.completed) {
stopPolling();
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'connection'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}
} catch {
// Polling error — keep trying until timeout
}
}, POLL_INTERVAL);
} catch {
setIsConnecting(false);
}
};
const handleDisconnect = async () => {
try {
await nextcloudApi.disconnect();
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk-rooms'] });
} catch {
// Disconnect failed silently
}
};
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Einstellungen
</Typography>
<Grid container spacing={3}>
{/* Widget Visibility */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Widgets color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Dashboard-Widgets</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
{prefsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : (
<FormGroup>
{WIDGETS.map((w) => (
<FormControlLabel
key={w.key}
control={
<Switch
checked={isWidgetVisible(w.key)}
onChange={() => toggleWidget(w.key)}
disabled={mutation.isPending}
/>
}
label={w.label}
/>
))}
</FormGroup>
)}
</CardContent>
</Card>
</Grid>
{/* Nextcloud Talk */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Forum color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Nextcloud Talk</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
{ncLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : ncConnected ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="Verbunden" size="small" color="success" />
{ncLoginName && (
<Typography variant="body2" color="text.secondary">
als {ncLoginName}
</Typography>
)}
</Box>
<Button
variant="outlined"
color="error"
startIcon={<LinkOff />}
onClick={handleDisconnect}
size="small"
>
Verbindung trennen
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="Nicht verbunden" size="small" color="default" />
</Box>
{isConnecting ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary">
Warte auf Bestätigung...
</Typography>
</Box>
) : (
<Button
variant="outlined"
startIcon={<Cloud />}
onClick={handleConnect}
size="small"
>
Mit Nextcloud verbinden
</Button>
)}
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Mitgliedsprofil */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Person color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Mitgliedsprofil</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Persönliche Daten, Standesbuchnummer und weitere Profileinstellungen.
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<OpenInNew />}
onClick={() => navigate(`/mitglieder/${user?.id}`)}
disabled={!user?.id}
>
Zum Mitgliedsprofil
</Button>
</CardContent>
</Card>
</Grid>
{/* Notification Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Notifications color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Benachrichtigungen</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<FormGroup>
<FormControlLabel
control={<Switch checked={false} disabled />}
label="E-Mail-Benachrichtigungen"
/>
<FormControlLabel
control={<Switch checked={false} disabled />}
label="Einsatz-Alarme"
/>
<FormControlLabel
control={<Switch checked={false} disabled />}
label="Wartungserinnerungen"
/>
<FormControlLabel
control={<Switch checked={false} disabled />}
label="System-Benachrichtigungen"
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
(Bald verfügbar)
</Typography>
</FormGroup>
</CardContent>
</Card>
</Grid>
{/* Display Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Palette color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Anzeigeoptionen</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<FormGroup>
<FormControlLabel
control={<Switch checked={false} disabled />}
label="Kompakte Ansicht"
/>
<FormControlLabel
control={<Switch checked={false} disabled />}
label="Animationen"
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
(Bald verfügbar)
</Typography>
<Box sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
Farbschema
</Typography>
<ToggleButtonGroup
value={themeMode}
exclusive
onChange={(_e, value) => { if (value) setThemeMode(value); }}
size="small"
>
<ToggleButton value="system">
<SettingsBrightness fontSize="small" sx={{ mr: 0.5 }} />
System
</ToggleButton>
<ToggleButton value="light">
<LightMode fontSize="small" sx={{ mr: 0.5 }} />
Hell
</ToggleButton>
<ToggleButton value="dark">
<DarkMode fontSize="small" sx={{ mr: 0.5 }} />
Dunkel
</ToggleButton>
</ToggleButtonGroup>
</Box>
</FormGroup>
</CardContent>
</Card>
</Grid>
{/* Language Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Language color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Sprache</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary">
Aktuelle Sprache: Deutsch
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Kommende Features: Sprachauswahl, Datumsformat, Zeitzone
</Typography>
</CardContent>
</Card>
</Grid>
{/* General Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<SettingsIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Allgemein</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary">
Kommende Features: Dashboard-Layout, Standardansichten, Exporteinstellungen
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Container>
</DashboardLayout>
);
}
export default Settings;