id = $id; $pr = $this->getMyPR(); $this->authorize('update', $pr); abort_unless( in_array($pr->status->value, ['draft', 'rejected']), 403, __('Nur Entwürfe und abgelehnte Pressemitteilungen können bearbeitet werden.') ); $this->currentStatus = $pr->status->value; $this->contentScore = $pr->content_score; $this->contentTier = $pr->content_tier?->value; $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 ?? ''; // Legacy-Plaintext (ältere Importe) in editierbares HTML wandeln, sonst // verschwinden die Zeilenumbrüche beim Laden in den Flux-Editor. $this->text = app(PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text); $this->keywords = $pr->keywords ?? ''; $this->backlinkUrl = $pr->backlink_url ?? ''; $this->boilerplateOverride = $pr->boilerplate_override ?? ''; $this->useBoilerplateOverride = filled($pr->boilerplate_override); $this->contactId = $pr->contacts()->withoutGlobalScope(PortalScope::class)->first()?->id ?? $this->defaultContactIdFor((int) $pr->company_id); if ($pr->scheduled_at) { $scheduledAt = $pr->scheduled_at; $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'); } $this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value; // KI-Grad vorbelegen (bereits deklariert), gemappt auf die zwei // Formular-Optionen; die verbindliche Bestätigung muss bei jedem // Submit erneut gesetzt werden. $this->aiTextDeclaration = $pr->ai_text_declaration?->formOption()->value; } #[On('placeholder-selected')] public function setPlaceholderVariant(string $variant): void { $this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value; } #[On('title-image-changed')] public function refreshTitleImage(): void { unset($this->tags, $this->presubmitChecks); } public function updatedCompanyId(): void { $company = $this->selectedCompany(); if ($company?->portal) { $this->portal = $company->portal->value; } if ($company) { $contactStillValid = $this->companyContact((int) $this->contactId, (int) $company->id); if (! $contactStillValid) { $this->contactId = $this->defaultContactIdFor((int) $company->id); } } else { $this->contactId = null; } unset($this->tags, $this->presubmitChecks); } public function updatedCompanySearch(): void { $this->resetErrorBag('companyId'); } // Tag-Verwaltung (addTag/removeTag/tagsArray) liegt im Trait // App\Livewire\Concerns\InteractsWithPressReleaseTags. /** * Single Source of Truth für die Validierungsregeln. * * @return array> */ protected function formRules(): array { return [ ...$this->baseFormRules(), 'companyId' => ['required', 'integer'], ]; } /** * Validiert und speichert die Bearbeitung. Gibt [PressRelease, contentChanged] * zurück oder null bei Firma-/Kontakt-Fehlern (Validierung wirft). * * Wichtig: einziger Persistenz-Pfad. Jede Aktion (Speichern, Einreichen, * „Buchung auswählen") MUSS zuerst hierüber speichern, bevor navigiert wird. * * @return array{0: \App\Models\PressRelease, 1: bool}|null */ private function persistEdit(): ?array { $this->syncScheduledAt(); $this->useEmbargo = false; $this->embargoAt = null; try { $this->validate($this->formRules()); } catch (\Illuminate\Validation\ValidationException $e) { $this->notifyValidationError($e); throw $e; } $pr = $this->getMyPR(); $this->authorize('update', $pr); $company = $this->selectedCompany(); if (! $company) { $this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.')); $this->notifyValidationError(); return null; } $contact = null; if ($this->contactId) { $contact = $this->companyContact((int) $this->contactId, (int) $company->id); if (! $contact) { $this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.')); $this->notifyValidationError(); return null; } } $this->portal = $company->portal?->value ?? Portal::Presseecho->value; $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, 'text' => $cleanText, 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null, 'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value, 'keywords' => $this->keywords ?: null, 'backlink_url' => $this->backlinkUrl ?: null, 'scheduled_at' => $this->publishMode === 'scheduled' ? $this->resolveScheduledAt() : null, 'embargo_at' => null, ...$this->aiDeclarationAttributes(), ]); $contentChanged = $pr->wasChanged(['title', 'text']); $pr->contacts()->sync($contact ? [$contact->id] : []); return [$pr, $contentChanged]; } /** * Speichert die Bearbeitung und leitet zur Buchungsseite — wird vom * Submit-Modal genutzt, wenn keine Buchung vorliegt. So gehen die * erfassten Änderungen nicht verloren (kein Sprung ohne Speichern). */ public function saveDraftAndChooseBooking(): void { $result = $this->persistEdit(); if (! $result) { return; } [$pr, $contentChanged] = $result; if ($contentChanged) { app(PressReleaseService::class)->reprocessAfterContentChange($pr); } Flux::toast( heading: __('Gespeichert'), text: __('Ihre Änderungen sind gesichert. Wählen Sie eine Buchung, um die PM anschließend einzureichen.'), variant: 'success', ); $this->redirect(route('me.bookings.index'), navigate: true); } public function save(bool $submitAfterSave = false): void { // KI-Kennzeichnung (Art. 50 KI-VO): Pflicht nur beim Einreichen — // zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen. if ($submitAfterSave) { $this->validateAiDeclaration(); $this->persistLegalAcceptances(); } $result = $this->persistEdit(); if (! $result) { return; } [$pr, $contentChanged] = $result; if ($submitAfterSave) { $this->authorize('submitForReview', $pr); try { app(PressReleaseService::class)->submitForReview($pr->fresh()); } catch (BlacklistViolationException $e) { Flux::toast( heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]), variant: 'danger', duration: 8000, ); $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); return; } catch (AiDeclarationRequiredException|BookingRequiredException|QuotaExceededException $e) { Flux::toast( heading: __('Gespeichert, aber nicht eingereicht'), text: $e->getMessage(), variant: 'warning', duration: 8000, ); return; } Flux::toast( heading: __('Eingereicht'), text: __('Die automatische Inhaltsprüfung ist in der Regel innerhalb weniger Minuten abgeschlossen.'), variant: 'success', ); } else { // Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu // prüfen (Re-Check ohne Routing) und neu bewerten. Beim Einreichen // übernimmt das submitForReview. if ($contentChanged) { app(PressReleaseService::class)->reprocessAfterContentChange($pr); } Flux::toast( heading: __('Gespeichert'), text: __('Deine Änderungen sind gesichert.'), variant: 'success', ); } $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); } public function saveAndSubmit(): void { $this->save(submitAfterSave: true); } public function with(): array { $user = auth()->user(); $context = app(CustomerCompanyContext::class); $selectedCompany = $this->selectedCompany(); $companyOptions = $context->searchCompaniesFor( $user, $this->companySearch, $this->companyId ? (int) $this->companyId : null, 10, ); $categories = Category::query() ->with('translations') ->where('is_active', true) ->orderBy('id') ->get(); $pressRelease = $this->getMyPR()->load('images'); $cover = app(PressReleaseCoverImage::class); // Bei abgelehnten PMs die Begründung direkt im Editor zeigen // (QA-Befund 07.07.), analog zur Detailseite. $latestRejection = null; if ($pressRelease->status->value === 'rejected') { $latestRejection = $pressRelease->statusLogs ->firstWhere(fn ($log) => $log->to_status?->value === 'rejected'); } return [ 'latestRejection' => $latestRejection, 'companyOptions' => $companyOptions, 'categories' => $categories, 'selectedCompany' => $selectedCompany, 'selectedCompanyContacts' => $selectedCompany ? $this->companyContacts((int) $selectedCompany->id) : Contact::query()->whereRaw('0 = 1')->get(), 'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), 'coverUrl' => $cover->coverUrl($pressRelease, 'cover'), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease), 'quotaTotal' => $user->pressReleaseQuotaTotal(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), ]; } #[Computed] public function tags(): array { return $this->tagsArray(); } private function getMyPR(): PressRelease { // Pro Livewire-Request memoisiert: mount(), with() und save() greifen // sonst jeweils mit einer eigenen Query auf dieselbe PM zu. return $this->cachedPressRelease ??= PressRelease::accessibleBy(auth()->user()) ->findOrFail($this->id); } private function selectedCompany(): ?Company { if (! $this->companyId) { return null; } return app(CustomerCompanyContext::class) ->findFor(auth()->user(), (int) $this->companyId); } }; ?>
{{-- ============== PAGE HEADER ============== --}} {{-- ============== REJECTION-HINWEIS ============== --}} @if ($currentStatus === 'rejected' && $latestRejection)
{{ __('Diese Pressemitteilung wurde abgelehnt') }} {{ __('Handlung erforderlich') }}
@if ($latestRejection->reason) {{ __('Begründung') }}: {{ $latestRejection->reason }} @else {{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }} @endif {{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
@endif {{-- ============== 2-COLUMN GRID ============== --}}
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
{{-- 1) FIRMA-SELEKTOR --}}
{{ __('Für Firma') }}
@foreach ($companyOptions as $company) {{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }} @endforeach @if (blank(trim($companySearch))) {{ __('Keine Firma verfügbar.') }} @else {{ __('Keine Firma gefunden.') }} @endif
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} @if ($selectedCompany) {{ __('Firmenprofil') }} @endif
{{-- 2) KATEGORIE (Pflichtfeld — bewusst prominent vor der Headline) --}}
{{ __('Kategorie') }} *
@foreach ($categories as $cat) @endforeach
{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}
{{-- 3) 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.') }}

{{-- 4) SUBTITLE --}}
{{ __('Untertitel') }} — {{ __('optional') }} @php $subLen = mb_strlen($subtitle); $subBar = min(100, max(0, ($subLen / 200) * 100)); @endphp {{ $subLen }} / 200
{{-- 5) 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') }}

{{ __('Links im Text sind ausdrücklich erwünscht. Externe Links werden bei Veröffentlichung automatisch als sponsored/nofollow ausgezeichnet (Google-Vorgabe für Pressemitteilungen) — Links auf Ihr Unternehmensprofil im Portal bleiben follow.') }}

{{ __('KI-Lektorat') }} {{ __('liest Korrektur, schlägt Kürzungen vor und prüft auf werbliche Sprache. Erscheint hier inline — bald verfügbar.') }}
@if ($coverIsPlaceholder) {{-- 6) TITELBILD-PLATZHALTER --}}
{{ __('Titelbild-Platzhalter') }} {{ __('Platzhalter wählen') }}

{{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }}

@endif {{-- 7) MEDIEN — Image-Manager direkt eingebunden --}} @if (config('images.rights_v2')) @else @endif {{-- 7) 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. --}} {{-- 8) 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 =================== --}}
{{-- Einreichungs-Modal (öffnet über „Speichern & zur Prüfung") --}}