From 46ced98bf4762dc029e81983944a5017bf6a4dec Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Sat, 14 Mar 2026 09:07:42 +0100 Subject: [PATCH] feat(core+frontend): Firmendaten im Admin + PDF-Fusszeile Backend: - GET/POST /settings/company (Redis-Key platform_company_settings) Felder: name, street, postalCode, city, phone, email, website - ProfileExportService: RedisService injiziert, laedt Firmendaten vor PDF-Erzeugung - PDF-Footer: Trennlinie + kompakte Zeile mit allen Firmendaten auf jeder Seite (bufferPages=true, switchToPage-Loop vor doc.end()) Frontend: - AdminCompanyPage: Formular mit Vorschau der Fusszeile - AdminLayout: neuer Tab 'Firmendaten' - App.tsx: Route /admin/company Co-Authored-By: Claude Sonnet 4.6 --- .../expert-profile/profile-export.service.ts | 38 ++- .../src/core/settings/settings.controller.ts | 69 ++++++ .../frontend/src/admin/AdminCompanyPage.tsx | 222 ++++++++++++++++++ packages/frontend/src/admin/AdminLayout.tsx | 1 + packages/frontend/src/shell/App.tsx | 2 + 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/admin/AdminCompanyPage.tsx diff --git a/packages/core-service/src/core/expert-profile/profile-export.service.ts b/packages/core-service/src/core/expert-profile/profile-export.service.ts index 106f1ac..c74f544 100644 --- a/packages/core-service/src/core/expert-profile/profile-export.service.ts +++ b/packages/core-service/src/core/expert-profile/profile-export.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ExpertProfileService } from './expert-profile.service'; +import { RedisService } from '../../redis/redis.service'; import PDFDocument from 'pdfkit'; import * as fs from 'fs'; import * as path from 'path'; @@ -111,7 +112,10 @@ export class ProfileExportService { // Pfad zu den Icon-Assets (relativ zum Working Directory /app im Container) private readonly iconsDir = path.resolve(process.cwd(), 'assets', 'icons'); - constructor(private readonly expertProfileService: ExpertProfileService) {} + constructor( + private readonly expertProfileService: ExpertProfileService, + private readonly redis: RedisService, + ) {} private loadIcon(name: string, color?: string): Buffer | null { try { @@ -187,6 +191,23 @@ export class ProfileExportService { async generatePdf(userId: string, accentColor = '#009688'): Promise<{ buffer: Buffer; firstName: string; lastName: string }> { const data = await this.expertProfileService.getExportData(userId) as ExportData; const profile = data.expertProfile; + + // Firmendaten für PDF-Footer laden + let companyFooterText = ''; + try { + const raw = await this.redis.get('platform_company_settings'); + if (raw) { + const c = JSON.parse(raw) as Record; + const address = [c['street'], [c['postalCode'], c['city']].filter(Boolean).join(' ')].filter(Boolean).join(', '); + companyFooterText = [ + c['name'], + address || null, + c['phone'] ? `Tel: ${c['phone']}` : null, + c['email'], + c['website'], + ].filter(Boolean).join(' | '); + } + } catch { /* Firmendaten nicht verfügbar — kein Footer */ } const fullName = `${data.firstName} ${data.lastName}`; const { firstName, lastName } = data; @@ -474,6 +495,21 @@ export class ProfileExportService { } } + // --- FOOTER: Firmendaten auf jeder Seite --- + if (companyFooterText) { + const pageHeight = doc.page.height; + const footerLineY = pageHeight - 32; + const footerTextY = pageHeight - 26; + const range = doc.bufferedPageRange(); + for (let p = range.start; p < range.start + range.count; p++) { + doc.switchToPage(p); + doc.moveTo(40, footerLineY).lineTo(pageWidth - 40, footerLineY) + .strokeColor('#cccccc').lineWidth(0.5).stroke(); + doc.font('Helvetica').fontSize(7).fillColor('#999999'); + doc.text(companyFooterText, 40, footerTextY, { width: pageWidth - 80, align: 'center', lineBreak: false }); + } + } + doc.end(); }); } diff --git a/packages/core-service/src/core/settings/settings.controller.ts b/packages/core-service/src/core/settings/settings.controller.ts index 39adafe..c8d802e 100644 --- a/packages/core-service/src/core/settings/settings.controller.ts +++ b/packages/core-service/src/core/settings/settings.controller.ts @@ -33,6 +33,17 @@ interface ExternalLink { const EXTERNAL_LINKS_KEY = 'platform_external_links'; const BRANDING_LOGO_KEY = 'platform_branding_logo'; const SSL_CONFIG_KEY = 'platform_ssl_config'; +const COMPANY_SETTINGS_KEY = 'platform_company_settings'; + +interface CompanySettings { + name: string | null; + street: string | null; + postalCode: string | null; + city: string | null; + phone: string | null; + email: string | null; + website: string | null; +} /** * SSL/Domain-Konfiguration — Metadata in Redis, Cert-Dateien auf Filesystem. @@ -386,6 +397,64 @@ export class SettingsController { return `${origin}/${bestHref}`; } + // ============================================================ + // Firmendaten (Company Settings) + // ============================================================ + + /** + * GET /api/v1/settings/company + * Firmendaten lesen (jeder authentifizierte User — fuer PDF-Footer). + */ + @Get('company') + @ApiOperation({ summary: 'Firmendaten lesen' }) + async getCompanySettings(): Promise { + const empty: CompanySettings = { + name: null, street: null, postalCode: null, city: null, + phone: null, email: null, website: null, + }; + const raw = await this.redis.get(COMPANY_SETTINGS_KEY); + if (!raw) return empty; + try { + const data = JSON.parse(raw) as Record; + return { + name: typeof data.name === 'string' ? data.name : null, + street: typeof data.street === 'string' ? data.street : null, + postalCode: typeof data.postalCode === 'string' ? data.postalCode : null, + city: typeof data.city === 'string' ? data.city : null, + phone: typeof data.phone === 'string' ? data.phone : null, + email: typeof data.email === 'string' ? data.email : null, + website: typeof data.website === 'string' ? data.website : null, + }; + } catch { + return empty; + } + } + + /** + * POST /api/v1/settings/company + * Firmendaten speichern (nur PLATFORM_ADMIN). + */ + @Post('company') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + @ApiOperation({ summary: 'Firmendaten speichern (Admin)' }) + async saveCompanySettings( + @Body() body: CompanySettings, + ): Promise<{ success: boolean }> { + const data: CompanySettings = { + name: body.name?.trim() || null, + street: body.street?.trim() || null, + postalCode: body.postalCode?.trim() || null, + city: body.city?.trim() || null, + phone: body.phone?.trim() || null, + email: body.email?.trim() || null, + website: body.website?.trim() || null, + }; + await this.redis.set(COMPANY_SETTINGS_KEY, JSON.stringify(data)); + this.logger.log('Firmendaten aktualisiert'); + return { success: true }; + } + // ============================================================ // SSL / Domain Configuration // ============================================================ diff --git a/packages/frontend/src/admin/AdminCompanyPage.tsx b/packages/frontend/src/admin/AdminCompanyPage.tsx new file mode 100644 index 0000000..72453d4 --- /dev/null +++ b/packages/frontend/src/admin/AdminCompanyPage.tsx @@ -0,0 +1,222 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; + +interface CompanySettings { + name: string | null; + street: string | null; + postalCode: string | null; + city: string | null; + phone: string | null; + email: string | null; + website: string | null; +} + +const empty: CompanySettings = { + name: null, street: null, postalCode: null, city: null, + phone: null, email: null, website: null, +}; + +const cardStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-sm)', + border: '1px solid var(--color-border)', + padding: '1.5rem', + marginBottom: '1.5rem', +}; + +const btnPrimary: React.CSSProperties = { + padding: '0.5rem 1.25rem', + background: 'var(--color-primary)', + color: 'white', + border: 'none', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer', +}; + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '0.75rem', + fontWeight: 600, + color: 'var(--color-text-secondary)', + marginBottom: '0.25rem', + textTransform: 'uppercase', + letterSpacing: '0.04em', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '0.5rem 0.75rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + background: 'var(--color-bg)', + color: 'var(--color-text)', + boxSizing: 'border-box', +}; + +const rowStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '1rem', +}; + +export function AdminCompanyPage() { + const queryClient = useQueryClient(); + const [form, setForm] = useState(empty); + const [saved, setSaved] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ['company-settings'], + queryFn: () => api.get('/settings/company').then((r) => r.data), + }); + + useEffect(() => { + if (data) setForm(data); + }, [data]); + + const mutation = useMutation({ + mutationFn: (settings: CompanySettings) => api.post('/settings/company', settings), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['company-settings'] }); + setSaved(true); + setTimeout(() => setSaved(false), 2500); + }, + }); + + const set = (field: keyof CompanySettings, value: string) => + setForm((prev) => ({ ...prev, [field]: value || null })); + + if (isLoading) return

Lädt…

; + + return ( +
+
+

Firmendaten

+

+ Diese Angaben erscheinen als Fußzeile in jedem PDF-Export. +

+ +
+ + set('name', e.target.value)} + placeholder="Muster GmbH" + /> +
+ +
+ + set('street', e.target.value)} + placeholder="Musterstraße 1" + /> +
+ +
+
+ + set('postalCode', e.target.value)} + placeholder="12345" + /> +
+
+ + set('city', e.target.value)} + placeholder="Musterstadt" + /> +
+
+ +
+
+ + set('phone', e.target.value)} + placeholder="+49 123 456789" + /> +
+
+ + set('email', e.target.value)} + placeholder="info@muster.de" + /> +
+
+ +
+ + set('website', e.target.value)} + placeholder="https://www.muster.de" + /> +
+ +
+ + {saved && ( + + Gespeichert ✓ + + )} + {mutation.isError && ( + + Fehler beim Speichern + + )} +
+
+ + {/* Vorschau Fußzeile */} + {(form.name || form.city || form.phone || form.email || form.website) && ( +
+

+ VORSCHAU FUSSZEILE +

+
+ {[ + form.name, + [form.street, [form.postalCode, form.city].filter(Boolean).join(' ')].filter(Boolean).join(', '), + form.phone ? `Tel: ${form.phone}` : null, + form.email, + form.website, + ].filter(Boolean).join(' | ')} +
+
+ )} +
+ ); +} diff --git a/packages/frontend/src/admin/AdminLayout.tsx b/packages/frontend/src/admin/AdminLayout.tsx index ca1c7f5..5de8f81 100644 --- a/packages/frontend/src/admin/AdminLayout.tsx +++ b/packages/frontend/src/admin/AdminLayout.tsx @@ -6,6 +6,7 @@ const tabs = [ { to: '/admin/sso', label: 'SSO-Konfiguration' }, { to: '/admin/external-links', label: 'Externe Links' }, { to: '/admin/customize', label: 'Anpassungen' }, + { to: '/admin/company', label: 'Firmendaten' }, { to: '/admin/events', label: 'Events' }, { to: '/admin/ssl', label: 'SSL / Domain' }, ]; diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 7908d2b..bde6a39 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -12,6 +12,7 @@ import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage'; import { AdminCustomizePage } from '../admin/AdminCustomizePage'; import { AdminEventsPage } from '../admin/AdminEventsPage'; import { AdminSslPage } from '../admin/AdminSslPage'; +import { AdminCompanyPage } from '../admin/AdminCompanyPage'; import { ProfilePage } from '../profile/ProfilePage'; import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; @@ -87,6 +88,7 @@ export function App() { } /> } /> } /> + } /> } /> } />