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

@@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import {
Box,
Table,
@@ -9,6 +9,7 @@ import {
TableRow,
Paper,
Button,
Collapse,
Dialog,
DialogTitle,
DialogContent,
@@ -24,10 +25,12 @@ import {
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import { useNotification } from '../../contexts/NotificationContext';
import type { PingResult } from '../../types/admin.types';
import type { PingResult, PingHistoryEntry } from '../../types/admin.types';
function ServiceManagerTab() {
const queryClient = useQueryClient();
@@ -36,6 +39,9 @@ function ServiceManagerTab() {
const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState('');
const [refreshInterval, setRefreshInterval] = useState(15000);
const [expandedService, setExpandedService] = useState<string | null>(null);
const [historyData, setHistoryData] = useState<PingHistoryEntry[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const { data: services, isLoading: servicesLoading } = useQuery({
queryKey: ['admin', 'services'],
@@ -79,6 +85,23 @@ function ServiceManagerTab() {
return pingResults?.find((p) => p.url === url);
};
const toggleHistory = async (serviceName: string) => {
if (expandedService === serviceName) {
setExpandedService(null);
return;
}
setExpandedService(serviceName);
setHistoryLoading(true);
try {
const data = await adminApi.getPingHistory(serviceName);
setHistoryData(data);
} catch {
setHistoryData([]);
} finally {
setHistoryLoading(false);
}
};
const handleCreate = () => {
if (newName.trim() && newUrl.trim()) {
createMutation.mutate({ name: newName.trim(), url: newUrl.trim() });
@@ -148,14 +171,20 @@ function ServiceManagerTab() {
<TableCell>URL</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Latenz</TableCell>
<TableCell>Geprüft</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allItems.map((item) => {
const ping = getPingForUrl(item.url);
const isExpanded = expandedService === item.name;
return (
<TableRow key={item.id}>
<React.Fragment key={item.id}>
<TableRow
sx={{ cursor: 'pointer', '& > *': { borderBottom: isExpanded ? 'unset' : undefined } }}
onClick={() => toggleHistory(item.name)}
>
<TableCell>
{pingLoading ? (
<CircularProgress size={16} />
@@ -177,23 +206,77 @@ function ServiceManagerTab() {
<TableCell>{item.type}</TableCell>
<TableCell>{ping ? `${ping.latencyMs}ms` : '-'}</TableCell>
<TableCell>
{item.isCustom && (
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
>
<DeleteIcon fontSize="small" />
{ping?.checked_at ? new Date(ping.checked_at).toLocaleString('de-DE') : '-'}
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{item.isCustom && (
<IconButton
size="small"
color="error"
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(item.id); }}
disabled={deleteMutation.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<IconButton size="small">
{isExpanded ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</IconButton>
)}
</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ py: 0 }} colSpan={7}>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box sx={{ py: 1, px: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Ping-Verlauf (letzte 20)</Typography>
{historyLoading ? (
<CircularProgress size={20} />
) : historyData.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Historie vorhanden</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Antwortzeit</TableCell>
<TableCell>Zeitpunkt</TableCell>
</TableRow>
</TableHead>
<TableBody>
{historyData.map((h) => (
<TableRow key={h.id}>
<TableCell>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: h.status === 'up' ? 'success.main' : 'error.main',
display: 'inline-block',
mr: 1,
}}
/>
{h.status}
</TableCell>
<TableCell>{h.response_time_ms != null ? `${h.response_time_ms}ms` : '-'}</TableCell>
<TableCell>{new Date(h.checked_at).toLocaleString('de-DE')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})}
{allItems.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center">Keine Services konfiguriert</TableCell>
<TableCell colSpan={7} align="center">Keine Services konfiguriert</TableCell>
</TableRow>
)}
</TableBody>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Box, Card, CardContent, Typography, Switch, FormControlLabel,
TextField, Button, Alert, CircularProgress,
@@ -20,17 +20,19 @@ export default function ServiceModeTab() {
const currentValue = setting?.value ?? { active: false, message: '' };
const [active, setActive] = useState<boolean>(currentValue.active ?? false);
const [message, setMessage] = useState<string>(currentValue.message ?? '');
const [endsAt, setEndsAt] = useState<string>('');
// Sync state when data loads
useState(() => {
useEffect(() => {
if (setting?.value) {
setActive(setting.value.active ?? false);
setMessage(setting.value.message ?? '');
setEndsAt(setting.value.ends_at ?? '');
}
});
}, [setting]);
const mutation = useMutation({
mutationFn: (value: { active: boolean; message: string }) =>
mutationFn: (value: { active: boolean; message: string; ends_at?: string | null }) =>
settingsApi.update('service_mode', value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-mode'] });
@@ -41,7 +43,7 @@ export default function ServiceModeTab() {
});
const handleSave = () => {
mutation.mutate({ active, message });
mutation.mutate({ active, message, ends_at: endsAt || null });
};
if (isLoading) {
@@ -63,6 +65,12 @@ export default function ServiceModeTab() {
</Alert>
)}
{active && endsAt && (
<Alert severity="info" sx={{ mb: 2 }}>
Wartungsmodus endet automatisch am {new Date(endsAt).toLocaleString('de-DE')}.
</Alert>
)}
<FormControlLabel
control={
<Switch
@@ -87,6 +95,17 @@ export default function ServiceModeTab() {
helperText="Diese Nachricht sehen Benutzer auf der Wartungsseite."
/>
<TextField
fullWidth
label="Automatisch deaktivieren am"
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
InputLabelProps={{ shrink: true }}
helperText="Optional: Wartungsmodus wird automatisch zu diesem Zeitpunkt deaktiviert."
sx={{ mb: 3, '& input': { color: 'text.primary' } }}
/>
<Button
variant="contained"
color={active ? 'error' : 'primary'}

View File

@@ -29,7 +29,9 @@ const ChatPanelInner: React.FC = () => {
elevation={2}
sx={{
width: COLLAPSED_WIDTH,
height: '100%',
height: '100vh',
position: 'sticky',
top: 0,
display: { xs: 'none', sm: 'flex' },
flexDirection: 'column',
alignItems: 'center',
@@ -90,12 +92,12 @@ const ChatPanelInner: React.FC = () => {
elevation={2}
sx={{
width: { xs: '100vw', sm: EXPANDED_WIDTH },
position: { xs: 'fixed', sm: 'relative' },
top: { xs: 0, sm: 'auto' },
position: { xs: 'fixed', sm: 'sticky' },
top: { xs: 0, sm: 0 },
right: { xs: 0, sm: 'auto' },
bottom: { xs: 0, sm: 'auto' },
zIndex: { xs: (theme) => theme.zIndex.drawer + 2, sm: 'auto' },
height: '100%',
height: { xs: '100vh', sm: '100vh' },
display: 'flex',
flexDirection: 'column',
flexShrink: 0,

View File

@@ -27,7 +27,7 @@ const LEVEL_TITLE: Record<BannerLevel, string> = {
critical: 'Kritisch',
};
export default function AnnouncementBanner() {
export default function AnnouncementBanner({ gridColumn }: { gridColumn?: string }) {
const [dismissed, setDismissed] = useState<string[]>(() => getDismissed());
const { data: banners = [] } = useQuery({
@@ -48,7 +48,7 @@ export default function AnnouncementBanner() {
if (visible.length === 0) return null;
return (
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ mb: 0, display: 'flex', flexDirection: 'column', gap: 1, ...(gridColumn ? { gridColumn } : {}) }}>
{visible.map(banner => (
<Collapse key={banner.id} in>
<Alert

View File

@@ -28,7 +28,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
const chatWidth = chatPanelOpen ? 360 : 60;
return (
<Box sx={{ display: 'flex' }}>
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Header onMenuClick={handleDrawerToggle} />
<Sidebar mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} />
@@ -38,7 +38,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - ${sidebarWidth}px - ${chatWidth}px)` },
minHeight: '100vh',
overflowY: 'auto',
backgroundColor: 'background.default',
transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)',
minWidth: 0,

View File

@@ -0,0 +1,63 @@
import {
Card,
CardContent,
Typography,
Link,
Stack,
Box,
Divider,
} from '@mui/material';
import { Link as LinkIcon, OpenInNew } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { configApi } from '../../services/config';
function LinksWidget() {
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
queryFn: () => configApi.getExternalLinks(),
staleTime: 10 * 60 * 1000,
});
const collections = externalLinks?.linkCollections ?? [];
const nonEmpty = collections.filter((c) => c.links.length > 0);
if (nonEmpty.length === 0) return null;
return (
<>
{nonEmpty.map((collection) => (
<Card key={collection.id}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<LinkIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">{collection.name}</Typography>
</Box>
<Divider sx={{ mb: 1.5 }} />
<Stack spacing={0.5}>
{collection.links.map((link, i) => (
<Link
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
py: 0.5,
}}
>
{link.name}
<OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
</Link>
))}
</Stack>
</CardContent>
</Card>
))}
</>
);
}
export default LinksWidget;

View File

@@ -152,6 +152,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
onChange={(e) => setBeginn(e.target.value)}
required
InputLabelProps={{ shrink: true }}
sx={{ '& input': { color: 'text.primary' } }}
/>
<TextField
@@ -163,6 +164,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
onChange={(e) => setEnde(e.target.value)}
required
InputLabelProps={{ shrink: true }}
sx={{ '& input': { color: 'text.primary' } }}
/>
<TextField

View File

@@ -13,3 +13,4 @@ export { default as AdminStatusWidget } from './AdminStatusWidget';
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
export { default as EventQuickAddWidget } from './EventQuickAddWidget';
export { default as AnnouncementBanner } from './AnnouncementBanner';
export { default as LinksWidget } from './LinksWidget';