1033 lines
49 KiB
PHP
1033 lines
49 KiB
PHP
<?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 setView(string $view): void
|
||
{
|
||
$this->statusFilter = $view;
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function resetFilters(): void
|
||
{
|
||
$this->search = '';
|
||
$this->statusFilter = 'all';
|
||
$this->portalFilter = 'all';
|
||
$this->languageFilter = 'all';
|
||
$this->categoryFilter = 'all';
|
||
$this->clearUserFilter();
|
||
$this->clearCompanyFilter();
|
||
$this->clearContactFilter();
|
||
$this->resetPage();
|
||
}
|
||
|
||
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, rejected: int, archived: 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])
|
||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as rejected', [PressReleaseStatus::Rejected->value])
|
||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as archived', [PressReleaseStatus::Archived->value])
|
||
->first();
|
||
|
||
return [
|
||
'total' => (int) ($stats->total ?? 0),
|
||
'published' => (int) ($stats->published ?? 0),
|
||
'review' => (int) ($stats->review ?? 0),
|
||
'draft' => (int) ($stats->draft ?? 0),
|
||
'rejected' => (int) ($stats->rejected ?? 0),
|
||
'archived' => (int) ($stats->archived ?? 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-8">
|
||
@if (session('success'))
|
||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||
{{ session('success') }}
|
||
</div>
|
||
@endif
|
||
@if (session('error'))
|
||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
|
||
{{ session('error') }}
|
||
</div>
|
||
@endif
|
||
|
||
{{-- ============== 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">{{ __('Content · Pressemitteilungen') }}</span>
|
||
</div>
|
||
<h1 class="text-[34px] font-bold tracking-[-0.7px] leading-[1.1] m-0 text-[color:var(--color-ink)]">
|
||
{{ __('Pressemitteilungen') }}
|
||
</h1>
|
||
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||
{{ __('Übersicht aller PMs beider Portale, mit Filter, Status-Workflow und Schnellaktionen.') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3 flex-shrink-0">
|
||
<flux:button icon="plus" variant="primary" href="{{ route('admin.press-releases.create') }}" wire:navigate>
|
||
{{ __('Neue PM') }}
|
||
</flux:button>
|
||
</div>
|
||
</header>
|
||
|
||
{{-- ============== KPI-Reihe ============== --}}
|
||
<section class="grid gap-4 grid-cols-2 sm:grid-cols-4">
|
||
<x-portal.stat-card variant="primary" :label="__('Gesamt')" :value="number_format($stats['total'])">
|
||
<x-slot:meta>{{ now()->format('Y') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('über beide Portale') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="ok" :label="__('Veröffentlicht')" :value="number_format($stats['published'])">
|
||
<x-slot:meta>{{ __('live') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('öffentlich sichtbar') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="warn" :label="__('In Prüfung')" :value="number_format($stats['review'])">
|
||
<x-slot:meta>{{ __('queue') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('redaktionelle Prüfung') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="muted" :label="__('Entwürfe')" :value="number_format($stats['draft'])">
|
||
<x-slot:meta>{{ __('privat') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('nicht eingereicht') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
</section>
|
||
|
||
{{-- ============== SAVED-VIEWS-TABS ============== --}}
|
||
<nav class="view-tabs" aria-label="{{ __('Gespeicherte Ansichten') }}">
|
||
<button type="button" wire:click="setView('all')"
|
||
@class(['view-tab', 'is-active' => $statusFilter === 'all'])>
|
||
{{ __('Alle') }} <span class="cnt">{{ number_format($stats['total']) }}</span>
|
||
</button>
|
||
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Review->value }}')"
|
||
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Review->value])>
|
||
{{ __('In Prüfung') }} <span class="cnt">{{ number_format($stats['review']) }}</span>
|
||
</button>
|
||
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Published->value }}')"
|
||
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Published->value])>
|
||
{{ __('Veröffentlicht') }} <span class="cnt">{{ number_format($stats['published']) }}</span>
|
||
</button>
|
||
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Draft->value }}')"
|
||
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Draft->value])>
|
||
{{ __('Entwürfe') }} <span class="cnt">{{ number_format($stats['draft']) }}</span>
|
||
</button>
|
||
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Rejected->value }}')"
|
||
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Rejected->value])>
|
||
{{ __('Abgelehnt') }} <span class="cnt">{{ number_format($stats['rejected'] ?? 0) }}</span>
|
||
</button>
|
||
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Archived->value }}')"
|
||
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Archived->value])>
|
||
{{ __('Archiv') }} <span class="cnt">{{ number_format($stats['archived'] ?? 0) }}</span>
|
||
</button>
|
||
</nav>
|
||
|
||
{{-- ============== 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-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;
|
||
@endphp
|
||
<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');
|
||
@endphp
|
||
<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>
|
||
|
||
{{-- Active-Chips --}}
|
||
@php
|
||
$hasAnyFilter = $search !== ''
|
||
|| $statusFilter !== 'all'
|
||
|| $portalFilter !== 'all'
|
||
|| $languageFilter !== 'all'
|
||
|| $categoryFilter !== 'all'
|
||
|| $userFilter !== 'all'
|
||
|| $companyFilter !== 'all'
|
||
|| $contactFilter !== 'all';
|
||
@endphp
|
||
@if ($hasAnyFilter)
|
||
<div class="flex items-center gap-2 flex-wrap text-[11.5px] pt-1">
|
||
<span class="eyebrow muted" style="margin-right:2px;">{{ __('Aktiv') }}</span>
|
||
|
||
@if ($search !== '')
|
||
<span class="active-chip">
|
||
<span>{{ __('Suche') }}:
|
||
<strong>„{{ \Illuminate\Support\Str::limit($search, 40) }}"</strong></span>
|
||
<button type="button" class="x" wire:click="$set('search', '')"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
@if ($statusFilter !== 'all')
|
||
@php $statusEnum = \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
|
||
<span class="active-chip">
|
||
<span>{{ __('Status') }}:
|
||
<strong>{{ $statusEnum?->label() ?? $statusFilter }}</strong></span>
|
||
<button type="button" class="x" wire:click="setView('all')"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
@if ($portalFilter !== 'all')
|
||
@php $portalEnum = \App\Enums\Portal::tryFrom($portalFilter); @endphp
|
||
<span class="active-chip">
|
||
<span>{{ __('Portal') }}:
|
||
<strong>{{ $portalEnum?->label() ?? $portalFilter }}</strong></span>
|
||
<button type="button" class="x" wire:click="$set('portalFilter', 'all')"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
@if ($languageFilter !== 'all')
|
||
<span class="active-chip">
|
||
<span>{{ __('Sprache') }}:
|
||
<strong>{{ strtoupper($languageFilter) }}</strong></span>
|
||
<button type="button" class="x" wire:click="$set('languageFilter', 'all')"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
@if ($categoryFilter !== 'all')
|
||
@php
|
||
$activeCat = $categoryOptions->firstWhere('id', (int) $categoryFilter);
|
||
$activeCatName = $activeCat?->translations->firstWhere('locale', 'de')?->name
|
||
?? '#'.$categoryFilter;
|
||
@endphp
|
||
<span class="active-chip">
|
||
<span>{{ __('Kategorie') }}: <strong>{{ $activeCatName }}</strong></span>
|
||
<button type="button" class="x" wire:click="$set('categoryFilter', 'all')"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
@if ($userFilter !== 'all')
|
||
@php $activeUser = $userLookupResults->firstWhere('id', (int) $userFilter); @endphp
|
||
<span class="active-chip">
|
||
<span>{{ __('User') }}:
|
||
<strong>{{ $activeUser?->name ?? '#'.$userFilter }}</strong></span>
|
||
<button type="button" class="x" wire:click="clearUserFilter"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
@if ($companyFilter !== 'all')
|
||
@php $activeCompany = $companyLookupResults->firstWhere('id', (int) $companyFilter); @endphp
|
||
<span class="active-chip">
|
||
<span>{{ __('Firma') }}:
|
||
<strong>{{ $activeCompany?->name ?? '#'.$companyFilter }}</strong></span>
|
||
<button type="button" class="x" wire:click="clearCompanyFilter"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
@if ($contactFilter !== 'all')
|
||
@php
|
||
$activeContact = $contactLookupResults->firstWhere('id', (int) $contactFilter);
|
||
$contactName = $activeContact
|
||
? trim(($activeContact->first_name ?? '').' '.($activeContact->last_name ?? ''))
|
||
: '';
|
||
$contactName = $contactName !== '' ? $contactName : '#'.$contactFilter;
|
||
@endphp
|
||
<span class="active-chip">
|
||
<span>{{ __('Kontakt') }}: <strong>{{ $contactName }}</strong></span>
|
||
<button type="button" class="x" wire:click="clearContactFilter"
|
||
aria-label="{{ __('Filter entfernen') }}">
|
||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
@endif
|
||
|
||
<button type="button" wire:click="resetFilters"
|
||
class="text-[11.5px] font-semibold text-[color:var(--color-ink-3)] hover:text-[color:var(--color-hub)] underline-offset-[3px] hover:underline">
|
||
{{ __('Alle zurücksetzen') }}
|
||
</button>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</article>
|
||
|
||
{{-- ============== TABELLE-PANEL ============== --}}
|
||
<article class="panel overflow-hidden">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Alle Pressemitteilungen') }}</span>
|
||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||
{{ __(':count Einträge', ['count' => $pressReleases->count()]) }}
|
||
</span>
|
||
</div>
|
||
<flux:table>
|
||
<flux:table.columns>
|
||
<flux:table.column class="w-[200px]" sortable :sorted="$sortBy === 'status'" :direction="$sortDir"
|
||
wire:click="sort('status')">{{ __('Status') }}</flux:table.column>
|
||
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir"
|
||
wire:click="sort('title')">{{ __('Titel') }}</flux:table.column>
|
||
<flux:table.column class="w-[200px]" sortable :sorted="$sortBy === 'portal'"
|
||
:direction="$sortDir" wire:click="sort('portal')">{{ __('Portal') }}</flux:table.column>
|
||
<flux:table.column class="w-[180px]">{{ __('Kategorie') }}</flux:table.column>
|
||
<flux:table.column class="w-[130px]" sortable :sorted="$sortBy === 'created_at'"
|
||
:direction="$sortDir" wire:click="sort('created_at')">{{ __('Datum') }}</flux:table.column>
|
||
<flux:table.column class="w-[80px]" sortable :sorted="$sortBy === 'hits'" :direction="$sortDir"
|
||
wire:click="sort('hits')">{{ __('Hits') }}</flux:table.column>
|
||
<flux:table.column class="w-[90px]">{{ __('Aktionen') }}</flux:table.column>
|
||
</flux:table.columns>
|
||
|
||
@forelse($pressReleases as $pr)
|
||
@php
|
||
$status = $pr->status->value;
|
||
$rowClass = match ($status) {
|
||
'review' => 'is-row-warn',
|
||
'rejected' => 'is-row-err',
|
||
default => '',
|
||
};
|
||
$badgeClass = match ($status) {
|
||
'published' => 'ok',
|
||
'review' => 'warn',
|
||
'rejected' => 'err',
|
||
'archived', 'draft' => 'muted',
|
||
default => 'hub',
|
||
};
|
||
$portal = $pr->portal?->value ?? 'both';
|
||
$showPe = in_array($portal, ['presseecho', 'both'], true);
|
||
$showBp = in_array($portal, ['businessportal24', 'both'], true);
|
||
$dateSubLabel = match ($status) {
|
||
'published' => __('veröffentlicht'),
|
||
'review' => __('eingereicht'),
|
||
'rejected' => __('abgelehnt'),
|
||
'draft' => __('erstellt'),
|
||
'archived' => __('archiviert'),
|
||
default => __('erstellt'),
|
||
};
|
||
$primaryDate = $status === 'published' && $pr->published_at
|
||
? $pr->published_at
|
||
: $pr->created_at;
|
||
@endphp
|
||
<flux:table.row wire:key="{{ $pr->id }}" class="{{ $rowClass }}">
|
||
<flux:table.cell>
|
||
<div class="flex items-center gap-1.5 flex-wrap">
|
||
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
|
||
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||
<flux:modal.trigger name="confirm-index-publish-{{ $pr->id }}">
|
||
<button type="button" class="inline-action"
|
||
title="{{ __('Pressemitteilung veröffentlichen') }}">
|
||
{{ __('Freigeben →') }}
|
||
</button>
|
||
</flux:modal.trigger>
|
||
<flux:modal.trigger name="confirm-index-reject-{{ $pr->id }}">
|
||
<button type="button" class="inline-action err"
|
||
title="{{ __('Pressemitteilung ablehnen') }}">
|
||
{{ __('Ablehnen →') }}
|
||
</button>
|
||
</flux:modal.trigger>
|
||
@elseif ($pr->status === \App\Enums\PressReleaseStatus::Published)
|
||
<flux:modal.trigger name="confirm-index-archive-{{ $pr->id }}">
|
||
<button type="button" class="inline-action warn"
|
||
title="{{ __('Pressemitteilung archivieren') }}">
|
||
{{ __('Archivieren →') }}
|
||
</button>
|
||
</flux:modal.trigger>
|
||
@endif
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<a href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
|
||
class="block font-semibold text-[13.5px] leading-[1.35] text-[color:var(--color-ink)] hover:text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)] truncate">
|
||
{{ $pr->title ?? '—' }}
|
||
</a>
|
||
<div class="text-[11.5px] text-[color:var(--color-ink-3)] mt-0.5 leading-[1.4]">
|
||
PM-{{ $pr->id }}
|
||
@if ($pr->company)
|
||
· {{ $pr->company->name }}
|
||
@endif
|
||
· {{ strtoupper($pr->language) }}
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<div class="flex flex-wrap gap-1">
|
||
@if ($showPe)
|
||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||
@endif
|
||
@if ($showBp)
|
||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||
@endif
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
@php
|
||
$categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '–';
|
||
@endphp
|
||
<span class="text-[12px] text-[color:var(--color-ink-2)] truncate block"
|
||
title="{{ $categoryName }}">
|
||
{{ $categoryName }}
|
||
</span>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<div class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||
{{ $primaryDate?->format('d.m.Y') }}
|
||
</div>
|
||
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
|
||
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
|
||
</div>
|
||
</flux:table.cell>
|
||
|
||
<flux:table.cell>
|
||
<span
|
||
class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">{{ number_format($pr->hits) }}</span>
|
||
</flux:table.cell>
|
||
|
||
<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
|
||
title="{{ __('Ansehen') }}" />
|
||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate
|
||
title="{{ __('Bearbeiten') }}" />
|
||
</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="7">
|
||
@if ($hasAnyFilter)
|
||
<div class="empty-stage">
|
||
<div class="empty-ico warm">
|
||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.8"
|
||
stroke-linecap="round" />
|
||
</svg>
|
||
</div>
|
||
<h3 class="empty-title">
|
||
{{ __('Keine Pressemitteilungen mit diesen Filtern') }}
|
||
</h3>
|
||
<p class="empty-sub">
|
||
{{ __('Passen Sie die Filter an oder setzen Sie alle Filter zurück, um wieder alle PMs zu sehen.') }}
|
||
</p>
|
||
<div class="flex items-center gap-2.5 mt-6">
|
||
<flux:button variant="primary" wire:click="resetFilters">
|
||
{{ __('Filter zurücksetzen') }}
|
||
</flux:button>
|
||
</div>
|
||
</div>
|
||
@else
|
||
<div class="empty-stage">
|
||
<div class="empty-ico">
|
||
<flux:icon.newspaper class="size-7" />
|
||
</div>
|
||
<h3 class="empty-title">{{ __('Noch keine Pressemitteilungen') }}</h3>
|
||
<p class="empty-sub">
|
||
{{ __('Sobald Kunden PMs anlegen oder einreichen, erscheinen sie hier zur Bearbeitung.') }}
|
||
</p>
|
||
<div class="flex items-center gap-2.5 mt-6">
|
||
<flux:button variant="primary" icon="plus"
|
||
href="{{ route('admin.press-releases.create') }}" wire:navigate>
|
||
{{ __('Neue PM anlegen') }}
|
||
</flux:button>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</flux:table.cell>
|
||
</flux:table.row>
|
||
@endforelse
|
||
</flux:table>
|
||
</article>
|
||
|
||
{{ $pressReleases->links() }}
|
||
</div>
|