R20: Begruendung im Modal, und der Waechter sieht jetzt auch Listenzeilen

feat/hostnamen-vergabe
nexxo 2026-07-31 22:09:05 +02:00
parent 7f7b654fcb
commit 29c2718235
9 changed files with 210 additions and 18 deletions

View File

@ -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<int, string> 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'));
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Admin;
use App\Models\FailedCheckout;
use LivewireUI\Modal\ModalComponent;
/**
* Einen geplatzten Kauf abhaken im Modal, nicht in der Zeile (R20).
*
* Die erste Fassung hatte das Feld und den Knopf in der Liste stehen. Beides
* steht wörtlich in R20 unter „Verboten"; durchgerutscht ist es, weil der
* Wächtertest auf `<td>` prüft und die Zeilen `<li>` 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');
}
}

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -71,17 +71,14 @@
{{ $problem->failed_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}
</p>
</div>
</div>
{{-- Begründung neben dem Knopf, nicht danach: „erledigt"
allein ist beim nächsten Nachfragen wertlos. --}}
<div class="mt-3 flex flex-wrap items-end gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="note-{{ $problem->id }}" wire:model="note.{{ $problem->id }}"
:label="__('payment_problems.note')" />
</div>
<x-ui.button variant="secondary" wire:click="resolve({{ $problem->id }})"
wire:loading.attr="disabled" wire:target="resolve({{ $problem->id }})">
{{-- 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. --}}
<x-ui.button variant="secondary" class="shrink-0"
x-on:click="$dispatch('openModal', { component: 'admin.resolve-failed-checkout', arguments: { id: {{ $problem->id }} } })">
{{ __('payment_problems.resolve') }}
</x-ui.button>
</div>

View File

@ -0,0 +1,23 @@
{{-- R24: ein Modal mit Eingabefeld benutzt <x-ui.modal> Kopf und Fuss
bleiben stehen, gescrollt wird nur die Mitte. --}}
<x-ui.modal :title="__('payment_problems.resolve_title')" :subtitle="$label">
<p class="text-sm text-muted">{{ __('payment_problems.resolve_body') }}</p>
<form id="resolve-failed-checkout" wire:submit="confirm" class="mt-4">
<x-ui.input name="note" wire:model="note"
:label="__('payment_problems.note')"
:hint="__('payment_problems.note_hint')" />
</form>
<x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">
{{ __('common.cancel') }}
</x-ui.button>
{{-- type=submit + form= ist der Preis für einen Fuss, der nicht
wegscrollt (R24) ein HTML-Attribut, kein Trick. --}}
<x-ui.button variant="primary" type="submit" form="resolve-failed-checkout"
wire:loading.attr="disabled">
{{ __('payment_problems.resolve') }}
</x-ui.button>
</x-slot:footer>
</x-ui.modal>

View File

@ -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();

View File

@ -0,0 +1,90 @@
<?php
use App\Livewire\Admin\PaymentProblems;
use App\Livewire\Admin\ResolveFailedCheckout;
use App\Models\FailedCheckout;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
/**
* R20, und ich hatte dagegen verstoßen.
*
* Die Liste der geplatzten Zahlungen trug je Zeile ein Eingabefeld für die
* Begründung und einen Knopf, der `resolve()` direkt am Seiten-Bauteil rief
* beide Muster stehen wörtlich in R20 unter „Verboten".
*
* Durchgerutscht ist es, weil `EditInModalTest` auf `<td>` prüft und meine
* Zeilen `<li>` 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();
});

View File

@ -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 <li>…</li>.
//
// 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
// <td> und damit den Buchstaben statt des Sinns.
//
// Checkbox, Radio und <select> bleiben auch hier erlaubt: ein Klick,
// ein Wert, keine Höhenänderung (R20s eigene Ausnahme). Getroffen wird
// nur, was eine Zeile wirklich wachsen lässt.
foreach (preg_split('#</li>#', $source) as $segment) {
$open = strrpos($segment, '<li');
if ($open === false) {
continue;
}
$row = substr($segment, $open);
if (preg_match('#<(input|textarea)\b(?![^>]*type="(checkbox|radio|hidden)")|<x-ui\.input\b#', $row, $m)) {
$offenders[$path][] = trim(strtok(ltrim($row), "\n") ?: $m[0]);
}
}
}
expect($offenders)->toBe([]);