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

@ -66,7 +66,7 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
})
->orderBy($sort, $this->sortDir);
$categories = $categoriesQuery->simplePaginate(50);
$categories = $categoriesQuery->paginate(50);
if (! $sortsByCount) {
$this->hydrateCounts($categories);
@ -306,5 +306,5 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
@endforelse
</section>
{{ $categories->links() }}
{{ $categories->links('components.portal.pagination') }}
</div>

View file

@ -101,7 +101,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
})
->orderBy($sort, $this->sortDir);
$companies = $companiesQuery->simplePaginate(50);
$companies = $companiesQuery->paginate(50);
if (! $sortsByCount) {
$this->hydrateCounts($companies);
@ -586,7 +586,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
</flux:table.rows>
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $companies->links() }}
{{ $companies->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -176,7 +176,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
$query->where('portal', $this->portalFilter);
})
->orderBy(in_array($this->sortBy, ['last_name', 'email', 'company_id', 'press_releases_count', 'created_at'], true) ? $this->sortBy : 'created_at', $this->sortDir)
->simplePaginate(50);
->paginate(50);
// Firmen-Filter: nur Live-Suche, nie alle laden
$term = trim($this->companySearch);
@ -745,7 +745,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $contacts->links() }}
{{ $contacts->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -250,7 +250,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $codes->links() }}
{{ $codes->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -328,7 +328,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $invoices->links() }}
{{ $invoices->links('components.portal.pagination') }}
</div>
</article>
</div>

View file

@ -208,7 +208,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellungen')] class extends
</flux:table.rows>
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $presets->links() }}
{{ $presets->links('components.portal.pagination') }}
</div>
</article>
</div>

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -157,16 +158,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
return;
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
session()->flash('success', __('Pressemitteilung veröffentlicht.'));
Flux::toast(text: __('Pressemitteilung veröffentlicht.'), variant: 'success');
}
public function reject(int $id): void
@ -176,12 +182,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
try {
app(PressReleaseService::class)->reject($pr, __('Bitte überarbeiten Sie die Pressemitteilung.'));
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
session()->flash('success', __('Pressemitteilung abgelehnt.'));
Flux::toast(text: __('Pressemitteilung abgelehnt.'), variant: 'warning');
}
public function archive(int $id): void
@ -191,12 +197,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
try {
app(PressReleaseService::class)->archive($pr);
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
Flux::toast(text: $e->getMessage(), variant: 'danger');
return;
}
session()->flash('success', __('Pressemitteilung archiviert.'));
Flux::toast(text: __('Pressemitteilung archiviert.'), variant: 'success');
}
public function with(): array
@ -226,7 +232,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
->when($this->companyFilter !== 'all', fn ($q) => $q->where('company_id', (int) $this->companyFilter))
->when($this->contactFilter !== 'all', fn ($q) => $q->whereHas('contacts', fn ($contactQuery) => $contactQuery->where('contacts.id', (int) $this->contactFilter)))
->orderBy(in_array($this->sortBy, ['title', 'status', 'portal', 'hits', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
->simplePaginate(50);
->paginate(50);
return [
'pressReleases' => $query,
@ -373,18 +379,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
}; ?>
<div class="space-y-8">
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@endif
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
@ -905,6 +900,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
</div>
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.calendar variant="micro" class="size-3" />
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
</div>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.lock-closed variant="micro" class="size-3" />
<span>{{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }}</span>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
@ -1029,5 +1036,5 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</flux:table>
</article>
{{ $pressReleases->links() }}
{{ $pressReleases->links('components.portal.pagination') }}
</div>

View file

@ -28,13 +28,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
Flux::modal('confirm-show-publish')->close();
return;
}
session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'));
Flux::toast(text: __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'), variant: 'success');
Flux::modal('confirm-show-publish')->close();
}
@ -49,7 +54,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$this->rejectReason = '';
session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'));
Flux::toast(text: __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'), variant: 'warning');
Flux::modal('confirm-show-reject')->close();
}
@ -57,7 +62,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->archive($pr);
session()->flash('success', __('Archiviert.'));
Flux::toast(text: __('Archiviert.'), variant: 'success');
Flux::modal('confirm-show-archive')->close();
}
@ -65,17 +70,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{
$pr = PressRelease::withoutGlobalScopes()
->with([
'company:id,name,slug',
'company:id,name,email,phone,slug',
'category.translations',
'user:id,name',
'user:id,name,email',
'images',
'attachments',
'contacts' => fn ($query) => $query
->withoutGlobalScopes()
->orderBy('last_name')
->orderBy('first_name')
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
'statusLogs.changedBy:id,name',
])
->findOrFail($this->id);
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
return [
'pr' => $pr,
'statusLogs' => $pr->statusLogs,
'contacts' => $pr->contacts,
'latestRejection' => $latestRejection,
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
?? $pr->category?->translations->first()?->name
?? '',
@ -100,18 +119,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
};
@endphp
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@endif
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
@ -126,6 +134,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
@if ($pr->subtitle)
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->subtitle }}
</p>
@endif
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Firma') }}:</strong>
{{ $pr->company?->name ?? '' }}
@ -148,39 +161,98 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
</header>
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Diese Pressemitteilung wurde abgelehnt') }}</span>
<span class="badge err dot">{{ __('Handlung erforderlich') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-err-soft)] border border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
<flux:icon.exclamation-triangle class="size-[18px]" />
</div>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($latestRejection->reason)
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Der Autor sollte den Inhalt überarbeiten und erneut einreichen.') }}
@endif
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
@if ($latestRejection->changedBy)
· {{ __('durch :name', ['name' => $latestRejection->changedBy->name]) }}
@endif
</span>
</div>
</div>
</article>
@endif
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<article class="panel">
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
{{ __('Diese PM wartet auf Prüfung.') }}
</p>
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="confirm-show-reject">
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
<div class="p-5 flex flex-wrap items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.clock class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
<p class="m-0">{{ __('Diese PM wartet auf eine redaktionelle Entscheidung.') }}</p>
@if ($pr->scheduled_at)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.calendar variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
{{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduled_at->format('d.m.Y H:i')]) }}
</p>
@endif
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="confirm-show-reject">
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
</div>
</div>
</article>
@endif
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<article class="panel">
<article class="panel" style="border-color:var(--color-ok); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge ok dot">{{ __('Live') }}</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
@if ($pr->hits > 0)
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</strong>
{{ __('Aufrufe seit Veröffentlichung') }}
<div class="p-5 flex flex-wrap items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-ok-soft)] border border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
<p class="m-0">
{{ __('Veröffentlicht am') }}
<strong class="text-[color:var(--color-ink)] font-semibold">{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}</strong>
</p>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.lock-closed variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
{{ __('Sperrfrist bis: :date', ['date' => $pr->embargo_at->format('d.m.Y H:i')]) }}
</p>
@endif
@if ($pr->hits > 0)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits, 0, ',', '.') }}</strong>
{{ __('Aufrufe seit Veröffentlichung') }}
</p>
@endif
</div>
<flux:modal.trigger name="confirm-show-archive">
<flux:button type="button" variant="ghost">{{ __('Archivieren') }}</flux:button>
</flux:modal.trigger>
@ -188,142 +260,256 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</article>
@endif
{{-- ============== TEXT + SIDEBAR ============== --}}
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
{{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
<div class="grid gap-6 xl:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
@if ($pr->company)
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate>
{{ __('Firma') }}
</flux:button>
@endif
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! $pr->renderedText() !!}
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-4">
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
</p>
<div class="space-y-2">
@forelse ($contacts as $contact)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($contact->email)
<a href="mailto:{{ $contact->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $contact->email }}
</a>
@endif
@if ($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
</div>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Dieser Pressemitteilung ist kein Pressekontakt zugeordnet.') }}
</div>
@endforelse
</div>
</div>
</article>
<aside class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Details') }}</span>
</div>
<dl class="p-5 space-y-2.5 text-[12.5px]">
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Status') }}</dt>
<dd class="font-semibold text-[color:var(--color-ink)]">{{ $pr->status->label() }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Erstellt') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->created_at->format('d.m.Y H:i') }}</dd>
</div>
@if ($pr->published_at)
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Veröffentlicht') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->published_at->format('d.m.Y H:i') }}</dd>
</div>
@endif
<div class="flex justify-between gap-2">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Aufrufe') }}</dt>
<dd class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</dd>
</div>
@if ($pr->keywords)
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Stichwörter') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->keywords }}</dd>
</div>
@endif
@if ($pr->backlink_url)
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Backlink') }}</dt>
<dd class="break-all">
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</dd>
</div>
@endif
@if ($pr->no_export)
<div class="mt-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
<span class="badge hub">{{ __('Kein Export') }}</span>
</div>
@endif
</dl>
</article>
@if ($pr->images->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bilder') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->images->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->images as $image)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
@if ($image->is_preview)
<span class="badge hub">{{ __('Preview') }}</span>
@endif
</div>
@endforeach
</div>
</article>
@endif
</aside>
</div>
{{-- ============== STATUS-VERLAUF ============== --}}
@if ($statusLogs->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Verlauf') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $statusLogs->count() }} {{ __('Einträge') }}
</span>
<span class="section-eyebrow">{{ __('Status & Verlauf') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
<div class="p-5">
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = match ($log->to_status?->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() ?? $log->to_status }}</span>
@if ($log->from_status)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('von') }} {{ $log->from_status->label() }}
</span>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $log->changedBy->name }}</span>
@endif
@if ($log->source !== 'admin')
<span class="badge hub">{{ $log->source }}</span>
@endif
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Autor') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1 truncate">
{{ $pr->user?->name ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Erstellt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->created_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Veröffentlicht') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($pr->hits, 0, ',', '.') }}
</div>
</div>
@if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplant') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
</div>
@endif
@if ($pr->embargo_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->embargo_at->format('d.m.Y H:i') }}
</div>
</div>
@endif
</div>
@if ($pr->no_export)
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.no-symbol variant="micro" class="size-3.5" />
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
</div>
@endif
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
@if ($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = match ($log->to_status?->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() ?? $log->to_status }}</span>
@if ($log->from_status)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('von') }} {{ $log->from_status->label() }}
</span>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $log->changedBy->name }}</span>
@endif
@if ($log->source && $log->source !== 'admin')
<span class="badge hub">{{ $log->source }}</span>
@endif
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0 whitespace-pre-line">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
@else
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</p>
@endif
</div>
</article>
</div>
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! $pr->renderedText() !!}
</div>
@if ($pr->keywords || $pr->backlink_url)
<div class="mt-6 space-y-2 border-t border-[color:var(--color-bg-rule)] pt-4 text-[12.5px] text-[color:var(--color-ink-2)]">
@if ($pr->keywords)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Stichwörter') }}:</strong>
{{ $pr->keywords }}
</p>
@endif
@if ($pr->backlink_url)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Backlink') }}:</strong>
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</p>
@endif
</div>
@endif
</div>
</article>
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
@if ($pr->boilerplate_override)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
<span class="badge hub">{{ __('Override') }}</span>
</div>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
</p>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $pr->boilerplate_override }}
</div>
</div>
</article>
@endif
{{-- ============== MEDIEN ============== --}}
@if ($pr->images->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bilder') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->images->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->images as $image)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
@if ($image->is_preview)
<span class="badge hub">{{ __('Preview') }}</span>
@endif
</div>
@endforeach
</div>
</article>
@endif
{{-- ANHÄNGE-ANZEIGE TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung.
Wird mit dem Anhang-Manager in einer späteren Phase wieder aktiviert.
@if ($pr->attachments->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anhänge') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->attachments->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->attachments as $attachment)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.paper-clip class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)] flex-1">
{{ $attachment->title ?: $attachment->original_name }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] flex-shrink-0">
{{ number_format($attachment->size / 1024, 0, ',', '.') }} KB
</span>
</div>
@endforeach
</div>
</article>
@endif
--}}
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal name="confirm-show-publish" class="max-w-lg">
<div class="space-y-6">

View file

@ -6,7 +6,7 @@ use App\Models\Company;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use Flux\Flux;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -66,18 +66,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
'pressReleases as published_press_releases_count' => fn ($query) => $query->where('status', PressReleaseStatus::Published->value),
])
->withExists(['profile', 'billingAddress'])
->when($this->search, function ($query): void {
$term = trim($this->search);
if ($this->supportsFullTextSearch($term)) {
$query->whereFullText(['name', 'email'], $term);
return;
}
$query->where(function ($searchQuery): void {
$searchQuery->where('name', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%');
});
->when(filled(trim($this->search)), function (Builder $query): void {
$this->applySearch($query, $this->search);
})
->when($this->activeFilter !== 'all', function ($query): void {
$query->where('is_active', $this->activeFilter === 'active');
@ -115,7 +105,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
};
})
->orderBy($sort, $this->sortDir)
->simplePaginate(50);
->paginate(50);
$this->hydrateCompanyCounts($users);
@ -266,9 +256,30 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
->find($this->viewingUserId);
}
private function supportsFullTextSearch(string $term): bool
private function applySearch(Builder $query, string $search): void
{
return mb_strlen($term) >= 3 && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
$terms = preg_split('/\s+/', trim($search), -1, PREG_SPLIT_NO_EMPTY);
if ($terms === false || $terms === []) {
return;
}
$query->where(function (Builder $searchQuery) use ($terms): void {
foreach ($terms as $term) {
$pattern = '%'.$this->escapeLikeTerm($term).'%';
$searchQuery->where(function (Builder $termQuery) use ($pattern): void {
$termQuery
->whereLike('name', $pattern)
->orWhereLike('email', $pattern);
});
}
});
}
private function escapeLikeTerm(string $term): string
{
return addcslashes($term, '\%_');
}
public function updatedSearch(): void
@ -586,7 +597,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $users->links() }}
{{ $users->links('components.portal.pagination') }}
</div>
</article>