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>
477 lines
24 KiB
PHP
477 lines
24 KiB
PHP
<?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>
|