- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner, Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit granted_via = api; Befehl company-memberships:api-assignments (Liste, --remove mit Vorschau) - K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort, remember_token und Sanctum-Tokens; SessionRevocation + Middleware EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich) - K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google ohne erzwungenes remember, magic-login ohne guest für eigene Links - B-10: eigener Magic-Link-Zweck image_confirmation (Migration) - K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden wird abgelehnt, Gate-Abfragen je Request zwischengespeichert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
763 lines
40 KiB
PHP
763 lines
40 KiB
PHP
<?php
|
|
|
|
use App\Enums\Portal;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\Boost;
|
|
use App\Models\PressRelease;
|
|
use App\Services\Customer\CustomerCompanyContext;
|
|
use App\Services\PressRelease\AiDeclarationRequiredException;
|
|
use App\Services\PressRelease\BlacklistViolationException;
|
|
use App\Services\PressRelease\BookingRequiredException;
|
|
use App\Services\PressRelease\PressReleaseService;
|
|
use App\Services\PressRelease\QuotaExceededException;
|
|
use App\Scopes\PortalScope;
|
|
use Flux\Flux;
|
|
use Illuminate\Support\Facades\DB;
|
|
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 \App\Livewire\Concerns\InteractsWithAiDeclaration;
|
|
use WithPagination;
|
|
|
|
public string $search = '';
|
|
|
|
public string $statusFilter = 'all';
|
|
|
|
#[Url(as: 'company', except: 'all')]
|
|
public string $companyFilter = 'all';
|
|
|
|
public string $portalFilter = '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 setView(string $view): void
|
|
{
|
|
$this->statusFilter = $view;
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function resetFilters(): void
|
|
{
|
|
$this->search = '';
|
|
$this->statusFilter = 'all';
|
|
$this->portalFilter = 'all';
|
|
if ($this->companyFilter !== 'all') {
|
|
$this->companyFilter = 'all';
|
|
}
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedSearch(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedStatusFilter(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedCompanyFilter(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedPortalFilter(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function submitForReview(int $id): void
|
|
{
|
|
$pr = $this->findMyPR($id);
|
|
if (!$pr) {
|
|
return;
|
|
}
|
|
|
|
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
|
|
// Submit-Modal validieren und vor dem Einreichen persistieren.
|
|
$this->validateAiDeclaration();
|
|
$this->persistLegalAcceptances();
|
|
$pr->update($this->aiDeclarationAttributes());
|
|
|
|
try {
|
|
app(PressReleaseService::class)->submitForReview($pr);
|
|
Flux::toast(heading: __('Eingereicht'), text: __('Pressemitteilung zur Prüfung eingereicht.'), variant: 'success');
|
|
} catch (BlacklistViolationException $e) {
|
|
Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000);
|
|
} catch (AiDeclarationRequiredException | BookingRequiredException | QuotaExceededException $e) {
|
|
Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000);
|
|
} catch (\LogicException $e) {
|
|
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
|
}
|
|
|
|
// Deklaration nicht zwischen PMs „verschleppen": Die Listen-Modals
|
|
// teilen sich die Properties, daher nach jedem Versuch zurücksetzen.
|
|
$this->reset('aiTextDeclaration', 'aiDeclarationConfirmed');
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
$context = app(CustomerCompanyContext::class);
|
|
$user = auth()->user();
|
|
$selectedCompanyId = $context->selectedCompanyId($user);
|
|
|
|
// Firmen-Scope der Übersicht: auf eine gewählte Firma einschränken bzw.
|
|
// (im globalen Kontext) auf zugeordnete/unzugeordnete PMs. Wird sowohl
|
|
// auf die Status-Counts als auch die Liste angewandt, damit beide
|
|
// deckungsgleich filtern.
|
|
$applyCompanyFilter = function ($query) use ($selectedCompanyId): void {
|
|
$query
|
|
->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'));
|
|
};
|
|
|
|
$statusCountsRaw = PressRelease::accessibleStatusCountsFor($user, $applyCompanyFilter);
|
|
|
|
$statusCounts = [
|
|
'all' => array_sum($statusCountsRaw),
|
|
PressReleaseStatus::Published->value => $statusCountsRaw[PressReleaseStatus::Published->value] ?? 0,
|
|
PressReleaseStatus::Draft->value => $statusCountsRaw[PressReleaseStatus::Draft->value] ?? 0,
|
|
PressReleaseStatus::Review->value => $statusCountsRaw[PressReleaseStatus::Review->value] ?? 0,
|
|
PressReleaseStatus::Rejected->value => $statusCountsRaw[PressReleaseStatus::Rejected->value] ?? 0,
|
|
PressReleaseStatus::Archived->value => $statusCountsRaw[PressReleaseStatus::Archived->value] ?? 0,
|
|
];
|
|
|
|
// Der Firmen-Scope plus die Suchleiste/Status-/Portal-Filter werden in
|
|
// beide UNION-Zweige gezogen (siehe PressRelease::accessiblePageFor()).
|
|
$applyListFilters = function ($query) use ($applyCompanyFilter): void {
|
|
$applyCompanyFilter($query);
|
|
$query
|
|
->when(filled($this->search), fn($q) => $q->where('title', 'like', '%' . $this->search . '%'))
|
|
->when($this->statusFilter !== 'all', fn($q) => $q->where('status', $this->statusFilter))
|
|
->when($this->portalFilter !== 'all', fn($q) => $q->where('portal', $this->portalFilter));
|
|
};
|
|
|
|
$prs = PressRelease::accessiblePageFor($user, $applyListFilters, $this->sortBy, $this->sortDir);
|
|
|
|
// Laufende Boosts der Seite in einer Query (statt isBoosted() je
|
|
// Zeile): [press_release_id => spätestes ends_at].
|
|
$boostEndsById = Boost::query()
|
|
->active()
|
|
->whereIn('press_release_id', collect($prs->items())->pluck('id'))
|
|
->selectRaw('press_release_id, max(ends_at) as ends_at')
|
|
->groupBy('press_release_id')
|
|
->pluck('ends_at', 'press_release_id')
|
|
->map(fn ($endsAt) => \Illuminate\Support\Carbon::parse($endsAt));
|
|
|
|
return [
|
|
'pressReleases' => $prs,
|
|
'boostEndsById' => $boostEndsById,
|
|
'statusOptions' => PressReleaseStatus::cases(),
|
|
'portalOptions' => Portal::cases(),
|
|
'selectedCompany' => $context->selectedCompany(auth()->user()),
|
|
'hasGlobalCompanyContext' => $selectedCompanyId === null,
|
|
'statusCounts' => $statusCounts,
|
|
];
|
|
}
|
|
|
|
private function findMyPR(int $id): ?PressRelease
|
|
{
|
|
return PressRelease::withoutGlobalScope(PortalScope::class)
|
|
->where('id', $id)
|
|
->where('user_id', auth()->id())
|
|
->first();
|
|
}
|
|
}; ?>
|
|
|
|
@php
|
|
$hasAnyFilter =
|
|
$search !== '' ||
|
|
$statusFilter !== 'all' ||
|
|
$portalFilter !== 'all' ||
|
|
($hasGlobalCompanyContext && $companyFilter !== 'all');
|
|
$hasAnyPR = $statusCounts['all'] > 0;
|
|
@endphp
|
|
|
|
<div class="space-y-8">
|
|
|
|
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
|
|
|
|
{{-- ============== PAGE HEADER ============== --}}
|
|
<header class="page-header">
|
|
<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>
|
|
|
|
{{-- Aktiver Firmen-Kontext direkt unter der Headline: macht sichtbar,
|
|
auf welche Firma (Firmen-Umschalter oben) die Liste eingeschränkt ist. --}}
|
|
<div class="mt-2 flex items-center gap-1.5 text-[13.5px] font-semibold text-[color:var(--color-ink-2)]">
|
|
<flux:icon.building-office variant="micro" class="size-4 text-[color:var(--color-ink-3)]" />
|
|
@if ($selectedCompany)
|
|
<span>{{ $selectedCompany->name }}</span>
|
|
@else
|
|
<span>{{ __('Alle Firmen') }}</span>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Counter-Strip + Kontext-Hinweis --}}
|
|
@if ($hasAnyPR)
|
|
<div class="counter-strip mt-3">
|
|
<span class="seg"><b>{{ $statusCounts['all'] }}</b> {{ __('Mitteilungen') }}</span>
|
|
@if ($statusCounts[\App\Enums\PressReleaseStatus::Published->value] > 0)
|
|
<span class="sep"></span>
|
|
<span
|
|
class="seg is-ok"><b>{{ $statusCounts[\App\Enums\PressReleaseStatus::Published->value] }}</b>
|
|
{{ __('veröffentlicht') }}</span>
|
|
@endif
|
|
@if ($statusCounts[\App\Enums\PressReleaseStatus::Review->value] > 0)
|
|
<span class="sep"></span>
|
|
<span
|
|
class="seg is-warn"><b>{{ $statusCounts[\App\Enums\PressReleaseStatus::Review->value] }}</b>
|
|
{{ __('in Prüfung') }}</span>
|
|
@endif
|
|
@if ($statusCounts[\App\Enums\PressReleaseStatus::Draft->value] > 0)
|
|
<span class="sep"></span>
|
|
<span
|
|
class="seg is-muted"><b>{{ $statusCounts[\App\Enums\PressReleaseStatus::Draft->value] }}</b>
|
|
{{ __('Entwürfe') }}</span>
|
|
@endif
|
|
@if ($statusCounts[\App\Enums\PressReleaseStatus::Rejected->value] > 0)
|
|
<span class="sep"></span>
|
|
<span
|
|
class="seg is-err"><b>{{ $statusCounts[\App\Enums\PressReleaseStatus::Rejected->value] }}</b>
|
|
{{ __('abgelehnt') }}</span>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
@if (!$hasAnyPR)
|
|
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
|
{{ __('Übersicht aller PMs Ihres Kundenkontos, mit Filter und Schnellaktionen.') }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
|
{{ __('Neue Pressemitteilung') }}
|
|
</flux:button>
|
|
</div>
|
|
</header>
|
|
|
|
{{-- ============== SAVED-VIEWS-TABS ============== --}}
|
|
@if ($hasAnyPR)
|
|
<nav class="view-tabs" aria-label="{{ __('Gespeicherte Ansichten') }}">
|
|
<button type="button" wire:click="setView('all')" @class(['view-tab', 'is-active' => $statusFilter === 'all'])>
|
|
{{ __('Alle') }} <span class="cnt">{{ $statusCounts['all'] }}</span>
|
|
</button>
|
|
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Published->value }}')"
|
|
@class([
|
|
'view-tab',
|
|
'is-active' =>
|
|
$statusFilter === \App\Enums\PressReleaseStatus::Published->value,
|
|
])>
|
|
{{ __('Veröffentlicht') }} <span
|
|
class="cnt">{{ $statusCounts[\App\Enums\PressReleaseStatus::Published->value] }}</span>
|
|
</button>
|
|
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Draft->value }}')"
|
|
@class([
|
|
'view-tab',
|
|
'is-active' =>
|
|
$statusFilter === \App\Enums\PressReleaseStatus::Draft->value,
|
|
])>
|
|
{{ __('Entwürfe') }} <span
|
|
class="cnt">{{ $statusCounts[\App\Enums\PressReleaseStatus::Draft->value] }}</span>
|
|
</button>
|
|
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Review->value }}')"
|
|
@class([
|
|
'view-tab',
|
|
'is-active' =>
|
|
$statusFilter === \App\Enums\PressReleaseStatus::Review->value,
|
|
])>
|
|
{{ __('In Prüfung') }} <span
|
|
class="cnt">{{ $statusCounts[\App\Enums\PressReleaseStatus::Review->value] }}</span>
|
|
</button>
|
|
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Rejected->value }}')"
|
|
@class([
|
|
'view-tab',
|
|
'is-active' =>
|
|
$statusFilter === \App\Enums\PressReleaseStatus::Rejected->value,
|
|
])>
|
|
{{ __('Abgelehnt') }} <span
|
|
class="cnt">{{ $statusCounts[\App\Enums\PressReleaseStatus::Rejected->value] }}</span>
|
|
</button>
|
|
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Archived->value }}')"
|
|
@class([
|
|
'view-tab',
|
|
'is-active' =>
|
|
$statusFilter === \App\Enums\PressReleaseStatus::Archived->value,
|
|
])>
|
|
{{ __('Archiv') }} <span
|
|
class="cnt">{{ $statusCounts[\App\Enums\PressReleaseStatus::Archived->value] }}</span>
|
|
</button>
|
|
</nav>
|
|
@endif
|
|
|
|
{{-- ============== FILTER + SUCHE ============== --}}
|
|
@if ($hasAnyPR)
|
|
<section class="space-y-3">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Titel suchen…') }}"
|
|
icon="magnifying-glass" class="max-w-[340px]" />
|
|
|
|
<span class="w-px h-6 bg-[color:var(--color-bg-rule)] mx-1"></span>
|
|
|
|
<flux:select wire:model.live="portalFilter" class="sm:w-44">
|
|
<option value="all">{{ __('Portal: Alle') }}</option>
|
|
@foreach ($portalOptions as $p)
|
|
<option value="{{ $p->value }}">{{ __('Portal:') }} {{ $p->label() }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
|
|
@if ($hasGlobalCompanyContext)
|
|
<flux:select wire:model.live="companyFilter" class="sm:w-56">
|
|
<option value="all">{{ __('Firma: Alle') }}</option>
|
|
<option value="assigned">{{ __('Firma: Mit Firma') }}</option>
|
|
<option value="unassigned">{{ __('Firma: Ohne Firma') }}</option>
|
|
</flux:select>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Active-Chips --}}
|
|
@if ($hasAnyFilter)
|
|
<div class="flex items-center gap-2 flex-wrap text-[11.5px]">
|
|
<span class="eyebrow muted" style="margin-right:2px;">{{ __('Aktiv') }}</span>
|
|
|
|
@if ($statusFilter !== 'all')
|
|
@php $statusEnum = \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
|
|
<span class="active-chip">
|
|
<span>{{ __('Status') }}:
|
|
<strong>{{ $statusEnum?->label() ?? $statusFilter }}</strong></span>
|
|
<button type="button" class="x" wire:click="setView('all')"
|
|
aria-label="{{ __('Filter entfernen') }}">
|
|
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
|
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
|
stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
@endif
|
|
|
|
@if ($portalFilter !== 'all')
|
|
@php $portalEnum = \App\Enums\Portal::tryFrom($portalFilter); @endphp
|
|
<span class="active-chip">
|
|
<span>{{ __('Portal') }}:
|
|
<strong>{{ $portalEnum?->label() ?? $portalFilter }}</strong></span>
|
|
<button type="button" class="x" wire:click="$set('portalFilter', 'all')"
|
|
aria-label="{{ __('Filter entfernen') }}">
|
|
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
|
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
|
stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
@endif
|
|
|
|
@if ($hasGlobalCompanyContext && $companyFilter !== 'all')
|
|
<span class="active-chip">
|
|
<span>{{ __('Firma') }}:
|
|
<strong>{{ $companyFilter === 'assigned' ? __('Mit Firma') : __('Ohne Firma') }}</strong></span>
|
|
<button type="button" class="x" wire:click="$set('companyFilter', 'all')"
|
|
aria-label="{{ __('Filter entfernen') }}">
|
|
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
|
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
|
stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
@endif
|
|
|
|
@if ($search !== '')
|
|
<span class="active-chip">
|
|
<span>{{ __('Suche') }}:
|
|
<strong>„{{ \Illuminate\Support\Str::limit($search, 40) }}"</strong></span>
|
|
<button type="button" class="x" wire:click="$set('search', '')"
|
|
aria-label="{{ __('Filter entfernen') }}">
|
|
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
|
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
|
stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
@endif
|
|
|
|
<button type="button" wire:click="resetFilters"
|
|
class="text-[11.5px] font-semibold text-[color:var(--color-ink-3)] hover:text-[color:var(--color-hub)] underline-offset-[3px] hover:underline">
|
|
{{ __('Alle zurücksetzen') }}
|
|
</button>
|
|
</div>
|
|
@endif
|
|
</section>
|
|
@endif
|
|
|
|
{{-- ============== TABELLE / EMPTY ============== --}}
|
|
<article class="panel overflow-hidden">
|
|
|
|
@if ($pressReleases->isNotEmpty())
|
|
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">
|
|
@if ($statusFilter !== 'all')
|
|
@php $sEnum = \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
|
|
{{ $sEnum?->label() ?? __('Pressemitteilungen') }}
|
|
@else
|
|
{{ __('Alle Pressemitteilungen') }}
|
|
@endif
|
|
</span>
|
|
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
|
{{ __(':count Einträge', ['count' => $pressReleases->total()]) }}
|
|
</span>
|
|
</div>
|
|
<flux:table>
|
|
<flux:table.columns>
|
|
<flux:table.column class="w-[140px]">{{ __('Status') }}</flux:table.column>
|
|
<flux:table.column class="w-[88px]">{{ __('Bild') }}</flux:table.column>
|
|
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir"
|
|
wire:click="sort('title')">
|
|
{{ __('Titel') }}
|
|
</flux:table.column>
|
|
<flux:table.column class="w-[180px]">{{ __('Portal') }}</flux:table.column>
|
|
<flux:table.column class="w-[180px]">{{ __('Firma') }}</flux:table.column>
|
|
<flux:table.column class="w-[140px]" sortable :sorted="$sortBy === 'created_at'"
|
|
:direction="$sortDir" wire:click="sort('created_at')">
|
|
{{ __('Datum') }}
|
|
</flux:table.column>
|
|
<flux:table.column class="w-[80px]">{{ __('Aktionen') }}</flux:table.column>
|
|
</flux:table.columns>
|
|
|
|
@foreach ($pressReleases as $pr)
|
|
@php
|
|
$status = $pr->status->value;
|
|
// Grün geprüft = boostbar (Gate im BoostService); der Haken
|
|
// zeigt das Prüfergebnis auch abseits des Boost-Hinweises.
|
|
$isGreen = $pr->classification === \App\Enums\PressReleaseClassification::Green;
|
|
$rowClass = match ($status) {
|
|
'review' => 'is-row-warn',
|
|
'rejected' => 'is-row-err',
|
|
default => '',
|
|
};
|
|
$badgeClass = $pr->status->badgeClass();
|
|
$portal = $pr->portal?->value ?? 'both';
|
|
$showPe = in_array($portal, ['presseecho', 'both'], true);
|
|
$showBp = in_array($portal, ['businessportal24', 'both'], true);
|
|
$dateSubLabel = match ($status) {
|
|
'published' => __('veröffentlicht'),
|
|
'review' => __('eingereicht'),
|
|
'rejected' => __('abgelehnt'),
|
|
'draft' => __('erstellt'),
|
|
'archived' => __('archiviert'),
|
|
default => __('erstellt'),
|
|
};
|
|
$primaryDate = match (true) {
|
|
$status === 'published' && $pr->published_at => $pr->published_at,
|
|
default => $pr->created_at,
|
|
};
|
|
@endphp
|
|
|
|
<flux:table.row wire:key="{{ $pr->id }}" class="{{ $rowClass }}">
|
|
<flux:table.cell>
|
|
<div class="flex items-center gap-1 flex-wrap">
|
|
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
|
|
@if ($status === 'draft')
|
|
{{-- Öffnet das gemeinsame Einreichungs-Modal (rechtliche
|
|
Bestätigungen inkl. Erlöschensklausel) statt eines
|
|
Schnell-Submits, der die Bestätigungen umgehen würde. --}}
|
|
<flux:modal.trigger :name="'confirm-submit-'.$pr->id">
|
|
<button type="button" class="inline-action"
|
|
title="{{ __('Zur Inhaltsprüfung senden') }}">
|
|
{{ __('Zur Prüfung →') }}
|
|
</button>
|
|
</flux:modal.trigger>
|
|
<x-press-release-submit-modal :name="'confirm-submit-' . $pr->id"
|
|
:action="'submitForReview(' . $pr->id . ')'"
|
|
:has-title-image="$pr->images_count > 0"
|
|
:legal-documents="$this->legalDocumentsForSubmit()" />
|
|
@endif
|
|
</div>
|
|
@if ($boostEndsAt = $boostEndsById->get($pr->id))
|
|
{{-- Laufende Boost-Platzierung (Add-on) direkt unter dem Status. --}}
|
|
<div class="mt-1">
|
|
{{-- Deckt beide Stufen ab (Startseiten-/Kategorie-Boost), daher neutral formuliert. --}}
|
|
<span class="badge hub dot" title="{{ __('Boost — bezahlte Platzierung (Details auf der PM-Seite)') }}">
|
|
{{ __('Boost bis :date', ['date' => $boostEndsAt->format('d.m.Y')]) }}
|
|
</span>
|
|
</div>
|
|
@elseif ($status === 'published' && $isGreen)
|
|
{{-- Boostbar (veröffentlicht + grün): Hinweis auf das Add-on (13.07.). --}}
|
|
<div class="mt-1">
|
|
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
|
|
class="inline-action" title="{{ __('Grün geprüft — ein Boost ist buchbar (Add-ons auf der PM-Seite)') }}">
|
|
{{ __('Boost möglich →') }}
|
|
</a>
|
|
</div>
|
|
@endif
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
{{-- Vorschaubild: Titelbild der PM, sonst der gewählte
|
|
Platzhalter, sonst generischer Bild-Fallback. --}}
|
|
@php $thumbUrl = $pr->thumbImageUrl() ?? $pr->chosenPlaceholderUrl(); @endphp
|
|
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate class="block w-16">
|
|
@if ($thumbUrl)
|
|
<img src="{{ $thumbUrl }}" alt=""
|
|
class="h-12 w-16 rounded-[3px] object-cover border border-[color:var(--color-bg-rule)]"
|
|
loading="lazy">
|
|
@else
|
|
<span
|
|
class="flex h-12 w-16 items-center justify-center rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-4)]">
|
|
<flux:icon.photo variant="micro" class="size-4" />
|
|
</span>
|
|
@endif
|
|
</a>
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
<div class="flex items-start gap-1.5">
|
|
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
|
|
class="block font-semibold text-[13.5px] leading-[1.35] text-[color:var(--color-ink)] hover:text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
|
|
{{ $pr->title }}
|
|
</a>
|
|
@if ($isGreen)
|
|
{{-- Grüner Haken = Inhaltsprüfung mit Ergebnis Grün (13.07.). --}}
|
|
<span class="shrink-0 mt-0.5 text-[color:var(--color-ok)]" title="{{ __('Inhaltsprüfung: Grün') }}">
|
|
<flux:icon.check-circle variant="micro" class="size-3.5" />
|
|
</span>
|
|
@endif
|
|
</div>
|
|
<div class="text-[11.5px] text-[color:var(--color-ink-3)] mt-0.5 leading-[1.4]">
|
|
PM-{{ $pr->id }}
|
|
</div>
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
<div class="flex flex-wrap gap-1">
|
|
@if ($showPe)
|
|
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
|
@endif
|
|
@if ($showBp)
|
|
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
|
@endif
|
|
</div>
|
|
@if ($categoryName = $pr->localizedCategoryName())
|
|
{{-- Rubrik direkt unter den Portal-Pills (13.07.). --}}
|
|
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-[1.4]">
|
|
{{ $categoryName }}
|
|
</div>
|
|
@endif
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
@if ($pr->company)
|
|
<span class="text-[12.5px] text-[color:var(--color-ink-2)] font-medium">
|
|
{{ $pr->company->name }}
|
|
</span>
|
|
@else
|
|
<span
|
|
class="text-[11.5px] text-[color:var(--color-ink-4)] italic">{{ __('— keine —') }}</span>
|
|
@endif
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
<div class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
|
{{ $primaryDate?->format('d.m.Y') }}
|
|
</div>
|
|
<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->scheduledAtLocal()->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->embargoAtLocal()->format('d.m.') }}</span>
|
|
</div>
|
|
@endif
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
<div class="flex items-center gap-1">
|
|
<flux:button size="sm" variant="filled" icon="eye"
|
|
href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate />
|
|
@if (in_array($status, ['draft', 'rejected']))
|
|
<flux:button size="sm" variant="filled" icon="pencil"
|
|
href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
|
|
@endif
|
|
</div>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@endforeach
|
|
</flux:table>
|
|
<x-portal.table-pagination :paginator="$pressReleases" />
|
|
@elseif ($hasAnyPR && $search !== '')
|
|
{{-- Empty: Suche ohne Treffer --}}
|
|
<x-portal.empty-state :title='__("Keine Treffer für „:term\"", ["term" => $search])' :subtitle="__(
|
|
'Wir konnten zu Ihrer Suche nichts finden. Prüfen Sie die Schreibweise oder schränken Sie den Suchbegriff ein.',
|
|
)">
|
|
<x-slot:icon>
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
<circle cx="10.5" cy="10.5" r="6" stroke="currentColor" stroke-width="1.6" />
|
|
<path d="M15.5 15.5L20 20" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
|
</svg>
|
|
</x-slot:icon>
|
|
<x-slot:actions>
|
|
<flux:button variant="primary" wire:click="$set('search', '')">
|
|
{{ __('Suche zurücksetzen') }}
|
|
</flux:button>
|
|
</x-slot:actions>
|
|
</x-portal.empty-state>
|
|
@elseif ($hasAnyPR && $hasAnyFilter)
|
|
{{-- Empty: Filter ohne Treffer --}}
|
|
<x-portal.empty-state variant="warm" :title="__('Keine Mitteilungen mit diesen Filtern')" :subtitle="__(
|
|
'Aktive Filter passen auf keine Einträge. Setzen Sie einen Filter zurück oder probieren Sie eine breitere Auswahl.',
|
|
)">
|
|
<x-slot:icon>
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.8"
|
|
stroke-linecap="round" />
|
|
</svg>
|
|
</x-slot:icon>
|
|
<x-slot:actions>
|
|
<flux:button variant="primary" wire:click="resetFilters">{{ __('Alle Filter zurücksetzen') }}
|
|
</flux:button>
|
|
</x-slot:actions>
|
|
</x-portal.empty-state>
|
|
@else
|
|
{{-- Empty: gar keine PMs überhaupt --}}
|
|
<x-portal.empty-state :title="__('Noch keine Pressemitteilungen')" :subtitle="__(
|
|
'Starten Sie mit Ihrer ersten Mitteilung. Nach bestandener Inhaltsprüfung erscheint sie i. d. R. binnen wenigen Minuten auf den Portalen.',
|
|
)">
|
|
<x-slot:icon>
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
<rect x="3" y="4" width="14" height="16" stroke="currentColor" stroke-width="1.5" />
|
|
<path d="M17 7h4v13H6" stroke="currentColor" stroke-width="1.5" />
|
|
<path d="M6 8h8M6 11h8M6 14h5" stroke="currentColor" stroke-width="1.3"
|
|
stroke-linecap="round" />
|
|
</svg>
|
|
</x-slot:icon>
|
|
<x-slot:actions>
|
|
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}"
|
|
wire:navigate>
|
|
{{ __('Erste Pressemitteilung erstellen') }}
|
|
</flux:button>
|
|
</x-slot:actions>
|
|
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
|
|
<div
|
|
class="text-left px-3 py-2.5 bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)] rounded-[3px]">
|
|
<div
|
|
class="font-mono text-[9.5px] tracking-[0.16em] text-[color:var(--color-accent-deep)] font-bold mb-1">
|
|
01</div>
|
|
<div class="text-[11.5px] font-semibold text-[color:var(--color-ink)] leading-tight">
|
|
{{ __('Firma zuordnen') }}</div>
|
|
</div>
|
|
<div
|
|
class="text-left px-3 py-2.5 bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)] rounded-[3px]">
|
|
<div
|
|
class="font-mono text-[9.5px] tracking-[0.16em] text-[color:var(--color-accent-deep)] font-bold mb-1">
|
|
02</div>
|
|
<div class="text-[11.5px] font-semibold text-[color:var(--color-ink)] leading-tight">
|
|
{{ __('Mitteilung verfassen') }}</div>
|
|
</div>
|
|
<div
|
|
class="text-left px-3 py-2.5 bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)] rounded-[3px]">
|
|
<div
|
|
class="font-mono text-[9.5px] tracking-[0.16em] text-[color:var(--color-accent-deep)] font-bold mb-1">
|
|
03</div>
|
|
<div class="text-[11.5px] font-semibold text-[color:var(--color-ink)] leading-tight">
|
|
{{ __('Zur Prüfung freigeben') }}</div>
|
|
</div>
|
|
</div>
|
|
</x-portal.empty-state>
|
|
|
|
@endif
|
|
</article>
|
|
|
|
{{-- ============== STATUS-AKTIONEN-LEGENDE ============== --}}
|
|
@if ($hasAnyPR)
|
|
<article class="panel-warm p-5">
|
|
<div class="grid items-start gap-6" style="grid-template-columns:auto 1fr;">
|
|
<div class="min-w-[180px]">
|
|
<div class="section-eyebrow">{{ __('Aktionen je Status') }}</div>
|
|
<p class="text-[12px] text-[color:var(--color-ink-3)] leading-[1.55] mt-3 m-0 max-w-[200px]">
|
|
{{ __('Welche Aktionen pro Status verfügbar sind.') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid gap-4" style="grid-template-columns:repeat(5,minmax(0,1fr));">
|
|
<div>
|
|
<span class="badge muted dot" style="margin-bottom:8px;">{{ __('Entwurf') }}</span>
|
|
<ul
|
|
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
|
<li>{{ __('Bearbeiten') }}</li>
|
|
<li>{{ __('Vorschau') }}</li>
|
|
<li class="text-[color:var(--color-accent-deep)] font-semibold">
|
|
{{ __('→ Zur Prüfung freigeben') }}</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<span class="badge warn dot" style="margin-bottom:8px;">{{ __('In Prüfung') }}</span>
|
|
<ul
|
|
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
|
<li>{{ __('Vorschau') }}</li>
|
|
<li class="text-[color:var(--color-ink-3)] italic">{{ __('Warten auf Prüfung') }}</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<span class="badge ok dot" style="margin-bottom:8px;">{{ __('Veröffentlicht') }}</span>
|
|
<ul
|
|
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
|
<li>{{ __('Vorschau') }}</li>
|
|
<li class="text-[color:var(--color-ink-3)] italic">{{ __('Statistik (bald)') }}</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<span class="badge err dot" style="margin-bottom:8px;">{{ __('Abgelehnt') }}</span>
|
|
<ul
|
|
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
|
<li>{{ __('Bearbeiten') }}</li>
|
|
<li class="text-[color:var(--color-accent-deep)] font-semibold">
|
|
{{ __('→ Erneut zur Prüfung freigeben') }}</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<span class="badge muted dot" style="margin-bottom:8px;">{{ __('Archiviert') }}</span>
|
|
<ul
|
|
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
|
<li>{{ __('Vorschau') }}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
@endif
|
|
</div>
|