diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php new file mode 100644 index 0000000..babf5db --- /dev/null +++ b/app/Enums/Permission.php @@ -0,0 +1,50 @@ + '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 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php new file mode 100644 index 0000000..dbdc287 --- /dev/null +++ b/app/Enums/Role.php @@ -0,0 +1,109 @@ + '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 + */ + 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 + */ + 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 + */ + 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 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Http/Controllers/Admin/Hr/EmployeeController.php b/app/Http/Controllers/Admin/Hr/EmployeeController.php index c7c8784..13205bf 100644 --- a/app/Http/Controllers/Admin/Hr/EmployeeController.php +++ b/app/Http/Controllers/Admin/Hr/EmployeeController.php @@ -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, ]; } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3bbf7d8..c9e1368 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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'), diff --git a/app/Http/Requests/Admin/Hr/EmployeeRequest.php b/app/Http/Requests/Admin/Hr/EmployeeRequest.php index c271b1f..98af213 100644 --- a/app/Http/Requests/Admin/Hr/EmployeeRequest.php +++ b/app/Http/Requests/Admin/Hr/EmployeeRequest.php @@ -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())], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index c93b8dc..617ff03 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..51747c7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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; + }); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 0f46242..8c18807 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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, diff --git a/composer.json b/composer.json index 9b3f398..0b1aa34 100644 --- a/composer.json +++ b/composer.json @@ -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" -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index af88d9f..d89d0f1 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..8f1f452 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * 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', + ], +]; diff --git a/database/migrations/2026_06_09_183326_create_permission_tables.php b/database/migrations/2026_06_09_183326_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_06_09_183326_create_permission_tables.php @@ -0,0 +1,137 @@ +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']); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b9c7377..68d7198 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + RolePermissionSeeder::class, UserSeeder::class, ]); } diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..55ddd23 --- /dev/null +++ b/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,72 @@ +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(); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index e795e1c..79d6128 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -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); }); } } diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 1d11bcb..01093dc 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -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 - + HR diff --git a/resources/js/components/hr/EmployeeForm.vue b/resources/js/components/hr/EmployeeForm.vue index e4cfa11..e02f37a 100644 --- a/resources/js/components/hr/EmployeeForm.vue +++ b/resources/js/components/hr/EmployeeForm.vue @@ -31,6 +31,7 @@ const props = withDefaults( defineProps<{ genders: EnumOption[]; employmentStatuses: EnumOption[]; + roles: EnumOption[]; initialData?: Partial; submitUrl: string; method?: 'post' | 'put'; @@ -55,6 +56,7 @@ const form = useForm({ 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() { -
+
Email @@ -94,6 +96,20 @@ function submit() { + + Role + + +
diff --git a/resources/js/components/hr/employees/columns.ts b/resources/js/components/hr/employees/columns.ts index 38f82c1..c0d2dba 100644 --- a/resources/js/components/hr/employees/columns.ts +++ b/resources/js/components/hr/employees/columns.ts @@ -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[] = [ 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, diff --git a/resources/js/components/hr/employees/data-table-actions.vue b/resources/js/components/hr/employees/data-table-actions.vue index 279d20c..ac52557 100644 --- a/resources/js/components/hr/employees/data-table-actions.vue +++ b/resources/js/components/hr/employees/data-table-actions.vue @@ -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() { diff --git a/resources/js/components/hr/employees/employee-status-toggle.vue b/resources/js/components/hr/employees/employee-status-toggle.vue index 1c54505..a1b0ff0 100644 --- a/resources/js/components/hr/employees/employee-status-toggle.vue +++ b/resources/js/components/hr/employees/employee-status-toggle.vue @@ -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) { diff --git a/resources/js/pages/admin/hr/employees/Edit.vue b/resources/js/pages/admin/hr/employees/Edit.vue index 5dddc3e..c0a8797 100644 --- a/resources/js/pages/admin/hr/employees/Edit.vue +++ b/resources/js/pages/admin/hr/employees/Edit.vue @@ -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 ?? '', })); @@ -49,6 +51,9 @@ const initialData = computed(() => ({ + :genders="genders" + :employment-statuses="employmentStatuses" + :roles="roles" + /> diff --git a/resources/js/pages/admin/hr/employees/Index.vue b/resources/js/pages/admin/hr/employees/Index.vue index bbd3e65..d94f71b 100644 --- a/resources/js/pages/admin/hr/employees/Index.vue +++ b/resources/js/pages/admin/hr/employees/Index.vue @@ -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( -