967 lines
54 KiB
PHP
967 lines
54 KiB
PHP
<?php
|
||
|
||
use App\Actions\Admin\UserImpersonation;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\Company;
|
||
use App\Models\User;
|
||
use App\Services\Admin\AdminPerformanceCache;
|
||
use Flux\Flux;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Livewire\Attributes\Layout;
|
||
use Livewire\Attributes\Title;
|
||
use Livewire\Volt\Component;
|
||
use Livewire\WithPagination;
|
||
use Spatie\Permission\Models\Role;
|
||
|
||
new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Component
|
||
{
|
||
use WithPagination;
|
||
|
||
public string $search = '';
|
||
|
||
public string $activeFilter = 'all';
|
||
|
||
public string $portalFilter = 'all';
|
||
|
||
public string $roleFilter = 'all';
|
||
|
||
public string $qualityFilter = 'all';
|
||
|
||
public string $permissionFilter = 'all';
|
||
|
||
public string $sortBy = 'created_at';
|
||
|
||
public string $sortDir = 'desc';
|
||
|
||
public ?int $viewingUserId = null;
|
||
|
||
public function sort(string $column): void
|
||
{
|
||
if ($this->sortBy === $column) {
|
||
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
$this->sortBy = $column;
|
||
$this->sortDir = 'asc';
|
||
}
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function with(): array
|
||
{
|
||
$sortable = ['name', 'email', 'portal', 'is_active', 'last_login_at', 'created_at'];
|
||
$sort = in_array($this->sortBy, $sortable) ? $this->sortBy : 'created_at';
|
||
$authUser = Auth::user();
|
||
$impersonation = app(UserImpersonation::class);
|
||
|
||
$users = User::query()
|
||
->select(['id', 'name', 'email', 'is_active', 'is_super_admin', 'portal', 'registration_type', 'last_login_at', 'created_at'])
|
||
->with([
|
||
'roles:id,name',
|
||
'roles.permissions:id,name',
|
||
'permissions:id,name',
|
||
])
|
||
->withCount([
|
||
'contacts',
|
||
'pressReleases as published_press_releases_count' => fn ($query) => $query->where('status', PressReleaseStatus::Published->value),
|
||
])
|
||
->withExists(['profile', 'billingAddress'])
|
||
->when(filled(trim($this->search)), function (Builder $query): void {
|
||
$this->applySearch($query, $this->search);
|
||
})
|
||
->when($this->activeFilter !== 'all', function ($query): void {
|
||
$query->where('is_active', $this->activeFilter === 'active');
|
||
})
|
||
->when($this->portalFilter !== 'all', function ($query): void {
|
||
$query->where('portal', $this->portalFilter);
|
||
})
|
||
->when($this->roleFilter !== 'all', function ($query): void {
|
||
$query->whereHas('roles', fn ($roleQuery) => $roleQuery->where('name', $this->roleFilter));
|
||
})
|
||
->when($this->qualityFilter !== 'all', function ($query): void {
|
||
match ($this->qualityFilter) {
|
||
'without_company' => $query->whereDoesntHave('companies'),
|
||
'with_company' => $query->whereHas('companies'),
|
||
'without_profile' => $query->whereDoesntHave('profile'),
|
||
'with_profile' => $query->whereHas('profile'),
|
||
'without_contacts' => $query->whereDoesntHave('contacts'),
|
||
'without_billing' => $query->whereDoesntHave('billingAddress'),
|
||
'with_press_releases' => $query->whereHas('pressReleases'),
|
||
'without_press_releases' => $query->whereDoesntHave('pressReleases'),
|
||
'with_published_press_releases' => $query->whereHas('pressReleases', fn ($pressReleaseQuery) => $pressReleaseQuery->where('status', PressReleaseStatus::Published->value)),
|
||
'without_published_press_releases' => $query->whereDoesntHave('pressReleases', fn ($pressReleaseQuery) => $pressReleaseQuery->where('status', PressReleaseStatus::Published->value)),
|
||
default => null,
|
||
};
|
||
})
|
||
->when($this->permissionFilter !== 'all', function ($query): void {
|
||
match ($this->permissionFilter) {
|
||
'can_publish_press_releases' => $query->where(function ($permissionQuery): void {
|
||
$permissionQuery
|
||
->where('is_super_admin', true)
|
||
->orWhereHas('roles.permissions', fn ($rolePermissionQuery) => $rolePermissionQuery->where('name', 'press-releases:publish'))
|
||
->orWhereHas('permissions', fn ($directPermissionQuery) => $directPermissionQuery->where('name', 'press-releases:publish'));
|
||
}),
|
||
default => null,
|
||
};
|
||
})
|
||
->orderBy($sort, $this->sortDir)
|
||
->paginate(50);
|
||
|
||
$this->hydrateCompanyCounts($users);
|
||
|
||
return [
|
||
'users' => $users,
|
||
'selectedUser' => $this->selectedUser(),
|
||
'stats' => $this->stats(),
|
||
'availableRoles' => Role::query()->orderBy('name')->pluck('name')->all(),
|
||
'canImpersonateUsers' => $authUser instanceof User && $impersonation->canInitiate($authUser),
|
||
'isImpersonating' => $impersonation->isActive(),
|
||
];
|
||
}
|
||
|
||
public function showUserDetails(int $userId): void
|
||
{
|
||
$this->viewingUserId = $userId;
|
||
|
||
Flux::modal('user-details')->show();
|
||
}
|
||
|
||
/**
|
||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||
*/
|
||
public function loginAsUser(int $userId): void
|
||
{
|
||
$currentUser = Auth::user();
|
||
|
||
abort_unless($currentUser instanceof User, 403);
|
||
|
||
$targetUser = User::query()
|
||
->with('roles')
|
||
->findOrFail($userId);
|
||
|
||
if ($currentUser->is($targetUser)) {
|
||
return;
|
||
}
|
||
|
||
app(UserImpersonation::class)->start($currentUser, $targetUser);
|
||
|
||
$this->redirect(route('me.dashboard'), navigate: false);
|
||
}
|
||
|
||
public function companyUserRoleLabel(?string $role): string
|
||
{
|
||
return match ($role ?? 'member') {
|
||
'owner' => __('Inhaber'),
|
||
'responsible' => __('Verantwortlich'),
|
||
'member' => __('Mitglied'),
|
||
default => (string) ($role ?? 'member'),
|
||
};
|
||
}
|
||
|
||
public function pressReleaseStatusColor(?string $status): string
|
||
{
|
||
return match ($status) {
|
||
'published' => 'green',
|
||
'review' => 'blue',
|
||
'rejected' => 'red',
|
||
'archived' => 'zinc',
|
||
default => 'amber',
|
||
};
|
||
}
|
||
|
||
public function resetFilters(): void
|
||
{
|
||
$this->search = '';
|
||
$this->activeFilter = 'all';
|
||
$this->portalFilter = 'all';
|
||
$this->roleFilter = 'all';
|
||
$this->qualityFilter = 'all';
|
||
$this->permissionFilter = 'all';
|
||
|
||
$this->resetPage();
|
||
}
|
||
|
||
/**
|
||
* @return array{total: int, active: int, inactive: int}
|
||
*/
|
||
private function stats(): array
|
||
{
|
||
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::UserStats, AdminPerformanceCache::StatsTtl, function (): array {
|
||
$stats = User::query()
|
||
->toBase()
|
||
->selectRaw('COUNT(*) as total')
|
||
->selectRaw('SUM(CASE WHEN is_active = ? THEN 1 ELSE 0 END) as active', [true])
|
||
->selectRaw('SUM(CASE WHEN is_active = ? THEN 1 ELSE 0 END) as inactive', [false])
|
||
->first();
|
||
|
||
return [
|
||
'total' => (int) ($stats->total ?? 0),
|
||
'active' => (int) ($stats->active ?? 0),
|
||
'inactive' => (int) ($stats->inactive ?? 0),
|
||
];
|
||
});
|
||
}
|
||
|
||
private function hydrateCompanyCounts($users): void
|
||
{
|
||
$userIds = $users->getCollection()->pluck('id');
|
||
|
||
if ($userIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$companyCounts = Company::query()->join('company_user', 'companies.id', '=', 'company_user.company_id')->whereIn('company_user.user_id', $userIds)->selectRaw('company_user.user_id, COUNT(*) as aggregate')->groupBy('company_user.user_id')->pluck('aggregate', 'user_id');
|
||
|
||
$users->getCollection()->each(function (User $user) use ($companyCounts): void {
|
||
$user->setAttribute('companies_count', (int) $companyCounts->get($user->id, 0));
|
||
});
|
||
}
|
||
|
||
private function selectedUser(): ?User
|
||
{
|
||
if ($this->viewingUserId === null) {
|
||
return null;
|
||
}
|
||
|
||
return User::query()
|
||
->withCount([
|
||
'contacts',
|
||
'pressReleases',
|
||
'pressReleases as published_press_releases_count' => fn ($query) => $query->where('status', PressReleaseStatus::Published->value),
|
||
'userPaymentOptions',
|
||
'tokens',
|
||
])
|
||
->with([
|
||
'roles' => fn ($query) => $query->orderBy('name'),
|
||
'profile',
|
||
'billingAddress',
|
||
'companies' => fn ($query) => $query
|
||
->withoutGlobalScopes()
|
||
->select(['companies.id', 'companies.name', 'companies.slug', 'companies.email', 'companies.phone', 'companies.website', 'companies.address', 'companies.country_code', 'companies.portal', 'companies.is_active', 'companies.disable_footer_code'])
|
||
->withCount(['contacts', 'pressReleases'])
|
||
->orderBy('name'),
|
||
'companies.contacts' => fn ($query) => $query
|
||
->withoutGlobalScopes()
|
||
->select(['contacts.id', 'contacts.company_id', 'contacts.portal', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone'])
|
||
->orderBy('last_name')
|
||
->orderBy('first_name')
|
||
->limit(10),
|
||
'companies.pressReleases' => fn ($query) => $query
|
||
->withoutGlobalScopes()
|
||
->select(['press_releases.id', 'press_releases.company_id', 'press_releases.user_id', 'press_releases.portal', 'press_releases.title', 'press_releases.status', 'press_releases.hits', 'press_releases.no_export', 'press_releases.published_at', 'press_releases.created_at'])
|
||
->latest('published_at')
|
||
->latest('created_at')
|
||
->limit(5),
|
||
])
|
||
->find($this->viewingUserId);
|
||
}
|
||
|
||
private function applySearch(Builder $query, string $search): void
|
||
{
|
||
$terms = preg_split('/\s+/', trim($search), -1, PREG_SPLIT_NO_EMPTY);
|
||
|
||
if ($terms === false || $terms === []) {
|
||
return;
|
||
}
|
||
|
||
$query->where(function (Builder $searchQuery) use ($terms): void {
|
||
foreach ($terms as $term) {
|
||
$pattern = '%'.$this->escapeLikeTerm($term).'%';
|
||
|
||
$searchQuery->where(function (Builder $termQuery) use ($pattern): void {
|
||
$termQuery
|
||
->whereLike('name', $pattern)
|
||
->orWhereLike('email', $pattern);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
private function escapeLikeTerm(string $term): string
|
||
{
|
||
return addcslashes($term, '\%_');
|
||
}
|
||
|
||
public function updatedSearch(): void
|
||
{
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function updatedActiveFilter(): void
|
||
{
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function updatedPortalFilter(): void
|
||
{
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function updatedRoleFilter(): void
|
||
{
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function updatedQualityFilter(): void
|
||
{
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function updatedPermissionFilter(): void
|
||
{
|
||
$this->resetPage();
|
||
}
|
||
}; ?>
|
||
|
||
<div class="space-y-8">
|
||
{{-- ============== PAGE HEADER ============== --}}
|
||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||
<div class="min-w-0">
|
||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
||
<span class="eyebrow muted">{{ __('Administration · User-Verwaltung') }}</span>
|
||
</div>
|
||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||
{{ __('Benutzer') }}
|
||
</h1>
|
||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||
{{ __('Alle Konten der Plattform mit Rollen, Berechtigungen und Migrations-/Pflegefällen.') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2 flex-shrink-0">
|
||
<flux:button variant="primary" icon="plus" href="{{ route('admin.users.create') }}" wire:navigate>
|
||
{{ __('Benutzer anlegen') }}
|
||
</flux:button>
|
||
</div>
|
||
</header>
|
||
|
||
{{-- ============== KPI-Reihe ============== --}}
|
||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||
<x-portal.stat-card variant="primary" :label="__('Gesamt')" :value="number_format($stats['total'])">
|
||
<x-slot:meta>{{ __('User-Konten') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('alle Portale') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="ok" :label="__('Aktiv')" :value="number_format($stats['active'])">
|
||
<x-slot:meta>{{ __('Login möglich') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('Produktiv') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="muted" :label="__('Inaktiv')" :value="number_format($stats['inactive'])">
|
||
<x-slot:meta>{{ __('gesperrt / archiviert') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('kein Login') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
</section>
|
||
|
||
{{-- ============== FILTER-PANEL ============== --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
|
||
@if ($search || $activeFilter !== 'all' || $portalFilter !== 'all' || $roleFilter !== 'all' || $qualityFilter !== 'all' || $permissionFilter !== 'all')
|
||
<div class="flex items-center gap-2">
|
||
<span class="badge hub dot">{{ __('Filter aktiv') }}</span>
|
||
<flux:button size="sm" variant="ghost" icon="arrow-path" type="button" wire:click="resetFilters">
|
||
{{ __('Zurücksetzen') }}
|
||
</flux:button>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
<div class="p-5 space-y-4">
|
||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Name oder E-Mail suchen...') }}"
|
||
icon="magnifying-glass" class="lg:max-w-sm" />
|
||
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||
<flux:select wire:model.live="activeFilter" class="sm:w-40">
|
||
<option value="all">{{ __('Alle Status') }}</option>
|
||
<option value="active">{{ __('Aktiv') }}</option>
|
||
<option value="inactive">{{ __('Inaktiv') }}</option>
|
||
</flux:select>
|
||
|
||
<flux:select wire:model.live="portalFilter" class="sm:w-48">
|
||
<option value="all">{{ __('Alle Portale') }}</option>
|
||
<option value="presseecho">Presseecho</option>
|
||
<option value="businessportal24">Businessportal24</option>
|
||
<option value="both">{{ __('Beide') }}</option>
|
||
</flux:select>
|
||
|
||
<flux:select wire:model.live="roleFilter" class="sm:w-44">
|
||
<option value="all">{{ __('Alle Rollen') }}</option>
|
||
@foreach ($availableRoles as $roleName)
|
||
<option value="{{ $roleName }}">{{ $roleName }}</option>
|
||
@endforeach
|
||
</flux:select>
|
||
|
||
<flux:select wire:model.live="qualityFilter" class="sm:w-56">
|
||
<option value="all">{{ __('Alle Datenstände') }}</option>
|
||
<option value="without_company">{{ __('Ohne Firma') }}</option>
|
||
<option value="with_company">{{ __('Mit Firma') }}</option>
|
||
<option value="without_profile">{{ __('Ohne Legacy-Profil') }}</option>
|
||
<option value="with_profile">{{ __('Mit Legacy-Profil') }}</option>
|
||
<option value="without_contacts">{{ __('Ohne Kontakte') }}</option>
|
||
<option value="without_billing">{{ __('Ohne Rechnungsadresse') }}</option>
|
||
<option value="with_press_releases">{{ __('Mit Pressemitteilungen') }}</option>
|
||
<option value="without_press_releases">{{ __('Ohne Pressemitteilungen') }}</option>
|
||
<option value="with_published_press_releases">{{ __('Mit veröffentlichten PMs') }}</option>
|
||
<option value="without_published_press_releases">{{ __('Ohne veröffentlichte PMs') }}</option>
|
||
</flux:select>
|
||
|
||
<flux:select wire:model.live="permissionFilter" class="sm:w-56">
|
||
<option value="all">{{ __('Alle Berechtigungen') }}</option>
|
||
<option value="can_publish_press_releases">{{ __('Kann PMs freigeben') }}</option>
|
||
</flux:select>
|
||
</div>
|
||
</div>
|
||
|
||
@if (! ($search || $activeFilter !== 'all' || $portalFilter !== 'all' || $roleFilter !== 'all' || $qualityFilter !== 'all' || $permissionFilter !== 'all'))
|
||
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
|
||
{{ __('Nutze die Filter, um offene Migrations- und Pflegefälle schnell zu finden.') }}
|
||
</p>
|
||
@endif
|
||
</div>
|
||
</article>
|
||
|
||
{{-- ============== TABELLE ============== --}}
|
||
<article class="panel overflow-hidden">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Alle Benutzer') }}</span>
|
||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||
{{ __(':count Einträge', ['count' => $users->count()]) }}
|
||
</span>
|
||
</div>
|
||
<flux:table>
|
||
<flux:table.columns>
|
||
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
|
||
|
||
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDir"
|
||
wire:click="sort('name')">
|
||
{{ __('Benutzer') }}</flux:table.column>
|
||
<flux:table.column sortable :sorted="$sortBy === 'portal'" :direction="$sortDir"
|
||
wire:click="sort('portal')">{{ __('Portal') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Rollen') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Zuordnung') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Datenqualität') }}</flux:table.column>
|
||
<flux:table.column sortable :sorted="$sortBy === 'is_active'" :direction="$sortDir"
|
||
wire:click="sort('is_active')">{{ __('Status') }}</flux:table.column>
|
||
<flux:table.column sortable :sorted="$sortBy === 'last_login_at'" :direction="$sortDir"
|
||
wire:click="sort('last_login_at')">{{ __('Letzter Login') }}</flux:table.column>
|
||
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDir"
|
||
wire:click="sort('created_at')">{{ __('Hinzugefügt') }}</flux:table.column>
|
||
</flux:table.columns>
|
||
|
||
<flux:table.rows>
|
||
@forelse ($users as $user)
|
||
<flux:table.row :key="$user->id">
|
||
@php
|
||
$portalValue = $user->portal?->value ?? (string) ($user->portal ?? '-');
|
||
$canPublishPressReleases = (bool) $user->is_super_admin
|
||
|| $user->permissions->contains('name', 'press-releases:publish')
|
||
|| $user->roles->contains(fn ($role) => $role->permissions->contains('name', 'press-releases:publish'));
|
||
$canLoginAsUser = $canImpersonateUsers
|
||
&& ! $isImpersonating
|
||
&& $user->id !== auth()->id()
|
||
&& app(UserImpersonation::class)->canBeImpersonated($user);
|
||
@endphp
|
||
|
||
<flux:table.cell>
|
||
<div class="flex flex-wrap gap-1">
|
||
<flux:button size="sm" variant="ghost" icon="eye"
|
||
wire:click="showUserDetails({{ $user->id }})" />
|
||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||
href="{{ route('admin.users.edit', $user->id) }}" wire:navigate />
|
||
@if($canLoginAsUser)
|
||
<flux:button
|
||
size="sm"
|
||
variant="ghost"
|
||
icon="user"
|
||
square
|
||
type="button"
|
||
tooltip="{{ __('Login als User') }}"
|
||
wire:click="loginAsUser({{ $user->id }})"
|
||
wire:confirm="{{ __('Als :name anmelden?', ['name' => $user->name]) }}"
|
||
/>
|
||
@endif
|
||
</div>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<div class="min-w-0">
|
||
<flux:text weight="semibold">{{ $user->name }}</flux:text>
|
||
<flux:text class="text-sm text-zinc-600 dark:text-zinc-400">{{ $user->email }}</flux:text>
|
||
<flux:text class="text-xs text-zinc-400">ID: {{ $user->id }}</flux:text>
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<flux:badge color="zinc" size="sm">{{ strtoupper($portalValue) }}</flux:badge>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<div class="flex flex-wrap gap-1">
|
||
@forelse($user->roles as $role)
|
||
<flux:badge color="zinc" size="sm">{{ $role->name }}</flux:badge>
|
||
@empty
|
||
<flux:text class="text-xs text-zinc-500">{{ __('–') }}</flux:text>
|
||
@endforelse
|
||
|
||
@if($canPublishPressReleases)
|
||
<flux:badge color="emerald" size="sm">{{ __('PM-Freigabe') }}</flux:badge>
|
||
@endif
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<div class="flex flex-wrap gap-1">
|
||
@if (($user->companies_count ?? 0) > 0)
|
||
<flux:badge color="green" size="sm" icon="building-office">
|
||
{{ trans_choice(':count Firma|:count Firmen', $user->companies_count, ['count' => $user->companies_count]) }}
|
||
</flux:badge>
|
||
@else
|
||
<flux:badge color="amber" size="sm">
|
||
{{ __('Keine Firma') }}
|
||
</flux:badge>
|
||
@endif
|
||
|
||
@if (($user->contacts_count ?? 0) > 0)
|
||
<flux:badge color="blue" size="sm">
|
||
{{ trans_choice(':count Kontakt|:count Kontakte', $user->contacts_count, ['count' => $user->contacts_count]) }}
|
||
</flux:badge>
|
||
@else
|
||
<flux:badge color="zinc" size="sm">{{ __('Keine Kontakte') }}</flux:badge>
|
||
@endif
|
||
|
||
@if (($user->published_press_releases_count ?? 0) > 0)
|
||
<flux:badge color="emerald" size="sm">
|
||
{{ trans_choice(':count veröffentlicht|:count veröffentlicht', $user->published_press_releases_count, ['count' => $user->published_press_releases_count]) }}
|
||
</flux:badge>
|
||
@else
|
||
<flux:badge color="zinc" size="sm">{{ __('Keine veröffentlichten PMs') }}</flux:badge>
|
||
@endif
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<div class="flex flex-wrap gap-1">
|
||
@if ($user->profile_exists)
|
||
<flux:badge color="green" size="sm">{{ __('Profil OK') }}</flux:badge>
|
||
@else
|
||
<flux:badge color="amber" size="sm">{{ __('Profil fehlt') }}</flux:badge>
|
||
@endif
|
||
|
||
@if ($user->billing_address_exists)
|
||
<flux:badge color="green" size="sm">{{ __('Rechnung OK') }}</flux:badge>
|
||
@else
|
||
<flux:badge color="zinc" size="sm">{{ __('Keine Rechnung') }}</flux:badge>
|
||
@endif
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
@if ($user->is_active)
|
||
<flux:badge color="green" size="sm" icon="check">{{ __('Aktiv') }}
|
||
</flux:badge>
|
||
@else
|
||
<flux:badge color="red" size="sm" icon="x-mark">{{ __('Inaktiv') }}
|
||
</flux:badge>
|
||
@endif
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<flux:text class="text-sm text-zinc-500">
|
||
{{ $user->last_login_at?->format('d.m.Y H:i') ?? __('Nie') }}
|
||
</flux:text>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<flux:text class="text-sm text-zinc-500">
|
||
{{ $user->created_at?->format('d.m.Y H:i') ?? '–' }}
|
||
</flux:text>
|
||
</flux:table.cell>
|
||
|
||
|
||
</flux:table.row>
|
||
@empty
|
||
<flux:table.row>
|
||
<flux:table.cell colspan="9">
|
||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||
<flux:icon.users class="size-6" />
|
||
</div>
|
||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||
{{ __('Keine Benutzer gefunden') }}
|
||
</div>
|
||
</div>
|
||
</flux:table.cell>
|
||
</flux:table.row>
|
||
@endforelse
|
||
</flux:table.rows>
|
||
</flux:table>
|
||
|
||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||
{{ $users->links('components.portal.pagination') }}
|
||
</div>
|
||
</article>
|
||
|
||
<flux:modal name="user-details" class="w-full max-w-5xl" scroll="body">
|
||
<div class="space-y-6">
|
||
@if ($selectedUser)
|
||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<flux:heading size="xl">{{ $selectedUser->name }}</flux:heading>
|
||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-zinc-500">
|
||
<span>{{ $selectedUser->email }}</span>
|
||
<span>ID: {{ $selectedUser->id }}</span>
|
||
@if($selectedUser->legacy_portal || $selectedUser->legacy_id)
|
||
<span>{{ __('Legacy') }}: {{ $selectedUser->legacy_portal ?? '—' }} #{{ $selectedUser->legacy_id ?? '—' }}</span>
|
||
@endif
|
||
</div>
|
||
<div class="mt-3 flex flex-wrap gap-2">
|
||
@if ($selectedUser->is_active)
|
||
<flux:badge color="green" size="sm">{{ __('Aktiv') }}</flux:badge>
|
||
@else
|
||
<flux:badge color="red" size="sm">{{ __('Inaktiv') }}</flux:badge>
|
||
@endif
|
||
<flux:badge color="zinc" size="sm">{{ $selectedUser->portal?->label() ?? '-' }}</flux:badge>
|
||
<flux:badge color="zinc" size="sm">{{ $selectedUser->registration_type?->label() ?? '-' }}</flux:badge>
|
||
@if($selectedUser->is_super_admin)
|
||
<flux:badge color="purple" size="sm">{{ __('Super Admin') }}</flux:badge>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap gap-2">
|
||
@if($canImpersonateUsers && ! $isImpersonating && auth()->id() !== $selectedUser->id && app(UserImpersonation::class)->canBeImpersonated($selectedUser))
|
||
<flux:button
|
||
type="button"
|
||
variant="primary"
|
||
wire:click="loginAsUser({{ $selectedUser->id }})"
|
||
wire:confirm="{{ __('Als :name anmelden?', ['name' => $selectedUser->name]) }}"
|
||
>
|
||
{{ __('Login als User') }}
|
||
</flux:button>
|
||
@endif
|
||
<flux:button icon="pencil" href="{{ route('admin.users.edit', $selectedUser->id) }}" wire:navigate>
|
||
{{ __('Bearbeiten') }}
|
||
</flux:button>
|
||
<flux:modal.close>
|
||
<flux:button variant="ghost">{{ __('Schließen') }}</flux:button>
|
||
</flux:modal.close>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||
<flux:card>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Firmen') }}</flux:text>
|
||
<flux:text size="xl" weight="bold">{{ $selectedUser->companies->count() }}</flux:text>
|
||
</flux:card>
|
||
<flux:card>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Kontakte') }}</flux:text>
|
||
<flux:text size="xl" weight="bold">{{ $selectedUser->contacts_count }}</flux:text>
|
||
</flux:card>
|
||
<flux:card>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Pressemitteilungen') }}</flux:text>
|
||
<flux:text size="xl" weight="bold">{{ $selectedUser->press_releases_count }}</flux:text>
|
||
@if($selectedUser->published_press_releases_count > 0)
|
||
<flux:button
|
||
size="sm"
|
||
variant="ghost"
|
||
icon="arrow-top-right-on-square"
|
||
class="mt-2"
|
||
href="{{ route('admin.press-releases.index', ['user' => $selectedUser->id, 'status' => \App\Enums\PressReleaseStatus::Published->value]) }}"
|
||
wire:navigate
|
||
>
|
||
{{ trans_choice(':count veröffentlichte PM anzeigen|:count veröffentlichte PMs anzeigen', $selectedUser->published_press_releases_count, ['count' => $selectedUser->published_press_releases_count]) }}
|
||
</flux:button>
|
||
@else
|
||
<flux:text class="mt-1 text-xs text-zinc-500">{{ __('Keine veröffentlichten PMs') }}</flux:text>
|
||
@endif
|
||
</flux:card>
|
||
<flux:card>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('API-Tokens') }}</flux:text>
|
||
<flux:text size="xl" weight="bold">{{ $selectedUser->tokens_count }}</flux:text>
|
||
</flux:card>
|
||
<flux:card>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Zahloptionen') }}</flux:text>
|
||
<flux:text size="xl" weight="bold">{{ $selectedUser->user_payment_options_count }}</flux:text>
|
||
</flux:card>
|
||
</div>
|
||
|
||
<div class="grid gap-4 lg:grid-cols-2">
|
||
<flux:card>
|
||
<flux:heading size="lg" class="mb-4">{{ __('Account & Zugriff') }}</flux:heading>
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('E-Mail verifiziert') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->email_verified_at?->format('d.m.Y H:i') ?? __('Nein') }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Sprache') }}</flux:text>
|
||
<flux:text>{{ strtoupper($selectedUser->language ?? 'de') }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Letzter Login') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->last_login_at?->format('d.m.Y H:i') ?? __('Nie') }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Letzte IP') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->last_login_ip ?: '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Zuletzt gesehen') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->last_seen_at?->format('d.m.Y H:i') ?? '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('DSGVO-Einwilligung') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->gdpr_consent_at?->format('d.m.Y H:i') ?? '–' }}</flux:text>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4 flex flex-wrap gap-2">
|
||
@forelse($selectedUser->roles as $role)
|
||
<flux:badge color="zinc" size="sm">{{ $role->name }}</flux:badge>
|
||
@empty
|
||
<flux:badge color="amber" size="sm">{{ __('Keine Rolle') }}</flux:badge>
|
||
@endforelse
|
||
</div>
|
||
</flux:card>
|
||
|
||
<flux:card>
|
||
<flux:heading size="lg" class="mb-4">{{ __('Legacy-Profil') }}</flux:heading>
|
||
@if ($selectedUser->profile)
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Name') }}</flux:text>
|
||
<flux:text>{{ trim(($selectedUser->profile->first_name ?? '').' '.($selectedUser->profile->last_name ?? '')) ?: '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Telefon') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->profile->phone ?: '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Geburtsdatum') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->profile->birthdate?->format('d.m.Y') ?? '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Land') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->profile->country_code ?: '–' }}</flux:text>
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Adresse') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->profile->address ?: '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Validierung') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->profile->validation_date?->format('d.m.Y') ?? '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Vertrag') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->profile->contract_date?->format('d.m.Y') ?? '–' }}</flux:text>
|
||
</div>
|
||
</div>
|
||
@else
|
||
<flux:text class="text-sm text-zinc-500">{{ __('Kein Legacy-Profil hinterlegt') }}</flux:text>
|
||
@endif
|
||
</flux:card>
|
||
|
||
<flux:card>
|
||
<flux:heading size="lg" class="mb-4">{{ __('Rechnungsadresse') }}</flux:heading>
|
||
@if ($selectedUser->billingAddress)
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<div class="sm:col-span-2">
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Name') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->billingAddress->name ?: '–' }}</flux:text>
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Adresse') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->billingAddress->address1 ?: '–' }}</flux:text>
|
||
@if ($selectedUser->billingAddress->address2)
|
||
<flux:text>{{ $selectedUser->billingAddress->address2 }}</flux:text>
|
||
@endif
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Ort') }}</flux:text>
|
||
<flux:text>{{ trim(($selectedUser->billingAddress->postal_code ?? '').' '.($selectedUser->billingAddress->city ?? '')) ?: '–' }}</flux:text>
|
||
</div>
|
||
<div>
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Land') }}</flux:text>
|
||
<flux:text>{{ $selectedUser->billingAddress->country_code ?: '–' }}</flux:text>
|
||
</div>
|
||
</div>
|
||
@else
|
||
<flux:text class="text-sm text-zinc-500">{{ __('Keine Rechnungsadresse hinterlegt') }}</flux:text>
|
||
@endif
|
||
</flux:card>
|
||
|
||
<flux:card>
|
||
<flux:heading size="lg" class="mb-4">{{ __('Datenqualität') }}</flux:heading>
|
||
<div class="flex flex-wrap gap-2">
|
||
<flux:badge :color="$selectedUser->profile ? 'green' : 'amber'" size="sm">
|
||
{{ $selectedUser->profile ? __('Profil vorhanden') : __('Profil fehlt') }}
|
||
</flux:badge>
|
||
<flux:badge :color="$selectedUser->billingAddress ? 'green' : 'zinc'" size="sm">
|
||
{{ $selectedUser->billingAddress ? __('Rechnung vorhanden') : __('Rechnung fehlt') }}
|
||
</flux:badge>
|
||
<flux:badge :color="$selectedUser->companies->isNotEmpty() ? 'green' : 'amber'" size="sm">
|
||
{{ $selectedUser->companies->isNotEmpty() ? __('Firma verknüpft') : __('Keine Firma') }}
|
||
</flux:badge>
|
||
<flux:badge :color="$selectedUser->contacts_count > 0 ? 'blue' : 'zinc'" size="sm">
|
||
{{ $selectedUser->contacts_count > 0 ? __('Kontakte vorhanden') : __('Keine Kontakte') }}
|
||
</flux:badge>
|
||
<flux:badge :color="$selectedUser->press_releases_count > 0 ? 'blue' : 'zinc'" size="sm">
|
||
{{ $selectedUser->press_releases_count > 0 ? __('PMs vorhanden') : __('Keine PMs') }}
|
||
</flux:badge>
|
||
</div>
|
||
</flux:card>
|
||
</div>
|
||
|
||
<flux:card>
|
||
<div class="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<flux:heading size="lg">{{ __('Firmen, Kontakte und Pressemitteilungen') }}</flux:heading>
|
||
<flux:text class="text-sm text-zinc-500">
|
||
{{ __('Struktur: User → Firmen → Kontakte / Pressemitteilungen') }}
|
||
</flux:text>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
@forelse($selectedUser->companies as $company)
|
||
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
||
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||
<div class="min-w-0 flex-1">
|
||
@if (\Illuminate\Support\Facades\Route::has('admin.companies.show'))
|
||
<a href="{{ route('admin.companies.show', $company->id) }}" wire:navigate class="block">
|
||
<flux:text weight="semibold" class="text-blue-600 hover:underline dark:text-blue-400">
|
||
{{ $company->name }}
|
||
</flux:text>
|
||
</a>
|
||
@else
|
||
<flux:text weight="semibold">{{ $company->name }}</flux:text>
|
||
@endif
|
||
<flux:text class="text-xs text-zinc-500">{{ $company->slug }}</flux:text>
|
||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500">
|
||
@if ($company->email)
|
||
<span>{{ $company->email }}</span>
|
||
@endif
|
||
@if ($company->phone)
|
||
<span>{{ $company->phone }}</span>
|
||
@endif
|
||
@if ($company->website)
|
||
<span>{{ $company->website }}</span>
|
||
@endif
|
||
</div>
|
||
@if($company->address)
|
||
<flux:text class="mt-1 text-xs text-zinc-500">{{ $company->address }}</flux:text>
|
||
@endif
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<flux:badge color="zinc" size="sm">
|
||
{{ $this->companyUserRoleLabel($company->pivot?->role ?? 'member') }}
|
||
</flux:badge>
|
||
<flux:badge color="blue" size="sm">
|
||
{{ trans_choice(':count PM|:count PMs', $company->press_releases_count, ['count' => $company->press_releases_count]) }}
|
||
</flux:badge>
|
||
<flux:badge color="zinc" size="sm">
|
||
{{ trans_choice(':count Kontakt|:count Kontakte', $company->contacts_count, ['count' => $company->contacts_count]) }}
|
||
</flux:badge>
|
||
<flux:badge color="zinc" size="sm">
|
||
{{ $company->portal?->label() ?? '—' }}
|
||
</flux:badge>
|
||
@if ($company->disable_footer_code)
|
||
<flux:badge color="amber" size="sm">{{ __('Footer-Code aus') }}</flux:badge>
|
||
@endif
|
||
@if (!$company->is_active)
|
||
<flux:badge color="red" size="sm">{{ __('Inaktiv') }}</flux:badge>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-4 lg:grid-cols-2">
|
||
<div>
|
||
<flux:text weight="semibold" class="mb-2 text-sm">{{ __('Kontakte') }}</flux:text>
|
||
@if ($company->contacts->isNotEmpty())
|
||
<div class="space-y-2">
|
||
@foreach ($company->contacts as $contact)
|
||
<div class="flex flex-col gap-2 rounded-md bg-zinc-50 p-2 dark:bg-zinc-900 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="min-w-0">
|
||
<flux:text class="text-sm" weight="medium">
|
||
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
|
||
</flux:text>
|
||
<flux:text class="text-xs text-zinc-500">
|
||
{{ $contact->responsibility ?? __('Keine Rolle hinterlegt') }}
|
||
</flux:text>
|
||
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||
@if ($contact->email)
|
||
<a href="mailto:{{ $contact->email }}" class="text-blue-600 hover:underline dark:text-blue-400">{{ $contact->email }}</a>
|
||
@endif
|
||
@if ($contact->phone)
|
||
<span>{{ $contact->phone }}</span>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
|
||
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate>
|
||
{{ __('Bearbeiten') }}
|
||
</flux:button>
|
||
@endif
|
||
</div>
|
||
@endforeach
|
||
@if ($company->contacts_count > $company->contacts->count())
|
||
<flux:text class="text-xs text-zinc-500">
|
||
{{ __(':count weitere Kontakte werden hier nicht geladen. Öffne die Firma, um alle Kontakte zu sehen.', ['count' => $company->contacts_count - $company->contacts->count()]) }}
|
||
</flux:text>
|
||
@endif
|
||
</div>
|
||
@else
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Keine Kontakte bei dieser Firma') }}</flux:text>
|
||
@endif
|
||
</div>
|
||
|
||
<div>
|
||
<flux:text weight="semibold" class="mb-2 text-sm">{{ __('Aktuelle Pressemitteilungen') }}</flux:text>
|
||
@if ($company->pressReleases->isNotEmpty())
|
||
<div class="space-y-2">
|
||
@foreach ($company->pressReleases as $pressRelease)
|
||
<div class="rounded-md bg-zinc-50 p-2 dark:bg-zinc-900">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="min-w-0">
|
||
@if (\Illuminate\Support\Facades\Route::has('admin.press-releases.show'))
|
||
<a href="{{ route('admin.press-releases.show', $pressRelease->id) }}" wire:navigate class="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400">
|
||
{{ $pressRelease->title ?? __('Ohne Titel') }}
|
||
</a>
|
||
@else
|
||
<flux:text class="text-sm" weight="medium">{{ $pressRelease->title ?? __('Ohne Titel') }}</flux:text>
|
||
@endif
|
||
<flux:text class="text-xs text-zinc-500">
|
||
{{ $pressRelease->published_at?->format('d.m.Y') ?? $pressRelease->created_at?->format('d.m.Y') ?? '–' }}
|
||
· {{ $pressRelease->portal?->label() ?? '—' }}
|
||
· {{ __('Hits') }}: {{ $pressRelease->hits }}
|
||
</flux:text>
|
||
</div>
|
||
<flux:badge :color="$this->pressReleaseStatusColor($pressRelease->status?->value)" size="sm">
|
||
{{ $pressRelease->status?->label() ?? $pressRelease->status?->value ?? '–' }}
|
||
</flux:badge>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
@if ($company->press_releases_count > $company->pressReleases->count())
|
||
<flux:text class="text-xs text-zinc-500">
|
||
{{ __(':count weitere Pressemitteilungen werden hier nicht geladen.', ['count' => $company->press_releases_count - $company->pressReleases->count()]) }}
|
||
</flux:text>
|
||
@endif
|
||
</div>
|
||
@else
|
||
<flux:text class="text-xs text-zinc-500">{{ __('Keine Pressemitteilungen bei dieser Firma') }}</flux:text>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@empty
|
||
<flux:text class="text-sm text-zinc-500">{{ __('Keine Firmen verknüpft') }}</flux:text>
|
||
@endforelse
|
||
</div>
|
||
</flux:card>
|
||
@else
|
||
<flux:heading size="lg">{{ __('Benutzer nicht gefunden') }}</flux:heading>
|
||
@endif
|
||
</div>
|
||
</flux:modal>
|
||
</div>
|