resolve issues with new features

This commit is contained in:
Matthias Hochmeister
2026-03-12 17:20:32 +01:00
parent 68586b01dc
commit 34ca007f9b
8 changed files with 232 additions and 49 deletions

View File

@@ -15,17 +15,23 @@ class ConfigController {
async getPdfSettings(_req: Request, res: Response): Promise<void> { async getPdfSettings(_req: Request, res: Response): Promise<void> {
try { try {
const header = await settingsService.get('pdf_header'); const [header, footer, logo, orgName] = await Promise.all([
const footer = await settingsService.get('pdf_footer'); settingsService.get('pdf_header'),
settingsService.get('pdf_footer'),
settingsService.get('pdf_logo'),
settingsService.get('pdf_org_name'),
]);
res.json({ res.json({
success: true, success: true,
data: { data: {
pdf_header: header?.value ?? '', pdf_header: header?.value ?? '',
pdf_footer: footer?.value ?? '', pdf_footer: footer?.value ?? '',
pdf_logo: logo?.value ?? '',
pdf_org_name: orgName?.value ?? '',
}, },
}); });
} catch { } catch {
res.json({ success: true, data: { pdf_header: '', pdf_footer: '' } }); res.json({ success: true, data: { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' } });
} }
} }

View File

@@ -81,6 +81,25 @@ function buildHeaders(): Record<string, string> {
}; };
} }
/**
* 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<Map<number, string>> {
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<BookStackPage[]> { async function getRecentPages(): Promise<BookStackPage[]> {
const { bookstack } = environment; const { bookstack } = environment;
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) { if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
@@ -88,17 +107,20 @@ async function getRecentPages(): Promise<BookStackPage[]> {
} }
try { try {
const response = await axios.get( const [response, bookSlugMap] = await Promise.all([
`${bookstack.url}/api/pages`, axios.get(
{ `${bookstack.url}/api/pages`,
params: { sort: '-updated_at', count: 20 }, {
headers: buildHeaders(), params: { sort: '-updated_at', count: 20 },
}, headers: buildHeaders(),
); },
),
getBookSlugMap(),
]);
const pages: BookStackPage[] = response.data?.data ?? []; const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({ return pages.map((p) => ({
...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) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@@ -174,11 +196,15 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
} }
try { try {
const response = await axios.get( const [response, bookSlugMap] = await Promise.all([
`${bookstack.url}/api/pages/${id}`, axios.get(
{ headers: buildHeaders() }, `${bookstack.url}/api/pages/${id}`,
); { headers: buildHeaders() },
),
getBookSlugMap(),
]);
const page = response.data; const page = response.data;
const bookSlug = bookSlugMap.get(page.book_id) || page.book?.slug || page.book_slug || page.book_id;
return { return {
id: page.id, id: page.id,
name: page.name, name: page.name,
@@ -189,7 +215,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: `${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, book: page.book,
createdBy: page.created_by, createdBy: page.created_by,
updatedBy: page.updated_by, updatedBy: page.updated_by,

View File

@@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { configApi } from '../../services/config'; import { configApi } from '../../services/config';
import { notificationsApi } from '../../services/notifications'; import { notificationsApi } from '../../services/notifications';
import { nextcloudApi } from '../../services/nextcloud';
import { safeOpenUrl } from '../../utils/safeOpenUrl'; import { safeOpenUrl } from '../../utils/safeOpenUrl';
import ChatRoomList from './ChatRoomList'; import ChatRoomList from './ChatRoomList';
import ChatMessageView from './ChatMessageView'; import ChatMessageView from './ChatMessageView';
@@ -36,12 +37,27 @@ const ChatPanelInner: React.FC = () => {
}); });
const nextcloudUrl = externalLinks?.nextcloud; 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(() => { React.useEffect(() => {
if (chatPanelOpen) { if (chatPanelOpen) {
// Dismiss our internal notification-centre entries
notificationsApi.dismissByType('nextcloud_talk').then(() => { notificationsApi.dismissByType('nextcloud_talk').then(() => {
queryClient.invalidateQueries({ queryKey: ['notifications'] }); queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] }); queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] });
}).catch(() => {}); }).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]); }, [chatPanelOpen, queryClient]);

View File

@@ -27,14 +27,16 @@ import {
ExpandLess, ExpandLess,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext'; import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { vehiclesApi } from '../../services/vehicles';
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED }; export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
interface SubItem { interface SubItem {
text: string; text: string;
tabIndex: number; path: string;
} }
interface NavigationItem { interface NavigationItem {
@@ -45,17 +47,17 @@ interface NavigationItem {
} }
const kalenderSubItems: SubItem[] = [ const kalenderSubItems: SubItem[] = [
{ text: 'Veranstaltungen', tabIndex: 0 }, { text: 'Veranstaltungen', path: '/kalender?tab=0' },
{ text: 'Fahrzeugbuchungen', tabIndex: 1 }, { text: 'Fahrzeugbuchungen', path: '/kalender?tab=1' },
]; ];
const adminSubItems: SubItem[] = [ const adminSubItems: SubItem[] = [
{ text: 'Services', tabIndex: 0 }, { text: 'Services', path: '/admin?tab=0' },
{ text: 'System', tabIndex: 1 }, { text: 'System', path: '/admin?tab=1' },
{ text: 'Benutzer', tabIndex: 2 }, { text: 'Benutzer', path: '/admin?tab=2' },
{ text: 'Broadcast', tabIndex: 3 }, { text: 'Broadcast', path: '/admin?tab=3' },
{ text: 'Banner', tabIndex: 4 }, { text: 'Banner', path: '/admin?tab=4' },
{ text: 'Wartung', tabIndex: 5 }, { text: 'Wartung', path: '/admin?tab=5' },
]; ];
const baseNavigationItems: NavigationItem[] = [ const baseNavigationItems: NavigationItem[] = [
@@ -123,9 +125,34 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const navigationItems = useMemo(() => { // Fetch vehicle list for dynamic dropdown sub-items
return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems; const { data: vehicleList } = useQuery({
}, [isAdmin]); 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: <DirectionsCar />,
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 // Expand state for items with sub-items — auto-expand when route matches
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({}); const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
@@ -169,7 +196,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
> >
<ListItemButton <ListItemButton
selected={isActive} selected={isActive}
onClick={() => handleNavigation(hasSubItems ? `${item.path}?tab=0` : item.path)} onClick={() => handleNavigation(hasSubItems ? item.subItems![0].path : item.path)}
aria-label={`Zu ${item.text} navigieren`} aria-label={`Zu ${item.text} navigieren`}
sx={{ sx={{
justifyContent: sidebarCollapsed ? 'center' : 'initial', justifyContent: sidebarCollapsed ? 'center' : 'initial',
@@ -219,14 +246,11 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
<Collapse in={expanded} timeout="auto" unmountOnExit> <Collapse in={expanded} timeout="auto" unmountOnExit>
<List disablePadding> <List disablePadding>
{item.subItems!.map((sub) => { {item.subItems!.map((sub) => {
const subPath = `${item.path}?tab=${sub.tabIndex}`; const isSubActive = location.pathname + location.search === sub.path;
const isSubActive =
location.pathname === item.path &&
location.search === `?tab=${sub.tabIndex}`;
return ( return (
<ListItemButton <ListItemButton
key={sub.tabIndex} key={sub.path}
onClick={() => handleNavigation(subPath)} onClick={() => handleNavigation(sub.path)}
selected={isSubActive} selected={isSubActive}
sx={{ sx={{
pl: 4, pl: 4,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Container, Container,
Typography, Typography,
@@ -76,9 +76,11 @@ function AdminSettings() {
adminServices: 15, adminServices: 15,
}); });
// State for PDF header/footer // State for PDF header/footer/logo/org
const [pdfHeader, setPdfHeader] = useState(''); const [pdfHeader, setPdfHeader] = useState('');
const [pdfFooter, setPdfFooter] = useState(''); const [pdfFooter, setPdfFooter] = useState('');
const [pdfLogo, setPdfLogo] = useState('');
const [pdfOrgName, setPdfOrgName] = useState('');
// Fetch all settings // Fetch all settings
const { data: settings, isLoading } = useQuery({ const { data: settings, isLoading } = useQuery({
@@ -113,6 +115,10 @@ function AdminSettings() {
if (pdfHeaderSetting?.value != null) setPdfHeader(pdfHeaderSetting.value); if (pdfHeaderSetting?.value != null) setPdfHeader(pdfHeaderSetting.value);
const pdfFooterSetting = settings.find((s) => s.key === 'pdf_footer'); const pdfFooterSetting = settings.find((s) => s.key === 'pdf_footer');
if (pdfFooterSetting?.value != null) setPdfFooter(pdfFooterSetting.value); 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]); }, [settings]);
@@ -156,17 +162,40 @@ function AdminSettings() {
queryClient.invalidateQueries({ queryKey: ['pdf-settings'] }); 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 () => { const handleSavePdfSettings = async () => {
try { try {
await Promise.all([ await Promise.all([
pdfHeaderMutation.mutateAsync(pdfHeader), pdfHeaderMutation.mutateAsync(pdfHeader),
pdfFooterMutation.mutateAsync(pdfFooter), pdfFooterMutation.mutateAsync(pdfFooter),
pdfLogoMutation.mutateAsync(pdfLogo),
pdfOrgNameMutation.mutateAsync(pdfOrgName),
]); ]);
showSuccess('PDF-Einstellungen gespeichert'); showSuccess('PDF-Einstellungen gespeichert');
} catch { } catch {
showError('Fehler beim Speichern der PDF-Einstellungen'); showError('Fehler beim Speichern der PDF-Einstellungen');
} }
}; };
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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) { if (!isAdmin) {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
@@ -423,7 +452,44 @@ function AdminSettings() {
</Typography> </Typography>
<Stack spacing={2}> <Stack spacing={2}>
<TextField <TextField
label="PDF Kopfzeile" label="Organisationsname (rechts im PDF-Header)"
value={pdfOrgName}
onChange={(e) => setPdfOrgName(e.target.value)}
fullWidth
size="small"
placeholder="FREIWILLIGE FEUERWEHR REMS"
/>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Logo (erscheint rechts im PDF-Header, neben dem Organisationsnamen)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button component="label" variant="outlined" size="small">
Logo hochladen
<input
type="file"
hidden
accept="image/*"
onChange={handleLogoUpload}
/>
</Button>
{pdfLogo && (
<>
<Box
component="img"
src={pdfLogo}
alt="Logo Vorschau"
sx={{ height: 40, maxWidth: 120, objectFit: 'contain', borderRadius: 1 }}
/>
<IconButton size="small" onClick={() => setPdfLogo('')} title="Logo entfernen">
<Delete fontSize="small" />
</IconButton>
</>
)}
</Box>
</Box>
<TextField
label="PDF Kopfzeile (links, unter dem Header-Banner)"
value={pdfHeader} value={pdfHeader}
onChange={(e) => setPdfHeader(e.target.value)} onChange={(e) => setPdfHeader(e.target.value)}
multiline multiline
@@ -447,7 +513,12 @@ function AdminSettings() {
onClick={handleSavePdfSettings} onClick={handleSavePdfSettings}
variant="contained" variant="contained"
size="small" size="small"
disabled={pdfHeaderMutation.isPending || pdfFooterMutation.isPending} disabled={
pdfHeaderMutation.isPending ||
pdfFooterMutation.isPending ||
pdfLogoMutation.isPending ||
pdfOrgNameMutation.isPending
}
> >
Speichern Speichern
</Button> </Button>

View File

@@ -398,7 +398,7 @@ function FahrzeugBuchungen() {
<TableContainer component={Paper} elevation={1}> <TableContainer component={Paper} elevation={1}>
<Table size="small" sx={{ tableLayout: 'fixed' }}> <Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead> <TableHead>
<TableRow sx={{ bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100' }}> <TableRow sx={{ bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}>
<TableCell sx={{ width: 160, fontWeight: 700 }}> <TableCell sx={{ width: 160, fontWeight: 700 }}>
Fahrzeug Fahrzeug
</TableCell> </TableCell>

View File

@@ -675,7 +675,7 @@ async function fetchPdfSettings(): Promise<PdfSettings> {
_pdfSettingsCacheTime = Date.now(); _pdfSettingsCacheTime = Date.now();
return _pdfSettingsCache; return _pdfSettingsCache;
} catch { } 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.setFontSize(14);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.text(`Kalender — ${monthLabel} ${year}`, 10, 12); doc.text(`Kalender — ${monthLabel} ${year}`, 10, 12);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal'); // Right side: logo and/or org name
doc.text('Feuerwehr Rems', 250, 12); 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 // Custom header text
let tableStartY = 22; let tableStartY = 22;
@@ -796,9 +815,28 @@ async function generateBookingsPdf(
doc.setFontSize(14); doc.setFontSize(14);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.text(`Fahrzeugbuchungen — ${kwLabel} · ${startLabel} ${endLabel}`, 10, 12); doc.text(`Fahrzeugbuchungen — ${kwLabel} · ${startLabel} ${endLabel}`, 10, 12);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal'); // Right side: logo and/or org name
doc.text('Feuerwehr Rems', 250, 12); 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 // Custom header text
let tableStartY = 22; let tableStartY = 22;
@@ -2747,7 +2785,7 @@ export default function Kalender() {
<TableContainer component={Paper} elevation={1}> <TableContainer component={Paper} elevation={1}>
<Table size="small" sx={{ tableLayout: 'fixed' }}> <Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead> <TableHead>
<TableRow sx={{ bgcolor: 'grey.100' }}> <TableRow sx={{ bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100' }}>
<TableCell sx={{ width: 160, fontWeight: 700 }}> <TableCell sx={{ width: 160, fontWeight: 700 }}>
Fahrzeug Fahrzeug
</TableCell> </TableCell>
@@ -2758,7 +2796,7 @@ export default function Kalender() {
sx={{ sx={{
fontWeight: fnsIsToday(day) ? 700 : 400, fontWeight: fnsIsToday(day) ? 700 : 400,
color: fnsIsToday(day) ? 'primary.main' : 'text.primary', 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,
}} }}
> >
<Typography variant="caption" display="block"> <Typography variant="caption" display="block">

View File

@@ -9,6 +9,8 @@ interface ApiResponse<T> {
export interface PdfSettings { export interface PdfSettings {
pdf_header: string; pdf_header: string;
pdf_footer: string; pdf_footer: string;
pdf_logo: string;
pdf_org_name: string;
} }
export const configApi = { export const configApi = {