diff --git a/backend/src/app.ts b/backend/src/app.ts index dc2e7b7..c7e6b43 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -86,6 +86,7 @@ import vikunjaRoutes from './routes/vikunja.routes'; import configRoutes from './routes/config.routes'; import serviceMonitorRoutes from './routes/serviceMonitor.routes'; import settingsRoutes from './routes/settings.routes'; +import bannerRoutes from './routes/banner.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -105,6 +106,7 @@ app.use('/api/vikunja', vikunjaRoutes); app.use('/api/config', configRoutes); app.use('/api/admin', serviceMonitorRoutes); app.use('/api/admin/settings', settingsRoutes); +app.use('/api/banners', bannerRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/banner.controller.ts b/backend/src/controllers/banner.controller.ts new file mode 100644 index 0000000..e3aaa47 --- /dev/null +++ b/backend/src/controllers/banner.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); diff --git a/backend/src/database/migrations/025_create_announcement_banners.sql b/backend/src/database/migrations/025_create_announcement_banners.sql new file mode 100644 index 0000000..4b575a2 --- /dev/null +++ b/backend/src/database/migrations/025_create_announcement_banners.sql @@ -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(); diff --git a/backend/src/routes/banner.routes.ts b/backend/src/routes/banner.routes.ts new file mode 100644 index 0000000..4e65732 --- /dev/null +++ b/backend/src/routes/banner.routes.ts @@ -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; diff --git a/backend/src/services/banner.service.ts b/backend/src/services/banner.service.ts new file mode 100644 index 0000000..a5bd71f --- /dev/null +++ b/backend/src/services/banner.service.ts @@ -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 { + 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 { + const result = await pool.query( + 'SELECT * FROM announcement_banners ORDER BY created_at DESC' + ); + return result.rows; + } + + async create(data: CreateBannerInput, userId: string): Promise { + 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 { + const result = await pool.query( + 'DELETE FROM announcement_banners WHERE id = $1', + [id] + ); + return (result.rowCount ?? 0) > 0; + } +} + +export default new BannerService(); diff --git a/frontend/src/components/admin/BannerManagementTab.tsx b/frontend/src/components/admin/BannerManagementTab.tsx new file mode 100644 index 0000000..574d16f --- /dev/null +++ b/frontend/src/components/admin/BannerManagementTab.tsx @@ -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 = { + info: 'Info', + important: 'Wichtig', + critical: 'Kritisch', +}; + +const LEVEL_COLOR: Record = { + 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('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 ; + } + + return ( + + + Ankündigungsbanner + + + + + + + + Stufe + Nachricht + Erstellt am + Ablauf + Aktionen + + + + {(banners ?? []).map((banner) => ( + + + + + {banner.message} + {formatDateTime(banner.created_at)} + {formatDateTime(banner.ends_at)} + + deleteMutation.mutate(banner.id)} + disabled={deleteMutation.isPending} + > + + + + + ))} + {(banners ?? []).length === 0 && ( + + Keine Banner vorhanden + + )} + +
+
+ + + Banner erstellen + + setNewMessage(e.target.value)} + inputProps={{ maxLength: 2000 }} + helperText={`${newMessage.length}/2000`} + /> + + Stufe + + + setNewEndsAt(e.target.value)} + InputLabelProps={{ shrink: true }} + helperText="Leer lassen für kein Ablaufdatum" + /> + + + + + + +
+ ); +} + +export default BannerManagementTab; diff --git a/frontend/src/components/dashboard/AnnouncementBanner.tsx b/frontend/src/components/dashboard/AnnouncementBanner.tsx new file mode 100644 index 0000000..976d2da --- /dev/null +++ b/frontend/src/components/dashboard/AnnouncementBanner.tsx @@ -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 = { + info: 'info', + important: 'warning', + critical: 'error', +}; + +const LEVEL_TITLE: Record = { + info: 'Information', + important: 'Wichtig', + critical: 'Kritisch', +}; + +export default function AnnouncementBanner() { + const [dismissed, setDismissed] = useState(() => 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 ( + + {visible.map(banner => ( + + handleDismiss(banner)}> + + + ) : undefined + } + > + {LEVEL_TITLE[banner.level]} + {banner.message} + + + ))} + + ); +} diff --git a/frontend/src/components/dashboard/EventQuickAddWidget.tsx b/frontend/src/components/dashboard/EventQuickAddWidget.tsx new file mode 100644 index 0000000..f363ccb --- /dev/null +++ b/frontend/src/components/dashboard/EventQuickAddWidget.tsx @@ -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 ( + + + + + Veranstaltung + + + {false ? ( + + + + + + ) : ( + + setTitel(e.target.value)} + required + inputProps={{ maxLength: 250 }} + /> + + setGanztaegig(e.target.checked)} + size="small" + /> + } + label={Ganztägig} + sx={{ mx: 0 }} + /> + + { + const val = e.target.value; + setDatumVon(ganztaegig ? val + 'T00:00' : val); + }} + required + InputLabelProps={{ shrink: true }} + /> + + { + const val = e.target.value; + setDatumBis(ganztaegig ? val + 'T00:00' : val); + }} + required + InputLabelProps={{ shrink: true }} + /> + + setBeschreibung(e.target.value)} + multiline + rows={2} + inputProps={{ maxLength: 1000 }} + /> + + + + )} + + + ); +}; + +export default EventQuickAddWidget; diff --git a/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx b/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx new file mode 100644 index 0000000..bd6759a --- /dev/null +++ b/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx @@ -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(''); + 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 ( + + + + + Fahrzeugbuchung + + + {vehiclesLoading ? ( + + + + + + + ) : ( + + + Fahrzeug + + + + setTitel(e.target.value)} + required + inputProps={{ maxLength: 250 }} + /> + + setBeginn(e.target.value)} + required + InputLabelProps={{ shrink: true }} + /> + + setEnde(e.target.value)} + required + InputLabelProps={{ shrink: true }} + /> + + setBeschreibung(e.target.value)} + multiline + rows={2} + inputProps={{ maxLength: 1000 }} + /> + + + + )} + + + ); +}; + +export default VehicleBookingQuickAddWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index ec4b8ff..cf12552 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -10,3 +10,6 @@ export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget'; export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget'; export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier'; export { default as AdminStatusWidget } from './AdminStatusWidget'; +export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget'; +export { default as EventQuickAddWidget } from './EventQuickAddWidget'; +export { default as AnnouncementBanner } from './AnnouncementBanner'; diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index d355ad0..3479953 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -6,6 +6,7 @@ import ServiceManagerTab from '../components/admin/ServiceManagerTab'; import SystemHealthTab from '../components/admin/SystemHealthTab'; import UserOverviewTab from '../components/admin/UserOverviewTab'; import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab'; +import BannerManagementTab from '../components/admin/BannerManagementTab'; import { useAuth } from '../contexts/AuthContext'; interface TabPanelProps { @@ -39,6 +40,7 @@ function AdminDashboard() { + @@ -54,6 +56,9 @@ function AdminDashboard() { + + + ); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3848845..f0b2cbc 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -19,12 +19,18 @@ import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget'; import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget'; import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier'; 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() { const { user } = useAuth(); const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; const canViewAtemschutz = user?.groups?.some(g => ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g) ) ?? false; + const canWrite = user?.groups?.some(g => + ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'].includes(g) + ) ?? false; const [dataLoading, setDataLoading] = useState(true); useEffect(() => { @@ -38,6 +44,7 @@ function Dashboard() { return ( + + {/* Vehicle Booking — Quick Add Widget */} + {canWrite && ( + + + + + + + + )} + + {/* Event — Quick Add Widget */} + {canWrite && ( + + + + + + + + )} + {/* Vikunja — Overdue Notifier (invisible, polling component) */} diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 13ce417..8925b6c 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -693,6 +693,70 @@ async function generatePdf( 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 // ────────────────────────────────────────────────────────────────────────────── @@ -2303,6 +2367,16 @@ export default function Kalender() { > Kalender + + {/* PDF Export */} + + generateBookingsPdf(currentWeekStart, weekEnd, bookings)} + > + + + {bookingsLoading && ( diff --git a/frontend/src/services/banners.ts b/frontend/src/services/banners.ts new file mode 100644 index 0000000..33f3f04 --- /dev/null +++ b/frontend/src/services/banners.ts @@ -0,0 +1,9 @@ +import { api } from './api'; +import type { Banner } from '../types/banner.types'; +interface Resp { success: boolean; data: T; } +export const bannerApi = { + getActive: () => api.get>('/api/banners/active').then(r => r.data.data), + getAll: () => api.get>('/api/banners').then(r => r.data.data), + create: (data: Omit) => api.post>('/api/banners', data).then(r => r.data.data), + delete: (id: string) => api.delete(`/api/banners/${id}`).then(() => undefined), +}; diff --git a/frontend/src/types/banner.types.ts b/frontend/src/types/banner.types.ts new file mode 100644 index 0000000..0481cd8 --- /dev/null +++ b/frontend/src/types/banner.types.ts @@ -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; +}