feature: add role permission table for HR Manager

- Add TableHeader DTO
- Refactor Toggle component to have checked prop, as wire:model does not work
- add null collasing for php 8.5 strict compatibility in scope directive
- refactor card component so that actions stay on the right
- make button component scaled small on active and change color on hover
This commit is contained in:
= 2026-05-11 07:16:31 +00:00
parent ede0064b54
commit ca628fb009
10 changed files with 129 additions and 38 deletions

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ManageRoles;
use Spatie\LaravelData\Data;
final class PermissionData extends Data
{
public function __construct(
public string $name,
public bool $view,
public bool $create,
public bool $update,
public bool $delete,
) {}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui;
use Spatie\LaravelData\Data;
final class TableHeader extends Data
{
public function __construct(
public string $key,
public string $label,
public ?array $format = null,
) {}
}

View File

@ -17,6 +17,7 @@ public function boot(): void
{ {
$this->registerScopeDirective(); $this->registerScopeDirective();
} }
public function registerScopeDirective(): void public function registerScopeDirective(): void
{ {
/** /**
@ -30,7 +31,10 @@ public function registerScopeDirective(): void
$directiveArguments = preg_split("/,(?![^\(\(]*[\)\)])/", $expression); $directiveArguments = preg_split("/,(?![^\(\(]*[\)\)])/", $expression);
$directiveArguments = array_map("trim", $directiveArguments); $directiveArguments = array_map("trim", $directiveArguments);
[$name, $functionArguments] = $directiveArguments; // PHP 8.5 Safe Unpacking (Prevents "Undefined array key" crashes)
$name = $directiveArguments[0] ?? "''";
$functionArguments = $directiveArguments[1] ?? '';
// Build function "uses" to inject extra external variables // Build function "uses" to inject extra external variables
$uses = Arr::except(array_flip($directiveArguments), [$name, $functionArguments]); $uses = Arr::except(array_flip($directiveArguments), [$name, $functionArguments]);
$uses = array_flip($uses); $uses = array_flip($uses);
@ -39,13 +43,14 @@ public function registerScopeDirective(): void
$uses = implode(",", $uses); $uses = implode(",", $uses);
/** /**
* Slot names can`t contains dot , eg: `user.city`. * Slot names can`t contains dot , eg: `user.city`.
* So we convert `user.city` to `user___city` * So we convert `user.city` to `user___city`
* *
* Later, on component it will be replaced back. * Later, on component it will be replaced back.
*/ */
$name = str_replace(".", "___", $name); $name = str_replace(".", "___", $name);
// PHP 8.5 Safe Loop Check
return "<?php \$__bladeCompiler = \$__bladeCompiler ?? null; \$__env->slot({$name}, function({$functionArguments}) use ({$uses}) { \$loop = !empty(\$__env->getLoopStack()) ? (object) \$__env->getLoopStack()[0] : null; ?>"; return "<?php \$__bladeCompiler = \$__bladeCompiler ?? null; \$__env->slot({$name}, function({$functionArguments}) use ({$uses}) { \$loop = !empty(\$__env->getLoopStack()) ? (object) \$__env->getLoopStack()[0] : null; ?>";
}); });

View File

@ -57,10 +57,10 @@
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
], ],
"lint": [ "lint": [
"pint --parallel" "pint --parallel --dirty"
], ],
"lint:check": [ "lint:check": [
"pint --parallel --test" "pint --parallel --dirty --test"
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",

View File

@ -0,0 +1,60 @@
<?php
use App\Data\Ui\ManageRoles\PermissionData;
use App\Data\Ui\TableHeader;
use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
new class extends Component {
#[DataCollectionOf(TableHeader::class)]
public DataCollection $header;
#[DataCollectionOf(PermissionData::class)]
public DataCollection $rolePermissions;
public function mount(): void
{
$this->header = TableHeader::collect([
new TableHeader('name', 'Permission'),
new TableHeader('view', 'View'),
new TableHeader('create', 'Create'),
new TableHeader('update', 'Edit'),
new TableHeader('delete', 'Delete'),
], DataCollection::class);
$this->rolePermissions = PermissionData::collect([
new PermissionData('Outlook - emails', true, true, false, false),
new PermissionData('Teams - Messages', true, false, true, true),
], DataCollection::class);
}
};
?>
<div>
<x-shared.card title="Role Permission - HR Manager" icon="lucide-lock" class="p-0">
<x-slot:actions>
<x-shared.button icon="lucide-x">
{{ __('Revoke All') }}
</x-shared.button>
<x-shared.button icon="lucide-check">
{{ __('Save') }}
</x-shared.button>
</x-slot:actions>
<x-table :headers="$header->toArray()" :rows="$rolePermissions">
@scope('cell_view', $row)
<x-shared.toggle :checked="$row->view" />
@endscope
@scope('cell_create', $row)
<x-shared.toggle :checked="$row->create" />
@endscope
@scope('cell_update', $row)
<x-shared.toggle :checked="$row->update" />
@endscope
@scope('cell_delete', $row)
<x-shared.toggle :checked="$row->update" />
@endscope
</x-table>
</x-shared.card>
</div>

View File

@ -1,34 +1,24 @@
<?php <?php
use App\Data\Ui\ManageRoles\RoleData; use App\Data\Ui\ManageRoles\RoleData;
use App\Data\Ui\ManageRoles\RolesCollection;
use App\Data\Ui\ManageRoles\RolesData;
use App\Data\Ui\ManageRoles\ServiceCollection;
use App\Data\Ui\ManageRoles\ServiceData; use App\Data\Ui\ManageRoles\ServiceData;
use App\Data\Users\UserCollection; use App\Data\Ui\TableHeader;
use App\Data\Users\UserData; use App\Data\Users\UserData;
use App\Data\Users\UsersData;
use App\Enums\RolesStatus; use App\Enums\RolesStatus;
use Livewire\Component; use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
new class extends Component { new class extends Component {
/** #[DataCollectionOf(TableHeader::class)]
* @var array<int, array{ public DataCollection $header;
* key: string,
* label: string,
* format: array{0: string, 1: string}|null
* }>
*/
public array $header;
#[DataCollectionOf(class: RoleData::class)] #[DataCollectionOf(class: RoleData::class)]
public DataCollection $roles; public DataCollection $roles;
public function mount(): void public function mount(): void
{ {
$this->header = [ $this->header = TableHeader::collect([
[ [
"key" => "name", "key" => "name",
"label" => "Role Name", "label" => "Role Name",
@ -45,7 +35,7 @@ public function mount(): void
"key" => "status", "key" => "status",
"label" => "Status", "label" => "Status",
], ],
]; ], DataCollection::class);
$this->roles = RoleData::collect( $this->roles = RoleData::collect(
[ [
new RoleData( new RoleData(
@ -91,7 +81,7 @@ public function mount(): void
</x-shared.button> </x-shared.button>
</x-slot:actions> </x-slot:actions>
<x-table :headers="$header" :rows="$roles"> <x-table :headers="$header->toArray()" :rows="$roles">
@scope('cell_users', $role) @scope('cell_users', $role)
@php /** @var RoleData $role */ @endphp @php /** @var RoleData $role */ @endphp
<div class="flex -space-x-3"> <div class="flex -space-x-3">

View File

@ -1,5 +1,5 @@
@props(['icon' => null]) @props(['icon' => null])
<button {{$attributes->twMerge(['class' => 'rounded-lg border border-gray-300 px-4 py-2 text-gray-700'])}}> <button {{$attributes->twMerge(['class' => 'rounded-lg border border-gray-300 px-4 py-2 text-gray-700 active:scale-75 hover:bg-gray-100 transition duration-150 ease-in-out'])}}>
<div class="gap-2 flex items-center"> <div class="gap-2 flex items-center">
@if($icon) @if($icon)
<span class="block"> <span class="block">

View File

@ -1,19 +1,19 @@
@props(['title', 'icon', 'actions']) @props(['title', 'icon', 'actions'])
<div {{$attributes->twMerge('rounded-xl border border-gray-200 p-4 shadow-xs bg-white')}}> <div {{$attributes->twMerge('rounded-xl border border-gray-200 p-4 shadow-xs bg-white')}}>
@isset($title) @isset($title)
<div class="p-4 flex justify-between border-b border-gray-200"> <div class="p-4 flex justify-between border-b border-gray-200">
<h2 class="font-medium flex items-center gap-x-2"> <h2 class="font-medium flex items-center gap-x-2">
@isset($icon) @isset($icon)
<span class="w-5 h-5"> <span class="w-5 h-5">@svg($icon)</span>
@svg($icon) @endisset
</span> {{ __($title) }}
@endisset </h2>
{{ __($title) }} <div class="">
</h2> @isset($actions)
@isset($actions) {{$actions}}
{{$actions}} @endisset
@endisset </div>
</div> </div>
@endisset @endisset
{{$slot}} {{$slot}}
</div> </div>

View File

@ -1,10 +1,11 @@
@props(['disabled' => false, 'label' => null]) @props(['disabled' => false, 'label' => null, 'checked' => false])
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@checked($checked)
{{ $disabled ? 'disabled' : '' }} {{ $disabled ? 'disabled' : '' }}
{!! $attributes->merge(['class' => 'sr-only peer']) !!} {{ $attributes->merge(['class' => 'sr-only peer']) }}
> >
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div> <div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>

View File

@ -17,5 +17,6 @@ class extends Component {
<x-dashboard-stats /> <x-dashboard-stats />
<livewire:dashboard.connected-services /> <livewire:dashboard.connected-services />
<livewire:dashboard.roles.manager /> <livewire:dashboard.roles.manager />
<livewire:dashboard.roles.hr-permission />
</div> </div>