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:
parent
46b5c4e7ad
commit
862385dbe0
31
backend/migrations/Version20260609164206.php
Normal file
31
backend/migrations/Version20260609164206.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend/src/Controller/EmployeeContactLinksController.php
Normal file
94
backend/src/Controller/EmployeeContactLinksController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
backend/src/Controller/EmployeePhotoController.php
Normal file
120
backend/src/Controller/EmployeePhotoController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -79,6 +79,31 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
|
|||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
private ?string $bio = null;
|
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)]
|
#[ORM\Column(length: 20)]
|
||||||
private string $status = 'active';
|
private string $status = 'active';
|
||||||
|
|
||||||
@ -278,6 +303,94 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
|
|||||||
return $this;
|
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
|
public function getStatus(): string
|
||||||
{
|
{
|
||||||
return $this->status;
|
return $this->status;
|
||||||
|
|||||||
18
frontend/src/components/CountrySelect.vue
Normal file
18
frontend/src/components/CountrySelect.vue
Normal 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>
|
||||||
47
frontend/src/components/PhoneInput.vue
Normal file
47
frontend/src/components/PhoneInput.vue
Normal 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>
|
||||||
96
frontend/src/data/countries.ts
Normal file
96
frontend/src/data/countries.ts
Normal 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(/^\+/, '') }
|
||||||
|
}
|
||||||
@ -5,6 +5,8 @@ import { list, create, update, remove } from '@/api/resources'
|
|||||||
import client from '@/api/client'
|
import client from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
|
import PhoneInput from '@/components/PhoneInput.vue'
|
||||||
|
import CountrySelect from '@/components/CountrySelect.vue'
|
||||||
|
|
||||||
const GROUP_LABEL: Record<string, string> = {
|
const GROUP_LABEL: Record<string, string> = {
|
||||||
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
|
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,
|
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 {
|
interface Employee {
|
||||||
'@id': string
|
'@id': string; id: string
|
||||||
id: string
|
firstName: string; lastName: string; slug: string
|
||||||
firstName: string
|
salutation: string | null; title: string | null
|
||||||
lastName: string
|
position: string | null; department: string | null; bio: string | null
|
||||||
slug: string
|
email: string | null; emailPrivate: string | null
|
||||||
position: string | null
|
phone: string | null; mobile: string | null; fax: string | null
|
||||||
department: string | null
|
phoneCentral: string | null; website: string | null
|
||||||
email: string | null
|
status: string; shortCode: string | null; roles: string[]; login: boolean
|
||||||
phone: string | null
|
company: string; location: string | null; photoPath: string | null
|
||||||
mobile: string | null
|
addressBusiness: Address | null; addressPrivate: Address | null
|
||||||
status: string
|
contactLinks: string[] // IRIs; volle Objekte via GET .../contact-links
|
||||||
shortCode: string | null
|
|
||||||
roles: string[]
|
|
||||||
login: boolean
|
|
||||||
company: string
|
|
||||||
location: string | null
|
|
||||||
}
|
}
|
||||||
interface Company { '@id': string; name: string; slug: string }
|
interface Company { '@id': string; name: string; slug: string }
|
||||||
interface Location { '@id': string; name: string; company: 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])))
|
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c])))
|
||||||
function companyName(iri: string) { return companyMap.value[iri]?.name ?? '–' }
|
function companyName(iri: string) { return companyMap.value[iri]?.name ?? '–' }
|
||||||
|
|
||||||
// Portal-Ebene (Plattform-Admin): firmenübergreifend, nur einloggbare Mitarbeiter
|
|
||||||
const portalMode = computed(() => auth.isPlatformAdmin)
|
const portalMode = computed(() => auth.isPlatformAdmin)
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
@ -115,8 +130,9 @@ async function saveAccess(e: Employee) {
|
|||||||
accessForm.value.password = ''
|
accessForm.value.password = ''
|
||||||
await load()
|
await load()
|
||||||
editing.value = employees.value.find((x) => x.id === e.id) ?? null
|
editing.value = employees.value.find((x) => x.id === e.id) ?? null
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
alert(err?.response?.data?.error ?? err?.response?.data?.detail ?? 'Speichern fehlgeschlagen.')
|
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) {
|
async function removeLogin(e: Employee) {
|
||||||
@ -131,12 +147,33 @@ const showForm = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const editing = ref<Employee | null>(null)
|
const editing = ref<Employee | null>(null)
|
||||||
|
const activeTab = ref('allgemein')
|
||||||
|
|
||||||
|
const emptyAddress = (): Address => ({ street: '', houseNumber: '', addressLine2: '', zip: '', city: '', state: '', country: '' })
|
||||||
const blank = () => ({
|
const blank = () => ({
|
||||||
firstName: '', lastName: '', slug: '', position: '', department: '',
|
salutation: '', title: '', firstName: '', lastName: '', slug: '',
|
||||||
email: '', phone: '', mobile: '', company: '', location: '',
|
position: '', department: '', bio: '',
|
||||||
|
email: '', emailPrivate: '', phone: '', mobile: '', fax: '', phoneCentral: '', website: '',
|
||||||
|
addressBusiness: emptyAddress(), addressPrivate: emptyAddress(),
|
||||||
|
company: '', location: '',
|
||||||
})
|
})
|
||||||
const form = ref(blank())
|
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(() => {
|
const ownCompanyIri = computed(() => {
|
||||||
if (auth.user?.company) return `/api/companies/${auth.user.company.id}`
|
if (auth.user?.company) return `/api/companies/${auth.user.company.id}`
|
||||||
return companies.value[0]?.['@id'] ?? ''
|
return companies.value[0]?.['@id'] ?? ''
|
||||||
@ -145,46 +182,127 @@ const availableLocations = computed(() =>
|
|||||||
locations.value.filter((l) => l.company === (form.value.company || ownCompanyIri.value)),
|
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() {
|
function openCreate() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
form.value = blank()
|
form.value = blank()
|
||||||
form.value.company = ownCompanyIri.value
|
form.value.company = ownCompanyIri.value
|
||||||
|
resetSocialPhoto()
|
||||||
|
activeTab.value = 'allgemein'
|
||||||
error.value = ''
|
error.value = ''
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
}
|
}
|
||||||
function openEdit(e: Employee) {
|
async function openEdit(e: Employee) {
|
||||||
editing.value = e
|
editing.value = e
|
||||||
form.value = {
|
form.value = {
|
||||||
|
salutation: e.salutation ?? '', title: e.title ?? '',
|
||||||
firstName: e.firstName, lastName: e.lastName, slug: e.slug,
|
firstName: e.firstName, lastName: e.lastName, slug: e.slug,
|
||||||
position: e.position ?? '', department: e.department ?? '',
|
position: e.position ?? '', department: e.department ?? '', bio: e.bio ?? '',
|
||||||
email: e.email ?? '', phone: e.phone ?? '', mobile: e.mobile ?? '',
|
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 ?? '',
|
company: e.company, location: e.location ?? '',
|
||||||
}
|
}
|
||||||
|
resetSocialPhoto()
|
||||||
accessForm.value = { group: groupOf(e), password: '' }
|
accessForm.value = { group: groupOf(e), password: '' }
|
||||||
|
activeTab.value = 'allgemein'
|
||||||
error.value = ''
|
error.value = ''
|
||||||
showForm.value = true
|
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() {
|
async function submit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
|
salutation: form.value.salutation || null,
|
||||||
|
title: form.value.title || null,
|
||||||
firstName: form.value.firstName,
|
firstName: form.value.firstName,
|
||||||
lastName: form.value.lastName,
|
lastName: form.value.lastName,
|
||||||
slug: form.value.slug || slugify(`${form.value.firstName}-${form.value.lastName}`),
|
slug: form.value.slug || slugify(`${form.value.firstName}-${form.value.lastName}`),
|
||||||
position: form.value.position || null,
|
position: form.value.position || null,
|
||||||
department: form.value.department || null,
|
department: form.value.department || null,
|
||||||
|
bio: form.value.bio || null,
|
||||||
email: form.value.email || null,
|
email: form.value.email || null,
|
||||||
|
emailPrivate: form.value.emailPrivate || null,
|
||||||
phone: form.value.phone || null,
|
phone: form.value.phone || null,
|
||||||
mobile: form.value.mobile || 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,
|
location: form.value.location || null,
|
||||||
|
addressBusiness: cleanAddress(form.value.addressBusiness),
|
||||||
|
addressPrivate: cleanAddress(form.value.addressPrivate),
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
let saved: Employee
|
||||||
if (editing.value) {
|
if (editing.value) {
|
||||||
await update(editing.value['@id'], payload)
|
saved = await update<Employee>(editing.value['@id'], payload)
|
||||||
} else {
|
} else {
|
||||||
payload.company = form.value.company || ownCompanyIri.value
|
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
|
showForm.value = false
|
||||||
await load()
|
await load()
|
||||||
@ -205,6 +323,14 @@ function copyShort(e: Employee) {
|
|||||||
navigator.clipboard?.writeText(`${PUBLIC_BASE}/t/${e.shortCode}`)
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -233,7 +359,10 @@ onMounted(load)
|
|||||||
<tr v-for="e in filtered" :key="e.id">
|
<tr v-for="e in filtered" :key="e.id">
|
||||||
<td>
|
<td>
|
||||||
<div class="who">
|
<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>
|
<div>
|
||||||
<strong>{{ e.firstName }} {{ e.lastName }}</strong>
|
<strong>{{ e.firstName }} {{ e.lastName }}</strong>
|
||||||
<div class="muted small">{{ e.email }}</div>
|
<div class="muted small">{{ e.email }}</div>
|
||||||
@ -258,27 +387,122 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal v-if="showForm" :title="editing ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter hinzufügen'" @close="showForm = false">
|
<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">
|
<form @submit.prevent="submit">
|
||||||
<div class="grid2">
|
<!-- Allgemein -->
|
||||||
<div class="field"><label>Vorname</label><input class="input" v-model="form.firstName" required /></div>
|
<div v-show="activeTab === 'allgemein'" class="panel">
|
||||||
<div class="field"><label>Nachname</label><input class="input" v-model="form.lastName" required /></div>
|
<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="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>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>
|
</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">
|
<!-- Kontakt -->
|
||||||
<div class="field"><label>Position</label><input class="input" v-model="form.position" /></div>
|
<div v-show="activeTab === 'kontakt'" class="panel">
|
||||||
<div class="field"><label>Abteilung</label><input class="input" v-model="form.department" /></div>
|
<div class="grid2">
|
||||||
|
<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>
|
</div>
|
||||||
<div class="field"><label>E-Mail</label><input class="input" v-model="form.email" type="email" /></div>
|
|
||||||
<div class="grid2">
|
<!-- Adresse -->
|
||||||
<div class="field"><label>Telefon</label><input class="input" v-model="form.phone" /></div>
|
<div v-show="activeTab === 'adresse'" class="panel">
|
||||||
<div class="field"><label>Mobil</label><input class="input" v-model="form.mobile" /></div>
|
<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"><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>
|
</div>
|
||||||
<div class="grid2">
|
|
||||||
<div class="field" v-if="!editing && (auth.isResellerAdmin || auth.isPlatformAdmin)">
|
<!-- Social -->
|
||||||
<label>Firma</label>
|
<div v-show="activeTab === 'social'" class="panel">
|
||||||
<select class="input" v-model="form.company">
|
<div v-for="n in NETWORKS" :key="n.type" class="field">
|
||||||
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
|
<label>{{ n.label }}</label>
|
||||||
</select>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Standort</label>
|
<label>Standort</label>
|
||||||
@ -287,34 +511,36 @@ onMounted(load)
|
|||||||
<option v-for="l in availableLocations" :key="l['@id']" :value="l['@id']">{{ l.name }}</option>
|
<option v-for="l in availableLocations" :key="l['@id']" :value="l['@id']">{{ l.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
|
||||||
<code>{{ PUBLIC_BASE }}/t/{{ editing.shortCode }}</code>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="copyShort(editing)">Kopieren</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="editing && canManageUsers" class="login-box">
|
<div v-if="editing && editing.shortCode" class="nfc">
|
||||||
<div class="nfc__label">Rechtegruppe & Login</div>
|
<div class="nfc__label">NFC / QR-Link (stabil – auf Tags schreiben)</div>
|
||||||
<div class="grid2">
|
<div class="nfc__row">
|
||||||
<div class="field">
|
<code>{{ PUBLIC_BASE }}/t/{{ editing.shortCode }}</code>
|
||||||
<label>Rechtegruppe</label>
|
<button type="button" class="btn btn-ghost btn-sm" @click="copyShort(editing)">Kopieren</button>
|
||||||
<select class="input" v-model="accessForm.group">
|
|
||||||
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>{{ editing.login ? 'Neues Passwort (optional)' : 'Passwort (für Login)' }}</label>
|
|
||||||
<input class="input" type="password" v-model="accessForm.password" minlength="6" placeholder="leer = kein Login" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="access-actions">
|
|
||||||
<button type="button" class="btn btn-soft btn-sm" @click="saveAccess(editing)">Rechtegruppe übernehmen</button>
|
<div v-if="editing && canManageUsers" class="login-box">
|
||||||
<button v-if="editing.login" type="button" class="btn btn-ghost btn-sm" @click="removeLogin(editing)">Login entziehen</button>
|
<div class="nfc__label">Rechtegruppe & Login</div>
|
||||||
<span class="muted small">Aktuell: {{ GROUP_LABEL[groupOf(editing)] }} · Login {{ editing.login ? 'aktiv' : 'inaktiv' }}</span>
|
<div class="grid2">
|
||||||
|
<div class="field">
|
||||||
|
<label>Rechtegruppe</label>
|
||||||
|
<select class="input" v-model="accessForm.group">
|
||||||
|
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ editing.login ? 'Neues Passwort (optional)' : 'Passwort (für Login)' }}</label>
|
||||||
|
<input class="input" type="password" v-model="accessForm.password" minlength="6" placeholder="leer = kein Login" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="access-actions">
|
||||||
|
<button type="button" class="btn btn-soft btn-sm" @click="saveAccess(editing)">Rechtegruppe übernehmen</button>
|
||||||
|
<button v-if="editing.login" type="button" class="btn btn-ghost btn-sm" @click="removeLogin(editing)">Login entziehen</button>
|
||||||
|
<span class="muted small">Aktuell: {{ GROUP_LABEL[groupOf(editing)] }} · Login {{ editing.login ? 'aktiv' : 'inaktiv' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else-if="!editing" class="muted small">Login & Rechtegruppe nach dem Anlegen vergeben.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@ -337,19 +563,39 @@ onMounted(load)
|
|||||||
.tbl td { padding: .8rem 1.2rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
|
.tbl td { padding: .8rem 1.2rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
|
||||||
.tbl tr:last-child td { border-bottom: none; }
|
.tbl tr:last-child td { border-bottom: none; }
|
||||||
.who { display: flex; align-items: center; gap: .8rem; }
|
.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; }
|
.small { font-size: .8rem; }
|
||||||
.right { text-align: right; white-space: nowrap; }
|
.right { text-align: right; white-space: nowrap; }
|
||||||
.right .btn { margin-left: .3rem; }
|
.right .btn { margin-left: .3rem; }
|
||||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
|
.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; }
|
.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__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 { display: flex; align-items: center; justify-content: space-between; gap: .6rem; }
|
||||||
.nfc__row code { font-size: .82rem; word-break: break-all; }
|
.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; }
|
.login-box .grid2 { margin-bottom: .4rem; }
|
||||||
.access-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
.access-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||||||
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
|
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user