Mitarbeiter: erweitertes Datenmodell + Tab-Formular

- Neue Felder: Anrede, akad. Titel, Privat-E-Mail, Fax, Zentrale, Website,
  Geschäfts-/Privatadresse (JSON), Über mich
- Foto-Upload (S3) + öffentliche Auslieferung /p/photo/{id}.jpg, Avatar in Liste
- Social-/Kontakt-Links: GET/PUT /api/employees/{id}/contact-links (Replace)
- Formular in Tabs: Allgemein / Kontakt / Adresse / Social / Zugang & NFC
- Telefonfelder mit Länder-Vorwahl + Emoji-Flagge (PhoneInput), Adress-Land
  per Flaggen-Auswahl (CountrySelect), countries.ts (Vorwahlen)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 18:56:22 +02:00
parent 46b5c4e7ad
commit 862385dbe0
8 changed files with 837 additions and 72 deletions

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260609164206 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE employee ADD salutation VARCHAR(20) DEFAULT NULL, ADD email_private VARCHAR(180) DEFAULT NULL, ADD fax VARCHAR(50) DEFAULT NULL, ADD phone_central VARCHAR(50) DEFAULT NULL, ADD website VARCHAR(255) DEFAULT NULL, ADD address_business JSON DEFAULT NULL, ADD address_private JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE employee DROP salutation, DROP email_private, DROP fax, DROP phone_central, DROP website, DROP address_business, DROP address_private');
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Controller;
use App\Entity\ContactLink;
use App\Entity\Employee;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Ersetzt die Social-/Kontakt-Links eines Mitarbeiters in einem Rutsch
* (das Mitarbeiter-Formular bearbeitet immer das ganze Set).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class EmployeeContactLinksController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/employees/{id}/contact-links', name: 'employee_links_get', methods: ['GET'])]
public function listLinks(string $id): JsonResponse
{
$employee = $this->scoped($id);
$out = [];
foreach ($employee->getContactLinks() as $link) {
$out[] = ['type' => $link->getType(), 'url' => $link->getUrl(), 'label' => $link->getLabel()];
}
return new JsonResponse(['links' => $out]);
}
#[Route('/api/employees/{id}/contact-links', name: 'employee_links_put', methods: ['PUT'])]
public function replace(string $id, Request $request): JsonResponse
{
$employee = $this->scoped($id);
foreach ($employee->getContactLinks()->toArray() as $link) {
$employee->removeContactLink($link);
}
$data = json_decode($request->getContent(), true) ?? [];
$links = \is_array($data['links'] ?? null) ? $data['links'] : [];
$pos = 0;
$out = [];
foreach ($links as $l) {
$url = trim((string) ($l['url'] ?? ''));
if ('' === $url) {
continue;
}
$type = substr(trim((string) ($l['type'] ?? 'custom')), 0, 40) ?: 'custom';
$label = trim((string) ($l['label'] ?? ''));
$link = (new ContactLink())
->setType($type)
->setUrl(substr($url, 0, 500))
->setLabel('' !== $label ? substr($label, 0, 120) : null)
->setPosition($pos++);
$employee->addContactLink($link);
$out[] = ['type' => $type, 'url' => $url, 'label' => '' !== $label ? $label : null];
}
$this->em->flush();
return new JsonResponse(['links' => $out]);
}
private function scoped(string $id): Employee
{
$employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $employee;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || true !== $employee->getReseller()?->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$employee->getCompany()->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $employee;
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Mitarbeiter-Foto: Upload/Löschen (geschützt, mandantengeprüft) und
* öffentliche Auslieferung für die Profilseite/Visitenkarte.
*/
final class EmployeePhotoController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
#[Route('/api/employees/{id}/photo', name: 'employee_photo_post', methods: ['POST'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function upload(string $id, Request $request): JsonResponse
{
$employee = $this->scoped($id);
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Keine Datei (Feld "file").');
}
$ext = strtolower((string) $file->getClientOriginalExtension());
if (!\in_array($ext, ['png', 'jpg', 'jpeg', 'webp'], true)) {
throw new BadRequestHttpException('Nur PNG, JPG oder WebP erlaubt.');
}
$old = $employee->getPhotoPath();
$key = sprintf('employee-photos/%s-%s.%s', $employee->getId()->toRfc4122(), bin2hex(random_bytes(4)), 'jpeg' === $ext ? 'jpg' : $ext);
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
if (null !== $old && $old !== $key && $this->cardAssets->fileExists($old)) {
$this->cardAssets->delete($old);
}
$employee->setPhotoPath($key);
$this->em->flush();
return new JsonResponse(['photoPath' => $key, 'photoUrl' => $this->photoUrl($employee)], 201);
}
#[Route('/api/employees/{id}/photo', name: 'employee_photo_delete', methods: ['DELETE'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function delete(string $id): JsonResponse
{
$employee = $this->scoped($id);
$key = $employee->getPhotoPath();
if (null !== $key && $this->cardAssets->fileExists($key)) {
$this->cardAssets->delete($key);
}
$employee->setPhotoPath(null);
$this->em->flush();
return new JsonResponse(['photoPath' => null, 'photoUrl' => null]);
}
/** Öffentliche Auslieferung (Profilseite/Visitenkarte). */
#[Route('/p/photo/{id}.jpg', name: 'employee_photo_public', methods: ['GET'])]
public function show(string $id): Response
{
$employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
$key = $employee?->getPhotoPath();
if (null === $key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Foto.');
}
$mime = str_ends_with($key, '.png') ? 'image/png' : (str_ends_with($key, '.webp') ? 'image/webp' : 'image/jpeg');
return new Response($this->cardAssets->read($key), 200, [
'Content-Type' => $mime,
'Cache-Control' => 'public, max-age=86400',
]);
}
private function photoUrl(Employee $employee): ?string
{
return null !== $employee->getPhotoPath()
? '/p/photo/'.$employee->getId().'.jpg?v='.substr(sha1($employee->getPhotoPath()), 0, 8)
: null;
}
private function scoped(string $id): Employee
{
$employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $employee;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || true !== $employee->getReseller()?->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$employee->getCompany()->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $employee;
}
}

View File

@ -79,6 +79,31 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
#[ORM\Column(type: 'text', nullable: true)]
private ?string $bio = null;
/** Anrede: Herr / Frau / Divers / null. */
#[ORM\Column(length: 20, nullable: true)]
private ?string $salutation = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $emailPrivate = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $fax = null;
/** Zentrale / Durchwahl. */
#[ORM\Column(length: 50, nullable: true)]
private ?string $phoneCentral = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $website = null;
/** Geschäftsadresse: {street,houseNumber,addressLine2,zip,city,state,country}. */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $addressBusiness = null;
/** Privatadresse (gleiche Struktur). */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $addressPrivate = null;
#[ORM\Column(length: 20)]
private string $status = 'active';
@ -278,6 +303,94 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
return $this;
}
public function getSalutation(): ?string
{
return $this->salutation;
}
public function setSalutation(?string $salutation): self
{
$this->salutation = $salutation;
return $this;
}
public function getEmailPrivate(): ?string
{
return $this->emailPrivate;
}
public function setEmailPrivate(?string $emailPrivate): self
{
$this->emailPrivate = $emailPrivate;
return $this;
}
public function getFax(): ?string
{
return $this->fax;
}
public function setFax(?string $fax): self
{
$this->fax = $fax;
return $this;
}
public function getPhoneCentral(): ?string
{
return $this->phoneCentral;
}
public function setPhoneCentral(?string $phoneCentral): self
{
$this->phoneCentral = $phoneCentral;
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): self
{
$this->website = $website;
return $this;
}
/** @return array<string, mixed>|null */
public function getAddressBusiness(): ?array
{
return $this->addressBusiness;
}
/** @param array<string, mixed>|null $addressBusiness */
public function setAddressBusiness(?array $addressBusiness): self
{
$this->addressBusiness = $addressBusiness;
return $this;
}
/** @return array<string, mixed>|null */
public function getAddressPrivate(): ?array
{
return $this->addressPrivate;
}
/** @param array<string, mixed>|null $addressPrivate */
public function setAddressPrivate(?array $addressPrivate): self
{
$this->addressPrivate = $addressPrivate;
return $this;
}
public function getStatus(): string
{
return $this->status;

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
// Länderauswahl (Emoji-Flagge + Name) für Adressen. v-model = ISO-Code.
import { COUNTRIES, flagEmoji } from '@/data/countries'
withDefaults(defineProps<{ modelValue?: string | null }>(), { modelValue: '' })
defineEmits<{ 'update:modelValue': [string] }>()
</script>
<template>
<select
class="input"
:value="modelValue ?? ''"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option value=""> Land wählen </option>
<option v-for="c in COUNTRIES" :key="c.code" :value="c.code">{{ flagEmoji(c.code) }} {{ c.name }}</option>
</select>
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
// Telefonfeld: Länder-Vorwahl per Auswahlfeld (Emoji-Flagge + Vorwahl) + Nummer.
// v-model speichert die volle internationale Nummer als String ("+49 151 ").
import { ref, watch } from 'vue'
import { COUNTRIES, dialOf, flagEmoji, splitPhone } from '@/data/countries'
const props = withDefaults(defineProps<{ modelValue?: string | null; placeholder?: string }>(), {
modelValue: '',
placeholder: '151 23456789',
})
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const code = ref('DE')
const number = ref('')
watch(
() => props.modelValue,
(v) => {
const p = splitPhone(v)
code.value = p.code
number.value = p.number
},
{ immediate: true },
)
function emitValue() {
const n = number.value.trim()
emit('update:modelValue', n ? `${dialOf(code.value)} ${n}` : '')
}
</script>
<template>
<div class="phone">
<select class="input dial" v-model="code" @change="emitValue" :title="'Ländervorwahl'">
<option v-for="c in COUNTRIES" :key="c.code" :value="c.code">
{{ flagEmoji(c.code) }} {{ c.dial }}
</option>
</select>
<input class="input num" type="tel" v-model="number" :placeholder="placeholder" @input="emitValue" />
</div>
</template>
<style scoped>
.phone { display: flex; gap: .5rem; }
.dial { flex: 0 0 7.5rem; }
.num { flex: 1; min-width: 0; }
</style>

View File

@ -0,0 +1,96 @@
// Länder mit ISO-Code, deutschem Namen und Telefon-Vorwahl.
// Die Flagge wird als Emoji aus dem ISO-Code abgeleitet (keine Asset-Datei).
export interface Country {
code: string // ISO 3166-1 alpha-2
name: string
dial: string
}
/** Emoji-Flagge aus dem ISO-Code (Regional Indicator Symbols). */
export function flagEmoji(code: string): string {
return code
.toUpperCase()
.replace(/[^A-Z]/g, '')
.replace(/./g, (c) => String.fromCodePoint(127397 + c.charCodeAt(0)))
}
// DACH zuerst, dann übriges Europa und wichtige Länder weltweit.
export const COUNTRIES: Country[] = [
{ code: 'DE', name: 'Deutschland', dial: '+49' },
{ code: 'AT', name: 'Österreich', dial: '+43' },
{ code: 'CH', name: 'Schweiz', dial: '+41' },
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
{ code: 'LU', name: 'Luxemburg', dial: '+352' },
{ code: 'BE', name: 'Belgien', dial: '+32' },
{ code: 'NL', name: 'Niederlande', dial: '+31' },
{ code: 'FR', name: 'Frankreich', dial: '+33' },
{ code: 'IT', name: 'Italien', dial: '+39' },
{ code: 'ES', name: 'Spanien', dial: '+34' },
{ code: 'PT', name: 'Portugal', dial: '+351' },
{ code: 'GB', name: 'Vereinigtes Königreich', dial: '+44' },
{ code: 'IE', name: 'Irland', dial: '+353' },
{ code: 'DK', name: 'Dänemark', dial: '+45' },
{ code: 'SE', name: 'Schweden', dial: '+46' },
{ code: 'NO', name: 'Norwegen', dial: '+47' },
{ code: 'FI', name: 'Finnland', dial: '+358' },
{ code: 'IS', name: 'Island', dial: '+354' },
{ code: 'PL', name: 'Polen', dial: '+48' },
{ code: 'CZ', name: 'Tschechien', dial: '+420' },
{ code: 'SK', name: 'Slowakei', dial: '+421' },
{ code: 'HU', name: 'Ungarn', dial: '+36' },
{ code: 'SI', name: 'Slowenien', dial: '+386' },
{ code: 'HR', name: 'Kroatien', dial: '+385' },
{ code: 'RO', name: 'Rumänien', dial: '+40' },
{ code: 'BG', name: 'Bulgarien', dial: '+359' },
{ code: 'GR', name: 'Griechenland', dial: '+30' },
{ code: 'EE', name: 'Estland', dial: '+372' },
{ code: 'LV', name: 'Lettland', dial: '+371' },
{ code: 'LT', name: 'Litauen', dial: '+370' },
{ code: 'RS', name: 'Serbien', dial: '+381' },
{ code: 'UA', name: 'Ukraine', dial: '+380' },
{ code: 'TR', name: 'Türkei', dial: '+90' },
{ code: 'RU', name: 'Russland', dial: '+7' },
{ code: 'US', name: 'USA', dial: '+1' },
{ code: 'CA', name: 'Kanada', dial: '+1' },
{ code: 'MX', name: 'Mexiko', dial: '+52' },
{ code: 'BR', name: 'Brasilien', dial: '+55' },
{ code: 'AR', name: 'Argentinien', dial: '+54' },
{ code: 'AU', name: 'Australien', dial: '+61' },
{ code: 'NZ', name: 'Neuseeland', dial: '+64' },
{ code: 'ZA', name: 'Südafrika', dial: '+27' },
{ code: 'AE', name: 'Ver. Arab. Emirate', dial: '+971' },
{ code: 'IL', name: 'Israel', dial: '+972' },
{ code: 'IN', name: 'Indien', dial: '+91' },
{ code: 'CN', name: 'China', dial: '+86' },
{ code: 'JP', name: 'Japan', dial: '+81' },
{ code: 'KR', name: 'Südkorea', dial: '+82' },
{ code: 'SG', name: 'Singapur', dial: '+65' },
{ code: 'HK', name: 'Hongkong', dial: '+852' },
]
const BY_CODE = new Map(COUNTRIES.map((c) => [c.code, c]))
export function countryByCode(code: string | null | undefined): Country | undefined {
return code ? BY_CODE.get(code.toUpperCase()) : undefined
}
/** Vorwahl zu einem ISO-Code (Default +49). */
export function dialOf(code: string): string {
return BY_CODE.get(code.toUpperCase())?.dial ?? '+49'
}
/**
* Zerlegt eine gespeicherte Nummer ("+49 151 …") in Länder-Code + Restnummer.
* Längste passende Vorwahl gewinnt; ohne Treffer Default DE.
*/
export function splitPhone(value: string | null | undefined): { code: string; number: string } {
const s = (value ?? '').trim()
if ('' === s) return { code: 'DE', number: '' }
let best: Country | undefined
for (const c of COUNTRIES) {
if (s.startsWith(c.dial) && (!best || c.dial.length > best.dial.length)) best = c
}
if (best) return { code: best.code, number: s.slice(best.dial.length).trim() }
return { code: 'DE', number: s.replace(/^\+/, '') }
}

View File

@ -5,6 +5,8 @@ import { list, create, update, remove } from '@/api/resources'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
import PhoneInput from '@/components/PhoneInput.vue'
import CountrySelect from '@/components/CountrySelect.vue'
const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
@ -18,23 +20,37 @@ const GROUP_LEVEL: Record<string, number> = {
platform_admin: 4, reseller_admin: 3, company_admin: 2, employee: 1, contact: 0,
}
// Bekannte Social-Netzwerke (weitere Links frei ergänzbar)
const NETWORKS = [
{ type: 'linkedin', label: 'LinkedIn', ph: 'https://linkedin.com/in/…' },
{ type: 'xing', label: 'XING', ph: 'https://xing.com/profile/…' },
{ type: 'instagram', label: 'Instagram', ph: 'https://instagram.com/…' },
{ type: 'facebook', label: 'Facebook', ph: 'https://facebook.com/…' },
{ type: 'twitter', label: 'X (Twitter)', ph: 'https://x.com/…' },
{ type: 'youtube', label: 'YouTube', ph: 'https://youtube.com/@…' },
{ type: 'tiktok', label: 'TikTok', ph: 'https://tiktok.com/@…' },
{ type: 'github', label: 'GitHub', ph: 'https://github.com/…' },
{ type: 'whatsapp', label: 'WhatsApp', ph: 'https://wa.me/49…' },
] as const
const KNOWN_TYPES = NETWORKS.map((n) => n.type) as readonly string[]
interface Address {
street?: string; houseNumber?: string; addressLine2?: string
zip?: string; city?: string; state?: string; country?: string
}
interface ContactLinkDto { type: string; url: string; label: string | null }
interface Employee {
'@id': string
id: string
firstName: string
lastName: string
slug: string
position: string | null
department: string | null
email: string | null
phone: string | null
mobile: string | null
status: string
shortCode: string | null
roles: string[]
login: boolean
company: string
location: string | null
'@id': string; id: string
firstName: string; lastName: string; slug: string
salutation: string | null; title: string | null
position: string | null; department: string | null; bio: string | null
email: string | null; emailPrivate: string | null
phone: string | null; mobile: string | null; fax: string | null
phoneCentral: string | null; website: string | null
status: string; shortCode: string | null; roles: string[]; login: boolean
company: string; location: string | null; photoPath: string | null
addressBusiness: Address | null; addressPrivate: Address | null
contactLinks: string[] // IRIs; volle Objekte via GET .../contact-links
}
interface Company { '@id': string; name: string; slug: string }
interface Location { '@id': string; name: string; company: string }
@ -69,7 +85,6 @@ async function workAs(e: Employee) {
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c])))
function companyName(iri: string) { return companyMap.value[iri]?.name ?? '' }
// Portal-Ebene (Plattform-Admin): firmenübergreifend, nur einloggbare Mitarbeiter
const portalMode = computed(() => auth.isPlatformAdmin)
const filtered = computed(() => {
@ -115,8 +130,9 @@ async function saveAccess(e: Employee) {
accessForm.value.password = ''
await load()
editing.value = employees.value.find((x) => x.id === e.id) ?? null
} catch (err: any) {
alert(err?.response?.data?.error ?? err?.response?.data?.detail ?? 'Speichern fehlgeschlagen.')
} catch (err: unknown) {
const ex = err as { response?: { data?: { error?: string; detail?: string } } }
alert(ex?.response?.data?.error ?? ex?.response?.data?.detail ?? 'Speichern fehlgeschlagen.')
}
}
async function removeLogin(e: Employee) {
@ -131,12 +147,33 @@ const showForm = ref(false)
const saving = ref(false)
const error = ref('')
const editing = ref<Employee | null>(null)
const activeTab = ref('allgemein')
const emptyAddress = (): Address => ({ street: '', houseNumber: '', addressLine2: '', zip: '', city: '', state: '', country: '' })
const blank = () => ({
firstName: '', lastName: '', slug: '', position: '', department: '',
email: '', phone: '', mobile: '', company: '', location: '',
salutation: '', title: '', firstName: '', lastName: '', slug: '',
position: '', department: '', bio: '',
email: '', emailPrivate: '', phone: '', mobile: '', fax: '', phoneCentral: '', website: '',
addressBusiness: emptyAddress(), addressPrivate: emptyAddress(),
company: '', location: '',
})
const form = ref(blank())
// Social
const socialKnown = ref<Record<string, string>>({})
const customLinks = ref<{ label: string; url: string }[]>([])
// Foto
const photoFile = ref<File | null>(null)
const photoPreview = ref<string | null>(null)
const photoRemoved = ref(false)
const photoInput = ref<HTMLInputElement | null>(null)
const currentPhotoUrl = computed(() =>
editing.value?.photoPath && !photoRemoved.value
? `${PUBLIC_BASE}/p/photo/${editing.value.id}.jpg?v=${Date.now()}`
: null,
)
const ownCompanyIri = computed(() => {
if (auth.user?.company) return `/api/companies/${auth.user.company.id}`
return companies.value[0]?.['@id'] ?? ''
@ -145,46 +182,127 @@ const availableLocations = computed(() =>
locations.value.filter((l) => l.company === (form.value.company || ownCompanyIri.value)),
)
function resetSocialPhoto() {
socialKnown.value = Object.fromEntries(KNOWN_TYPES.map((t) => [t, '']))
customLinks.value = []
photoFile.value = null
photoPreview.value = null
photoRemoved.value = false
}
function openCreate() {
editing.value = null
form.value = blank()
form.value.company = ownCompanyIri.value
resetSocialPhoto()
activeTab.value = 'allgemein'
error.value = ''
showForm.value = true
}
function openEdit(e: Employee) {
async function openEdit(e: Employee) {
editing.value = e
form.value = {
salutation: e.salutation ?? '', title: e.title ?? '',
firstName: e.firstName, lastName: e.lastName, slug: e.slug,
position: e.position ?? '', department: e.department ?? '',
email: e.email ?? '', phone: e.phone ?? '', mobile: e.mobile ?? '',
position: e.position ?? '', department: e.department ?? '', bio: e.bio ?? '',
email: e.email ?? '', emailPrivate: e.emailPrivate ?? '',
phone: e.phone ?? '', mobile: e.mobile ?? '', fax: e.fax ?? '',
phoneCentral: e.phoneCentral ?? '', website: e.website ?? '',
addressBusiness: { ...emptyAddress(), ...(e.addressBusiness ?? {}) },
addressPrivate: { ...emptyAddress(), ...(e.addressPrivate ?? {}) },
company: e.company, location: e.location ?? '',
}
resetSocialPhoto()
accessForm.value = { group: groupOf(e), password: '' }
activeTab.value = 'allgemein'
error.value = ''
showForm.value = true
try {
const { data } = await client.get<{ links: ContactLinkDto[] }>(`/employees/${e.id}/contact-links`)
for (const l of data.links ?? []) {
if (KNOWN_TYPES.includes(l.type)) socialKnown.value[l.type] = l.url
else customLinks.value.push({ label: l.label ?? '', url: l.url })
}
} catch { /* keine Links egal */ }
}
function cleanAddress(a: Address): Address | null {
const out: Address = {}
let any = false
for (const [k, v] of Object.entries(a)) {
const t = (v ?? '').toString().trim()
if (t) { (out as Record<string, string>)[k] = t; any = true }
}
return any ? out : null
}
function onPhotoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
photoFile.value = file
photoPreview.value = URL.createObjectURL(file)
photoRemoved.value = false
}
function removePhoto() {
photoFile.value = null
photoPreview.value = null
photoRemoved.value = true
if (photoInput.value) photoInput.value.value = ''
}
function buildLinks(): ContactLinkDto[] {
const links: ContactLinkDto[] = []
for (const t of KNOWN_TYPES) {
const url = (socialKnown.value[t] ?? '').trim()
if (url) links.push({ type: t, url, label: null })
}
for (const c of customLinks.value) {
if (c.url.trim()) links.push({ type: 'link', url: c.url.trim(), label: c.label.trim() || null })
}
return links
}
async function submit() {
error.value = ''
saving.value = true
const payload: Record<string, unknown> = {
salutation: form.value.salutation || null,
title: form.value.title || null,
firstName: form.value.firstName,
lastName: form.value.lastName,
slug: form.value.slug || slugify(`${form.value.firstName}-${form.value.lastName}`),
position: form.value.position || null,
department: form.value.department || null,
bio: form.value.bio || null,
email: form.value.email || null,
emailPrivate: form.value.emailPrivate || null,
phone: form.value.phone || null,
mobile: form.value.mobile || null,
fax: form.value.fax || null,
phoneCentral: form.value.phoneCentral || null,
website: form.value.website || null,
location: form.value.location || null,
addressBusiness: cleanAddress(form.value.addressBusiness),
addressPrivate: cleanAddress(form.value.addressPrivate),
}
try {
let saved: Employee
if (editing.value) {
await update(editing.value['@id'], payload)
saved = await update<Employee>(editing.value['@id'], payload)
} else {
payload.company = form.value.company || ownCompanyIri.value
await create('employees', payload)
saved = await create<Employee>('employees', payload)
}
const id = saved.id ?? editing.value?.id
if (id) {
await client.put(`/employees/${id}/contact-links`, { links: buildLinks() })
if (photoFile.value) {
const fd = new FormData()
fd.append('file', photoFile.value)
await client.post(`/employees/${id}/photo`, fd)
} else if (photoRemoved.value && editing.value?.photoPath) {
await client.delete(`/employees/${id}/photo`)
}
}
showForm.value = false
await load()
@ -205,6 +323,14 @@ function copyShort(e: Employee) {
navigator.clipboard?.writeText(`${PUBLIC_BASE}/t/${e.shortCode}`)
}
const TABS = [
{ key: 'allgemein', label: 'Allgemein' },
{ key: 'kontakt', label: 'Kontakt' },
{ key: 'adresse', label: 'Adresse' },
{ key: 'social', label: 'Social' },
{ key: 'zugang', label: 'Zugang & NFC' },
]
onMounted(load)
</script>
@ -233,7 +359,10 @@ onMounted(load)
<tr v-for="e in filtered" :key="e.id">
<td>
<div class="who">
<span class="avatar">{{ initials(e).toUpperCase() }}</span>
<span class="avatar">
<img v-if="e.photoPath" :src="`${PUBLIC_BASE}/p/photo/${e.id}.jpg`" alt="" />
<template v-else>{{ initials(e).toUpperCase() }}</template>
</span>
<div>
<strong>{{ e.firstName }} {{ e.lastName }}</strong>
<div class="muted small">{{ e.email }}</div>
@ -258,28 +387,123 @@ onMounted(load)
</div>
<Modal v-if="showForm" :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>
<form @submit.prevent="submit">
<!-- Allgemein -->
<div v-show="activeTab === 'allgemein'" class="panel">
<div class="photo-row">
<div class="photo-preview">
<img v-if="photoPreview" :src="photoPreview" alt="" />
<img v-else-if="currentPhotoUrl" :src="currentPhotoUrl" alt="" />
<span v-else class="muted small">Kein Foto</span>
</div>
<div class="photo-actions">
<input ref="photoInput" type="file" accept="image/png,image/jpeg,image/webp" hidden @change="onPhotoChange" />
<button type="button" class="btn btn-soft btn-sm" @click="photoInput?.click()">Foto wählen</button>
<button v-if="photoPreview || currentPhotoUrl" type="button" class="btn btn-ghost btn-sm" @click="removePhoto">Entfernen</button>
<p class="muted small">PNG, JPG oder WebP.</p>
</div>
</div>
<div class="grid2">
<div class="field">
<label>Anrede</label>
<select class="input" v-model="form.salutation">
<option value=""></option><option>Herr</option><option>Frau</option><option>Divers</option>
</select>
</div>
<div class="field"><label>Titel</label><input class="input" v-model="form.title" placeholder="Dr., Prof. …" /></div>
</div>
<div class="grid2">
<div class="field"><label>Vorname</label><input class="input" v-model="form.firstName" required /></div>
<div class="field"><label>Nachname</label><input class="input" v-model="form.lastName" required /></div>
</div>
<div class="field"><label>Slug (URL)</label><input class="input" v-model="form.slug" :placeholder="slugify(form.firstName + '-' + form.lastName)" /></div>
<div class="grid2">
<div class="field"><label>Position</label><input class="input" v-model="form.position" /></div>
<div class="field"><label>Abteilung</label><input class="input" v-model="form.department" /></div>
</div>
<div class="field"><label>E-Mail</label><input class="input" v-model="form.email" type="email" /></div>
<div class="field"><label>Slug (URL)</label><input class="input" v-model="form.slug" :placeholder="slugify(form.firstName + '-' + form.lastName)" /></div>
<div class="field"><label>Über mich</label><textarea class="input" rows="3" v-model="form.bio"></textarea></div>
</div>
<!-- Kontakt -->
<div v-show="activeTab === 'kontakt'" class="panel">
<div class="grid2">
<div class="field"><label>Telefon</label><input class="input" v-model="form.phone" /></div>
<div class="field"><label>Mobil</label><input class="input" v-model="form.mobile" /></div>
<div class="field"><label>E-Mail (geschäftlich)</label><input class="input" type="email" v-model="form.email" /></div>
<div class="field"><label>E-Mail (privat)</label><input class="input" type="email" v-model="form.emailPrivate" /></div>
</div>
<div class="field"><label>Telefon</label><PhoneInput v-model="form.phone" /></div>
<div class="field"><label>Mobil</label><PhoneInput v-model="form.mobile" placeholder="151 23456789" /></div>
<div class="grid2">
<div class="field"><label>Fax</label><PhoneInput v-model="form.fax" /></div>
<div class="field"><label>Zentrale / Durchwahl</label><PhoneInput v-model="form.phoneCentral" /></div>
</div>
<div class="field"><label>Website</label><input class="input" v-model="form.website" placeholder="https://…" /></div>
</div>
<!-- Adresse -->
<div v-show="activeTab === 'adresse'" class="panel">
<div class="addr-block">
<div class="nfc__label">Geschäftsadresse</div>
<div class="grid-addr">
<div class="field s3"><label>Straße</label><input class="input" v-model="form.addressBusiness.street" /></div>
<div class="field"><label>Nr.</label><input class="input" v-model="form.addressBusiness.houseNumber" /></div>
</div>
<div class="field"><label>Adresszusatz</label><input class="input" v-model="form.addressBusiness.addressLine2" /></div>
<div class="grid-addr">
<div class="field"><label>PLZ</label><input class="input" v-model="form.addressBusiness.zip" /></div>
<div class="field s3"><label>Ort</label><input class="input" v-model="form.addressBusiness.city" /></div>
</div>
<div class="grid2">
<div class="field" v-if="!editing && (auth.isResellerAdmin || auth.isPlatformAdmin)">
<div class="field"><label>Bundesland / Region</label><input class="input" v-model="form.addressBusiness.state" /></div>
<div class="field"><label>Land</label><CountrySelect v-model="form.addressBusiness.country" /></div>
</div>
</div>
<div class="addr-block">
<div class="nfc__label">Privatadresse</div>
<div class="grid-addr">
<div class="field s3"><label>Straße</label><input class="input" v-model="form.addressPrivate.street" /></div>
<div class="field"><label>Nr.</label><input class="input" v-model="form.addressPrivate.houseNumber" /></div>
</div>
<div class="field"><label>Adresszusatz</label><input class="input" v-model="form.addressPrivate.addressLine2" /></div>
<div class="grid-addr">
<div class="field"><label>PLZ</label><input class="input" v-model="form.addressPrivate.zip" /></div>
<div class="field s3"><label>Ort</label><input class="input" v-model="form.addressPrivate.city" /></div>
</div>
<div class="grid2">
<div class="field"><label>Bundesland / Region</label><input class="input" v-model="form.addressPrivate.state" /></div>
<div class="field"><label>Land</label><CountrySelect v-model="form.addressPrivate.country" /></div>
</div>
</div>
</div>
<!-- Social -->
<div v-show="activeTab === 'social'" class="panel">
<div v-for="n in NETWORKS" :key="n.type" class="field">
<label>{{ n.label }}</label>
<input class="input" v-model="socialKnown[n.type]" :placeholder="n.ph" />
</div>
<div class="nfc__label" style="margin-top:1rem">Weitere Links</div>
<div v-for="(c, i) in customLinks" :key="i" class="custom-link">
<input class="input" v-model="c.label" placeholder="Bezeichnung" />
<input class="input" v-model="c.url" placeholder="https://…" />
<button type="button" class="btn btn-ghost btn-sm" @click="customLinks.splice(i, 1)"></button>
</div>
<button type="button" class="btn btn-soft btn-sm" @click="customLinks.push({ label: '', url: '' })">+ Link</button>
</div>
<!-- Zugang & NFC -->
<div v-show="activeTab === 'zugang'" class="panel">
<div class="grid2" v-if="!editing && (auth.isResellerAdmin || auth.isPlatformAdmin)">
<div class="field">
<label>Firma</label>
<select class="input" v-model="form.company">
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
</select>
</div>
</div>
<div class="field">
<label>Standort</label>
<select class="input" v-model="form.location">
@ -287,7 +511,7 @@ onMounted(load)
<option v-for="l in availableLocations" :key="l['@id']" :value="l['@id']">{{ l.name }}</option>
</select>
</div>
</div>
<div v-if="editing && editing.shortCode" class="nfc">
<div class="nfc__label">NFC / QR-Link (stabil auf Tags schreiben)</div>
<div class="nfc__row">
@ -316,6 +540,8 @@ onMounted(load)
<span class="muted small">Aktuell: {{ GROUP_LABEL[groupOf(editing)] }} · Login {{ editing.login ? 'aktiv' : 'inaktiv' }}</span>
</div>
</div>
<p v-else-if="!editing" class="muted small">Login & Rechtegruppe nach dem Anlegen vergeben.</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
@ -337,19 +563,39 @@ onMounted(load)
.tbl td { padding: .8rem 1.2rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.who { display: flex; align-items: center; gap: .8rem; }
.avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--psc-orange-soft); color: var(--psc-orange-dark); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: .85rem; }
.avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--psc-orange-soft); color: var(--psc-orange-dark); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: .85rem; overflow: hidden; flex-shrink: 0; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.small { font-size: .8rem; }
.right { text-align: right; white-space: nowrap; }
.right .btn { margin-left: .3rem; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.grid-addr { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
.grid-addr .s3 { grid-column: span 1; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.2rem; }
.error { color: var(--danger); font-size: .88rem; }
.nfc { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .4rem; }
.tabs { display: flex; gap: .3rem; border-bottom: 1px solid var(--line); margin-bottom: 1rem; flex-wrap: wrap; }
.tab { background: none; border: none; padding: .6rem .9rem; font-weight: 600; font-size: .9rem; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; font-family: var(--font); }
.tab:hover { color: var(--text); }
.tab.active { color: var(--psc-orange-dark); border-bottom-color: var(--psc-orange); }
.panel { min-height: 260px; }
.panel .field { margin-bottom: .8rem; }
.photo-row { display: flex; gap: 1.2rem; align-items: center; margin-bottom: 1rem; }
.photo-preview { width: 84px; height: 84px; border-radius: 50%; border: 1px dashed var(--line); background: #fafafa; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0; }
.photo-preview img { width: 100%; height: 100%; object-fit: cover; }
.photo-actions { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; }
.photo-actions .muted { margin: .1rem 0 0; }
.addr-block { margin-bottom: 1.2rem; }
.custom-link { display: grid; grid-template-columns: 1fr 1.6fr auto; gap: .5rem; margin-bottom: .5rem; align-items: center; }
.nfc { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .8rem; }
.nfc__label { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .4rem; }
.nfc__row { display: flex; align-items: center; justify-content: space-between; gap: .6rem; }
.nfc__row code { font-size: .82rem; word-break: break-all; }
.login-box { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .6rem; }
.login-box { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .8rem; }
.login-box .grid2 { margin-bottom: .4rem; }
.access-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }