This commit is contained in:
Matthias Hochmeister
2026-03-26 09:29:59 +01:00
parent 884397b520
commit d5e5f2d44e
10 changed files with 428 additions and 154 deletions

View File

@@ -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=<generated-with-openssl-rand-base64-32>
# 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=<from-authentik>
# AUTHENTIK_CLIENT_SECRET=<from-authentik>
# 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
#

View File

@@ -15,11 +15,12 @@ class ConfigController {
async getPdfSettings(_req: Request, res: Response): Promise<void> {
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: '' } });
}
}

View File

@@ -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"

View File

@@ -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 | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
@@ -83,25 +87,24 @@ function Header({ onMenuClick }: HeaderProps) {
<MenuIcon />
</IconButton>
<LocalFireDepartment sx={{ mr: 2 }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Feuerwehr Dashboard
</Typography>
{settings?.app_logo ? (
<Box
onClick={() => navigate('/dashboard')}
sx={{ mr: 2, cursor: 'pointer', display: 'flex', alignItems: 'center', flexGrow: 1 }}
>
<img src={settings.app_logo} alt="Logo" style={{ height: 36 }} />
</Box>
) : (
<>
<LocalFireDepartment sx={{ mr: 2 }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Feuerwehr Dashboard
</Typography>
</>
)}
{user && (
<>
<Tooltip title="Chat">
<IconButton
color="inherit"
onClick={toggleChatPanel}
size="small"
aria-label="Chat öffnen"
sx={{ ml: 0.5 }}
>
<Chat />
</IconButton>
</Tooltip>
<NotificationBell />
<IconButton

View File

@@ -27,6 +27,7 @@ import {
Info,
ExpandMore,
PictureAsPdf as PdfIcon,
Settings as SettingsIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Navigate } from 'react-router-dom';
@@ -82,6 +83,9 @@ function AdminSettings() {
const [pdfLogo, setPdfLogo] = useState('');
const [pdfOrgName, setPdfOrgName] = useState('');
// State for app logo
const [appLogo, setAppLogo] = useState('');
// Fetch all settings
const { data: settings, isLoading } = useQuery({
queryKey: ['admin-settings'],
@@ -119,6 +123,8 @@ function AdminSettings() {
if (pdfLogoSetting?.value != null) setPdfLogo(pdfLogoSetting.value);
const pdfOrgNameSetting = settings.find((s) => 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<HTMLInputElement>) => {
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 <Navigate to="/dashboard" replace />;
}
@@ -276,7 +306,59 @@ function AdminSettings() {
</Typography>
<Stack spacing={3}>
{/* Section 1: Link Collections */}
{/* Section 1: General Settings (App Logo) */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<SettingsIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Allgemeine Einstellungen</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Stack spacing={2}>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Logo (wird im Header und in PDF-Exporten verwendet)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button component="label" variant="outlined" size="small">
Logo hochladen
<input
type="file"
hidden
accept="image/*"
onChange={handleAppLogoUpload}
/>
</Button>
{appLogo && (
<>
<Box
component="img"
src={appLogo}
alt="Logo Vorschau"
sx={{ height: 40, maxWidth: 120, objectFit: 'contain', borderRadius: 1 }}
/>
<IconButton size="small" onClick={() => setAppLogo('')} title="Logo entfernen">
<Delete fontSize="small" />
</IconButton>
</>
)}
</Box>
</Box>
<Box>
<Button
onClick={handleSaveAppLogo}
variant="contained"
size="small"
disabled={appLogoMutation.isPending}
>
Speichern
</Button>
</Box>
</Stack>
</CardContent>
</Card>
{/* Section 2: Link Collections */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
@@ -375,7 +457,7 @@ function AdminSettings() {
</CardContent>
</Card>
{/* Section 2: Refresh Intervals */}
{/* Section 3: Refresh Intervals */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
@@ -439,7 +521,7 @@ function AdminSettings() {
</CardContent>
</Card>
{/* Section 3: PDF Settings */}
{/* Section 4: PDF Settings */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
@@ -459,35 +541,6 @@ function AdminSettings() {
size="small"
placeholder="FREIWILLIGE FEUERWEHR REMS"
/>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Logo (erscheint rechts im PDF-Header, neben dem Organisationsnamen)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button component="label" variant="outlined" size="small">
Logo hochladen
<input
type="file"
hidden
accept="image/*"
onChange={handleLogoUpload}
/>
</Button>
{pdfLogo && (
<>
<Box
component="img"
src={pdfLogo}
alt="Logo Vorschau"
sx={{ height: 40, maxWidth: 120, objectFit: 'contain', borderRadius: 1 }}
/>
<IconButton size="small" onClick={() => setPdfLogo('')} title="Logo entfernen">
<Delete fontSize="small" />
</IconButton>
</>
)}
</Box>
</Box>
<TextField
label="PDF Kopfzeile (links, unter dem Header-Banner)"
value={pdfHeader}
@@ -527,7 +580,7 @@ function AdminSettings() {
</CardContent>
</Card>
{/* Section 4: Info */}
{/* Section 5: Info */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>

View File

@@ -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 (
<DashboardLayout>
@@ -374,6 +465,13 @@ export default function BestellungDetail() {
<ArrowBack />
</IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography>
{canExport && !editMode && (
<Tooltip title="PDF Export">
<IconButton onClick={generateBestellungDetailPdf} color="primary">
<PdfIcon />
</IconButton>
</Tooltip>
)}
{canCreate && !editMode && (
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
)}

View File

@@ -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 (
<DashboardLayout>
<Typography variant="h4" sx={{ mb: 3 }}>Bestellungen</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>Bestellungen</Typography>
{canExport && (
<Tooltip title="PDF Export">
<IconButton onClick={generateBestellungenPdf} color="primary">
<PdfIcon />
</IconButton>
</Tooltip>
)}
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">

View File

@@ -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`;

View File

@@ -11,6 +11,7 @@ export interface PdfSettings {
pdf_footer: string;
pdf_logo: string;
pdf_org_name: string;
app_logo?: string;
}
export const configApi = {

View File

@@ -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,
});
};
}