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 { 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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
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/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' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue