Rollen: aufklappbare Karten, gruppierte Rechte, Knopf im Raster

main
nexxo 2026-07-31 22:39:46 +02:00
parent a64d41639b
commit fb1ef3fc63
5 changed files with 137 additions and 72 deletions

View File

@ -154,6 +154,13 @@ class Roles extends Component
->withCount('users')
->orderBy('name')
->get(),
// Nach dem Präfix gruppiert — die Namen tragen ihre Ordnung längst
// in sich (`billing.`, `customers.`, `provisioning.`), und
// einundzwanzig Kästchen flach untereinander sind eine Wand, in
// der niemand das eine findet, das er sucht.
'groups' => collect($this->allPermissions())
->groupBy(fn (string $name) => str_contains($name, '.') ? strtok($name, '.') : 'sonstiges')
->sortKeys(),
'permissions' => $this->allPermissions(),
// Wie viele Menschen an dieser Rolle hängen — damit niemand blind
// etwas wegnimmt, das gerade sechs Leute benutzen.

View File

@ -16,6 +16,8 @@ return [
'in_use' => 'Die Rolle :role wird noch gehalten. Wer sie hat, stünde beim nächsten Anmelden ohne Rolle da und käme nicht mehr in die Konsole.',
'holders' => '{0} Niemand hat diese Rolle|{1} :count Person hat diese Rolle|[2,*] :count Personen haben diese Rolle',
'granted_count' => ':granted von :total Rechten',
'can' => [
'billing.manage' => 'Rechnungen, Gutschriften, Zahlungsprobleme',
'console.view' => 'Die Konsole überhaupt betreten',

View File

@ -16,6 +16,8 @@ return [
'in_use' => 'The role :role is still held. Whoever has it would be left with no role on their next sign-in and could not enter the console.',
'holders' => '{0} Nobody holds this role|{1} :count person holds this role|[2,*] :count people hold this role',
'granted_count' => ':granted of :total capabilities',
'can' => [
'billing.manage' => 'Invoices, credit notes, payment problems',
'console.view' => 'Enter the console at all',

View File

@ -4,84 +4,113 @@
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('roles.subtitle') }}</p>
</div>
{{-- Neue Rolle. Ein Feld, ein Knopf die Rechte bekommt sie danach in
ihrer eigenen Karte, weil eine leere Rolle noch nichts kaputtmachen
kann und die Matrix im selben Formular unlesbar würde. --}}
<div class="flex flex-wrap items-end gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="min-w-56 flex-1">
<x-ui.input name="newRole" wire:model="newRole"
:label="__('roles.new_label')" :hint="__('roles.new_hint')" />
</div>
<x-ui.button variant="primary" wire:click="create" wire:loading.attr="disabled" wire:target="create">
{{ __('roles.create') }}
</x-ui.button>
</div>
{{-- Neue Rolle.
@foreach ($roles as $role)
@php $isProtected = $role->name === $protectedRole; @endphp
<div wire:key="role-{{ $role->id }}"
class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="flex flex-wrap items-center gap-2 font-semibold text-ink">
{{ $role->name }}
Vorher stand das Feld auf `flex-1` neben einem Knopf ohne `shrink-0`,
und beide auf `items-end` bei schmalem Fenster schob der Hinweistext
unter dem Feld den Knopf aus der Karte heraus. Jetzt ein Raster mit
zwei Spuren: das Feld nimmt, was übrig ist, der Knopf behält seine
Breite, und der Hinweis steht UNTER der Reihe statt in ihr. --}}
<form wire:submit="create" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
<x-ui.input name="newRole" wire:model="newRole" :label="__('roles.new_label')" />
<x-ui.button type="submit" variant="primary" class="shrink-0"
wire:loading.attr="disabled" wire:target="create">
{{ __('roles.create') }}
</x-ui.button>
</div>
<p class="mt-2 text-xs text-muted">{{ __('roles.new_hint') }}</p>
@error('newRole')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</form>
{{-- Eine Karte je Rolle, zugeklappt.
Sechs Rollen mal einundzwanzig Rechte waren über hundert Kästchen
untereinander man scrollte an der Rolle vorbei, die man ändern
wollte. Zugeklappt ist die Seite sechs Zeilen lang, und die Zeile sagt
schon, was sie enthält: wie viele Rechte, wie viele Menschen. --}}
<div class="space-y-3">
@foreach ($roles as $role)
@php
$isProtected = $role->name === $protectedRole;
// NICHT $granted nennen: das ist die Eigenschaft des
// Bauteils, und in der zweiten Runde der Schleife wäre sie
// eine Zahl statt der Liste.
$grantedCount = count($granted[$role->id] ?? []);
@endphp
<div wire:key="role-{{ $role->id }}" x-data="{ open: false }"
class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
{{-- Die Kopfzeile IST der Schalter. Ein eigener Pfeil-Knopf
daneben wäre ein zweites Ziel für dieselbe Absicht. --}}
<button type="button" x-on:click="open = ! open" :aria-expanded="open"
class="flex w-full flex-wrap items-center justify-between gap-3 px-6 py-4 text-left transition-colors hover:bg-surface-hover">
<span class="flex min-w-0 flex-wrap items-center gap-2">
{{-- chevron-down gedreht: der Satz kennt kein chevron-right, und
eine zweite Ikone für dieselbe Sache wäre eine zu viel. --}}
<x-ui.icon name="chevron-down" class="size-4 shrink-0 text-muted transition-transform"
x-bind:class="open ? '' : '-rotate-90'" />
<span class="font-semibold text-ink">{{ $role->name }}</span>
@if ($isProtected)
<span class="rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">
{{ __('roles.locked_badge') }}
</span>
<span class="rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">{{ __('roles.locked_badge') }}</span>
@endif
@if (in_array($role->name, $ownRoles, true))
<span class="rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
{{ __('roles.your_role') }}
</span>
<span class="rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">{{ __('roles.your_role') }}</span>
@endif
</h2>
<p class="mt-0.5 text-xs text-muted">
{{ trans_choice('roles.holders', $role->users_count, ['count' => $role->users_count]) }}
</p>
</div>
</span>
@unless ($isProtected)
<div class="flex flex-wrap gap-2">
<x-ui.button variant="primary" wire:click="save({{ $role->id }})"
wire:loading.attr="disabled" wire:target="save({{ $role->id }})">
{{ __('common.save') }}
</x-ui.button>
@if ($role->users_count === 0)
<x-ui.button variant="ghost" wire:click="delete({{ $role->id }})"
wire:loading.attr="disabled" wire:target="delete({{ $role->id }})">
{{ __('roles.delete') }}
</x-ui.button>
@endif
<span class="flex shrink-0 items-center gap-3 text-xs text-muted">
<span>{{ __('roles.granted_count', ['granted' => $grantedCount, 'total' => count($permissions)]) }}</span>
<span aria-hidden="true">·</span>
<span>{{ trans_choice('roles.holders', $role->users_count, ['count' => $role->users_count]) }}</span>
</span>
</button>
<div x-show="open" x-cloak class="border-t border-line px-6 py-5">
@if ($isProtected)
<x-ui.alert variant="warning" class="mb-4">{{ __('roles.owner_locked') }}</x-ui.alert>
@endif
@error('granted.'.$role->id)<p class="mb-3 text-xs text-danger">{{ $message }}</p>@enderror
<div class="grid gap-x-8 gap-y-6 sm:grid-cols-2">
@foreach ($groups as $prefix => $names)
<div>
<h3 class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $prefix }}</h3>
<div class="mt-2 space-y-2">
@foreach ($names as $permission)
<label class="flex items-start gap-2 text-sm text-body">
<input type="checkbox" value="{{ $permission }}"
wire:model="granted.{{ $role->id }}"
@disabled($isProtected)
class="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent-active)] disabled:opacity-50">
<span class="min-w-0">
<span class="block">{{ __('roles.can.'.$permission) }}</span>
<span class="block font-mono text-[11px] text-faint">{{ $permission }}</span>
</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
@endunless
@unless ($isProtected)
<div class="mt-6 flex flex-wrap gap-2 border-t border-line pt-5">
<x-ui.button variant="primary" wire:click="save({{ $role->id }})"
wire:loading.attr="disabled" wire:target="save({{ $role->id }})">
{{ __('common.save') }}
</x-ui.button>
@if ($role->users_count === 0)
<x-ui.button variant="ghost" wire:click="delete({{ $role->id }})"
wire:loading.attr="disabled" wire:target="delete({{ $role->id }})">
{{ __('roles.delete') }}
</x-ui.button>
@endif
</div>
@endunless
</div>
</div>
@error('granted.'.$role->id)<p class="text-xs text-danger">{{ $message }}</p>@enderror
@if ($isProtected)
<p class="text-sm text-muted">{{ __('roles.owner_locked') }}</p>
@endif
{{-- Die Matrix. Auch für Owner sichtbar, nur nicht änderbar: was
der Owner darf, ist die Antwort auf „was gibt es überhaupt",
und die soll man nachlesen können. --}}
<div class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($permissions as $permission)
<label class="flex items-start gap-2 text-sm text-body">
<input type="checkbox" value="{{ $permission }}"
wire:model="granted.{{ $role->id }}"
@disabled($isProtected)
class="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent-active)] disabled:opacity-50">
<span class="min-w-0">
<span class="font-mono text-xs">{{ $permission }}</span>
<span class="block text-xs text-muted">{{ __('roles.can.'.$permission) }}</span>
</span>
</label>
@endforeach
</div>
</div>
@endforeach
@error('newRole')<p class="text-sm text-danger">{{ $message }}</p>@enderror
@endforeach
</div>
</div>

View File

@ -121,3 +121,28 @@ it('is not reachable without staff.manage', function () {
->test(Roles::class)
->assertForbidden();
});
it('keeps counting capabilities correctly past the first role', function () {
// Ein Fehler, den ich beim Umbau selbst gebaut habe: die Ansicht nannte
// die Zählvariable `$granted` — genauso wie die Eigenschaft des Bauteils.
// Ab der zweiten Rolle war sie eine Zahl statt der Liste, und die Seite
// starb an `count(int)`. Mit einer Rolle wäre es nie aufgefallen.
Role::findOrCreate('Buchhaltung', 'operator')->givePermissionTo('console.view');
Role::findOrCreate('Werkstatt', 'operator');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->assertOk()
->assertSee('Buchhaltung')
->assertSee('Werkstatt');
});
it('groups the capabilities instead of listing twenty-one in a row', function () {
$groups = Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->viewData('groups');
expect($groups)->toHaveKey('billing')
->and($groups)->toHaveKey('provisioning')
->and(count($groups))->toBeLessThan(21);
});