presseportale/resources/views/livewire/admin/companies/index.blade.php
Kevin Adametz e8c47b7553
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
22-05-2026 Optimierung der User und Admin Panels
2026-05-22 11:18:59 +02:00

592 lines
25 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\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\CurrentPortalContext;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $activeFilter = 'all';
#[Url(as: 'portal', except: 'all')]
public string $portalFilter = 'all';
#[Url(as: 'user', except: 'all')]
public string $userFilter = 'all';
public string $userLookup = '';
#[Url(as: 'contact', except: 'all')]
public string $contactFilter = 'all';
public string $contactLookup = '';
#[Url(as: 'data', except: 'all')]
public string $qualityFilter = 'all';
public string $sortBy = 'created_at';
public string $sortDir = 'desc';
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', 'is_active', 'press_releases_count', 'contacts_count', 'created_at'];
$sort = in_array($this->sortBy, $sortable) ? $this->sortBy : 'name';
$sortsByCount = in_array($sort, ['press_releases_count', 'contacts_count'], true);
$companiesQuery = Company::query()
->when($sortsByCount, fn ($query) => $query->withCount(['pressReleases', 'contacts']))
->when($this->search, function ($query): void {
$term = trim($this->search);
if ($this->supportsFullTextSearch($term)) {
$query->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$query->where(function ($searchQuery): void {
$searchQuery->where('name', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$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->userFilter !== 'all', function ($query): void {
$query->where(function ($userScopedQuery): void {
$userScopedQuery
->where('owner_user_id', (int) $this->userFilter)
->orWhereHas('users', fn ($userQuery) => $userQuery->where('users.id', (int) $this->userFilter));
});
})
->when($this->contactFilter !== 'all', function ($query): void {
$query->whereHas('contacts', fn ($contactQuery) => $contactQuery->where('contacts.id', (int) $this->contactFilter));
})
->when($this->qualityFilter !== 'all', function ($query): void {
match ($this->qualityFilter) {
'with_press_releases' => $query->whereHas('pressReleases'),
'without_press_releases' => $query->whereDoesntHave('pressReleases'),
'with_contacts' => $query->whereHas('contacts'),
'without_contacts' => $query->whereDoesntHave('contacts'),
default => null,
};
})
->orderBy($sort, $this->sortDir);
$companies = $companiesQuery->paginate(50);
if (! $sortsByCount) {
$this->hydrateCounts($companies);
}
return [
'companies' => $companies,
'stats' => $this->stats(),
'portalOptions' => Portal::cases(),
'userLookupResults' => $this->userLookupResults(),
'contactLookupResults' => $this->contactLookupResults(),
];
}
/**
* @return array{total: int, active: int, inactive: int}
*/
private function stats(): array
{
$portal = CurrentPortalContext::get()?->value ?? 'all';
$cache = app(AdminPerformanceCache::class);
return $cache->remember($cache->companiesStatsKey($portal), AdminPerformanceCache::StatsTtl, function (): array {
$stats = Company::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 hydrateCounts($companies): void
{
$companyIds = $companies->getCollection()->pluck('id');
if ($companyIds->isEmpty()) {
return;
}
$pressReleaseCounts = PressRelease::query()
->whereIn('company_id', $companyIds)
->selectRaw('company_id, COUNT(*) as aggregate')
->groupBy('company_id')
->pluck('aggregate', 'company_id');
$contactCounts = Contact::query()
->whereIn('company_id', $companyIds)
->selectRaw('company_id, COUNT(*) as aggregate')
->groupBy('company_id')
->pluck('aggregate', 'company_id');
$companies->getCollection()->each(function (Company $company) use ($pressReleaseCounts, $contactCounts): void {
$company->setAttribute('press_releases_count', (int) $pressReleaseCounts->get($company->id, 0));
$company->setAttribute('contacts_count', (int) $contactCounts->get($company->id, 0));
});
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedActiveFilter(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function updatedUserFilter(): void
{
if (blank($this->userFilter)) {
$this->userFilter = 'all';
}
$this->userLookup = '';
$this->resetPage();
}
public function updatedContactFilter(): void
{
if (blank($this->contactFilter)) {
$this->contactFilter = 'all';
}
$this->contactLookup = '';
$this->resetPage();
}
public function updatedQualityFilter(): void
{
$this->resetPage();
}
public function clearUserSearch(): void
{
$this->userFilter = 'all';
$this->userLookup = '';
$this->resetPage();
}
public function clearContactSearch(): void
{
$this->contactFilter = 'all';
$this->contactLookup = '';
$this->resetPage();
}
private function portalBadgeColor(?Portal $portal): string
{
return match ($portal) {
Portal::Presseecho => 'blue',
Portal::Businessportal24 => 'purple',
Portal::Both => 'zinc',
default => 'zinc',
};
}
private function userLookupResults()
{
$term = trim($this->userLookup);
if ($term === '' && $this->userFilter === 'all') {
return collect();
}
return User::query()
->select(['id', 'name', 'email'])
->where(function ($query) use ($term): void {
if ($this->userFilter !== 'all') {
$query->where('id', (int) $this->userFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('name', 'like', '%'.$term.'%')
->orWhere('email', 'like', '%'.$term.'%');
});
}
})
->orderBy('name')
->limit(20)
->get();
}
private function contactLookupResults()
{
$term = trim($this->contactLookup);
if ($term === '' && $this->contactFilter === 'all') {
return collect();
}
return Contact::withoutGlobalScopes()
->select(['id', 'company_id', 'first_name', 'last_name', 'email'])
->with('company:id,name')
->where(function ($query) use ($term): void {
if ($this->contactFilter !== 'all') {
$query->where('id', (int) $this->contactFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('first_name', 'like', '%'.$term.'%')
->orWhere('last_name', 'like', '%'.$term.'%')
->orWhere('email', 'like', '%'.$term.'%');
});
}
})
->orderBy('last_name')
->orderBy('first_name')
->limit(20)
->get();
}
}; ?>
<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">{{ __('Stammdaten · Firmen') }}</span>
</div>
<h1 class="text-[34px] font-bold tracking-[-0.7px] leading-[1.1] m-0 text-[color:var(--color-ink)]">
{{ __('Firmen') }}
</h1>
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Übersicht aller Firmen beider Portale, mit Filter, Datenqualität und Schnellaktionen.') }}
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<flux:button icon="plus" variant="primary" href="{{ route('admin.companies.create') }}" wire:navigate>
{{ __('Neue Firma') }}
</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>{{ __('alle Portale') }}</x-slot:meta>
<x-slot:trend>{{ __('Stammdaten-Basis') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Aktiv')" :value="number_format($stats['active'])">
<x-slot:meta>{{ __('produktiv') }}</x-slot:meta>
<x-slot:trend>{{ __('für PMs nutzbar') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Inaktiv')" :value="number_format($stats['inactive'])">
<x-slot:meta>{{ __('pausiert') }}</x-slot:meta>
<x-slot:trend>{{ __('archiviert oder geblockt') }}</x-slot:trend>
</x-portal.stat-card>
</section>
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
</div>
<div class="p-5 flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-7">
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Suchen...') }}"
icon="magnifying-glass" class="xl:col-span-2" />
<flux:select wire:model.live="activeFilter" class="w-full">
<option value="all">{{ __('Alle') }}</option>
<option value="active">{{ __('Aktiv') }}</option>
<option value="inactive">{{ __('Inaktiv') }}</option>
</flux:select>
<flux:select wire:model.live="portalFilter" class="w-full">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="qualityFilter" class="w-full">
<option value="all">{{ __('Alle Datenstände') }}</option>
<option value="with_press_releases">{{ __('Mit Pressemitteilungen') }}</option>
<option value="without_press_releases">{{ __('Ohne Pressemitteilungen') }}</option>
<option value="with_contacts">{{ __('Mit Kontakten') }}</option>
<option value="without_contacts">{{ __('Ohne Kontakte') }}</option>
</flux:select>
<div class="flex gap-2">
<flux:select
wire:model.live="userFilter"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Alle User') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input wire:model.live.debounce.300ms="userLookup" placeholder="{{ __('User suchen…') }}" />
</x-slot>
@foreach($userLookupResults as $userOption)
<flux:select.option :value="$userOption->id" wire:key="company-user-{{ $userOption->id }}">
{{ $userOption->name }}
<span class="ml-1 text-zinc-400">· {{ $userOption->email }}</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($userLookup)) ? __('Usernamen oder E-Mail eingeben…') : __('Kein User gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearUserSearch"
title="{{ __('Usersuche zurücksetzen') }}"
/>
</div>
<div class="flex gap-2">
<flux:select
wire:model.live="contactFilter"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Alle Kontakte') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input wire:model.live.debounce.300ms="contactLookup" placeholder="{{ __('Kontakt suchen…') }}" />
</x-slot>
@foreach($contactLookupResults as $contactOption)
@php($contactName = trim(($contactOption->first_name ?? '').' '.($contactOption->last_name ?? '')) ?: __('Kontakt ohne Name'))
<flux:select.option :value="$contactOption->id" wire:key="company-contact-{{ $contactOption->id }}">
{{ $contactName }}
<span class="ml-1 text-zinc-400">
@if($contactOption->email)· {{ $contactOption->email }} @endif
· {{ $contactOption->company?->name ?? __('Unbekannte Firma') }}
</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($contactLookup)) ? __('Kontaktname oder E-Mail eingeben…') : __('Kein Kontakt gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearContactSearch"
title="{{ __('Kontaktsuche zurücksetzen') }}"
/>
</div>
</div>
</div>
</article>
{{-- ============== TABELLE-PANEL ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Alle Firmen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $companies->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')">
{{ __('Firma') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'email'" :direction="$sortDir"
wire:click="sort('email')">{{ __('Kontakt') }}</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>{{ __('Portal') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'press_releases_count'" :direction="$sortDir"
wire:click="sort('press_releases_count')">{{ __('PMs') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'contacts_count'" :direction="$sortDir"
wire:click="sort('contacts_count')">{{ __('Kontakte') }}</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($companies as $company)
<flux:table.row :key="$company->id">
@php($logoUrl = $company->logoUrl())
<flux:table.cell>
<div class="flex gap-2">
<flux:button size="sm" variant="ghost" icon="pencil"
href="{{ route('admin.companies.edit', $company->id) }}" wire:navigate />
<flux:button size="sm" variant="ghost" icon="eye"
href="{{ route('admin.companies.show', $company->id) }}" wire:navigate />
</div>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-3">
@if ($logoUrl)
<img src="{{ $logoUrl }}" width="36" height="36"
class="h-9 max-h-9 w-9 max-w-9 rounded-[4px] border border-[color:var(--color-bg-rule)] object-contain bg-[color:var(--color-bg-elev)]"
alt="{{ $company->name }}">
@else
<div class="flex h-9 w-9 items-center justify-center rounded-[4px]
border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<flux:icon.building-office class="size-5 text-[color:var(--color-ink-3)]" />
</div>
@endif
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ \Illuminate\Support\Str::limit($company->name, 60) }}
</span>
</div>
</flux:table.cell>
<flux:table.cell>
<div class="space-y-0.5">
<div class="text-[12.5px] text-[color:var(--color-ink)]">
{{ $company->email ?: __('Keine E-Mail') }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $company->phone ?: __('Kein Telefon') }}
</div>
</div>
</flux:table.cell>
<flux:table.cell>
@if ($company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="badge hub">{{ $company->portal?->label() ?? __('Unbekannt') }}</span>
</flux:table.cell>
<flux:table.cell>
@if ($company->press_releases_count > 0)
<flux:button
size="sm"
variant="ghost"
href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}"
wire:navigate
>
{{ $company->press_releases_count }} {{ __('PMs') }}
</flux:button>
@else
<span class="badge hub">0</span>
@endif
</flux:table.cell>
<flux:table.cell>
@if ($company->contacts_count > 0)
<flux:button
size="sm"
variant="ghost"
href="{{ route('admin.contacts.index', ['company' => $company->id]) }}"
wire:navigate
>
{{ $company->contacts_count }} {{ __('Kontakte') }}
</flux:button>
@else
<span class="badge hub">0</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $company->created_at?->format('d.m.Y H:i') ?? '' }}
</span>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-4
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.building-office class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Firmen gefunden') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Passen Sie die Filter an oder legen Sie eine neue Firma an.') }}
</p>
</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">
{{ $companies->links('components.portal.pagination') }}
</div>
</article>
</div>