presseportale/resources/views/livewire/customer/press-releases/edit.blade.php
Kevin Adametz e8c47b7553
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
22-05-2026 Optimierung der User und Admin Panels
2026-05-22 11:18:59 +02:00

1064 lines
46 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\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
{
#[Locked]
public int $id;
public string $portal = '';
public string $language = 'de';
public int|string|null $companyId = null;
public int|string|null $categoryId = null;
public int|string|null $contactId = null;
public string $title = '';
public string $subtitle = '';
public string $text = '';
public string $keywords = '';
public string $backlinkUrl = '';
public string $boilerplateOverride = '';
public bool $useBoilerplateOverride = false;
public string $publishMode = 'now';
public ?string $scheduledAt = null;
public bool $useEmbargo = false;
public ?string $embargoAt = null;
public string $currentStatus = '';
public function mount(int $id): void
{
$this->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<string, array<int, mixed>>
*/
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<string>
*/
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<int, Contact>
*/
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<string>
*/
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,
])));
}
}; ?>
<div class="space-y-8" x-data="{ tagInput: '' }">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Bearbeiten') }}</span>
@if ($currentStatus === 'rejected')
<span class="badge err dot">{{ __('Abgelehnt') }}</span>
@else
<span class="badge muted dot">{{ __('Entwurf') }}</span>
@endif
<span class="badge hub">ID {{ $id }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Pressemitteilung bearbeiten') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Schreibfläche links, Steuerung rechts. Pflichtfelder werden rechts in der Checkliste angezeigt.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="eye" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
{{ __('Vorschau / Detail') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zur Liste') }}
</flux:button>
</div>
</header>
{{-- ============== 2-COLUMN GRID ============== --}}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
<div class="space-y-6 min-w-0">
{{-- 1) FIRMA-SELEKTOR --}}
<section class="panel">
<div class="p-4 flex flex-wrap items-center gap-4">
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
<flux:select wire:model.live="companyId" class="!w-auto min-w-[260px]">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }}
</span>
<span class="flex-1"></span>
@if ($selectedCompany)
<flux:button size="sm" variant="ghost" icon="building-office"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Firmenprofil') }}
</flux:button>
@endif
</div>
<flux:error name="companyId" />
</section>
{{-- 2) TITEL --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Titel / Headline') }} <span class="req">*</span>
</span>
<div class="flex items-center gap-3">
@php
$titleLen = mb_strlen($title);
$titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : '');
$titleBar = min(100, max(0, ($titleLen / 100) * 100));
@endphp
<span class="pr-meter {{ $titleClass }}">
<span class="bar"><i style="width: {{ $titleBar }}%;"></i></span>
{{ $titleLen }} / 100
</span>
<span class="pr-bald-badge">{{ __('KI-Titel · bald') }}</span>
</div>
</div>
<flux:input
wire:model.live.debounce.300ms="title"
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}"
size="lg"
/>
<p class="pr-form-help">
{{ __('4090 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
</p>
<flux:error name="title" />
</div>
</section>
{{-- 3) SUBTITLE --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Untertitel') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
— {{ __('optional') }}
</span>
</span>
@php
$subLen = mb_strlen($subtitle);
$subBar = min(100, max(0, ($subLen / 200) * 100));
@endphp
<span class="pr-meter">
<span class="bar"><i style="width: {{ $subBar }}%;"></i></span>
{{ $subLen }} / 200
</span>
</div>
<flux:input
wire:model.live.debounce.300ms="subtitle"
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}"
/>
<flux:error name="subtitle" />
</div>
</section>
{{-- 4) FLIESSTEXT --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Fließtext') }} <span class="req">*</span>
</span>
<div class="flex items-center gap-3">
@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
<span class="pr-meter {{ $textClass }}">
<span class="bar"><i style="width: {{ $textBar }}%;"></i></span>
{{ number_format($textLen, 0, ',', '.') }} / 3.500 Z.
</span>
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
</div>
</div>
<flux:editor
wire:model.live.debounce.500ms="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
placeholder="{{ __('Hier weiterschreiben…') }}"
/>
<flux:error name="text" />
<div class="pr-ai-hint mt-4">
<span class="ico">
<flux:icon name="sparkles" variant="micro" />
</span>
<span>
<strong class="font-semibold">{{ __('KI-Lektorat') }}</strong>
{{ __('liest Korrektur, schlägt Kürzungen vor und prüft auf werbliche Sprache. Erscheint hier inline — bald verfügbar.') }}
</span>
</div>
</div>
</section>
{{-- 5) MEDIEN — Image-Manager direkt eingebunden --}}
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
{{-- 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.
<livewire:components.press-release-attachments-manager :press-release-id="$id" :wire:key="'pr-attachments-'.$id" />
--}}
{{-- 7) BOILERPLATE --}}
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Über das Unternehmen') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
— {{ __('Boilerplate aus Firma') }}
</span>
</span>
<flux:checkbox
wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')"
/>
</div>
@if ($selectedCompany?->boilerplate)
<div class="pr-boiler">
<p class="m-0">{!! nl2br(e($selectedCompany->boilerplate)) !!}</p>
@if ($selectedCompany->website)
<p class="m-0 text-[12px] text-[color:var(--color-ink-3)] mt-3">
<span class="font-semibold text-[color:var(--color-ink-2)]">{{ __('Web') }}:</span>
{{ $selectedCompany->website }}
</p>
@endif
</div>
@else
<div class="pr-boiler text-[color:var(--color-ink-3)]">
{{ __('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.') }}
</div>
@endif
@if ($useBoilerplateOverride)
<div class="mt-3">
<flux:textarea
wire:model.live.debounce.500ms="boilerplateOverride"
rows="5"
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}"
/>
<flux:error name="boilerplateOverride" />
</div>
@endif
<p class="pr-form-help">
{{ __('Wird automatisch unter jeder Pressemitteilung dieser Firma angefügt. Pro PM editierbar.') }}
</p>
</div>
</section>
</div>
{{-- /Schreibfläche --}}
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
<aside class="space-y-4 lg:sticky lg:top-4 self-start">
{{-- Status & Absenden --}}
<article class="panel" style="border-color:var(--color-hub);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status & Absenden') }}</span>
@if ($currentStatus === 'rejected')
<span class="badge err dot">{{ __('Abgelehnt') }}</span>
@else
<span class="badge muted dot">{{ __('Entwurf') }}</span>
@endif
</div>
<div class="p-5">
<div class="rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 mb-3">
@php
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
$totalCount = count($this->presubmitChecks);
@endphp
<div class="flex items-center justify-between mb-1">
<span class="eyebrow muted" style="font-size:9.5px;letter-spacing:0.16em;">
{{ __('Pre-Submit-Check') }}
</span>
<span class="text-[10.5px] font-mono font-semibold text-[color:var(--color-ok)]">
{{ $okCount }} / {{ $totalCount }} ok
</span>
</div>
@foreach ($this->presubmitChecks as $check)
<div class="pr-check-row {{ $check['status'] }}">
<span class="ic">
@if ($check['status'] === 'ok')
<flux:icon name="check" variant="micro" class="size-3" />
@elseif ($check['status'] === 'warn')
<flux:icon name="exclamation-triangle" variant="micro" class="size-3" />
@else
<flux:icon name="x-mark" variant="micro" class="size-3" />
@endif
</span>
<span class="lbl">
{{ $check['label'] }}
@if (! empty($check['sub']))
<span class="sub">{{ $check['sub'] }}</span>
@endif
</span>
</div>
@endforeach
</div>
<flux:button
type="button"
variant="primary"
icon="paper-airplane"
class="w-full"
wire:click="saveAndSubmit"
wire:confirm="{{ __('Änderungen speichern und zur Prüfung einreichen?') }}"
wire:loading.attr="disabled"
>
{{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }}
</flux:button>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}
</p>
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
<flux:button
type="button"
variant="ghost"
icon="bookmark"
class="w-full"
wire:click="save"
wire:loading.attr="disabled"
>
{{ __('Nur speichern') }}
</flux:button>
</div>
</article>
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">
{{ __('Kategorie') }}
<span class="text-[color:var(--color-err)]">*</span>
</span>
</div>
<div class="p-5">
<flux:field>
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($categories as $cat)
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
</flux:field>
</div>
</article>
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
@php
$portalPillClass = 'portal-pill';
if ($portal === 'presseecho') {
$portalPillClass = 'portal-pill pe';
} elseif ($portal === 'businessportal24') {
$portalPillClass = 'portal-pill bp';
}
@endphp
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Portal') }}</span>
</div>
<div class="p-5">
<div class="flex items-center gap-3">
<span class="{{ $portalPillClass }}">
<span class="pdot"></span>{{ $selectedPortalLabel }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-4)]">
{{ __('automatisch aus der Firma') }}
</span>
</div>
<p class="text-[11px] text-[color:var(--color-ink-4)] mt-3 m-0 leading-[1.45]">
{{ __('Cross-Publishing auf beide Portale folgt in Phase 2.') }}
</p>
</div>
</article>
{{-- Pressekontakt --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Pressekontakt') }}</span>
</div>
<div class="p-5 space-y-3">
@if ($selectedCompanyContacts->isEmpty())
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Diese Firma hat noch keine Pressekontakte.') }}
</p>
@if ($selectedCompany)
<flux:button size="sm" variant="ghost" icon="plus" class="w-full"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Kontakt im Firmenprofil anlegen') }}
</flux:button>
@endif
@else
<flux:field>
<flux:label>{{ __('Kontakt für diese PM') }}</flux:label>
<flux:select wire:model.live="contactId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($selectedCompanyContacts as $contact)
@php
$contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''))
?: __('Kontakt #:n', ['n' => $contact->id]);
$contactRole = $contact->responsibility ?: __('Kontakt');
@endphp
<option value="{{ $contact->id }}">
{{ $contactName }} — {{ $contactRole }}
</option>
@endforeach
</flux:select>
<flux:error name="contactId" />
</flux:field>
@if (! $contactId)
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
</div>
@else
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
@if ($activeContact && empty($activeContact->phone))
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
</div>
@endif
@endif
@endif
</div>
</article>
{{-- Themen-Tags --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
<strong class="font-mono text-[color:var(--color-ink-2)]">{{ count($this->tags) }}</strong> / 5
</span>
</div>
<div class="p-5 space-y-3">
<div class="border border-[color:var(--color-bg-rule)] rounded-[4px] bg-[color:var(--color-bg-card)] px-2 py-2 min-h-[58px] flex flex-wrap items-center gap-1.5">
@forelse ($this->tags as $tag)
<span class="pr-tag-chip" wire:key="tag-{{ $tag }}">
{{ $tag }}
<button type="button" class="x" wire:click="removeTag(@js($tag))" title="{{ __('Entfernen') }}">×</button>
</span>
@empty
@if (count($this->tags) === 0)
<span class="text-[11.5px] text-[color:var(--color-ink-4)] italic px-1.5">
{{ __('Tag tippen + Enter…') }}
</span>
@endif
@endforelse
<input
type="text"
x-model="tagInput"
@keydown.enter.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
@keydown.comma.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
class="flex-1 min-w-[80px] border-0 bg-transparent text-[12px] text-[color:var(--color-ink)] focus:outline-none p-1"
placeholder="{{ count($this->tags) === 0 ? '' : '+ Tag' }}"
@disabled(count($this->tags) >= 5)
/>
</div>
@if (! empty($tagSuggestions))
<div>
<div class="eyebrow muted mb-1.5" style="font-size:9.5px;">{{ __('Vorschläge') }}</div>
<div class="flex flex-wrap gap-1.5">
@foreach ($tagSuggestions as $suggestion)
@if (! in_array($suggestion, $this->tags, true))
<button
type="button"
class="pr-tag-suggest"
wire:click="addTag(@js($suggestion))"
>+ {{ $suggestion }}</button>
@endif
@endforeach
</div>
</div>
@endif
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
{{ __('Tags helfen bei SEO und Auffindbarkeit.') }}
</p>
</div>
</article>
{{-- Veröffentlichung --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
</div>
<div class="p-5 space-y-3">
<label class="pr-pub-opt {{ $publishMode === 'now' ? 'is-checked' : '' }}">
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
<span class="dot-out"></span>
<span>
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] block leading-tight">
{{ __('Sofort nach Freigabe') }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('geht live, sobald die Redaktion grünes Licht gibt') }}
</span>
</span>
</label>
<label class="pr-pub-opt {{ $publishMode === 'scheduled' ? 'is-checked' : '' }}">
<input type="radio" wire:model.live="publishMode" value="scheduled" class="sr-only" />
<span class="dot-out"></span>
<span class="flex-1">
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] leading-tight">
{{ __('Geplanter Termin') }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('Datum + Uhrzeit — wird automatisch zum Termin veröffentlicht') }}
</span>
</span>
</label>
@if ($publishMode === 'scheduled')
<flux:field>
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
<flux:input
wire:model.live="scheduledAt"
type="datetime-local"
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
/>
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
<flux:error name="scheduledAt" />
</flux:field>
@endif
<div class="border-t pt-3" style="border-color: var(--color-line);">
<flux:switch
wire:model.live="useEmbargo"
:label="__('Sperrfrist (Embargo) setzen')"
/>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
{{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
</p>
@if ($useEmbargo)
<flux:field class="mt-3">
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
<flux:input
wire:model.live="embargoAt"
type="datetime-local"
:min="now()->format('Y-m-d\\TH:i')"
/>
<flux:error name="embargoAt" />
</flux:field>
@endif
</div>
</div>
</article>
{{-- Weitere Felder --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Weitere Felder') }}</span>
</div>
<div class="p-5 space-y-3">
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
<flux:field>
<flux:label>{{ __('Sprache') }}</flux:label>
<flux:select wire:model="language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</flux:field>
</div>
</article>
{{-- Phase-2-Footer --}}
<div class="rounded-[5px] border p-3.5"
style="background:var(--color-accent-soft);border-color:color-mix(in srgb, var(--color-accent) 50%, transparent);">
<div class="flex items-center gap-2 mb-2">
<flux:icon name="sparkles" variant="micro" class="size-3.5 text-[color:var(--color-accent-deep)]" />
<span class="eyebrow accent">{{ __('Phase 2 — bald') }}</span>
</div>
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
</ul>
</div>
</aside>
</div>
</div>