mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
1d4894b637
commit
46ced98bf4
5 changed files with 331 additions and 1 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ExpertProfileService } from './expert-profile.service';
|
import { ExpertProfileService } from './expert-profile.service';
|
||||||
|
import { RedisService } from '../../redis/redis.service';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -111,7 +112,10 @@ export class ProfileExportService {
|
||||||
// Pfad zu den Icon-Assets (relativ zum Working Directory /app im Container)
|
// Pfad zu den Icon-Assets (relativ zum Working Directory /app im Container)
|
||||||
private readonly iconsDir = path.resolve(process.cwd(), 'assets', 'icons');
|
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 {
|
private loadIcon(name: string, color?: string): Buffer | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -187,6 +191,23 @@ export class ProfileExportService {
|
||||||
async generatePdf(userId: string, accentColor = '#009688'): Promise<{ buffer: Buffer; firstName: string; lastName: string }> {
|
async generatePdf(userId: string, accentColor = '#009688'): Promise<{ buffer: Buffer; firstName: string; lastName: string }> {
|
||||||
const data = await this.expertProfileService.getExportData(userId) as ExportData;
|
const data = await this.expertProfileService.getExportData(userId) as ExportData;
|
||||||
const profile = data.expertProfile;
|
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 fullName = `${data.firstName} ${data.lastName}`;
|
||||||
const { firstName, lastName } = data;
|
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();
|
doc.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,17 @@ interface ExternalLink {
|
||||||
const EXTERNAL_LINKS_KEY = 'platform_external_links';
|
const EXTERNAL_LINKS_KEY = 'platform_external_links';
|
||||||
const BRANDING_LOGO_KEY = 'platform_branding_logo';
|
const BRANDING_LOGO_KEY = 'platform_branding_logo';
|
||||||
const SSL_CONFIG_KEY = 'platform_ssl_config';
|
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.
|
* SSL/Domain-Konfiguration — Metadata in Redis, Cert-Dateien auf Filesystem.
|
||||||
|
|
@ -386,6 +397,64 @@ export class SettingsController {
|
||||||
return `${origin}/${bestHref}`;
|
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
|
// SSL / Domain Configuration
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
222
packages/frontend/src/admin/AdminCompanyPage.tsx
Normal file
222
packages/frontend/src/admin/AdminCompanyPage.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ const tabs = [
|
||||||
{ to: '/admin/sso', label: 'SSO-Konfiguration' },
|
{ to: '/admin/sso', label: 'SSO-Konfiguration' },
|
||||||
{ to: '/admin/external-links', label: 'Externe Links' },
|
{ to: '/admin/external-links', label: 'Externe Links' },
|
||||||
{ to: '/admin/customize', label: 'Anpassungen' },
|
{ to: '/admin/customize', label: 'Anpassungen' },
|
||||||
|
{ to: '/admin/company', label: 'Firmendaten' },
|
||||||
{ to: '/admin/events', label: 'Events' },
|
{ to: '/admin/events', label: 'Events' },
|
||||||
{ to: '/admin/ssl', label: 'SSL / Domain' },
|
{ to: '/admin/ssl', label: 'SSL / Domain' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
|
||||||
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
||||||
import { AdminEventsPage } from '../admin/AdminEventsPage';
|
import { AdminEventsPage } from '../admin/AdminEventsPage';
|
||||||
import { AdminSslPage } from '../admin/AdminSslPage';
|
import { AdminSslPage } from '../admin/AdminSslPage';
|
||||||
|
import { AdminCompanyPage } from '../admin/AdminCompanyPage';
|
||||||
import { ProfilePage } from '../profile/ProfilePage';
|
import { ProfilePage } from '../profile/ProfilePage';
|
||||||
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
||||||
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||||
|
|
@ -87,6 +88,7 @@ export function App() {
|
||||||
<Route path="sso" element={<AdminSsoPage />} />
|
<Route path="sso" element={<AdminSsoPage />} />
|
||||||
<Route path="external-links" element={<AdminExternalLinksPage />} />
|
<Route path="external-links" element={<AdminExternalLinksPage />} />
|
||||||
<Route path="customize" element={<AdminCustomizePage />} />
|
<Route path="customize" element={<AdminCustomizePage />} />
|
||||||
|
<Route path="company" element={<AdminCompanyPage />} />
|
||||||
<Route path="events" element={<AdminEventsPage />} />
|
<Route path="events" element={<AdminEventsPage />} />
|
||||||
<Route path="ssl" element={<AdminSslPage />} />
|
<Route path="ssl" element={<AdminSslPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue