presseportale/resources/views/livewire/admin/users/edit.blade.php
Kevin Adametz 301bca3a90 auth: automatische Firmenrechte mit Herkunft und Entzug, Nachweis vor Sicherheitsaktionen (Audit Block 1, Deploy C)
F10: company_user.granted_via unterscheidet automatisch vergebene
Rechte (E-Mail-Sync) von manuellen und aus dem Altbestand. Beim Login
senkt der Sync automatische responsible-Rechte ohne aktuellen E-Mail-
Treffer auf member (E2) — Mitarbeiterwechsel nehmen den Schreibzugriff
mit, Lesezugriff bleibt. Das Admin-Formular markiert nur neue oder
geänderte Zeilen als manual, unveränderte behalten ihre Herkunft.

Bestandsprüfung: php artisan company-memberships:audit listet
responsible-Zuordnungen ohne E-Mail-Grundlage (Tabelle ab Go-Live, CSV
vollständig) und markiert mit --classify die erklärbaren als auto. Auf
Live nach der Migration einmal mit --classify laufen lassen; nicht
erklärbare Zeilen bleiben manual und damit geschützt.

F03 (E3): Zwei-Faktor deaktivieren, Wiederherstellungs-Codes erneuern
und die E-Mail-Änderung verlangen einen aktuellen App-Code oder einen
Wiederherstellungs-Code (der dabei verbraucht wird); ohne 2FA das
Passwort. Reine Magic-Link-Konten ohne Passwort (der Altbestand) können
die Adresse weiter ändern — die neue Adresse muss ihren Bestätigungslink
ohnehin einlösen. Fünf Fehlversuche pro Minute.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00

1179 lines
48 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
use \App\Livewire\Concerns\ProvidesUserFormRules;
#[Locked]
public int $id;
public string $name = '';
public string $agencyName = '';
public string $email = '';
public string $portal = '';
public string $registrationType = '';
public string $language = 'de';
/**
* @var array{
* salutation_key:?string,
* title:?string,
* first_name:?string,
* last_name:?string,
* phone:?string,
* address:?string,
* country_code:?string,
* birthdate:string,
* backlink_url:?string,
* show_stats:bool,
* validation_date:string,
* contract_date:string,
* tax_id_number:?string,
* tax_exempt:bool,
* tax_exempt_reason:?string,
* disable_footer_code:bool
* }
*/
public array $profile = [
'salutation_key' => null,
'title' => null,
'first_name' => null,
'last_name' => null,
'phone' => null,
'address' => null,
'country_code' => null,
'birthdate' => '',
'backlink_url' => null,
'show_stats' => false,
'validation_date' => '',
'contract_date' => '',
'tax_id_number' => null,
'tax_exempt' => false,
'tax_exempt_reason' => null,
'disable_footer_code' => false,
];
public bool $isActive = true;
public bool $isSuperAdmin = false;
/** @var array<int, string> */
public array $selectedRoles = [];
/** @var array<int, int> */
public array $linkedCompanyIds = [];
/** @var array<int, string> */
public array $companyRoles = [];
public string $notification = '';
public string $companyLookup = '';
public ?int $selectedLookupCompanyId = null;
/**
* @var array<int, array{
* id:int,
* company_id:int,
* company_name:string,
* first_name:?string,
* last_name:?string,
* email:?string,
* phone:?string,
* responsibility:?string
* }>
*/
public array $contactForms = [];
/** @var array<int, int> */
public array $linkedContactIds = [];
public string $contactLookup = '';
public ?int $selectedLookupContactId = null;
/**
* @var array{
* salutation_key:?string,
* title:?string,
* name:?string,
* address1:?string,
* address2:?string,
* postal_code:?string,
* city:?string,
* country_code:?string
* }
*/
public array $billing = [
'salutation_key' => null,
'title' => null,
'name' => null,
'address1' => null,
'address2' => null,
'postal_code' => null,
'city' => null,
'country_code' => null,
];
/**
* Verhindert, dass ein Nicht-Super-Admin (nur `users:manage`) ein
* mächtigeres Super-Admin-Konto bearbeitet — sonst ließe sich über einen
* E-Mail-/Rollen-/Aktiv-Wechsel das Konto übernehmen (analog zur
* Impersonation-Rangprüfung). Nur ein Super-Admin darf Super-Admins ändern.
*/
private function assertMayManageRank(User $user): void
{
abort_if($user->is_super_admin && ! auth()->user()->is_super_admin, 403);
}
public function mount(int $id): void
{
$this->authorize('users:manage');
$this->id = $id;
$user = User::query()
->with([
'roles:id,name',
'companies:id,name,slug',
'contacts:id,company_id,first_name,last_name,email,phone,responsibility',
'profile',
'billingAddress',
])
->findOrFail($id);
$this->assertMayManageRank($user);
$this->name = $user->name;
$this->agencyName = (string) ($user->agency_name ?? '');
$this->email = $user->email;
$this->portal = $user->portal?->value ?? Portal::Both->value;
$this->registrationType = $user->registration_type?->value ?? RegistrationType::ExistingLegacy->value;
$this->language = $user->language ?: 'de';
$this->isActive = (bool) $user->is_active;
$this->isSuperAdmin = (bool) $user->is_super_admin;
$this->selectedRoles = $user->roles->pluck('name')->values()->all();
$this->linkedCompanyIds = $user->companies->pluck('id')->map(fn ($id) => (int) $id)->values()->all();
$profile = $user->profile;
if ($profile) {
$this->profile = [
'salutation_key' => $profile->salutation_key,
'title' => $profile->title,
'first_name' => $profile->first_name,
'last_name' => $profile->last_name,
'phone' => $profile->phone,
'address' => $profile->address,
'country_code' => $profile->country_code,
'birthdate' => $profile->birthdate?->format('Y-m-d') ?? '',
'backlink_url' => $profile->backlink_url,
'show_stats' => (bool) $profile->show_stats,
'validation_date' => $profile->validation_date?->format('Y-m-d') ?? '',
'contract_date' => $profile->contract_date?->format('Y-m-d') ?? '',
'tax_id_number' => $profile->tax_id_number,
'tax_exempt' => (bool) $profile->tax_exempt,
'tax_exempt_reason' => $profile->tax_exempt_reason,
'disable_footer_code' => (bool) $profile->disable_footer_code,
];
}
foreach ($user->companies as $company) {
$this->companyRoles[(int) $company->id] = (string) ($company->pivot->role ?? 'member');
}
$this->linkedContactIds = $user->contacts
->pluck('id')
->map(fn ($contactId) => (int) $contactId)
->values()
->all();
if ($this->linkedContactIds === []) {
$this->linkedContactIds = Contact::query()
->whereIn('company_id', $this->linkedCompanyIds ?: [-1])
->orderBy('id')
->limit(40)
->pluck('id')
->map(fn ($contactId) => (int) $contactId)
->values()
->all();
}
$billingAddress = $user->billingAddress;
if ($billingAddress) {
$this->billing = [
'salutation_key' => $billingAddress->salutation_key,
'title' => $billingAddress->title,
'name' => $billingAddress->name,
'address1' => $billingAddress->address1,
'address2' => $billingAddress->address2,
'postal_code' => $billingAddress->postal_code,
'city' => $billingAddress->city,
'country_code' => $billingAddress->country_code,
];
}
$this->syncContactFormsToSelectedCompanies();
}
public function updatedLinkedCompanyIds(): void
{
$this->syncContactFormsToSelectedCompanies();
}
// Combobox-Auswahl löst sofort Zuordnung aus – kein separater Button nötig
public function updatedSelectedLookupCompanyId(): void
{
if (! $this->selectedLookupCompanyId) {
return;
}
$this->addLinkedCompany();
}
public function updatedSelectedLookupContactId(): void
{
if (! $this->selectedLookupContactId) {
return;
}
$this->addLinkedContact();
}
public function clearCompanyLookup(): void
{
$this->companyLookup = '';
$this->selectedLookupCompanyId = null;
}
public function clearContactLookup(): void
{
$this->contactLookup = '';
$this->selectedLookupContactId = null;
}
public function addLinkedCompany(): void
{
if (! $this->selectedLookupCompanyId) {
return;
}
if (! in_array($this->selectedLookupCompanyId, $this->linkedCompanyIds, true)) {
$this->linkedCompanyIds[] = $this->selectedLookupCompanyId;
$this->linkedCompanyIds = collect($this->linkedCompanyIds)
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
}
$this->companyRoles[$this->selectedLookupCompanyId] ??= 'member';
$this->persistUserLinks();
$this->companyLookup = '';
$this->selectedLookupCompanyId = null;
$this->syncContactFormsToSelectedCompanies();
$this->notification = __('Firma wurde direkt verknüpft.');
}
public function removeLinkedCompany(int $companyId): void
{
$this->linkedCompanyIds = collect($this->linkedCompanyIds)
->reject(fn ($id): bool => (int) $id === $companyId)
->map(fn ($id) => (int) $id)
->values()
->all();
unset($this->companyRoles[$companyId]);
$removedCompanyContactIds = Contact::query()
->where('company_id', $companyId)
->pluck('id')
->map(fn ($contactId) => (int) $contactId)
->all();
$this->linkedContactIds = collect($this->linkedContactIds)
->reject(fn ($contactId): bool => in_array((int) $contactId, $removedCompanyContactIds, true))
->map(fn ($contactId) => (int) $contactId)
->values()
->all();
$this->persistUserLinks();
$this->syncContactFormsToSelectedCompanies();
$this->notification = __('Firma wurde direkt entfernt.');
}
public function addLinkedContact(): void
{
if (! $this->selectedLookupContactId) {
return;
}
$contact = Contact::withoutGlobalScopes()
->with('company:id,name')
->find($this->selectedLookupContactId);
if (! $contact) {
return;
}
$contactCompanyId = (int) $contact->company_id;
if ($contactCompanyId > 0 && ! in_array($contactCompanyId, $this->linkedCompanyIds, true)) {
$this->linkedCompanyIds[] = $contactCompanyId;
$this->linkedCompanyIds = collect($this->linkedCompanyIds)
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
$this->companyRoles[$contactCompanyId] ??= 'member';
}
$alreadyLinked = collect($this->contactForms)
->contains(fn ($form): bool => (int) $form['id'] === (int) $contact->id);
if (! $alreadyLinked) {
$this->linkedContactIds[] = (int) $contact->id;
$this->linkedContactIds = collect($this->linkedContactIds)
->map(fn ($contactId) => (int) $contactId)
->unique()
->values()
->all();
$this->contactForms[] = [
'id' => (int) $contact->id,
'company_id' => (int) $contact->company_id,
'company_name' => (string) ($contact->company?->name ?? __('Unbekannte Firma')),
'first_name' => $contact->first_name,
'last_name' => $contact->last_name,
'email' => $contact->email,
'phone' => $contact->phone,
'responsibility' => $contact->responsibility,
];
}
$this->persistUserLinks();
$this->contactLookup = '';
$this->selectedLookupContactId = null;
$this->notification = __('Kontakt wurde direkt zugeordnet.');
}
public function removeLinkedContact(int $contactId): void
{
$this->linkedContactIds = collect($this->linkedContactIds)
->reject(fn ($linkedContactId): bool => (int) $linkedContactId === $contactId)
->map(fn ($linkedContactId) => (int) $linkedContactId)
->values()
->all();
$this->contactForms = collect($this->contactForms)
->reject(fn ($form): bool => (int) $form['id'] === $contactId)
->values()
->all();
$this->persistUserLinks();
$this->notification = __('Kontakt wurde direkt entfernt.');
}
public function save(): void
{
$this->authorize('users:manage');
$validated = $this->validate([
...$this->userIdentityRules($this->id),
'profile.salutation_key' => ['nullable', 'string', 'max:20'],
'profile.title' => ['nullable', 'string', 'max:80'],
'profile.first_name' => ['nullable', 'string', 'max:80'],
'profile.last_name' => ['nullable', 'string', 'max:80'],
'profile.phone' => ['nullable', 'string', 'max:40'],
'profile.address' => ['nullable', 'string', 'max:1000'],
'profile.country_code' => ['nullable', 'string', 'size:2'],
'profile.birthdate' => ['nullable', 'date'],
'profile.backlink_url' => ['nullable', 'string', 'max:255'],
'profile.show_stats' => ['boolean'],
'profile.validation_date' => ['nullable', 'date'],
'profile.contract_date' => ['nullable', 'date'],
'profile.tax_id_number' => ['nullable', 'string', 'max:255'],
'profile.tax_exempt' => ['boolean'],
'profile.tax_exempt_reason' => ['nullable', 'string', 'max:1000'],
'profile.disable_footer_code' => ['boolean'],
...$this->userRolesAndCompaniesRules(),
'contactForms' => ['array'],
'contactForms.*.id' => ['required', 'integer', Rule::exists('contacts', 'id')],
'contactForms.*.company_id' => ['required', 'integer', Rule::exists('companies', 'id')],
'contactForms.*.first_name' => ['nullable', 'string', 'max:80'],
'contactForms.*.last_name' => ['nullable', 'string', 'max:80'],
'contactForms.*.email' => ['nullable', 'email', 'max:255'],
'contactForms.*.phone' => ['nullable', 'string', 'max:40'],
'contactForms.*.responsibility' => ['nullable', 'string', 'max:255'],
...$this->userBillingRules(),
]);
$user = User::query()->findOrFail($this->id);
$this->assertMayManageRank($user);
$user->update([
'name' => $validated['name'],
'agency_name' => $validated['agencyName'] ?: null,
'email' => $validated['email'],
'portal' => $validated['portal'],
'registration_type' => $validated['registrationType'],
'language' => $validated['language'],
'is_active' => $this->isActive,
]);
// Super-Admin-Rang darf nur ein Super-Admin vergeben/entziehen. is_super_admin
// ist nicht mass-assignable und wird ausschließlich hier autorisiert gesetzt.
if (auth()->user()->is_super_admin) {
$user->forceFill(['is_super_admin' => $this->isSuperAdmin])->save();
}
$user->syncRoles($validated['selectedRoles']);
$profilePayload = [
'salutation_key' => $validated['profile']['salutation_key'] ?: null,
'title' => $validated['profile']['title'] ?: null,
'first_name' => $validated['profile']['first_name'] ?: null,
'last_name' => $validated['profile']['last_name'] ?: null,
'phone' => $validated['profile']['phone'] ?: null,
'address' => $validated['profile']['address'] ?: null,
'country_code' => filled($validated['profile']['country_code'])
? strtoupper((string) $validated['profile']['country_code'])
: null,
'birthdate' => filled($validated['profile']['birthdate']) ? $validated['profile']['birthdate'] : null,
'backlink_url' => $validated['profile']['backlink_url'] ?: null,
'show_stats' => (bool) $validated['profile']['show_stats'],
'validation_date' => filled($validated['profile']['validation_date']) ? $validated['profile']['validation_date'] : null,
'contract_date' => filled($validated['profile']['contract_date']) ? $validated['profile']['contract_date'] : null,
'tax_id_number' => $validated['profile']['tax_id_number'] ?: null,
'tax_exempt' => (bool) $validated['profile']['tax_exempt'],
'tax_exempt_reason' => $validated['profile']['tax_exempt_reason'] ?: null,
'disable_footer_code' => (bool) $validated['profile']['disable_footer_code'],
];
if ($this->profileHasInput($profilePayload)) {
$user->profile()->updateOrCreate([], $profilePayload);
} else {
$user->profile()->delete();
}
$user->syncCompaniesWithRoles($validated['linkedCompanyIds'], $validated['companyRoles']);
$linkedCompanyIds = $validated['linkedCompanyIds'];
$contactIds = collect($validated['contactForms'])
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$this->linkedContactIds = $contactIds;
$contactsById = Contact::query()
->whereIn('id', $contactIds)
->whereIn('company_id', $linkedCompanyIds ?: [-1])
->get()
->keyBy('id');
foreach ($validated['contactForms'] as $contactForm) {
/** @var Contact|null $contact */
$contact = $contactsById->get((int) $contactForm['id']);
if (! $contact) {
continue;
}
$contact->update([
'first_name' => $contactForm['first_name'] ?: null,
'last_name' => $contactForm['last_name'] ?: null,
'email' => $contactForm['email'] ?: null,
'phone' => $contactForm['phone'] ?: null,
'responsibility' => $contactForm['responsibility'] ?: null,
]);
}
$user->contacts()->sync($contactIds);
$billingAddress = $user->billingAddress()->first();
$billingPayload = [
'salutation_key' => $validated['billing']['salutation_key'] ?: null,
'title' => $validated['billing']['title'] ?: null,
'name' => $validated['billing']['name'] ?: null,
'address1' => $validated['billing']['address1'] ?: null,
'address2' => $validated['billing']['address2'] ?: null,
'postal_code' => $validated['billing']['postal_code'] ?: null,
'city' => $validated['billing']['city'] ?: null,
'country_code' => filled($validated['billing']['country_code'])
? strtoupper((string) $validated['billing']['country_code'])
: null,
];
if (! $this->billingHasInput()) {
if ($billingAddress) {
$billingAddress->delete();
}
} elseif ($billingAddress) {
// Payload direkt übernehmen – null-Werte leeren das Feld bewusst.
// Pflichtfelder (name, address1, postal_code, city, country_code) müssen befüllt bleiben.
if (
filled($billingPayload['name'])
&& filled($billingPayload['address1'])
&& filled($billingPayload['postal_code'])
&& filled($billingPayload['city'])
&& filled($billingPayload['country_code'])
) {
$billingAddress->update($billingPayload);
}
} elseif ($this->billingIsComplete()) {
$user->billingAddress()->create([
'salutation_key' => $billingPayload['salutation_key'],
'title' => $billingPayload['title'],
'name' => (string) $billingPayload['name'],
'address1' => (string) $billingPayload['address1'],
'address2' => $billingPayload['address2'],
'postal_code' => (string) $billingPayload['postal_code'],
'city' => (string) $billingPayload['city'],
'country_code' => (string) $billingPayload['country_code'],
]);
}
session()->flash('success', __('Benutzer, Profil, Rollen, Firmenzuordnung, Kontakte und Rechnungsadresse wurden gespeichert.'));
$this->redirect(route('admin.users.index'), navigate: true);
}
public function with(): array
{
$linkedCompanyIds = $this->linkedCompanyIds ?: [-1];
$currentContactIds = collect($this->linkedContactIds)
->map(fn ($id) => (int) $id)
->all();
$companyLookupResults = collect();
$companyLookupTerm = trim($this->companyLookup);
if (mb_strlen($companyLookupTerm) >= 1) {
$companyLookupResults = Company::withoutGlobalScopes()
->whereNotIn('id', $this->linkedCompanyIds ?: [-1])
->where(function ($query) use ($companyLookupTerm): void {
if ($this->supportsFullTextSearch($companyLookupTerm)) {
$query->whereFullText(['name', 'email', 'slug'], $companyLookupTerm);
return;
}
$query
->where('name', 'like', '%'.$companyLookupTerm.'%')
->orWhere('slug', 'like', '%'.$companyLookupTerm.'%')
->orWhere('email', 'like', '%'.$companyLookupTerm.'%');
})
->orderBy('name')
->limit(50)
->get(['id', 'name', 'slug', 'email']);
}
$contactLookupResults = collect();
$contactLookupTerm = trim($this->contactLookup);
if (mb_strlen($contactLookupTerm) >= 1) {
$contactLookupResults = Contact::withoutGlobalScopes()
->with('company:id,name')
->whereNotIn('id', $currentContactIds ?: [-1])
->where(function ($query) use ($contactLookupTerm): void {
if ($this->supportsFullTextSearch($contactLookupTerm)) {
$query->whereFullText(['first_name', 'last_name', 'email', 'responsibility'], $contactLookupTerm);
return;
}
$query
->where('first_name', 'like', '%'.$contactLookupTerm.'%')
->orWhere('last_name', 'like', '%'.$contactLookupTerm.'%')
->orWhere('email', 'like', '%'.$contactLookupTerm.'%');
})
->orderBy('last_name')
->orderBy('first_name')
->limit(50)
->get(['id', 'company_id', 'first_name', 'last_name', 'email']);
}
return [
'availableRoles' => $this->availableRoles(),
'linkedCompanies' => Company::withoutGlobalScopes()
->whereIn('id', $linkedCompanyIds)
->orderBy('name')
->get(['id', 'name', 'slug', 'email']),
'companyLookupResults' => $companyLookupResults,
'contactLookupResults' => $contactLookupResults,
'salutations' => config('salutations.items', []),
'countries' => config('countries.items', []),
'portalOptions' => Portal::cases(),
'registrationTypeOptions' => RegistrationType::cases(),
];
}
private function availableRoles(): Collection
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::RoleOptions, AdminPerformanceCache::OptionsTtl, fn () => Role::query()
->orderBy('name')
->get(['id', 'name']));
}
protected function syncContactFormsToSelectedCompanies(): void
{
$currentFormsById = collect($this->contactForms)->keyBy('id');
$allowedCompanyIds = $this->linkedCompanyIds ?: [-1];
$currentContactIds = $currentFormsById->keys()->map(fn ($id) => (int) $id)->all();
$contactsQuery = Contact::query()
->with('company:id,name')
->whereIn('company_id', $allowedCompanyIds)
->orderBy('company_id')
->orderBy('id');
if ($currentContactIds !== []) {
$contactsQuery->whereIn('id', $currentContactIds);
} else {
$contactsQuery->limit(40);
}
$contacts = $contactsQuery->get();
$this->contactForms = $contacts->map(function (Contact $contact) use ($currentFormsById): array {
$current = $currentFormsById->get($contact->id);
return [
'id' => (int) $contact->id,
'company_id' => (int) $contact->company_id,
'company_name' => (string) ($contact->company?->name ?? __('Unbekannte Firma')),
'first_name' => $current['first_name'] ?? $contact->first_name,
'last_name' => $current['last_name'] ?? $contact->last_name,
'email' => $current['email'] ?? $contact->email,
'phone' => $current['phone'] ?? $contact->phone,
'responsibility' => $current['responsibility'] ?? $contact->responsibility,
];
})->values()->all();
}
protected function billingHasInput(): bool
{
return collect($this->billing)
->filter(fn ($value): bool => filled($value))
->isNotEmpty();
}
/**
* @param array<string, mixed> $profilePayload
*/
protected function profileHasInput(array $profilePayload): bool
{
return collect($profilePayload)
->except(['show_stats', 'tax_exempt', 'disable_footer_code'])
->filter(fn ($value): bool => filled($value))
->isNotEmpty()
|| (bool) $profilePayload['show_stats']
|| (bool) $profilePayload['tax_exempt']
|| (bool) $profilePayload['disable_footer_code'];
}
protected function persistUserLinks(): void
{
$user = User::query()->find($this->id);
if (! $user) {
return;
}
$user->syncCompaniesWithRoles($this->linkedCompanyIds, $this->companyRoles);
$user->contacts()->sync($this->linkedContactIds);
}
protected function billingIsComplete(): bool
{
return filled($this->billing['name'])
&& filled($this->billing['address1'])
&& filled($this->billing['postal_code'])
&& filled($this->billing['city'])
&& filled($this->billing['country_code']);
}
}; ?>
<form wire:submit="save" class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:eyebrow="__('Administration · Benutzer · Bearbeiten')"
:title="$name ?: __('Benutzer bearbeiten')"
:description="$email"
>
<x-slot:meta>
<span class="badge hub">ID #{{ $id }}</span>
<span class="badge hub">{{ strtoupper($portal) }}</span>
@if ($isActive)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
</x-slot:meta>
<x-slot:actions>
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.users.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
@if ($notification)
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 4000)" x-show="show" x-transition
class="rounded-[5px] border-l-[3px] px-4 py-3 text-[12.5px]"
style="border-color: var(--color-ok); background: color-mix(in oklab, var(--color-ok) 10%, var(--color-bg)); color: var(--color-ink);">
{{ $notification }}
</div>
@endif
@php
$hasProfileInput = collect($profile)
->except(['show_stats', 'tax_exempt', 'disable_footer_code'])
->filter(fn ($value) => filled($value))
->isNotEmpty()
|| (bool) $profile['show_stats']
|| (bool) $profile['tax_exempt']
|| (bool) $profile['disable_footer_code'];
$billingComplete = filled($billing['name'])
&& filled($billing['address1'])
&& filled($billing['postal_code'])
&& filled($billing['city'])
&& filled($billing['country_code']);
@endphp
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bearbeitungsflow') }}</span>
<div class="flex flex-wrap gap-1.5">
<a href="#account"
class="px-2.5 py-1 text-[11px] font-medium rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-2)] hover:text-[color:var(--color-hub)] hover:border-[color:var(--color-hub)]/40 transition">{{ __('Account') }}</a>
<a href="#legacy-profile"
class="px-2.5 py-1 text-[11px] font-medium rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-2)] hover:text-[color:var(--color-hub)] hover:border-[color:var(--color-hub)]/40 transition">{{ __('Profil') }}</a>
<a href="#company-links"
class="px-2.5 py-1 text-[11px] font-medium rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-2)] hover:text-[color:var(--color-hub)] hover:border-[color:var(--color-hub)]/40 transition">{{ __('Firmen') }}</a>
<a href="#contact-links"
class="px-2.5 py-1 text-[11px] font-medium rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-2)] hover:text-[color:var(--color-hub)] hover:border-[color:var(--color-hub)]/40 transition">{{ __('Kontakte') }}</a>
<a href="#billing-address"
class="px-2.5 py-1 text-[11px] font-medium rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-2)] hover:text-[color:var(--color-hub)] hover:border-[color:var(--color-hub)]/40 transition">{{ __('Rechnung') }}</a>
</div>
</div>
<div class="p-5">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 mb-4">
{{ __('Die wichtigsten Pflegebereiche sind hier zusammengefasst. Springe direkt in den Abschnitt, der noch Arbeit braucht.') }}
</p>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] text-[color:var(--color-ink-3)] mb-2 uppercase tracking-[0.04em]">{{ __('Account') }}</div>
<div class="flex flex-wrap gap-2">
@if ($isActive)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
<span class="badge hub">{{ strtoupper($portal) }}</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] text-[color:var(--color-ink-3)] mb-2 uppercase tracking-[0.04em]">{{ __('Legacy-Profil') }}</div>
@if ($hasProfileInput)
<span class="badge ok dot">{{ __('Profil vorhanden') }}</span>
@else
<span class="badge warn dot">{{ __('Profil fehlt') }}</span>
@endif
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] text-[color:var(--color-ink-3)] mb-2 uppercase tracking-[0.04em]">{{ __('Verknüpfungen') }}</div>
<div class="flex flex-wrap gap-2">
<span class="badge {{ count($linkedCompanyIds) > 0 ? 'ok' : 'warn' }} dot">
{{ trans_choice(':count Firma|:count Firmen', count($linkedCompanyIds), ['count' => count($linkedCompanyIds)]) }}
</span>
<span class="badge hub">
{{ trans_choice(':count Kontakt|:count Kontakte', count($contactForms), ['count' => count($contactForms)]) }}
</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] text-[color:var(--color-ink-3)] mb-2 uppercase tracking-[0.04em]">{{ __('Rechnungsadresse') }}</div>
@if ($billingComplete)
<span class="badge ok dot">{{ __('Vollständig') }}</span>
@else
<span class="badge warn dot">{{ __('Unvollständig') }}</span>
@endif
</div>
</div>
</div>
</article>
<flux:card id="account">
<flux:heading size="lg" class="mb-4">{{ __('Basisdaten') }}</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Name') }}</flux:label>
<flux:input wire:model="name" />
<flux:error name="name" />
</flux:field>
<flux:field>
<flux:label>{{ __('E-Mail') }}</flux:label>
<flux:input wire:model="email" type="email" />
<flux:error name="email" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Agentur-/Verteilername') }}</flux:label>
<flux:input wire:model="agencyName" />
<flux:description>{{ __('Erscheint öffentlich als Übermittler („übermittelt von …") unter den Mitteilungen dieses Accounts.') }}</flux:description>
<flux:error name="agencyName" />
</flux:field>
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
<flux:field>
<flux:label>{{ __('Registrierungstyp') }}</flux:label>
<flux:select wire:model="registrationType">
@foreach($registrationTypeOptions as $registrationTypeOption)
<option value="{{ $registrationTypeOption->value }}">{{ $registrationTypeOption->label() }}</option>
@endforeach
</flux:select>
<flux:error name="registrationType" />
</flux:field>
</div>
<div class="mt-4 flex flex-wrap gap-6">
<flux:checkbox wire:model="isActive" label="{{ __('Aktiv') }}" />
<flux:checkbox wire:model="isSuperAdmin" label="{{ __('Super Admin') }}" />
</div>
</flux:card>
<flux:card id="legacy-profile">
<flux:heading size="lg" class="mb-4">{{ __('Legacy-Profil') }}</flux:heading>
<flux:text class="mb-4 text-sm text-zinc-500">
{{ __('Diese Daten stammen aus sf_guard_user_profile und sind direkt mit dem Benutzer verknüpft.') }}
</flux:text>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Anrede') }}</flux:label>
<flux:select wire:model="profile.salutation_key">
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($salutations as $key => $labels)
<option value="{{ $key }}">{{ $labels['de'] ?? $key }}</option>
@endforeach
</flux:select>
<flux:error name="profile.salutation_key" />
</flux:field>
<flux:field>
<flux:label>{{ __('Titel') }}</flux:label>
<flux:input wire:model="profile.title" />
<flux:error name="profile.title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Vorname') }}</flux:label>
<flux:input wire:model="profile.first_name" />
<flux:error name="profile.first_name" />
</flux:field>
<flux:field>
<flux:label>{{ __('Nachname') }}</flux:label>
<flux:input wire:model="profile.last_name" />
<flux:error name="profile.last_name" />
</flux:field>
<flux:field>
<flux:label>{{ __('Telefon') }}</flux:label>
<flux:input wire:model="profile.phone" />
<flux:error name="profile.phone" />
</flux:field>
<flux:field>
<flux:label>{{ __('Geburtsdatum') }}</flux:label>
<flux:date-picker wire:model="profile.birthdate" type="input" placeholder="" clearable />
<flux:error name="profile.birthdate" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Adresse') }}</flux:label>
<flux:textarea wire:model="profile.address" rows="3" />
<flux:error name="profile.address" />
</flux:field>
<flux:field>
<flux:label>{{ __('Land') }}</flux:label>
<flux:select wire:model="profile.country_code">
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($countries as $countryCode => $countryName)
<option value="{{ $countryCode }}">{{ $countryName }}</option>
@endforeach
</flux:select>
<flux:error name="profile.country_code" />
</flux:field>
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="profile.backlink_url" />
<flux:error name="profile.backlink_url" />
</flux:field>
<flux:field>
<flux:label>{{ __('Validierungsdatum') }}</flux:label>
<flux:date-picker wire:model="profile.validation_date" type="input" placeholder="" clearable />
<flux:error name="profile.validation_date" />
</flux:field>
<flux:field>
<flux:label>{{ __('Vertragsdatum') }}</flux:label>
<flux:date-picker wire:model="profile.contract_date" type="input" placeholder="" clearable />
<flux:error name="profile.contract_date" />
</flux:field>
<flux:field>
<flux:label>{{ __('USt-IdNr.') }}</flux:label>
<flux:input wire:model="profile.tax_id_number" />
<flux:error name="profile.tax_id_number" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Steuerbefreiungsgrund') }}</flux:label>
<flux:textarea wire:model="profile.tax_exempt_reason" rows="2" />
<flux:error name="profile.tax_exempt_reason" />
</flux:field>
</div>
<div class="mt-4 flex flex-wrap gap-6">
<flux:checkbox wire:model="profile.show_stats" label="{{ __('Statistiken anzeigen') }}" />
<flux:checkbox wire:model="profile.tax_exempt" label="{{ __('Steuerbefreit') }}" />
<flux:checkbox wire:model="profile.disable_footer_code" label="{{ __('Footer-Code deaktivieren') }}" />
</div>
</flux:card>
<flux:card id="roles">
<flux:heading size="lg" class="mb-4">{{ __('Rollenzuweisung') }}</flux:heading>
<x-portal.user-role-fields :roles="$availableRoles" />
</flux:card>
<flux:card id="company-links">
<flux:heading size="lg" class="mb-4">{{ __('Firmenverknüpfung') }}</flux:heading>
<flux:text class="mb-4 text-sm text-zinc-500">
{{ __('Hier verknüpfst du den Benutzer mit Firmen. Die verknüpften Firmenkontakte können darunter direkt gepflegt werden.') }}
</flux:text>
<div class="flex gap-2">
<flux:select
wire:model.live="selectedLookupCompanyId"
variant="combobox"
:filter="false"
placeholder="{{ __('Firma suchen und auswählen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companyLookup"
placeholder="{{ __('Name, Slug oder E-Mail…') }}"
/>
</x-slot>
@foreach($companyLookupResults as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}@if($company->email)<span class="ml-1 text-zinc-400">· {{ $company->email }}</span>@endif
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companyLookup)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="filled"
icon="x-mark"
wire:click="clearCompanyLookup"
title="{{ __('Firmensuche zurücksetzen') }}"
/>
</div>
<div class="mt-4 space-y-3">
@forelse($linkedCompanies as $company)
<div class="grid gap-3 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700 sm:grid-cols-[1fr,auto,auto] sm:items-center">
<div>
<flux:text weight="medium">{{ $company->name }}</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $company->slug }}</flux:text>
</div>
<flux:select wire:model="companyRoles.{{ $company->id }}">
<option value="member">{{ __('Member') }}</option>
<option value="responsible">{{ __('Responsible') }}</option>
<option value="owner">{{ __('Owner') }}</option>
</flux:select>
<flux:button type="button" variant="filled" icon="x-mark" wire:click="removeLinkedCompany({{ $company->id }})">
{{ __('Entfernen') }}
</flux:button>
</div>
@empty
<flux:text class="text-sm text-zinc-500">
{{ __('Aktuell sind keine Firmen verknüpft.') }}
</flux:text>
@endforelse
</div>
</flux:card>
<flux:card id="contact-links">
<flux:heading size="lg" class="mb-4">{{ __('Kontakte der verknüpften Firmen') }}</flux:heading>
<div class="mb-4 flex gap-2">
<flux:select
wire:model.live="selectedLookupContactId"
variant="combobox"
:filter="false"
placeholder="{{ __('Kontakt suchen und auswählen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="contactLookup"
placeholder="{{ __('Name oder E-Mail…') }}"
/>
</x-slot>
@foreach($contactLookupResults as $contactResult)
@php
$contactResultName = trim(($contactResult->first_name ?? '').' '.($contactResult->last_name ?? '')) ?: __('Kontakt ohne Name');
@endphp
<flux:select.option :value="$contactResult->id" wire:key="{{ $contactResult->id }}">
{{ $contactResultName }}
<span class="ml-1 text-zinc-400">
@if($contactResult->email)· {{ $contactResult->email }} @endif
· {{ $contactResult->company?->name ?? __('Unbekannte Firma') }}
</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($contactLookup)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Kein Kontakt gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="filled"
icon="x-mark"
wire:click="clearContactLookup"
title="{{ __('Kontaktsuche zurücksetzen') }}"
/>
</div>
@forelse($contactForms as $index => $contactForm)
<div class="mb-4 rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-3 flex items-center justify-between gap-2">
<flux:text class="text-sm text-zinc-500">
{{ __('Firma') }}: {{ $contactForm['company_name'] }}
</flux:text>
<flux:button
size="sm"
variant="filled"
icon="x-mark"
type="button"
wire:click="removeLinkedContact({{ $contactForm['id'] }})"
>
{{ __('Entfernen') }}
</flux:button>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Vorname') }}</flux:label>
<flux:input wire:model="contactForms.{{ $index }}.first_name" />
</flux:field>
<flux:field>
<flux:label>{{ __('Nachname') }}</flux:label>
<flux:input wire:model="contactForms.{{ $index }}.last_name" />
</flux:field>
<flux:field>
<flux:label>{{ __('E-Mail') }}</flux:label>
<flux:input wire:model="contactForms.{{ $index }}.email" type="email" />
</flux:field>
<flux:field>
<flux:label>{{ __('Telefon') }}</flux:label>
<flux:input wire:model="contactForms.{{ $index }}.phone" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Rolle / Verantwortlichkeit') }}</flux:label>
<flux:input wire:model="contactForms.{{ $index }}.responsibility" />
</flux:field>
</div>
</div>
@empty
<flux:text class="text-sm text-zinc-500">
{{ __('Zu den aktuell verknüpften Firmen sind keine Kontakte vorhanden.') }}
</flux:text>
@endforelse
</flux:card>
<flux:card id="billing-address">
<flux:heading size="lg" class="mb-4">{{ __('Rechnungsadresse') }}</flux:heading>
<x-portal.user-billing-fields :salutations="$salutations" :countries="$countries" />
</flux:card>
<article class="panel">
<div class="p-5 flex justify-end gap-3">
<flux:button variant="filled" href="{{ route('admin.users.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary" icon="check">
{{ __('Speichern') }}
</flux:button>
</div>
</article>
</form>