presseportale/resources/views/livewire/admin/press-releases/index.blade.php
Kevin Adametz 2cde7284a1 livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

1435 lines
72 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\Http\Controllers\Api\Legacy\LegacyPressReleaseController;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Models\AppSetting;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Models\PressReleaseSearchIndex;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class extends Component
{
use \App\Livewire\Concerns\HandlesAdminPressReleaseActions;
use \App\Livewire\Concerns\HasFullTextSearch;
use \App\Livewire\Concerns\HasSortableTable;
use WithPagination;
public string $search = '';
#[Url(as: 'status', except: 'all')]
public string $statusFilter = 'all';
#[Url(as: 'classification', except: 'all')]
public string $classificationFilter = '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 updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function updatedClassificationFilter(): 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();
}
/**
* Admin-Schalter (Entscheidung 10.07.2026): KI-Prüfung für Legacy-API-
* Einlieferungen. Aus = Alt-Verhalten Sofort-Publish (connektar).
*/
public bool $legacyApiAiReview = false;
public function mount(): void
{
$this->legacyApiAiReview = AppSetting::getBool(LegacyPressReleaseController::SETTING_AI_REVIEW);
}
public function updatedLegacyApiAiReview(bool $value): void
{
$this->authorize('settings:manage');
AppSetting::setValue(LegacyPressReleaseController::SETTING_AI_REVIEW, $value);
Flux::toast(
$value
? __('KI-Prüfung für Legacy-API-Einlieferungen aktiviert.')
: __('Legacy-API veröffentlicht wieder sofort (Alt-Verhalten).'),
variant: 'success',
);
}
/** PM-ID, deren KI-Prüfdetails aktuell im Modal angezeigt werden. */
public ?int $aiCheckId = null;
/** Zeitpunkt (ISO 8601) des zuletzt angestoßenen Laufs — steuert die „läuft…"-Anzeige. */
public ?string $aiCheckStartedAt = null;
public function openAiCheck(int $id): void
{
$this->aiCheckId = $id;
$this->aiCheckStartedAt = null;
Flux::modal('ai-check-details')->show();
}
/**
* Manuelle KI-Prüfung aus der Liste — ohne Routing: bewertet und
* protokolliert, gibt aber nie frei. Ausnahme (Audit U03): Liefert der
* Lauf Rot und wartet die Meldung noch in `review`, lehnt der Job sie ab
* (inkl. Autorenmail). Deshalb dieselbe Berechtigung wie Freigeben und
* Ablehnen (Audit K-06).
*/
public function runAiCheck(?int $id = null): void
{
$this->authorize('press-releases:publish');
$id ??= $this->aiCheckId;
if ($id === null) {
return;
}
$pr = $this->findAdminPR($id);
ClassifyPressRelease::dispatch($pr->id, route: false)->onQueue('classification');
ScorePressRelease::dispatch($pr->id)->onQueue('classification');
if ($id === $this->aiCheckId) {
$this->aiCheckStartedAt = now()->toIso8601String();
}
Flux::toast(__('KI-Prüfung angestoßen — Ergebnis erscheint nach dem nächsten Queue-Lauf.'), variant: 'success');
}
public function resetFilters(): void
{
$this->search = '';
$this->statusFilter = 'all';
$this->classificationFilter = 'all';
$this->portalFilter = 'all';
$this->languageFilter = 'all';
$this->categoryFilter = 'all';
$this->clearUserFilter();
$this->clearCompanyFilter();
$this->clearContactFilter();
$this->resetPage();
}
public function publish(int $id): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($id);
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
return;
} catch (QuotaExceededException $e) {
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
return;
} catch (ImageRightsConfirmationRequiredException $e) {
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
return;
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
Flux::toast(text: __('Pressemitteilung veröffentlicht.'), variant: 'success');
}
public function reject(int $id): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($id);
try {
app(PressReleaseService::class)->reject($pr, __('Bitte überarbeiten Sie die Pressemitteilung.'));
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
Flux::toast(text: __('Pressemitteilung abgelehnt.'), variant: 'warning');
}
public function archive(int $id): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($id);
try {
app(PressReleaseService::class)->archive($pr);
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
Flux::toast(text: __('Pressemitteilung archiviert.'), variant: 'success');
}
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)) {
// Volltext über die Suchtabelle — press_releases selbst
// trägt keinen Index mehr, damit neue Spalten dort ohne
// stundenlangen Rebuild angelegt werden können.
$q->where(fn ($inner) => PressReleaseSearchIndex::constrain($inner, ['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.'%'));
});
})
// „Prüfung nötig" (E3): geblockt in review, Automatik erschöpft.
->when($this->statusFilter === 'manual', fn ($q) => $q->where('status', PressReleaseStatus::Review->value)->whereNotNull('manual_review_required_at'))
// Bild-Sichtung (Block K): Titelbild wartet auf eine menschliche Entscheidung.
->when($this->statusFilter === 'image_review', fn ($q) => $q->whereHas('images', fn ($imageQuery) => $imageQuery->awaitingPrecheckReview()))
->when(! in_array($this->statusFilter, ['all', 'manual', 'image_review'], true), fn ($q) => $q->where('status', $this->statusFilter))
->when($this->classificationFilter !== 'all', fn ($q) => $q->where('classification', $this->classificationFilter))
->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)
->paginate(50);
return [
'pressReleases' => $query,
'aiCheck' => $this->aiCheckDetails(),
'stats' => $this->pressReleaseStats(),
'statusOptions' => PressReleaseStatus::cases(),
'portalOptions' => Portal::cases(),
'categoryOptions' => $this->categoryOptions(),
'userLookupResults' => $this->userLookupResults(),
'companyLookupResults' => $this->companyLookupResults(),
'contactLookupResults' => $this->contactLookupResults(),
];
}
/**
* Daten für das KI-Prüfungs-Modal: PM, jüngste Audit-Einträge und ob
* gerade ein angestoßener Lauf noch aussteht (Queue noch nicht durch).
*
* @return array{pr: PressRelease, classificationAudit: KiAudit|null, scoreAudit: KiAudit|null, pending: bool}|null
*/
private function aiCheckDetails(): ?array
{
if ($this->aiCheckId === null) {
return null;
}
$pr = PressRelease::withoutGlobalScopes()
->with(['kiAudits' => fn ($q) => $q->limit(10)])
->find($this->aiCheckId);
if ($pr === null) {
return null;
}
$classificationAudit = $pr->kiAudits->firstWhere('type', KiAudit::TYPE_CLASSIFICATION);
$scoreAudit = $pr->kiAudits->firstWhere('type', KiAudit::TYPE_CONTENT_SCORE);
$startedAt = $this->aiCheckStartedAt !== null
? \Illuminate\Support\Carbon::parse($this->aiCheckStartedAt)
: null;
$pending = $startedAt !== null && (
$classificationAudit === null
|| $classificationAudit->created_at->lt($startedAt)
|| $scoreAudit === null
|| $scoreAudit->created_at->lt($startedAt)
);
return [
'pr' => $pr,
'classificationAudit' => $classificationAudit,
'scoreAudit' => $scoreAudit,
'scoreBreakdown' => $this->scoreBreakdown($scoreAudit),
'pending' => $pending,
];
}
/**
* Extrahiert die Faktor-Aufschlüsselung des Content-Scores aus der
* Audit-Roh-Antwort. Deterministischer Treiber legt sie direkt ab,
* OpenAI liefert sie im JSON der Message.
*
* @return array<string, int|float>
*/
private function scoreBreakdown(?KiAudit $scoreAudit): array
{
if ($scoreAudit === null) {
return [];
}
$raw = $scoreAudit->raw_response ?? [];
$breakdown = $raw['breakdown'] ?? null;
if (! is_array($breakdown)) {
$content = data_get($raw, 'choices.0.message.content');
$parsed = is_string($content) ? json_decode($content, true) : null;
$breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : [];
}
return array_filter($breakdown, static fn ($value): bool => is_numeric($value));
}
/**
* @return array{total: int, published: int, review: int, draft: int, rejected: int, archived: int, manual: int, image_review: 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])
->selectRaw('SUM(CASE WHEN status = ? AND manual_review_required_at IS NOT NULL THEN 1 ELSE 0 END) as manual', [PressReleaseStatus::Review->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),
'manual' => (int) ($stats->manual ?? 0),
'image_review' => \App\Models\PressReleaseImage::query()->awaitingPrecheckReview()->count(),
];
});
}
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();
}
}; ?>
<div class="space-y-8">
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
size="lg"
:badge="__('Admin Backend')"
:eyebrow="__('Content · Pressemitteilungen')"
:title="__('Pressemitteilungen')"
:description="__('Übersicht aller PMs beider Portale, mit Filter, Status-Workflow und Schnellaktionen.')"
>
<x-slot:actions>
<flux:button icon="plus" variant="primary" href="{{ route('admin.press-releases.create') }}" wire:navigate>
{{ __('Neue PM') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid gap-4 grid-cols-2 xl: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>{{ __('Inhaltsprü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>
{{-- E3: Automatik erschöpft — hier wartet eine menschliche Entscheidung. --}}
<button type="button" wire:click="setView('manual')"
@class(['view-tab', 'is-active' => $statusFilter === 'manual'])>
{{ __('Prüfung nötig') }} <span @class(['cnt', 'text-[color:var(--color-err)]' => ($stats['manual'] ?? 0) > 0])>{{ number_format($stats['manual'] ?? 0) }}</span>
</button>
{{-- Block K: Titelbild aus dem Vorab-Check wartet auf Sichtung. --}}
<button type="button" wire:click="setView('image_review')"
@class(['view-tab', 'is-active' => $statusFilter === 'image_review'])>
{{ __('Bild-Sichtung') }} <span @class(['cnt', 'text-[color:var(--color-err)]' => ($stats['image_review'] ?? 0) > 0])>{{ number_format($stats['image_review'] ?? 0) }}</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
<option value="manual">{{ __('Prüfung nötig (Automatik erschöpft)') }}</option>
<option value="image_review">{{ __('Bild-Sichtung (Vorab-Check)') }}</option>
</flux:select>
<flux:select wire:model.live="classificationFilter" class="w-full">
<option value="all">{{ __('Alle KI-Bewertungen') }}</option>
@foreach (\App\Enums\PressReleaseClassification::cases() as $c)
<option value="{{ $c->value }}">{{ __('KI: :label', ['label' => $c->label()]) }}</option>
@endforeach
</flux:select>
{{-- Entscheidung 10.07.2026: KI-Prüfung für Legacy-API-Einlieferungen;
aus = Alt-Verhalten (Sofort-Publish für berechtigte Bestandskunden). --}}
@can('settings:manage')
<flux:field variant="inline" class="whitespace-nowrap">
<flux:switch wire:model.live="legacyApiAiReview" />
<flux:label>{{ __('KI-Prüfung Legacy-API') }}</flux:label>
</flux:field>
@endcan
<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="filled"
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="filled"
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="filled"
icon="x-mark"
wire:click="clearContactFilter"
title="{{ __('Kontaktsuche zurücksetzen') }}"
/>
</div>
</div>
{{-- Active-Chips --}}
@php
$hasAnyFilter = $search !== ''
|| $statusFilter !== 'all'
|| $classificationFilter !== '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 = in_array($statusFilter, ['manual', 'image_review'], true) ? null : \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
<span class="active-chip">
<span>{{ __('Status') }}:
<strong>{{ $statusEnum?->label() ?? match ($statusFilter) { 'manual' => __('Prüfung nötig'), 'image_review' => __('Bild-Sichtung'), default => $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 ($classificationFilter !== 'all')
@php $classificationEnum = \App\Enums\PressReleaseClassification::tryFrom($classificationFilter); @endphp
<span class="active-chip">
<span>{{ __('KI-Bewertung') }}:
<strong>{{ $classificationEnum?->label() ?? $classificationFilter }}</strong></span>
<button type="button" class="x" wire:click="$set('classificationFilter', '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-[90px]">{{ __('Aktionen') }}</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-[120px]">{{ __('KI-Prüfung') }}</flux:table.column>
<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-[80px]" sortable :sorted="$sortBy === 'hits'" :direction="$sortDir"
wire:click="sort('hits')">{{ __('Hits') }}</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 = $pr->status->badgeClass();
$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">
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
title="{{ __('Ansehen') }}" />
<flux:button size="sm" variant="filled" icon="pencil"
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate
title="{{ __('Bearbeiten') }}" />
</div>
</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>
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.calendar variant="micro" class="size-3" />
<span>{{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }}</span>
</div>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.lock-closed variant="micro" class="size-3" />
<span>{{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }}</span>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1.5">
<flux:button size="sm" variant="filled" icon="sparkles"
wire:click="openAiCheck({{ $pr->id }})"
title="{{ __('KI-Prüfung öffnen') }}" />
@if ($pr->classification)
@php
$kiBadgeClass = match ($pr->classification) {
\App\Enums\PressReleaseClassification::Green => 'published',
\App\Enums\PressReleaseClassification::Yellow => 'review',
\App\Enums\PressReleaseClassification::Red => 'rejected',
};
@endphp
<button type="button" wire:click="openAiCheck({{ $pr->id }})"
class="cursor-pointer" title="{{ __('KI-Prüfung öffnen') }}">
<span @class(['badge', $kiBadgeClass])>{{ $pr->classification->label() }}</span>
</button>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1.5 flex-wrap">
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
@if ($pr->isAwaitingManualReview())
<span class="badge err" title="{{ __('Automatische Prüfung erschöpft — Entscheidung nötig') }}">{{ __('Prüfung nötig') }}</span>
@elseif ($pr->requiresManualReview())
<span class="badge muted" title="{{ __('Automatik für diese Meldung aus') }}">{{ __('nur manuell') }}</span>
@endif
@if ($pr->classification)
@php
$kiBadge = match ($pr->classification) {
\App\Enums\PressReleaseClassification::Green => 'ok',
\App\Enums\PressReleaseClassification::Yellow => 'warn',
\App\Enums\PressReleaseClassification::Red => 'err',
};
@endphp
<span class="badge {{ $kiBadge }}" title="{{ __('KI-Bewertung') }}">{{ __('KI: :label', ['label' => $pr->classification->label()]) }}</span>
@endif
@if (! is_null($pr->content_score) && $pr->content_tier)
@php
$tierBadge = match ($pr->content_tier) {
\App\Enums\PressReleaseContentTier::Hochwertig => 'ok',
\App\Enums\PressReleaseContentTier::Geprueft => 'hub',
\App\Enums\PressReleaseContentTier::Standard => 'muted',
};
@endphp
<span class="badge {{ $tierBadge }}" title="{{ __('Content-Score') }}">{{ $pr->content_score }} · {{ $pr->content_tier->label() }}</span>
@endif
@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>
<span
class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">{{ number_format($pr->hits) }}</span>
@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">
@if ($hasAnyFilter)
<x-portal.empty-state
variant="warm"
:title="__('Keine Pressemitteilungen mit diesen Filtern')"
:subtitle="__('Passen Sie die Filter an oder setzen Sie alle Filter zurück, um wieder alle PMs zu sehen.')"
>
<x-slot:icon>
<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>
</x-slot:icon>
<x-slot:actions>
<flux:button variant="primary" wire:click="resetFilters">
{{ __('Filter zurücksetzen') }}
</flux:button>
</x-slot:actions>
</x-portal.empty-state>
@else
<x-portal.empty-state
:title="__('Noch keine Pressemitteilungen')"
:subtitle="__('Sobald Kunden PMs anlegen oder einreichen, erscheinen sie hier zur Bearbeitung.')"
>
<x-slot:icon>
<flux:icon.newspaper class="size-7" />
</x-slot:icon>
<x-slot:actions>
<flux:button variant="primary" icon="plus"
href="{{ route('admin.press-releases.create') }}" wire:navigate>
{{ __('Neue PM anlegen') }}
</flux:button>
</x-slot:actions>
</x-portal.empty-state>
@endif
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</article>
{{ $pressReleases->links('components.portal.pagination') }}
{{-- ============== KI-PRÜFUNGS-MODAL ============== --}}
<flux:modal name="ai-check-details" class="max-w-2xl">
@if ($aiCheck)
@php
$aiPr = $aiCheck['pr'];
$classificationAudit = $aiCheck['classificationAudit'];
$scoreAudit = $aiCheck['scoreAudit'];
@endphp
<div class="space-y-6" @if ($aiCheck['pending']) wire:poll.3s @endif>
<div>
<flux:heading size="lg">{{ __('KI-Prüfung') }}</flux:heading>
<flux:subheading>
PM-{{ $aiPr->id }} · {{ \Illuminate\Support\Str::limit($aiPr->title, 90) }}
</flux:subheading>
</div>
@if ($aiCheck['pending'])
<div class="flex items-center gap-2 rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2.5 text-[12.5px] text-[color:var(--color-accent-deep)]">
<flux:icon.arrow-path class="size-4 animate-spin" />
{{ __('Prüfung läuft — das Ergebnis erscheint hier automatisch, sobald die Queue durchgelaufen ist.') }}
</div>
@endif
<div class="grid gap-4 sm:grid-cols-2">
{{-- Klassifikation (Rot/Gelb/Grün) --}}
<section class="rounded-[6px] border border-[color:var(--color-line)] p-4 space-y-2">
<span class="section-eyebrow">{{ __('Klassifikation') }}</span>
<div>
@if ($aiPr->classification)
@php
$kiBadge = match ($aiPr->classification) {
\App\Enums\PressReleaseClassification::Green => 'ok',
\App\Enums\PressReleaseClassification::Yellow => 'warn',
\App\Enums\PressReleaseClassification::Red => 'err',
};
@endphp
<span class="badge {{ $kiBadge }} dot">{{ $aiPr->classification->label() }}</span>
@else
<span class="badge muted">{{ __('Noch nicht geprüft') }}</span>
@endif
</div>
@if ($classificationAudit)
@if ($classificationAudit->reason)
<p class="m-0 text-[12.5px] leading-[1.5] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $classificationAudit->reason }}
</p>
@elseif ($aiPr->classification === \App\Enums\PressReleaseClassification::Green)
<p class="m-0 text-[12.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ __('Keine Auffälligkeiten — bei Grün liefert die KI keine Begründung.') }}
</p>
@endif
<p class="m-0 text-[11px] text-[color:var(--color-ink-3)]">
{{ $classificationAudit->created_at->format('d.m.Y H:i') }}
· {{ $classificationAudit->provider }}
@if ($classificationAudit->model)
· {{ $classificationAudit->model }}
@endif
</p>
@endif
</section>
{{-- Content-Score --}}
<section class="rounded-[6px] border border-[color:var(--color-line)] p-4 space-y-2">
<span class="section-eyebrow">{{ __('Content-Score') }}</span>
<div>
@if (! is_null($aiPr->content_score) && $aiPr->content_tier)
@php
$tierBadge = match ($aiPr->content_tier) {
\App\Enums\PressReleaseContentTier::Hochwertig => 'ok',
\App\Enums\PressReleaseContentTier::Geprueft => 'hub',
\App\Enums\PressReleaseContentTier::Standard => 'muted',
};
@endphp
<span class="badge {{ $tierBadge }}">{{ $aiPr->content_score }} / 100 · {{ $aiPr->content_tier->label() }}</span>
@else
<span class="badge muted">{{ __('Noch nicht bewertet') }}</span>
@endif
</div>
@if ($aiCheck['scoreBreakdown'] !== [])
@php
$breakdownMeta = $scoreAudit?->provider === 'deterministic'
? [
'base' => [__('Basiswert'), 40],
'length' => [__('Textlänge'), 20],
'subtitle' => [__('Untertitel'), 6],
'image' => [__('Bildmaterial'), 10],
'source' => [__('Quelle/Link'), 8],
'headline' => [__('Headline'), 8],
'keywords' => [__('Keywords'), 4],
'contact' => [__('Pressekontakt'), 4],
]
: [
'pressestil' => [__('Pressestil'), 25],
'struktur' => [__('Struktur'), 15],
'lesbarkeit' => [__('Lesbarkeit'), 10],
'vollstaendigkeit' => [__('Vollständigkeit'), 15],
'bild' => [__('Bildmaterial'), 10],
'quellen' => [__('Quellen/Belege'), 5],
'headline' => [__('Headline'), 10],
'originalitaet' => [__('Originalität'), 10],
];
@endphp
<dl class="m-0 space-y-1.5 pt-1">
@foreach ($aiCheck['scoreBreakdown'] as $factor => $points)
@php
[$factorLabel, $factorMax] = $breakdownMeta[$factor] ?? [ucfirst($factor), null];
$factorRatio = $factorMax ? min(100, (int) round(($points / $factorMax) * 100)) : null;
@endphp
<div class="flex items-center gap-2" wire:key="ai-breakdown-{{ $factor }}">
<dt class="w-[110px] flex-shrink-0 text-[11px] text-[color:var(--color-ink-3)]">
{{ $factorLabel }}
</dt>
<dd class="m-0 flex-1">
@if ($factorRatio !== null)
<div class="h-1.5 rounded-full bg-[color:var(--color-line)] overflow-hidden">
<div class="h-full rounded-full bg-[color:var(--color-hub)]" style="width: {{ $factorRatio }}%"></div>
</div>
@endif
</dd>
<dd class="m-0 w-[46px] flex-shrink-0 text-right font-mono text-[11px] tabular-nums text-[color:var(--color-ink-2)]">
{{ $points }}{{ $factorMax !== null ? ' / '.$factorMax : '' }}
</dd>
</div>
@endforeach
</dl>
@endif
@if ($scoreAudit)
<p class="m-0 text-[11px] text-[color:var(--color-ink-3)]">
{{ $scoreAudit->created_at->format('d.m.Y H:i') }}
· {{ $scoreAudit->provider }}
@if ($scoreAudit->model)
· {{ $scoreAudit->model }}
@endif
</p>
@endif
</section>
</div>
<p class="m-0 text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Die manuelle Prüfung bewertet und protokolliert nur — der Status der PM bleibt unverändert.') }}
</p>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" icon="sparkles" wire:click="runAiCheck"
wire:loading.attr="disabled" :disabled="$aiCheck['pending']">
{{ $aiPr->classification ? __('Erneut prüfen') : __('Prüfung starten') }}
</flux:button>
</div>
</div>
@endif
</flux:modal>
</div>