From 68586b01dc21bbbacd584dbcde3627d539e6fcb3 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 12 Mar 2026 16:42:21 +0100 Subject: [PATCH] resolve issues with new features --- backend/src/controllers/config.controller.ts | 16 ++ .../controllers/notification.controller.ts | 28 +++ backend/src/routes/config.routes.ts | 1 + backend/src/routes/notification.routes.ts | 2 + backend/src/services/bookstack.service.ts | 2 +- backend/src/services/notification.service.ts | 27 +++ frontend/src/components/chat/ChatPanel.tsx | 13 +- .../dashboard/BookStackSearchWidget.tsx | 2 +- .../src/components/dashboard/LinksWidget.tsx | 80 ++++----- .../components/shared/NotificationBell.tsx | 26 ++- frontend/src/components/shared/Sidebar.tsx | 169 +++++++++++++----- frontend/src/pages/AdminDashboard.tsx | 17 +- frontend/src/pages/AdminSettings.tsx | 85 ++++++++- frontend/src/pages/AusruestungForm.tsx | 26 ++- frontend/src/pages/Dashboard.tsx | 21 ++- frontend/src/pages/FahrzeugBuchungen.tsx | 2 +- frontend/src/pages/Kalender.tsx | 100 ++++++++++- frontend/src/services/config.ts | 10 ++ frontend/src/services/notifications.ts | 8 + 19 files changed, 526 insertions(+), 109 deletions(-) diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts index 288e2ee..0700f57 100644 --- a/backend/src/controllers/config.controller.ts +++ b/backend/src/controllers/config.controller.ts @@ -13,6 +13,22 @@ class ConfigController { } } + async getPdfSettings(_req: Request, res: Response): Promise { + try { + const header = await settingsService.get('pdf_header'); + const footer = await settingsService.get('pdf_footer'); + res.json({ + success: true, + data: { + pdf_header: header?.value ?? '', + pdf_footer: footer?.value ?? '', + }, + }); + } catch { + res.json({ success: true, data: { pdf_header: '', pdf_footer: '' } }); + } + } + async getExternalLinks(_req: Request, res: Response): Promise { const envLinks: Record = {}; if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl; diff --git a/backend/src/controllers/notification.controller.ts b/backend/src/controllers/notification.controller.ts index 441615e..a6b67f9 100644 --- a/backend/src/controllers/notification.controller.ts +++ b/backend/src/controllers/notification.controller.ts @@ -67,6 +67,34 @@ class NotificationController { res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' }); } } + /** POST /api/notifications/dismiss-by-type — marks all unread notifications of a given type as read. */ + async dismissByType(req: Request, res: Response): Promise { + try { + const { quellTyp } = req.body; + if (!quellTyp || typeof quellTyp !== 'string') { + res.status(400).json({ success: false, message: 'quellTyp ist erforderlich' }); + return; + } + const userId = req.user!.id; + await notificationService.dismissByType(userId, quellTyp); + res.status(200).json({ success: true, message: 'Notifications als gelesen markiert' }); + } catch (error) { + logger.error('NotificationController.dismissByType error', { error }); + res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' }); + } + } + + /** DELETE /api/notifications/read — deletes all read notifications for the authenticated user. */ + async deleteAllRead(req: Request, res: Response): Promise { + try { + const userId = req.user!.id; + await notificationService.deleteAllRead(userId); + res.status(200).json({ success: true, message: 'Gelesene Notifications gelöscht' }); + } catch (error) { + logger.error('NotificationController.deleteAllRead error', { error }); + res.status(500).json({ success: false, message: 'Gelesene Notifications konnten nicht gelöscht werden' }); + } + } } export default new NotificationController(); diff --git a/backend/src/routes/config.routes.ts b/backend/src/routes/config.routes.ts index 93ca8f4..9d38f16 100644 --- a/backend/src/routes/config.routes.ts +++ b/backend/src/routes/config.routes.ts @@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth.middleware'; const router = Router(); router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController)); +router.get('/pdf-settings', authenticate, configController.getPdfSettings.bind(configController)); router.get('/service-mode', authenticate, configController.getServiceMode.bind(configController)); export default router; diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts index 79e10af..1fa0a81 100644 --- a/backend/src/routes/notification.routes.ts +++ b/backend/src/routes/notification.routes.ts @@ -9,5 +9,7 @@ router.get('/', authenticate, notificationController.getNotific router.get('/count', authenticate, notificationController.getUnreadCount.bind(notificationController)); router.patch('/:id/read', authenticate, notificationController.markAsRead.bind(notificationController)); router.post('/mark-all-read', authenticate, notificationController.markAllRead.bind(notificationController)); +router.post('/dismiss-by-type', authenticate, notificationController.dismissByType.bind(notificationController)); +router.delete('/read', authenticate, notificationController.deleteAllRead.bind(notificationController)); export default router; diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index 94bbe48..9d29379 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -189,7 +189,7 @@ async function getPageById(id: number): Promise { html: page.html ?? '', created_at: page.created_at, updated_at: page.updated_at, - url: `${bookstack.url}/books/${page.book_slug || page.book_id}/page/${page.slug}`, + url: `${bookstack.url}/books/${page.book?.slug || page.book_slug || page.book_id}/page/${page.slug}`, book: page.book, createdBy: page.created_by, updatedBy: page.updated_by, diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts index e8df813..11ba490 100644 --- a/backend/src/services/notification.service.ts +++ b/backend/src/services/notification.service.ts @@ -113,6 +113,33 @@ class NotificationService { } } + /** Marks all unread notifications of a given quell_typ as read for a user. */ + async dismissByType(userId: string, quellTyp: string): Promise { + try { + await pool.query( + `UPDATE notifications SET gelesen = TRUE, gelesen_am = NOW() + WHERE user_id = $1 AND quell_typ = $2 AND gelesen = FALSE`, + [userId, quellTyp] + ); + } catch (error) { + logger.error('NotificationService.dismissByType failed', { error, userId, quellTyp }); + throw new Error('Notifications konnten nicht als gelesen markiert werden'); + } + } + + /** Deletes all read notifications for a user. */ + async deleteAllRead(userId: string): Promise { + try { + await pool.query( + `DELETE FROM notifications WHERE user_id = $1 AND gelesen = TRUE`, + [userId] + ); + } catch (error) { + logger.error('NotificationService.deleteAllRead failed', { error, userId }); + throw new Error('Gelesene Notifications konnten nicht gelöscht werden'); + } + } + /** Deletes read notifications older than 90 days for all users. */ async deleteOldRead(): Promise { try { diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 108c7bd..6a6027a 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -14,8 +14,9 @@ import ListItem from '@mui/material/ListItem'; import { useLayout } from '../../contexts/LayoutContext'; import { ChatProvider, useChat } from '../../contexts/ChatContext'; import { Link } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { configApi } from '../../services/config'; +import { notificationsApi } from '../../services/notifications'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; import ChatRoomList from './ChatRoomList'; import ChatMessageView from './ChatMessageView'; @@ -27,6 +28,7 @@ const EXPANDED_WIDTH = 360; const ChatPanelInner: React.FC = () => { const { chatPanelOpen, setChatPanelOpen } = useLayout(); const { rooms, selectedRoomToken, selectRoom, connected } = useChat(); + const queryClient = useQueryClient(); const { data: externalLinks } = useQuery({ queryKey: ['external-links'], queryFn: () => configApi.getExternalLinks(), @@ -34,6 +36,15 @@ const ChatPanelInner: React.FC = () => { }); const nextcloudUrl = externalLinks?.nextcloud; + React.useEffect(() => { + if (chatPanelOpen) { + notificationsApi.dismissByType('nextcloud_talk').then(() => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] }); + }).catch(() => {}); + } + }, [chatPanelOpen, queryClient]); + if (!chatPanelOpen) { return ( { )} {results.length > 0 && ( - + {results.map((result, index) => ( 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; +interface LinksWidgetProps { + collection: LinkCollection; +} +function LinksWidget({ collection }: LinksWidgetProps) { return ( - <> - {nonEmpty.map((collection) => ( - - - - - {collection.name} - - - - {collection.links.map((link, i) => ( - - {link.name} - - - ))} - - - - ))} - + + + + + {collection.name} + + + + {collection.links.map((link, i) => ( + + {link.name} + + + ))} + + + ); } diff --git a/frontend/src/components/shared/NotificationBell.tsx b/frontend/src/components/shared/NotificationBell.tsx index 185362f..cb88175 100644 --- a/frontend/src/components/shared/NotificationBell.tsx +++ b/frontend/src/components/shared/NotificationBell.tsx @@ -138,6 +138,15 @@ const NotificationBell: React.FC = () => { } }; + const handleDeleteAllRead = async () => { + try { + await notificationsApi.deleteAllRead(); + setNotifications((prev) => prev.filter((n) => !n.gelesen)); + } catch { + // non-critical + } + }; + const open = Boolean(anchorEl); const hasUnread = unreadCount > 0; @@ -169,11 +178,18 @@ const NotificationBell: React.FC = () => { Benachrichtigungen - {unreadCount > 0 && ( - - )} + + {notifications.some((n) => n.gelesen) && ( + + )} + {unreadCount > 0 && ( + + )} + diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index a3f5f33..a24140b 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -1,6 +1,7 @@ -import { useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { Box, + Collapse, Drawer, IconButton, List, @@ -22,6 +23,8 @@ import { AdminPanelSettings, Settings, Menu as MenuIcon, + ExpandMore, + ExpandLess, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext'; @@ -29,12 +32,32 @@ import { useAuth } from '../../contexts/AuthContext'; export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED }; +interface SubItem { + text: string; + tabIndex: number; +} + interface NavigationItem { text: string; icon: JSX.Element; path: string; + subItems?: SubItem[]; } +const kalenderSubItems: SubItem[] = [ + { text: 'Veranstaltungen', tabIndex: 0 }, + { text: 'Fahrzeugbuchungen', tabIndex: 1 }, +]; + +const adminSubItems: SubItem[] = [ + { text: 'Services', tabIndex: 0 }, + { text: 'System', tabIndex: 1 }, + { text: 'Benutzer', tabIndex: 2 }, + { text: 'Broadcast', tabIndex: 3 }, + { text: 'Banner', tabIndex: 4 }, + { text: 'Wartung', tabIndex: 5 }, +]; + const baseNavigationItems: NavigationItem[] = [ { text: 'Dashboard', @@ -45,6 +68,7 @@ const baseNavigationItems: NavigationItem[] = [ text: 'Kalender', icon: , path: '/kalender', + subItems: kalenderSubItems, }, { text: 'Fahrzeuge', @@ -77,6 +101,7 @@ const adminItem: NavigationItem = { text: 'Admin', icon: , path: '/admin', + subItems: adminSubItems, }; const adminSettingsItem: NavigationItem = { @@ -102,6 +127,19 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems; }, [isAdmin]); + // Expand state for items with sub-items — auto-expand when route matches + const [expandedItems, setExpandedItems] = useState>({}); + + const isExpanded = (item: NavigationItem) => { + if (expandedItems[item.path] !== undefined) return expandedItems[item.path]; + // Auto-expand when the current route matches + return location.pathname === item.path || location.pathname.startsWith(item.path + '/'); + }; + + const toggleExpand = (path: string) => { + setExpandedItems((prev) => ({ ...prev, [path]: !isExpanded({ path } as NavigationItem) })); + }; + const handleNavigation = (path: string) => { navigate(path); onMobileClose(); @@ -118,50 +156,101 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { {navigationItems.map((item) => { const isActive = location.pathname === item.path; + const hasSubItems = item.subItems && item.subItems.length > 0; + const expanded = hasSubItems && isExpanded(item); return ( - - - handleNavigation(item.path)} - aria-label={`Zu ${item.text} navigieren`} - sx={{ - justifyContent: sidebarCollapsed ? 'center' : 'initial', - '&.Mui-selected': { - backgroundColor: 'primary.light', - color: 'primary.contrastText', - '&:hover': { - backgroundColor: 'primary.main', - }, - '& .MuiListItemIcon-root': { - color: 'primary.contrastText', - }, - }, - }} + + + - handleNavigation(hasSubItems ? `${item.path}?tab=0` : item.path)} + aria-label={`Zu ${item.text} navigieren`} sx={{ - color: isActive ? 'inherit' : 'text.secondary', - minWidth: sidebarCollapsed ? 0 : undefined, - justifyContent: 'center', + justifyContent: sidebarCollapsed ? 'center' : 'initial', + '&.Mui-selected': { + backgroundColor: 'primary.light', + color: 'primary.contrastText', + '&:hover': { + backgroundColor: 'primary.main', + }, + '& .MuiListItemIcon-root': { + color: 'primary.contrastText', + }, + }, }} > - {item.icon} - - - - - + + {item.icon} + + + {hasSubItems && !sidebarCollapsed && ( + { + e.stopPropagation(); + toggleExpand(item.path); + }} + sx={{ color: isActive ? 'inherit' : 'text.secondary' }} + > + {expanded ? : } + + )} + + + + {hasSubItems && !sidebarCollapsed && ( + + + {item.subItems!.map((sub) => { + const subPath = `${item.path}?tab=${sub.tabIndex}`; + const isSubActive = + location.pathname === item.path && + location.search === `?tab=${sub.tabIndex}`; + return ( + handleNavigation(subPath)} + selected={isSubActive} + sx={{ + pl: 4, + py: 0.5, + '&.Mui-selected': { + backgroundColor: 'primary.light', + color: 'primary.contrastText', + '&:hover': { + backgroundColor: 'primary.main', + }, + }, + }} + > + + + ); + })} + + + )} + ); })} diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 65876ab..867c642 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Box, Tabs, Tab, Typography } from '@mui/material'; -import { Navigate } from 'react-router-dom'; +import { Navigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ServiceManagerTab from '../components/admin/ServiceManagerTab'; import SystemHealthTab from '../components/admin/SystemHealthTab'; @@ -21,8 +21,19 @@ function TabPanel({ children, value, index }: TabPanelProps) { return {children}; } +const ADMIN_TAB_COUNT = 6; + function AdminDashboard() { - const [tab, setTab] = useState(0); + const [searchParams] = useSearchParams(); + const [tab, setTab] = useState(() => { + const t = Number(searchParams.get('tab')); + return t >= 0 && t < ADMIN_TAB_COUNT ? t : 0; + }); + + useEffect(() => { + const t = Number(searchParams.get('tab')); + if (t >= 0 && t < ADMIN_TAB_COUNT) setTab(t); + }, [searchParams]); const { user } = useAuth(); const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index 7cf4509..a8e38ab 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -26,6 +26,7 @@ import { Timer, Info, ExpandMore, + PictureAsPdf as PdfIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Navigate } from 'react-router-dom'; @@ -75,6 +76,10 @@ function AdminSettings() { adminServices: 15, }); + // State for PDF header/footer + const [pdfHeader, setPdfHeader] = useState(''); + const [pdfFooter, setPdfFooter] = useState(''); + // Fetch all settings const { data: settings, isLoading } = useQuery({ queryKey: ['admin-settings'], @@ -103,6 +108,11 @@ function AdminSettings() { adminServices: intervalsSetting.value.adminServices ?? 15, }); } + + const pdfHeaderSetting = settings.find((s) => s.key === 'pdf_header'); + if (pdfHeaderSetting?.value != null) setPdfHeader(pdfHeaderSetting.value); + const pdfFooterSetting = settings.find((s) => s.key === 'pdf_footer'); + if (pdfFooterSetting?.value != null) setPdfFooter(pdfFooterSetting.value); } }, [settings]); @@ -131,6 +141,33 @@ function AdminSettings() { }, }); + // Mutation for saving PDF settings + const pdfHeaderMutation = useMutation({ + mutationFn: (value: string) => settingsApi.update('pdf_header', value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + queryClient.invalidateQueries({ queryKey: ['pdf-settings'] }); + }, + }); + const pdfFooterMutation = useMutation({ + mutationFn: (value: string) => settingsApi.update('pdf_footer', value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + queryClient.invalidateQueries({ queryKey: ['pdf-settings'] }); + }, + }); + const handleSavePdfSettings = async () => { + try { + await Promise.all([ + pdfHeaderMutation.mutateAsync(pdfHeader), + pdfFooterMutation.mutateAsync(pdfFooter), + ]); + showSuccess('PDF-Einstellungen gespeichert'); + } catch { + showError('Fehler beim Speichern der PDF-Einstellungen'); + } + }; + if (!isAdmin) { return ; } @@ -373,7 +410,53 @@ function AdminSettings() { - {/* Section 3: Info */} + {/* Section 3: PDF Settings */} + + + + + PDF-Einstellungen + + + + Kopf- und Fußzeile für Kalender-PDF-Exporte. Zeilenumbrüche werden übernommen, **fett** erzeugt fettgedruckten Text. + + + setPdfHeader(e.target.value)} + multiline + minRows={2} + maxRows={6} + fullWidth + size="small" + /> + setPdfFooter(e.target.value)} + multiline + minRows={2} + maxRows={6} + fullWidth + size="small" + /> + + + + + + + + {/* Section 4: Info */} diff --git a/frontend/src/pages/AusruestungForm.tsx b/frontend/src/pages/AusruestungForm.tsx index b8e0bf0..4fe8217 100644 --- a/frontend/src/pages/AusruestungForm.tsx +++ b/frontend/src/pages/AusruestungForm.tsx @@ -19,7 +19,7 @@ import { import { ArrowBack, Save } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { toGermanDate, fromGermanDate } from '../utils/dateInput'; +import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput'; import { equipmentApi } from '../services/equipment'; import { vehiclesApi } from '../services/vehicles'; import { @@ -190,6 +190,12 @@ function AusruestungForm() { errors.pruef_intervall_monate = 'Prüfintervall muss zwischen 1 und 120 Monaten liegen.'; } } + if (form.letzte_pruefung_am && !isValidGermanDate(form.letzte_pruefung_am)) { + errors.letzte_pruefung_am = 'Ungültiges Datum. Format: TT.MM.JJJJ'; + } + if (form.naechste_pruefung_am && !isValidGermanDate(form.naechste_pruefung_am)) { + errors.naechste_pruefung_am = 'Ungültiges Datum. Format: TT.MM.JJJJ'; + } setFieldErrors(errors); return Object.keys(errors).length === 0; }; @@ -466,6 +472,15 @@ function AusruestungForm() { placeholder="TT.MM.JJJJ" value={form.letzte_pruefung_am} onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))} + onBlur={() => { + if (form.letzte_pruefung_am && !isValidGermanDate(form.letzte_pruefung_am)) { + setFieldErrors((prev) => ({ ...prev, letzte_pruefung_am: 'Ungültiges Datum. Format: TT.MM.JJJJ' })); + } else { + setFieldErrors((prev) => ({ ...prev, letzte_pruefung_am: undefined })); + } + }} + error={Boolean(fieldErrors.letzte_pruefung_am)} + helperText={fieldErrors.letzte_pruefung_am} InputLabelProps={{ shrink: true }} /> @@ -476,6 +491,15 @@ function AusruestungForm() { placeholder="TT.MM.JJJJ" value={form.naechste_pruefung_am} onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))} + onBlur={() => { + if (form.naechste_pruefung_am && !isValidGermanDate(form.naechste_pruefung_am)) { + setFieldErrors((prev) => ({ ...prev, naechste_pruefung_am: 'Ungültiges Datum. Format: TT.MM.JJJJ' })); + } else { + setFieldErrors((prev) => ({ ...prev, naechste_pruefung_am: undefined })); + } + }} + error={Boolean(fieldErrors.naechste_pruefung_am)} + helperText={fieldErrors.naechste_pruefung_am} InputLabelProps={{ shrink: true }} /> diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a3e9ddc..5a0c0e7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -26,6 +26,7 @@ import LinksWidget from '../components/dashboard/LinksWidget'; import BannerWidget from '../components/dashboard/BannerWidget'; import WidgetGroup from '../components/dashboard/WidgetGroup'; import { preferencesApi } from '../services/settings'; +import { configApi } from '../services/config'; import { WidgetKey } from '../constants/widgets'; function Dashboard() { @@ -44,6 +45,16 @@ function Dashboard() { queryFn: preferencesApi.get, }); + const { data: externalLinks } = useQuery({ + queryKey: ['external-links'], + queryFn: () => configApi.getExternalLinks(), + staleTime: 10 * 60 * 1000, + }); + + const linkCollections = (externalLinks?.linkCollections ?? []).filter( + (c) => c.links.length > 0 + ); + const widgetVisible = (key: WidgetKey) => { return preferences?.widgets?.[key] !== false; }; @@ -187,15 +198,15 @@ function Dashboard() { {/* Information Group */} - {widgetVisible('links') && ( - + {widgetVisible('links') && linkCollections.map((collection, idx) => ( + - + - )} + ))} - + diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index 4b6fbb2..fbfbd92 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -408,7 +408,7 @@ function FahrzeugBuchungen() { align="center" sx={{ fontWeight: isToday(day) ? 700 : 400, - color: isToday(day) ? 'primary.main' : 'text.secondary', + color: isToday(day) ? 'primary.main' : 'text.primary', bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined, }} > diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 2b3bc07..2d140aa 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -68,7 +68,7 @@ import { ViewWeek as ViewWeekIcon, Warning, } from '@mui/icons-material'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDate, isValidGermanDateTime } from '../utils/dateInput'; import { useAuth } from '../contexts/AuthContext'; @@ -76,6 +76,7 @@ import { useNotification } from '../contexts/NotificationContext'; import { trainingApi } from '../services/training'; import { eventsApi } from '../services/events'; import { bookingApi, fetchVehicles } from '../services/bookings'; +import { configApi, type PdfSettings } from '../services/config'; import type { UebungListItem, UebungTyp, @@ -622,6 +623,62 @@ function DayPopover({ // PDF Export helper // ────────────────────────────────────────────────────────────────────────────── +/** + * Render text with basic markdown (**bold**) and line breaks into a jsPDF doc. + * Returns the final Y position after rendering. + */ +function renderMarkdownText( + doc: import('jspdf').jsPDF, + text: string, + x: number, + y: number, + options?: { fontSize?: number; maxWidth?: number }, +): number { + const fontSize = options?.fontSize ?? 9; + const lineHeight = fontSize * 0.5; // ~mm per line + doc.setFontSize(fontSize); + doc.setTextColor(0, 0, 0); + + const lines = text.split('\n'); + let curY = y; + + for (const line of lines) { + // Split by ** to alternate normal/bold + const segments = line.split('**'); + let curX = x; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (!seg) continue; + const isBold = i % 2 === 1; + doc.setFont('helvetica', isBold ? 'bold' : 'normal'); + doc.text(seg, curX, curY); + curX += doc.getTextWidth(seg); + } + curY += lineHeight; + } + + // Reset font + doc.setFont('helvetica', 'normal'); + return curY; +} + +let _pdfSettingsCache: PdfSettings | null = null; +let _pdfSettingsCacheTime = 0; + +async function fetchPdfSettings(): Promise { + // Cache for 30 seconds to avoid fetching on every export click + if (_pdfSettingsCache && Date.now() - _pdfSettingsCacheTime < 30_000) { + return _pdfSettingsCache; + } + try { + _pdfSettingsCache = await configApi.getPdfSettings(); + _pdfSettingsCacheTime = Date.now(); + return _pdfSettingsCache; + } catch { + return { pdf_header: '', pdf_footer: '' }; + } +} + async function generatePdf( year: number, month: number, @@ -635,6 +692,8 @@ async function generatePdf( const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); const monthLabel = MONTH_LABELS[month]; + const pdfSettings = await fetchPdfSettings(); + // Header bar doc.setFillColor(183, 28, 28); // fire-red doc.rect(0, 0, 297, 18, 'F'); @@ -646,6 +705,12 @@ async function generatePdf( doc.setFont('helvetica', 'normal'); doc.text('Feuerwehr Rems', 250, 12); + // Custom header text + let tableStartY = 22; + if (pdfSettings.pdf_header) { + tableStartY = renderMarkdownText(doc, pdfSettings.pdf_header, 10, 24, { fontSize: 9 }) + 2; + } + // Build combined list (same logic as CombinedListView) type ListEntry = | { kind: 'training'; item: UebungListItem } @@ -681,7 +746,7 @@ async function generatePdf( autoTable(doc, { head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']], body: rows, - startY: 22, + startY: tableStartY, headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' }, alternateRowStyles: { fillColor: [250, 235, 235] }, margin: { left: 10, right: 10 }, @@ -693,6 +758,11 @@ async function generatePdf( 3: { cellWidth: 40 }, 4: { cellWidth: 60 }, }, + didDrawPage: pdfSettings.pdf_footer + ? () => { + renderMarkdownText(doc, pdfSettings.pdf_footer, 10, doc.internal.pageSize.height - 12, { fontSize: 8 }); + } + : undefined, }); const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`; @@ -717,6 +787,8 @@ async function generateBookingsPdf( const endLabel = fnsFormat(weekEnd, 'dd.MM.yyyy'); const kwLabel = `KW ${fnsFormat(weekStart, 'w')}`; + const pdfSettings = await fetchPdfSettings(); + // Header bar doc.setFillColor(183, 28, 28); // fire-red doc.rect(0, 0, 297, 18, 'F'); @@ -728,6 +800,12 @@ async function generateBookingsPdf( doc.setFont('helvetica', 'normal'); doc.text('Feuerwehr Rems', 250, 12); + // Custom header text + let tableStartY = 22; + if (pdfSettings.pdf_header) { + tableStartY = renderMarkdownText(doc, pdfSettings.pdf_header, 10, 24, { fontSize: 9 }) + 2; + } + const formatDt = (iso: string) => { const d = new Date(iso); return fnsFormat(d, 'dd.MM.yyyy HH:mm'); @@ -745,7 +823,7 @@ async function generateBookingsPdf( autoTable(doc, { head: [['Fahrzeug', 'Titel', 'Beginn', 'Ende', 'Art']], body: rows, - startY: 22, + startY: tableStartY, headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' }, alternateRowStyles: { fillColor: [250, 235, 235] }, margin: { left: 10, right: 10 }, @@ -757,6 +835,11 @@ async function generateBookingsPdf( 3: { cellWidth: 38 }, 4: { cellWidth: 35 }, }, + didDrawPage: pdfSettings.pdf_footer + ? () => { + renderMarkdownText(doc, pdfSettings.pdf_footer, 10, doc.internal.pageSize.height - 12, { fontSize: 8 }); + } + : undefined, }); const filename = `fahrzeugbuchungen_${fnsFormat(weekStart, 'yyyy-MM-dd')}.pdf`; @@ -1557,6 +1640,7 @@ function VeranstaltungFormDialog({ export default function Kalender() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { user } = useAuth(); const notification = useNotification(); const theme = useTheme(); @@ -1569,7 +1653,15 @@ export default function Kalender() { const canCreateBookings = !!user; // ── Tab ───────────────────────────────────────────────────────────────────── - const [activeTab, setActiveTab] = useState(0); + const [activeTab, setActiveTab] = useState(() => { + const t = Number(searchParams.get('tab')); + return t >= 0 && t < 2 ? t : 0; + }); + + useEffect(() => { + const t = Number(searchParams.get('tab')); + if (t >= 0 && t < 2) setActiveTab(t); + }, [searchParams]); // ── Calendar tab state ─────────────────────────────────────────────────────── const today = new Date(); diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts index a702747..e44dd49 100644 --- a/frontend/src/services/config.ts +++ b/frontend/src/services/config.ts @@ -6,6 +6,11 @@ interface ApiResponse { data: T; } +export interface PdfSettings { + pdf_header: string; + pdf_footer: string; +} + export const configApi = { getExternalLinks(): Promise { return api @@ -17,4 +22,9 @@ export const configApi = { .get>('/api/config/service-mode') .then((r) => r.data.data); }, + getPdfSettings(): Promise { + return api + .get>('/api/config/pdf-settings') + .then((r) => r.data.data); + }, }; diff --git a/frontend/src/services/notifications.ts b/frontend/src/services/notifications.ts index ff500e0..caade81 100644 --- a/frontend/src/services/notifications.ts +++ b/frontend/src/services/notifications.ts @@ -28,4 +28,12 @@ export const notificationsApi = { async markAllRead(): Promise { await api.post('/api/notifications/mark-all-read'); }, + + async dismissByType(quellTyp: string): Promise { + await api.post('/api/notifications/dismiss-by-type', { quellTyp }); + }, + + async deleteAllRead(): Promise { + await api.delete('/api/notifications/read'); + }, };