Profil + vCard mit neuen Mitarbeiterfeldern; Modal breiter

- VCardBuilder: Titel (N-Präfix), Abteilung (ORG), Privat-E-Mail, Mobil,
  Zentrale, Fax, Website, Geschäfts-/Privatadresse, Foto (PHOTO URI)
- Profilseite: Foto über echte URL, Kontakt-Sektion (alle Nummern/Mails/Web),
  Adresse geschäftlich+privat, Branding via BrandingService (Reseller-Vererbung)
- Mitarbeiter-Formular nutzt das breite Modal (kein Overflow mehr)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 19:33:46 +02:00
parent 862385dbe0
commit fa321fb6a5
4 changed files with 158 additions and 21 deletions

View File

@ -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()

View File

@ -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<string, mixed>|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<string, mixed>|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
{

View File

@ -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('') %}
<div class="addr">
{% if label %}<span class="at">{{ label }}</span>{% endif %}
{% if s %}{{ s }} {{ a.houseNumber|default('') }}<br>{% endif %}
{% if l2 %}{{ l2 }}<br>{% endif %}
{% if zip or city %}{{ zip }} {{ city }}<br>{% endif %}
{% if st %}{{ st }}<br>{% endif %}
{% if co %}{{ co }}{% endif %}
</div>
{% 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 @@
</div>
<div class="vc__head">
{% if e.photoPath %}
<img class="vc__avatar" src="{{ e.photoPath }}" alt="{{ fullName }}">
<img class="vc__avatar" src="{{ path('employee_photo_public', {id: e.id}) }}" alt="{{ fullName }}">
{% else %}
<div class="vc__avatar">{{ (e.firstName|first ~ e.lastName|first)|upper }}</div>
{% endif %}
@ -116,6 +145,7 @@
{% if e.phone %}<a class="btn btn-soft" href="tel:{{ e.phone }}">Anrufen</a>{% endif %}
{% if e.mobile %}<a class="btn btn-soft" href="tel:{{ e.mobile }}">Mobil</a>{% endif %}
{% if e.email %}<a class="btn btn-soft" href="mailto:{{ e.email }}">E-Mail</a>{% endif %}
{% if e.website %}<a class="btn btn-soft" href="{{ e.website }}" target="_blank" rel="noopener">Web</a>{% endif %}
</div>
</div>
@ -126,6 +156,36 @@
</div>
{% 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 %}
<div class="vc__section">
<div class="vc__label">Kontakt</div>
<div class="contact">
{% if e.phone %}<a class="crow" href="tel:{{ e.phone }}"><span class="ck">Telefon</span><span class="cv">{{ e.phone }}</span></a>{% endif %}
{% if e.mobile %}<a class="crow" href="tel:{{ e.mobile }}"><span class="ck">Mobil</span><span class="cv">{{ e.mobile }}</span></a>{% endif %}
{% if e.phoneCentral %}<a class="crow" href="tel:{{ e.phoneCentral }}"><span class="ck">Zentrale</span><span class="cv">{{ e.phoneCentral }}</span></a>{% endif %}
{% if e.fax %}<div class="crow"><span class="ck">Fax</span><span class="cv">{{ e.fax }}</span></div>{% endif %}
{% if e.email %}<a class="crow" href="mailto:{{ e.email }}"><span class="ck">E-Mail</span><span class="cv">{{ e.email }}</span></a>{% endif %}
{% if e.emailPrivate %}<a class="crow" href="mailto:{{ e.emailPrivate }}"><span class="ck">E-Mail privat</span><span class="cv">{{ e.emailPrivate }}</span></a>{% endif %}
{% if e.website %}<a class="crow" href="{{ e.website }}" target="_blank" rel="noopener"><span class="ck">Website</span><span class="cv">{{ e.website }}</span></a>{% endif %}
</div>
</div>
{% endif %}
{% set ab = e.addressBusiness %}
{% set ap = e.addressPrivate %}
{% if ab or ap %}
<div class="vc__section">
<div class="vc__label">Adresse</div>
{% if ab %}
{{ _self.addressBlock(ab, ap ? 'Geschäftlich' : null) }}
{% endif %}
{% if ap %}
{{ _self.addressBlock(ap, 'Privat') }}
{% endif %}
</div>
{% endif %}
{% if e.contactLinks|length %}
<div class="vc__section">
<div class="vc__label">Links</div>

View File

@ -386,7 +386,7 @@ onMounted(load)
</table>
</div>
<Modal v-if="showForm" :title="editing ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter hinzufügen'" @close="showForm = false">
<Modal v-if="showForm" wide :title="editing ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter hinzufügen'" @close="showForm = false">
<div class="tabs">
<button v-for="t in TABS" :key="t.key" type="button" class="tab" :class="{ active: activeTab === t.key }" @click="activeTab = t.key">{{ t.label }}</button>
</div>