admin: internes Newsletter-Modul mit Filtern, Protokoll und Abmeldung
Neues Modul unter Admin ▸ Newsletter (nur users:manage): Kampagnen mit Betreff + leicht formatiertem Text (Purifier-Profil newsletter: fett/ kursiv, Listen, Links) im zentralen Mail-Layout. Empfänger-Filter auf der User-Datenbank: Portal, Legacy-Vertrag (Bestandsschutz), Login innerhalb/außerhalb X Monaten, Sortierung nach Login/Erstellung — mit Live-Zähler und Vorschau im Editor. Versand friert die Empfängerliste als Protokoll ein (newsletter_ campaign_recipients) und stellt pro Empfänger einen Queue-Job ein; der Opt-out wird im Worker erneut geprüft. Jede Mail trägt den signierten One-Click-Abmelde-Link (neuer EmailNotificationType:: Newsletter, bestehende Unsubscribe-Infrastruktur inkl. RFC 8058); die Abmeldung greift sofort für alle künftigen Auswahlen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d411e6ce7f
commit
215cbcbdbf
19 changed files with 1560 additions and 5 deletions
|
|
@ -0,0 +1,345 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Mail\NewsletterCampaignMail;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Services\Newsletter\CampaignRecipients;
|
||||
use App\Services\Newsletter\CampaignSender;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
|
||||
/*
|
||||
* Kampagnen-Editor: Betreff + leicht formatierter Text (fett/kursiv, Listen,
|
||||
* Links) und die Empfänger-Filter. Empfängerzahl und Vorschau aktualisieren
|
||||
* live beim Ändern der Filter. Versand friert die Liste als Protokoll ein.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public ?int $id = null;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public string $filterPortal = '';
|
||||
|
||||
public bool $filterLegacyContract = false;
|
||||
|
||||
public string $filterLogin = CampaignRecipients::LOGIN_ANY;
|
||||
|
||||
public int $filterLoginMonths = 6;
|
||||
|
||||
public string $sortField = 'created_at';
|
||||
|
||||
public string $sortDirection = 'desc';
|
||||
|
||||
public function mount(?int $id = null): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
if ($id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign = NewsletterCampaign::query()->findOrFail($id);
|
||||
|
||||
if (! $campaign->isDraft()) {
|
||||
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filters = app(CampaignRecipients::class)->normalize($campaign->filters ?? []);
|
||||
|
||||
$this->subject = $campaign->subject;
|
||||
$this->body = $campaign->body;
|
||||
$this->filterPortal = $filters['portal'];
|
||||
$this->filterLegacyContract = $filters['legacy_contract'];
|
||||
$this->filterLogin = $filters['login'];
|
||||
$this->filterLoginMonths = $filters['login_months'];
|
||||
$this->sortField = $filters['sort'];
|
||||
$this->sortDirection = $filters['direction'];
|
||||
}
|
||||
|
||||
public function saveDraft(): void
|
||||
{
|
||||
$campaign = $this->persist();
|
||||
|
||||
Flux::toast(text: __('Entwurf gespeichert.'), variant: 'success');
|
||||
|
||||
if ($this->id === null) {
|
||||
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTest(): void
|
||||
{
|
||||
$campaign = $this->persist();
|
||||
|
||||
Mail::to(auth()->user()->email)->send(new NewsletterCampaignMail($campaign, auth()->user()));
|
||||
|
||||
Flux::toast(
|
||||
text: __('Test-Mail an :email versendet.', ['email' => auth()->user()->email]),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
if ($this->id === null) {
|
||||
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function send(): void
|
||||
{
|
||||
$campaign = $this->persist();
|
||||
|
||||
$total = app(CampaignSender::class)->start($campaign);
|
||||
|
||||
if ($total === 0) {
|
||||
Flux::toast(text: __('Keine Empfänger für die gewählten Filter — nichts versendet.'), variant: 'warning');
|
||||
Flux::modal('confirm-campaign-send')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Flux::toast(
|
||||
text: __(':count Mails eingeplant. Der Versand läuft über die Queue.', ['count' => $total]),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
|
||||
}
|
||||
|
||||
private function persist(): NewsletterCampaign
|
||||
{
|
||||
$this->validate([
|
||||
'subject' => ['required', 'string', 'max:190'],
|
||||
'body' => ['required', 'string', 'max:20000'],
|
||||
]);
|
||||
|
||||
$attributes = [
|
||||
'subject' => trim($this->subject),
|
||||
// Bereits beim Speichern auf leichtes HTML reduzieren — der
|
||||
// Entwurf enthält damit nie mehr, als die Mail rendern darf.
|
||||
'body' => (string) Purifier::clean($this->body, 'newsletter'),
|
||||
'filters' => $this->filters(),
|
||||
];
|
||||
|
||||
if ($this->id !== null) {
|
||||
$campaign = NewsletterCampaign::query()->findOrFail($this->id);
|
||||
abort_unless($campaign->isDraft(), 403);
|
||||
$campaign->update($attributes);
|
||||
|
||||
return $campaign;
|
||||
}
|
||||
|
||||
$campaign = NewsletterCampaign::query()->create($attributes + [
|
||||
'status' => NewsletterCampaignStatus::Draft->value,
|
||||
'created_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->id = $campaign->id;
|
||||
|
||||
return $campaign;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
|
||||
*/
|
||||
private function filters(): array
|
||||
{
|
||||
return app(CampaignRecipients::class)->normalize([
|
||||
'portal' => $this->filterPortal,
|
||||
'legacy_contract' => $this->filterLegacyContract,
|
||||
'login' => $this->filterLogin,
|
||||
'login_months' => $this->filterLoginMonths,
|
||||
'sort' => $this->sortField,
|
||||
'direction' => $this->sortDirection,
|
||||
]);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$recipients = app(CampaignRecipients::class);
|
||||
$query = $recipients->query($this->filters());
|
||||
|
||||
return [
|
||||
'recipientCount' => (clone $query)->count(),
|
||||
'recipientPreview' => $query->limit(8)->get(['id', 'name', 'email', 'last_login_at', 'created_at']),
|
||||
'filterSummary' => $recipients->describe($this->filters()),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Newsletter')"
|
||||
:title="$id === null ? __('Neue Kampagne') : __('Kampagne bearbeiten')"
|
||||
:description="__('Der Text wird in das zentrale Mail-Layout eingesetzt; jede Mail enthält automatisch den Abmelde-Link des Empfängers.')"
|
||||
>
|
||||
<x-slot:actions>
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||
{{-- ============== INHALT ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
<flux:badge size="sm" color="zinc">{{ __('Entwurf') }}</flux:badge>
|
||||
</div>
|
||||
<div class="p-5 space-y-5">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Betreff') }}</flux:label>
|
||||
<flux:input wire:model="subject" :placeholder="__('z. B. Ihr Presseportal ist umgezogen — das ist neu')" maxlength="190" />
|
||||
<flux:error name="subject" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Text') }}</flux:label>
|
||||
<flux:description>
|
||||
{{ __('Leichte Formatierung (fett, kursiv, Listen, Links). Anrede und Abmelde-Hinweis ergänzt das Mail-Layout automatisch.') }}
|
||||
</flux:description>
|
||||
<flux:editor wire:model="body"
|
||||
toolbar="bold italic underline | bullet ordered | link | undo redo"
|
||||
placeholder="{{ __('Hier den Newsletter-Text schreiben…') }}" />
|
||||
<flux:error name="body" />
|
||||
</flux:field>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] pt-4">
|
||||
<flux:button variant="filled" icon="document-check" wire:click="saveDraft">
|
||||
{{ __('Als Entwurf speichern') }}
|
||||
</flux:button>
|
||||
<flux:button variant="filled" icon="beaker" wire:click="sendTest">
|
||||
{{ __('Test-Mail an mich') }}
|
||||
</flux:button>
|
||||
<flux:spacer />
|
||||
<flux:modal.trigger name="confirm-campaign-send">
|
||||
<flux:button variant="primary" icon="paper-airplane">
|
||||
{{ __('Jetzt senden') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== EMPFÄNGER ============== --}}
|
||||
<div class="space-y-6">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Empfänger-Filter') }}</span>
|
||||
<flux:badge size="sm" color="blue">{{ number_format($recipientCount, 0, ',', '.') }} {{ __('Empfänger') }}</flux:badge>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Portal') }}</flux:label>
|
||||
<flux:select wire:model.live="filterPortal">
|
||||
<option value="">{{ __('Alle Portale') }}</option>
|
||||
<option value="presseecho">presseecho</option>
|
||||
<option value="businessportal24">businessportal24</option>
|
||||
<option value="both">{{ __('Beide (both)') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field variant="inline">
|
||||
<flux:checkbox wire:model.live="filterLegacyContract" />
|
||||
<flux:label>{{ __('Nur User mit Legacy-Vertrag (Bestandsschutz)') }}</flux:label>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Letzter Login') }}</flux:label>
|
||||
<flux:select wire:model.live="filterLogin">
|
||||
<option value="any">{{ __('Alle — unabhängig vom Login') }}</option>
|
||||
<option value="within">{{ __('In den letzten X Monaten eingeloggt') }}</option>
|
||||
<option value="not_within">{{ __('Seit X Monaten nicht eingeloggt (oder nie)') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
@if ($filterLogin !== 'any')
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Zeitraum X in Monaten') }}</flux:label>
|
||||
<flux:input type="number" min="1" max="120" wire:model.live.debounce.400ms="filterLoginMonths" />
|
||||
<flux:description>{{ __('z. B. 6 oder 12 Monate') }}</flux:description>
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Sortierung') }}</flux:label>
|
||||
<flux:select wire:model.live="sortField">
|
||||
<option value="created_at">{{ __('Erstellungsdatum') }}</option>
|
||||
<option value="last_login_at">{{ __('Letzter Login') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Reihenfolge') }}</flux:label>
|
||||
<flux:select wire:model.live="sortDirection">
|
||||
<option value="desc">{{ __('Neueste zuerst') }}</option>
|
||||
<option value="asc">{{ __('Älteste zuerst') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<ul class="list-disc ps-4 space-y-0.5">
|
||||
@foreach ($filterSummary as $line)
|
||||
<li>{{ $line }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Vorschau der Empfänger') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('erste :count', ['count' => $recipientPreview->count()]) }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-2">
|
||||
@forelse ($recipientPreview as $user)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2">
|
||||
<div class="text-[12.5px] font-semibold text-[color:var(--color-ink)] truncate">{{ $user->name }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">{{ $user->email }}</div>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)] tabular-nums">
|
||||
{{ __('Login:') }} {{ $user->last_login_at?->format('d.m.Y') ?? __('nie') }}
|
||||
· {{ __('Erstellt:') }} {{ $user->created_at?->format('d.m.Y') ?? '—' }}
|
||||
</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)]">
|
||||
{{ __('Keine Empfänger für die gewählten Filter.') }}
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ============== SENDEN-MODAL ============== --}}
|
||||
<flux:modal name="confirm-campaign-send" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Kampagne jetzt versenden?') }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Die Mail geht an :count Empfänger laut aktueller Filter. Die Empfängerliste wird als Protokoll eingefroren; der Versand läuft über die Queue und lässt sich nicht zurückholen.', ['count' => number_format($recipientCount, 0, ',', '.')]) }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button variant="primary" wire:click="send">{{ __('Senden') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use App\Models\NewsletterCampaign;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Newsletter')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'campaigns' => NewsletterCampaign::query()
|
||||
->with('createdBy:id,name')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Newsletter')"
|
||||
:title="__('Newsletter-Kampagnen')"
|
||||
:description="__('Interne Mailings an Bestands-User auf Basis der hinterlegten E-Mail-Adressen — mit Empfänger-Filtern, Versand-Protokoll und Abmelde-Link in jeder Mail.')"
|
||||
>
|
||||
<x-slot:actions>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('admin.newsletter.campaigns.create') }}" wire:navigate>
|
||||
{{ __('Neue Kampagne') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
{{-- ============== KAMPAGNEN ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Kampagnen') }}</span>
|
||||
<span class="badge hub">{{ number_format($campaigns->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Betreff') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Empfänger') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Angelegt von') }}</flux:table.column>
|
||||
<flux:table.column></flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($campaigns as $campaign)
|
||||
<flux:table.row :key="$campaign->id">
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ $campaign->created_at->format('d.m.Y H:i') }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<a href="{{ route('admin.newsletter.campaigns.show', $campaign->id) }}" wire:navigate
|
||||
class="text-[12.5px] font-semibold text-[color:var(--color-ink)] hover:underline">
|
||||
{{ $campaign->subject }}
|
||||
</a>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" :color="$campaign->status->badgeColor()">{{ $campaign->status->label() }}</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
@if ($campaign->isDraft())
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">—</span>
|
||||
@else
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ number_format($campaign->sent_count, 0, ',', '.') }} / {{ number_format($campaign->recipients_total, 0, ',', '.') }}
|
||||
</span>
|
||||
@if ($campaign->failed_count > 0)
|
||||
<flux:badge size="sm" color="red">{{ $campaign->failed_count }} {{ __('Fehler') }}</flux:badge>
|
||||
@endif
|
||||
@if ($campaign->skipped_count > 0)
|
||||
<flux:badge size="sm" color="amber">{{ $campaign->skipped_count }} {{ __('abgemeldet') }}</flux:badge>
|
||||
@endif
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ $campaign->createdBy?->name ?? '—' }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap text-right">
|
||||
@if ($campaign->isDraft())
|
||||
<flux:button size="xs" variant="filled" icon="pencil" href="{{ route('admin.newsletter.campaigns.edit', $campaign->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button size="xs" variant="filled" icon="eye" href="{{ route('admin.newsletter.campaigns.show', $campaign->id) }}" wire:navigate>
|
||||
{{ __('Protokoll') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="6">
|
||||
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Noch keine Kampagnen. Legen Sie die erste Kampagne an, um Bestands-User zu informieren.') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@if ($campaigns->hasPages())
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
|
||||
{{ $campaigns->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\NewsletterRecipientStatus;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Services\Newsletter\CampaignRecipients;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/*
|
||||
* Kampagnen-Detail: Inhalt, Filter-Zusammenfassung und das Versand-Protokoll
|
||||
* (welche Mail ging an welchen Empfänger, mit Status und Fehlern).
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
public string $statusFilter = '';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function updated(string $property): void
|
||||
{
|
||||
if ($property === 'statusFilter') {
|
||||
$this->resetPage();
|
||||
}
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$campaign = NewsletterCampaign::query()
|
||||
->with('createdBy:id,name')
|
||||
->findOrFail($this->id);
|
||||
|
||||
$recipients = $campaign->recipients()
|
||||
->with('user:id,name')
|
||||
->when($this->statusFilter !== '', fn ($query) => $query->where('status', $this->statusFilter))
|
||||
->orderBy('id')
|
||||
->paginate(50);
|
||||
|
||||
return [
|
||||
'campaign' => $campaign,
|
||||
'recipients' => $recipients,
|
||||
'filterSummary' => app(CampaignRecipients::class)->describe($campaign->filters ?? []),
|
||||
'recipientStatuses' => NewsletterRecipientStatus::cases(),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Newsletter')"
|
||||
:title="$campaign->subject"
|
||||
:description="__('Angelegt am :date:by', ['date' => $campaign->created_at->format('d.m.Y H:i'), 'by' => $campaign->createdBy ? ' von '.$campaign->createdBy->name : ''])"
|
||||
>
|
||||
<x-slot:actions>
|
||||
@if ($campaign->isDraft())
|
||||
<flux:button variant="primary" icon="pencil" href="{{ route('admin.newsletter.campaigns.edit', $campaign->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
{{-- ============== KPI-Reihe ============== --}}
|
||||
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<x-portal.stat-card variant="primary" :label="__('Empfänger')" :value="number_format($campaign->recipients_total)">
|
||||
<x-slot:meta>{{ $campaign->status->label() }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="ok" :label="__('Versendet')" :value="number_format($campaign->sent_count)">
|
||||
<x-slot:meta>{{ $campaign->sent_at ? __('abgeschlossen :date', ['date' => $campaign->sent_at->format('d.m.Y H:i')]) : __('läuft…') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Übersprungen')" :value="number_format($campaign->skipped_count)">
|
||||
<x-slot:meta>{{ __('abgemeldet') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Fehlgeschlagen')" :value="number_format($campaign->failed_count)">
|
||||
<x-slot:meta>{{ __('siehe Protokoll') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
{{-- ============== INHALT ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
<flux:badge size="sm" :color="$campaign->status->badgeColor()">{{ $campaign->status->label() }}</flux:badge>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mb-3">
|
||||
{{ __('Betreff') }}: {{ $campaign->subject }}
|
||||
</div>
|
||||
<div class="pr-content max-w-[640px] text-[13px]">
|
||||
{!! $campaign->sanitizedBody() !!}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== FILTER ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Empfänger-Auswahl') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<ul class="list-disc ps-4 space-y-1 text-[12.5px] text-[color:var(--color-ink-2)]">
|
||||
@foreach ($filterSummary as $line)
|
||||
<li>{{ $line }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-3 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Die Liste wurde beim Versandstart eingefroren. Abmeldungen nach dem Start werden beim Versand übersprungen und im Protokoll vermerkt.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{{-- ============== PROTOKOLL ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Versand-Protokoll') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:select wire:model.live="statusFilter" size="sm">
|
||||
<option value="">{{ __('Alle Status') }}</option>
|
||||
@foreach ($recipientStatuses as $status)
|
||||
<option value="{{ $status->value }}">{{ $status->label() }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<span class="badge hub">{{ number_format($recipients->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Empfänger') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Versendet am') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Fehler') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($recipients as $recipient)
|
||||
<flux:table.row :key="$recipient->id">
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
@if ($recipient->user)
|
||||
<a href="{{ route('admin.users.show', $recipient->user->id) }}" wire:navigate
|
||||
class="text-[12.5px] text-[color:var(--color-ink)] hover:underline">
|
||||
{{ $recipient->user->name }}
|
||||
</a>
|
||||
@else
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Gelöschter User') }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $recipient->email }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" :color="$recipient->status->badgeColor()">{{ $recipient->status->label() }}</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ $recipient->sent_at?->format('d.m.Y H:i:s') ?? '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $recipient->error ?? '' }}</span>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5">
|
||||
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ $campaign->isDraft()
|
||||
? __('Noch kein Versand — die Kampagne ist ein Entwurf.')
|
||||
: __('Keine Einträge für diesen Filter.') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@if ($recipients->hasPages())
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
|
||||
{{ $recipients->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue