diff --git a/.env.example b/.env.example index 102a657..dd34f8b 100644 --- a/.env.example +++ b/.env.example @@ -87,9 +87,9 @@ JWT_SECRET=your_jwt_secret_here # The frontend URL that is allowed to make requests to the backend # IMPORTANT: Must match your frontend URL exactly! # Development: http://localhost:5173 (Vite dev server) -# Production: https://start.feuerwehr-rems.at +# Production: https://portal.feuerwehr-rems.at # Multiple origins: Use comma-separated values (if supported by your setup) -CORS_ORIGIN=https://start.feuerwehr-rems.at +CORS_ORIGIN=https://portal.feuerwehr-rems.at # ============================================================================ # FRONTEND CONFIGURATION @@ -103,9 +103,9 @@ FRONTEND_PORT=80 # API URL for frontend # The URL where the frontend will send API requests # Development: http://localhost:3000 -# Production: https://start.feuerwehr-rems.at (proxied via nginx /api/) +# Production: https://portal.feuerwehr-rems.at (proxied via nginx /api/) # IMPORTANT: Must be accessible from the user's browser! -VITE_API_URL=https://start.feuerwehr-rems.at +VITE_API_URL=https://portal.feuerwehr-rems.at # Authentik URL for frontend # The base URL of your Authentik instance (without application path) @@ -143,8 +143,8 @@ AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerweh # The URL where Authentik will redirect after successful authentication # Must match EXACTLY what you configured in Authentik # Development: http://localhost:5173/auth/callback -# Production: https://start.feuerwehr-rems.at/auth/callback -AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback +# Production: https://portal.feuerwehr-rems.at/auth/callback +AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback # OAuth Scopes (optional, has defaults) # Default: openid profile email @@ -283,14 +283,14 @@ FDISK_SYNC_URL=http://fdisk-sync:3001 # BACKEND_PORT=3000 # NODE_ENV=production # JWT_SECRET= -# CORS_ORIGIN=https://start.feuerwehr-rems.at +# CORS_ORIGIN=https://portal.feuerwehr-rems.at # FRONTEND_PORT=80 -# VITE_API_URL=https://start.feuerwehr-rems.at +# VITE_API_URL=https://portal.feuerwehr-rems.at # AUTHENTIK_CLIENT_ID= # AUTHENTIK_CLIENT_SECRET= # AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at # AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/ -# AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback +# AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback # NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at # LOG_LEVEL=info # diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts index fd62a18..e0aea85 100644 --- a/backend/src/controllers/config.controller.ts +++ b/backend/src/controllers/config.controller.ts @@ -15,11 +15,12 @@ class ConfigController { async getPdfSettings(_req: Request, res: Response): Promise { try { - const [header, footer, logo, orgName] = await Promise.all([ + const [header, footer, logo, orgName, appLogo] = await Promise.all([ settingsService.get('pdf_header'), settingsService.get('pdf_footer'), settingsService.get('pdf_logo'), settingsService.get('pdf_org_name'), + settingsService.get('app_logo'), ]); res.json({ success: true, @@ -28,10 +29,11 @@ class ConfigController { pdf_footer: footer?.value ?? '', pdf_logo: logo?.value ?? '', pdf_org_name: orgName?.value ?? '', + app_logo: appLogo?.value ?? '', }, }); } catch { - res.json({ success: true, data: { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' } }); + res.json({ success: true, data: { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '', app_logo: '' } }); } } diff --git a/docker-compose.yml b/docker-compose.yml index 3e8407d..5f5526c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,13 +35,13 @@ services: DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h} - CORS_ORIGIN: ${CORS_ORIGIN:-https://start.feuerwehr-rems.at} + CORS_ORIGIN: ${CORS_ORIGIN:-https://portal.feuerwehr-rems.at} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:?AUTHENTIK_ISSUER is required} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:?AUTHENTIK_CLIENT_ID is required} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:?AUTHENTIK_CLIENT_SECRET is required} - AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI:-https://start.feuerwehr-rems.at/auth/callback} + AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI:-https://portal.feuerwehr-rems.at/auth/callback} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.feuerwehr-rems.at} - ICAL_BASE_URL: ${ICAL_BASE_URL:-https://start.feuerwehr-rems.at} + ICAL_BASE_URL: ${ICAL_BASE_URL:-https://portal.feuerwehr-rems.at} BOOKSTACK_URL: ${BOOKSTACK_URL:-} BOOKSTACK_TOKEN_ID: ${BOOKSTACK_TOKEN_ID:-} BOOKSTACK_TOKEN_SECRET: ${BOOKSTACK_TOKEN_SECRET:-} @@ -68,14 +68,14 @@ services: context: ./frontend dockerfile: Dockerfile args: - VITE_API_URL: ${VITE_API_URL:-https://start.feuerwehr-rems.at} + VITE_API_URL: ${VITE_API_URL:-https://portal.feuerwehr-rems.at} AUTHENTIK_URL: ${AUTHENTIK_URL:?AUTHENTIK_URL is required} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:?AUTHENTIK_CLIENT_ID is required} container_name: feuerwehr_frontend_prod labels: - "traefik.enable=true" - "traefik.http.routers.feuerwehr-frontend.entrypoints=websecure" - - "traefik.http.routers.feuerwehr-frontend.rule=Host(`start.feuerwehr-rems.at`)" + - "traefik.http.routers.feuerwehr-frontend.rule=Host(`portal.feuerwehr-rems.at`)" - "traefik.http.routers.feuerwehr-frontend.tls=true" - "traefik.http.routers.feuerwehr-frontend.tls.certresolver=letsencrypt" - "traefik.http.routers.feuerwehr-frontend.service=feuerwehr-frontend" diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx index f732613..9b82119 100644 --- a/frontend/src/components/shared/Header.tsx +++ b/frontend/src/components/shared/Header.tsx @@ -8,7 +8,6 @@ import { ListItemIcon, Divider, Box, - Tooltip, Menu, MenuItem, } from '@mui/material'; @@ -18,12 +17,12 @@ import { Settings, Logout, Menu as MenuIcon, - Chat, } from '@mui/icons-material'; import { useAuth } from '../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { configApi } from '../../services/config'; import NotificationBell from './NotificationBell'; -import { useLayout } from '../../contexts/LayoutContext'; interface HeaderProps { onMenuClick: () => void; @@ -32,7 +31,12 @@ interface HeaderProps { function Header({ onMenuClick }: HeaderProps) { const { user, logout } = useAuth(); const navigate = useNavigate(); - const { toggleChatPanel } = useLayout(); + const { data: settings } = useQuery({ + queryKey: ['pdfSettings'], + queryFn: () => configApi.getPdfSettings(), + staleTime: 5 * 60 * 1000, + }); + const [anchorEl, setAnchorEl] = useState(null); const handleMenuOpen = (event: React.MouseEvent) => { @@ -83,25 +87,24 @@ function Header({ onMenuClick }: HeaderProps) { - - - Feuerwehr Dashboard - + {settings?.app_logo ? ( + navigate('/dashboard')} + sx={{ mr: 2, cursor: 'pointer', display: 'flex', alignItems: 'center', flexGrow: 1 }} + > + Logo + + ) : ( + <> + + + Feuerwehr Dashboard + + + )} {user && ( <> - - - - - - s.key === 'pdf_org_name'); if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value); + const appLogoSetting = settings.find((s) => s.key === 'app_logo'); + if (appLogoSetting?.value != null) setAppLogo(appLogoSetting.value); } }, [settings]); @@ -197,6 +203,30 @@ function AdminSettings() { reader.readAsDataURL(file); }; + // App logo mutation + handlers + const appLogoMutation = useMutation({ + mutationFn: (value: string) => settingsApi.update('app_logo', value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + queryClient.invalidateQueries({ queryKey: ['pdf-settings'] }); + }, + }); + const handleAppLogoUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => setAppLogo(ev.target?.result as string); + reader.readAsDataURL(file); + }; + const handleSaveAppLogo = async () => { + try { + await appLogoMutation.mutateAsync(appLogo); + showSuccess('Logo gespeichert'); + } catch { + showError('Fehler beim Speichern des Logos'); + } + }; + if (!canAccess) { return ; } @@ -276,7 +306,59 @@ function AdminSettings() { - {/* Section 1: Link Collections */} + {/* Section 1: General Settings (App Logo) */} + + + + + Allgemeine Einstellungen + + + + + + Logo (wird im Header und in PDF-Exporten verwendet) + + + + {appLogo && ( + <> + + setAppLogo('')} title="Logo entfernen"> + + + + )} + + + + + + + + + + {/* Section 2: Link Collections */} @@ -375,7 +457,7 @@ function AdminSettings() { - {/* Section 2: Refresh Intervals */} + {/* Section 3: Refresh Intervals */} @@ -439,7 +521,7 @@ function AdminSettings() { - {/* Section 3: PDF Settings */} + {/* Section 4: PDF Settings */} @@ -459,35 +541,6 @@ function AdminSettings() { size="small" placeholder="FREIWILLIGE FEUERWEHR REMS" /> - - - Logo (erscheint rechts im PDF-Header, neben dem Organisationsnamen) - - - - {pdfLogo && ( - <> - - setPdfLogo('')} title="Logo entfernen"> - - - - )} - - - {/* Section 4: Info */} + {/* Section 5: Info */} diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index 354dcc8..c304b07 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -28,6 +28,7 @@ import { AccordionSummary, AccordionDetails, Autocomplete, + Tooltip, } from '@mui/material'; import { ArrowBack, @@ -41,6 +42,7 @@ import { ArrowDropDown, ExpandMore as ExpandMoreIcon, Save as SaveIcon, + PictureAsPdf as PdfIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -48,6 +50,8 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { bestellungApi } from '../services/bestellung'; +import { configApi } from '../services/config'; +import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types'; @@ -158,6 +162,7 @@ export default function BestellungDetail() { const canDelete = hasPermission('bestellungen:delete'); const canManageReminders = hasPermission('bestellungen:manage_reminders'); const canManageOrders = hasPermission('bestellungen:manage_orders'); + const canExport = hasPermission('bestellungen:export'); const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : []; // All statuses except current, for force override @@ -341,6 +346,92 @@ export default function BestellungDetail() { // ── Loading / Error ── + // ── PDF Export ── + async function generateBestellungDetailPdf() { + if (!bestellung) return; + const { jsPDF } = await import('jspdf'); + const autoTable = (await import('jspdf-autotable')).default; + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + + let settings; + try { settings = await configApi.getPdfSettings(); } catch { settings = { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; } + + const kennung = bestellung.laufende_nummer + ? `${new Date(bestellung.erstellt_am).getFullYear()}/${bestellung.laufende_nummer}` + : String(bestellung.id); + const title = `Bestellung #${kennung}`; + + let curY = addPdfHeader(doc, title, settings, 210); + + // Metadata block + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + const meta: [string, string][] = [ + ['Bezeichnung', bestellung.bezeichnung], + ['Lieferant', bestellung.lieferant_name || '–'], + ['Besteller', bestellung.besteller_name || '–'], + ['Status', BESTELLUNG_STATUS_LABELS[bestellung.status]], + ['Bestelldatum', bestellung.bestellt_am ? formatDate(bestellung.bestellt_am) : '–'], + ['Erstellt am', formatDate(bestellung.erstellt_am)], + ]; + for (const [label, value] of meta) { + doc.setFont('helvetica', 'bold'); + doc.text(`${label}:`, 10, curY); + doc.setFont('helvetica', 'normal'); + doc.text(value, 45, curY); + curY += 5; + } + curY += 4; + + // Line items table + const steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100; + const rows = positionen.map((p) => { + const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined; + const menge = parseFloat(String(p.menge)) || 0; + const gesamt = ep != null ? ep * menge : undefined; + return [ + p.bezeichnung, + p.artikelnummer || '', + `${menge} ${p.einheit}`, + ep != null ? formatCurrency(ep) : '–', + gesamt != null ? formatCurrency(gesamt) : '–', + ]; + }); + + // Total + const totalNetto = positionen.reduce((sum, p) => { + const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : 0; + const m = parseFloat(String(p.menge)) || 0; + return sum + ep * m; + }, 0); + const totalBrutto = totalNetto * (1 + steuersatz); + + autoTable(doc, { + head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']], + body: rows, + startY: curY, + headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' }, + alternateRowStyles: { fillColor: [245, 245, 245] }, + margin: { left: 10, right: 10 }, + styles: { fontSize: 9, cellPadding: 2 }, + columnStyles: { + 0: { cellWidth: 60 }, + 1: { cellWidth: 30 }, + 2: { cellWidth: 25, halign: 'right' }, + 3: { cellWidth: 30, halign: 'right' }, + 4: { cellWidth: 30, halign: 'right' }, + }, + foot: [ + ['', '', '', 'Netto:', formatCurrency(totalNetto)], + ['', '', '', 'Brutto:', formatCurrency(totalBrutto)], + ], + footStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: 'bold' }, + didDrawPage: addPdfFooter(doc, settings), + }); + + doc.save(`bestellung_${kennung.replace('/', '-')}.pdf`); + } + if (isLoading) { return ( @@ -374,6 +465,13 @@ export default function BestellungDetail() { {bestellung.bezeichnung} + {canExport && !editMode && ( + + + + + + )} {canCreate && !editMode && ( )} diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index 52711c4..17ada09 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -7,8 +7,10 @@ import { Card, CardContent, Grid, + IconButton, Tab, Tabs, + Tooltip, Typography, Table, TableBody, @@ -25,13 +27,15 @@ import { LinearProgress, Divider, } from '@mui/material'; -import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon } from '@mui/icons-material'; +import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { usePermissionContext } from '../contexts/PermissionContext'; import { bestellungApi } from '../services/bestellung'; +import { configApi } from '../services/config'; +import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; import type { BestellungStatus, Bestellung } from '../types/bestellung.types'; @@ -83,6 +87,7 @@ export default function Bestellungen() { const [searchParams] = useSearchParams(); const { hasPermission } = usePermissionContext(); const canManageVendors = hasPermission('bestellungen:manage_vendors'); + const canExport = hasPermission('bestellungen:export'); // Tab from URL const [tab, setTab] = useState(() => { @@ -178,11 +183,66 @@ export default function Bestellungen() { return selectedVendors === null || selectedVendors.has(key); } + // ── PDF Export ── + async function generateBestellungenPdf() { + const { jsPDF } = await import('jspdf'); + const autoTable = (await import('jspdf-autotable')).default; + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + + let settings; + try { settings = await configApi.getPdfSettings(); } catch { settings = { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; } + + const startY = addPdfHeader(doc, 'Bestellungen — Übersicht', settings, 210); + + const rows = filteredOrders.map((o) => { + const brutto = calcBrutto(o); + return [ + formatKennung(o), + o.lieferant_name || '–', + BESTELLUNG_STATUS_LABELS[o.status], + String(o.items_count ?? 0), + formatCurrency(brutto), + formatDate(o.bestellt_am || o.erstellt_am), + ]; + }); + + autoTable(doc, { + head: [['Kennung', 'Lieferant', 'Status', 'Pos.', 'Betrag (brutto)', 'Datum']], + body: rows, + startY, + headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' }, + alternateRowStyles: { fillColor: [245, 245, 245] }, + margin: { left: 10, right: 10 }, + styles: { fontSize: 9, cellPadding: 2 }, + columnStyles: { + 0: { cellWidth: 22 }, + 1: { cellWidth: 40 }, + 2: { cellWidth: 30 }, + 3: { cellWidth: 15, halign: 'right' }, + 4: { cellWidth: 35, halign: 'right' }, + 5: { cellWidth: 25 }, + }, + didDrawPage: addPdfFooter(doc, settings), + }); + + const today = new Date().toISOString().slice(0, 10); + doc.save(`bestellungen_uebersicht_${today}.pdf`); + } + // ── Render ── return ( - Bestellungen + + Bestellungen + {canExport && ( + + + + + + )} + { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto"> diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 3b8251b..0fa3102 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -77,6 +77,7 @@ import { useNotification } from '../contexts/NotificationContext'; import { trainingApi } from '../services/training'; import { eventsApi } from '../services/events'; import { configApi, type PdfSettings } from '../services/config'; +import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import type { UebungListItem, UebungTyp, @@ -608,49 +609,6 @@ function DayPopover({ ); } -// ────────────────────────────────────────────────────────────────────────────── -// 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; @@ -683,41 +641,8 @@ async function generatePdf( const pdfSettings = await fetchPdfSettings(); - // 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(`Kalender — ${monthLabel} ${year}`, 10, 12); - - // Right side: logo and/or org name - const logoSize = 14; - const logoX = 297 - 4 - logoSize; // 4mm right margin - if (pdfSettings.pdf_logo) { - try { - const fmt = pdfSettings.pdf_logo.match(/^data:image\/(\w+);/)?.[1]?.toUpperCase() ?? 'PNG'; - doc.addImage(pdfSettings.pdf_logo, fmt === 'JPG' ? 'JPEG' : fmt, logoX, 2, logoSize, logoSize); - } catch { /* ignore invalid image */ } - } - if (pdfSettings.pdf_org_name) { - doc.setFontSize(9); - doc.setFont('helvetica', 'bold'); - doc.setTextColor(255, 255, 255); - const nameW = doc.getTextWidth(pdfSettings.pdf_org_name); - const nameX = (pdfSettings.pdf_logo ? logoX - 3 : 297 - 4) - nameW; - doc.text(pdfSettings.pdf_org_name, nameX, 12); - } else if (!pdfSettings.pdf_logo) { - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('Feuerwehr Rems', 250, 12); - } - - // Custom header text - let tableStartY = 22; - if (pdfSettings.pdf_header) { - tableStartY = renderMarkdownText(doc, pdfSettings.pdf_header, 10, 24, { fontSize: 9 }) + 2; - } + // Header + const tableStartY = addPdfHeader(doc, `Kalender — ${monthLabel} ${year}`, pdfSettings, 297); // Build combined list (same logic as CombinedListView) type ListEntry = @@ -766,11 +691,7 @@ async function generatePdf( 3: { cellWidth: 40 }, 4: { cellWidth: 60 }, }, - didDrawPage: pdfSettings.pdf_footer - ? () => { - renderMarkdownText(doc, pdfSettings.pdf_footer, 10, doc.internal.pageSize.height - 12, { fontSize: 8 }); - } - : undefined, + didDrawPage: addPdfFooter(doc, pdfSettings), }); const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`; diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts index c6cb4d8..b8743ec 100644 --- a/frontend/src/services/config.ts +++ b/frontend/src/services/config.ts @@ -11,6 +11,7 @@ export interface PdfSettings { pdf_footer: string; pdf_logo: string; pdf_org_name: string; + app_logo?: string; } export const configApi = { diff --git a/frontend/src/utils/pdfExport.ts b/frontend/src/utils/pdfExport.ts new file mode 100644 index 0000000..b012d2e --- /dev/null +++ b/frontend/src/utils/pdfExport.ts @@ -0,0 +1,136 @@ +import type jsPDF from 'jspdf'; +import type { PdfSettings } from '../services/config'; + +/** + * Render text with basic markdown (**bold**) and line breaks into a jsPDF doc. + * Returns the final Y position after rendering. + */ +export function renderMarkdownText( + doc: jsPDF, + text: string, + x: number, + y: number, + options?: { fontSize?: number; lineHeight?: number }, +): number { + const fontSize = options?.fontSize ?? 9; + const lineHeight = options?.lineHeight ?? fontSize * 0.5; + doc.setFontSize(fontSize); + doc.setTextColor(0, 0, 0); + + const lines = text.split('\n'); + let curY = y; + + for (const line of lines) { + 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; + } + + doc.setFont('helvetica', 'normal'); + return curY; +} + +/** + * Add a PDF header with white background: + * - Left: pdf_header text (bold italic, multi-line) + * - Right: org name + logo side-by-side + * - Thin dark separator line below + * Returns Y position where content should start. + */ +export function addPdfHeader( + doc: jsPDF, + title: string, + settings: PdfSettings, + pageWidth: number, +): number { + const logoSize = 14; + const margin = 6; + const rightEdge = pageWidth - margin; + + // ── Left side: title (bold) ── + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(0, 0, 0); + doc.text(title, 10, 12); + + // ── Left side: pdf_header text below title (bold italic, smaller) ── + let headerEndY = 16; + if (settings.pdf_header) { + doc.setFontSize(8); + doc.setTextColor(100, 100, 100); + const headerLines = settings.pdf_header.split('\n'); + let hy = 18; + for (const line of headerLines) { + const segments = line.split('**'); + let hx = 10; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (!seg) continue; + const isBold = i % 2 === 1; + doc.setFont('helvetica', isBold ? 'bolditalic' : 'italic'); + doc.text(seg, hx, hy); + hx += doc.getTextWidth(seg); + } + hy += 3.5; + } + headerEndY = Math.max(headerEndY, hy); + } + + // ── Right side: logo + org name ── + const logoSrc = settings.app_logo || settings.pdf_logo; + let logoLeftEdge = rightEdge; + + if (logoSrc) { + try { + const fmt = logoSrc.startsWith('data:image/png') ? 'PNG' : 'JPEG'; + const logoX = rightEdge - logoSize; + doc.addImage(logoSrc, fmt, logoX, 3, logoSize, logoSize); + logoLeftEdge = logoX - 3; + } catch { /* ignore invalid image */ } + } + + if (settings.pdf_org_name) { + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(0, 0, 0); + const nameW = doc.getTextWidth(settings.pdf_org_name); + const nameX = logoLeftEdge - nameW; + // Vertically centered with logo area (~y=10) + doc.text(settings.pdf_org_name, nameX, 11); + } + + // ── Separator line ── + const lineY = Math.max(headerEndY, 20) + 2; + doc.setDrawColor(60, 60, 60); + doc.setLineWidth(0.5); + doc.line(margin, lineY, pageWidth - margin, lineY); + + // Reset + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + + return lineY + 4; +} + +/** + * Returns a `didDrawPage` callback that renders pdf_footer at the bottom of each page. + */ +export function addPdfFooter( + doc: jsPDF, + settings: PdfSettings, +): ((data: any) => void) | undefined { + if (!settings.pdf_footer) return undefined; + return () => { + renderMarkdownText(doc, settings.pdf_footer, 10, doc.internal.pageSize.height - 12, { + fontSize: 8, + }); + }; +}