From 926da48a8ee2215c79588471485469a42040728e Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 10 Jun 2026 01:16:15 +0700 Subject: [PATCH] Enhance employee and user profile models by adding formatted date attributes and relationships. Introduce enum labels for EmployeeStatus, EmploymentStatus, and Gender. Update routes for employee management with new HR controller actions. --- app/Enums/EmployeeStatus.php | 13 + app/Enums/EmploymentStatus.php | 14 + app/Enums/Gender.php | 12 + .../Admin/Hr/EmployeeController.php | 274 ++++++++++++++++++ .../Concerns/ParsesDataTableQuery.php | 33 +++ .../Requests/Admin/Hr/EmployeeRequest.php | 42 +++ app/Models/Employee.php | 26 +- app/Models/User.php | 5 + app/Models/UserProfile.php | 11 +- app/Observers/EmployeeObserver.php | 35 +++ app/Traits/ProvidesEnumOptions.php | 25 ++ resources/js/components/hr/EmployeeForm.vue | 195 +++++++++++++ .../js/components/hr/ResetPasswordDialog.vue | 74 +++++ .../js/components/hr/employees/columns.ts | 146 ++++++++++ .../hr/employees/employee-status-toggle.vue | 54 ++++ .../js/components/ui/sidebar/SidebarInset.vue | 2 +- resources/js/components/ui/switch/index.ts | 1 + resources/js/layouts/AdminLayout.vue | 2 +- .../js/pages/admin/hr/employees/Create.vue | 38 +++ .../js/pages/admin/hr/employees/Edit.vue | 54 ++++ .../js/pages/admin/hr/employees/Index.vue | 125 ++++++++ routes/web.php | 11 + 22 files changed, 1183 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Admin/Hr/EmployeeController.php create mode 100644 app/Http/Controllers/Concerns/ParsesDataTableQuery.php create mode 100644 app/Http/Requests/Admin/Hr/EmployeeRequest.php create mode 100644 app/Observers/EmployeeObserver.php create mode 100644 app/Traits/ProvidesEnumOptions.php create mode 100644 resources/js/components/hr/EmployeeForm.vue create mode 100644 resources/js/components/hr/ResetPasswordDialog.vue create mode 100644 resources/js/components/hr/employees/columns.ts create mode 100644 resources/js/components/hr/employees/employee-status-toggle.vue create mode 100644 resources/js/components/ui/switch/index.ts create mode 100644 resources/js/pages/admin/hr/employees/Create.vue create mode 100644 resources/js/pages/admin/hr/employees/Edit.vue create mode 100644 resources/js/pages/admin/hr/employees/Index.vue diff --git a/app/Enums/EmployeeStatus.php b/app/Enums/EmployeeStatus.php index 8b24cba..a9b7025 100644 --- a/app/Enums/EmployeeStatus.php +++ b/app/Enums/EmployeeStatus.php @@ -2,9 +2,22 @@ namespace App\Enums; +use App\Traits\ProvidesEnumOptions; + enum EmployeeStatus: string { + use ProvidesEnumOptions; + case ACTIVE = 'active'; case INACTIVE = 'inactive'; case RESIGNED = 'resigned'; + + public function label(): string + { + return match ($this) { + self::ACTIVE => 'Aktif', + self::INACTIVE => 'Tidak Aktif', + self::RESIGNED => 'Resign', + }; + } } diff --git a/app/Enums/EmploymentStatus.php b/app/Enums/EmploymentStatus.php index 8c82070..eea1b4a 100644 --- a/app/Enums/EmploymentStatus.php +++ b/app/Enums/EmploymentStatus.php @@ -2,10 +2,24 @@ namespace App\Enums; +use App\Traits\ProvidesEnumOptions; + enum EmploymentStatus: string { + use ProvidesEnumOptions; + case FULL_TIME = 'full_time'; case PART_TIME = 'part_time'; case CONTRACT = 'contract'; case TEMPORARY = 'temporary'; + + public function label(): string + { + return match ($this) { + self::FULL_TIME => 'Penuh Waktu', + self::PART_TIME => 'Paruh Waktu', + self::CONTRACT => 'Kontrak', + self::TEMPORARY => 'Sementara', + }; + } } diff --git a/app/Enums/Gender.php b/app/Enums/Gender.php index 654b442..9e253c2 100644 --- a/app/Enums/Gender.php +++ b/app/Enums/Gender.php @@ -2,8 +2,20 @@ namespace App\Enums; +use App\Traits\ProvidesEnumOptions; + enum Gender: string { + use ProvidesEnumOptions; + case MALE = 'male'; case FEMALE = 'female'; + + public function label(): string + { + return match ($this) { + self::MALE => 'Laki-laki', + self::FEMALE => 'Perempuan', + }; + } } diff --git a/app/Http/Controllers/Admin/Hr/EmployeeController.php b/app/Http/Controllers/Admin/Hr/EmployeeController.php new file mode 100644 index 0000000..bc8d4b0 --- /dev/null +++ b/app/Http/Controllers/Admin/Hr/EmployeeController.php @@ -0,0 +1,274 @@ +parseDataTableQuery($request); + $search = $tableQuery['search']; + $sort = $tableQuery['sort']; + $direction = $tableQuery['direction']; + + $employmentStatus = $request->string('employment_status')->toString(); + $employeeStatus = $request->string('employee_status')->toString(); + + $query = User::query() + ->with(['profile', 'employee']) + ->whereHas('employee') + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($query) use ($search): void { + $query->where('email', 'like', "%{$search}%") + ->orWhere('username', 'like', "%{$search}%") + ->orWhereHas('profile', function ($query) use ($search): void { + $query->where('full_name', 'like', "%{$search}%") + ->orWhere('phone_number', 'like', "%{$search}%"); + }) + ->orWhereHas('employee', function ($query) use ($search): void { + $query->where('employee_code', 'like', "%{$search}%"); + }); + }); + }) + ->when( + $employmentStatus !== '', + fn ($query) => $query->whereHas('employee', fn ($query) => $query->where('employment_status', $employmentStatus)) + ) + ->when( + $employeeStatus !== '', + fn ($query) => $query->whereHas('employee', fn ($query) => $query->where('employee_status', $employeeStatus)) + ); + + $this->applySorting($query, $sort, $direction); + + $employees = $query + ->paginate(10) + ->withQueryString() + ->through(fn (User $user) => $this->transformEmployee($user)); + + return Inertia::render('admin/hr/employees/Index', [ + 'employees' => $employees, + 'filters' => $this->dataTableFilters($tableQuery, [ + 'employment_status' => $employmentStatus, + 'employee_status' => $employeeStatus, + ]), + 'employmentStatuses' => EmploymentStatus::selectOptions(), + 'employeeStatuses' => EmployeeStatus::selectOptions(), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/hr/employees/Create', [ + 'genders' => Gender::selectOptions(), + 'employmentStatuses' => EmploymentStatus::selectOptions(), + ]); + } + + public function store(EmployeeRequest $request): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated): void { + $user = User::create([ + 'email' => $validated['email'], + 'username' => $validated['username'], + 'password' => Hash::make(config('auth.password_default')), + ]); + + UserProfile::create([ + 'user_id' => $user->id, + 'full_name' => $validated['full_name'], + 'phone_number' => $validated['phone_number'], + 'gender' => $validated['gender'], + 'birth_date' => $validated['birth_date'], + 'address' => $validated['address'], + ]); + + Employee::create([ + 'user_id' => $user->id, + 'join_date' => $validated['join_date'], + 'employment_status' => $validated['employment_status'], + 'base_salary' => $validated['base_salary'], + ]); + }); + + Inertia::flash('success', 'Pegawai berhasil ditambahkan.'); + + return redirect()->route('admin.hr.employees.index'); + } + + public function edit(User $user): Response + { + $user->load(['profile', 'employee']); + + return Inertia::render('admin/hr/employees/Edit', [ + 'genders' => Gender::selectOptions(), + 'employmentStatuses' => EmploymentStatus::selectOptions(), + 'employee' => $this->transformEmployee($user), + ]); + } + + public function update(EmployeeRequest $request, User $user): RedirectResponse + { + $validated = $request->validated(); + $employee = $user->employee; + + DB::transaction(function () use ($validated, $user, $employee): void { + $user->email = $validated['email']; + $user->username = $validated['username']; + $user->save(); + + $profile = $user->profile; + $profile->full_name = $validated['full_name']; + $profile->phone_number = $validated['phone_number']; + $profile->gender = $validated['gender']; + $profile->birth_date = $validated['birth_date']; + $profile->address = $validated['address']; + $profile->save(); + + $employee->join_date = $validated['join_date']; + $employee->employment_status = $validated['employment_status']; + $employee->base_salary = $validated['base_salary']; + $employee->save(); + }); + + Inertia::flash('success', 'Data pegawai berhasil diperbarui.'); + + return redirect()->route('admin.hr.employees.index'); + } + + public function toggleStatus(Request $request, User $user): RedirectResponse + { + $validated = $request->validate([ + 'is_active' => ['required', 'boolean'], + ]); + + $user->is_active = $validated['is_active']; + $user->save(); + + if (! $validated['is_active']) { + DB::table('sessions')->where('user_id', $user->id)->delete(); + } + + Inertia::flash('success', 'Status pegawai berhasil diperbarui.'); + + return back(); + } + + public function resetPassword(User $user): RedirectResponse + { + $user->password = config('auth.password_default'); + $user->save(); + + DB::table('sessions')->where('user_id', $user->id)->delete(); + + Inertia::flash('success', 'Kata sandi berhasil direset. Pengguna telah logout dari semua sesi.'); + + return back(); + } + + public function destroy(User $user): RedirectResponse + { + DB::transaction(function () use ($user): void { + $user->employee?->delete(); + $user->profile?->delete(); + $user->delete(); + }); + + Inertia::flash('success', 'Pegawai berhasil dihapus.'); + + return redirect()->route('admin.hr.employees.index'); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + $employeeSorts = [ + 'employee_code', + 'join_date', + 'base_salary', + 'employment_status', + 'employee_status', + ]; + + if (in_array($sort, $employeeSorts, true)) { + $query->orderBy( + Employee::select($sort) + ->whereColumn('employees.user_id', 'users.id') + ->limit(1), + $direction + ); + + return; + } + + if ($sort === 'full_name') { + $query->orderBy( + UserProfile::select('full_name') + ->whereColumn('user_profiles.user_id', 'users.id') + ->limit(1), + $direction + ); + + return; + } + + if ($sort === 'email') { + $query->orderBy('email', $direction); + + return; + } + + $query->latest(); + } + + /** + * @return array + */ + private function transformEmployee(User $user): array + { + $employee = $user->employee; + $profile = $user->profile; + + return [ + 'id' => $user->id, + 'employee_code' => $employee->employee_code, + 'join_date' => $employee->join_date_formatted, + 'resign_date' => $employee->resign_date_formatted, + 'employment_status' => $employee->employment_status?->value, + 'employment_status_label' => $employee->employment_status?->label(), + 'employee_status' => $employee->employee_status?->value, + 'employee_status_label' => $employee->employee_status?->label(), + 'base_salary' => $employee->base_salary, + 'base_salary_formatted' => $employee->base_salary_formatted, + 'email' => $user->email, + 'username' => $user->username, + 'is_active' => $user->is_active, + 'full_name' => $profile->full_name, + 'phone_number' => $profile->phone_number, + 'gender' => $profile->gender?->value, + 'gender_label' => $profile->gender?->label(), + 'birth_date' => $profile->birth_date_formatted, + 'address' => $profile->address, + ]; + } +} diff --git a/app/Http/Controllers/Concerns/ParsesDataTableQuery.php b/app/Http/Controllers/Concerns/ParsesDataTableQuery.php new file mode 100644 index 0000000..9bb10b3 --- /dev/null +++ b/app/Http/Controllers/Concerns/ParsesDataTableQuery.php @@ -0,0 +1,33 @@ + $request->string('search')->trim()->toString(), + 'sort' => $request->string('sort')->toString(), + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + ]; + } + + /** + * @param array{search: string, sort: string, direction: 'asc'|'desc'} $query + * @return array{search: string, sort: string, direction: 'asc'|'desc'|null} + */ + protected function dataTableFilters(array $query, array $extra = []): array + { + return array_merge([ + 'search' => $query['search'], + 'sort' => $query['sort'], + 'direction' => $query['sort'] !== '' ? $query['direction'] : null, + ], $extra); + } +} diff --git a/app/Http/Requests/Admin/Hr/EmployeeRequest.php b/app/Http/Requests/Admin/Hr/EmployeeRequest.php new file mode 100644 index 0000000..383092e --- /dev/null +++ b/app/Http/Requests/Admin/Hr/EmployeeRequest.php @@ -0,0 +1,42 @@ +check(); + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'base_salary' => $this->integer('base_salary'), + ]); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:100', Rule::unique('users', 'email')->ignore($this->route('user')?->id)], + 'username' => ['required', 'string', 'max:20', 'alpha_dash', Rule::unique('users', 'username')->ignore($this->route('user')?->id)], + 'full_name' => ['required', 'string', 'max:200'], + 'phone_number' => ['nullable', 'string', 'regex:/^08\d{8,11}$/'], + 'gender' => ['nullable', Rule::enum(Gender::class)], + 'birth_date' => ['nullable', 'date', 'before:today'], + 'address' => ['nullable', 'string'], + 'join_date' => ['required', 'date'], + 'employment_status' => ['required', Rule::enum(EmploymentStatus::class)], + 'base_salary' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php index 2c69867..e885d30 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -4,8 +4,11 @@ use App\Enums\EmployeeStatus; use App\Enums\EmploymentStatus; +use App\Observers\EmployeeObserver; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -14,7 +17,8 @@ use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] -#[Appends(['base_salary_formatted'])] +#[Appends(['base_salary_formatted', 'join_date_formatted', 'resign_date_formatted'])] +#[ObservedBy([EmployeeObserver::class])] class Employee extends Model { use SoftDeletes; @@ -74,12 +78,26 @@ public function temporary(Builder $query): void public function baseSalaryFormatted(): Attribute { return Attribute::make( - get: fn (int $value) => number_format($value, 0, ',', '.'), + get: fn () => number_format($this->base_salary, 0, ',', '.'), ); } - public function profile(): BelongsTo + public function joinDateFormatted(): Attribute { - return $this->belongsTo(UserProfile::class); + return Attribute::make( + get: fn () => Carbon::parse($this->join_date)->format('l, d F Y'), + ); + } + + public function resignDateFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->resign_date ? Carbon::parse($this->resign_date)->format('l, d F Y') : null, + ); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); } } diff --git a/app/Models/User.php b/app/Models/User.php index d5bbc67..66ec32e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,4 +29,9 @@ public function profile(): HasOne { return $this->hasOne(UserProfile::class); } + + public function employee(): HasOne + { + return $this->hasOne(Employee::class); + } } diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php index 6dd6f83..4955642 100644 --- a/app/Models/UserProfile.php +++ b/app/Models/UserProfile.php @@ -3,13 +3,16 @@ namespace App\Models; use App\Enums\Gender; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] +#[Appends(['birth_date_formatted'])] class UserProfile extends Model { use SoftDeletes; @@ -22,9 +25,11 @@ protected function casts(): array ]; } - public function employee(): HasOne + protected function birthDateFormatted(): Attribute { - return $this->hasOne(Employee::class); + return Attribute::make( + get: fn () => Carbon::parse($this->birth_date)->format('l, d F Y'), + ); } public function user(): BelongsTo diff --git a/app/Observers/EmployeeObserver.php b/app/Observers/EmployeeObserver.php new file mode 100644 index 0000000..5e029e6 --- /dev/null +++ b/app/Observers/EmployeeObserver.php @@ -0,0 +1,35 @@ +employee_code)) { + return; + } + + $year = now()->format('Y'); + $prefix = "EMP-{$year}-"; + + $employee->employee_code = DB::transaction(function () use ($prefix): string { + $latestCode = Employee::withTrashed() + ->where('employee_code', 'like', $prefix.'%') + ->lockForUpdate() + ->orderByDesc('employee_code') + ->value('employee_code'); + + $sequence = 1; + + if ($latestCode !== null) { + $sequence = (int) substr($latestCode, strlen($prefix)) + 1; + } + + return $prefix.str_pad((string) $sequence, 4, '0', STR_PAD_LEFT); + }); + } +} diff --git a/app/Traits/ProvidesEnumOptions.php b/app/Traits/ProvidesEnumOptions.php new file mode 100644 index 0000000..7ddc6d4 --- /dev/null +++ b/app/Traits/ProvidesEnumOptions.php @@ -0,0 +1,25 @@ +mapWithKeys(fn ($case) => [ + $case->value => $case->label(), + ]) + ->all(); + } + + public static function selectOptions(): array + { + return collect(static::cases()) + ->map(fn ($case) => [ + 'value' => $case->value, + 'label' => $case->label(), + ]) + ->all(); + } +} diff --git a/resources/js/components/hr/EmployeeForm.vue b/resources/js/components/hr/EmployeeForm.vue new file mode 100644 index 0000000..e4cfa11 --- /dev/null +++ b/resources/js/components/hr/EmployeeForm.vue @@ -0,0 +1,195 @@ + + +