presseportale/resources/views/livewire/admin/contacts/index.blade.php
Kevin Adametz 5b8bdf4182
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
12-05-2026 Frontend dev
2026-05-12 18:32:33 +02:00

729 lines
31 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\User;
use App\Models\UserFilterPreset;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\CurrentPortalContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
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('Kontakte')] class extends Component
{
use WithPagination;
public string $search = '';
#[Url(as: 'company', except: 'all')]
public string $companyFilter = 'all';
public string $companySearch = '';
#[Url(as: 'user', except: 'all')]
public string $userFilter = 'all';
public string $userSearch = '';
#[Url(as: 'data', except: 'all')]
public string $qualityFilter = 'all';
public string $portalFilter = 'all';
public ?int $selectedPresetId = null;
public string $presetName = '';
public string $notification = '';
public string $notificationType = 'success';
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 updatedCompanyFilter(): void
{
// Flux clearable setzt den Wert auf null normalisieren auf 'all'
if (blank($this->companyFilter)) {
$this->companyFilter = 'all';
}
$this->companySearch = '';
$this->resetPage();
}
public function updatedCompanySearch(): void
{
$this->resetPage();
}
public function updatedUserFilter(): void
{
if (blank($this->userFilter)) {
$this->userFilter = 'all';
}
$this->userSearch = '';
$this->resetPage();
}
public function updatedUserSearch(): void
{
$this->resetPage();
}
public function clearCompanySearch(): void
{
$this->companyFilter = 'all';
$this->companySearch = '';
$this->resetPage();
}
public function clearUserSearch(): void
{
$this->userFilter = 'all';
$this->userSearch = '';
$this->resetPage();
}
public function updatedQualityFilter(): void
{
$this->resetPage();
}
public function mount(): void
{
$currentUser = auth()->user();
if (! $currentUser) {
return;
}
$defaultPreset = UserFilterPreset::query()->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index')->where('is_default', true)->first();
if (! $defaultPreset) {
return;
}
$this->selectedPresetId = (int) $defaultPreset->id;
$filters = $defaultPreset->filters ?? [];
$this->search = (string) ($filters['search'] ?? '');
$this->companyFilter = (string) ($filters['company_filter'] ?? 'all');
$this->userFilter = (string) ($filters['user_filter'] ?? 'all');
$this->qualityFilter = (string) ($filters['quality_filter'] ?? 'all');
$this->portalFilter = (string) ($filters['portal_filter'] ?? 'all');
}
public function with(): array
{
$currentUser = auth()->user();
$contacts = Contact::query()
->with('company:id,name')
->withCount('pressReleases')
->when($this->search, function ($query): void {
$term = trim($this->search);
if ($this->supportsFullTextSearch($term)) {
$query->where(function ($query) use ($term): void {
$query->whereFullText(['first_name', 'last_name', 'email', 'responsibility'], $term)
->orWhereHas('company', fn ($companyQuery) => $companyQuery->whereFullText(['name', 'email', 'slug'], $term));
});
return;
}
$query->where(function ($searchQuery): void {
$searchQuery
->where('first_name', 'like', '%'.$this->search.'%')
->orWhere('last_name', 'like', '%'.$this->search.'%')
->orWhere('email', 'like', '%'.$this->search.'%')
->orWhereHas('company', function ($companyQuery): void {
$companyQuery->where('name', 'like', '%'.$this->search.'%');
});
});
})
->when($this->companyFilter !== 'all', function ($query): void {
$query->where('company_id', (int) $this->companyFilter);
})
->when($this->userFilter !== 'all', function ($query): void {
$query->whereHas('users', fn ($userQuery) => $userQuery->where('users.id', (int) $this->userFilter));
})
->when($this->qualityFilter !== 'all', function ($query): void {
match ($this->qualityFilter) {
'with_press_releases' => $query->whereHas('pressReleases'),
'without_press_releases' => $query->whereDoesntHave('pressReleases'),
default => null,
};
})
->when($this->portalFilter !== 'all', function ($query): void {
$query->where('portal', $this->portalFilter);
})
->orderBy(in_array($this->sortBy, ['last_name', 'email', 'company_id', 'press_releases_count', 'created_at'], true) ? $this->sortBy : 'created_at', $this->sortDir)
->simplePaginate(50);
// Firmen-Filter: nur Live-Suche, nie alle laden
$term = trim($this->companySearch);
$selectedCompanyId = $this->companyFilter !== 'all' ? (int) $this->companyFilter : null;
$filterCompanies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$q->where('name', 'like', '%'.$term.'%');
})
->when(blank($term) && $selectedCompanyId, fn ($q) => $q->whereIn('id', [$selectedCompanyId]))
->when(blank($term) && ! $selectedCompanyId, fn ($q) => $q->whereRaw('0 = 1'))
->orderBy('name')
->limit(50)
->get(['id', 'name']);
$userTerm = trim($this->userSearch);
$selectedUserId = $this->userFilter !== 'all' ? (int) $this->userFilter : null;
$filterUsers = User::query()
->select(['id', 'name', 'email'])
->where(function ($query) use ($userTerm, $selectedUserId): void {
if ($selectedUserId) {
$query->where('id', $selectedUserId);
}
if ($userTerm !== '') {
$query->orWhere(function ($searchQuery) use ($userTerm): void {
$searchQuery
->where('name', 'like', '%'.$userTerm.'%')
->orWhere('email', 'like', '%'.$userTerm.'%');
});
}
})
->when($userTerm === '' && ! $selectedUserId, fn ($query) => $query->whereRaw('0 = 1'))
->orderBy('name')
->limit(20)
->get();
return [
'contacts' => $contacts,
'filterCompanies' => $filterCompanies,
'filterUsers' => $filterUsers,
'portalOptions' => Portal::cases(),
'presets' => $currentUser
? UserFilterPreset::query()
->where('user_id', $currentUser->id)
->where('page', 'admin.contacts.index')
->orderByDesc('is_default')
->orderByDesc('last_used_at')
->orderBy('name')
->get(['id', 'name', 'is_default', 'last_used_at', 'filters'])
: collect(),
'stats' => $this->stats(),
];
}
/**
* @return array{total: int, companies_with_contacts: int, avg_per_company: float}
*/
private function stats(): array
{
$portal = CurrentPortalContext::get()?->value ?? 'all';
$cache = app(AdminPerformanceCache::class);
return $cache->remember($cache->contactsStatsKey($portal), AdminPerformanceCache::StatsTtl, function (): array {
$total = Contact::count();
$companiesWithContacts = Contact::query()
->distinct()
->count('company_id');
return [
'total' => $total,
'companies_with_contacts' => $companiesWithContacts,
'avg_per_company' => $companiesWithContacts > 0 ? round($total / $companiesWithContacts, 1) : 0.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 updatedPortalFilter(): void
{
$this->resetPage();
}
public function savePreset(): void
{
$currentUser = auth()->user();
if (! $currentUser) {
return;
}
$validated = $this->validate([
'presetName' => ['required', 'string', 'min:2', 'max:120', Rule::unique('user_filter_presets', 'name')->where(fn ($query) => $query->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index'))],
]);
UserFilterPreset::query()->create([
'user_id' => $currentUser->id,
'page' => 'admin.contacts.index',
'name' => $validated['presetName'],
'last_used_at' => now(),
'filters' => [
'search' => $this->search,
'company_filter' => $this->companyFilter,
'user_filter' => $this->userFilter,
'quality_filter' => $this->qualityFilter,
'portal_filter' => $this->portalFilter,
],
]);
$this->presetName = '';
$this->notification = __('Filter-Preset wurde gespeichert.');
$this->notificationType = 'success';
}
public function applyPreset(): void
{
$currentUser = auth()->user();
if (! $currentUser || ! $this->selectedPresetId) {
return;
}
$preset = UserFilterPreset::query()->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index')->find($this->selectedPresetId);
if (! $preset) {
return;
}
$filters = $preset->filters ?? [];
$this->search = (string) ($filters['search'] ?? '');
$this->companyFilter = (string) ($filters['company_filter'] ?? 'all');
$this->userFilter = (string) ($filters['user_filter'] ?? 'all');
$this->qualityFilter = (string) ($filters['quality_filter'] ?? 'all');
$this->portalFilter = (string) ($filters['portal_filter'] ?? 'all');
$preset->update(['last_used_at' => now()]);
$this->resetPage();
}
public function deletePreset(): void
{
$currentUser = auth()->user();
if (! $currentUser || ! $this->selectedPresetId) {
return;
}
UserFilterPreset::query()->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index')->whereKey($this->selectedPresetId)->delete();
$this->selectedPresetId = null;
$this->notification = __('Filter-Preset wurde gelöscht.');
$this->notificationType = 'success';
}
public function setDefaultPreset(): void
{
$currentUser = auth()->user();
if (! $currentUser || ! $this->selectedPresetId) {
return;
}
UserFilterPreset::query()
->where('user_id', $currentUser->id)
->where('page', 'admin.contacts.index')
->update(['is_default' => false]);
UserFilterPreset::query()
->where('user_id', $currentUser->id)
->where('page', 'admin.contacts.index')
->whereKey($this->selectedPresetId)
->update(['is_default' => true]);
$this->notification = __('Standard-Preset wurde gesetzt.');
$this->notificationType = 'success';
}
public function deleteContactFromIndex(int $contactId): void
{
$contact = Contact::query()->find($contactId);
if (! $contact) {
$this->notification = __('Der angeforderte Kontakt wurde nicht gefunden.');
$this->notificationType = 'error';
return;
}
$contact->delete();
$this->notification = __('Kontakt wurde gelöscht.');
$this->notificationType = 'success';
$this->resetPage();
}
private function portalBadgeColor(?Portal $portal): string
{
return match ($portal) {
Portal::Presseecho => 'blue',
Portal::Businessportal24 => 'purple',
Portal::Both => 'zinc',
default => 'zinc',
};
}
}; ?>
<div class="space-y-6">
@if ($notification)
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3000)" x-show="show" x-transition
class="rounded-md px-4 py-3 text-sm border
{{ $notificationType === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300' }}">
{{ $notification }}
</div>
@endif
{{-- Statistiken --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</div>
<flux:icon.user-group class="size-8 text-blue-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Firmen mit Kontakten') }}
</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['companies_with_contacts'] }}</flux:text>
</div>
<flux:icon.building-office class="size-8 text-green-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Ø pro Firma') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['avg_per_company'], 1) }}
</flux:text>
</div>
<flux:icon.chart-bar class="size-8 text-purple-500" />
</div>
</flux:card>
</div>
{{-- Filter & Actions --}}
<flux:card>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<flux:input wire:model.live.debounce.300ms="search"
placeholder="{{ __('Name, Email oder Firma suchen...') }}" icon="magnifying-glass" class="flex-1" />
<div class="flex w-full gap-2 xl:w-64">
<flux:select wire:model.live="companyFilter" variant="combobox" :filter="false" clearable
placeholder="{{ __('Alle Firmen') }}" class="min-w-0 flex-1">
<x-slot name="input">
<flux:select.input wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Firma suchen…') }}" />
</x-slot>
@foreach ($filterCompanies as $company)
<flux:select.option :value="$company->id" wire:key="fc-{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if (blank(trim($companySearch)))
{{ __('Name eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearCompanySearch"
title="{{ __('Firmensuche zurücksetzen') }}"
/>
</div>
<div class="flex w-full gap-2 xl:w-64">
<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="userSearch"
placeholder="{{ __('User suchen…') }}" />
</x-slot>
@foreach ($filterUsers as $user)
<flux:select.option :value="$user->id" wire:key="contact-user-{{ $user->id }}">
{{ $user->name }}
<span class="ml-1 text-zinc-400">· {{ $user->email }}</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if (blank(trim($userSearch)))
{{ __('Usernamen oder E-Mail eingeben…') }}
@else
{{ __('Kein User gefunden.') }}
@endif
</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>
<flux:select wire:model.live="qualityFilter" class="w-full xl:w-56">
<option value="all">{{ __('Alle Datenstände') }}</option>
<option value="with_press_releases">{{ __('Mit Pressemitteilungen') }}</option>
<option value="without_press_releases">{{ __('Ohne Pressemitteilungen') }}</option>
</flux:select>
<flux:select wire:model.live="portalFilter" class="w-full xl:w-48">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach ($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.create'))
<flux:button icon="plus" href="{{ route('admin.contacts.create') }}" wire:navigate>
{{ __('Neuer Kontakt') }}
</flux:button>
@else
<flux:button icon="plus" disabled>
{{ __('Neuer Kontakt') }}
</flux:button>
@endif
</div>
</div>
</flux:card>
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 gap-3">
<flux:input wire:model="presetName" placeholder="{{ __('Neues Preset speichern...') }}"
class="flex-1" />
<flux:button wire:click="savePreset" variant="subtle" icon="bookmark">
{{ __('Preset speichern') }}
</flux:button>
</div>
<div class="flex gap-3">
<flux:select wire:model="selectedPresetId" class="w-64">
<option value="">{{ __('Preset auswählen') }}</option>
@foreach ($presets as $preset)
<option value="{{ $preset->id }}">
{{ $preset->name }}{{ $preset->is_default ? ' (Standard)' : '' }}
</option>
@endforeach
</flux:select>
<flux:button wire:click="applyPreset" variant="ghost">{{ __('Anwenden') }}</flux:button>
<flux:button wire:click="setDefaultPreset" variant="ghost">{{ __('Als Standard') }}</flux:button>
<flux:button wire:click="deletePreset" variant="danger">{{ __('Löschen') }}</flux:button>
</div>
</div>
<flux:error name="presetName" class="mt-3" />
</flux:card>
{{-- Tabelle --}}
<flux:card class="overflow-hidden">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'last_name'" :direction="$sortDir"
wire:click="sort('last_name')">{{ __('Name') }}</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 === 'company_id'" :direction="$sortDir"
wire:click="sort('company_id')">{{ __('Firma') }}</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 === 'created_at'" :direction="$sortDir"
wire:click="sort('created_at')">{{ __('Hinzugefügt') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($contacts as $contact)
@php
$contactDisplayName =
trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?:
__('Kontakt ohne Name');
$contactCompanyName = $contact->company?->name ?? __('Unbekannte Firma');
@endphp
<flux:table.row :key="$contact->id">
<flux:table.cell>
<div class="flex gap-2">
@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 />
@endif
@if ($contact->company && \Illuminate\Support\Facades\Route::has('admin.companies.show'))
<flux:button size="sm" variant="ghost" icon="building-office"
href="{{ route('admin.companies.show', $contact->company_id) }}"
wire:navigate />
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div>
<flux:text weight="semibold truncate">
{{ $contactDisplayName }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<div class="space-y-1">
<flux:text class="text-sm">
<a href="mailto:{{ $contact->email }}"
class="text-blue-600 hover:underline dark:text-blue-400">
{{ $contact->email ?: __('Keine E-Mail') }}
</a>
</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $contact->phone ?: __('Kein Telefon') }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
@if ($contact->company && \Illuminate\Support\Facades\Route::has('admin.companies.show'))
<a href="{{ route('admin.companies.show', $contact->company_id) }}" wire:navigate
class="text-blue-600 hover:underline dark:text-blue-400">
{{ \Illuminate\Support\Str::limit($contact->company->name, 60) }}
</a>
@else
<flux:text>
{{ \Illuminate\Support\Str::limit($contact->company?->name ?? __('Unbekannte Firma'), 80) }}
</flux:text>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:badge color="{{ $this->portalBadgeColor($contact->portal) }}" size="sm">
{{ $contact->portal?->label() ?? __('Unbekannt') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if ($contact->press_releases_count > 0)
<flux:button
size="sm"
variant="ghost"
href="{{ route('admin.press-releases.index', ['contact' => $contact->id]) }}"
wire:navigate
>
{{ $contact->press_releases_count }} {{ __('PMs') }}
</flux:button>
@else
<flux:badge color="zinc" size="sm">0</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">
{{ $contact->created_at?->format('d.m.Y H:i') ?? '-' }}
</flux:text>
</flux:table.cell>
<flux:table.cell>
<div class="flex gap-2">
<flux:modal.trigger name="confirm-contact-delete-{{ $contact->id }}">
<flux:button size="sm" variant="ghost" icon="trash" type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-contact-delete-{{ $contact->id }}')" />
</flux:modal.trigger>
<flux:button size="sm" variant="ghost" icon="envelope"
href="mailto:{{ $contact->email }}" />
</div>
<flux:modal name="confirm-contact-delete-{{ $contact->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Kontakt wirklich löschen?') }}
</flux:heading>
<flux:subheading>
{{ __('Du löschst: :contact (Firma: :company). Dieser Kontakt wird archiviert (Soft Delete) und aus den Standardlisten entfernt.', ['contact' => $contactDisplayName, 'company' => $contactCompanyName]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger"
wire:click="deleteContactFromIndex({{ $contact->id }})">
{{ __('Löschung bestätigen') }}
</flux:button>
</div>
</div>
</flux:modal>
</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">
<flux:icon.user-group class="size-12 text-zinc-400 dark:text-zinc-600" />
<flux:text class="mt-4 text-zinc-500">{{ __('Keine Kontakte gefunden') }}</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">
{{ $contacts->links() }}
</div>
</flux:card>
</div>