22-05-2026 Optimierung der User und Admin Panels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
Kevin Adametz 2026-05-22 11:18:59 +02:00
parent d2ba22c0cf
commit e8c47b7553
73 changed files with 10282 additions and 1546 deletions

View file

@ -8,6 +8,7 @@ use App\Models\Contact;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@ -44,6 +45,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public string $publishMode = 'now';
public ?string $scheduledAt = null;
public bool $useEmbargo = false;
public ?string $embargoAt = null;
public function mount(): void
{
$user = auth()->user();
@ -72,6 +79,86 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
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 „Entwurf 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.
* Die einzelnen Feld-Errors werden weiterhin direkt am Input angezeigt,
* der Toast dient als zusätzlicher Wegweiser, falls der erste Fehler
* außerhalb des Viewports liegt.
*/
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'])],
];
// Termin-Pflicht nur, wenn der User explizit Scheduling gewählt hat.
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
// die PM verlässlich rechtzeitig fängt.
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 addTag(string $tag): void
{
$tag = trim($tag);
@ -110,34 +197,35 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public function save(string $submitStatus = 'draft'): void
{
$this->validate([
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'contactId' => ['required', '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'],
]);
try {
$this->validate($this->formRules());
} catch (\Illuminate\Validation\ValidationException $e) {
$this->notifyValidationError($e);
throw $e;
}
$user = auth()->user();
$company = $this->selectedCompany();
if (! $company) {
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
$this->notifyValidationError();
return;
}
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
$contact = null;
if (! $contact) {
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
if ($this->contactId) {
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
return;
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;
@ -167,14 +255,28 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
: 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,
'status' => $status->value,
]);
$pr->contacts()->sync([$contact->id]);
if ($contact) {
$pr->contacts()->sync([$contact->id]);
}
session()->flash('success', $status === PressReleaseStatus::Review
? __('Pressemitteilung zur Prüfung eingereicht.')
: __('Entwurf gespeichert.'));
Flux::toast(
heading: $status === PressReleaseStatus::Review
? __('Eingereicht')
: __('Entwurf gespeichert'),
text: $status === PressReleaseStatus::Review
? __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.')
: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'),
variant: 'success',
);
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
}
@ -246,9 +348,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
],
[
'key' => 'contact',
'status' => $this->contactId ? 'ok' : 'err',
'status' => $this->contactId ? 'ok' : 'warn',
'label' => __('Pressekontakt zugeordnet'),
'sub' => $this->contactId ? '' : __('Mindestens ein Kontakt erforderlich'),
'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'),
],
[
'key' => 'tags',
@ -365,7 +467,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</header>
{{-- ============== 2-COLUMN GRID ============== --}}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),360px]">
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
<div class="space-y-6 min-w-0">
@ -513,7 +615,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</section>
{{-- 6) ANHÄNGE (nach Speichern verfügbar) --}}
{{-- 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.
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4">
@ -529,6 +634,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</div>
</section>
--}}
{{-- 7) BOILERPLATE --}}
<section class="panel">
@ -655,14 +761,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</article>
{{-- Portal (Read-only) --}}
{{-- 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="badge hub dot">{{ $selectedPortalLabel }}</span>
<span class="{{ $portalPillClass }}">
<span class="pdot"></span>{{ $selectedPortalLabel }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-4)]">
{{ __('automatisch aus der Firma') }}
</span>
@ -691,7 +829,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@endif
@else
<flux:field>
<flux:label>{{ __('Kontakt für diese PM') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<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)
@ -708,18 +846,25 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:error name="contactId" />
</flux:field>
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
@if ($activeContact && empty($activeContact->phone))
@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>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
<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 + Kategorie --}}
{{-- Themen-Tags --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
@ -769,20 +914,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
@endif
<flux:field>
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($categories as $cat)
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
</flux:field>
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
{{ __('Tags helfen bei SEO und Auffindbarkeit. Die Kategorie steuert, in welcher Rubrik die PM erscheint.') }}
{{ __('Tags helfen bei SEO und Auffindbarkeit.') }}
</p>
</div>
</article>
@ -792,7 +925,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<div class="panel-head">
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
</div>
<div class="p-5 space-y-2">
<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>
@ -805,18 +938,53 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</span>
</span>
</label>
<span class="pr-pub-opt is-disabled">
<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-ink-2)] leading-tight flex items-center gap-2">
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] leading-tight">
{{ __('Geplanter Termin') }}
<span class="pr-bald-badge">{{ __('bald') }}</span>
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('Datum + Uhrzeit, automatische Veröffentlichung') }}
{{ __('Datum + Uhrzeit — wird automatisch zum Termin veröffentlicht') }}
</span>
</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>
@ -851,7 +1019,6 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</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>· {{ __('Geplante Veröffentlichung / Scheduling') }}</li>
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
</ul>