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 @@ + + +