Add role and permission management using Spatie's Laravel Permission package. Introduce Role and Permission enums, update User model to use roles, and implement authorization checks in EmployeeController and related components. Enhance employee management forms and data tables to include role selection and display. Update migrations and seeders for roles and permissions.

This commit is contained in:
Yoga Pangestu 2026-06-10 01:51:05 +07:00
parent 184b012fd9
commit a917c76c6b
27 changed files with 930 additions and 29 deletions

50
app/Enums/Permission.php Normal file
View File

@ -0,0 +1,50 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum Permission: string
{
use ProvidesEnumOptions;
case DASHBOARD_VIEW = 'dashboard.view';
case EMPLOYEES_VIEW = 'employees.view';
case EMPLOYEES_CREATE = 'employees.create';
case EMPLOYEES_UPDATE = 'employees.update';
case EMPLOYEES_DELETE = 'employees.delete';
case EMPLOYEES_RESET_PASSWORD = 'employees.reset-password';
case EMPLOYEES_TOGGLE_STATUS = 'employees.toggle-status';
public function label(): string
{
return match ($this) {
self::DASHBOARD_VIEW => 'Lihat Dashboard',
self::EMPLOYEES_VIEW => 'Lihat Pegawai',
self::EMPLOYEES_CREATE => 'Tambah Pegawai',
self::EMPLOYEES_UPDATE => 'Ubah Pegawai',
self::EMPLOYEES_DELETE => 'Hapus Pegawai',
self::EMPLOYEES_RESET_PASSWORD => 'Reset Kata Sandi Pegawai',
self::EMPLOYEES_TOGGLE_STATUS => 'Ubah Status Pegawai',
};
}
public function group(): string
{
return match ($this) {
self::DASHBOARD_VIEW => 'Umum',
self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE,
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
};
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

109
app/Enums/Role.php Normal file
View File

@ -0,0 +1,109 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum Role: string
{
use ProvidesEnumOptions;
case DEVELOPER = 'developer';
case OWNER = 'owner';
case ADMIN_TOKO = 'admin-toko';
case ADMIN_BAHAN_BAKU = 'admin-bahan-baku';
case DIREKTUR = 'direktur';
case MARKETING = 'marketing';
case NON_OPERATOR = 'non-operator';
public function label(): string
{
return match ($this) {
self::DEVELOPER => 'Developer',
self::OWNER => 'Owner',
self::ADMIN_TOKO => 'Admin Toko',
self::ADMIN_BAHAN_BAKU => 'Admin Bahan Baku',
self::DIREKTUR => 'Direktur',
self::MARKETING => 'Marketing',
self::NON_OPERATOR => 'Non Operator',
};
}
/**
* @return list<Permission>
*/
public function permissions(): array
{
return match ($this) {
self::DEVELOPER, self::OWNER => Permission::cases(),
self::DIREKTUR => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::EMPLOYEES_CREATE,
Permission::EMPLOYEES_UPDATE,
Permission::EMPLOYEES_DELETE,
Permission::EMPLOYEES_RESET_PASSWORD,
Permission::EMPLOYEES_TOGGLE_STATUS,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::EMPLOYEES_CREATE,
Permission::EMPLOYEES_UPDATE,
Permission::EMPLOYEES_RESET_PASSWORD,
Permission::EMPLOYEES_TOGGLE_STATUS,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
],
self::NON_OPERATOR => [
Permission::DASHBOARD_VIEW,
],
};
}
public function isAssignable(): bool
{
return $this !== self::DEVELOPER;
}
/**
* @return list<array{value: string, label: string}>
*/
public static function assignableSelectOptions(): array
{
return collect(self::cases())
->filter(fn (self $role) => $role->isAssignable())
->map(fn (self $role) => [
'value' => $role->value,
'label' => $role->label(),
])
->values()
->all();
}
/**
* @return list<string>
*/
public static function assignableValues(): array
{
return collect(self::cases())
->filter(fn (self $role) => $role->isAssignable())
->map(fn (self $role) => $role->value)
->values()
->all();
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@ -4,6 +4,7 @@
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use App\Enums\Role;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Hr\EmployeeRequest;
@ -32,7 +33,7 @@ public function index(Request $request): Response
$employmentStatus = $request->string('employment_status')->toString();
$query = User::query()
->with(['profile', 'employee'])
->with(['profile', 'employee', 'roles'])
->whereHas('employee')
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($query) use ($search): void {
@ -70,6 +71,7 @@ public function create(): Response
return Inertia::render('admin/hr/employees/Create', [
'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => Role::assignableSelectOptions(),
]);
}
@ -99,6 +101,8 @@ public function store(EmployeeRequest $request): RedirectResponse
'employment_status' => $validated['employment_status'],
'base_salary' => $validated['base_salary'],
]);
$user->syncRoles([$validated['role']]);
});
Inertia::flash('success', 'Pegawai berhasil ditambahkan.');
@ -108,11 +112,12 @@ public function store(EmployeeRequest $request): RedirectResponse
public function edit(User $user): Response
{
$user->load(['profile', 'employee']);
$user->load(['profile', 'employee', 'roles']);
return Inertia::render('admin/hr/employees/Edit', [
'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => Role::assignableSelectOptions(),
'employee' => $this->transformEmployeeForForm($user),
]);
}
@ -139,6 +144,8 @@ public function update(EmployeeRequest $request, User $user): RedirectResponse
$employee->employment_status = $validated['employment_status'];
$employee->base_salary = $validated['base_salary'];
$employee->save();
$user->syncRoles([$validated['role']]);
});
Inertia::flash('success', 'Data pegawai berhasil diperbarui.');
@ -248,6 +255,7 @@ private function transformEmployeeForForm(User $user): array
'join_date' => $employee->join_date?->format('Y-m-d'),
'employment_status' => $employee->employment_status?->value,
'base_salary' => $employee->base_salary,
'role' => $user->roles->first()?->name,
];
}
@ -258,6 +266,7 @@ private function transformEmployee(User $user): array
{
$employee = $user->employee;
$profile = $user->profile;
$role = $user->roles->first();
return [
'id' => $user->id,
@ -276,6 +285,8 @@ private function transformEmployee(User $user): array
'gender_label' => $profile->gender?->label(),
'birth_date' => $profile->birth_date_formatted,
'address' => $profile->address,
'role' => $role?->name,
'role_label' => $role ? Role::from($role->name)->label() : null,
];
}
}

View File

@ -38,8 +38,16 @@ public function share(Request $request): array
return [
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user(),
'auth' => fn () => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'email' => $request->user()->email,
'username' => $request->user()->username,
'is_active' => $request->user()->is_active,
'last_login_at' => $request->user()->last_login_at,
'roles' => $request->user()->getRoleNames(),
'permissions' => $request->user()->getAllPermissions()->pluck('name'),
] : null,
],
'flash' => [
'success' => $request->session()->get('success'),

View File

@ -4,6 +4,8 @@
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use App\Enums\Permission;
use App\Enums\Role;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -11,7 +13,11 @@ class EmployeeRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
$permission = $this->isMethod('POST')
? Permission::EMPLOYEES_CREATE
: Permission::EMPLOYEES_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
@ -30,6 +36,7 @@ public function rules(): array
'join_date' => ['required', 'date'],
'employment_status' => ['required', Rule::enum(EmploymentStatus::class)],
'base_salary' => ['required', 'integer', 'min:0'],
'role' => ['required', Rule::in(Role::assignableValues())],
];
}
}

View File

@ -11,12 +11,13 @@
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
#[Guarded(['id'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
use HasFactory, Notifiable, SoftDeletes;
use HasFactory, HasRoles, Notifiable, SoftDeletes;
protected function casts(): array
{

View File

@ -2,9 +2,11 @@
namespace App\Providers;
use App\Enums\Role;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@ -24,6 +26,18 @@ public function register(): void
public function boot(): void
{
$this->configureDefaults();
$this->configureAuthorization();
}
protected function configureAuthorization(): void
{
Gate::before(function ($user, string $ability) {
if ($user->hasRole(Role::DEVELOPER->value) || $user->hasRole(Role::OWNER->value)) {
return true;
}
return null;
});
}
/**

View File

@ -6,6 +6,9 @@
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request;
use Spatie\Permission\Middleware\PermissionMiddleware;
use Spatie\Permission\Middleware\RoleMiddleware;
use Spatie\Permission\Middleware\RoleOrPermissionMiddleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@ -14,6 +17,12 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'role' => RoleMiddleware::class,
'permission' => PermissionMiddleware::class,
'role_or_permission' => RoleOrPermissionMiddleware::class,
]);
$middleware->web(append: [
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,

View File

@ -13,7 +13,8 @@
"inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14"
"laravel/wayfinder": "^0.1.14",
"spatie/laravel-permission": "^8.0"
},
"require-dev": {
"fakerphp/faker": "^1.24",
@ -103,4 +104,4 @@
}
},
"minimum-stability": "stable"
}
}

150
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "addd2ce0fdffa77be44c490a027acb70",
"content-hash": "214f5e51f5d046a46a01d066adf96929",
"packages": [
{
"name": "brick/math",
@ -3488,6 +3488,154 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git",
"reference": "d5552849801f2642aea710557463234b59ef65eb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
"reference": "d5552849801f2642aea710557463234b59ef65eb",
"shasum": ""
},
"require": {
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.5",
"orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
"pestphp/pest": "^2.1|^3.1|^4.0",
"phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
"phpunit/phpunit": "^10.5|^11.5|^12.5",
"spatie/pest-plugin-test-time": "^2.2|^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\LaravelPackageTools\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Tools for creating Laravel packages",
"homepage": "https://github.com/spatie/laravel-package-tools",
"keywords": [
"laravel-package-tools",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues",
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-05-19T14:06:37+00:00"
},
{
"name": "spatie/laravel-permission",
"version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-permission.git",
"reference": "70a6ab04108616b438e0839598f473b513281644"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/70a6ab04108616b438e0839598f473b513281644",
"reference": "70a6ab04108616b438e0839598f473b513281644",
"shasum": ""
},
"require": {
"illuminate/auth": "^12.0|^13.0",
"illuminate/container": "^12.0|^13.0",
"illuminate/contracts": "^12.0|^13.0",
"illuminate/database": "^12.0|^13.0",
"php": "^8.3",
"spatie/laravel-package-tools": "^1.0"
},
"require-dev": {
"larastan/larastan": "^3.9",
"laravel/passport": "^13.0",
"laravel/pint": "^1.0",
"orchestra/testbench": "^10.0|^11.0",
"pestphp/pest": "^3.0|^4.0",
"pestphp/pest-plugin-laravel": "^3.0|^4.1",
"phpstan/phpstan": "^2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Permission\\PermissionServiceProvider"
]
},
"branch-alias": {
"dev-main": "8.x-dev",
"dev-master": "8.x-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Spatie\\Permission\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Permission handling for Laravel 12 and up",
"homepage": "https://github.com/spatie/laravel-permission",
"keywords": [
"acl",
"laravel",
"permission",
"permissions",
"rbac",
"roles",
"security",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-permission/issues",
"source": "https://github.com/spatie/laravel-permission/tree/8.0.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-05-30T19:30:22+00:00"
},
{
"name": "symfony/clock",
"version": "v8.1.0",

219
config/permission.php Normal file
View File

@ -0,0 +1,219 @@
<?php
use Spatie\Permission\DefaultTeamResolver;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Role::class,
/*
* When using the "Teams" feature from this package, we need to know which
* Eloquent model should be used to retrieve your teams. Of course, it
* is often just the "Team" model but you may use whatever you like.
*/
'team' => null,
/*
* When using the "HasModels" trait and passing raw IDs to syncModels,
* attachModels, or detachModels, this model class will be used to
* resolve those IDs. If null, defaults to the guard's model.
*/
'default_model' => null,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttachedEvent
* \Spatie\Permission\Events\RoleDetachedEvent
* \Spatie\Permission\Events\PermissionAttachedEvent
* \Spatie\Permission\Events\PermissionDetachedEvent
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

View File

@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};

View File

@ -15,6 +15,7 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->call([
RolePermissionSeeder::class,
UserSeeder::class,
]);
}

View File

@ -0,0 +1,72 @@
<?php
namespace Database\Seeders;
use App\Enums\Permission as PermissionEnum;
use App\Enums\Role as RoleEnum;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
$registrar = app(PermissionRegistrar::class);
$registrar->forgetCachedPermissions();
$permissions = collect(PermissionEnum::cases())
->mapWithKeys(fn (PermissionEnum $permission) => [
$permission->value => Permission::findOrCreate($permission->value, 'web'),
]);
$registrar->forgetCachedPermissions();
foreach (RoleEnum::cases() as $role) {
$roleModel = Role::findOrCreate($role->value, 'web');
$roleModel->syncPermissions(
collect($role->permissions())
->map(fn (PermissionEnum $permission) => $permissions[$permission->value])
->all()
);
}
$legacyRoleMap = [
'super-admin' => RoleEnum::DEVELOPER->value,
'admin' => RoleEnum::DIREKTUR->value,
'hr' => RoleEnum::ADMIN_TOKO->value,
'user' => RoleEnum::NON_OPERATOR->value,
];
foreach ($legacyRoleMap as $oldRole => $newRole) {
$oldRoleModel = Role::query()->where('name', $oldRole)->where('guard_name', 'web')->first();
if (! $oldRoleModel) {
continue;
}
$newRoleModel = Role::findByName($newRole, 'web');
foreach ($oldRoleModel->users as $user) {
$user->removeRole($oldRole);
$user->assignRole($newRoleModel);
}
$oldRoleModel->delete();
}
Role::query()
->where('guard_name', 'web')
->whereNotIn('name', RoleEnum::values())
->each(fn (Role $role) => $role->delete());
Permission::query()
->where('guard_name', 'web')
->whereNotIn('name', PermissionEnum::values())
->each(fn (Permission $permission) => $permission->delete());
$registrar->forgetCachedPermissions();
}
}

View File

@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Enums\Gender;
use App\Enums\Role;
use App\Models\User;
use App\Models\UserProfile;
use Illuminate\Database\Seeder;
@ -27,6 +28,8 @@ public function run(): void
'phone_number' => '082121495806',
'gender' => Gender::MALE->value,
]);
$developer->assignRole(Role::DEVELOPER->value);
});
}
}

View File

@ -14,8 +14,10 @@ import {
SidebarMenuItem,
SidebarRail,
} from '@/components/ui/sidebar';
import { useCan } from '@/composables/useCan';
const page = usePage();
const { can } = useCan();
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
@ -52,7 +54,7 @@ const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employee
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroup v-if="can('employees.view')">
<SidebarGroupLabel>HR</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>

View File

@ -31,6 +31,7 @@ const props = withDefaults(
defineProps<{
genders: EnumOption[];
employmentStatuses: EnumOption[];
roles: EnumOption[];
initialData?: Partial<EmployeeFormData>;
submitUrl: string;
method?: 'post' | 'put';
@ -55,6 +56,7 @@ const form = useForm<EmployeeFormData>({
join_date: props.initialData?.join_date ?? '',
employment_status: props.initialData?.employment_status ?? 'full_time',
base_salary: props.initialData?.base_salary ?? '',
role: props.initialData?.role ?? '',
});
function submit() {
@ -83,7 +85,7 @@ function submit() {
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-2">
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel for="email" required>Email</FieldLabel>
<Input id="email" v-model="form.email" type="email" placeholder="nama@perusahaan.com" />
@ -94,6 +96,20 @@ function submit() {
<Input id="username" v-model="form.username" type="text" placeholder="username" />
<FieldError :errors="form.errors.username ? [form.errors.username] : []" />
</Field>
<Field>
<FieldLabel for="role" required>Role</FieldLabel>
<Select v-model="form.role">
<SelectTrigger id="role" class="w-full">
<SelectValue placeholder="Pilih role" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in roles" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FieldError :errors="form.errors.role ? [form.errors.role] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>

View File

@ -3,6 +3,7 @@ import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import DataTableActions from '@/components/hr/employees/data-table-actions.vue';
import EmployeeStatusToggle from '@/components/hr/employees/employee-status-toggle.vue';
import { Badge } from '@/components/ui/badge';
import type { EmployeeListItem } from '@/types/employee';
function formatDate(value: string | null): string {
@ -39,6 +40,14 @@ export const columns: ColumnDef<EmployeeListItem>[] = [
h('div', { class: 'text-muted-foreground text-xs' }, row.original.username ?? '-'),
]),
},
{
accessorKey: 'role_label',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Role', column: 'role' }),
cell: ({ row }) => row.original.role_label
? h(Badge, { variant: 'outline' }, () => row.original.role_label)
: '-',
},
{
accessorKey: 'phone_number',
enableSorting: false,

View File

@ -6,12 +6,15 @@ import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const resetPasswordConfirmOpen = ref(false);
const deleteProcessing = ref(false);
@ -53,7 +56,7 @@ function resetPassword() {
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip>
<Tooltip v-if="can('employees.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/hr/employees/${employee.id}/edit`">
@ -65,7 +68,7 @@ function resetPassword() {
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="can('employees.reset-password')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="resetPasswordConfirmOpen = true">
<KeyRound class="size-4" />
@ -75,7 +78,7 @@ function resetPassword() {
<TooltipContent>Reset Kata Sandi</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="can('employees.delete')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true">
@ -87,12 +90,18 @@ function resetPassword() {
</Tooltip>
</div>
<ConfirmDialog v-model:open="resetPasswordConfirmOpen" title="Reset kata sandi pegawai?"
<ConfirmDialog
v-if="can('employees.reset-password')"
v-model:open="resetPasswordConfirmOpen"
title="Reset kata sandi pegawai?"
:description="`Kata sandi ${employee.full_name ?? 'pegawai'} akan direset ke kata sandi default sistem. Pengguna akan otomatis logout dari semua sesi aktif.`"
confirm-label="Reset Kata Sandi" cancel-label="Batal" :loading="resetPasswordProcessing"
@confirm="resetPassword" />
<ConfirmDialog v-model:open="deleteConfirmOpen" title="Hapus pegawai?"
<ConfirmDialog
v-if="can('employees.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus pegawai?"
:description="`Data pegawai ${employee.full_name ?? ''} akan dihapus secara permanen beserta akun pengguna terkait. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyEmployee" />
</template>

View File

@ -4,12 +4,15 @@ import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
}>();
const { can } = useCan();
const isActive = ref(props.employee.is_active);
const processing = ref(false);
@ -21,6 +24,10 @@ watch(
);
function toggleStatus(checked: boolean) {
if (!can('employees.toggle-status')) {
return;
}
const previous = isActive.value;
isActive.value = checked;
processing.value = true;
@ -42,7 +49,11 @@ function toggleStatus(checked: boolean) {
<template>
<div class="flex items-center gap-2">
<Switch :model-value="isActive" :disabled="processing" @update:model-value="toggleStatus" />
<Switch
:model-value="isActive"
:disabled="processing || !can('employees.toggle-status')"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>

View File

@ -0,0 +1,29 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
export function useCan() {
const page = usePage();
const permissions = computed(() => page.props.auth.user?.permissions ?? []);
const roles = computed(() => page.props.auth.user?.roles ?? []);
function can(permission: string): boolean {
return permissions.value.includes(permission);
}
function hasRole(role: string): boolean {
return roles.value.includes(role);
}
function hasAnyRole(roleList: string[]): boolean {
return roleList.some((role) => roles.value.includes(role));
}
return {
permissions,
roles,
can,
hasRole,
hasAnyRole,
};
}

View File

@ -9,6 +9,7 @@ import type { EnumOption } from '@/types/employee';
defineProps<{
genders: EnumOption[];
employmentStatuses: EnumOption[];
roles: EnumOption[];
}>();
</script>
@ -32,7 +33,13 @@ defineProps<{
</Button>
</div>
<EmployeeForm submit-url="/admin/hr/employees" method="post" submit-label="Simpan" :genders="genders"
:employment-statuses="employmentStatuses" />
<EmployeeForm
submit-url="/admin/hr/employees"
method="post"
submit-label="Simpan"
:genders="genders"
:employment-statuses="employmentStatuses"
:roles="roles"
/>
</AdminLayout>
</template>

View File

@ -11,6 +11,7 @@ const props = defineProps<{
employee: EmployeeEditItem;
genders: EnumOption[];
employmentStatuses: EnumOption[];
roles: EnumOption[];
}>();
const initialData = computed(() => ({
@ -24,6 +25,7 @@ const initialData = computed(() => ({
join_date: props.employee.join_date ?? '',
employment_status: props.employee.employment_status ?? 'full_time',
base_salary: props.employee.base_salary != null ? String(props.employee.base_salary) : '',
role: props.employee.role ?? '',
}));
</script>
@ -49,6 +51,9 @@ const initialData = computed(() => ({
<EmployeeForm :submit-url="`/admin/hr/employees/${employee.id}`" method="put" submit-label="Perbarui"
:initial-data="initialData" :employee-id="employee.id" :employee-name="employee.full_name"
:genders="genders" :employment-statuses="employmentStatuses" />
:genders="genders"
:employment-statuses="employmentStatuses"
:roles="roles"
/>
</AdminLayout>
</template>

View File

@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/hr/employees/columns';
import { Button } from '@/components/ui/button';
import { useCan } from '@/composables/useCan';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
@ -22,6 +23,7 @@ const props = defineProps<{
employmentStatuses: EnumOption[];
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
@ -87,7 +89,7 @@ watch(
</h2>
</div>
<Button as-child class="shrink-0 self-start sm:self-center">
<Button v-if="can('employees.create')" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/hr/employees/create">
<Plus class="size-4" />
Tambah

View File

@ -4,8 +4,8 @@ export type User = {
username: string;
is_active: boolean;
last_login_at: string | null;
created_at: string;
updated_at: string;
roles: string[];
permissions: string[];
[key: string]: unknown;
};

View File

@ -15,6 +15,7 @@ export type EmployeeEditItem = {
join_date: string | null;
employment_status: string | null;
base_salary: number | null;
role: string | null;
};
export type EmployeeListItem = {
@ -34,6 +35,8 @@ export type EmployeeListItem = {
gender_label: string | null;
birth_date: string | null;
address: string | null;
role: string | null;
role_label: string | null;
};
export type EmployeeFormData = {
@ -47,6 +50,7 @@ export type EmployeeFormData = {
join_date: string;
employment_status: string;
base_salary: string;
role: string;
};
export type EmployeeFilters = {

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\Permission;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Auth\LoginController;
@ -13,20 +14,36 @@
Route::post('/auth/login', [LoginController::class, 'store']);
});
Route::middleware('auth')->group(function () {
Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () {
Route::post('/auth/logout', [LogoutController::class, 'store'])->name('logout');
Route::prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
Route::prefix('hr')->name('hr.')->group(function () {
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])
->middleware('permission:'.Permission::EMPLOYEES_RESET_PASSWORD->value)
->name('employees.reset-password');
Route::patch('employees/{user}/toggle-status', [EmployeeController::class, 'toggleStatus'])
->middleware('permission:'.Permission::EMPLOYEES_TOGGLE_STATUS->value)
->name('employees.toggle-status');
Route::resource('employees', EmployeeController::class)
->except(['show'])
->parameters(['employees' => 'user']);
Route::get('employees/create', [EmployeeController::class, 'create'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.create');
Route::post('employees', [EmployeeController::class, 'store'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.store');
Route::get('employees/{user}/edit', [EmployeeController::class, 'edit'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.edit');
Route::put('employees/{user}', [EmployeeController::class, 'update'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.update');
Route::delete('employees/{user}', [EmployeeController::class, 'destroy'])
->middleware('permission:'.Permission::EMPLOYEES_DELETE->value)
->name('employees.destroy');
Route::get('employees', [EmployeeController::class, 'index'])->name('employees.index');
});
});
});