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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')}`,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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)}
|
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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
17
frontend/src/constants/widgets.ts
Normal file
17
frontend/src/constants/widgets.ts
Normal 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'];
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user