id = $id; $pr = PressRelease::withoutGlobalScopes()->findOrFail($id); $this->portal = $pr->portal->value; $this->language = $pr->language; $this->companyId = $pr->company_id; $this->categoryId = $pr->category_id; $this->title = $pr->title; $this->subtitle = $pr->subtitle ?? ''; $this->text = $pr->text; $this->keywords = $pr->keywords ?? ''; $this->backlinkUrl = $pr->backlink_url ?? ''; $this->boilerplateOverride = $pr->boilerplate_override ?? ''; $this->useBoilerplateOverride = filled($pr->boilerplate_override); $this->noExport = $pr->no_export; $this->currentStatus = $pr->status->value; $this->targetStatus = $this->currentStatus; $this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id ?? $this->defaultContactIdFor((int) $pr->company_id); if ($pr->scheduled_at) { // DB-Wert ist UTC; für die Eingabefelder nach Europe/Berlin wandeln. $scheduledAt = $pr->scheduled_at->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE); $this->publishMode = 'scheduled'; $this->scheduledAt = $scheduledAt->format('Y-m-d\TH:i'); $this->scheduledDate = $scheduledAt->format('Y-m-d'); $this->scheduledTime = $scheduledAt->format('H:i'); } } public function updatedCompanySearch(): void { $this->resetErrorBag('companyId'); } public function updatedPublishMode(): void { $this->syncScheduledAt(); if ($this->publishMode === 'now') { $this->scheduledDate = null; $this->scheduledTime = null; $this->scheduledAt = null; $this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']); } } public function updatedScheduledDate(): void { $this->syncScheduledAt(); $this->validateScheduledAtWhenReady(); } public function updatedScheduledTime(): void { $this->syncScheduledAt(); $this->validateScheduledAtWhenReady(); } public function updatedCompanyId(): void { if (!$this->companyId) { $this->contactId = null; return; } $contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId); if (!$contactStillValid) { $this->contactId = $this->defaultContactIdFor((int) $this->companyId); } unset($this->presubmitChecks); } public function addTag(string $tag): void { $tag = trim($tag); if ($tag === '') { return; } $existing = $this->tagsArray(); if (count($existing) >= 5) { return; } if (in_array($tag, $existing, true)) { return; } $existing[] = $tag; $this->keywords = implode(', ', $existing); unset($this->tags, $this->presubmitChecks); } public function removeTag(string $tag): void { $existing = array_values(array_filter($this->tagsArray(), fn(string $existingTag): bool => $existingTag !== $tag)); $this->keywords = implode(', ', $existing); unset($this->tags, $this->presubmitChecks); } /** * @return array> */ protected function formRules(): array { $rules = [ 'portal' => ['required', Rule::in(array_map(fn(Portal $p) => $p->value, Portal::cases()))], 'language' => ['required', Rule::in(['de', 'en'])], 'companyId' => ['required', 'integer', Rule::exists('companies', 'id')], 'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')], 'contactId' => ['nullable', 'integer'], 'title' => ['required', 'string', 'min:5', 'max:255'], 'subtitle' => ['nullable', 'string', 'max:255'], 'text' => ['required', 'string', 'min:50'], 'keywords' => ['nullable', 'string', 'max:255'], 'backlinkUrl' => ['nullable', 'url', 'max:255'], 'boilerplateOverride' => ['nullable', 'string', 'max:5000'], 'publishMode' => ['required', Rule::in(['now', 'scheduled'])], ]; if ($this->publishMode === 'scheduled') { $rules['scheduledDate'] = ['required', 'date']; $rules['scheduledTime'] = ['required', 'date_format:H:i']; $rules['scheduledAt'] = [ 'required', 'date', // Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen- // bewusst prüfen statt über die naive `after:`-Regel. function (string $attribute, mixed $value, \Closure $fail): void { $scheduledAt = $this->scheduledAtUtc(); if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { $fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); } }, ]; } else { $rules['scheduledDate'] = ['nullable']; $rules['scheduledTime'] = ['nullable']; $rules['scheduledAt'] = ['nullable']; } $rules['embargoAt'] = ['nullable']; return $rules; } protected function syncScheduledAt(): void { if ($this->publishMode !== 'scheduled') { $this->scheduledAt = null; return; } if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) { $scheduledAt = \Carbon\Carbon::parse($this->scheduledAt); $this->scheduledDate = $scheduledAt->format('Y-m-d'); $this->scheduledTime = $scheduledAt->format('H:i'); return; } if (blank($this->scheduledDate) || blank($this->scheduledTime)) { $this->scheduledAt = null; return; } $this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}"; } /** * Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt, * wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt. */ protected function scheduledAtUtc(): ?\Carbon\Carbon { if (blank($this->scheduledAt)) { return null; } return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc(); } protected function validateScheduledAtWhenReady(): void { if (blank($this->scheduledAt)) { return; } $this->resetErrorBag('scheduledAt'); $scheduledAt = $this->scheduledAtUtc(); if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) { $this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.')); return; } try { $this->validateOnly('scheduledAt', $this->formRules()); } catch (\Illuminate\Validation\ValidationException) { // Termin bleibt invalid. } } /** * Die Termin-Synchronisierung liegt vollständig in den spezifischen * `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt * nur die generische Re-Validierung bereits fehlerhafter Felder. */ public function updated(string $property): void { if (!$this->getErrorBag()->has($property)) { return; } try { $this->validateOnly($property, $this->formRules()); } catch (\Illuminate\Validation\ValidationException) { // Field bleibt invalid. } } protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void { $count = $exception ? array_sum(array_map('count', $exception->errors())) : count($this->getErrorBag()->all()); Flux::toast(heading: __('Bitte Eingaben prüfen'), text: $count > 1 ? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count]) : __('Ein Feld benötigt deine Aufmerksamkeit.'), variant: 'danger', duration: 6000); } public function save(): void { $this->syncScheduledAt(); $this->useEmbargo = false; $this->embargoAt = null; try { $this->validate($this->formRules()); } catch (\Illuminate\Validation\ValidationException $e) { $this->notifyValidationError($e); throw $e; } $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); if ($pr->title !== $this->title || $pr->portal !== $this->portal || $pr->language !== $this->language) { $slug = $pr->generateUniqueSlug($this->title, [ 'portal' => $this->portal, 'language' => $this->language, ]); } else { $slug = $pr->slug; } $cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text); $pr->update([ 'portal' => $this->portal, 'language' => $this->language, 'company_id' => (int) $this->companyId, 'category_id' => (int) $this->categoryId, 'title' => $this->title, 'subtitle' => trim($this->subtitle) ?: null, 'slug' => $slug, 'text' => $cleanText, 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null, 'keywords' => $this->keywords ?: null, 'backlink_url' => $this->backlinkUrl ?: null, 'scheduled_at' => $this->publishMode === 'scheduled' ? $this->scheduledAtUtc() : null, 'embargo_at' => null, 'no_export' => $this->noExport, ]); $contentChanged = $pr->wasChanged(['title', 'text']); if ($this->contactId) { $contact = $this->companyContact((int) $this->contactId, (int) $this->companyId); if ($contact) { $pr->contacts()->sync([$contact->id]); } } // Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu // prüfen (Re-Check ohne Routing) und neu bewerten. if ($contentChanged) { $service = app(PressReleaseService::class); $fresh = $pr->fresh(); $service->reclassifyIfClassified($fresh); $service->rescoreIfScored($fresh); } Flux::toast(text: __('Pressemitteilung gespeichert.'), variant: 'success'); } public function submitForReview(): void { $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); try { app(PressReleaseService::class)->submitForReview($pr); } catch (BlacklistViolationException $e) { $this->currentStatus = PressReleaseStatus::Rejected->value; Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000); return; } $this->currentStatus = PressReleaseStatus::Review->value; Flux::toast(text: __('Zur Prüfung eingereicht.'), variant: 'success'); } /** * Stößt eine manuelle KI-Prüfung im Hintergrund an (Re-Check). * * Anders als beim Einreichen wird hier NICHT geroutet (Status bleibt * unverändert): Das Ergebnis aktualisiert nur Klassifikation + Audit, die * Entscheidung trifft der Admin weiterhin selbst. */ public function runKiCheck(): void { if (! $this->kiRunClassification && ! $this->kiRunContentScore) { Flux::toast(text: __('Es wurde keine Prüfung ausgewählt.'), variant: 'warning'); return; } $provider = $this->kiProvider === 'default' ? null : $this->kiProvider; if ($this->kiRunClassification) { ClassifyPressRelease::dispatch($this->id, route: false, providerOverride: $provider) ->onQueue('classification'); } if ($this->kiRunContentScore) { ScorePressRelease::dispatch($this->id, providerOverride: $provider) ->onQueue('classification'); } Flux::modal('admin-ki-check')->close(); Flux::toast( heading: __('KI-Prüfung gestartet'), text: __('Die Prüfung läuft im Hintergrund. Das Ergebnis erscheint nach Abschluss in der Detailansicht.'), variant: 'success', duration: 7000, ); } public function publish(): void { $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); try { app(PressReleaseService::class)->publish($pr); } catch (BlacklistViolationException $e) { $this->currentStatus = PressReleaseStatus::Rejected->value; Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000); return; } $this->currentStatus = PressReleaseStatus::Published->value; Flux::toast(text: __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'), variant: 'success'); } public function reject(): void { $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); app(PressReleaseService::class)->reject($pr); $this->currentStatus = PressReleaseStatus::Rejected->value; Flux::toast(text: __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'), variant: 'warning'); } public function backToDraft(): void { $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); app(PressReleaseService::class)->backToDraft($pr); $this->currentStatus = PressReleaseStatus::Draft->value; Flux::toast(text: __('Zurück auf Entwurf gesetzt.'), variant: 'success'); } public function archive(): void { $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); app(PressReleaseService::class)->archive($pr); $this->currentStatus = PressReleaseStatus::Archived->value; $this->targetStatus = $this->currentStatus; Flux::toast(text: __('Pressemitteilung archiviert.'), variant: 'success'); } public function changeStatus(): void { $this->validate([ 'targetStatus' => ['required', Rule::in(array_map(fn(PressReleaseStatus $status) => $status->value, PressReleaseStatus::cases()))], ]); if ($this->targetStatus === $this->currentStatus) { $this->addError('targetStatus', __('Bitte wähle einen anderen Status aus.')); return; } $status = PressReleaseStatus::from($this->targetStatus); $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); app(PressReleaseService::class)->changeStatusFromAdmin($pr, $status); $this->currentStatus = $status->value; $this->targetStatus = $status->value; Flux::toast(text: __('Status wurde auf ":status" geändert.', ['status' => $status->label()]), variant: 'success'); Flux::modal('confirm-status-change')->close(); } public function deletePressRelease(): void { $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); $wasPublished = $pr->status === PressReleaseStatus::Published; app(PressReleaseService::class)->deleteFromAdmin($pr); Flux::toast(text: $wasPublished ? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.') : __('Pressemitteilung wurde gelöscht.'), variant: 'success'); $this->redirect(route('admin.press-releases.index'), navigate: true); } public function with(): array { $term = Portal::stripTrailingAbbreviation($this->companySearch); $companies = Company::withoutGlobalScopes() ->when(filled($term), function ($q) use ($term): void { if ($this->supportsFullTextSearch($term)) { $q->whereFullText(['name', 'email', 'slug'], $term); return; } $q->where('name', 'like', '%' . $term . '%')->orWhere('slug', 'like', '%' . $term . '%'); }) ->when(blank($term) && $this->companyId, fn($q) => $q->whereIn('id', [(int) $this->companyId])) ->when(blank($term) && !$this->companyId, fn($q) => $q->whereRaw('0 = 1')) ->orderBy('name') ->limit(50) ->get(['id', 'name']); $statusEnum = PressReleaseStatus::tryFrom($this->currentStatus); $selectedCompany = $this->companyId ? Company::withoutGlobalScopes()->find((int) $this->companyId) : null; return [ 'companies' => $companies, 'categories' => $this->categoryOptions(), 'portalOptions' => array_filter(Portal::cases(), fn(Portal $p) => $p !== Portal::Both), 'statusOptions' => PressReleaseStatus::cases(), 'statusEnum' => $statusEnum, 'targetStatusEnum' => PressReleaseStatus::tryFrom($this->targetStatus), 'selectedCompany' => $selectedCompany, 'selectedCompanyContacts' => $selectedCompany ? $this->companyContacts((int) $selectedCompany->id) : Contact::query()->whereRaw('0 = 1')->get(), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), 'statusColor' => match ($this->currentStatus) { 'published' => 'green', 'review' => 'yellow', 'rejected' => 'red', 'archived' => 'blue', default => 'zinc', }, ]; } #[Computed] public function tags(): array { return $this->tagsArray(); } #[Computed] public function presubmitChecks(): array { $titleLen = mb_strlen(trim($this->title)); $textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text); $tagsCount = count($this->tagsArray()); return [ [ 'key' => 'title', 'status' => $titleLen >= 5 ? 'ok' : 'err', 'label' => __('Titel vorhanden'), 'sub' => $titleLen > 0 ? __(':n Zeichen', ['n' => $titleLen]) : __('Noch leer'), ], [ 'key' => 'text', 'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'), 'label' => __('Mindestlänge Fließtext erreicht'), 'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]), ], [ 'key' => 'company', 'status' => $this->companyId ? 'ok' : 'err', 'label' => __('Firma zugeordnet'), 'sub' => $this->companyId ? '' : __('Keine Firma gewählt'), ], [ 'key' => 'category', 'status' => $this->categoryId ? 'ok' : 'err', 'label' => __('Kategorie gewählt'), 'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'), ], [ 'key' => 'contact', 'status' => $this->contactId ? 'ok' : 'warn', 'label' => __('Pressekontakt zugeordnet'), 'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'), ], [ 'key' => 'tags', 'status' => $tagsCount >= 1 ? 'ok' : 'warn', 'label' => __('Themen-Tags vergeben'), 'sub' => $tagsCount >= 1 ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) : __('empfohlen für SEO & Auffindbarkeit'), ], ]; } /** * @return list */ private function tagsArray(): array { if (trim($this->keywords) === '') { return []; } return collect(explode(',', $this->keywords)) ->map(fn(string $tag): string => trim($tag)) ->filter() ->unique() ->values() ->all(); } private function defaultContactIdFor(int $companyId): ?int { if ($companyId <= 0) { return null; } return $this->companyContacts($companyId)->first()?->id; } /** * @return Collection */ private function companyContacts(int $companyId): Collection { return Contact::withoutGlobalScopes() ->where('company_id', $companyId) ->orderBy('last_name') ->orderBy('first_name') ->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']); } private function companyContact(int $contactId, int $companyId): ?Contact { if ($contactId <= 0 || $companyId <= 0) { return null; } return Contact::withoutGlobalScopes()->where('company_id', $companyId)->whereKey($contactId)->first(); } /** * @return list */ private function tagSuggestionsFor(?Company $company): array { $defaults = [__('Mittelstand'), __('Unternehmen'), __('Eröffnung'), __('Innovation'), __('Nachhaltigkeit')]; if (!$company) { return $defaults; } return array_values(array_unique(array_filter([$company->portal?->label(), $company->country_code === 'DE' ? __('Deutschland') : null, ...$defaults]))); } private function categoryOptions(): Collection { return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn() => Category::query()->with('translations')->where('is_active', true)->orderBy('id')->get()); } private function supportsFullTextSearch(string $term): bool { return mb_strlen($term) >= 3 && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true); } }; ?>
@php $statusClass = match ($currentStatus) { 'published' => 'ok', 'review' => 'warn', 'rejected' => 'err', default => 'hub', }; @endphp {{-- Flash-Banner ersetzt durch im Layout. --}} {{-- ============== PAGE HEADER ============== --}} {{-- ============== KI-PRÜFUNG (On-Demand) ============== --}}
{{ __('KI-Prüfung') }} {{ __('Prüfung im Hintergrund starten') }} {{ __('Startet eine erneute KI-Prüfung. Das Ergebnis aktualisiert nur die Bewertung und das Audit-Log – der Status bleibt unverändert.') }}
{{ __('Abbrechen') }} {{ __('Prüfung starten') }}
{{-- ============== 2-COLUMN GRID ============== --}}
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
{{-- 1) FIRMA-SELEKTOR --}}
{{ __('Für Firma') }} * @foreach ($companies as $company) {{ $company->name }} @if ($company->portal) ({{ $company->portal->abbreviation() }}) @endif @endforeach @if (blank(trim($companySearch))) {{ __('Mindestens 1 Zeichen eingeben…') }} @else {{ __('Keine Firma gefunden.') }} @endif
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} @if ($selectedCompany) {{ __('Firmenprofil') }} @endif
{{-- 2) TITEL --}}
{{ __('Titel / Headline') }} *
@php $titleLen = mb_strlen($title); $titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : ''); $titleBar = min(100, max(0, ($titleLen / 100) * 100)); @endphp {{ $titleLen }} / 100 {{ __('KI-Titel · bald') }}

{{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}

{{-- 3) SUBTITLE --}}
{{ __('Untertitel') }} — {{ __('optional') }} @php $subLen = mb_strlen($subtitle); $subBar = min(100, max(0, ($subLen / 200) * 100)); @endphp {{ $subLen }} / 200
{{-- 4) FLIESSTEXT --}}
{{ __('Fließtext') }} *
@php $textLen = app( \App\Services\PressRelease\PressReleaseHtmlSanitizer::class, )->plainTextLength($text); $textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : ''); $textBar = min(100, max(0, ($textLen / 3500) * 100)); @endphp {{ number_format($textLen, 0, ',', '.') }} / 3.500 Z. {{ __('KI-Lektorat · bald') }}
{{ __('KI-Lektorat') }} {{ __('liest Korrektur, schlägt Kürzungen vor und prüft auf werbliche Sprache. Erscheint hier inline — bald verfügbar.') }}
{{-- 5) MEDIEN --}} {{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT Datei-Uploads erfordern eine vollständige Sicherheitsprüfung (Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten). Wird in einer späteren Phase aktiviert. --}} {{-- 7) BOILERPLATE --}}
{{ __('Über das Unternehmen') }} — {{ __('Boilerplate aus Firma') }}
@if ($selectedCompany?->boilerplate)

{!! nl2br(e($selectedCompany->boilerplate)) !!}

@if ($selectedCompany->website)

{{ __('Web') }}: {{ $selectedCompany->website }}

@endif
@else
{{ __('Für diese Firma ist noch kein Boilerplate-Text hinterlegt. Du kannst entweder einen Override-Text für diese PM setzen oder das Firmenprofil ergänzen.') }}
@endif @if ($useBoilerplateOverride)
@endif

{{ __('Wird automatisch unter jeder Pressemitteilung dieser Firma angefügt. Pro PM editierbar.') }}

{{-- /Schreibfläche --}} {{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
{{ __('Status wirklich wechseln?') }} {{ __('Aktuell: :current. Neuer Status: :target.', [ 'current' => $statusEnum?->label() ?? $currentStatus, 'target' => $targetStatusEnum?->label() ?? $targetStatus, ]) }}
{{ __('Abbrechen') }} {{ __('Status ändern') }}
{{ __('Pressemitteilung löschen?') }} @if ($currentStatus === 'published') {{ __('Diese Pressemitteilung ist veröffentlicht. Sie wird nicht entfernt, sondern archiviert und der Inhalt wird durch den voreingestellten Ersatztext ersetzt, damit die URL keinen 404-Fehler erzeugt.') }} @else {{ __('Diese Pressemitteilung wird per Soft Delete aus den Standardlisten entfernt.') }} @endif
{{ __('Abbrechen') }} {{ __('Löschung bestätigen') }}