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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-14 09:07:42 +01:00
parent 1d4894b637
commit 46ced98bf4
5 changed files with 331 additions and 1 deletions

View file

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

View file

@ -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<CompanySettings> {
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<string, unknown>;
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
// ============================================================

View file

@ -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<CompanySettings>(empty);
const [saved, setSaved] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['company-settings'],
queryFn: () => api.get<CompanySettings>('/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 <p>Lädt</p>;
return (
<div style={{ maxWidth: 640 }}>
<div style={cardStyle}>
<h2 style={{ margin: '0 0 0.25rem', fontSize: '1.125rem', fontWeight: 700 }}>Firmendaten</h2>
<p style={{ margin: '0 0 1.5rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Diese Angaben erscheinen als Fußzeile in jedem PDF-Export.
</p>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Firmenname</label>
<input
style={inputStyle}
value={form.name ?? ''}
onChange={(e) => set('name', e.target.value)}
placeholder="Muster GmbH"
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Straße &amp; Hausnummer</label>
<input
style={inputStyle}
value={form.street ?? ''}
onChange={(e) => set('street', e.target.value)}
placeholder="Musterstraße 1"
/>
</div>
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Postleitzahl</label>
<input
style={inputStyle}
value={form.postalCode ?? ''}
onChange={(e) => set('postalCode', e.target.value)}
placeholder="12345"
/>
</div>
<div>
<label style={labelStyle}>Stadt</label>
<input
style={inputStyle}
value={form.city ?? ''}
onChange={(e) => set('city', e.target.value)}
placeholder="Musterstadt"
/>
</div>
</div>
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Telefon</label>
<input
style={inputStyle}
value={form.phone ?? ''}
onChange={(e) => set('phone', e.target.value)}
placeholder="+49 123 456789"
/>
</div>
<div>
<label style={labelStyle}>E-Mail</label>
<input
style={inputStyle}
type="email"
value={form.email ?? ''}
onChange={(e) => set('email', e.target.value)}
placeholder="info@muster.de"
/>
</div>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={labelStyle}>Website</label>
<input
style={inputStyle}
value={form.website ?? ''}
onChange={(e) => set('website', e.target.value)}
placeholder="https://www.muster.de"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<button
style={btnPrimary}
onClick={() => mutation.mutate(form)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Speichern…' : 'Speichern'}
</button>
{saved && (
<span style={{ color: 'var(--color-success, #16a34a)', fontSize: '0.875rem', fontWeight: 500 }}>
Gespeichert
</span>
)}
{mutation.isError && (
<span style={{ color: 'var(--color-danger, #dc2626)', fontSize: '0.875rem' }}>
Fehler beim Speichern
</span>
)}
</div>
</div>
{/* Vorschau Fußzeile */}
{(form.name || form.city || form.phone || form.email || form.website) && (
<div style={cardStyle}>
<h3 style={{ margin: '0 0 0.75rem', fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-secondary)' }}>
VORSCHAU FUSSZEILE
</h3>
<div style={{
borderTop: '1px solid #ccc',
paddingTop: '0.5rem',
fontSize: '0.6875rem',
color: '#999',
textAlign: 'center',
}}>
{[
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(' | ')}
</div>
</div>
)}
</div>
);
}

View file

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

View file

@ -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() {
<Route path="sso" element={<AdminSsoPage />} />
<Route path="external-links" element={<AdminExternalLinksPage />} />
<Route path="customize" element={<AdminCustomizePage />} />
<Route path="company" element={<AdminCompanyPage />} />
<Route path="events" element={<AdminEventsPage />} />
<Route path="ssl" element={<AdminSslPage />} />
</Route>