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

@@ -106,6 +106,7 @@ app.use('/api/vikunja', vikunjaRoutes);
app.use('/api/config', configRoutes); app.use('/api/config', configRoutes);
app.use('/api/admin', serviceMonitorRoutes); app.use('/api/admin', serviceMonitorRoutes);
app.use('/api/admin/settings', settingsRoutes); app.use('/api/admin/settings', settingsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/banners', bannerRoutes); app.use('/api/banners', bannerRoutes);
// 404 handler // 404 handler

View File

@@ -19,13 +19,14 @@ class ConfigController {
if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url; if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url;
if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url; if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url;
const customLinks = await settingsService.getExternalLinks(); const linkCollections = await settingsService.getExternalLinks();
res.status(200).json({ res.status(200).json({
success: true, success: true,
data: { data: {
...envLinks, ...envLinks,
customLinks, customLinks: linkCollections.flatMap(c => c.links),
linkCollections,
}, },
}); });
} }

View File

@@ -137,7 +137,7 @@ class ServiceMonitorController {
async getUsers(_req: Request, res: Response): Promise<void> { async getUsers(_req: Request, res: Response): Promise<void> {
try { try {
const result = await pool.query( const result = await pool.query(
`SELECT id, email, name, role, authentik_groups as groups, is_active, last_login_at `SELECT id, email, name, authentik_groups as groups, is_active, last_login_at
FROM users ORDER BY name` FROM users ORDER BY name`
); );
res.json({ success: true, data: result.rows }); res.json({ success: true, data: result.rows });
@@ -147,6 +147,17 @@ class ServiceMonitorController {
} }
} }
async getPingHistory(req: Request, res: Response): Promise<void> {
try {
const { serviceId } = req.params;
const data = await serviceMonitorService.getPingHistory(serviceId as string);
res.json({ success: true, data });
} catch (error) {
logger.error('Failed to get ping history', { error });
res.status(500).json({ success: false, message: 'Failed to get ping history' });
}
}
async broadcastNotification(req: Request, res: Response): Promise<void> { async broadcastNotification(req: Request, res: Response): Promise<void> {
try { try {
const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body); const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body);

View File

@@ -1,6 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import settingsService from '../services/settings.service'; import settingsService from '../services/settings.service';
import pool from '../config/database';
import logger from '../utils/logger'; import logger from '../utils/logger';
const updateSchema = z.object({ const updateSchema = z.object({
@@ -8,8 +9,12 @@ const updateSchema = z.object({
}); });
const externalLinkSchema = z.array(z.object({ const externalLinkSchema = z.array(z.object({
id: z.string().min(1),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
url: z.string().url().max(500), links: z.array(z.object({
name: z.string().min(1).max(200),
url: z.string().url().max(500),
})),
})); }));
class SettingsController { class SettingsController {
@@ -57,6 +62,32 @@ class SettingsController {
res.status(500).json({ success: false, message: 'Failed to update setting' }); res.status(500).json({ success: false, message: 'Failed to update setting' });
} }
} }
async getUserPreferences(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user.id;
const result = await pool.query('SELECT preferences FROM users WHERE id = $1', [userId]);
const prefs = result.rows[0]?.preferences ?? {};
res.json({ success: true, data: prefs });
} catch (error) {
logger.error('Failed to get user preferences', { error });
res.status(500).json({ success: false, message: 'Failed to get user preferences' });
}
}
async updateUserPreferences(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user.id;
const preferences = req.body;
await pool.query(
'UPDATE users SET preferences = $1 WHERE id = $2',
[JSON.stringify(preferences), userId]
);
res.json({ success: true });
} catch (error) {
logger.error('Failed to update user preferences', { error });
res.status(500).json({ success: false, message: 'Failed to update user preferences' });
}
}
} }
export default new SettingsController(); export default new SettingsController();

View File

@@ -7,6 +7,7 @@ import logger from '../utils/logger';
class VikunjaController { class VikunjaController {
async getMyTasks(_req: Request, res: Response): Promise<void> { async getMyTasks(_req: Request, res: Response): Promise<void> {
if (!environment.vikunja.url) { if (!environment.vikunja.url) {
logger.warn('Vikunja not configured VIKUNJA_URL is empty');
res.status(200).json({ success: true, data: [], configured: false }); res.status(200).json({ success: true, data: [], configured: false });
return; return;
} }
@@ -21,6 +22,7 @@ class VikunjaController {
async getOverdueTasks(req: Request, res: Response): Promise<void> { async getOverdueTasks(req: Request, res: Response): Promise<void> {
if (!environment.vikunja.url) { if (!environment.vikunja.url) {
logger.warn('Vikunja not configured VIKUNJA_URL is empty');
res.status(200).json({ success: true, data: [], configured: false }); res.status(200).json({ success: true, data: [], configured: false });
return; return;
} }
@@ -53,6 +55,7 @@ class VikunjaController {
async getProjects(_req: Request, res: Response): Promise<void> { async getProjects(_req: Request, res: Response): Promise<void> {
if (!environment.vikunja.url) { if (!environment.vikunja.url) {
logger.warn('Vikunja not configured VIKUNJA_URL is empty');
res.status(200).json({ success: true, data: [], configured: false }); res.status(200).json({ success: true, data: [], configured: false });
return; return;
} }

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS service_ping_history (
id SERIAL PRIMARY KEY,
service_id VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
response_time_ms INTEGER,
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sph_service_checked ON service_ping_history(service_id, checked_at DESC);

View File

@@ -9,6 +9,7 @@ const auth = [authenticate, requirePermission('admin:access')] as const;
// Static routes first (before parameterized :id routes) // Static routes first (before parameterized :id routes)
router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController)); router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController));
router.get('/services/status-summary', ...auth, serviceMonitorController.getStatusSummary.bind(serviceMonitorController)); router.get('/services/status-summary', ...auth, serviceMonitorController.getStatusSummary.bind(serviceMonitorController));
router.get('/services/:serviceId/ping-history', ...auth, serviceMonitorController.getPingHistory.bind(serviceMonitorController));
router.get('/services', ...auth, serviceMonitorController.getAll.bind(serviceMonitorController)); router.get('/services', ...auth, serviceMonitorController.getAll.bind(serviceMonitorController));
router.post('/services', ...auth, serviceMonitorController.create.bind(serviceMonitorController)); router.post('/services', ...auth, serviceMonitorController.create.bind(serviceMonitorController));
router.put('/services/:id', ...auth, serviceMonitorController.update.bind(serviceMonitorController)); router.put('/services/:id', ...auth, serviceMonitorController.update.bind(serviceMonitorController));

View File

@@ -7,6 +7,8 @@ const router = Router();
const auth = [authenticate, requirePermission('admin:access')] as const; const auth = [authenticate, requirePermission('admin:access')] as const;
router.get('/', ...auth, settingsController.getAll.bind(settingsController)); router.get('/', ...auth, settingsController.getAll.bind(settingsController));
router.get('/preferences', authenticate, settingsController.getUserPreferences.bind(settingsController));
router.put('/preferences', authenticate, settingsController.updateUserPreferences.bind(settingsController));
router.get('/:key', ...auth, settingsController.get.bind(settingsController)); router.get('/:key', ...auth, settingsController.get.bind(settingsController));
router.put('/:key', ...auth, settingsController.update.bind(settingsController)); router.put('/:key', ...auth, settingsController.update.bind(settingsController));

View File

@@ -31,6 +31,13 @@ const startServer = async (): Promise<void> => {
environment: environment.nodeEnv, environment: environment.nodeEnv,
database: dbConnected ? 'connected' : 'disconnected', database: dbConnected ? 'connected' : 'disconnected',
}); });
// Log integration status so operators can see what's configured
logger.info('Integration status', {
bookstack: !!environment.bookstack.url,
nextcloud: !!environment.nextcloudUrl,
vikunja: !!environment.vikunja.url,
});
}); });
// Graceful shutdown handling // Graceful shutdown handling

View File

@@ -98,7 +98,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
const pages: BookStackPage[] = response.data?.data ?? []; const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({ return pages.map((p) => ({
...p, ...p,
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`, url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug || p.book_id}/page/${p.slug}`,
})); }));
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@@ -134,7 +134,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
slug: item.slug, slug: item.slug,
book_id: item.book_id ?? 0, book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '', book_slug: item.book_slug ?? '',
url: item.url || `${bookstack.url}/books/${item.book_slug}/page/${item.slug}`, url: item.url || `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`,
preview_html: item.preview_html ?? { content: '' }, preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [], tags: item.tags ?? [],
})); }));
@@ -189,7 +189,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
html: page.html ?? '', html: page.html ?? '',
created_at: page.created_at, created_at: page.created_at,
updated_at: page.updated_at, updated_at: page.updated_at,
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`, url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug || page.book_id}/page/${page.slug}`,
book: page.book, book: page.book,
createdBy: page.created_by, createdBy: page.created_by,
updatedBy: page.updated_by, updatedBy: page.updated_by,

View File

@@ -202,7 +202,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
try { try {
const response = await axios.get( const response = await axios.get(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
{ {
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 }, params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
headers: { headers: {
@@ -251,7 +251,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
try { try {
await axios.post( await axios.post(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
{ message }, { message },
{ {
headers: { headers: {
@@ -288,7 +288,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
try { try {
await axios.delete( await axios.delete(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`, `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}/read`,
{ {
headers: { headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,

View File

@@ -18,6 +18,7 @@ export interface PingResult {
status: 'up' | 'down'; status: 'up' | 'down';
latencyMs: number; latencyMs: number;
error?: string; error?: string;
checked_at: string;
} }
export interface StatusSummary { export interface StatusSummary {
@@ -92,6 +93,7 @@ class ServiceMonitorService {
url, url,
status: 'up', status: 'up',
latencyMs: Date.now() - start, latencyMs: Date.now() - start,
checked_at: new Date().toISOString(),
}; };
} catch (error) { } catch (error) {
// Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints // Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints
@@ -105,6 +107,7 @@ class ServiceMonitorService {
url, url,
status: 'up', status: 'up',
latencyMs: Date.now() - start, latencyMs: Date.now() - start,
checked_at: new Date().toISOString(),
}; };
} }
} }
@@ -113,6 +116,7 @@ class ServiceMonitorService {
url, url,
status: 'down', status: 'down',
latencyMs: Date.now() - start, latencyMs: Date.now() - start,
checked_at: new Date().toISOString(),
error: axios.isAxiosError(error) error: axios.isAxiosError(error)
? `${error.code ?? 'ERROR'}: ${error.message}` ? `${error.code ?? 'ERROR'}: ${error.message}`
: String(error), : String(error),
@@ -140,9 +144,37 @@ class ServiceMonitorService {
}) })
); );
// Store ping results in history (fire-and-forget)
this.storePingResults(results).catch(() => {});
return results; return results;
} }
async getPingHistory(serviceId: string): Promise<Array<{ id: number; service_id: string; status: string; response_time_ms: number | null; checked_at: string }>> {
const result = await pool.query(
'SELECT * FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20',
[serviceId]
);
return result.rows;
}
private async storePingResults(results: PingResult[]): Promise<void> {
for (const r of results) {
const serviceId = r.name || r.url;
await pool.query(
'INSERT INTO service_ping_history (service_id, status, response_time_ms, checked_at) VALUES ($1, $2, $3, $4)',
[serviceId, r.status, r.latencyMs, r.checked_at]
);
// Keep only last 20 per service
await pool.query(
`DELETE FROM service_ping_history WHERE service_id = $1 AND id NOT IN (
SELECT id FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20
)`,
[serviceId]
);
}
}
async getStatusSummary(): Promise<StatusSummary> { async getStatusSummary(): Promise<StatusSummary> {
const results = await this.pingAll(); const results = await this.pingAll();
return { return {

View File

@@ -34,9 +34,20 @@ class SettingsService {
return (result.rowCount ?? 0) > 0; return (result.rowCount ?? 0) > 0;
} }
async getExternalLinks(): Promise<Array<{name: string; url: string}>> { async getExternalLinks(): Promise<Array<{id: string; name: string; links: Array<{name: string; url: string}>}>> {
const setting = await this.get('external_links'); const setting = await this.get('external_links');
return Array.isArray(setting?.value) ? setting.value : []; const value = setting?.value;
if (!Array.isArray(value)) return [];
// Already in LinkCollection[] format
if (value.length === 0) return [];
if (value[0] && 'links' in value[0]) return value;
// Old flat format: Array<{name, url}> — migrate to single collection
const migrated = [{ id: 'default', name: 'Links', links: value }];
// Persist migrated format (use system user id)
await this.set('external_links', migrated, 'system');
return migrated;
} }
} }

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { import {
Box, Box,
Table, Table,
@@ -9,6 +9,7 @@ import {
TableRow, TableRow,
Paper, Paper,
Button, Button,
Collapse,
Dialog, Dialog,
DialogTitle, DialogTitle,
DialogContent, DialogContent,
@@ -24,10 +25,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add'; 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../../services/admin'; import { adminApi } from '../../services/admin';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import type { PingResult } from '../../types/admin.types'; import type { PingResult, PingHistoryEntry } from '../../types/admin.types';
function ServiceManagerTab() { function ServiceManagerTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -36,6 +39,9 @@ function ServiceManagerTab() {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState(''); const [newUrl, setNewUrl] = useState('');
const [refreshInterval, setRefreshInterval] = useState(15000); 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({ const { data: services, isLoading: servicesLoading } = useQuery({
queryKey: ['admin', 'services'], queryKey: ['admin', 'services'],
@@ -79,6 +85,23 @@ function ServiceManagerTab() {
return pingResults?.find((p) => p.url === url); 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 = () => { const handleCreate = () => {
if (newName.trim() && newUrl.trim()) { if (newName.trim() && newUrl.trim()) {
createMutation.mutate({ name: newName.trim(), url: newUrl.trim() }); createMutation.mutate({ name: newName.trim(), url: newUrl.trim() });
@@ -148,14 +171,20 @@ function ServiceManagerTab() {
<TableCell>URL</TableCell> <TableCell>URL</TableCell>
<TableCell>Typ</TableCell> <TableCell>Typ</TableCell>
<TableCell>Latenz</TableCell> <TableCell>Latenz</TableCell>
<TableCell>Geprüft</TableCell>
<TableCell>Aktionen</TableCell> <TableCell>Aktionen</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{allItems.map((item) => { {allItems.map((item) => {
const ping = getPingForUrl(item.url); const ping = getPingForUrl(item.url);
const isExpanded = expandedService === item.name;
return ( return (
<TableRow key={item.id}> <React.Fragment key={item.id}>
<TableRow
sx={{ cursor: 'pointer', '& > *': { borderBottom: isExpanded ? 'unset' : undefined } }}
onClick={() => toggleHistory(item.name)}
>
<TableCell> <TableCell>
{pingLoading ? ( {pingLoading ? (
<CircularProgress size={16} /> <CircularProgress size={16} />
@@ -177,23 +206,77 @@ function ServiceManagerTab() {
<TableCell>{item.type}</TableCell> <TableCell>{item.type}</TableCell>
<TableCell>{ping ? `${ping.latencyMs}ms` : '-'}</TableCell> <TableCell>{ping ? `${ping.latencyMs}ms` : '-'}</TableCell>
<TableCell> <TableCell>
{item.isCustom && ( {ping?.checked_at ? new Date(ping.checked_at).toLocaleString('de-DE') : '-'}
<IconButton </TableCell>
size="small" <TableCell>
color="error" <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
onClick={() => deleteMutation.mutate(item.id)} {item.isCustom && (
disabled={deleteMutation.isPending} <IconButton
> size="small"
<DeleteIcon fontSize="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> </IconButton>
)} </Box>
</TableCell> </TableCell>
</TableRow> </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 && ( {allItems.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} align="center">Keine Services konfiguriert</TableCell> <TableCell colSpan={7} align="center">Keine Services konfiguriert</TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ const LEVEL_TITLE: Record<BannerLevel, string> = {
critical: 'Kritisch', critical: 'Kritisch',
}; };
export default function AnnouncementBanner() { export default function AnnouncementBanner({ gridColumn }: { gridColumn?: string }) {
const [dismissed, setDismissed] = useState<string[]>(() => getDismissed()); const [dismissed, setDismissed] = useState<string[]>(() => getDismissed());
const { data: banners = [] } = useQuery({ const { data: banners = [] } = useQuery({
@@ -48,7 +48,7 @@ export default function AnnouncementBanner() {
if (visible.length === 0) return null; if (visible.length === 0) return null;
return ( 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 => ( {visible.map(banner => (
<Collapse key={banner.id} in> <Collapse key={banner.id} in>
<Alert <Alert

View File

@@ -28,7 +28,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
const chatWidth = chatPanelOpen ? 360 : 60; const chatWidth = chatPanelOpen ? 360 : 60;
return ( return (
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Header onMenuClick={handleDrawerToggle} /> <Header onMenuClick={handleDrawerToggle} />
<Sidebar mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} /> <Sidebar mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} />
@@ -38,7 +38,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
flexGrow: 1, flexGrow: 1,
p: 3, p: 3,
width: { sm: `calc(100% - ${sidebarWidth}px - ${chatWidth}px)` }, width: { sm: `calc(100% - ${sidebarWidth}px - ${chatWidth}px)` },
minHeight: '100vh', overflowY: 'auto',
backgroundColor: 'background.default', backgroundColor: 'background.default',
transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)', transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)',
minWidth: 0, 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)} onChange={(e) => setBeginn(e.target.value)}
required required
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
sx={{ '& input': { color: 'text.primary' } }}
/> />
<TextField <TextField
@@ -163,6 +164,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
onChange={(e) => setEnde(e.target.value)} onChange={(e) => setEnde(e.target.value)}
required required
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
sx={{ '& input': { color: 'text.primary' } }}
/> />
<TextField <TextField

View File

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

View File

@@ -0,0 +1,17 @@
export const WIDGETS = [
{ key: 'vehicles', label: 'Fahrzeuge', defaultVisible: true },
{ key: 'equipment', label: 'Ausrüstung', defaultVisible: true },
{ key: 'atemschutz', label: 'Atemschutz', defaultVisible: true },
{ key: 'events', label: 'Termine', defaultVisible: true },
{ key: 'nextcloudTalk', label: 'Nextcloud Talk', defaultVisible: true },
{ key: 'bookstackRecent', label: 'Wissen — Neueste', defaultVisible: true },
{ key: 'bookstackSearch', label: 'Wissen — Suche', defaultVisible: true },
{ key: 'vikunjaTasks', label: 'Vikunja Aufgaben', defaultVisible: true },
{ key: 'vikunjaQuickAdd', label: 'Vikunja Schnelleingabe', defaultVisible: true },
{ key: 'vehicleBooking', label: 'Fahrzeugbuchung', defaultVisible: true },
{ key: 'eventQuickAdd', label: 'Termin erstellen', defaultVisible: true },
{ key: 'adminStatus', label: 'Admin Status', defaultVisible: true },
{ key: 'links', label: 'Links', defaultVisible: true },
] as const;
export type WidgetKey = typeof WIDGETS[number]['key'];

View File

@@ -15,6 +15,9 @@ import {
InputLabel, InputLabel,
Stack, Stack,
CircularProgress, CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material'; } from '@mui/material';
import { import {
Delete, Delete,
@@ -22,6 +25,7 @@ import {
Link as LinkIcon, Link as LinkIcon,
Timer, Timer,
Info, Info,
ExpandMore,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
@@ -30,9 +34,10 @@ import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { settingsApi } from '../services/settings'; import { settingsApi } from '../services/settings';
interface ExternalLink { interface LinkCollection {
id: string;
name: string; name: string;
url: string; links: Array<{ name: string; url: string }>;
} }
interface RefreshIntervals { interface RefreshIntervals {
@@ -61,8 +66,8 @@ function AdminSettings() {
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
// State for external links // State for link collections
const [externalLinks, setExternalLinks] = useState<ExternalLink[]>([]); const [linkCollections, setLinkCollections] = useState<LinkCollection[]>([]);
// State for refresh intervals // State for refresh intervals
const [refreshIntervals, setRefreshIntervals] = useState<RefreshIntervals>({ const [refreshIntervals, setRefreshIntervals] = useState<RefreshIntervals>({
@@ -82,7 +87,13 @@ function AdminSettings() {
if (settings) { if (settings) {
const linksSetting = settings.find((s) => s.key === 'external_links'); const linksSetting = settings.find((s) => s.key === 'external_links');
if (linksSetting?.value) { 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'); const intervalsSetting = settings.find((s) => s.key === 'refresh_intervals');
@@ -95,9 +106,9 @@ function AdminSettings() {
} }
}, [settings]); }, [settings]);
// Mutation for saving external links // Mutation for saving link collections
const linksMutation = useMutation({ const linksMutation = useMutation({
mutationFn: (links: ExternalLink[]) => settingsApi.update('external_links', links), mutationFn: (collections: LinkCollection[]) => settingsApi.update('external_links', collections),
onSuccess: () => { onSuccess: () => {
showSuccess('Externe Links gespeichert'); showSuccess('Externe Links gespeichert');
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
@@ -124,22 +135,49 @@ function AdminSettings() {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
const handleAddLink = () => { const handleAddCollection = () => {
setExternalLinks([...externalLinks, { name: '', url: '' }]); setLinkCollections([...linkCollections, { id: crypto.randomUUID(), name: '', links: [] }]);
}; };
const handleRemoveLink = (index: number) => { const handleRemoveCollection = (id: string) => {
setExternalLinks(externalLinks.filter((_, i) => i !== index)); setLinkCollections(linkCollections.filter((c) => c.id !== id));
}; };
const handleLinkChange = (index: number, field: keyof ExternalLink, value: string) => { const handleCollectionNameChange = (id: string, name: string) => {
const updated = [...externalLinks]; setLinkCollections(linkCollections.map((c) => (c.id === id ? { ...c, name } : c)));
updated[index] = { ...updated[index], [field]: value }; };
setExternalLinks(updated);
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 = () => { const handleSaveLinks = () => {
linksMutation.mutate(externalLinks); linksMutation.mutate(linkCollections);
}; };
const handleSaveIntervals = () => { const handleSaveIntervals = () => {
@@ -172,50 +210,91 @@ function AdminSettings() {
</Typography> </Typography>
<Stack spacing={3}> <Stack spacing={3}>
{/* Section 1: External Links */} {/* Section 1: Link Collections */}
<Card> <Card>
<CardContent> <CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<LinkIcon color="primary" sx={{ mr: 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> </Box>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<Stack spacing={2}> <Stack spacing={1}>
{externalLinks.map((link, index) => ( {linkCollections.map((collection) => (
<Box key={index} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> <Accordion key={collection.id} defaultExpanded>
<TextField <AccordionSummary expandIcon={<ExpandMore />}>
label="Name" <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1, mr: 2 }}>
value={link.name} <TextField
onChange={(e) => handleLinkChange(index, 'name', e.target.value)} label="Sammlungsname"
size="small" value={collection.name}
sx={{ flex: 1 }} onChange={(e) => handleCollectionNameChange(collection.id, e.target.value)}
/> size="small"
<TextField sx={{ flex: 1 }}
label="URL" onClick={(e) => e.stopPropagation()}
value={link.url} />
onChange={(e) => handleLinkChange(index, 'url', e.target.value)} <IconButton
size="small" color="error"
sx={{ flex: 2 }} onClick={(e) => {
/> e.stopPropagation();
<IconButton handleRemoveCollection(collection.id);
color="error" }}
onClick={() => handleRemoveLink(index)} aria-label="Sammlung entfernen"
aria-label="Link entfernen" size="small"
> >
<Delete /> <Delete />
</IconButton> </IconButton>
</Box> </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 <Button
startIcon={<Add />} startIcon={<Add />}
onClick={handleAddLink} onClick={handleAddCollection}
variant="outlined" variant="outlined"
size="small" size="small"
> >
Link hinzufügen Neue Sammlung
</Button> </Button>
<Button <Button
onClick={handleSaveLinks} onClick={handleSaveLinks}

View File

@@ -4,6 +4,7 @@ import {
Box, Box,
Fade, Fade,
} from '@mui/material'; } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import SkeletonCard from '../components/shared/SkeletonCard'; import SkeletonCard from '../components/shared/SkeletonCard';
@@ -22,6 +23,10 @@ import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
import AnnouncementBanner from '../components/dashboard/AnnouncementBanner'; import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget'; import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget'; import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
import LinksWidget from '../components/dashboard/LinksWidget';
import { preferencesApi } from '../services/settings';
import { WidgetKey } from '../constants/widgets';
function Dashboard() { function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
@@ -33,6 +38,15 @@ function Dashboard() {
) ?? false; ) ?? false;
const [dataLoading, setDataLoading] = useState(true); 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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDataLoading(false); setDataLoading(false);
@@ -44,7 +58,6 @@ function Dashboard() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth={false} disableGutters> <Container maxWidth={false} disableGutters>
<AnnouncementBanner />
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
@@ -53,6 +66,9 @@ function Dashboard() {
alignItems: 'start', 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 Profile Card — full width, contains welcome greeting */}
{user && ( {user && (
<Box sx={{ gridColumn: '1 / -1' }}> <Box sx={{ gridColumn: '1 / -1' }}>
@@ -69,6 +85,7 @@ function Dashboard() {
)} )}
{/* Vehicle Status Card */} {/* Vehicle Status Card */}
{widgetVisible('vehicles') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box> <Box>
@@ -76,8 +93,10 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
)}
{/* Equipment Status Card */} {/* Equipment Status Card */}
{widgetVisible('equipment') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box> <Box>
@@ -85,9 +104,10 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
)}
{/* Atemschutz Status Card */} {/* Atemschutz Status Card */}
{canViewAtemschutz && ( {canViewAtemschutz && widgetVisible('atemschutz') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
<Box> <Box>
@@ -98,6 +118,7 @@ function Dashboard() {
)} )}
{/* Upcoming Events Widget */} {/* Upcoming Events Widget */}
{widgetVisible('events') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box> <Box>
@@ -105,8 +126,10 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
)}
{/* Nextcloud Talk Widget */} {/* Nextcloud Talk Widget */}
{widgetVisible('nextcloudTalk') && (
<Box> <Box>
{dataLoading ? ( {dataLoading ? (
<SkeletonCard variant="basic" /> <SkeletonCard variant="basic" />
@@ -118,8 +141,10 @@ function Dashboard() {
</Fade> </Fade>
)} )}
</Box> </Box>
)}
{/* BookStack Recent Pages Widget */} {/* BookStack Recent Pages Widget */}
{widgetVisible('bookstackRecent') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box> <Box>
@@ -127,8 +152,10 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
)}
{/* BookStack Search Widget */} {/* BookStack Search Widget */}
{widgetVisible('bookstackSearch') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box> <Box>
@@ -136,8 +163,10 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
)}
{/* Vikunja — My Tasks Widget */} {/* Vikunja — My Tasks Widget */}
{widgetVisible('vikunjaTasks') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box> <Box>
@@ -145,8 +174,10 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
)}
{/* Vikunja — Quick Add Widget */} {/* Vikunja — Quick Add Widget */}
{widgetVisible('vikunjaQuickAdd') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box> <Box>
@@ -154,9 +185,10 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
)}
{/* Vehicle Booking — Quick Add Widget */} {/* Vehicle Booking — Quick Add Widget */}
{canWrite && ( {canWrite && widgetVisible('vehicleBooking') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
<Box> <Box>
@@ -167,7 +199,7 @@ function Dashboard() {
)} )}
{/* Event — Quick Add Widget */} {/* Event — Quick Add Widget */}
{canWrite && ( {canWrite && widgetVisible('eventQuickAdd') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
<Box> <Box>
@@ -180,8 +212,19 @@ function Dashboard() {
{/* Vikunja — Overdue Notifier (invisible, polling component) */} {/* Vikunja — Overdue Notifier (invisible, polling component) */}
<VikunjaOverdueNotifier /> <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 */} {/* Admin Status Widget — only for admins */}
{isAdmin && ( {isAdmin && widgetVisible('adminStatus') && (
<Box> <Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box> <Box>

View File

@@ -11,13 +11,41 @@ import {
Box, Box,
ToggleButtonGroup, ToggleButtonGroup,
ToggleButton, ToggleButton,
CircularProgress,
} from '@mui/material'; } 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 DashboardLayout from '../components/dashboard/DashboardLayout';
import { useThemeMode } from '../contexts/ThemeContext'; import { useThemeMode } from '../contexts/ThemeContext';
import { preferencesApi } from '../services/settings';
import { WIDGETS, WidgetKey } from '../constants/widgets';
function Settings() { function Settings() {
const { themeMode, setThemeMode } = useThemeMode(); 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 ( return (
<DashboardLayout> <DashboardLayout>
@@ -27,6 +55,40 @@ function Settings() {
</Typography> </Typography>
<Grid container spacing={3}> <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 */} {/* Notification Settings */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Card> <Card>
@@ -146,20 +208,6 @@ function Settings() {
</Card> </Card>
</Grid> </Grid>
</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> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -1,5 +1,5 @@
import { api } from './api'; import { api } from './api';
import type { MonitoredService, PingResult, StatusSummary, SystemHealth, UserOverview, BroadcastPayload } from '../types/admin.types'; import type { MonitoredService, PingResult, PingHistoryEntry, StatusSummary, SystemHealth, UserOverview, BroadcastPayload } from '../types/admin.types';
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
@@ -16,4 +16,5 @@ export const adminApi = {
getSystemHealth: () => api.get<ApiResponse<SystemHealth>>('/api/admin/system/health').then(r => r.data.data), getSystemHealth: () => api.get<ApiResponse<SystemHealth>>('/api/admin/system/health').then(r => r.data.data),
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data), getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data), broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
}; };

View File

@@ -16,3 +16,8 @@ export const settingsApi = {
get: (key: string) => api.get<ApiResponse<AppSetting>>(`/api/admin/settings/${key}`).then(r => r.data.data), get: (key: string) => api.get<ApiResponse<AppSetting>>(`/api/admin/settings/${key}`).then(r => r.data.data),
update: (key: string, value: any) => api.put<ApiResponse<AppSetting>>(`/api/admin/settings/${key}`, { value }).then(r => r.data.data), update: (key: string, value: any) => api.put<ApiResponse<AppSetting>>(`/api/admin/settings/${key}`, { value }).then(r => r.data.data),
}; };
export const preferencesApi = {
get: () => api.get<{ success: boolean; data: Record<string, any> }>('/api/settings/preferences').then(r => r.data.data),
update: (prefs: Record<string, any>) => api.put('/api/settings/preferences', prefs).then(r => r.data),
};

View File

@@ -14,6 +14,15 @@ export interface PingResult {
status: 'up' | 'down'; status: 'up' | 'down';
latencyMs: number; latencyMs: number;
error?: string; error?: string;
checked_at?: string;
}
export interface PingHistoryEntry {
id: number;
service_id: string;
status: string;
response_time_ms: number | null;
checked_at: string;
} }
export interface StatusSummary { export interface StatusSummary {

View File

@@ -1,8 +1,15 @@
export interface LinkCollection {
id: string;
name: string;
links: Array<{ name: string; url: string }>;
}
export interface ExternalLinks { export interface ExternalLinks {
nextcloud?: string; nextcloud?: string;
bookstack?: string; bookstack?: string;
vikunja?: string; vikunja?: string;
customLinks?: Array<{ name: string; url: string }>; customLinks?: Array<{ name: string; url: string }>;
linkCollections?: LinkCollection[];
} }
export interface ServiceModeStatus { export interface ServiceModeStatus {