diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts index 0700f57..fd62a18 100644 --- a/backend/src/controllers/config.controller.ts +++ b/backend/src/controllers/config.controller.ts @@ -15,17 +15,23 @@ class ConfigController { async getPdfSettings(_req: Request, res: Response): Promise { try { - const header = await settingsService.get('pdf_header'); - const footer = await settingsService.get('pdf_footer'); + const [header, footer, logo, orgName] = await Promise.all([ + settingsService.get('pdf_header'), + settingsService.get('pdf_footer'), + settingsService.get('pdf_logo'), + settingsService.get('pdf_org_name'), + ]); res.json({ success: true, data: { pdf_header: header?.value ?? '', pdf_footer: footer?.value ?? '', + pdf_logo: logo?.value ?? '', + pdf_org_name: orgName?.value ?? '', }, }); } catch { - res.json({ success: true, data: { pdf_header: '', pdf_footer: '' } }); + res.json({ success: true, data: { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' } }); } } diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index 9d29379..170de6f 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -81,6 +81,25 @@ function buildHeaders(): Record { }; } +/** + * Fetches all BookStack books and returns a map of book_id → book_slug. + * The /api/pages list endpoint does not reliably include book_slug, so we + * look it up separately and use it when constructing page URLs. + */ +async function getBookSlugMap(): Promise> { + const { bookstack } = environment; + try { + const response = await axios.get( + `${bookstack.url}/api/books`, + { params: { count: 500 }, headers: buildHeaders() }, + ); + const books: Array<{ id: number; slug: string }> = response.data?.data ?? []; + return new Map(books.map((b) => [b.id, b.slug])); + } catch { + return new Map(); + } +} + async function getRecentPages(): Promise { const { bookstack } = environment; if (!bookstack.url || !isValidServiceUrl(bookstack.url)) { @@ -88,17 +107,20 @@ async function getRecentPages(): Promise { } try { - const response = await axios.get( - `${bookstack.url}/api/pages`, - { - params: { sort: '-updated_at', count: 20 }, - headers: buildHeaders(), - }, - ); + const [response, bookSlugMap] = await Promise.all([ + axios.get( + `${bookstack.url}/api/pages`, + { + params: { sort: '-updated_at', count: 20 }, + headers: buildHeaders(), + }, + ), + getBookSlugMap(), + ]); const pages: BookStackPage[] = response.data?.data ?? []; return pages.map((p) => ({ ...p, - url: `${bookstack.url}/books/${p.book_slug || p.book_id}/page/${p.slug}`, + url: `${bookstack.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`, })); } catch (error) { if (axios.isAxiosError(error)) { @@ -174,11 +196,15 @@ async function getPageById(id: number): Promise { } try { - const response = await axios.get( - `${bookstack.url}/api/pages/${id}`, - { headers: buildHeaders() }, - ); + const [response, bookSlugMap] = await Promise.all([ + axios.get( + `${bookstack.url}/api/pages/${id}`, + { headers: buildHeaders() }, + ), + getBookSlugMap(), + ]); const page = response.data; + const bookSlug = bookSlugMap.get(page.book_id) || page.book?.slug || page.book_slug || page.book_id; return { id: page.id, name: page.name, @@ -189,7 +215,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_slug || page.book_id}/page/${page.slug}`, + url: `${bookstack.url}/books/${bookSlug}/page/${page.slug}`, book: page.book, createdBy: page.created_by, updatedBy: page.updated_by, diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 6a6027a..f86d108 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -17,6 +17,7 @@ import { Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { configApi } from '../../services/config'; import { notificationsApi } from '../../services/notifications'; +import { nextcloudApi } from '../../services/nextcloud'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; import ChatRoomList from './ChatRoomList'; import ChatMessageView from './ChatMessageView'; @@ -36,12 +37,27 @@ const ChatPanelInner: React.FC = () => { }); const nextcloudUrl = externalLinks?.nextcloud; + // Keep a ref to rooms so the effect can access the latest list without + // re-running every time room data refreshes. + const roomsRef = React.useRef(rooms); + roomsRef.current = rooms; + React.useEffect(() => { if (chatPanelOpen) { + // Dismiss our internal notification-centre entries notificationsApi.dismissByType('nextcloud_talk').then(() => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] }); }).catch(() => {}); + + // Also mark all unread rooms as read directly in Nextcloud so that + // Nextcloud's own notification badges clear as well. + roomsRef.current + .filter((r) => r.unreadMessages > 0) + .forEach((r) => { + nextcloudApi.markAsRead(r.token).catch(() => {}); + }); + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); } }, [chatPanelOpen, queryClient]); diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index a24140b..34ee2eb 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -27,14 +27,16 @@ import { ExpandLess, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext'; import { useAuth } from '../../contexts/AuthContext'; +import { vehiclesApi } from '../../services/vehicles'; export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED }; interface SubItem { text: string; - tabIndex: number; + path: string; } interface NavigationItem { @@ -45,17 +47,17 @@ interface NavigationItem { } const kalenderSubItems: SubItem[] = [ - { text: 'Veranstaltungen', tabIndex: 0 }, - { text: 'Fahrzeugbuchungen', tabIndex: 1 }, + { text: 'Veranstaltungen', path: '/kalender?tab=0' }, + { text: 'Fahrzeugbuchungen', path: '/kalender?tab=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 }, + { text: 'Services', path: '/admin?tab=0' }, + { text: 'System', path: '/admin?tab=1' }, + { text: 'Benutzer', path: '/admin?tab=2' }, + { text: 'Broadcast', path: '/admin?tab=3' }, + { text: 'Banner', path: '/admin?tab=4' }, + { text: 'Wartung', path: '/admin?tab=5' }, ]; const baseNavigationItems: NavigationItem[] = [ @@ -123,9 +125,34 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; - const navigationItems = useMemo(() => { - return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems; - }, [isAdmin]); + // Fetch vehicle list for dynamic dropdown sub-items + const { data: vehicleList } = useQuery({ + queryKey: ['vehicles', 'sidebar'], + queryFn: () => vehiclesApi.getAll(), + staleTime: 2 * 60 * 1000, + }); + + const vehicleSubItems: SubItem[] = useMemo( + () => + (vehicleList ?? []).map((v) => ({ + text: v.kurzname ?? v.bezeichnung, + path: `/fahrzeuge/${v.id}`, + })), + [vehicleList], + ); + + const navigationItems = useMemo((): NavigationItem[] => { + const fahrzeugeItem: NavigationItem = { + text: 'Fahrzeuge', + icon: , + path: '/fahrzeuge', + subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined, + }; + const items = baseNavigationItems.map((item) => + item.path === '/fahrzeuge' ? fahrzeugeItem : item, + ); + return isAdmin ? [...items, adminItem, adminSettingsItem] : items; + }, [isAdmin, vehicleSubItems]); // Expand state for items with sub-items — auto-expand when route matches const [expandedItems, setExpandedItems] = useState>({}); @@ -169,7 +196,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { > handleNavigation(hasSubItems ? `${item.path}?tab=0` : item.path)} + onClick={() => handleNavigation(hasSubItems ? item.subItems![0].path : item.path)} aria-label={`Zu ${item.text} navigieren`} sx={{ justifyContent: sidebarCollapsed ? 'center' : 'initial', @@ -219,14 +246,11 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { {item.subItems!.map((sub) => { - const subPath = `${item.path}?tab=${sub.tabIndex}`; - const isSubActive = - location.pathname === item.path && - location.search === `?tab=${sub.tabIndex}`; + const isSubActive = location.pathname + location.search === sub.path; return ( handleNavigation(subPath)} + key={sub.path} + onClick={() => handleNavigation(sub.path)} selected={isSubActive} sx={{ pl: 4, diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index a8e38ab..cbf1503 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Container, Typography, @@ -76,9 +76,11 @@ function AdminSettings() { adminServices: 15, }); - // State for PDF header/footer + // State for PDF header/footer/logo/org const [pdfHeader, setPdfHeader] = useState(''); const [pdfFooter, setPdfFooter] = useState(''); + const [pdfLogo, setPdfLogo] = useState(''); + const [pdfOrgName, setPdfOrgName] = useState(''); // Fetch all settings const { data: settings, isLoading } = useQuery({ @@ -113,6 +115,10 @@ function AdminSettings() { if (pdfHeaderSetting?.value != null) setPdfHeader(pdfHeaderSetting.value); const pdfFooterSetting = settings.find((s) => s.key === 'pdf_footer'); if (pdfFooterSetting?.value != null) setPdfFooter(pdfFooterSetting.value); + const pdfLogoSetting = settings.find((s) => s.key === 'pdf_logo'); + if (pdfLogoSetting?.value != null) setPdfLogo(pdfLogoSetting.value); + const pdfOrgNameSetting = settings.find((s) => s.key === 'pdf_org_name'); + if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value); } }, [settings]); @@ -156,17 +162,40 @@ function AdminSettings() { queryClient.invalidateQueries({ queryKey: ['pdf-settings'] }); }, }); + const pdfLogoMutation = useMutation({ + mutationFn: (value: string) => settingsApi.update('pdf_logo', value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + queryClient.invalidateQueries({ queryKey: ['pdf-settings'] }); + }, + }); + const pdfOrgNameMutation = useMutation({ + mutationFn: (value: string) => settingsApi.update('pdf_org_name', 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), + pdfLogoMutation.mutateAsync(pdfLogo), + pdfOrgNameMutation.mutateAsync(pdfOrgName), ]); showSuccess('PDF-Einstellungen gespeichert'); } catch { showError('Fehler beim Speichern der PDF-Einstellungen'); } }; + const handleLogoUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => setPdfLogo(ev.target?.result as string); + reader.readAsDataURL(file); + }; if (!isAdmin) { return ; @@ -423,7 +452,44 @@ function AdminSettings() { setPdfOrgName(e.target.value)} + fullWidth + size="small" + placeholder="FREIWILLIGE FEUERWEHR REMS" + /> + + + Logo (erscheint rechts im PDF-Header, neben dem Organisationsnamen) + + + + {pdfLogo && ( + <> + + setPdfLogo('')} title="Logo entfernen"> + + + + )} + + + setPdfHeader(e.target.value)} multiline @@ -447,7 +513,12 @@ function AdminSettings() { onClick={handleSavePdfSettings} variant="contained" size="small" - disabled={pdfHeaderMutation.isPending || pdfFooterMutation.isPending} + disabled={ + pdfHeaderMutation.isPending || + pdfFooterMutation.isPending || + pdfLogoMutation.isPending || + pdfOrgNameMutation.isPending + } > Speichern diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index fbfbd92..6f8ab0a 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -398,7 +398,7 @@ function FahrzeugBuchungen() { - theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100' }}> + theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}> Fahrzeug diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 2d140aa..39f2bbe 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -675,7 +675,7 @@ async function fetchPdfSettings(): Promise { _pdfSettingsCacheTime = Date.now(); return _pdfSettingsCache; } catch { - return { pdf_header: '', pdf_footer: '' }; + return { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; } } @@ -701,9 +701,28 @@ async function generatePdf( doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text(`Kalender — ${monthLabel} ${year}`, 10, 12); - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('Feuerwehr Rems', 250, 12); + + // Right side: logo and/or org name + const logoSize = 14; + const logoX = 297 - 4 - logoSize; // 4mm right margin + if (pdfSettings.pdf_logo) { + try { + const fmt = pdfSettings.pdf_logo.match(/^data:image\/(\w+);/)?.[1]?.toUpperCase() ?? 'PNG'; + doc.addImage(pdfSettings.pdf_logo, fmt === 'JPG' ? 'JPEG' : fmt, logoX, 2, logoSize, logoSize); + } catch { /* ignore invalid image */ } + } + if (pdfSettings.pdf_org_name) { + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + const nameW = doc.getTextWidth(pdfSettings.pdf_org_name); + const nameX = (pdfSettings.pdf_logo ? logoX - 3 : 297 - 4) - nameW; + doc.text(pdfSettings.pdf_org_name, nameX, 12); + } else if (!pdfSettings.pdf_logo) { + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text('Feuerwehr Rems', 250, 12); + } // Custom header text let tableStartY = 22; @@ -796,9 +815,28 @@ async function generateBookingsPdf( doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text(`Fahrzeugbuchungen — ${kwLabel} · ${startLabel} – ${endLabel}`, 10, 12); - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('Feuerwehr Rems', 250, 12); + + // Right side: logo and/or org name + const logoSize = 14; + const logoX = 297 - 4 - logoSize; + if (pdfSettings.pdf_logo) { + try { + const fmt = pdfSettings.pdf_logo.match(/^data:image\/(\w+);/)?.[1]?.toUpperCase() ?? 'PNG'; + doc.addImage(pdfSettings.pdf_logo, fmt === 'JPG' ? 'JPEG' : fmt, logoX, 2, logoSize, logoSize); + } catch { /* ignore invalid image */ } + } + if (pdfSettings.pdf_org_name) { + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + const nameW = doc.getTextWidth(pdfSettings.pdf_org_name); + const nameX = (pdfSettings.pdf_logo ? logoX - 3 : 297 - 4) - nameW; + doc.text(pdfSettings.pdf_org_name, nameX, 12); + } else if (!pdfSettings.pdf_logo) { + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text('Feuerwehr Rems', 250, 12); + } // Custom header text let tableStartY = 22; @@ -2747,7 +2785,7 @@ export default function Kalender() {
- + theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}> Fahrzeug @@ -2758,7 +2796,7 @@ export default function Kalender() { sx={{ fontWeight: fnsIsToday(day) ? 700 : 400, color: fnsIsToday(day) ? 'primary.main' : 'text.primary', - bgcolor: fnsIsToday(day) ? 'primary.50' : undefined, + bgcolor: fnsIsToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined, }} > diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts index e44dd49..c6cb4d8 100644 --- a/frontend/src/services/config.ts +++ b/frontend/src/services/config.ts @@ -9,6 +9,8 @@ interface ApiResponse { export interface PdfSettings { pdf_header: string; pdf_footer: string; + pdf_logo: string; + pdf_org_name: string; } export const configApi = {