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:
parent
862385dbe0
commit
fa321fb6a5
@ -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()
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user