feat: bug fixes, layout improvements, and new features
Bug fixes: - Remove non-existent `role` column from admin users SQL query (A1) - Fix Nextcloud Talk chat API path v4 → v1 for messages/send/read (A2) - Fix ServiceModeTab sync: useState → useEffect to reflect DB state (A3) - Guard BookStack book_slug with book_id fallback to avoid broken URLs (A4) Layout & UI: - Chat panel: sticky full-height positioning, main content scrolls independently (B1) - Vehicle booking datetime inputs: explicit text color for dark mode (B2) - AnnouncementBanner moved into grid with full-width span (B3) Features: - Per-user widget visibility preferences stored in users.preferences JSONB (C1) - Link collections: grouped external links in admin UI and dashboard widget (C2) - Admin ping history: migration 026, checked_at timestamps, expandable history rows (C4) - Service mode end date picker with scheduled deactivation display (C5) - Vikunja startup config logging and configured:false warnings (C7) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,9 @@ import {
|
||||
InputLabel,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
Link as LinkIcon,
|
||||
Timer,
|
||||
Info,
|
||||
ExpandMore,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
@@ -30,9 +34,10 @@ import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { settingsApi } from '../services/settings';
|
||||
|
||||
interface ExternalLink {
|
||||
interface LinkCollection {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
links: Array<{ name: string; url: string }>;
|
||||
}
|
||||
|
||||
interface RefreshIntervals {
|
||||
@@ -61,8 +66,8 @@ function AdminSettings() {
|
||||
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
|
||||
// State for external links
|
||||
const [externalLinks, setExternalLinks] = useState<ExternalLink[]>([]);
|
||||
// State for link collections
|
||||
const [linkCollections, setLinkCollections] = useState<LinkCollection[]>([]);
|
||||
|
||||
// State for refresh intervals
|
||||
const [refreshIntervals, setRefreshIntervals] = useState<RefreshIntervals>({
|
||||
@@ -82,7 +87,13 @@ function AdminSettings() {
|
||||
if (settings) {
|
||||
const linksSetting = settings.find((s) => s.key === 'external_links');
|
||||
if (linksSetting?.value) {
|
||||
setExternalLinks(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');
|
||||
@@ -95,9 +106,9 @@ function AdminSettings() {
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Mutation for saving external links
|
||||
// Mutation for saving link collections
|
||||
const linksMutation = useMutation({
|
||||
mutationFn: (links: ExternalLink[]) => settingsApi.update('external_links', links),
|
||||
mutationFn: (collections: LinkCollection[]) => settingsApi.update('external_links', collections),
|
||||
onSuccess: () => {
|
||||
showSuccess('Externe Links gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
@@ -124,22 +135,49 @@ function AdminSettings() {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const handleAddLink = () => {
|
||||
setExternalLinks([...externalLinks, { name: '', url: '' }]);
|
||||
const handleAddCollection = () => {
|
||||
setLinkCollections([...linkCollections, { id: crypto.randomUUID(), name: '', links: [] }]);
|
||||
};
|
||||
|
||||
const handleRemoveLink = (index: number) => {
|
||||
setExternalLinks(externalLinks.filter((_, i) => i !== index));
|
||||
const handleRemoveCollection = (id: string) => {
|
||||
setLinkCollections(linkCollections.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleLinkChange = (index: number, field: keyof ExternalLink, value: string) => {
|
||||
const updated = [...externalLinks];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setExternalLinks(updated);
|
||||
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(externalLinks);
|
||||
linksMutation.mutate(linkCollections);
|
||||
};
|
||||
|
||||
const handleSaveIntervals = () => {
|
||||
@@ -172,50 +210,91 @@ function AdminSettings() {
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Section 1: External Links */}
|
||||
{/* 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 — Externe Links</Typography>
|
||||
<Typography variant="h6">FF Rems Tools — Link-Sammlungen</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>
|
||||
<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 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
onClick={handleAddLink}
|
||||
onClick={handleAddCollection}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Link hinzufügen
|
||||
Neue Sammlung
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveLinks}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Box,
|
||||
Fade,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import SkeletonCard from '../components/shared/SkeletonCard';
|
||||
@@ -22,6 +23,10 @@ import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
|
||||
import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
|
||||
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
|
||||
import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
|
||||
import LinksWidget from '../components/dashboard/LinksWidget';
|
||||
import { preferencesApi } from '../services/settings';
|
||||
import { WidgetKey } from '../constants/widgets';
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
@@ -33,6 +38,15 @@ function Dashboard() {
|
||||
) ?? false;
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
|
||||
const { data: preferences } = useQuery({
|
||||
queryKey: ['user-preferences'],
|
||||
queryFn: preferencesApi.get,
|
||||
});
|
||||
|
||||
const widgetVisible = (key: WidgetKey) => {
|
||||
return preferences?.widgets?.[key] !== false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDataLoading(false);
|
||||
@@ -44,7 +58,6 @@ function Dashboard() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth={false} disableGutters>
|
||||
<AnnouncementBanner />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
@@ -53,6 +66,9 @@ function Dashboard() {
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Announcement Banner — spans full width, renders null when no banners */}
|
||||
<AnnouncementBanner gridColumn="1 / -1" />
|
||||
|
||||
{/* User Profile Card — full width, contains welcome greeting */}
|
||||
{user && (
|
||||
<Box sx={{ gridColumn: '1 / -1' }}>
|
||||
@@ -69,6 +85,7 @@ function Dashboard() {
|
||||
)}
|
||||
|
||||
{/* Vehicle Status Card */}
|
||||
{widgetVisible('vehicles') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
|
||||
<Box>
|
||||
@@ -76,8 +93,10 @@ function Dashboard() {
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Equipment Status Card */}
|
||||
{widgetVisible('equipment') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
|
||||
<Box>
|
||||
@@ -85,9 +104,10 @@ function Dashboard() {
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Atemschutz Status Card */}
|
||||
{canViewAtemschutz && (
|
||||
{canViewAtemschutz && widgetVisible('atemschutz') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
|
||||
<Box>
|
||||
@@ -98,6 +118,7 @@ function Dashboard() {
|
||||
)}
|
||||
|
||||
{/* Upcoming Events Widget */}
|
||||
{widgetVisible('events') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
|
||||
<Box>
|
||||
@@ -105,8 +126,10 @@ function Dashboard() {
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Nextcloud Talk Widget */}
|
||||
{widgetVisible('nextcloudTalk') && (
|
||||
<Box>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
@@ -118,8 +141,10 @@ function Dashboard() {
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* BookStack Recent Pages Widget */}
|
||||
{widgetVisible('bookstackRecent') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
|
||||
<Box>
|
||||
@@ -127,8 +152,10 @@ function Dashboard() {
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* BookStack Search Widget */}
|
||||
{widgetVisible('bookstackSearch') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
|
||||
<Box>
|
||||
@@ -136,8 +163,10 @@ function Dashboard() {
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Vikunja — My Tasks Widget */}
|
||||
{widgetVisible('vikunjaTasks') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
|
||||
<Box>
|
||||
@@ -145,8 +174,10 @@ function Dashboard() {
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Vikunja — Quick Add Widget */}
|
||||
{widgetVisible('vikunjaQuickAdd') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
|
||||
<Box>
|
||||
@@ -154,9 +185,10 @@ function Dashboard() {
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Vehicle Booking — Quick Add Widget */}
|
||||
{canWrite && (
|
||||
{canWrite && widgetVisible('vehicleBooking') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
|
||||
<Box>
|
||||
@@ -167,7 +199,7 @@ function Dashboard() {
|
||||
)}
|
||||
|
||||
{/* Event — Quick Add Widget */}
|
||||
{canWrite && (
|
||||
{canWrite && widgetVisible('eventQuickAdd') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
|
||||
<Box>
|
||||
@@ -180,8 +212,19 @@ function Dashboard() {
|
||||
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
|
||||
<VikunjaOverdueNotifier />
|
||||
|
||||
{/* Links Widget */}
|
||||
{widgetVisible('links') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '790ms' }}>
|
||||
<Box>
|
||||
<LinksWidget />
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Admin Status Widget — only for admins */}
|
||||
{isAdmin && (
|
||||
{isAdmin && widgetVisible('adminStatus') && (
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
|
||||
<Box>
|
||||
|
||||
@@ -11,13 +11,41 @@ import {
|
||||
Box,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material';
|
||||
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets } from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useThemeMode } from '../contexts/ThemeContext';
|
||||
import { preferencesApi } from '../services/settings';
|
||||
import { WIDGETS, WidgetKey } from '../constants/widgets';
|
||||
|
||||
function Settings() {
|
||||
const { themeMode, setThemeMode } = useThemeMode();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
@@ -27,6 +55,40 @@ function Settings() {
|
||||
</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>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
@@ -146,20 +208,6 @@ function Settings() {
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
backgroundColor: 'info.light',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="info.dark">
|
||||
Diese Einstellungen sind derzeit nur zur Demonstration verfügbar. Die Funktionalität
|
||||
wird in zukünftigen Updates implementiert.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user