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->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->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id ?? $this->defaultContactIdFor((int) $pr->company_id); if ($pr->scheduled_at) { $this->publishMode = 'scheduled'; $this->scheduledAt = $pr->scheduled_at->format('Y-m-d\TH:i'); } if ($pr->embargo_at) { $this->useEmbargo = true; $this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i'); } } 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 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); } /** * Live-Re-Validation: sobald für ein Property bereits ein Error im Bag * liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein * roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und * der User muss nicht erst auf „Speichern" klicken. */ 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 — Error-Bag wird automatisch befüllt. } } /** * Toast mit Sammelhinweis nach fehlgeschlagener Validierung. */ 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, ); } /** * Single Source of Truth für die Validierungsregeln. * * @return array> */ protected function formRules(): array { $rules = [ 'language' => ['required', Rule::in(['de', 'en'])], 'companyId' => ['required', 'integer'], '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['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()]; } else { $rules['scheduledAt'] = ['nullable']; } if ($this->useEmbargo) { $rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()]; } else { $rules['embargoAt'] = ['nullable']; } return $rules; } public function save(bool $submitAfterSave = false): void { 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; } $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; } } $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, 'keywords' => $this->keywords ?: null, 'backlink_url' => $this->backlinkUrl ?: null, 'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt ? \Carbon\Carbon::parse($this->scheduledAt) : null, 'embargo_at' => $this->useEmbargo && $this->embargoAt ? \Carbon\Carbon::parse($this->embargoAt) : null, ]); $pr->contacts()->sync($contact ? [$contact->id] : []); 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; } Flux::toast( heading: __('Eingereicht'), text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'), variant: 'success', ); } else { 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); $myCompanies = $context->companiesFor($user); $selectedCompany = $this->selectedCompany(); $categories = Category::query() ->with('translations') ->where('is_active', true) ->orderBy('id') ->get(); return [ 'myCompanies' => $myCompanies, '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), ]; } #[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->selectedCompany()?->name ?? __('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 { 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 { return Contact::withoutGlobalScopes() ->where('company_id', $companyId) ->whereKey($contactId) ->first(); } private function getMyPR(): PressRelease { return PressRelease::withoutGlobalScopes() ->where('user_id', auth()->id()) ->findOrFail($this->id); } private function selectedCompany(): ?Company { if (! $this->companyId) { return null; } return app(CustomerCompanyContext::class) ->findFor(auth()->user(), (int) $this->companyId); } /** * @return list */ private function tagSuggestionsFor(?Company $company): array { $defaults = [ __('Mittelstand'), __('Unternehmen'), __('Eröffnung'), __('Innovation'), __('Nachhaltigkeit'), ]; if (! $company) { return $defaults; } $portalLabel = $company->portal?->label(); return array_values(array_unique(array_filter([ $portalLabel, $company->country_code === 'DE' ? __('Deutschland') : null, ...$defaults, ]))); } }; ?>
{{-- ============== PAGE HEADER ============== --}}
{{ __('User Backend') }} {{ __('Mein Bereich · Bearbeiten') }} @if ($currentStatus === 'rejected') {{ __('Abgelehnt') }} @else {{ __('Entwurf') }} @endif ID {{ $id }}

{{ __('Pressemitteilung bearbeiten') }}

{{ __('Schreibfläche links, Steuerung rechts. Pflichtfelder werden rechts in der Checkliste angezeigt.') }}

{{ __('Vorschau / Detail') }} {{ __('Zur Liste') }}
{{-- ============== 2-COLUMN GRID ============== --}}
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
{{-- 1) FIRMA-SELEKTOR --}}
{{ __('Für Firma') }} @foreach ($myCompanies as $c) @endforeach {{ __('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 — Image-Manager direkt eingebunden --}} {{-- 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 =================== --}}