presseportale/resources/views/livewire/admin/press-releases/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

719 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\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
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('Pressemitteilungen')] class extends Component
{
use WithPagination;
public string $search = '';
#[Url(as: 'status', except: 'all')]
public string $statusFilter = 'all';
public string $portalFilter = 'all';
public string $languageFilter = 'all';
#[Url(as: 'category', except: 'all')]
public string $categoryFilter = 'all';
#[Url(as: 'user', except: 'all')]
public string $userFilter = 'all';
#[Url(as: 'company', except: 'all')]
public string $companyFilter = 'all';
#[Url(as: 'contact', except: 'all')]
public string $contactFilter = 'all';
public string $userLookup = '';
public string $companyLookup = '';
public string $contactLookup = '';
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 updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function updatedLanguageFilter(): void
{
$this->resetPage();
}
public function updatedCategoryFilter(): void
{
$this->resetPage();
}
public function updatedUserFilter(): void
{
$this->resetPage();
}
public function updatedCompanyFilter(): void
{
$this->resetPage();
}
public function updatedContactFilter(): void
{
$this->resetPage();
}
public function clearUserFilter(): void
{
$this->userFilter = 'all';
$this->userLookup = '';
$this->resetPage();
}
public function clearCompanyFilter(): void
{
$this->companyFilter = 'all';
$this->companyLookup = '';
$this->resetPage();
}
public function clearContactFilter(): void
{
$this->contactFilter = 'all';
$this->contactLookup = '';
$this->resetPage();
}
public function resetEntityFilters(): void
{
$this->clearUserFilter();
$this->clearCompanyFilter();
$this->clearContactFilter();
}
public function publish(int $id): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
return;
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', __('Pressemitteilung veröffentlicht.'));
}
public function reject(int $id): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
try {
app(PressReleaseService::class)->reject($pr, __('Bitte überarbeiten Sie die Pressemitteilung.'));
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', __('Pressemitteilung abgelehnt.'));
}
public function archive(int $id): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
try {
app(PressReleaseService::class)->archive($pr);
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', __('Pressemitteilung archiviert.'));
}
public function with(): array
{
$query = PressRelease::withoutGlobalScopes()
->with(['company:id,name', 'category.translations', 'user:id,name'])
->when(filled($this->search), function ($q): void {
$term = trim($this->search);
$q->where(function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['title', 'keywords'], $term)
->orWhereHas('company', fn ($q) => $q->whereFullText(['name', 'email', 'slug'], $term));
return;
}
$q->where('title', 'like', '%'.$term.'%')
->orWhere('keywords', 'like', '%'.$term.'%')
->orWhereHas('company', fn ($q) => $q->where('name', 'like', '%'.$term.'%'));
});
})
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
->when($this->categoryFilter !== 'all', fn ($q) => $q->where('category_id', (int) $this->categoryFilter))
->when($this->userFilter !== 'all', fn ($q) => $q->where('user_id', (int) $this->userFilter))
->when($this->companyFilter !== 'all', fn ($q) => $q->where('company_id', (int) $this->companyFilter))
->when($this->contactFilter !== 'all', fn ($q) => $q->whereHas('contacts', fn ($contactQuery) => $contactQuery->where('contacts.id', (int) $this->contactFilter)))
->orderBy(in_array($this->sortBy, ['title', 'status', 'portal', 'hits', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
->simplePaginate(50);
return [
'pressReleases' => $query,
'stats' => $this->pressReleaseStats(),
'statusOptions' => PressReleaseStatus::cases(),
'portalOptions' => Portal::cases(),
'categoryOptions' => $this->categoryOptions(),
'userLookupResults' => $this->userLookupResults(),
'companyLookupResults' => $this->companyLookupResults(),
'contactLookupResults' => $this->contactLookupResults(),
];
}
/**
* @return array{total: int, published: int, review: int, draft: int}
*/
private function pressReleaseStats(): array
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PressReleaseStats, AdminPerformanceCache::StatsTtl, function (): array {
$stats = PressRelease::withoutGlobalScopes()
->toBase()
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
->first();
return [
'total' => (int) ($stats->total ?? 0),
'published' => (int) ($stats->published ?? 0),
'review' => (int) ($stats->review ?? 0),
'draft' => (int) ($stats->draft ?? 0),
];
});
}
private function categoryOptions()
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PressReleaseCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
->select(['id', 'is_active'])
->with(['translations:id,category_id,locale,name,slug'])
->where('is_active', true)
->orderBy('id')
->get());
}
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 companyLookupResults()
{
$term = trim($this->companyLookup);
if ($term === '' && $this->companyFilter === 'all') {
return collect();
}
return Company::withoutGlobalScopes()
->select(['id', 'name', 'slug', 'email'])
->where(function ($query) use ($term): void {
if ($this->companyFilter !== 'all') {
$query->where('id', (int) $this->companyFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('name', 'like', '%'.$term.'%')
->orWhere('slug', '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();
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<div class="space-y-6">
@if (session('success'))
<div
class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
{{ session('success') }}
</div>
@endif
{{-- Statistiken --}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-green-600">{{ __('Veröffentlicht') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['published'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-yellow-600">{{ __('In Prüfung') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['review'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Entwürfe') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['draft'] }}</flux:text>
</flux:card>
</div>
<div class="flex justify-end">
<flux:button icon="plus" variant="primary" href="{{ route('admin.press-releases.create') }}" wire:navigate>
{{ __('Neue PM') }}
</flux:button>
</div>
{{-- Filter --}}
<flux:card>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-6">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Titel, Stichwort, Firma…') }}"
icon="magnifying-glass"
class="lg:col-span-2"
/>
<flux:select wire:model.live="statusFilter" class="w-full">
<option value="all">{{ __('Alle Status') }}</option>
@foreach ($statusOptions as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="portalFilter" class="w-full">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach ($portalOptions as $p)
@if ($p !== \App\Enums\Portal::Both)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endif
@endforeach
</flux:select>
<flux:select wire:model.live="languageFilter" class="w-full">
<option value="all">{{ __('Alle Sprachen') }}</option>
<option value="de">DE</option>
<option value="en">EN</option>
</flux:select>
<flux:select wire:model.live="categoryFilter" class="w-full">
<option value="all">{{ __('Alle Kategorien') }}</option>
@foreach ($categoryOptions as $categoryOption)
@php($categoryName = $categoryOption->translations->firstWhere('locale', 'de')?->name ?? '#' . $categoryOption->id)
<option value="{{ $categoryOption->id }}">{{ $categoryName }}</option>
@endforeach
</flux:select>
</div>
<div class="grid gap-3 lg:grid-cols-3">
<div class="flex gap-2">
<flux:select
wire:model.live="userFilter"
variant="combobox"
:filter="false"
placeholder="{{ __('User suchen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="userLookup"
placeholder="{{ __('User suchen…') }}"
/>
</x-slot>
<flux:select.option value="all">{{ __('Alle User') }}</flux:select.option>
@foreach($userLookupResults as $userOption)
<flux:select.option :value="$userOption->id" wire:key="pm-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)) ? __('Zum Laden 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="clearUserFilter"
title="{{ __('Usersuche zurücksetzen') }}"
/>
</div>
<div class="flex gap-2">
<flux:select
wire:model.live="companyFilter"
variant="combobox"
:filter="false"
placeholder="{{ __('Firma suchen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companyLookup"
placeholder="{{ __('Firma, Slug oder E-Mail…') }}"
/>
</x-slot>
<flux:select.option value="all">{{ __('Alle Firmen') }}</flux:select.option>
@foreach($companyLookupResults as $companyOption)
<flux:select.option :value="$companyOption->id" wire:key="pm-company-{{ $companyOption->id }}">
{{ $companyOption->name }}
@if($companyOption->email)<span class="ml-1 text-zinc-400">· {{ $companyOption->email }}</span>@endif
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($companyLookup)) ? __('Zum Laden Firmennamen, Slug oder E-Mail eingeben.') : __('Keine Firma gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearCompanyFilter"
title="{{ __('Firmensuche zurücksetzen') }}"
/>
</div>
<div class="flex gap-2">
<flux:select
wire:model.live="contactFilter"
variant="combobox"
:filter="false"
placeholder="{{ __('Kontakt suchen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="contactLookup"
placeholder="{{ __('Kontakt oder E-Mail…') }}"
/>
</x-slot>
<flux:select.option value="all">{{ __('Alle Kontakte') }}</flux:select.option>
@foreach($contactLookupResults as $contactOption)
@php($contactName = trim(($contactOption->first_name ?? '').' '.($contactOption->last_name ?? '')) ?: __('Kontakt ohne Name'))
<flux:select.option :value="$contactOption->id" wire:key="pm-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)) ? __('Zum Laden 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="clearContactFilter"
title="{{ __('Kontaktsuche zurücksetzen') }}"
/>
</div>
</div>
</div>
</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 === 'title'" :direction="$sortDir"
wire:click="sort('title')">{{ __('Titel') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDir"
wire:click="sort('created_at')">{{ __('Erstellt') }}</flux:table.column>
<flux:table.column>{{ __('Kategorie') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'status'" :direction="$sortDir"
wire:click="sort('status')">{{ __('Status') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'portal'" :direction="$sortDir"
wire:click="sort('portal')">{{ __('Portal') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'hits'" :direction="$sortDir"
wire:click="sort('hits')">
{{ __('Hits') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse($pressReleases as $pr)
<flux:table.row wire:key="{{ $pr->id }}">
<flux:table.cell>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="eye"
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate />
<flux:button size="sm" variant="ghost" icon="pencil"
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate />
</div>
</flux:table.cell>
<flux:table.cell>
<div class="max-w-xs">
<p class="truncate font-medium">{{ $pr->title ?? '' }}</p>
<p class="text-sm truncate text-zinc-400">
{{ $pr->company?->name ?? '' . ' | ' . strtoupper($pr->language) }}
</p>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $pr->created_at->format('d.m.Y H:i') }}</flux:text>
</flux:table.cell>
<flux:table.cell>
@php($categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '')
<div class="max-w-48">
<flux:text class="truncate text-sm" title="{{ $categoryName }}">{{ $categoryName }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:badge
color="{{ match ($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'draft' => 'zinc',
'rejected' => 'red',
'archived' => 'blue',
} }}">
{{ $pr->status->label() }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm">{{ $pr->portal->label() }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm">{{ number_format($pr->hits) }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1">
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal.trigger name="confirm-index-publish-{{ $pr->id }}">
<flux:button size="sm" variant="ghost" icon="check-circle"
class="text-green-600" />
</flux:modal.trigger>
<flux:modal.trigger name="confirm-index-reject-{{ $pr->id }}">
<flux:button size="sm" variant="ghost" icon="x-circle" class="text-red-600" />
</flux:modal.trigger>
@endif
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:modal.trigger name="confirm-index-archive-{{ $pr->id }}">
<flux:button size="sm" variant="ghost" icon="archive-box"
class="text-zinc-500" />
</flux:modal.trigger>
@endif
</div>
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal name="confirm-index-publish-{{ $pr->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung veröffentlichen?') }}
</flux:heading>
<flux:subheading>
{{ __('Diese Aktion veröffentlicht die Pressemitteilung ":title".', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
</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="primary" wire:click="publish({{ $pr->id }})">
{{ __('Veröffentlichen') }}</flux:button>
</div>
</div>
</flux:modal>
<flux:modal name="confirm-index-reject-{{ $pr->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung ablehnen?') }}
</flux:heading>
<flux:subheading>
{{ __('Diese Aktion lehnt die Pressemitteilung ":title" ab.', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
</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="reject({{ $pr->id }})">
{{ __('Ablehnen') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:modal name="confirm-index-archive-{{ $pr->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung archivieren?') }}
</flux:heading>
<flux:subheading>
{{ __('Diese Aktion archiviert die Pressemitteilung ":title".', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
</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="primary" wire:click="archive({{ $pr->id }})">
{{ __('Archivieren') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
</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-10">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text class="mt-3 text-zinc-500">{{ __('Keine Pressemitteilungen gefunden.') }}
</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</flux:card>
{{ $pressReleases->links() }}
</div>