diff --git a/backend/src/Controller/PublicProfileController.php b/backend/src/Controller/PublicProfileController.php index 3b000bc..547ffc2 100644 --- a/backend/src/Controller/PublicProfileController.php +++ b/backend/src/Controller/PublicProfileController.php @@ -4,6 +4,8 @@ namespace App\Controller; use App\Entity\Employee; use App\Repository\EmployeeRepository; +use App\Service\BrandingService; +use App\Service\ResolvedTenant; use App\Service\VCardBuilder; use App\Service\WalletService; use Endroid\QrCode\Builder\Builder; @@ -26,12 +28,15 @@ final class PublicProfileController extends AbstractController } #[Route('/p/{companySlug}/{slug}', name: 'public_profile', methods: ['GET'])] - public function show(string $companySlug, string $slug, WalletService $wallet): Response + public function show(string $companySlug, string $slug, WalletService $wallet, BrandingService $branding): Response { $employee = $this->resolve($companySlug, $slug); + $company = $employee->getCompany(); + $tenant = new ResolvedTenant(ResolvedTenant::KIND_COMPANY, $company->getReseller(), $company); return $this->render('public/profile.html.twig', [ 'e' => $employee, + 'branding' => $branding->forTenant($tenant)['branding'], 'profileUrl' => $this->profileUrl($employee), 'shareUrl' => $this->shareUrl($employee), 'walletEnabled' => null !== $employee->getShortCode() diff --git a/backend/src/Service/VCardBuilder.php b/backend/src/Service/VCardBuilder.php index 3f53aa4..3a21320 100644 --- a/backend/src/Service/VCardBuilder.php +++ b/backend/src/Service/VCardBuilder.php @@ -3,6 +3,7 @@ namespace App\Service; use App\Entity\Employee; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * Erzeugt eine vCard (RFC 6350, Version 3.0 – breite Kompatibilität mit @@ -10,46 +11,73 @@ use App\Entity\Employee; */ final class VCardBuilder { + public function __construct(private readonly UrlGeneratorInterface $urls) + { + } + public function build(Employee $e): string { $company = $e->getCompany(); - $location = $e->getLocation(); $lines = []; $lines[] = 'BEGIN:VCARD'; $lines[] = 'VERSION:3.0'; - $lines[] = 'N:'.$this->esc($e->getLastName()).';'.$this->esc($e->getFirstName()).';;;'; - $lines[] = 'FN:'.$this->esc(trim($e->getFirstName().' '.$e->getLastName())); + // N: Nachname;Vorname;;Präfix(Titel); + $lines[] = 'N:'.$this->esc($e->getLastName()).';'.$this->esc($e->getFirstName()).';;' + .$this->esc((string) $e->getTitle()).';'; + $lines[] = 'FN:'.$this->esc(trim(($e->getTitle() ? $e->getTitle().' ' : '').$e->getFirstName().' '.$e->getLastName())); if ($company) { - $lines[] = 'ORG:'.$this->esc($company->getName()); + // ORG:Firma;Abteilung + $org = $this->esc($company->getName()); + if ($e->getDepartment()) { + $org .= ';'.$this->esc($e->getDepartment()); + } + $lines[] = 'ORG:'.$org; } if ($e->getPosition()) { $lines[] = 'TITLE:'.$this->esc($e->getPosition()); } + if ($e->getEmail()) { $lines[] = 'EMAIL;TYPE=WORK:'.$this->esc($e->getEmail()); } + if ($e->getEmailPrivate()) { + $lines[] = 'EMAIL;TYPE=HOME:'.$this->esc($e->getEmailPrivate()); + } + if ($e->getPhone()) { $lines[] = 'TEL;TYPE=WORK,VOICE:'.$this->esc($e->getPhone()); } if ($e->getMobile()) { $lines[] = 'TEL;TYPE=CELL:'.$this->esc($e->getMobile()); } - - if ($location && ($location->getStreet() || $location->getCity())) { - // ADR: ;;Straße;Ort;;PLZ;Land - $lines[] = 'ADR;TYPE=WORK:;;' - .$this->esc((string) $location->getStreet()).';' - .$this->esc((string) $location->getCity()).';;' - .$this->esc((string) $location->getPostalCode()).';' - .$this->esc((string) $location->getCountry()); + if ($e->getPhoneCentral()) { + $lines[] = 'TEL;TYPE=WORK:'.$this->esc($e->getPhoneCentral()); } + if ($e->getFax()) { + $lines[] = 'TEL;TYPE=WORK,FAX:'.$this->esc($e->getFax()); + } + + if ($e->getWebsite()) { + $lines[] = 'URL:'.$this->esc($e->getWebsite()); + } + + $this->addAddress($lines, 'WORK', $e->getAddressBusiness() ?? $this->fromLocation($e)); + $this->addAddress($lines, 'HOME', $e->getAddressPrivate()); foreach ($e->getContactLinks() as $link) { $lines[] = 'URL:'.$this->esc($link->getUrl()); } + if (null !== $e->getPhotoPath()) { + $lines[] = 'PHOTO;VALUE=URI:'.$this->urls->generate( + 'employee_photo_public', + ['id' => (string) $e->getId()], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + } + if ($e->getBio()) { $lines[] = 'NOTE:'.$this->esc($e->getBio()); } @@ -61,6 +89,50 @@ final class VCardBuilder return implode("\r\n", $lines)."\r\n"; } + /** + * @param string[] $lines + * @param array|null $a + */ + private function addAddress(array &$lines, string $type, ?array $a): void + { + if (null === $a) { + return; + } + $street = trim((string) ($a['street'] ?? '').' '.(string) ($a['houseNumber'] ?? '')); + $city = (string) ($a['city'] ?? ''); + if ('' === $street && '' === $city) { + return; + } + // ADR: PO;Ext;Straße;Ort;Region;PLZ;Land + $lines[] = 'ADR;TYPE='.$type.':;' + .$this->esc((string) ($a['addressLine2'] ?? '')).';' + .$this->esc($street).';' + .$this->esc($city).';' + .$this->esc((string) ($a['state'] ?? '')).';' + .$this->esc((string) ($a['zip'] ?? '')).';' + .$this->esc((string) ($a['country'] ?? '')); + } + + /** + * Fallback: Geschäftsadresse aus dem zugeordneten Standort. + * + * @return array|null + */ + private function fromLocation(Employee $e): ?array + { + $l = $e->getLocation(); + if (null === $l || (!$l->getStreet() && !$l->getCity())) { + return null; + } + + return [ + 'street' => (string) $l->getStreet(), + 'city' => (string) $l->getCity(), + 'zip' => (string) $l->getPostalCode(), + 'country' => (string) $l->getCountry(), + ]; + } + /** Escaping gemäß vCard-Spezifikation. */ private function esc(string $value): string { diff --git a/backend/templates/public/profile.html.twig b/backend/templates/public/profile.html.twig index a4fb6a4..8a74b14 100644 --- a/backend/templates/public/profile.html.twig +++ b/backend/templates/public/profile.html.twig @@ -1,13 +1,29 @@ {% extends 'base.html.twig' %} -{% set fullName = (e.firstName ~ ' ' ~ e.lastName)|trim %} +{% set fullName = ((e.title ? e.title ~ ' ' : '') ~ e.firstName ~ ' ' ~ e.lastName)|trim %} {% set reseller = e.company.reseller %} -{# Firmenspezifisches Branding – defensiv validiert #} -{% set b = e.company.brandingConfig %} -{% set primary = (b.primaryColor is defined and b.primaryColor matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryColor : null %} -{% set primaryDark = (b.primaryDark is defined and b.primaryDark matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryDark : primary %} -{% set logo = (b.logoUrl is defined and (b.logoUrl starts with 'https://' or b.logoUrl starts with '/')) ? b.logoUrl : null %} +{# White-Label-Branding (Firma → Reseller → Standard), aus dem BrandingService #} +{% set primary = branding.primaryColor %} +{% set primaryDark = branding.primaryColorDark %} +{% set logo = branding.logoUrl %} + +{% macro addressBlock(a, label) %} + {% set s = a.street|default('') %} + {% set l2 = a.addressLine2|default('') %} + {% set zip = a.zip|default('') %} + {% set city = a.city|default('') %} + {% set st = a.state|default('') %} + {% set co = a.country|default('') %} +
+ {% if label %}{{ label }}{% endif %} + {% if s %}{{ s }} {{ a.houseNumber|default('') }}
{% endif %} + {% if l2 %}{{ l2 }}
{% endif %} + {% if zip or city %}{{ zip }} {{ city }}
{% endif %} + {% if st %}{{ st }}
{% endif %} + {% if co %}{{ co }}{% endif %} +
+{% endmacro %} {% block title %}{{ fullName }} – {{ e.company.name }}{% endblock %} @@ -80,6 +96,19 @@ .link .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--psc-orange); } .bio { color: var(--text); font-size: .95rem; } + .contact { display: grid; gap: .45rem; } + .crow { + display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; + padding: .6rem .9rem; border: 1px solid #eee; border-radius: var(--radius-sm); + color: var(--text); + } + .crow:hover { border-color: var(--psc-border); background: var(--psc-orange-soft-2); text-decoration: none; } + .crow .ck { color: var(--muted); font-size: .82rem; font-weight: 600; white-space: nowrap; } + .crow .cv { font-weight: 600; font-size: .92rem; text-align: right; word-break: break-word; } + .addr { font-size: .95rem; line-height: 1.55; color: var(--text); } + .addr + .addr { margin-top: .9rem; } + .addr .at { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .25rem; } + .qr { text-align: center; } .qr img { width: 190px; height: 190px; border-radius: var(--radius-sm); border: 1px solid #eee; } .qr p { color: var(--muted); font-size: .82rem; margin: .6rem 0 0; } @@ -97,7 +126,7 @@
{% if e.photoPath %} - {{ fullName }} + {{ fullName }} {% else %}
{{ (e.firstName|first ~ e.lastName|first)|upper }}
{% endif %} @@ -116,6 +145,7 @@ {% if e.phone %}Anrufen{% endif %} {% if e.mobile %}Mobil{% endif %} {% if e.email %}E-Mail{% endif %} + {% if e.website %}Web{% endif %}
@@ -126,6 +156,36 @@ {% endif %} + {% set hasContact = e.phone or e.mobile or e.phoneCentral or e.fax or e.email or e.emailPrivate or e.website %} + {% if hasContact %} +
+
Kontakt
+
+ {% if e.phone %}Telefon{{ e.phone }}{% endif %} + {% if e.mobile %}Mobil{{ e.mobile }}{% endif %} + {% if e.phoneCentral %}Zentrale{{ e.phoneCentral }}{% endif %} + {% if e.fax %}
Fax{{ e.fax }}
{% endif %} + {% if e.email %}E-Mail{{ e.email }}{% endif %} + {% if e.emailPrivate %}E-Mail privat{{ e.emailPrivate }}{% endif %} + {% if e.website %}Website{{ e.website }}{% endif %} +
+
+ {% endif %} + + {% set ab = e.addressBusiness %} + {% set ap = e.addressPrivate %} + {% if ab or ap %} +
+
Adresse
+ {% if ab %} + {{ _self.addressBlock(ab, ap ? 'Geschäftlich' : null) }} + {% endif %} + {% if ap %} + {{ _self.addressBlock(ap, 'Privat') }} + {% endif %} +
+ {% endif %} + {% if e.contactLinks|length %}
Links
diff --git a/frontend/src/views/EmployeesView.vue b/frontend/src/views/EmployeesView.vue index 583a8a0..2720ff1 100644 --- a/frontend/src/views/EmployeesView.vue +++ b/frontend/src/views/EmployeesView.vue @@ -386,7 +386,7 @@ onMounted(load)
- +