press-releases: begrenzter Pruefkreislauf mit Admin-Queue (Roadmap 1d.4, E3)

Automatische Pruefung je Meldung hoechstens max_automatic_attempts (3);
das letzte Rot blockt statt abzulehnen: Meldung bleibt in review, Admin
sieht sie unter "Pruefung noetig" mit allen KI-Begruendungen und
entscheidet (Freischalten / Mit Kommentar zurueck). Danach ist die
Automatik fuer diese Meldung aus - jede Freigabe geht direkt zum Menschen.

Gelb: yellow_to_manual_queue wird jetzt ausgewertet (Vorgabe false).
Kundenseite: "Zur Pruefung freigeben" mit Erklaerung der drei Schritte,
Versuchszaehler an der Ablehnung, Hinweis auf menschliche Pruefung.

Blade: inline @php(...) vor einem @php...@endphp-Block wird als Rohblock
gelesen - vier Stellen auf Blockform umgestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-16 14:30:26 +00:00
parent c08c9e5de0
commit 0692d892e0
17 changed files with 487 additions and 47 deletions

View file

@ -30,7 +30,10 @@
Bildrechte-Bestätigung — es gibt nichts zu bestätigen. Default true, damit
die Checkbox im Zweifel lieber einmal zu viel erscheint.
--}}
@php($bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true))
{{-- Blockform: ein inline @php(...) würde bis zum nächsten @endphp weiter unten als Rohblock gelesen. --}}
@php
$bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true);
@endphp
<flux:modal :name="$name" class="w-full max-w-xl">
@if ($bookingRequired)
@ -72,16 +75,32 @@
<div class="space-y-5" x-data="{ agb: false, images: {{ $hasTitleImage ? 'false' : 'true' }}, contact: false, waiver: false, ai: false }">
<div>
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung freigeben') }}</flux:heading>
</div>
{{-- E3 (16.09.2026): unmissverständlich, dass die Freigabe nicht
„online" heißt — erst die Prüfung, dann die Veröffentlichung. --}}
@php
$maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
@endphp
<article class="panel overflow-hidden" style="border-color:var(--color-hub); border-left-width:3px;">
<div class="p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Was nach der Freigabe passiert') }}</p>
<ol class="m-0 list-decimal space-y-1 ps-5">
<li>{{ __('Der Text geht zur automatischen Inhaltsprüfung — er ist damit noch nicht online.') }}</li>
<li>{{ __('Grün oder Gelb: die Mitteilung wird veröffentlicht. Rot: sie kommt mit Begründung zurück, Sie können nachbessern und erneut freigeben.') }}</li>
<li>{{ __('Nach :max Prüfungen mit Rot sieht sich die Redaktion den Text an und entscheidet.', ['max' => $maxAttempts]) }}</li>
</ol>
</div>
</article>
{{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}}
<article class="panel overflow-hidden">
<div class="panel-head" style="padding:10px 16px;">
<span class="section-eyebrow">{{ __('Rechtliche Hinweise') }}</span>
</div>
<div class="p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit dem Einreichen versichern Sie:') }}</p>
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit der Freigabe versichern Sie:') }}</p>
<ul class="m-0 list-disc space-y-1 ps-5">
<li>{{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}</li>
<li>{{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}</li>
@ -205,7 +224,7 @@
<flux:button variant="primary" wire:click="{{ $action }}"
wire:loading.attr="disabled"
x-bind:disabled="! (agb && images && contact && waiver && ai)">
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
{{ $confirmLabel ?? __('Zur Prüfung freigeben') }}
</flux:button>
</div>
</div>

View file

@ -307,7 +307,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
->orWhereHas('company', fn ($q) => $q->where('name', 'like', '%'.$term.'%'));
});
})
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
// „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'))
->when($this->statusFilter !== 'all' && $this->statusFilter !== 'manual', 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))
@ -413,6 +415,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
->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 [
@ -422,6 +425,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
'draft' => (int) ($stats->draft ?? 0),
'rejected' => (int) ($stats->rejected ?? 0),
'archived' => (int) ($stats->archived ?? 0),
'manual' => (int) ($stats->manual ?? 0),
];
});
}
@ -574,6 +578,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@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>
<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>
@ -611,6 +620,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@foreach ($statusOptions as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
<option value="manual">{{ __('Prüfung nötig (Automatik erschöpft)') }}</option>
</flux:select>
<flux:select wire:model.live="classificationFilter" class="w-full">
@ -814,10 +824,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@endif
@if ($statusFilter !== 'all')
@php $statusEnum = \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
@php $statusEnum = $statusFilter === 'manual' ? null : \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
<span class="active-chip">
<span>{{ __('Status') }}:
<strong>{{ $statusEnum?->label() ?? $statusFilter }}</strong></span>
<strong>{{ $statusEnum?->label() ?? ($statusFilter === 'manual' ? __('Prüfung nötig') : $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">
@ -1055,6 +1065,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<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) {

View file

@ -259,10 +259,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
@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>
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</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
@ -270,8 +277,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<flux:icon.clock class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
<p class="m-0">{{ __('Diese PM wartet auf eine Prüf-Entscheidung.') }}</p>
@if ($latestClassification && $latestClassification->reason)
@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 }}
@ -286,10 +316,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
<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">{{ __('Ablehnen') }}</flux:button>
<flux:button type="button" variant="danger">{{ $manual ? __('Mit Kommentar zurück') : __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
</div>
</div>

View file

@ -826,7 +826,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
class="w-full"
wire:loading.attr="disabled"
>
{{ __('Zur Prüfung senden') }}
{{ __('Zur Prüfung freigeben') }}
</flux:button>
</flux:modal.trigger>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
@ -1084,14 +1084,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}}
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung freigeben") --}}
{{-- Im Erstellen-Formular gibt es noch keinen Bild-Upload (der folgt im
Editor nach dem Entwurf-Sichern), daher entfällt die Bildrechte-Frage. --}}
<x-press-release-submit-modal
name="confirm-submit-review"
action="save('review')"
booking-action="saveDraftAndChooseBooking"
:confirm-label="__('Zur Prüfung senden')"
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="false" />

View file

@ -1055,7 +1055,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
name="confirm-submit-review"
action="saveAndSubmit"
booking-action="saveDraftAndChooseBooking"
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & einreichen')"
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut zur Prüfung freigeben') : __('Speichern & zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder" />

View file

@ -691,7 +691,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
class="font-mono text-[9.5px] tracking-[0.16em] text-[color:var(--color-accent-deep)] font-bold mb-1">
03</div>
<div class="text-[11.5px] font-semibold text-[color:var(--color-ink)] leading-tight">
{{ __('Zur Prüfung senden') }}</div>
{{ __('Zur Prüfung freigeben') }}</div>
</div>
</div>
</x-portal.empty-state>
@ -718,7 +718,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
<li>{{ __('Bearbeiten') }}</li>
<li>{{ __('Vorschau') }}</li>
<li class="text-[color:var(--color-accent-deep)] font-semibold">
{{ __('→ Zur Prüfung senden') }}</li>
{{ __('→ Zur Prüfung freigeben') }}</li>
</ul>
</div>
<div>
@ -743,7 +743,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
<li>{{ __('Bearbeiten') }}</li>
<li class="text-[color:var(--color-accent-deep)] font-semibold">
{{ __('→ Erneut einreichen') }}</li>
{{ __('→ Erneut zur Prüfung freigeben') }}</li>
</ul>
</div>
<div>

View file

@ -797,6 +797,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
return [
'pr' => $pr,
'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null,
'helpPageUrl' => rtrim((string) config('domains.domain_portal_url'), '/').'/de/gute-pressemitteilung',
'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [],
'viewRanges' => VisibilityStats::ranges(),
'publicLinks' => $pr->status === PressReleaseStatus::Published
@ -1035,6 +1036,19 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<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') }}
</span>
@php
$maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
@endphp
@if ($pr->requiresManualReview())
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Für diese Mitteilung entscheidet ab jetzt ein Mensch: Nach der nächsten Freigabe prüft die Redaktion direkt, ohne automatische Vorprüfung.') }}
</span>
@elseif ($pr->auto_check_attempts > 0)
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Automatische Prüfung :used von :max genutzt. Bleibt der Text danach Rot, prüft ein Mensch.', ['used' => $pr->auto_check_attempts, 'max' => $maxAttempts]) }}
<a href="{{ $helpPageUrl }}" target="_blank" rel="noopener" class="underline underline-offset-2">{{ __('Was eine gute Pressemitteilung ausmacht') }}</a>
</span>
@endif
</div>
</div>
</article>
@ -1068,8 +1082,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[220px]">
{{ $pr->status === PressReleaseStatus::Rejected
? __('Sie können den Text bearbeiten und erneut zur Prüfung einreichen.')
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
? __('Sie können den Text bearbeiten und erneut zur Prüfung freigeben.')
: __('Geben Sie den Entwurf zur Prüfung frei, sobald er vollständig ist. Online geht er erst nach der Prüfung.') }}
</p>
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap">
@if ($canEdit)
@ -1080,7 +1094,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if ($hasActiveBooking)
<flux:modal.trigger name="confirm-submit-review">
<flux:button type="button" variant="primary">
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut zur Prüfung freigeben') : __('Zur Prüfung freigeben') }}
</flux:button>
</flux:modal.trigger>
@else
@ -1219,9 +1233,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
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>
<p class="flex-1 text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
</p>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($pr->isAwaitingManualReview())
{{-- E3: Automatik erschöpft — ein Mensch prüft. Kein Termin
versprechen, den wir nicht halten. --}}
<p class="m-0 font-semibold text-[color:var(--color-ink)]">{{ __('Ihre Pressemitteilung wird von einem Menschen geprüft.') }}</p>
<p class="m-0 mt-1">{{ __('Die automatische Prüfung hat den Text mehrfach nicht freigegeben. Jetzt sieht sich die Redaktion Text und Begründungen an und schaltet frei oder meldet sich mit einem Kommentar. Sie werden per E-Mail benachrichtigt.') }}</p>
@else
<p class="m-0">{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}</p>
@endif
</div>
</div>
</article>
@endif
@ -1631,7 +1652,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<x-press-release-submit-modal
name="confirm-submit-review"
action="submitForReview"
:confirm-label="__('Veröffentlichung anfordern')"
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder" />