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
|
||||
# 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
|
||||
#
|
||||
|
||||
@@ -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: '' } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PdfSettings {
|
||||
pdf_footer: string;
|
||||
pdf_logo: string;
|
||||
pdf_org_name: string;
|
||||
app_logo?: string;
|
||||
}
|
||||
|
||||
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