Admin: Widerrufe laufen in der Recht-&-Compliance-Queue auf

Eigene Widerrufe-Sektion unter der Anfragen-Tabelle in
/admin/legal-requests: Vorgangsnummer, Zuordnungsdaten, Statusfilter
(offen/alle/erledigt, eigener Paginator), Erledigt/Wieder-oeffnen-Aktion
mit resolved_at; KPI-Karte "Offene Widerrufe". Dazu
WithdrawalRequest-Factory (+resolved-State) fuer die Tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-03 10:29:32 +00:00
parent e85241611c
commit 6621b030e6
4 changed files with 259 additions and 2 deletions

View file

@ -2,7 +2,9 @@
namespace App\Models;
use Database\Factories\WithdrawalRequestFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
@ -11,6 +13,9 @@ use Illuminate\Database\Eloquent\Model;
*/
class WithdrawalRequest extends Model
{
/** @use HasFactory<WithdrawalRequestFactory> */
use HasFactory;
protected $fillable = [
'name',
'email',

View file

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\WithdrawalRequest;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<WithdrawalRequest>
*/
class WithdrawalRequestFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->safeEmail(),
'order_reference' => fake()->optional()->bothify('STR-2026-####'),
'host' => 'pressekonto.test',
'requester_ip' => fake()->ipv4(),
'status' => 'open',
];
}
public function resolved(): static
{
return $this->state(fn (): array => [
'status' => 'resolved',
'resolved_at' => now(),
]);
}
}

View file

@ -3,6 +3,7 @@
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Models\LegalRequest;
use App\Models\WithdrawalRequest;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
@ -19,6 +20,9 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
#[Url(as: 'type', except: 'all')]
public string $typeFilter = 'all';
#[Url(as: 'widerrufe', except: 'open')]
public string $withdrawalStatusFilter = 'open';
public function updatedStatusFilter(): void
{
$this->resetPage();
@ -29,6 +33,30 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
$this->resetPage();
}
public function updatedWithdrawalStatusFilter(): void
{
$this->resetPage('withdrawals');
}
/**
* 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,
]);
}
public function with(): array
{
$requests = LegalRequest::query()
@ -39,11 +67,19 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
->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');
return [
'requests' => $requests,
'typeOptions' => LegalRequestType::cases(),
'openCount' => LegalRequest::query()->open()->count(),
'totalCount' => LegalRequest::query()->count(),
'withdrawals' => $withdrawals,
'openWithdrawalCount' => WithdrawalRequest::query()->open()->count(),
];
}
@ -64,11 +100,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 – manuelle Bearbeitung, keine KI.')"
:description="__('DSGVO-Anonymisierung, Persönlichkeitsrechte und Meldungen zu Pressemitteilungen sowie Online-Widerrufe über den Widerrufsbutton – manuelle Bearbeitung, keine KI.')"
/>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<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>
@ -77,6 +113,10 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
<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>
</section>
{{-- ============== FILTER ============== --}}
@ -174,4 +214,104 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
<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>
</div>

View file

@ -0,0 +1,75 @@
<?php
use App\Models\User;
use App\Models\WithdrawalRequest;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt;
use Tests\TestCase;
beforeEach(function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function withdrawalAdmin(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('the legal queue lists open withdrawal requests', function () {
$withdrawal = WithdrawalRequest::factory()->create([
'name' => 'Paula Presse',
'email' => 'paula@example.test',
'order_reference' => 'STR-2026-0042',
]);
Volt::actingAs(withdrawalAdmin())
->test('admin.legal-requests.index')
->assertOk()
->assertSee('Widerrufe')
->assertSee('W-'.$withdrawal->id)
->assertSee('Paula Presse')
->assertSee('paula@example.test')
->assertSee('STR-2026-0042');
});
test('an admin can resolve a withdrawal request', function () {
$withdrawal = WithdrawalRequest::factory()->create();
Volt::actingAs(withdrawalAdmin())
->test('admin.legal-requests.index')
->call('resolveWithdrawal', $withdrawal->id)
->assertOk();
$withdrawal->refresh();
expect($withdrawal->status)->toBe('resolved');
expect($withdrawal->resolved_at)->not->toBeNull();
});
test('an admin can reopen a resolved withdrawal request', function () {
$withdrawal = WithdrawalRequest::factory()->resolved()->create();
Volt::actingAs(withdrawalAdmin())
->test('admin.legal-requests.index')
->set('withdrawalStatusFilter', 'all')
->call('reopenWithdrawal', $withdrawal->id)
->assertOk();
$withdrawal->refresh();
expect($withdrawal->status)->toBe('open');
expect($withdrawal->resolved_at)->toBeNull();
});
test('resolved withdrawals are hidden from the default open view', function () {
WithdrawalRequest::factory()->resolved()->create(['name' => 'Erledigte Person']);
Volt::actingAs(withdrawalAdmin())
->test('admin.legal-requests.index')
->assertOk()
->assertDontSee('Erledigte Person');
});