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}