From 29c2718235679b7beba195938d5006ce50f5cde9 Mon Sep 17 00:00:00 2001
From: nexxo
Date: Fri, 31 Jul 2026 22:09:05 +0200
Subject: [PATCH] R20: Begruendung im Modal, und der Waechter sieht jetzt auch
Listenzeilen
---
app/Livewire/Admin/PaymentProblems.php | 10 +--
app/Livewire/Admin/ResolveFailedCheckout.php | 51 +++++++++++
lang/de/payment_problems.php | 3 +
lang/en/payment_problems.php | 3 +
.../livewire/admin/payment-problems.blade.php | 17 ++--
.../admin/resolve-failed-checkout.blade.php | 23 +++++
tests/Feature/Billing/PaymentProblemsTest.php | 6 +-
tests/Feature/Billing/ResolveInModalTest.php | 90 +++++++++++++++++++
tests/Feature/EditInModalTest.php | 25 ++++++
9 files changed, 210 insertions(+), 18 deletions(-)
create mode 100644 app/Livewire/Admin/ResolveFailedCheckout.php
create mode 100644 resources/views/livewire/admin/resolve-failed-checkout.blade.php
create mode 100644 tests/Feature/Billing/ResolveInModalTest.php
diff --git a/app/Livewire/Admin/PaymentProblems.php b/app/Livewire/Admin/PaymentProblems.php
index fa33ab3..72118ca 100644
--- a/app/Livewire/Admin/PaymentProblems.php
+++ b/app/Livewire/Admin/PaymentProblems.php
@@ -8,6 +8,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
+use Livewire\Attributes\On;
use Livewire\Component;
/**
@@ -32,9 +33,6 @@ use Livewire\Component;
#[Layout('layouts.admin')]
class PaymentProblems extends Component
{
- /** @var array FailedCheckout-ID => Begründung */
- public array $note = [];
-
public function mount(): void
{
abort_unless(Gate::allows('billing.manage'), 403);
@@ -47,13 +45,14 @@ class PaymentProblems extends Component
* nächsten Nachfragen wertlos — überwiesen? neu bestellt? nicht erreichbar?
* Wer es war, steht mit dabei.
*/
- public function resolve(int $id): void
+ #[On('failed-checkout-resolved')]
+ public function onResolved(int $id, string $note = ''): void
{
$this->authorize('billing.manage');
$problem = FailedCheckout::query()->findOr($id, fn () => abort(404));
- $begruendung = trim($this->note[$id] ?? '');
+ $begruendung = trim($note);
$wer = Auth::guard('operator')->user()?->email ?? 'console';
$problem->update([
@@ -61,7 +60,6 @@ class PaymentProblems extends Component
'note' => $begruendung !== '' ? $begruendung.' — '.$wer : $wer,
]);
- unset($this->note[$id]);
$this->dispatch('notify', message: __('payment_problems.resolved'));
}
diff --git a/app/Livewire/Admin/ResolveFailedCheckout.php b/app/Livewire/Admin/ResolveFailedCheckout.php
new file mode 100644
index 0000000..69d3d5a
--- /dev/null
+++ b/app/Livewire/Admin/ResolveFailedCheckout.php
@@ -0,0 +1,51 @@
+` prüft und die Zeilen `
` waren.
+ *
+ * Mutiert nichts selbst (R23): der Bestätigen-Knopf löst ein Ereignis aus, das
+ * Admin\PaymentProblems auffängt — damit bleibt die Berechtigungsprüfung an der
+ * einen Stelle, an der sie schon stand, statt hier verdoppelt zu werden. Der
+ * Aufruf hier prüft trotzdem: ein Modal ist ohne die Route-Middleware der Seite
+ * erreichbar.
+ */
+class ResolveFailedCheckout extends ModalComponent
+{
+ public int $id;
+
+ public string $note = '';
+
+ public string $label = '';
+
+ public function mount(int $id): void
+ {
+ $this->authorize('billing.manage');
+
+ $problem = FailedCheckout::query()->findOr($id, fn () => abort(404));
+
+ $this->id = $problem->id;
+ $this->label = $problem->name ?: $problem->email;
+ }
+
+ public function confirm(): void
+ {
+ $this->authorize('billing.manage');
+
+ $this->dispatch('failed-checkout-resolved', id: $this->id, note: $this->note);
+ $this->closeModal();
+ }
+
+ public function render()
+ {
+ return view('livewire.admin.resolve-failed-checkout');
+ }
+}
diff --git a/lang/de/payment_problems.php b/lang/de/payment_problems.php
index c16b2f7..a09dbe1 100644
--- a/lang/de/payment_problems.php
+++ b/lang/de/payment_problems.php
@@ -21,4 +21,7 @@ return [
'note' => 'Begründung',
'resolve' => 'Erledigt',
'resolved' => 'Als erledigt vermerkt.',
+ 'resolve_title' => 'Als erledigt vermerken?',
+ 'resolve_body' => 'Der Vorgang verschwindet aus dieser Liste. Was Sie hier eintragen, steht später daneben — zusammen mit Ihrer Adresse.',
+ 'note_hint' => 'Zum Beispiel: hat überwiesen, hat neu bestellt, nicht erreichbar.',
];
diff --git a/lang/en/payment_problems.php b/lang/en/payment_problems.php
index 1c5b036..be806e9 100644
--- a/lang/en/payment_problems.php
+++ b/lang/en/payment_problems.php
@@ -21,4 +21,7 @@ return [
'note' => 'Reason',
'resolve' => 'Done',
'resolved' => 'Marked as dealt with.',
+ 'resolve_title' => 'Mark as dealt with?',
+ 'resolve_body' => 'It leaves this list. What you write here stays with it afterwards, together with your address.',
+ 'note_hint' => 'For example: paid by transfer, ordered again, unreachable.',
];
diff --git a/resources/views/livewire/admin/payment-problems.blade.php b/resources/views/livewire/admin/payment-problems.blade.php
index 0ee445d..dca419f 100644
--- a/resources/views/livewire/admin/payment-problems.blade.php
+++ b/resources/views/livewire/admin/payment-problems.blade.php
@@ -71,17 +71,14 @@
{{ $problem->failed_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}
-
- {{-- Begründung neben dem Knopf, nicht danach: „erledigt"
- allein ist beim nächsten Nachfragen wertlos. --}}
-
-
-
-
-
+ {{-- R20: die Begründung wird im Modal eingetippt, nicht
+ in der Zeile. Ein Feld hier liesse die Zeile
+ wachsen und die Nachbarn springen — und ein
+ Knopf, der eine Methode am Seiten-Bauteil ruft,
+ ist der Inline-Editor unter anderem Namen. --}}
+
{{ __('payment_problems.resolve') }}
diff --git a/resources/views/livewire/admin/resolve-failed-checkout.blade.php b/resources/views/livewire/admin/resolve-failed-checkout.blade.php
new file mode 100644
index 0000000..bf257f8
--- /dev/null
+++ b/resources/views/livewire/admin/resolve-failed-checkout.blade.php
@@ -0,0 +1,23 @@
+{{-- R24: ein Modal mit Eingabefeld benutzt — Kopf und Fuss
+ bleiben stehen, gescrollt wird nur die Mitte. --}}
+
+
{{ __('payment_problems.resolve_body') }}
+
+
+
+
+
+ {{ __('common.cancel') }}
+
+ {{-- type=submit + form=… ist der Preis für einen Fuss, der nicht
+ wegscrollt (R24) — ein HTML-Attribut, kein Trick. --}}
+
+ {{ __('payment_problems.resolve') }}
+
+
+
diff --git a/tests/Feature/Billing/PaymentProblemsTest.php b/tests/Feature/Billing/PaymentProblemsTest.php
index f3daede..b1b4187 100644
--- a/tests/Feature/Billing/PaymentProblemsTest.php
+++ b/tests/Feature/Billing/PaymentProblemsTest.php
@@ -93,10 +93,12 @@ it('lets somebody mark a bounced payment as dealt with', function () {
'failed_at' => Carbon::now(),
]);
+ // Seit R20 geht die Begründung durch das Modal (siehe ResolveInModalTest):
+ // die Seite fängt nur noch das Ereignis auf. Ein Feld in der Zeile wäre
+ // genau der Inline-Editor, den die Regel verbietet.
Livewire::actingAs(operator('Owner'), 'operator')
->test(PaymentProblems::class)
- ->set("note.{$problem->id}", 'Kunde hat überwiesen')
- ->call('resolve', $problem->id)
+ ->call('onResolved', $problem->id, 'Kunde hat überwiesen')
->assertHasNoErrors();
$problem->refresh();
diff --git a/tests/Feature/Billing/ResolveInModalTest.php b/tests/Feature/Billing/ResolveInModalTest.php
new file mode 100644
index 0000000..93c2b20
--- /dev/null
+++ b/tests/Feature/Billing/ResolveInModalTest.php
@@ -0,0 +1,90 @@
+` prüft und meine
+ * Zeilen `
` waren. Der Buchstabe der Prüfung, nicht ihr Sinn. Dieser Test
+ * schliesst die Lücke für diese Seite; die allgemeine Fassung wäre ein eigener
+ * Punkt (siehe unten).
+ */
+function bouncedPayment(): FailedCheckout
+{
+ return FailedCheckout::query()->create([
+ 'stripe_session_id' => 'cs_geplatzt', 'email' => 'neu@example.test',
+ 'name' => 'Neu GmbH', 'plan' => 'start', 'amount_cents' => 4900,
+ 'currency' => 'EUR', 'failed_at' => Carbon::now(),
+ ]);
+}
+
+it('carries no input field on the page itself', function () {
+ // Die Zeile bleibt eine Zeile. Ein Feld darin lässt sie wachsen, die
+ // Nachbarzeilen springen, und eine halb im Bearbeitungsmodus stehende
+ // Liste liest sich wie ein Darstellungsfehler.
+ bouncedPayment();
+
+ $html = Livewire::actingAs(operator('Owner'), 'operator')
+ ->test(PaymentProblems::class)
+ ->html();
+
+ expect($html)->not->toContain('wire:model="note.');
+});
+
+it('opens a modal instead', function () {
+ $problem = bouncedPayment();
+
+ $html = Livewire::actingAs(operator('Owner'), 'operator')
+ ->test(PaymentProblems::class)
+ ->html();
+
+ expect($html)->toContain('admin.resolve-failed-checkout')
+ ->and($html)->toContain((string) $problem->id);
+});
+
+it('lets the modal hand the reason back to the page', function () {
+ // R23: das Modal mutiert nichts selbst. Es löst ein Ereignis aus, und die
+ // Seiten-Komponente behält ihre eigene Berechtigungsprüfung.
+ $problem = bouncedPayment();
+
+ Livewire::actingAs(operator('Owner'), 'operator')
+ ->test(ResolveFailedCheckout::class, ['id' => $problem->id])
+ ->set('note', 'Kunde hat überwiesen')
+ ->call('confirm')
+ ->assertDispatched('failed-checkout-resolved');
+});
+
+it('records reason and person when the page catches that event', function () {
+ $problem = bouncedPayment();
+
+ Livewire::actingAs(operator('Owner'), 'operator')
+ ->test(PaymentProblems::class)
+ ->call('onResolved', $problem->id, 'Kunde hat überwiesen')
+ ->assertHasNoErrors();
+
+ $problem->refresh();
+
+ expect($problem->resolved_at)->not->toBeNull()
+ ->and($problem->note)->toContain('überwiesen');
+});
+
+it('keeps the capability check on the page, not only on the modal', function () {
+ // Ein Modal ist OHNE die Route-Middleware der Seite erreichbar — und das
+ // Ereignis, das es auslöst, könnte jemand auch direkt schicken.
+ $problem = bouncedPayment();
+
+ Livewire::actingAs(operator('Support'), 'operator')
+ ->test(ResolveFailedCheckout::class, ['id' => $problem->id])
+ ->assertForbidden();
+
+ expect($problem->fresh()->resolved_at)->toBeNull();
+});
diff --git a/tests/Feature/EditInModalTest.php b/tests/Feature/EditInModalTest.php
index 708db70..2232fbe 100644
--- a/tests/Feature/EditInModalTest.php
+++ b/tests/Feature/EditInModalTest.php
@@ -55,6 +55,31 @@ it('never grows input fields inside a table row', function () {
$offenders[$path][] = trim(strtok(ltrim($cell), "\n") ?: $m[0]);
}
}
+
+ // Und dasselbe in
…
.
+ //
+ // Nachgetragen am 31.7.2026, weil genau hier ein Verstoß durchrutschte:
+ // die Liste der geplatzten Zahlungen trug je Zeile ein Textfeld für die
+ // Begründung und einen Knopf, der eine Methode am Seiten-Bauteil rief.
+ // Beides steht wörtlich in R20 unter „Verboten" — die Prüfung sah nur
+ //
und damit den Buchstaben statt des Sinns.
+ //
+ // Checkbox, Radio und