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:
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
frontend/src/components/dashboard/LinksWidget.tsx
Normal file
63
frontend/src/components/dashboard/LinksWidget.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user