resolve issues with new features
This commit is contained in:
@@ -13,6 +13,22 @@ class ConfigController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPdfSettings(_req: Request, res: Response): Promise<void> {
|
||||||
|
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<void> {
|
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
||||||
const envLinks: Record<string, string> = {};
|
const envLinks: Record<string, string> = {};
|
||||||
if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl;
|
if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl;
|
||||||
|
|||||||
@@ -67,6 +67,34 @@ class NotificationController {
|
|||||||
res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' });
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
export default new NotificationController();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth.middleware';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController));
|
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));
|
router.get('/service-mode', authenticate, configController.getServiceMode.bind(configController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ router.get('/', authenticate, notificationController.getNotific
|
|||||||
router.get('/count', authenticate, notificationController.getUnreadCount.bind(notificationController));
|
router.get('/count', authenticate, notificationController.getUnreadCount.bind(notificationController));
|
||||||
router.patch('/:id/read', authenticate, notificationController.markAsRead.bind(notificationController));
|
router.patch('/:id/read', authenticate, notificationController.markAsRead.bind(notificationController));
|
||||||
router.post('/mark-all-read', authenticate, notificationController.markAllRead.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;
|
export default router;
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
|
|||||||
html: page.html ?? '',
|
html: page.html ?? '',
|
||||||
created_at: page.created_at,
|
created_at: page.created_at,
|
||||||
updated_at: page.updated_at,
|
updated_at: page.updated_at,
|
||||||
url: `${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,
|
book: page.book,
|
||||||
createdBy: page.created_by,
|
createdBy: page.created_by,
|
||||||
updatedBy: page.updated_by,
|
updatedBy: page.updated_by,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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. */
|
/** Deletes read notifications older than 90 days for all users. */
|
||||||
async deleteOldRead(): Promise<void> {
|
async deleteOldRead(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import ListItem from '@mui/material/ListItem';
|
|||||||
import { useLayout } from '../../contexts/LayoutContext';
|
import { useLayout } from '../../contexts/LayoutContext';
|
||||||
import { ChatProvider, useChat } from '../../contexts/ChatContext';
|
import { ChatProvider, useChat } from '../../contexts/ChatContext';
|
||||||
import { Link } from 'react-router-dom';
|
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 { configApi } from '../../services/config';
|
||||||
|
import { notificationsApi } from '../../services/notifications';
|
||||||
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';
|
||||||
@@ -27,6 +28,7 @@ const EXPANDED_WIDTH = 360;
|
|||||||
const ChatPanelInner: React.FC = () => {
|
const ChatPanelInner: React.FC = () => {
|
||||||
const { chatPanelOpen, setChatPanelOpen } = useLayout();
|
const { chatPanelOpen, setChatPanelOpen } = useLayout();
|
||||||
const { rooms, selectedRoomToken, selectRoom, connected } = useChat();
|
const { rooms, selectedRoomToken, selectRoom, connected } = useChat();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: externalLinks } = useQuery({
|
const { data: externalLinks } = useQuery({
|
||||||
queryKey: ['external-links'],
|
queryKey: ['external-links'],
|
||||||
queryFn: () => configApi.getExternalLinks(),
|
queryFn: () => configApi.getExternalLinks(),
|
||||||
@@ -34,6 +36,15 @@ const ChatPanelInner: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const nextcloudUrl = externalLinks?.nextcloud;
|
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) {
|
if (!chatPanelOpen) {
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ const BookStackSearchWidget: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1, maxHeight: 300, overflow: 'auto' }}>
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) => (
|
||||||
<ResultRow
|
<ResultRow
|
||||||
key={result.id}
|
key={result.id}
|
||||||
|
|||||||
@@ -8,25 +8,15 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Link as LinkIcon, OpenInNew } from '@mui/icons-material';
|
import { Link as LinkIcon, OpenInNew } from '@mui/icons-material';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import type { LinkCollection } from '../../types/config.types';
|
||||||
import { configApi } from '../../services/config';
|
|
||||||
|
|
||||||
function LinksWidget() {
|
interface LinksWidgetProps {
|
||||||
const { data: externalLinks } = useQuery({
|
collection: LinkCollection;
|
||||||
queryKey: ['external-links'],
|
}
|
||||||
queryFn: () => configApi.getExternalLinks(),
|
|
||||||
staleTime: 10 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collections = externalLinks?.linkCollections ?? [];
|
|
||||||
const nonEmpty = collections.filter((c) => c.links.length > 0);
|
|
||||||
|
|
||||||
if (nonEmpty.length === 0) return null;
|
|
||||||
|
|
||||||
|
function LinksWidget({ collection }: LinksWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Card>
|
||||||
{nonEmpty.map((collection) => (
|
|
||||||
<Card key={collection.id}>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
<LinkIcon color="primary" sx={{ mr: 1 }} />
|
<LinkIcon color="primary" sx={{ mr: 1 }} />
|
||||||
@@ -55,8 +45,6 @@ function LinksWidget() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 open = Boolean(anchorEl);
|
||||||
const hasUnread = unreadCount > 0;
|
const hasUnread = unreadCount > 0;
|
||||||
|
|
||||||
@@ -169,12 +178,19 @@ const NotificationBell: React.FC = () => {
|
|||||||
<Typography variant="subtitle1" fontWeight={600}>
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
Benachrichtigungen
|
Benachrichtigungen
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
{notifications.some((n) => n.gelesen) && (
|
||||||
|
<Button size="small" onClick={handleDeleteAllRead} sx={{ fontSize: '0.75rem' }}>
|
||||||
|
Gelesene löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
|
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
|
||||||
Alle als gelesen markieren
|
Alle als gelesen markieren
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Collapse,
|
||||||
Drawer,
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
@@ -22,6 +23,8 @@ import {
|
|||||||
AdminPanelSettings,
|
AdminPanelSettings,
|
||||||
Settings,
|
Settings,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
|
ExpandMore,
|
||||||
|
ExpandLess,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
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 };
|
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
||||||
|
|
||||||
|
interface SubItem {
|
||||||
|
text: string;
|
||||||
|
tabIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
text: string;
|
text: string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
path: string;
|
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[] = [
|
const baseNavigationItems: NavigationItem[] = [
|
||||||
{
|
{
|
||||||
text: 'Dashboard',
|
text: 'Dashboard',
|
||||||
@@ -45,6 +68,7 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
text: 'Kalender',
|
text: 'Kalender',
|
||||||
icon: <CalendarMonth />,
|
icon: <CalendarMonth />,
|
||||||
path: '/kalender',
|
path: '/kalender',
|
||||||
|
subItems: kalenderSubItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Fahrzeuge',
|
text: 'Fahrzeuge',
|
||||||
@@ -77,6 +101,7 @@ const adminItem: NavigationItem = {
|
|||||||
text: 'Admin',
|
text: 'Admin',
|
||||||
icon: <AdminPanelSettings />,
|
icon: <AdminPanelSettings />,
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
|
subItems: adminSubItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminSettingsItem: NavigationItem = {
|
const adminSettingsItem: NavigationItem = {
|
||||||
@@ -102,6 +127,19 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems;
|
return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems;
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
// Expand state for items with sub-items — auto-expand when route matches
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
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) => {
|
const handleNavigation = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
onMobileClose();
|
onMobileClose();
|
||||||
@@ -118,8 +156,11 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
<List sx={{ flex: 1 }}>
|
<List sx={{ flex: 1 }}>
|
||||||
{navigationItems.map((item) => {
|
{navigationItems.map((item) => {
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
|
const hasSubItems = item.subItems && item.subItems.length > 0;
|
||||||
|
const expanded = hasSubItems && isExpanded(item);
|
||||||
return (
|
return (
|
||||||
<ListItem key={item.text} disablePadding>
|
<Box key={item.text}>
|
||||||
|
<ListItem disablePadding>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={item.text}
|
title={item.text}
|
||||||
placement="right"
|
placement="right"
|
||||||
@@ -128,7 +169,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
>
|
>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
selected={isActive}
|
selected={isActive}
|
||||||
onClick={() => handleNavigation(item.path)}
|
onClick={() => handleNavigation(hasSubItems ? `${item.path}?tab=0` : item.path)}
|
||||||
aria-label={`Zu ${item.text} navigieren`}
|
aria-label={`Zu ${item.text} navigieren`}
|
||||||
sx={{
|
sx={{
|
||||||
justifyContent: sidebarCollapsed ? 'center' : 'initial',
|
justifyContent: sidebarCollapsed ? 'center' : 'initial',
|
||||||
@@ -159,9 +200,57 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
display: sidebarCollapsed ? 'none' : 'block',
|
display: sidebarCollapsed ? 'none' : 'block',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{hasSubItems && !sidebarCollapsed && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpand(item.path);
|
||||||
|
}}
|
||||||
|
sx={{ color: isActive ? 'inherit' : 'text.secondary' }}
|
||||||
|
>
|
||||||
|
{expanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
{hasSubItems && !sidebarCollapsed && (
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<List disablePadding>
|
||||||
|
{item.subItems!.map((sub) => {
|
||||||
|
const subPath = `${item.path}?tab=${sub.tabIndex}`;
|
||||||
|
const isSubActive =
|
||||||
|
location.pathname === item.path &&
|
||||||
|
location.search === `?tab=${sub.tabIndex}`;
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={sub.tabIndex}
|
||||||
|
onClick={() => handleNavigation(subPath)}
|
||||||
|
selected={isSubActive}
|
||||||
|
sx={{
|
||||||
|
pl: 4,
|
||||||
|
py: 0.5,
|
||||||
|
'&.Mui-selected': {
|
||||||
|
backgroundColor: 'primary.light',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={sub.text}
|
||||||
|
primaryTypographyProps={{ variant: 'body2' }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Box, Tabs, Tab, Typography } from '@mui/material';
|
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 DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import ServiceManagerTab from '../components/admin/ServiceManagerTab';
|
import ServiceManagerTab from '../components/admin/ServiceManagerTab';
|
||||||
import SystemHealthTab from '../components/admin/SystemHealthTab';
|
import SystemHealthTab from '../components/admin/SystemHealthTab';
|
||||||
@@ -21,8 +21,19 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
|||||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADMIN_TAB_COUNT = 6;
|
||||||
|
|
||||||
function AdminDashboard() {
|
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 { user } = useAuth();
|
||||||
|
|
||||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Timer,
|
Timer,
|
||||||
Info,
|
Info,
|
||||||
ExpandMore,
|
ExpandMore,
|
||||||
|
PictureAsPdf as PdfIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
@@ -75,6 +76,10 @@ function AdminSettings() {
|
|||||||
adminServices: 15,
|
adminServices: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// State for PDF header/footer
|
||||||
|
const [pdfHeader, setPdfHeader] = useState('');
|
||||||
|
const [pdfFooter, setPdfFooter] = useState('');
|
||||||
|
|
||||||
// Fetch all settings
|
// Fetch all settings
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
queryKey: ['admin-settings'],
|
queryKey: ['admin-settings'],
|
||||||
@@ -103,6 +108,11 @@ function AdminSettings() {
|
|||||||
adminServices: intervalsSetting.value.adminServices ?? 15,
|
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]);
|
}, [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) {
|
if (!isAdmin) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
@@ -373,7 +410,53 @@ function AdminSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 3: Info */}
|
{/* Section 3: PDF Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<PdfIcon color="primary" sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">PDF-Einstellungen</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Kopf- und Fußzeile für Kalender-PDF-Exporte. Zeilenumbrüche werden übernommen, <strong>**fett**</strong> erzeugt fettgedruckten Text.
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="PDF Kopfzeile"
|
||||||
|
value={pdfHeader}
|
||||||
|
onChange={(e) => setPdfHeader(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={6}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="PDF Fußzeile"
|
||||||
|
value={pdfFooter}
|
||||||
|
onChange={(e) => setPdfFooter(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={6}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
onClick={handleSavePdfSettings}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={pdfHeaderMutation.isPending || pdfFooterMutation.isPending}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 4: Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { ArrowBack, Save } from '@mui/icons-material';
|
import { ArrowBack, Save } from '@mui/icons-material';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
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 { equipmentApi } from '../services/equipment';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
import {
|
import {
|
||||||
@@ -190,6 +190,12 @@ function AusruestungForm() {
|
|||||||
errors.pruef_intervall_monate = 'Prüfintervall muss zwischen 1 und 120 Monaten liegen.';
|
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);
|
setFieldErrors(errors);
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
};
|
};
|
||||||
@@ -466,6 +472,15 @@ function AusruestungForm() {
|
|||||||
placeholder="TT.MM.JJJJ"
|
placeholder="TT.MM.JJJJ"
|
||||||
value={form.letzte_pruefung_am}
|
value={form.letzte_pruefung_am}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
|
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 }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -476,6 +491,15 @@ function AusruestungForm() {
|
|||||||
placeholder="TT.MM.JJJJ"
|
placeholder="TT.MM.JJJJ"
|
||||||
value={form.naechste_pruefung_am}
|
value={form.naechste_pruefung_am}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
|
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 }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import LinksWidget from '../components/dashboard/LinksWidget';
|
|||||||
import BannerWidget from '../components/dashboard/BannerWidget';
|
import BannerWidget from '../components/dashboard/BannerWidget';
|
||||||
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
|
import { configApi } from '../services/config';
|
||||||
import { WidgetKey } from '../constants/widgets';
|
import { WidgetKey } from '../constants/widgets';
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
@@ -44,6 +45,16 @@ function Dashboard() {
|
|||||||
queryFn: preferencesApi.get,
|
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) => {
|
const widgetVisible = (key: WidgetKey) => {
|
||||||
return preferences?.widgets?.[key] !== false;
|
return preferences?.widgets?.[key] !== false;
|
||||||
};
|
};
|
||||||
@@ -187,15 +198,15 @@ function Dashboard() {
|
|||||||
|
|
||||||
{/* Information Group */}
|
{/* Information Group */}
|
||||||
<WidgetGroup title="Information" gridColumn="1 / -1">
|
<WidgetGroup title="Information" gridColumn="1 / -1">
|
||||||
{widgetVisible('links') && (
|
{widgetVisible('links') && linkCollections.map((collection, idx) => (
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
|
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + idx * 40}ms` }}>
|
||||||
<Box>
|
<Box>
|
||||||
<LinksWidget />
|
<LinksWidget collection={collection} />
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '790ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}>
|
||||||
<Box>
|
<Box>
|
||||||
<BannerWidget />
|
<BannerWidget />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ function FahrzeugBuchungen() {
|
|||||||
align="center"
|
align="center"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: isToday(day) ? 700 : 400,
|
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,
|
bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ import {
|
|||||||
ViewWeek as ViewWeekIcon,
|
ViewWeek as ViewWeekIcon,
|
||||||
Warning,
|
Warning,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDate, isValidGermanDateTime } from '../utils/dateInput';
|
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDate, isValidGermanDateTime } from '../utils/dateInput';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -76,6 +76,7 @@ import { useNotification } from '../contexts/NotificationContext';
|
|||||||
import { trainingApi } from '../services/training';
|
import { trainingApi } from '../services/training';
|
||||||
import { eventsApi } from '../services/events';
|
import { eventsApi } from '../services/events';
|
||||||
import { bookingApi, fetchVehicles } from '../services/bookings';
|
import { bookingApi, fetchVehicles } from '../services/bookings';
|
||||||
|
import { configApi, type PdfSettings } from '../services/config';
|
||||||
import type {
|
import type {
|
||||||
UebungListItem,
|
UebungListItem,
|
||||||
UebungTyp,
|
UebungTyp,
|
||||||
@@ -622,6 +623,62 @@ function DayPopover({
|
|||||||
// PDF Export helper
|
// 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<PdfSettings> {
|
||||||
|
// 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(
|
async function generatePdf(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
@@ -635,6 +692,8 @@ async function generatePdf(
|
|||||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
const monthLabel = MONTH_LABELS[month];
|
const monthLabel = MONTH_LABELS[month];
|
||||||
|
|
||||||
|
const pdfSettings = await fetchPdfSettings();
|
||||||
|
|
||||||
// Header bar
|
// Header bar
|
||||||
doc.setFillColor(183, 28, 28); // fire-red
|
doc.setFillColor(183, 28, 28); // fire-red
|
||||||
doc.rect(0, 0, 297, 18, 'F');
|
doc.rect(0, 0, 297, 18, 'F');
|
||||||
@@ -646,6 +705,12 @@ async function generatePdf(
|
|||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.text('Feuerwehr Rems', 250, 12);
|
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)
|
// Build combined list (same logic as CombinedListView)
|
||||||
type ListEntry =
|
type ListEntry =
|
||||||
| { kind: 'training'; item: UebungListItem }
|
| { kind: 'training'; item: UebungListItem }
|
||||||
@@ -681,7 +746,7 @@ async function generatePdf(
|
|||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']],
|
head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']],
|
||||||
body: rows,
|
body: rows,
|
||||||
startY: 22,
|
startY: tableStartY,
|
||||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
|
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
|
||||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||||
margin: { left: 10, right: 10 },
|
margin: { left: 10, right: 10 },
|
||||||
@@ -693,6 +758,11 @@ async function generatePdf(
|
|||||||
3: { cellWidth: 40 },
|
3: { cellWidth: 40 },
|
||||||
4: { cellWidth: 60 },
|
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`;
|
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 endLabel = fnsFormat(weekEnd, 'dd.MM.yyyy');
|
||||||
const kwLabel = `KW ${fnsFormat(weekStart, 'w')}`;
|
const kwLabel = `KW ${fnsFormat(weekStart, 'w')}`;
|
||||||
|
|
||||||
|
const pdfSettings = await fetchPdfSettings();
|
||||||
|
|
||||||
// Header bar
|
// Header bar
|
||||||
doc.setFillColor(183, 28, 28); // fire-red
|
doc.setFillColor(183, 28, 28); // fire-red
|
||||||
doc.rect(0, 0, 297, 18, 'F');
|
doc.rect(0, 0, 297, 18, 'F');
|
||||||
@@ -728,6 +800,12 @@ async function generateBookingsPdf(
|
|||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.text('Feuerwehr Rems', 250, 12);
|
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 formatDt = (iso: string) => {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return fnsFormat(d, 'dd.MM.yyyy HH:mm');
|
return fnsFormat(d, 'dd.MM.yyyy HH:mm');
|
||||||
@@ -745,7 +823,7 @@ async function generateBookingsPdf(
|
|||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
head: [['Fahrzeug', 'Titel', 'Beginn', 'Ende', 'Art']],
|
head: [['Fahrzeug', 'Titel', 'Beginn', 'Ende', 'Art']],
|
||||||
body: rows,
|
body: rows,
|
||||||
startY: 22,
|
startY: tableStartY,
|
||||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
|
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
|
||||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||||
margin: { left: 10, right: 10 },
|
margin: { left: 10, right: 10 },
|
||||||
@@ -757,6 +835,11 @@ async function generateBookingsPdf(
|
|||||||
3: { cellWidth: 38 },
|
3: { cellWidth: 38 },
|
||||||
4: { cellWidth: 35 },
|
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`;
|
const filename = `fahrzeugbuchungen_${fnsFormat(weekStart, 'yyyy-MM-dd')}.pdf`;
|
||||||
@@ -1557,6 +1640,7 @@ function VeranstaltungFormDialog({
|
|||||||
|
|
||||||
export default function Kalender() {
|
export default function Kalender() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -1569,7 +1653,15 @@ export default function Kalender() {
|
|||||||
const canCreateBookings = !!user;
|
const canCreateBookings = !!user;
|
||||||
|
|
||||||
// ── Tab ─────────────────────────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────
|
// ── Calendar tab state ───────────────────────────────────────────────────────
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PdfSettings {
|
||||||
|
pdf_header: string;
|
||||||
|
pdf_footer: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
getExternalLinks(): Promise<ExternalLinks> {
|
getExternalLinks(): Promise<ExternalLinks> {
|
||||||
return api
|
return api
|
||||||
@@ -17,4 +22,9 @@ export const configApi = {
|
|||||||
.get<ApiResponse<ServiceModeStatus>>('/api/config/service-mode')
|
.get<ApiResponse<ServiceModeStatus>>('/api/config/service-mode')
|
||||||
.then((r) => r.data.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
getPdfSettings(): Promise<PdfSettings> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<PdfSettings>>('/api/config/pdf-settings')
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,4 +28,12 @@ export const notificationsApi = {
|
|||||||
async markAllRead(): Promise<void> {
|
async markAllRead(): Promise<void> {
|
||||||
await api.post('/api/notifications/mark-all-read');
|
await api.post('/api/notifications/mark-all-read');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async dismissByType(quellTyp: string): Promise<void> {
|
||||||
|
await api.post('/api/notifications/dismiss-by-type', { quellTyp });
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAllRead(): Promise<void> {
|
||||||
|
await api.delete('/api/notifications/read');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user