presseportale/resources/views/livewire/admin/press-releases/show.blade.php
Kevin Adametz 31da9ea8b6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
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

1007 lines
54 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\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Enums\AiImageDeclaration;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Enums\ImagePrecheckCategory;
use App\Services\Image\Precheck\ImagePrecheckReviewService;
use App\Services\PressRelease\Images\ImageCorrectionService;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
{
use \App\Livewire\Concerns\HandlesAdminPressReleaseActions;
#[Locked]
public int $id;
public string $rejectReason = '';
public function mount(int $id): void
{
$this->id = $id;
}
/*
* Berechtigung (Audit K-06): jede Aktion, die Status oder Bild ändert,
* verlangt wie Liste und Bearbeiten `press-releases:publish`.
*/
public function publish(): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
try {
app(PressReleaseService::class)->publish($pr);
} catch (QuotaExceededException $e) {
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
Flux::modal('confirm-show-publish')->close();
return;
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
Flux::modal('confirm-show-publish')->close();
return;
} catch (ImageRightsConfirmationRequiredException $e) {
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
Flux::modal('confirm-show-publish')->close();
return;
} catch (BlacklistViolationException $e) {
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
Flux::modal('confirm-show-publish')->close();
return;
}
Flux::toast(text: __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'), variant: 'success');
Flux::modal('confirm-show-publish')->close();
}
public function reject(): void
{
$this->authorize('press-releases:publish');
$this->validate([
'rejectReason' => ['required', 'string', 'min:5', 'max:2000'],
]);
$pr = $this->findAdminPR($this->id);
try {
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
Flux::modal('confirm-show-reject')->close();
return;
}
$this->rejectReason = '';
Flux::toast(text: __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'), variant: 'warning');
Flux::modal('confirm-show-reject')->close();
}
public function archive(): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
try {
app(PressReleaseService::class)->archive($pr);
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
Flux::modal('confirm-show-archive')->close();
return;
}
Flux::toast(text: __('Archiviert.'), variant: 'success');
Flux::modal('confirm-show-archive')->close();
}
/**
* Korrekturweg (Bildkennzeichnung E.6): Bildnachweis oder
* Portalkennzeichnung mit Pflichtgrund ändern, Bild vorläufig zurückhalten.
*/
public string $correctionCredit = '';
public string $correctionAiDeclaration = '';
public string $correctionReason = '';
public function correctImage(ImageCorrectionService $corrections): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
if ($image === null) {
return;
}
$this->validate([
'correctionReason' => ['required', 'string', 'min:5', 'max:1000'],
'correctionCredit' => ['nullable', 'string', 'max:255'],
'correctionAiDeclaration' => ['nullable', \Illuminate\Validation\Rule::enum(AiImageDeclaration::class)],
], [
'correctionReason.required' => __('Bitte geben Sie den Grund der Korrektur an.'),
]);
$admin = auth()->user();
$changed = 0;
if ($this->correctionCredit !== '' && $this->correctionCredit !== (string) $image->copyright) {
$corrections->correctCredit($image, $admin, trim($this->correctionCredit), $this->correctionReason);
$changed++;
}
if ($this->correctionAiDeclaration !== '' && $this->correctionAiDeclaration !== $image->ai_declaration?->value) {
$corrections->correctAiDeclaration($image, $admin, AiImageDeclaration::from($this->correctionAiDeclaration), $this->correctionReason);
$changed++;
}
$this->reset(['correctionCredit', 'correctionAiDeclaration', 'correctionReason']);
Flux::toast(
text: $changed > 0 ? __('Korrektur protokolliert und übernommen.') : __('Keine Änderung: Die Angaben entsprechen dem aktuellen Stand.'),
variant: $changed > 0 ? 'success' : 'warning',
);
}
/**
* Bild-Sichtung (Bildkennzeichnung Block K, DU 12.3): Ausgang `queue`
* des Vorab-Checks braucht eine menschliche Entscheidung mit Vermerk.
*/
public string $precheckNote = '';
public function approvePrecheck(ImagePrecheckReviewService $reviews): void
{
$this->decidePrecheck($reviews, true);
}
public function rejectPrecheck(ImagePrecheckReviewService $reviews): void
{
$this->decidePrecheck($reviews, false);
}
private function decidePrecheck(ImagePrecheckReviewService $reviews, bool $approve): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->awaitingPrecheckReview()->orderByDesc('is_preview')->orderBy('sort_order')->first();
if ($image === null) {
Flux::toast(text: __('Kein Bild wartet auf eine Sichtung.'), variant: 'warning');
return;
}
$this->validate(
['precheckNote' => ['required', 'string', 'min:5', 'max:1000']],
['precheckNote.required' => __('Bitte geben Sie einen Vermerk zur Entscheidung an.')],
);
$approve
? $reviews->approve($image, auth()->user(), trim($this->precheckNote))
: $reviews->reject($image, auth()->user(), trim($this->precheckNote));
$this->reset(['precheckNote']);
Flux::toast(text: $approve ? __('Bild freigegeben und sichtbar.') : __('Bild abgelehnt und entfernt.'), variant: 'success');
}
public function toggleWithheld(ImageCorrectionService $corrections): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
if ($image === null) {
return;
}
$this->validate(
['correctionReason' => ['required', 'string', 'min:5', 'max:1000']],
['correctionReason.required' => __('Bitte geben Sie den Grund an.')],
);
// Nur der Rückhalt aus dem Korrekturweg (B-07): eine offene
// Vorab-Sichtung wird in der Sichtungs-Queue entschieden.
$image->isWithheldByCorrection()
? $corrections->release($image, auth()->user(), $this->correctionReason)
: $corrections->withhold($image, auth()->user(), $this->correctionReason);
$this->reset(['correctionReason']);
Flux::toast(text: $image->fresh()->isWithheldByCorrection() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
}
public function with(): array
{
$pr = PressRelease::withoutGlobalScopes()
->with([
'company:id,name,email,phone,website,address,country_code,slug',
'company.users:users.id,users.name,users.email',
'category.translations',
'user:id,name,email',
'images',
'attachments',
'contacts' => fn ($query) => $query
->withoutGlobalScopes()
->orderBy('last_name')
->orderBy('first_name')
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
'statusLogs.changedBy:id,name',
'kiAudits',
])
->findOrFail($this->id);
$latestClassification = $pr->kiAudits
->firstWhere('type', \App\Models\KiAudit::TYPE_CLASSIFICATION);
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
$cover = app(PressReleaseCoverImage::class);
return [
'pr' => $pr,
'statusLogs' => $pr->statusLogs,
'contacts' => $pr->contacts,
'latestClassification' => $latestClassification,
'latestRejection' => $latestRejection,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
?? $pr->category?->translations->first()?->name
?? '–',
];
}
}; ?>
<div class="space-y-8">
@php
$statusClass = $pr->status->badgeClass();
@endphp
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
<span class="eyebrow muted">{{ __('Content · Pressemitteilung') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
@if ($pr->classification)
@php
$kiBadgeClass = match ($pr->classification) {
\App\Enums\PressReleaseClassification::Green => 'ok',
\App\Enums\PressReleaseClassification::Yellow => 'warn',
\App\Enums\PressReleaseClassification::Red => 'err',
};
@endphp
<span @class(['badge', $kiBadgeClass])>{{ __('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])>{{ __('Score :score · :tier', ['score' => $pr->content_score, 'tier' => $pr->content_tier->label()]) }}</span>
@endif
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
<span class="badge hub">{{ $pr->portal->label() }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
@if ($pr->subtitle)
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->subtitle }}
</p>
@endif
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Firma') }}:</strong>
@if ($pr->company)
<a href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->company->name }}
</a>
@else
–
@endif
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Kategorie') }}:</strong>
{{ $categoryName }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Autor') }}:</strong>
@if ($pr->user)
<a href="{{ route('admin.users.show', $pr->user->id) }}" wire:navigate
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->user->name }}
</a>
@else
–
@endif
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="filled" icon="pencil" href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</header>
{{-- ============== TITELBILD (Hero) ============== --}}
{{-- Harte Obergrenze 1280x580 px: Container deckelt Breite und Seitenverhältnis,
damit das Bild auf großen Screens nicht über die Detailgröße hinauswächst. --}}
<article class="panel overflow-hidden mx-auto w-full max-w-[1280px]">
<div class="relative aspect-[1280/580] w-full">
<img src="{{ $coverUrl }}" alt="{{ $pr->title }}"
class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
</div>
@if ($coverIsPlaceholder)
<div class="flex items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.photo variant="micro" class="size-3.5" />
<span>{{ __('Platzhalter-Titelbild (kein eigenes Bild hochgeladen).') }}</span>
</div>
@else
@php
$adminTitleImage = $pr->images->sortByDesc('is_preview')->first();
@endphp
@if ($adminTitleImage && ($adminCreditInfo = \App\Support\ImageCredit::for($adminTitleImage, $pr->company))->hasDetails())
{{-- Bildnachweis, KI-Kennzeichnung, Herkunft und Bestätigungsstand
(Bildkennzeichnung D.1/D.3) --}}
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
@if ($adminCreditInfo->aiLabel)
<span class="badge hub">{{ $adminCreditInfo->aiLabel }}</span>
@if ($adminCreditInfo->aiSourceLabel)
<span class="text-[color:var(--color-ink-4)]">({{ $adminCreditInfo->aiSourceLabel }})</span>
@endif
@endif
@if ($adminCreditInfo->credit)
<span>{{ $adminCreditInfo->creditLabel }}: {{ $adminCreditInfo->credit }}</span>
@endif
@if ($adminCreditInfo->confirmation)
<span class="text-[color:var(--color-ink-4)]">· {{ __('Rechte bestätigt am :date (:key, AGB :agb, :channel)', ['date' => $adminCreditInfo->confirmation->confirmed_at->format('d.m.Y H:i'), 'key' => $adminCreditInfo->confirmation->legal_text_key, 'agb' => $adminCreditInfo->confirmation->agb_version ?? '–', 'channel' => $adminCreditInfo->confirmation->channel->value]) }}</span>
@elseif ($adminTitleImage->awaitsCustomerConfirmation())
<span class="badge">{{ __('wartet auf Kundenbestätigung') }}</span>
@elseif ($adminCreditInfo->isLegacy)
<span class="text-[color:var(--color-ink-4)]">· {{ __('Bestand ohne Bestätigungsprotokoll') }}</span>
@endif
</div>
@endif
@endif
</article>
{{-- ============== BILD-SICHTUNG (BLOCK K) ============== --}}
@if (isset($adminTitleImage) && $adminTitleImage && $adminTitleImage->awaitsPrecheckReview())
<article class="panel" data-image-precheck-review style="border-color:var(--color-warn); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bild-Sichtung: Vorab-Check hat etwas gefunden') }}</span>
<span class="badge warn">{{ __('wartet auf Entscheidung') }}</span>
</div>
<div class="p-5 space-y-3 max-w-[760px]">
<p class="m-0 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Das Bild ist bis zur Entscheidung zurückgehalten; die Meldung läuft normal weiter. Freigabe macht das Bild sichtbar, Ablehnung entfernt es. Beides wird mit Vermerk protokolliert.') }}
</p>
<ul class="m-0 list-disc pl-5 text-[13px]">
@foreach (($adminTitleImage->precheck['evaluation']['triggers'] ?? []) as $precheckTrigger)
<li>
{{ ImagePrecheckCategory::tryFrom($precheckTrigger['category'] ?? '')?->label() ?? $precheckTrigger['category'] }}
<span class="text-[color:var(--color-ink-4)]">({{ number_format((float) ($precheckTrigger['confidence'] ?? 0) * 100, 0) }} %)</span>
@if (! empty($precheckTrigger['note']))
— {{ $precheckTrigger['note'] }}
@endif
</li>
@endforeach
</ul>
<flux:textarea wire:model="precheckNote" :label="__('Vermerk (Pflicht)')" rows="2" />
<div class="flex flex-wrap gap-2">
<flux:button variant="primary" wire:click="approvePrecheck">{{ __('Bild freigeben') }}</flux:button>
<flux:button variant="danger" wire:click="rejectPrecheck" wire:confirm="{{ __('Bild wirklich ablehnen und entfernen?') }}">{{ __('Bild ablehnen') }}</flux:button>
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
@csrf
<input type="hidden" name="purpose" value="Bild-Sichtung nach Vorab-Check">
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
</form>
</div>
</div>
</article>
@elseif (isset($adminTitleImage) && $adminTitleImage && $adminTitleImage->precheck_reviewed_at !== null)
<p class="m-0 text-[12px] text-[color:var(--color-ink-4)]" data-image-precheck-decided>
{{ __('Bild-Sichtung am :date: :note', ['date' => $adminTitleImage->precheck_reviewed_at->format('d.m.Y H:i'), 'note' => $adminTitleImage->precheck_review_note]) }}
</p>
@endif
{{-- ============== KORREKTURWEG BILD (E.6) ============== --}}
@if (! $coverIsPlaceholder && isset($adminTitleImage) && $adminTitleImage && ! $adminTitleImage->isLegacyRules())
<article class="panel" data-image-correction>
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bildnachweis und Kennzeichnung korrigieren') }}</span>
@if ($adminTitleImage->isWithheld())
<span class="badge">{{ __('vorläufig zurückgehalten') }}</span>
@endif
</div>
<div class="p-5 space-y-3 max-w-[760px]">
<p class="m-0 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Kundenangabe und Bestätigung bleiben unverändert; jede Korrektur wird mit Grund und Bearbeiter protokolliert. Kundenangabe: :user · Portalkennzeichnung: :portal (:source).', [
'user' => $adminTitleImage->ai_declaration_user?->label() ?? '–',
'portal' => $adminTitleImage->ai_declaration?->label() ?? '–',
'source' => $adminTitleImage->ai_declaration_source?->publicLabel() ?? '–',
]) }}
</p>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="correctionCredit" :label="__('Neuer Bildnachweis')" :placeholder="$adminTitleImage->copyright" />
<flux:select wire:model="correctionAiDeclaration" :label="__('Neue Portalkennzeichnung')">
<flux:select.option value="">{{ __('unverändert') }}</flux:select.option>
@foreach (AiImageDeclaration::cases() as $aiOption)
<flux:select.option :value="$aiOption->value">{{ $aiOption->label() }}</flux:select.option>
@endforeach
</flux:select>
</div>
<flux:textarea wire:model="correctionReason" :label="__('Grund (Pflicht)')" rows="2" />
<div class="flex flex-wrap gap-2">
<flux:button variant="primary" wire:click="correctImage">{{ __('Korrektur übernehmen') }}</flux:button>
<flux:button variant="filled" wire:click="toggleWithheld">
{{ $adminTitleImage->isWithheldByCorrection() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
</flux:button>
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
@csrf
<input type="hidden" name="purpose" value="Sichtprüfung im Admin">
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
</form>
</div>
</div>
</article>
@endif
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Diese Pressemitteilung wurde abgelehnt') }}</span>
<span class="badge err dot">{{ __('Handlung erforderlich') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-err-soft)] border border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
<flux:icon.exclamation-triangle class="size-[18px]" />
</div>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($latestRejection->reason)
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Der Autor sollte den Inhalt überarbeiten und erneut einreichen.') }}
@endif
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
@if ($latestRejection->changedBy)
· {{ __('durch :name', ['name' => $latestRejection->changedBy->name]) }}
@endif
</span>
</div>
</div>
</article>
@endif
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
@php
$manual = $pr->isAwaitingManualReview();
@endphp
<article class="panel" style="border-color:var(--color-{{ $manual ? 'err' : 'warn' }}); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
@if ($manual)
<span class="badge err dot">{{ __('Prüfung nötig — Automatik erschöpft') }}</span>
@else
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
@endif
</div>
<div class="p-5 flex flex-wrap items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.clock class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
@if ($manual)
{{-- E3: die automatische Prüfung hat ihre Versuche aufgebraucht.
Ab hier entscheidet ein Mensch — Freischalten oder mit
Kommentar zurück; danach bleibt die Automatik für diese
Meldung aus. --}}
<p class="m-0 font-semibold text-[color:var(--color-ink)]">
{{ __(':n automatische Prüfungen, zuletzt Rot. Ihre Entscheidung: veröffentlichen oder mit Kommentar zurück an den Autor.', ['n' => $pr->auto_check_attempts]) }}
</p>
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Jede weitere Freigabe des Autors landet ohne KI-Lauf wieder hier.') }}
</p>
@php
$kiRejections = $statusLogs->filter(fn ($log) => $log->source === 'ki' && filled($log->reason))->sortBy('created_at')->values();
@endphp
@if ($kiRejections->isNotEmpty())
<ol class="m-0 mt-3 ps-4 space-y-1.5 text-[12px] text-[color:var(--color-ink-2)] list-decimal">
@foreach ($kiRejections as $log)
<li><span class="font-mono text-[11px] text-[color:var(--color-ink-4)]">{{ $log->created_at->format('d.m. H:i') }}</span> · {{ $log->reason }}</li>
@endforeach
</ol>
@endif
@else
<p class="m-0">{{ __('Diese PM wartet auf eine Prüf-Entscheidung.') }}</p>
@endif
@if (! $manual && $latestClassification && $latestClassification->reason)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<strong class="text-[color:var(--color-ink-2)]">{{ __('KI-Hinweis') }}:</strong>
{{ $latestClassification->reason }}
</p>
@endif
@if ($pr->scheduled_at)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.calendar variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
{{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduledAtLocal()->format('d.m.Y H:i')]) }}
</p>
@endif
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ $manual ? __('Freischalten') : __('Veröffentlichen') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="confirm-show-reject">
<flux:button type="button" variant="danger">{{ $manual ? __('Mit Kommentar zurück') : __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
</div>
</div>
</article>
@endif
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<article class="panel" style="border-color:var(--color-ok); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge ok dot">{{ __('Live') }}</span>
</div>
<div class="p-5 flex flex-wrap items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-ok-soft)] border border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
<p class="m-0">
{{ __('Veröffentlicht am') }}
<strong class="text-[color:var(--color-ink)] font-semibold">{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}</strong>
</p>
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.lock-closed variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
{{ __('Sperrfrist bis: :date', ['date' => $pr->embargoAtLocal()->format('d.m.Y H:i')]) }}
</p>
@endif
@if ($pr->hits > 0)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits, 0, ',', '.') }}</strong>
{{ __('Aufrufe seit Veröffentlichung') }}
</p>
@endif
</div>
<flux:modal.trigger name="confirm-show-archive">
<flux:button type="button" variant="filled">{{ __('Archivieren') }}</flux:button>
</flux:modal.trigger>
</div>
</article>
@endif
{{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
<div class="grid gap-6 xl:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Firma & Pressekontakte') }}</span>
@if ($pr->company)
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate>
{{ __('Firma öffnen') }}
</flux:button>
@endif
</div>
<div class="p-5">
@if ($pr->company)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[13px] font-semibold">
<a href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->company->name }}
</a>
</div>
@if ($pr->company->address || $pr->company->country_code)
<div class="text-[12px] text-[color:var(--color-ink-3)] whitespace-pre-line">{{ trim($pr->company->address.' '.$pr->company->country_code) }}</div>
@endif
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($pr->company->email)
<a href="mailto:{{ $pr->company->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->company->email }}
</a>
@endif
@if ($pr->company->phone)
<span>{{ $pr->company->phone }}</span>
@endif
@if ($pr->company->website)
<a href="{{ $pr->company->websiteUrl() }}" target="_blank" rel="noopener"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->company->website }}
</a>
@endif
</div>
</div>
@else
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Dieser Pressemitteilung ist keine Firma zugeordnet.') }}
</div>
@endif
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mt-4 mb-2">
{{ __('Zugeordnete Pressekontakte') }}
</div>
<div class="space-y-2">
@forelse ($contacts as $contact)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $contact->displayName() }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($contact->email)
<a href="mailto:{{ $contact->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $contact->email }}
</a>
@endif
@if ($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
</div>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Dieser Pressemitteilung ist kein Pressekontakt zugeordnet.') }}
</div>
@endforelse
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status & Verlauf') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
<div class="p-5">
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Autor') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1 truncate">
@if ($pr->user)
<a href="{{ route('admin.users.show', $pr->user->id) }}" wire:navigate
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->user->name }}
</a>
@else
–
@endif
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Erstellt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->created_at?->format('d.m.Y H:i') ?? '–' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Veröffentlicht') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($pr->hits, 0, ',', '.') }}
</div>
</div>
@if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplant') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@endif
@if ($pr->embargo_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@endif
</div>
@php
// Neben dem Autor können weitere User (Firmen-Mitglieder, z. B.
// per Magic-Link eingeladen) auf die PM zugreifen.
$companyUsers = $pr->company?->users ?? collect();
@endphp
@if ($companyUsers->isNotEmpty())
<div class="mt-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">
{{ __('Benutzer mit Zugriff (über Firma)') }}
</div>
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px]">
@foreach ($companyUsers as $companyUser)
<span class="inline-flex items-center gap-1.5">
<a href="{{ route('admin.users.show', $companyUser->id) }}" wire:navigate
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $companyUser->name }}
</a>
<span class="text-[11px] text-[color:var(--color-ink-3)]">
{{ $companyUser->pivot->role === 'owner' ? __('Owner') : __('Member') }}@if ($companyUser->id === $pr->user_id) · {{ __('Autor') }}@endif
</span>
</span>
@endforeach
</div>
</div>
@endif
@if ($pr->no_export)
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.no-symbol variant="micro" class="size-3.5" />
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
</div>
@endif
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
@if ($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = $log->to_status?->badgeClass() ?? 'muted';
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() ?? $log->to_status }}</span>
@if ($log->from_status)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('von') }} {{ $log->from_status->label() }}
</span>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $log->changedBy->name }}</span>
@endif
@if ($log->source && $log->source !== 'admin')
<span class="badge hub">{{ $log->source }}</span>
@endif
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0 whitespace-pre-line">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
@else
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</p>
@endif
</div>
</article>
</div>
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
@if ($pr->ai_text_declaration)
<span class="text-[11px] text-[color:var(--color-ink-3)]">
{{ __('KI-Deklaration:') }} {{ $pr->ai_text_declaration->label() }}
</span>
@endif
</div>
<div class="p-5">
{{-- KI-Label (Art. 50 KI-VO): erscheint so auch in der
Veröffentlichung — am Anfang des Inhalts, in Einreicher-Stimme. --}}
<x-portal.ai-public-label :label="$pr->aiTextPublicLabel()" class="mb-4 max-w-[760px]" />
<div class="pr-content max-w-[760px]">
{!! $pr->renderedText() !!}
</div>
@if ($pr->keywords || $pr->backlink_url)
<div class="mt-6 space-y-2 border-t border-[color:var(--color-bg-rule)] pt-4 text-[12.5px] text-[color:var(--color-ink-2)]">
@if ($pr->keywords)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Stichwörter') }}:</strong>
{{ $pr->keywords }}
</p>
@endif
@if ($pr->backlink_url)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Backlink') }}:</strong>
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</p>
@endif
</div>
@endif
</div>
</article>
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
@if ($pr->boilerplate_override)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
<span class="badge hub">{{ __('Override') }}</span>
</div>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
</p>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $pr->boilerplate_override }}
</div>
</div>
</article>
@endif
{{-- ============== MEDIEN ============== --}}
@if ($pr->images->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bilder') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->images->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->images as $image)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
@if ($image->is_preview)
<span class="badge hub">{{ __('Preview') }}</span>
@endif
</div>
@endforeach
</div>
</article>
@endif
{{-- ANHÄNGE-ANZEIGE — TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung.
Wird mit dem Anhang-Manager in einer späteren Phase wieder aktiviert.
@if ($pr->attachments->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anhänge') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->attachments->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->attachments as $attachment)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.paper-clip class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)] flex-1">
{{ $attachment->title ?: $attachment->original_name }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] flex-shrink-0">
{{ number_format($attachment->size / 1024, 0, ',', '.') }} KB
</span>
</div>
@endforeach
</div>
</article>
@endif
--}}
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal name="confirm-show-publish" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung veröffentlichen?') }}</flux:heading>
<flux:subheading>{{ __('Die Pressemitteilung wird öffentlich sichtbar und der Autor wird benachrichtigt.') }}</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">{{ __('Veröffentlichen') }}</flux:button>
</div>
</div>
</flux:modal>
<flux:modal name="confirm-show-reject" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung ablehnen?') }}</flux:heading>
<flux:subheading>{{ __('Die Pressemitteilung wird abgelehnt und der Autor wird benachrichtigt. Bitte begründen Sie die Ablehnung.') }}</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Begründung (an den Autor sichtbar)') }}</flux:label>
<flux:textarea
wire:model="rejectReason"
rows="5"
placeholder="{{ __('z. B. Werbliche Sprache, fehlende Belege, doppelte Veröffentlichung…') }}"
/>
<flux:error name="rejectReason" />
</flux:field>
<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">{{ __('Ablehnen') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
@if($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:modal name="confirm-show-archive" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung archivieren?') }}</flux:heading>
<flux:subheading>{{ __('Die Pressemitteilung bleibt intern erhalten, wird aber archiviert.') }}</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">{{ __('Archivieren') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
</div>