annoucement banners, calendar pdf export, vehicle booking quck-add, even quick-add
This commit is contained in:
@@ -86,6 +86,7 @@ import vikunjaRoutes from './routes/vikunja.routes';
|
|||||||
import configRoutes from './routes/config.routes';
|
import configRoutes from './routes/config.routes';
|
||||||
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||||
import settingsRoutes from './routes/settings.routes';
|
import settingsRoutes from './routes/settings.routes';
|
||||||
|
import bannerRoutes from './routes/banner.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -105,6 +106,7 @@ app.use('/api/vikunja', vikunjaRoutes);
|
|||||||
app.use('/api/config', configRoutes);
|
app.use('/api/config', configRoutes);
|
||||||
app.use('/api/admin', serviceMonitorRoutes);
|
app.use('/api/admin', serviceMonitorRoutes);
|
||||||
app.use('/api/admin/settings', settingsRoutes);
|
app.use('/api/admin/settings', settingsRoutes);
|
||||||
|
app.use('/api/banners', bannerRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
64
backend/src/controllers/banner.controller.ts
Normal file
64
backend/src/controllers/banner.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import bannerService from '../services/banner.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
message: z.string().min(1).max(2000),
|
||||||
|
level: z.enum(['info', 'important', 'critical']).default('info'),
|
||||||
|
starts_at: z.string().datetime().optional(),
|
||||||
|
ends_at: z.string().datetime().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
class BannerController {
|
||||||
|
async getActive(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const banners = await bannerService.getActive();
|
||||||
|
res.json({ success: true, data: banners });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get banners', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to get banners' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const banners = await bannerService.getAll();
|
||||||
|
res.json({ success: true, data: banners });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get all banners', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to get banners' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = createSchema.parse(req.body);
|
||||||
|
const banner = await bannerService.create(data, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data: banner });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Failed to create banner', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to create banner' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const deleted = await bannerService.delete(req.params.id);
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ success: false, message: 'Banner not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete banner', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to delete banner' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BannerController();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TYPE banner_level AS ENUM ('info', 'important', 'critical');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS announcement_banners (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
level banner_level NOT NULL DEFAULT 'info',
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ends_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_banners_active ON announcement_banners (starts_at, ends_at)
|
||||||
|
WHERE ends_at IS NULL OR ends_at > NOW();
|
||||||
16
backend/src/routes/banner.routes.ts
Normal file
16
backend/src/routes/banner.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import bannerController from '../controllers/banner.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const adminAuth = [authenticate, requirePermission('admin:access')] as const;
|
||||||
|
|
||||||
|
// Public (authenticated): get active banners
|
||||||
|
router.get('/active', authenticate, bannerController.getActive.bind(bannerController));
|
||||||
|
// Admin: manage banners
|
||||||
|
router.get('/', ...adminAuth, bannerController.getAll.bind(bannerController));
|
||||||
|
router.post('/', ...adminAuth, bannerController.create.bind(bannerController));
|
||||||
|
router.delete('/:id', ...adminAuth, bannerController.delete.bind(bannerController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
59
backend/src/services/banner.service.ts
Normal file
59
backend/src/services/banner.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import pool from '../config/database';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
export interface Banner {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
level: 'info' | 'important' | 'critical';
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBannerInput {
|
||||||
|
message: string;
|
||||||
|
level: 'info' | 'important' | 'critical';
|
||||||
|
starts_at?: string;
|
||||||
|
ends_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BannerService {
|
||||||
|
async getActive(): Promise<Banner[]> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM announcement_banners
|
||||||
|
WHERE starts_at <= NOW()
|
||||||
|
AND (ends_at IS NULL OR ends_at > NOW())
|
||||||
|
ORDER BY
|
||||||
|
CASE level WHEN 'critical' THEN 0 WHEN 'important' THEN 1 ELSE 2 END,
|
||||||
|
created_at DESC`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<Banner[]> {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM announcement_banners ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateBannerInput, userId: string): Promise<Banner> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO announcement_banners (message, level, starts_at, ends_at, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||||
|
[data.message, data.level, data.starts_at ?? new Date().toISOString(), data.ends_at ?? null, userId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM announcement_banners WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BannerService();
|
||||||
229
frontend/src/components/admin/BannerManagementTab.tsx
Normal file
229
frontend/src/components/admin/BannerManagementTab.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { bannerApi } from '../../services/banners';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import type { BannerLevel } from '../../types/banner.types';
|
||||||
|
|
||||||
|
const LEVEL_LABEL: Record<BannerLevel, string> = {
|
||||||
|
info: 'Info',
|
||||||
|
important: 'Wichtig',
|
||||||
|
critical: 'Kritisch',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_COLOR: Record<BannerLevel, 'info' | 'warning' | 'error'> = {
|
||||||
|
info: 'info',
|
||||||
|
important: 'warning',
|
||||||
|
critical: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return 'Kein Ablauf';
|
||||||
|
return new Date(iso).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function BannerManagementTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [newLevel, setNewLevel] = useState<BannerLevel>('info');
|
||||||
|
const [newEndsAt, setNewEndsAt] = useState('');
|
||||||
|
|
||||||
|
const { data: banners, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'banners'],
|
||||||
|
queryFn: bannerApi.getAll,
|
||||||
|
placeholderData: (previousData: any) => previousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
bannerApi.create({
|
||||||
|
message: newMessage.trim(),
|
||||||
|
level: newLevel,
|
||||||
|
starts_at: new Date().toISOString(),
|
||||||
|
ends_at: newEndsAt ? new Date(newEndsAt).toISOString() : null,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'banners'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['banners', 'active'] });
|
||||||
|
showSuccess('Banner wurde erstellt');
|
||||||
|
setDialogOpen(false);
|
||||||
|
setNewMessage('');
|
||||||
|
setNewLevel('info');
|
||||||
|
setNewEndsAt('');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const message = error?.response?.data?.message || 'Banner konnte nicht erstellt werden';
|
||||||
|
showError(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => bannerApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'banners'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['banners', 'active'] });
|
||||||
|
showSuccess('Banner wurde gelöscht');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Banner konnte nicht gelöscht werden');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (newMessage.trim()) {
|
||||||
|
createMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setNewMessage('');
|
||||||
|
setNewLevel('info');
|
||||||
|
setNewEndsAt('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Ankündigungsbanner</Typography>
|
||||||
|
<Button startIcon={<AddIcon />} variant="contained" onClick={() => setDialogOpen(true)}>
|
||||||
|
Banner erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Stufe</TableCell>
|
||||||
|
<TableCell>Nachricht</TableCell>
|
||||||
|
<TableCell>Erstellt am</TableCell>
|
||||||
|
<TableCell>Ablauf</TableCell>
|
||||||
|
<TableCell>Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{(banners ?? []).map((banner) => (
|
||||||
|
<TableRow key={banner.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={LEVEL_LABEL[banner.level]}
|
||||||
|
color={LEVEL_COLOR[banner.level]}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 400 }}>{banner.message}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(banner.created_at)}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(banner.ends_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => deleteMutation.mutate(banner.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{(banners ?? []).length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} align="center">Keine Banner vorhanden</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Banner erstellen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Nachricht"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
inputProps={{ maxLength: 2000 }}
|
||||||
|
helperText={`${newMessage.length}/2000`}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth margin="dense">
|
||||||
|
<InputLabel>Stufe</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={newLevel}
|
||||||
|
label="Stufe"
|
||||||
|
onChange={(e) => setNewLevel(e.target.value as BannerLevel)}
|
||||||
|
>
|
||||||
|
<MenuItem value="info">Info</MenuItem>
|
||||||
|
<MenuItem value="important">Wichtig</MenuItem>
|
||||||
|
<MenuItem value="critical">Kritisch</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Ablaufdatum (optional)"
|
||||||
|
type="datetime-local"
|
||||||
|
fullWidth
|
||||||
|
value={newEndsAt}
|
||||||
|
onChange={(e) => setNewEndsAt(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
helperText="Leer lassen für kein Ablaufdatum"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
variant="contained"
|
||||||
|
disabled={createMutation.isPending || !newMessage.trim()}
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BannerManagementTab;
|
||||||
72
frontend/src/components/dashboard/AnnouncementBanner.tsx
Normal file
72
frontend/src/components/dashboard/AnnouncementBanner.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, AlertTitle, Box, IconButton, Collapse } from '@mui/material';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { bannerApi } from '../../services/banners';
|
||||||
|
import type { Banner, BannerLevel } from '../../types/banner.types';
|
||||||
|
|
||||||
|
const DISMISSED_KEY = 'dismissed_banners'; // sessionStorage key
|
||||||
|
|
||||||
|
function getDismissed(): string[] {
|
||||||
|
try { return JSON.parse(sessionStorage.getItem(DISMISSED_KEY) ?? '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
function addDismissed(id: string) {
|
||||||
|
const list = getDismissed();
|
||||||
|
if (!list.includes(id)) sessionStorage.setItem(DISMISSED_KEY, JSON.stringify([...list, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_MAP: Record<BannerLevel, 'info' | 'warning' | 'error'> = {
|
||||||
|
info: 'info',
|
||||||
|
important: 'warning',
|
||||||
|
critical: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_TITLE: Record<BannerLevel, string> = {
|
||||||
|
info: 'Information',
|
||||||
|
important: 'Wichtig',
|
||||||
|
critical: 'Kritisch',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AnnouncementBanner() {
|
||||||
|
const [dismissed, setDismissed] = useState<string[]>(() => getDismissed());
|
||||||
|
|
||||||
|
const { data: banners = [] } = useQuery({
|
||||||
|
queryKey: ['banners', 'active'],
|
||||||
|
queryFn: bannerApi.getActive,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = banners.filter(b => !dismissed.includes(b.id) || b.level === 'critical');
|
||||||
|
|
||||||
|
const handleDismiss = (banner: Banner) => {
|
||||||
|
if (banner.level === 'critical') return; // never dismiss critical
|
||||||
|
addDismissed(banner.id);
|
||||||
|
setDismissed(getDismissed());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{visible.map(banner => (
|
||||||
|
<Collapse key={banner.id} in>
|
||||||
|
<Alert
|
||||||
|
severity={LEVEL_MAP[banner.level]}
|
||||||
|
variant="filled"
|
||||||
|
action={
|
||||||
|
banner.level !== 'critical' ? (
|
||||||
|
<IconButton size="small" color="inherit" onClick={() => handleDismiss(banner)}>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertTitle sx={{ fontWeight: 700 }}>{LEVEL_TITLE[banner.level]}</AlertTitle>
|
||||||
|
{banner.message}
|
||||||
|
</Alert>
|
||||||
|
</Collapse>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
frontend/src/components/dashboard/EventQuickAddWidget.tsx
Normal file
216
frontend/src/components/dashboard/EventQuickAddWidget.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Skeleton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { CalendarMonth } from '@mui/icons-material';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { eventsApi } from '../../services/events';
|
||||||
|
import type { CreateVeranstaltungInput } from '../../types/events.types';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'];
|
||||||
|
|
||||||
|
function toDatetimeLocal(date: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
date.getFullYear() +
|
||||||
|
'-' + pad(date.getMonth() + 1) +
|
||||||
|
'-' + pad(date.getDate()) +
|
||||||
|
'T' + pad(date.getHours()) +
|
||||||
|
':' + pad(date.getMinutes())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnly(date: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDefaults() {
|
||||||
|
const now = new Date();
|
||||||
|
const later = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
return { datumVon: toDatetimeLocal(now), datumBis: toDatetimeLocal(later) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventQuickAddWidget: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false;
|
||||||
|
|
||||||
|
const defaults = makeDefaults();
|
||||||
|
const [titel, setTitel] = useState('');
|
||||||
|
const [datumVon, setDatumVon] = useState(defaults.datumVon);
|
||||||
|
const [datumBis, setDatumBis] = useState(defaults.datumBis);
|
||||||
|
const [ganztaegig, setGanztaegig] = useState(false);
|
||||||
|
const [beschreibung, setBeschreibung] = useState('');
|
||||||
|
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
let datum_von: string;
|
||||||
|
let datum_bis: string;
|
||||||
|
|
||||||
|
if (ganztaegig) {
|
||||||
|
const vonDate = new Date(datumVon);
|
||||||
|
const bisDate = new Date(datumBis);
|
||||||
|
datum_von = new Date(toDateOnly(vonDate) + 'T00:00:00').toISOString();
|
||||||
|
datum_bis = new Date(toDateOnly(bisDate) + 'T23:59:59').toISOString();
|
||||||
|
} else {
|
||||||
|
datum_von = new Date(datumVon).toISOString();
|
||||||
|
datum_bis = new Date(datumBis).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: CreateVeranstaltungInput = {
|
||||||
|
titel: titel.trim(),
|
||||||
|
beschreibung: beschreibung.trim() || null,
|
||||||
|
ort: null,
|
||||||
|
kategorie_id: null,
|
||||||
|
datum_von,
|
||||||
|
datum_bis,
|
||||||
|
ganztaegig,
|
||||||
|
zielgruppen: [],
|
||||||
|
alle_gruppen: true,
|
||||||
|
max_teilnehmer: null,
|
||||||
|
anmeldung_erforderlich: false,
|
||||||
|
};
|
||||||
|
return eventsApi.createEvent(data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess('Veranstaltung erstellt');
|
||||||
|
const fresh = makeDefaults();
|
||||||
|
setTitel('');
|
||||||
|
setDatumVon(fresh.datumVon);
|
||||||
|
setDatumBis(fresh.datumBis);
|
||||||
|
setGanztaegig(false);
|
||||||
|
setBeschreibung('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming-events'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Veranstaltung konnte nicht erstellt werden');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!titel.trim() || !datumVon || !datumBis) return;
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canWrite) return null;
|
||||||
|
|
||||||
|
const dateFieldType = ganztaegig ? 'date' : 'datetime-local';
|
||||||
|
|
||||||
|
const datumVonValue = ganztaegig ? datumVon.slice(0, 10) : datumVon;
|
||||||
|
const datumBisValue = ganztaegig ? datumBis.slice(0, 10) : datumBis;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
'&:hover': { boxShadow: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<CalendarMonth color="primary" />
|
||||||
|
<Typography variant="h6">Veranstaltung</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{false ? (
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Titel"
|
||||||
|
value={titel}
|
||||||
|
onChange={(e) => setTitel(e.target.value)}
|
||||||
|
required
|
||||||
|
inputProps={{ maxLength: 250 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={ganztaegig}
|
||||||
|
onChange={(e) => setGanztaegig(e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">Ganztägig</Typography>}
|
||||||
|
sx={{ mx: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Datum von"
|
||||||
|
type={dateFieldType}
|
||||||
|
value={datumVonValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setDatumVon(ganztaegig ? val + 'T00:00' : val);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Datum bis"
|
||||||
|
type={dateFieldType}
|
||||||
|
value={datumBisValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setDatumBis(ganztaegig ? val + 'T00:00' : val);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Beschreibung (optional)"
|
||||||
|
value={beschreibung}
|
||||||
|
onChange={(e) => setBeschreibung(e.target.value)}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
inputProps={{ maxLength: 1000 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!titel.trim() || !datumVon || !datumBis || mutation.isPending}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventQuickAddWidget;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Skeleton,
|
||||||
|
SelectChangeEvent,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DirectionsCar } from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { bookingApi, fetchVehicles } from '../../services/bookings';
|
||||||
|
import type { CreateBuchungInput } from '../../types/booking.types';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'];
|
||||||
|
|
||||||
|
function toDatetimeLocal(date: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
date.getFullYear() +
|
||||||
|
'-' + pad(date.getMonth() + 1) +
|
||||||
|
'-' + pad(date.getDate()) +
|
||||||
|
'T' + pad(date.getHours()) +
|
||||||
|
':' + pad(date.getMinutes())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDefaults() {
|
||||||
|
const now = new Date();
|
||||||
|
const later = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
return { beginn: toDatetimeLocal(now), ende: toDatetimeLocal(later) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const VehicleBookingQuickAddWidget: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false;
|
||||||
|
|
||||||
|
const defaults = makeDefaults();
|
||||||
|
const [fahrzeugId, setFahrzeugId] = useState<string>('');
|
||||||
|
const [titel, setTitel] = useState('');
|
||||||
|
const [beginn, setBeginn] = useState(defaults.beginn);
|
||||||
|
const [ende, setEnde] = useState(defaults.ende);
|
||||||
|
const [beschreibung, setBeschreibung] = useState('');
|
||||||
|
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: vehicles, isLoading: vehiclesLoading } = useQuery({
|
||||||
|
queryKey: ['vehicles'],
|
||||||
|
queryFn: fetchVehicles,
|
||||||
|
refetchInterval: 10 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const data: CreateBuchungInput = {
|
||||||
|
fahrzeugId,
|
||||||
|
titel: titel.trim(),
|
||||||
|
beschreibung: beschreibung.trim() || null,
|
||||||
|
beginn: new Date(beginn).toISOString(),
|
||||||
|
ende: new Date(ende).toISOString(),
|
||||||
|
buchungsArt: 'intern',
|
||||||
|
};
|
||||||
|
return bookingApi.create(data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess('Fahrzeugbuchung erstellt');
|
||||||
|
const fresh = makeDefaults();
|
||||||
|
setFahrzeugId('');
|
||||||
|
setTitel('');
|
||||||
|
setBeginn(fresh.beginn);
|
||||||
|
setEnde(fresh.ende);
|
||||||
|
setBeschreibung('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Fahrzeugbuchung konnte nicht erstellt werden');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!fahrzeugId || !titel.trim() || !beginn || !ende) return;
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canWrite) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
'&:hover': { boxShadow: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<DirectionsCar color="primary" />
|
||||||
|
<Typography variant="h6">Fahrzeugbuchung</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{vehiclesLoading ? (
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Fahrzeug</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={fahrzeugId}
|
||||||
|
label="Fahrzeug"
|
||||||
|
onChange={(e: SelectChangeEvent<string>) => setFahrzeugId(e.target.value)}
|
||||||
|
>
|
||||||
|
{(vehicles ?? []).map((v) => (
|
||||||
|
<MenuItem key={v.id} value={v.id}>
|
||||||
|
{v.bezeichnung}{v.amtliches_kennzeichen ? ` (${v.amtliches_kennzeichen})` : ''}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Titel"
|
||||||
|
value={titel}
|
||||||
|
onChange={(e) => setTitel(e.target.value)}
|
||||||
|
required
|
||||||
|
inputProps={{ maxLength: 250 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Beginn"
|
||||||
|
type="datetime-local"
|
||||||
|
value={beginn}
|
||||||
|
onChange={(e) => setBeginn(e.target.value)}
|
||||||
|
required
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Ende"
|
||||||
|
type="datetime-local"
|
||||||
|
value={ende}
|
||||||
|
onChange={(e) => setEnde(e.target.value)}
|
||||||
|
required
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Beschreibung (optional)"
|
||||||
|
value={beschreibung}
|
||||||
|
onChange={(e) => setBeschreibung(e.target.value)}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
inputProps={{ maxLength: 1000 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!fahrzeugId || !titel.trim() || !beginn || !ende || mutation.isPending}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VehicleBookingQuickAddWidget;
|
||||||
@@ -10,3 +10,6 @@ export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
|
|||||||
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
||||||
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
||||||
export { default as AdminStatusWidget } from './AdminStatusWidget';
|
export { default as AdminStatusWidget } from './AdminStatusWidget';
|
||||||
|
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
|
||||||
|
export { default as EventQuickAddWidget } from './EventQuickAddWidget';
|
||||||
|
export { default as AnnouncementBanner } from './AnnouncementBanner';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ServiceManagerTab from '../components/admin/ServiceManagerTab';
|
|||||||
import SystemHealthTab from '../components/admin/SystemHealthTab';
|
import SystemHealthTab from '../components/admin/SystemHealthTab';
|
||||||
import UserOverviewTab from '../components/admin/UserOverviewTab';
|
import UserOverviewTab from '../components/admin/UserOverviewTab';
|
||||||
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
|
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
|
||||||
|
import BannerManagementTab from '../components/admin/BannerManagementTab';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
@@ -39,6 +40,7 @@ function AdminDashboard() {
|
|||||||
<Tab label="System" />
|
<Tab label="System" />
|
||||||
<Tab label="Benutzer" />
|
<Tab label="Benutzer" />
|
||||||
<Tab label="Broadcast" />
|
<Tab label="Broadcast" />
|
||||||
|
<Tab label="Banner" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -54,6 +56,9 @@ function AdminDashboard() {
|
|||||||
<TabPanel value={tab} index={3}>
|
<TabPanel value={tab} index={3}>
|
||||||
<NotificationBroadcastTab />
|
<NotificationBroadcastTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={tab} index={4}>
|
||||||
|
<BannerManagementTab />
|
||||||
|
</TabPanel>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget';
|
|||||||
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
|
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
|
||||||
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
|
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
|
||||||
import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
|
import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
|
||||||
|
import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
|
||||||
|
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
|
||||||
|
import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||||
const canViewAtemschutz = user?.groups?.some(g =>
|
const canViewAtemschutz = user?.groups?.some(g =>
|
||||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g)
|
['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g)
|
||||||
) ?? false;
|
) ?? false;
|
||||||
|
const canWrite = user?.groups?.some(g =>
|
||||||
|
['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'].includes(g)
|
||||||
|
) ?? false;
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +44,7 @@ function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth={false} disableGutters>
|
<Container maxWidth={false} disableGutters>
|
||||||
|
<AnnouncementBanner />
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -148,6 +155,28 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Vehicle Booking — Quick Add Widget */}
|
||||||
|
{canWrite && (
|
||||||
|
<Box>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
|
||||||
|
<Box>
|
||||||
|
<VehicleBookingQuickAddWidget />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event — Quick Add Widget */}
|
||||||
|
{canWrite && (
|
||||||
|
<Box>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
|
||||||
|
<Box>
|
||||||
|
<EventQuickAddWidget />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
|
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
|
||||||
<VikunjaOverdueNotifier />
|
<VikunjaOverdueNotifier />
|
||||||
|
|
||||||
|
|||||||
@@ -693,6 +693,70 @@ async function generatePdf(
|
|||||||
doc.save(filename);
|
doc.save(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// PDF Export — Fahrzeugbuchungen
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function generateBookingsPdf(
|
||||||
|
weekStart: Date,
|
||||||
|
weekEnd: Date,
|
||||||
|
bookings: FahrzeugBuchungListItem[],
|
||||||
|
) {
|
||||||
|
const { jsPDF } = await import('jspdf');
|
||||||
|
const autoTable = (await import('jspdf-autotable')).default;
|
||||||
|
|
||||||
|
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
|
|
||||||
|
const startLabel = fnsFormat(weekStart, 'dd.MM.yyyy');
|
||||||
|
const endLabel = fnsFormat(weekEnd, 'dd.MM.yyyy');
|
||||||
|
const kwLabel = `KW ${fnsFormat(weekStart, 'w')}`;
|
||||||
|
|
||||||
|
// Header bar
|
||||||
|
doc.setFillColor(183, 28, 28); // fire-red
|
||||||
|
doc.rect(0, 0, 297, 18, 'F');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`Fahrzeugbuchungen — ${kwLabel} · ${startLabel} – ${endLabel}`, 10, 12);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Feuerwehr Rems', 250, 12);
|
||||||
|
|
||||||
|
const formatDt = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return fnsFormat(d, 'dd.MM.yyyy HH:mm');
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = bookings.filter((b) => !b.abgesagt);
|
||||||
|
const rows = active.map((b) => [
|
||||||
|
b.fahrzeug_name + (b.fahrzeug_kennzeichen ? `\n${b.fahrzeug_kennzeichen}` : ''),
|
||||||
|
b.titel,
|
||||||
|
formatDt(b.beginn),
|
||||||
|
formatDt(b.ende),
|
||||||
|
BUCHUNGS_ART_LABELS[b.buchungs_art],
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
head: [['Fahrzeug', 'Titel', 'Beginn', 'Ende', 'Art']],
|
||||||
|
body: rows,
|
||||||
|
startY: 22,
|
||||||
|
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
|
||||||
|
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||||
|
margin: { left: 10, right: 10 },
|
||||||
|
styles: { fontSize: 9, cellPadding: 2 },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 45 },
|
||||||
|
1: { cellWidth: 90 },
|
||||||
|
2: { cellWidth: 38 },
|
||||||
|
3: { cellWidth: 38 },
|
||||||
|
4: { cellWidth: 35 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `fahrzeugbuchungen_${fnsFormat(weekStart, 'yyyy-MM-dd')}.pdf`;
|
||||||
|
doc.save(filename);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
// CSV Import Dialog
|
// CSV Import Dialog
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -2303,6 +2367,16 @@ export default function Kalender() {
|
|||||||
>
|
>
|
||||||
Kalender
|
Kalender
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* PDF Export */}
|
||||||
|
<Tooltip title="PDF exportieren">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => generateBookingsPdf(currentWeekStart, weekEnd, bookings)}
|
||||||
|
>
|
||||||
|
<FileDownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{bookingsLoading && (
|
{bookingsLoading && (
|
||||||
|
|||||||
9
frontend/src/services/banners.ts
Normal file
9
frontend/src/services/banners.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type { Banner } from '../types/banner.types';
|
||||||
|
interface Resp<T> { success: boolean; data: T; }
|
||||||
|
export const bannerApi = {
|
||||||
|
getActive: () => api.get<Resp<Banner[]>>('/api/banners/active').then(r => r.data.data),
|
||||||
|
getAll: () => api.get<Resp<Banner[]>>('/api/banners').then(r => r.data.data),
|
||||||
|
create: (data: Omit<Banner, 'id'|'created_at'>) => api.post<Resp<Banner>>('/api/banners', data).then(r => r.data.data),
|
||||||
|
delete: (id: string) => api.delete(`/api/banners/${id}`).then(() => undefined),
|
||||||
|
};
|
||||||
10
frontend/src/types/banner.types.ts
Normal file
10
frontend/src/types/banner.types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export type BannerLevel = 'info' | 'important' | 'critical';
|
||||||
|
|
||||||
|
export interface Banner {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
level: BannerLevel;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user