R20: Begruendung im Modal, und der Waechter sieht jetzt auch Listenzeilen
parent
7f7b654fcb
commit
29c2718235
|
|
@ -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'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue