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:
Matthias Hochmeister
2026-03-12 14:57:54 +01:00
parent 81174c2498
commit a5cd78f01f
29 changed files with 593 additions and 105 deletions

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>
);