update
This commit is contained in:
18
.env.example
18
.env.example
@@ -87,9 +87,9 @@ JWT_SECRET=your_jwt_secret_here
|
|||||||
# The frontend URL that is allowed to make requests to the backend
|
# The frontend URL that is allowed to make requests to the backend
|
||||||
# IMPORTANT: Must match your frontend URL exactly!
|
# IMPORTANT: Must match your frontend URL exactly!
|
||||||
# Development: http://localhost:5173 (Vite dev server)
|
# 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)
|
# 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
|
# FRONTEND CONFIGURATION
|
||||||
@@ -103,9 +103,9 @@ FRONTEND_PORT=80
|
|||||||
# API URL for frontend
|
# API URL for frontend
|
||||||
# The URL where the frontend will send API requests
|
# The URL where the frontend will send API requests
|
||||||
# Development: http://localhost:3000
|
# 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!
|
# 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
|
# Authentik URL for frontend
|
||||||
# The base URL of your Authentik instance (without application path)
|
# 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
|
# The URL where Authentik will redirect after successful authentication
|
||||||
# Must match EXACTLY what you configured in Authentik
|
# Must match EXACTLY what you configured in Authentik
|
||||||
# Development: http://localhost:5173/auth/callback
|
# Development: http://localhost:5173/auth/callback
|
||||||
# Production: https://start.feuerwehr-rems.at/auth/callback
|
# Production: https://portal.feuerwehr-rems.at/auth/callback
|
||||||
AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
|
AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback
|
||||||
|
|
||||||
# OAuth Scopes (optional, has defaults)
|
# OAuth Scopes (optional, has defaults)
|
||||||
# Default: openid profile email
|
# Default: openid profile email
|
||||||
@@ -283,14 +283,14 @@ FDISK_SYNC_URL=http://fdisk-sync:3001
|
|||||||
# BACKEND_PORT=3000
|
# BACKEND_PORT=3000
|
||||||
# NODE_ENV=production
|
# NODE_ENV=production
|
||||||
# JWT_SECRET=<generated-with-openssl-rand-base64-32>
|
# 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
|
# 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_ID=<from-authentik>
|
||||||
# AUTHENTIK_CLIENT_SECRET=<from-authentik>
|
# AUTHENTIK_CLIENT_SECRET=<from-authentik>
|
||||||
# AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at
|
# AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at
|
||||||
# AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
|
# 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
|
# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ class ConfigController {
|
|||||||
|
|
||||||
async getPdfSettings(_req: Request, res: Response): Promise<void> {
|
async getPdfSettings(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
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_header'),
|
||||||
settingsService.get('pdf_footer'),
|
settingsService.get('pdf_footer'),
|
||||||
settingsService.get('pdf_logo'),
|
settingsService.get('pdf_logo'),
|
||||||
settingsService.get('pdf_org_name'),
|
settingsService.get('pdf_org_name'),
|
||||||
|
settingsService.get('app_logo'),
|
||||||
]);
|
]);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -28,10 +29,11 @@ class ConfigController {
|
|||||||
pdf_footer: footer?.value ?? '',
|
pdf_footer: footer?.value ?? '',
|
||||||
pdf_logo: logo?.value ?? '',
|
pdf_logo: logo?.value ?? '',
|
||||||
pdf_org_name: orgName?.value ?? '',
|
pdf_org_name: orgName?.value ?? '',
|
||||||
|
app_logo: appLogo?.value ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} 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: '' } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ services:
|
|||||||
DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
||||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
|
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_ISSUER: ${AUTHENTIK_ISSUER:?AUTHENTIK_ISSUER is required}
|
||||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:?AUTHENTIK_CLIENT_ID 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_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}
|
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_URL: ${BOOKSTACK_URL:-}
|
||||||
BOOKSTACK_TOKEN_ID: ${BOOKSTACK_TOKEN_ID:-}
|
BOOKSTACK_TOKEN_ID: ${BOOKSTACK_TOKEN_ID:-}
|
||||||
BOOKSTACK_TOKEN_SECRET: ${BOOKSTACK_TOKEN_SECRET:-}
|
BOOKSTACK_TOKEN_SECRET: ${BOOKSTACK_TOKEN_SECRET:-}
|
||||||
@@ -68,14 +68,14 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
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_URL: ${AUTHENTIK_URL:?AUTHENTIK_URL is required}
|
||||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:?AUTHENTIK_CLIENT_ID is required}
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:?AUTHENTIK_CLIENT_ID is required}
|
||||||
container_name: feuerwehr_frontend_prod
|
container_name: feuerwehr_frontend_prod
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.feuerwehr-frontend.entrypoints=websecure"
|
- "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=true"
|
||||||
- "traefik.http.routers.feuerwehr-frontend.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.feuerwehr-frontend.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.feuerwehr-frontend.service=feuerwehr-frontend"
|
- "traefik.http.routers.feuerwehr-frontend.service=feuerwehr-frontend"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
Divider,
|
Divider,
|
||||||
Box,
|
Box,
|
||||||
Tooltip,
|
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
@@ -18,12 +17,12 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Logout,
|
Logout,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Chat,
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { configApi } from '../../services/config';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
import { useLayout } from '../../contexts/LayoutContext';
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
@@ -32,7 +31,12 @@ interface HeaderProps {
|
|||||||
function Header({ onMenuClick }: HeaderProps) {
|
function Header({ onMenuClick }: HeaderProps) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
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 [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
@@ -83,25 +87,24 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<LocalFireDepartment sx={{ mr: 2 }} />
|
{settings?.app_logo ? (
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
<Box
|
||||||
Feuerwehr Dashboard
|
onClick={() => navigate('/dashboard')}
|
||||||
</Typography>
|
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 && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<Tooltip title="Chat">
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
onClick={toggleChatPanel}
|
|
||||||
size="small"
|
|
||||||
aria-label="Chat öffnen"
|
|
||||||
sx={{ ml: 0.5 }}
|
|
||||||
>
|
|
||||||
<Chat />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
ExpandMore,
|
ExpandMore,
|
||||||
PictureAsPdf as PdfIcon,
|
PictureAsPdf as PdfIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
@@ -82,6 +83,9 @@ function AdminSettings() {
|
|||||||
const [pdfLogo, setPdfLogo] = useState('');
|
const [pdfLogo, setPdfLogo] = useState('');
|
||||||
const [pdfOrgName, setPdfOrgName] = useState('');
|
const [pdfOrgName, setPdfOrgName] = useState('');
|
||||||
|
|
||||||
|
// State for app logo
|
||||||
|
const [appLogo, setAppLogo] = useState('');
|
||||||
|
|
||||||
// Fetch all settings
|
// Fetch all settings
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
queryKey: ['admin-settings'],
|
queryKey: ['admin-settings'],
|
||||||
@@ -119,6 +123,8 @@ function AdminSettings() {
|
|||||||
if (pdfLogoSetting?.value != null) setPdfLogo(pdfLogoSetting.value);
|
if (pdfLogoSetting?.value != null) setPdfLogo(pdfLogoSetting.value);
|
||||||
const pdfOrgNameSetting = settings.find((s) => s.key === 'pdf_org_name');
|
const pdfOrgNameSetting = settings.find((s) => s.key === 'pdf_org_name');
|
||||||
if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value);
|
if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value);
|
||||||
|
const appLogoSetting = settings.find((s) => s.key === 'app_logo');
|
||||||
|
if (appLogoSetting?.value != null) setAppLogo(appLogoSetting.value);
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
@@ -197,6 +203,30 @@ function AdminSettings() {
|
|||||||
reader.readAsDataURL(file);
|
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) {
|
if (!canAccess) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
@@ -276,7 +306,59 @@ function AdminSettings() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={3}>
|
<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>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
@@ -375,7 +457,7 @@ function AdminSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 2: Refresh Intervals */}
|
{/* Section 3: Refresh Intervals */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
@@ -439,7 +521,7 @@ function AdminSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 3: PDF Settings */}
|
{/* Section 4: PDF Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
@@ -459,35 +541,6 @@ function AdminSettings() {
|
|||||||
size="small"
|
size="small"
|
||||||
placeholder="FREIWILLIGE FEUERWEHR REMS"
|
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
|
<TextField
|
||||||
label="PDF Kopfzeile (links, unter dem Header-Banner)"
|
label="PDF Kopfzeile (links, unter dem Header-Banner)"
|
||||||
value={pdfHeader}
|
value={pdfHeader}
|
||||||
@@ -527,7 +580,7 @@ function AdminSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 4: Info */}
|
{/* Section 5: Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
AccordionSummary,
|
AccordionSummary,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack,
|
ArrowBack,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
ArrowDropDown,
|
ArrowDropDown,
|
||||||
ExpandMore as ExpandMoreIcon,
|
ExpandMore as ExpandMoreIcon,
|
||||||
Save as SaveIcon,
|
Save as SaveIcon,
|
||||||
|
PictureAsPdf as PdfIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
@@ -48,6 +50,8 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
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 { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
|
||||||
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } 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 canDelete = hasPermission('bestellungen:delete');
|
||||||
const canManageReminders = hasPermission('bestellungen:manage_reminders');
|
const canManageReminders = hasPermission('bestellungen:manage_reminders');
|
||||||
const canManageOrders = hasPermission('bestellungen:manage_orders');
|
const canManageOrders = hasPermission('bestellungen:manage_orders');
|
||||||
|
const canExport = hasPermission('bestellungen:export');
|
||||||
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
||||||
|
|
||||||
// All statuses except current, for force override
|
// All statuses except current, for force override
|
||||||
@@ -341,6 +346,92 @@ export default function BestellungDetail() {
|
|||||||
|
|
||||||
// ── Loading / Error ──
|
// ── 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -374,6 +465,13 @@ export default function BestellungDetail() {
|
|||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography>
|
<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 && (
|
{canCreate && !editMode && (
|
||||||
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
|
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Grid,
|
Grid,
|
||||||
|
IconButton,
|
||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -25,13 +27,15 @@ import {
|
|||||||
LinearProgress,
|
LinearProgress,
|
||||||
Divider,
|
Divider,
|
||||||
} from '@mui/material';
|
} 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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
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 { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
|
||||||
import type { BestellungStatus, Bestellung } from '../types/bestellung.types';
|
import type { BestellungStatus, Bestellung } from '../types/bestellung.types';
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ export default function Bestellungen() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const canManageVendors = hasPermission('bestellungen:manage_vendors');
|
const canManageVendors = hasPermission('bestellungen:manage_vendors');
|
||||||
|
const canExport = hasPermission('bestellungen:export');
|
||||||
|
|
||||||
// Tab from URL
|
// Tab from URL
|
||||||
const [tab, setTab] = useState(() => {
|
const [tab, setTab] = useState(() => {
|
||||||
@@ -178,11 +183,66 @@ export default function Bestellungen() {
|
|||||||
return selectedVendors === null || selectedVendors.has(key);
|
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 ──
|
// ── Render ──
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<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' }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { useNotification } from '../contexts/NotificationContext';
|
|||||||
import { trainingApi } from '../services/training';
|
import { trainingApi } from '../services/training';
|
||||||
import { eventsApi } from '../services/events';
|
import { eventsApi } from '../services/events';
|
||||||
import { configApi, type PdfSettings } from '../services/config';
|
import { configApi, type PdfSettings } from '../services/config';
|
||||||
|
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
||||||
import type {
|
import type {
|
||||||
UebungListItem,
|
UebungListItem,
|
||||||
UebungTyp,
|
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 _pdfSettingsCache: PdfSettings | null = null;
|
||||||
let _pdfSettingsCacheTime = 0;
|
let _pdfSettingsCacheTime = 0;
|
||||||
|
|
||||||
@@ -683,41 +641,8 @@ async function generatePdf(
|
|||||||
|
|
||||||
const pdfSettings = await fetchPdfSettings();
|
const pdfSettings = await fetchPdfSettings();
|
||||||
|
|
||||||
// Header bar
|
// Header
|
||||||
doc.setFillColor(183, 28, 28); // fire-red
|
const tableStartY = addPdfHeader(doc, `Kalender — ${monthLabel} ${year}`, pdfSettings, 297);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build combined list (same logic as CombinedListView)
|
// Build combined list (same logic as CombinedListView)
|
||||||
type ListEntry =
|
type ListEntry =
|
||||||
@@ -766,11 +691,7 @@ async function generatePdf(
|
|||||||
3: { cellWidth: 40 },
|
3: { cellWidth: 40 },
|
||||||
4: { cellWidth: 60 },
|
4: { cellWidth: 60 },
|
||||||
},
|
},
|
||||||
didDrawPage: pdfSettings.pdf_footer
|
didDrawPage: addPdfFooter(doc, pdfSettings),
|
||||||
? () => {
|
|
||||||
renderMarkdownText(doc, pdfSettings.pdf_footer, 10, doc.internal.pageSize.height - 12, { fontSize: 8 });
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`;
|
const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface PdfSettings {
|
|||||||
pdf_footer: string;
|
pdf_footer: string;
|
||||||
pdf_logo: string;
|
pdf_logo: string;
|
||||||
pdf_org_name: string;
|
pdf_org_name: string;
|
||||||
|
app_logo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
|
|||||||
136
frontend/src/utils/pdfExport.ts
Normal file
136
frontend/src/utils/pdfExport.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user