resolve issues with new features
This commit is contained in:
@@ -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: '' } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user