From 862385dbe04166d9234988b8673f26037235ef9f Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Tue, 9 Jun 2026 18:56:22 +0200 Subject: [PATCH] Mitarbeiter: erweitertes Datenmodell + Tab-Formular MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/Version20260609164206.php | 31 ++ .../EmployeeContactLinksController.php | 94 +++++ .../Controller/EmployeePhotoController.php | 120 ++++++ backend/src/Entity/Employee.php | 113 +++++ frontend/src/components/CountrySelect.vue | 18 + frontend/src/components/PhoneInput.vue | 47 +++ frontend/src/data/countries.ts | 96 +++++ frontend/src/views/EmployeesView.vue | 390 ++++++++++++++---- 8 files changed, 837 insertions(+), 72 deletions(-) create mode 100644 backend/migrations/Version20260609164206.php create mode 100644 backend/src/Controller/EmployeeContactLinksController.php create mode 100644 backend/src/Controller/EmployeePhotoController.php create mode 100644 frontend/src/components/CountrySelect.vue create mode 100644 frontend/src/components/PhoneInput.vue create mode 100644 frontend/src/data/countries.ts diff --git a/backend/migrations/Version20260609164206.php b/backend/migrations/Version20260609164206.php new file mode 100644 index 0000000..68164b5 --- /dev/null +++ b/backend/migrations/Version20260609164206.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/backend/src/Controller/EmployeeContactLinksController.php b/backend/src/Controller/EmployeeContactLinksController.php new file mode 100644 index 0000000..3308ce8 --- /dev/null +++ b/backend/src/Controller/EmployeeContactLinksController.php @@ -0,0 +1,94 @@ +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; + } +} diff --git a/backend/src/Controller/EmployeePhotoController.php b/backend/src/Controller/EmployeePhotoController.php new file mode 100644 index 0000000..35a1d61 --- /dev/null +++ b/backend/src/Controller/EmployeePhotoController.php @@ -0,0 +1,120 @@ +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; + } +} diff --git a/backend/src/Entity/Employee.php b/backend/src/Entity/Employee.php index 4bf374c..87c83e4 100644 --- a/backend/src/Entity/Employee.php +++ b/backend/src/Entity/Employee.php @@ -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|null */ + public function getAddressBusiness(): ?array + { + return $this->addressBusiness; + } + + /** @param array|null $addressBusiness */ + public function setAddressBusiness(?array $addressBusiness): self + { + $this->addressBusiness = $addressBusiness; + + return $this; + } + + /** @return array|null */ + public function getAddressPrivate(): ?array + { + return $this->addressPrivate; + } + + /** @param array|null $addressPrivate */ + public function setAddressPrivate(?array $addressPrivate): self + { + $this->addressPrivate = $addressPrivate; + + return $this; + } + public function getStatus(): string { return $this->status; diff --git a/frontend/src/components/CountrySelect.vue b/frontend/src/components/CountrySelect.vue new file mode 100644 index 0000000..fa1952b --- /dev/null +++ b/frontend/src/components/CountrySelect.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/PhoneInput.vue b/frontend/src/components/PhoneInput.vue new file mode 100644 index 0000000..897bda9 --- /dev/null +++ b/frontend/src/components/PhoneInput.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/data/countries.ts b/frontend/src/data/countries.ts new file mode 100644 index 0000000..be8a6af --- /dev/null +++ b/frontend/src/data/countries.ts @@ -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(/^\+/, '') } +} diff --git a/frontend/src/views/EmployeesView.vue b/frontend/src/views/EmployeesView.vue index aae4bc8..583a8a0 100644 --- a/frontend/src/views/EmployeesView.vue +++ b/frontend/src/views/EmployeesView.vue @@ -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 = { platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin', @@ -18,23 +20,37 @@ const GROUP_LEVEL: Record = { 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(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>({}) +const customLinks = ref<{ label: string; url: string }[]>([]) + +// Foto +const photoFile = ref(null) +const photoPreview = ref(null) +const photoRemoved = ref(false) +const photoInput = ref(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)[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 = { + 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(editing.value['@id'], payload) } else { payload.company = form.value.company || ownCompanyIri.value - await create('employees', payload) + saved = await create('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) @@ -233,7 +359,10 @@ onMounted(load)
- {{ initials(e).toUpperCase() }} + + + +
{{ e.firstName }} {{ e.lastName }}
{{ e.email }}
@@ -258,27 +387,122 @@ onMounted(load)
+
+ +
+
-
-
-
+ +
+
+
+ + + Kein Foto +
+
+ + + +

PNG, JPG oder WebP.

+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+ + +
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+ + +
+
+
Geschäftsadresse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Privatadresse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- - + + +
+
+ + +
+
Weitere Links
+ + +
+ + +
+
+
+ + +
@@ -287,34 +511,36 @@ onMounted(load)
-
-
-
NFC / QR-Link (stabil – auf Tags schreiben)
-
- {{ PUBLIC_BASE }}/t/{{ editing.shortCode }} - -
-
-