Umbenennung presseportale → pressekonto in Domains, Themes und Dokumentation. Design-Tokens, Portal-Shell, Customer-Dashboard, Auth- und Admin-PM-Views. Artisan-Befehl migrate:legacy-media mit Tests und Hub-Flux-Entwicklungsdocs. Co-authored-by: Cursor <cursoragent@cursor.com>
236 lines
11 KiB
PHP
236 lines
11 KiB
PHP
<?php
|
||
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\PressRelease;
|
||
use App\Services\PressRelease\BlacklistViolationException;
|
||
use App\Services\PressRelease\PressReleaseService;
|
||
use App\Services\Customer\CustomerCompanyContext;
|
||
use Livewire\Attributes\Layout;
|
||
use Livewire\Attributes\Title;
|
||
use Livewire\Attributes\Url;
|
||
use Livewire\Volt\Component;
|
||
use Livewire\WithPagination;
|
||
|
||
new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class extends Component
|
||
{
|
||
use WithPagination;
|
||
|
||
public string $search = '';
|
||
|
||
public string $statusFilter = 'all';
|
||
|
||
#[Url(as: 'company', except: 'all')]
|
||
public string $companyFilter = 'all';
|
||
|
||
public string $sortBy = 'created_at';
|
||
|
||
public string $sortDir = 'desc';
|
||
|
||
public function sort(string $column): void
|
||
{
|
||
if ($this->sortBy === $column) {
|
||
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
$this->sortBy = $column;
|
||
$this->sortDir = 'asc';
|
||
}
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function updatedSearch(): void { $this->resetPage(); }
|
||
|
||
public function updatedStatusFilter(): void { $this->resetPage(); }
|
||
|
||
public function updatedCompanyFilter(): void { $this->resetPage(); }
|
||
|
||
public function submitForReview(int $id): void
|
||
{
|
||
$pr = $this->findMyPR($id);
|
||
if (! $pr) { return; }
|
||
|
||
try {
|
||
app(PressReleaseService::class)->submitForReview($pr);
|
||
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
|
||
} catch (BlacklistViolationException $e) {
|
||
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||
} catch (\LogicException $e) {
|
||
session()->flash('error', $e->getMessage());
|
||
}
|
||
}
|
||
|
||
public function with(): array
|
||
{
|
||
$userId = auth()->id();
|
||
$context = app(CustomerCompanyContext::class);
|
||
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
|
||
|
||
$prs = PressRelease::withoutGlobalScopes()
|
||
->where('user_id', $userId)
|
||
->with('company:id,name')
|
||
->when($selectedCompanyId !== null, fn ($q) => $q->where('company_id', $selectedCompanyId))
|
||
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn ($q) => $q->whereNotNull('company_id'))
|
||
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn ($q) => $q->whereNull('company_id'))
|
||
->when(filled($this->search), function ($q): void {
|
||
$term = $this->search;
|
||
$q->where('title', 'like', '%'.$term.'%');
|
||
})
|
||
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
|
||
->orderBy(in_array($this->sortBy, ['title', 'status', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
|
||
->paginate(100);
|
||
|
||
return [
|
||
'pressReleases' => $prs,
|
||
'statusOptions' => PressReleaseStatus::cases(),
|
||
'selectedCompany' => $context->selectedCompany(auth()->user()),
|
||
'hasGlobalCompanyContext' => $selectedCompanyId === null,
|
||
];
|
||
}
|
||
|
||
private function findMyPR(int $id): ?PressRelease
|
||
{
|
||
return PressRelease::withoutGlobalScopes()
|
||
->where('id', $id)
|
||
->where('user_id', auth()->id())
|
||
->first();
|
||
}
|
||
}; ?>
|
||
|
||
<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
|
||
|
||
{{-- ============== 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 · Pressemitteilungen') }}</span>
|
||
</div>
|
||
<h1 class="text-[34px] font-bold tracking-[-0.7px] leading-[1.1] m-0 text-[color:var(--color-ink)]">
|
||
{{ __('Meine Pressemitteilungen') }}
|
||
</h1>
|
||
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||
@if ($selectedCompany)
|
||
{{ __('Gefiltert auf :company', ['company' => $selectedCompany->name]) }}
|
||
@else
|
||
{{ __('Übersicht aller PMs Ihres Kundenkontos, mit Filter und Schnellaktionen.') }}
|
||
@endif
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3 flex-shrink-0">
|
||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||
{{ __('Neue Pressemitteilung') }}
|
||
</flux:button>
|
||
</div>
|
||
</header>
|
||
|
||
{{-- ============== FILTER-PANEL ============== --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
|
||
</div>
|
||
<div class="p-5 flex flex-col gap-3 sm:flex-row">
|
||
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Titel suchen…') }}" icon="magnifying-glass" class="flex-1" />
|
||
<flux:select wire:model.live="statusFilter" class="sm:w-44">
|
||
<option value="all">{{ __('Alle Status') }}</option>
|
||
@foreach($statusOptions as $s)
|
||
<option value="{{ $s->value }}">{{ $s->label() }}</option>
|
||
@endforeach
|
||
</flux:select>
|
||
@if($hasGlobalCompanyContext)
|
||
<flux:select wire:model.live="companyFilter" class="sm:w-48">
|
||
<option value="all">{{ __('Alle Firmenzuordnungen') }}</option>
|
||
<option value="assigned">{{ __('Mit Firma') }}</option>
|
||
<option value="unassigned">{{ __('Ohne Firma') }}</option>
|
||
</flux:select>
|
||
@endif
|
||
</div>
|
||
</article>
|
||
|
||
{{-- ============== TABELLE-PANEL ============== --}}
|
||
<article class="panel overflow-hidden">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Alle Pressemitteilungen') }}</span>
|
||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||
{{ __(':count Einträge', ['count' => $pressReleases->count()]) }}
|
||
</span>
|
||
</div>
|
||
<div class="p-4">
|
||
<flux:table>
|
||
<flux:table.columns>
|
||
<flux:table.column sortable :sorted="$sortBy==='title'" :direction="$sortDir" wire:click="sort('title')">{{ __('Titel') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Firma') }}</flux:table.column>
|
||
<flux:table.column sortable :sorted="$sortBy==='status'" :direction="$sortDir" wire:click="sort('status')">{{ __('Status') }}</flux:table.column>
|
||
<flux:table.column sortable :sorted="$sortBy==='created_at'" :direction="$sortDir" wire:click="sort('created_at')">{{ __('Erstellt') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
|
||
</flux:table.columns>
|
||
|
||
@forelse($pressReleases as $pr)
|
||
<flux:table.row wire:key="{{ $pr->id }}">
|
||
<flux:table.cell>
|
||
<p class="max-w-xs truncate font-medium">{{ $pr->title }}</p>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<flux:text class="text-sm">{{ $pr->company?->name ?? '–' }}</flux:text>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<span @class([
|
||
'badge',
|
||
'ok' => $pr->status->value === 'published',
|
||
'warn' => $pr->status->value === 'review',
|
||
'err' => $pr->status->value === 'rejected',
|
||
'hub' => in_array($pr->status->value, ['archived', 'draft'], true),
|
||
])>{{ $pr->status->label() }}</span>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<flux:text class="text-sm text-zinc-500">{{ $pr->created_at->format('d.m.Y') }}</flux:text>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<div class="flex items-center gap-1">
|
||
<flux:button size="sm" variant="ghost" icon="eye" href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate />
|
||
@if(in_array($pr->status->value, ['draft', 'rejected']))
|
||
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
|
||
<flux:button size="sm" variant="ghost" icon="paper-airplane" wire:click="submitForReview({{ $pr->id }})"
|
||
wire:confirm="{{ __('Pressemitteilung zur Prüfung einreichen?') }}" />
|
||
@endif
|
||
</div>
|
||
</flux:table.cell>
|
||
</flux:table.row>
|
||
@empty
|
||
<flux:table.row>
|
||
<flux:table.cell colspan="5">
|
||
<div class="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-4
|
||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||
<flux:icon.newspaper class="size-6" />
|
||
</div>
|
||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||
{{ __('Keine Pressemitteilungen gefunden') }}
|
||
</div>
|
||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0 mb-4">
|
||
{{ __('Passen Sie die Filter an oder erstellen Sie eine neue Pressemitteilung.') }}
|
||
</p>
|
||
<flux:button size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||
{{ __('Neue Pressemitteilung') }}
|
||
</flux:button>
|
||
</div>
|
||
</flux:table.cell>
|
||
</flux:table.row>
|
||
@endforelse
|
||
</flux:table>
|
||
</div>
|
||
</article>
|
||
|
||
{{ $pressReleases->links() }}
|
||
</div>
|