Recht: Kuendigungsbutton nach Par. 312k BGB - Backend + Admin-Queue

Oeffentliche Bestaetigungsseite /de/kuendigung ohne Login: Art der
Kuendigung (Grund nur bei ausserordentlich), Zuordnungsdaten,
Beendigungszeitpunkt; Honeypot + Rate-Limit wie beim Widerruf.
Sofortige automatische Zugangsbestaetigung per E-Mail (Par. 312k
Abs. 4 BGB) + Betreiber-Mail. Kuendigungen laufen in der
Recht-&-Compliance-Queue unter den Widerrufen auf (K-Vorgaenge,
eigener Filter, Erledigt/Wieder-oeffnen, vierte KPI-Karte).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-03 11:32:43 +00:00
parent 239ec571bc
commit 1c3a52ae3c
12 changed files with 826 additions and 2 deletions

View file

@ -3,6 +3,7 @@
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;
@ -23,6 +24,9 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
#[Url(as: 'widerrufe', except: 'open')]
public string $withdrawalStatusFilter = 'open';
#[Url(as: 'kuendigungen', except: 'open')]
public string $terminationStatusFilter = 'open';
public function updatedStatusFilter(): void
{
$this->resetPage();
@ -38,6 +42,11 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
$this->resetPage('withdrawals');
}
public function updatedTerminationStatusFilter(): void
{
$this->resetPage('terminations');
}
/**
* Widerruf als erledigt markieren (Rückabwicklung angestoßen/abgeschlossen).
*/
@ -57,6 +66,26 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
]);
}
/**
* 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()
@ -73,6 +102,12 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
->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(),
@ -80,6 +115,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
'totalCount' => LegalRequest::query()->count(),
'withdrawals' => $withdrawals,
'openWithdrawalCount' => WithdrawalRequest::query()->open()->count(),
'terminations' => $terminations,
'openTerminationCount' => TerminationRequest::query()->open()->count(),
];
}
@ -100,11 +137,11 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Recht & Compliance')"
:title="__('Recht & Compliance')"
:description="__('DSGVO-Anonymisierung, Persönlichkeitsrechte und Meldungen zu Pressemitteilungen sowie Online-Widerrufe über den Widerrufsbutton – manuelle Bearbeitung, keine KI.')"
: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-3">
<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>
@ -117,6 +154,10 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
<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 ============== --}}
@ -314,4 +355,120 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
<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>