Decision-Update Abschnitt 12 (E15, S24): Sichtung auf offensichtliche Probleme, keine Rechteprüfung, öffentlich weiterhin „automatisierte Prüfung". - ImagePrecheckManager mit openai- und deterministic-Treiber (Muster der Text-Prüfstrecke), Bild auf 1024 px, Metadaten gestrippt, JPEG als Data-URI, Antwortschema 12.4; Fehler → Ausgang `error`, durchlassen mit Vermerk. - Auswertung 12.3: reject (Stock-Wasserzeichen, Nacktheit/Gewalt, Dokument ab 0,85), queue (Screenshot, fremdes Logo, Kennzeichen ab 0,6 sowie Ablehnungskategorien 0,6–0,85), hint (Personen, „wirkt KI" nur bei „ohne KI", technischer Mangel). Stufe 1 wertet nur das Wasserzeichen; die KI setzt nie die Kennzeichnung (E9). Herabstufung per Config (12.8). - Uploader: Check vor jedem Schreiben, Ablehnung speichert nichts und landet als ki_audits `image_precheck` an der PM; Hash-Bindung zwischen Vorprüfung im Formular und Speichern; Spalten precheck*, Snapshot `precheck_snapshot` im Bestätigungsprotokoll, Archiv-Payload erweitert. - Queue: Bild zurückgehalten (withheld_at), Meldung läuft weiter; Admin entscheidet mit Vermerk (Freigabe/Ablehnung → ki_audits `image_precheck_review`); Ansicht „Bild-Sichtung" in der PM-Liste, Sichtungsblock auf der PM-Seite, Systemstatus-Zähler. - Formular v2: Ablehnungsgrund unter dem Dateifeld, Hinweise nach der Dateiauswahl, „wird kurz gesichtet" an der Karte; API 422 mit Grund, Warnung `image_under_review`. - Config images.precheck (IMAGE_PRECHECK aus, STAGE 1); 17 Tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1425 lines
71 KiB
PHP
1425 lines
71 KiB
PHP
<?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\PressReleaseService;
|
||
use App\Services\PressRelease\QuotaExceededException;
|
||
use Flux\Flux;
|
||
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 \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 — bewusst ohne Routing: bewertet
|
||
* und protokolliert, ändert aber nie den Status (Admin entscheidet).
|
||
*/
|
||
public function runAiCheck(?int $id = null): void
|
||
{
|
||
$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 (\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>
|