presseportale/resources/views/livewire/admin/legal-requests/index.blade.php
Kevin Adametz 2a9be7a9af T10: DSA-Meldeformulare mit URL-Feld, Vorgangsnummer und Eingangsbestaetigung
Zwei eigenstaendige Formulare unter /melden/rechtswidrig (DSA Art. 16:
URL, Name, E-Mail, Begruendung, Wahrheitsversicherung) und
/melden/kontaktdaten (DSGVO-Antrag) auf allen drei Domains. Gemeldete
URLs werden automatisch der passenden veroeffentlichten PM zugeordnet;
ohne Treffer bleibt die URL im payload. Jede Meldung erhaelt eine
Vorgangsnummer (M-{id}, analog K-{id} der Kuendigung), die meldende
Person unverzueglich eine Eingangsbestaetigung (DSA Art. 16 Abs. 4) und
das Melde-Postfach einen internen Hinweis — auch im bestehenden
Per-PM-Flow. Admin-Queue zeigt Vorgangsnummer, Name und gemeldete URL;
/melden verlinkt die neuen Formulare als ersten Weg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:35:30 +00:00

477 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Models\LegalRequest;
use App\Models\TerminationRequest;
use App\Models\WithdrawalRequest;
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('Recht & Compliance')] class extends Component
{
use WithPagination;
#[Url(as: 'status', except: 'open')]
public string $statusFilter = 'open';
#[Url(as: 'type', except: 'all')]
public string $typeFilter = 'all';
#[Url(as: 'widerrufe', except: 'open')]
public string $withdrawalStatusFilter = 'open';
#[Url(as: 'kuendigungen', except: 'open')]
public string $terminationStatusFilter = 'open';
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function updatedTypeFilter(): void
{
$this->resetPage();
}
public function updatedWithdrawalStatusFilter(): void
{
$this->resetPage('withdrawals');
}
public function updatedTerminationStatusFilter(): void
{
$this->resetPage('terminations');
}
/**
* Widerruf als erledigt markieren (Rückabwicklung angestoßen/abgeschlossen).
*/
public function resolveWithdrawal(int $id): void
{
WithdrawalRequest::query()->findOrFail($id)->update([
'status' => 'resolved',
'resolved_at' => now(),
]);
}
public function reopenWithdrawal(int $id): void
{
WithdrawalRequest::query()->findOrFail($id)->update([
'status' => 'open',
'resolved_at' => null,
]);
}
/**
* Kündigung (§ 312k) als erledigt markieren (Vertrag beendet, Kunde hat
* die Bestätigung des Beendigungszeitpunkts erhalten).
*/
public function resolveTermination(int $id): void
{
TerminationRequest::query()->findOrFail($id)->update([
'status' => 'resolved',
'resolved_at' => now(),
]);
}
public function reopenTermination(int $id): void
{
TerminationRequest::query()->findOrFail($id)->update([
'status' => 'open',
'resolved_at' => null,
]);
}
public function with(): array
{
$requests = LegalRequest::query()
->with('pressRelease:id,title,slug')
->when($this->statusFilter === 'open', fn ($query) => $query->open())
->when(! in_array($this->statusFilter, ['open', 'all'], true), fn ($query) => $query->where('status', $this->statusFilter))
->when($this->typeFilter !== 'all', fn ($query) => $query->where('type', $this->typeFilter))
->latest()
->paginate(50);
$withdrawals = WithdrawalRequest::query()
->when($this->withdrawalStatusFilter === 'open', fn ($query) => $query->open())
->when($this->withdrawalStatusFilter === 'resolved', fn ($query) => $query->where('status', 'resolved'))
->latest()
->paginate(25, pageName: 'withdrawals');
$terminations = TerminationRequest::query()
->when($this->terminationStatusFilter === 'open', fn ($query) => $query->open())
->when($this->terminationStatusFilter === 'resolved', fn ($query) => $query->where('status', 'resolved'))
->latest()
->paginate(25, pageName: 'terminations');
return [
'requests' => $requests,
'typeOptions' => LegalRequestType::cases(),
'openCount' => LegalRequest::query()->open()->count(),
'totalCount' => LegalRequest::query()->count(),
'withdrawals' => $withdrawals,
'openWithdrawalCount' => WithdrawalRequest::query()->open()->count(),
'terminations' => $terminations,
'openTerminationCount' => TerminationRequest::query()->open()->count(),
];
}
public function statusBadgeClass(LegalRequestStatus $status): string
{
return match ($status) {
LegalRequestStatus::Open => 'warn',
LegalRequestStatus::InProgress => 'hub',
LegalRequestStatus::Resolved => 'ok',
LegalRequestStatus::Rejected => 'err',
};
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Recht & Compliance')"
:title="__('Recht & Compliance')"
:description="__('DSGVO-Anonymisierung, Persönlichkeitsrechte und Meldungen zu Pressemitteilungen sowie Online-Widerrufe (Widerrufsbutton) und Online-Kündigungen (Kündigungsbutton § 312k BGB) – manuelle Bearbeitung, keine KI.')"
/>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
<x-slot:meta>{{ __('alle Anfragen') }}</x-slot:meta>
<x-slot:trend>{{ __('inkl. erledigt') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('Offene Widerrufe')" :value="number_format($openWithdrawalCount)">
<x-slot:meta>{{ __('Online-Widerruf') }}</x-slot:meta>
<x-slot:trend>{{ __('gesetzliche Frist beachten') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('Offene Kündigungen')" :value="number_format($openTerminationCount)">
<x-slot:meta>{{ __('Kündigungsbutton § 312k') }}</x-slot:meta>
<x-slot:trend>{{ __('Beendigungszeitpunkt bestätigen') }}</x-slot:trend>
</x-portal.stat-card>
</section>
{{-- ============== FILTER ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter') }}</span>
</div>
<div class="p-5 flex flex-col gap-4 sm:flex-row">
<flux:select wire:model.live="statusFilter" class="w-full sm:w-56">
<option value="open">{{ __('Offen & in Bearbeitung') }}</option>
<option value="all">{{ __('Alle Status') }}</option>
<option value="resolved">{{ __('Erledigt') }}</option>
<option value="rejected">{{ __('Abgelehnt') }}</option>
</flux:select>
<flux:select wire:model.live="typeFilter" class="w-full sm:w-56">
<option value="all">{{ __('Alle Typen') }}</option>
@foreach ($typeOptions as $typeOption)
<option value="{{ $typeOption->value }}">{{ $typeOption->label() }}</option>
@endforeach
</flux:select>
</div>
</article>
{{-- ============== TABELLE ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anfragen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $requests->total()]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Typ') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Pressemitteilung') }}</flux:table.column>
<flux:table.column>{{ __('Absender') }}</flux:table.column>
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($requests as $request)
<flux:table.row :key="$request->id">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $request->type->label() }}
</span>
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">
{{ $request->reference() }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="badge dot {{ $this->statusBadgeClass($request->status) }}">
{{ $request->status->label() }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]">
{{ \Illuminate\Support\Str::limit($request->pressRelease?->title ?? $request->reportedUrl() ?? __('PM gelöscht'), 60) }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">
{{ $request->requester_email ?: __('anonym') }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $request->created_at?->format('d.m.Y H:i') ?? '-' }}
</span>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('admin.legal-requests.show', $request->id) }}" wire:navigate>
{{ __('Öffnen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.scale class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Anfragen in dieser Ansicht') }}
</div>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<x-portal.table-pagination :paginator="$requests" />
</article>
{{-- ============== WIDERRUFE (Online-Widerruf, gesetzlicher Widerrufsbutton) ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Widerrufe') }}</span>
<div class="flex items-center gap-3">
<flux:select wire:model.live="withdrawalStatusFilter" size="sm" class="w-44">
<option value="open">{{ __('Offen') }}</option>
<option value="all">{{ __('Alle') }}</option>
<option value="resolved">{{ __('Erledigt') }}</option>
</flux:select>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $withdrawals->total()]) }}
</span>
</div>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Vorgang') }}</flux:table.column>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
<flux:table.column>{{ __('Bestell-/Rechnungsnr.') }}</flux:table.column>
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($withdrawals as $withdrawal)
<flux:table.row :key="'withdrawal-'.$withdrawal->id">
<flux:table.cell>
<span class="text-[12.5px] font-mono font-semibold text-[color:var(--color-ink)]">
W-{{ $withdrawal->id }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $withdrawal->name }}
</span>
</flux:table.cell>
<flux:table.cell>
<a href="mailto:{{ $withdrawal->email }}"
class="text-[12.5px] text-[color:var(--color-hub)] hover:underline">
{{ $withdrawal->email }}
</a>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">
{{ $withdrawal->order_reference ?: '—' }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $withdrawal->created_at?->format('d.m.Y H:i') ?? '-' }}
</span>
</flux:table.cell>
<flux:table.cell>
@if ($withdrawal->status === 'open')
<span class="badge warn dot">{{ __('Offen') }}</span>
@else
<span class="badge ok dot">{{ __('Erledigt') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
@if ($withdrawal->status === 'open')
<flux:button size="sm" variant="filled" icon="check"
wire:click="resolveWithdrawal({{ $withdrawal->id }})">
{{ __('Erledigt') }}
</flux:button>
@else
<flux:button size="sm" variant="filled" icon="arrow-uturn-left"
wire:click="reopenWithdrawal({{ $withdrawal->id }})">
{{ __('Wieder öffnen') }}
</flux:button>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.arrow-uturn-left class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Widerrufe in dieser Ansicht') }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[420px]">
{{ __('Online-Widerrufe über den „Vertrag widerrufen"-Button laufen hier auf. Die Eingangsbestätigung an den Verbraucher wird automatisch versendet.') }}
</p>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<x-portal.table-pagination :paginator="$withdrawals" />
</article>
{{-- ============== KÜNDIGUNGEN (Kündigungsbutton § 312k BGB) ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Kündigungen') }}</span>
<div class="flex items-center gap-3">
<flux:select wire:model.live="terminationStatusFilter" size="sm" class="w-44">
<option value="open">{{ __('Offen') }}</option>
<option value="all">{{ __('Alle') }}</option>
<option value="resolved">{{ __('Erledigt') }}</option>
</flux:select>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $terminations->total()]) }}
</span>
</div>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Vorgang') }}</flux:table.column>
<flux:table.column>{{ __('Art') }}</flux:table.column>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
<flux:table.column>{{ __('Beendigung') }}</flux:table.column>
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($terminations as $termination)
<flux:table.row :key="'termination-'.$termination->id">
<flux:table.cell>
<span class="text-[12.5px] font-mono font-semibold text-[color:var(--color-ink)]">
K-{{ $termination->id }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]">
{{ $termination->isExtraordinary() ? __('Außerordentlich') : __('Ordentlich') }}
</span>
@if ($termination->isExtraordinary() && $termination->reason)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0 mt-0.5 max-w-[220px]">
{{ \Illuminate\Support\Str::limit($termination->reason, 90) }}
</p>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $termination->name }}
</span>
@if ($termination->contract_reference)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0 mt-0.5">
{{ __('Vertrag/Rechnung:') }} {{ $termination->contract_reference }}
</p>
@endif
</flux:table.cell>
<flux:table.cell>
<a href="mailto:{{ $termination->email }}"
class="text-[12.5px] text-[color:var(--color-hub)] hover:underline">
{{ $termination->email }}
</a>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">
{{ $termination->terminate_at?->format('d.m.Y') ?? __('nächstmöglich') }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $termination->created_at?->format('d.m.Y H:i') ?? '-' }}
</span>
</flux:table.cell>
<flux:table.cell>
@if ($termination->status === 'open')
<span class="badge warn dot">{{ __('Offen') }}</span>
@else
<span class="badge ok dot">{{ __('Erledigt') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
@if ($termination->status === 'open')
<flux:button size="sm" variant="filled" icon="check"
wire:click="resolveTermination({{ $termination->id }})">
{{ __('Erledigt') }}
</flux:button>
@else
<flux:button size="sm" variant="filled" icon="arrow-uturn-left"
wire:click="reopenTermination({{ $termination->id }})">
{{ __('Wieder öffnen') }}
</flux:button>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.no-symbol class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Kündigungen in dieser Ansicht') }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[420px]">
{{ __('Online-Kündigungen über den Kündigungsbutton (§ 312k BGB) laufen hier auf. Die Zugangsbestätigung an den Verbraucher wird automatisch versendet – der Beendigungszeitpunkt muss manuell bestätigt werden.') }}
</p>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<x-portal.table-pagination :paginator="$terminations" />
</article>
</div>