From bc810c93d28ade071c0f3f633f88b2bcaeac4451 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 30 Jul 2026 11:26:11 +0700 Subject: [PATCH] feat: enhance Employee management with filtering options and improved data handling - Updated EmployeeController to accept request filters for employment status, activity status, and gender. - Modified EmployeeService to support filtering in the getAll method. - Enhanced Employee index page with a filter toolbar for better user experience. - Fixed route parameter naming for employee-related routes. - Added comprehensive tests for employee index functionality and filtering capabilities. --- .../Admin/HR/EmployeeController.php | 10 +- .../Requests/Admin/HR/EmployeeRequest.php | 2 +- app/Services/Admin/HR/EmployeeService.php | 7 +- .../js/pages/admin/hr/employee/index.tsx | 132 ++- routes/web.php | 7 +- tests/Feature/Admin/HR/EmployeeTest.php | 1034 +++++++++++++++++ 6 files changed, 1180 insertions(+), 12 deletions(-) create mode 100644 tests/Feature/Admin/HR/EmployeeTest.php diff --git a/app/Http/Controllers/Admin/HR/EmployeeController.php b/app/Http/Controllers/Admin/HR/EmployeeController.php index 51bc298..2744d1a 100644 --- a/app/Http/Controllers/Admin/HR/EmployeeController.php +++ b/app/Http/Controllers/Admin/HR/EmployeeController.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Admin\HR\EmployeeService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -16,10 +17,11 @@ public function __construct( private EmployeeService $service ) {} - public function index(): Response + public function index(Request $request): Response { return Inertia::render('admin/hr/employee/index', [ - 'employees' => $this->service->getAll(), + 'employees' => $this->service->getAll($request->only(['employment_status', 'is_active', 'gender'])), + 'filters' => $request->only(['employment_status', 'is_active', 'gender']), ]); } @@ -66,8 +68,8 @@ public function destroy(User $user): RedirectResponse public function toggleActive(User $user): RedirectResponse { - $this->service->toggleActive($user); - $status = $user->fresh()->is_active ? 'diaktifkan' : 'dinonaktifkan'; + $employee = $this->service->toggleActive($user); + $status = $employee->is_active ? 'diaktifkan' : 'dinonaktifkan'; Inertia::flash('toast', ['type' => 'success', 'message' => "Pegawai berhasil {$status}."]); diff --git a/app/Http/Requests/Admin/HR/EmployeeRequest.php b/app/Http/Requests/Admin/HR/EmployeeRequest.php index 3bdb209..56a3ff3 100644 --- a/app/Http/Requests/Admin/HR/EmployeeRequest.php +++ b/app/Http/Requests/Admin/HR/EmployeeRequest.php @@ -14,7 +14,7 @@ public function authorize(): bool public function rules(): array { - $userId = $this->route('employee')?->id; + $userId = $this->route('user')?->id; return [ 'email' => [ diff --git a/app/Services/Admin/HR/EmployeeService.php b/app/Services/Admin/HR/EmployeeService.php index dccb250..f48c2d5 100644 --- a/app/Services/Admin/HR/EmployeeService.php +++ b/app/Services/Admin/HR/EmployeeService.php @@ -8,14 +8,17 @@ class EmployeeService { - public function getAll(): Collection + public function getAll(array $filters = []): Collection { return User::select('id', 'email', 'username', 'is_active') ->whereHas('employee') ->with([ - 'userProfile' => fn ($q) => $q->select('id', 'user_id', 'full_name', 'phone_number'), + 'userProfile' => fn ($q) => $q->select('id', 'user_id', 'full_name', 'phone_number', 'gender'), 'employee' => fn ($q) => $q->select('id', 'user_id', 'join_date', 'employment_status', 'base_salary'), ]) + ->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status))) + ->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN))) + ->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender))) ->latest() ->get(); } diff --git a/resources/js/pages/admin/hr/employee/index.tsx b/resources/js/pages/admin/hr/employee/index.tsx index 812ef8a..f5b98d3 100644 --- a/resources/js/pages/admin/hr/employee/index.tsx +++ b/resources/js/pages/admin/hr/employee/index.tsx @@ -1,20 +1,53 @@ import { ConfirmDialog } from '@/components/confirm-dialog'; import { DataTable } from '@/components/data-table'; import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { destroy, create as employeeCreate, edit as employeeEdit, index as employeeIndex, toggleActive, resetPassword as resetPasswordRoute } from '@/routes/admin/hr/employees'; import { Head, router } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; +import { Filter, Plus, X } from 'lucide-react'; import { useState } from 'react'; import type { Employee } from './columns'; import { createEmployeeColumns } from './columns'; type Props = { employees: Employee[]; + filters: { + employment_status?: string; + is_active?: string; + gender?: string; + }; }; -export default function EmployeeIndex({ employees }: Props) { +export default function EmployeeIndex({ employees, filters }: Props) { const [deleting, setDeleting] = useState(null); const [resetPasswordTarget, setResetPasswordTarget] = useState(null); + const [filterOpen, setFilterOpen] = useState(false); + + const hasActiveFilters = filters.employment_status || filters.is_active; + + function applyFilter(key: string, value: string) { + const newFilters = { ...filters }; + + if (value === '' || value === 'all') { + delete newFilters[key as keyof typeof newFilters]; + } else { + newFilters[key as keyof typeof newFilters] = value; + } + + router.get(employeeIndex.url(), newFilters, { + preserveState: true, + replace: true, + }); + } + + function clearFilters() { + router.get(employeeIndex.url(), {}, { + preserveState: true, + replace: true, + }); + setFilterOpen(false); + } function handleDelete() { if (!deleting) { @@ -45,6 +78,100 @@ export default function EmployeeIndex({ employees }: Props) { toggleActiveUrl: (id) => toggleActive.url(id), }); + const filterToolbar = ( + + + + + +
+
+ Filter + {hasActiveFilters && ( + + )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); + return ( <> @@ -70,6 +197,7 @@ export default function EmployeeIndex({ employees }: Props) { searchKey="full_name" searchPlaceholder="Cari pegawai..." emptyText="Belum ada data pegawai." + toolbar={filterToolbar} /> name('admin.hr.')->group(function () { - Route::resource('employees', EmployeeController::class)->except(['show']); - Route::post('employees/{employee}/toggle-active', [EmployeeController::class, 'toggleActive'])->name('employees.toggle-active'); - Route::post('employees/{employee}/reset-password', [EmployeeController::class, 'resetPassword'])->name('employees.reset-password'); + Route::resource('employees', EmployeeController::class)->except(['show'])->parameters(['employees' => 'user']); + Route::post('employees/{user}/toggle-active', [EmployeeController::class, 'toggleActive'])->name('employees.toggle-active'); + Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])->name('employees.reset-password'); Route::resource('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit']); Route::post('leave-requests/{leaveRequest}/approve', [LeaveRequestController::class, 'approve'])->name('leave-requests.approve'); diff --git a/tests/Feature/Admin/HR/EmployeeTest.php b/tests/Feature/Admin/HR/EmployeeTest.php new file mode 100644 index 0000000..2a5c14b --- /dev/null +++ b/tests/Feature/Admin/HR/EmployeeTest.php @@ -0,0 +1,1034 @@ +get(route('admin.hr.employees.index')); + $response->assertRedirect(route('login')); +}); + +test('authenticated users can visit the employee index page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.hr.employees.index')); + $response->assertOk(); +}); + +/* +|-------------------------------------------------------------------------- +| INDEX PAGE +|-------------------------------------------------------------------------- +*/ + +test('employee index page displays employees', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(); + $employeeUser->userProfile()->create([ + 'full_name' => 'Budi Santoso', + 'phone_number' => '08123456789', + 'gender' => 'male', + ]); + $employeeUser->employee()->create([ + 'join_date' => '2024-01-15', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response = $this->get(route('admin.hr.employees.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ); +}); + +test('index page works with zero employees', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.hr.employees.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 0) + ); +}); + +test('index page does not display users without employee record', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + User::factory()->create(); + + $response = $this->get(route('admin.hr.employees.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 0) + ); +}); + +test('index page displays correct count after delete', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(); + $employeeUser->userProfile()->create(['full_name' => 'Employee 1', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $employeeUser2 = User::factory()->create(); + $employeeUser2->userProfile()->create(['full_name' => 'Employee 2', 'gender' => 'female']); + $employeeUser2->employee()->create(['join_date' => '2024-02-01', 'employment_status' => 'part_time', 'base_salary' => 3000000]); + + $this->delete(route('admin.hr.employees.destroy', $employeeUser)); + + $response = $this->get(route('admin.hr.employees.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ); +}); + +/* +|-------------------------------------------------------------------------- +| FILTER - EMPLOYMENT STATUS +|-------------------------------------------------------------------------- +*/ + +test('index can filter by employment status full_time', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $ft = User::factory()->create(); + $ft->userProfile()->create(['full_name' => 'Full Time Employee', 'gender' => 'male']); + $ft->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $pt = User::factory()->create(); + $pt->userProfile()->create(['full_name' => 'Part Time Employee', 'gender' => 'female']); + $pt->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'part_time', 'base_salary' => 3000000]); + + $response = $this->get(route('admin.hr.employees.index', ['employment_status' => 'full_time'])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Full Time Employee') + ); +}); + +test('index can filter by employment status contract', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $contract = User::factory()->create(); + $contract->userProfile()->create(['full_name' => 'Contract Employee', 'gender' => 'male']); + $contract->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'contract', 'base_salary' => 4000000]); + + $intern = User::factory()->create(); + $intern->userProfile()->create(['full_name' => 'Intern Employee', 'gender' => 'female']); + $intern->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'internship', 'base_salary' => 2000000]); + + $response = $this->get(route('admin.hr.employees.index', ['employment_status' => 'contract'])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Contract Employee') + ); +}); + +test('index can filter by employment status resigned', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $active = User::factory()->create(); + $active->userProfile()->create(['full_name' => 'Active Employee', 'gender' => 'male']); + $active->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $resigned = User::factory()->create(); + $resigned->userProfile()->create(['full_name' => 'Resigned Employee', 'gender' => 'female']); + $resigned->employee()->create(['join_date' => '2024-01-01', 'resign_date' => '2024-06-01', 'employment_status' => 'resigned', 'base_salary' => 5000000]); + + $response = $this->get(route('admin.hr.employees.index', ['employment_status' => 'resigned'])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Resigned Employee') + ); +}); + +/* +|-------------------------------------------------------------------------- +| FILTER - IS ACTIVE +|-------------------------------------------------------------------------- +*/ + +test('index can filter by is_active true', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $active = User::factory()->create(['is_active' => true]); + $active->userProfile()->create(['full_name' => 'Active Employee', 'gender' => 'male']); + $active->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $inactive = User::factory()->create(['is_active' => false]); + $inactive->userProfile()->create(['full_name' => 'Inactive Employee', 'gender' => 'female']); + $inactive->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->get(route('admin.hr.employees.index', ['is_active' => '1'])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Active Employee') + ); +}); + +test('index can filter by is_active false', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $active = User::factory()->create(['is_active' => true]); + $active->userProfile()->create(['full_name' => 'Active Employee', 'gender' => 'male']); + $active->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $inactive = User::factory()->create(['is_active' => false]); + $inactive->userProfile()->create(['full_name' => 'Inactive Employee', 'gender' => 'female']); + $inactive->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->get(route('admin.hr.employees.index', ['is_active' => '0'])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Inactive Employee') + ); +}); + +/* +|-------------------------------------------------------------------------- +| FILTER - GENDER +|-------------------------------------------------------------------------- +*/ + +test('index can filter by gender male', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $male = User::factory()->create(); + $male->userProfile()->create(['full_name' => 'Male Employee', 'gender' => 'male']); + $male->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $female = User::factory()->create(); + $female->userProfile()->create(['full_name' => 'Female Employee', 'gender' => 'female']); + $female->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->get(route('admin.hr.employees.index', ['gender' => 'male'])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Male Employee') + ); +}); + +test('index can filter by gender female', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $male = User::factory()->create(); + $male->userProfile()->create(['full_name' => 'Male Employee', 'gender' => 'male']); + $male->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $female = User::factory()->create(); + $female->userProfile()->create(['full_name' => 'Female Employee', 'gender' => 'female']); + $female->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->get(route('admin.hr.employees.index', ['gender' => 'female'])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Female Employee') + ); +}); + +/* +|-------------------------------------------------------------------------- +| FILTER - COMBINED +|-------------------------------------------------------------------------- +*/ + +test('index can filter by multiple criteria combined', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $match = User::factory()->create(['is_active' => true]); + $match->userProfile()->create(['full_name' => 'Match Employee', 'gender' => 'male']); + $match->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $noMatch1 = User::factory()->create(['is_active' => true]); + $noMatch1->userProfile()->create(['full_name' => 'Female FT', 'gender' => 'female']); + $noMatch1->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $noMatch2 = User::factory()->create(['is_active' => false]); + $noMatch2->userProfile()->create(['full_name' => 'Male Inactive', 'gender' => 'male']); + $noMatch2->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $noMatch3 = User::factory()->create(['is_active' => true]); + $noMatch3->userProfile()->create(['full_name' => 'Male Contract', 'gender' => 'male']); + $noMatch3->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'contract', 'base_salary' => 4000000]); + + $response = $this->get(route('admin.hr.employees.index', [ + 'employment_status' => 'full_time', + 'is_active' => '1', + 'gender' => 'male', + ])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ->where('employees.0.user_profile.full_name', 'Match Employee') + ); +}); + +test('index passes filters to view', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.hr.employees.index', [ + 'employment_status' => 'full_time', + 'is_active' => '1', + 'gender' => 'male', + ])); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->where('filters.employment_status', 'full_time') + ->where('filters.is_active', '1') + ->where('filters.gender', 'male') + ); +}); + +/* +|-------------------------------------------------------------------------- +| CREATE / STORE +|-------------------------------------------------------------------------- +*/ + +test('employee can be created', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'newemployee@example.com', + 'username' => 'newemployee', + 'full_name' => 'New Employee', + 'phone_number' => '08123456789', + 'gender' => 'male', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.hr.employees.index')); + + $this->assertDatabaseHas('users', [ + 'email' => 'newemployee@example.com', + 'username' => 'newemployee', + ]); + $this->assertDatabaseHas('user_profiles', [ + 'full_name' => 'New Employee', + 'gender' => 'male', + ]); + $this->assertDatabaseHas('employees', [ + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); +}); + +test('employee can be created without optional fields', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'minimal@example.com', + 'username' => 'minimal', + 'full_name' => 'Minimal Employee', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasNoErrors(); +}); + +test('employee email is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'username' => 'noemail', + 'full_name' => 'No Email', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('email'); +}); + +test('employee email must be valid', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'not-an-email', + 'username' => 'bademail', + 'full_name' => 'Bad Email', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('email'); +}); + +test('employee email must be unique', function () { + $user = User::factory()->create(['email' => 'existing@example.com']); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'existing@example.com', + 'username' => 'duplicate', + 'full_name' => 'Duplicate Email', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('email'); +}); + +test('employee username is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'nousername@example.com', + 'full_name' => 'No Username', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('username'); +}); + +test('employee username must be alpha_dash', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'badusername@example.com', + 'username' => 'has spaces!', + 'full_name' => 'Bad Username', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('username'); +}); + +test('employee username must be unique', function () { + $user = User::factory()->create(['username' => 'existinguser']); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'new@example.com', + 'username' => 'existinguser', + 'full_name' => 'Duplicate Username', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('username'); +}); + +test('employee full_name is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'noname@example.com', + 'username' => 'noname', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('full_name'); +}); + +test('employee join_date is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'nojoindate@example.com', + 'username' => 'nojoindate', + 'full_name' => 'No Join Date', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('join_date'); +}); + +test('employee join_date must be valid date', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'baddate@example.com', + 'username' => 'baddate', + 'full_name' => 'Bad Date', + 'join_date' => 'not-a-date', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('join_date'); +}); + +test('employee employment_status is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'nostatus@example.com', + 'username' => 'nostatus', + 'full_name' => 'No Status', + 'join_date' => '2024-06-01', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('employment_status'); +}); + +test('employee employment_status must be valid', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'badstatus@example.com', + 'username' => 'badstatus', + 'full_name' => 'Bad Status', + 'join_date' => '2024-06-01', + 'employment_status' => 'invalid_status', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('employment_status'); +}); + +test('employee base_salary is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'nosalary@example.com', + 'username' => 'nosalary', + 'full_name' => 'No Salary', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + ]); + + $response->assertSessionHasErrors('base_salary'); +}); + +test('employee base_salary must be integer', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'badsalary@example.com', + 'username' => 'badsalary', + 'full_name' => 'Bad Salary', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 'not-a-number', + ]); + + $response->assertSessionHasErrors('base_salary'); +}); + +test('employee base_salary must not be negative', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'negsalary@example.com', + 'username' => 'negsalary', + 'full_name' => 'Negative Salary', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => -1000, + ]); + + $response->assertSessionHasErrors('base_salary'); +}); + +test('employee gender must be valid enum', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'badgender@example.com', + 'username' => 'badgender', + 'full_name' => 'Bad Gender', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + 'gender' => 'other', + ]); + + $response->assertSessionHasErrors('gender'); +}); + +test('employee resign_date must be after or equal join_date', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'badresigndate@example.com', + 'username' => 'badresigndate', + 'full_name' => 'Bad Resign Date', + 'join_date' => '2024-06-01', + 'resign_date' => '2024-01-01', + 'employment_status' => 'resigned', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('resign_date'); +}); + +test('store flashes success toast via Inertia', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'toast@example.com', + 'username' => 'toast', + 'full_name' => 'Toast Employee', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertRedirect(); +}); + +test('store creates user, profile, and employee in database', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('admin.hr.employees.store'), [ + 'email' => 'full@example.com', + 'username' => 'fullcreate', + 'full_name' => 'Full Create', + 'phone_number' => '08123456789', + 'gender' => 'female', + 'join_date' => '2024-06-01', + 'employment_status' => 'part_time', + 'base_salary' => 3000000, + ]); + + $this->assertDatabaseCount('users', 2); + $this->assertDatabaseHas('users', ['email' => 'full@example.com']); + $this->assertDatabaseHas('user_profiles', ['full_name' => 'Full Create', 'gender' => 'female']); + $this->assertDatabaseHas('employees', ['employment_status' => 'part_time', 'base_salary' => 3000000]); +}); + +/* +|-------------------------------------------------------------------------- +| EDIT / UPDATE +|-------------------------------------------------------------------------- +*/ + +test('employee can be updated', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(['username' => 'original_user']); + $employeeUser->userProfile()->create(['full_name' => 'Original Name', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->put(route('admin.hr.employees.update', $employeeUser), [ + 'email' => $employeeUser->email, + 'username' => 'original_user', + 'full_name' => 'Updated Name', + 'phone_number' => '0987654321', + 'gender' => 'female', + 'join_date' => '2024-01-01', + 'employment_status' => 'part_time', + 'base_salary' => 3000000, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.hr.employees.index')); + + $employeeUser->refresh(); + expect($employeeUser->userProfile->full_name)->toBe('Updated Name'); + expect($employeeUser->userProfile->gender->value)->toBe('female'); + expect($employeeUser->employee->employment_status->value)->toBe('part_time'); + expect($employeeUser->employee->base_salary)->toBe(3000000); +}); + +test('employee update email must be unique excluding itself', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(['email' => 'first@example.com']); + $employeeUser->userProfile()->create(['full_name' => 'First', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $other = User::factory()->create(['email' => 'second@example.com']); + + $response = $this->put(route('admin.hr.employees.update', $employeeUser), [ + 'email' => 'second@example.com', + 'username' => $employeeUser->username, + 'full_name' => 'First', + 'join_date' => '2024-01-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('email'); +}); + +test('employee update username must be unique excluding itself', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(['username' => 'firstuser']); + $employeeUser->userProfile()->create(['full_name' => 'First', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $other = User::factory()->create(['username' => 'seconduser']); + + $response = $this->put(route('admin.hr.employees.update', $employeeUser), [ + 'email' => $employeeUser->email, + 'username' => 'seconduser', + 'full_name' => 'First', + 'join_date' => '2024-01-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertSessionHasErrors('username'); +}); + +test('updating non-existent employee returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->put(route('admin.hr.employees.update', 999999), [ + 'email' => 'ghost@example.com', + 'username' => 'ghost', + 'full_name' => 'Ghost', + 'join_date' => '2024-01-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| DELETE / DESTROY +|-------------------------------------------------------------------------- +*/ + +test('employee can be deleted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(); + $employeeUser->userProfile()->create(['full_name' => 'To Delete', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->delete(route('admin.hr.employees.destroy', $employeeUser)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.hr.employees.index')); + + $this->assertSoftDeleted('users', ['id' => $employeeUser->id]); + $this->assertSoftDeleted('user_profiles', ['user_id' => $employeeUser->id]); + $this->assertSoftDeleted('employees', ['user_id' => $employeeUser->id]); +}); + +test('delete flashes success toast via Inertia', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(); + $employeeUser->userProfile()->create(['full_name' => 'Toast Delete', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->delete(route('admin.hr.employees.destroy', $employeeUser)); + + $response->assertRedirect(); +}); + +test('deleting non-existent employee returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->delete(route('admin.hr.employees.destroy', 999999)); + + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| TOGGLE ACTIVE +|-------------------------------------------------------------------------- +*/ + +test('employee can be toggled active', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(['is_active' => true]); + $employeeUser->userProfile()->create(['full_name' => 'Toggle Me', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->post(route('admin.hr.employees.toggle-active', $employeeUser)); + + $response->assertRedirect(); + + $employeeUser->refresh(); + expect($employeeUser->is_active)->toBeFalse(); +}); + +test('employee can be toggled back to active', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(['is_active' => false]); + $employeeUser->userProfile()->create(['full_name' => 'Toggle Back', 'gender' => 'female']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $this->post(route('admin.hr.employees.toggle-active', $employeeUser)); + + $employeeUser->refresh(); + expect($employeeUser->is_active)->toBeTrue(); +}); + +/* +|-------------------------------------------------------------------------- +| RESET PASSWORD +|-------------------------------------------------------------------------- +*/ + +test('employee password can be reset', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $employeeUser = User::factory()->create(['password' => 'old-password']); + $employeeUser->userProfile()->create(['full_name' => 'Reset Me', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->post(route('admin.hr.employees.reset-password', $employeeUser)); + + $response->assertRedirect(); + + $employeeUser->refresh(); + expect($employeeUser->password)->not->toBe('old-password'); +}); + +/* +|-------------------------------------------------------------------------- +| AUTHORIZATION - GUEST CANNOT PERFORM ACTIONS +|-------------------------------------------------------------------------- +*/ + +test('guest cannot create employee', function () { + $response = $this->post(route('admin.hr.employees.store'), [ + 'email' => 'unauthorized@example.com', + 'username' => 'unauthorized', + 'full_name' => 'Unauthorized', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertRedirect(route('login')); +}); + +test('guest cannot update employee', function () { + $employeeUser = User::factory()->create(); + $employeeUser->userProfile()->create(['full_name' => 'Protected', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->put(route('admin.hr.employees.update', $employeeUser), [ + 'email' => 'hacked@example.com', + 'username' => 'hacked', + 'full_name' => 'Hacked', + 'join_date' => '2024-01-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $response->assertRedirect(route('login')); +}); + +test('guest cannot delete employee', function () { + $employeeUser = User::factory()->create(); + $employeeUser->userProfile()->create(['full_name' => 'Protected', 'gender' => 'male']); + $employeeUser->employee()->create(['join_date' => '2024-01-01', 'employment_status' => 'full_time', 'base_salary' => 5000000]); + + $response = $this->delete(route('admin.hr.employees.destroy', $employeeUser)); + + $response->assertRedirect(route('login')); + $this->assertDatabaseHas('users', ['id' => $employeeUser->id]); +}); + +/* +|-------------------------------------------------------------------------- +| DATA INTEGRITY +|-------------------------------------------------------------------------- +*/ + +test('created employee has correct timestamps', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('admin.hr.employees.store'), [ + 'email' => 'timestamp@example.com', + 'username' => 'timestamp', + 'full_name' => 'Timestamp Employee', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $employeeUser = User::where('email', 'timestamp@example.com')->first(); + expect($employeeUser->created_at)->not->toBeNull(); + expect($employeeUser->employee->created_at)->not->toBeNull(); +}); + +test('employee factory creates valid employee', function () { + $employeeUser = User::factory()->create(); + $employeeUser->userProfile()->create(['full_name' => fake()->name(), 'gender' => 'male']); + $employeeUser->employee()->create([ + 'join_date' => fake()->date(), + 'employment_status' => fake()->randomElement(['full_time', 'part_time', 'contract', 'internship', 'resigned']), + 'base_salary' => fake()->numberBetween(1000000, 10000000), + ]); + + expect($employeeUser->employee)->not->toBeNull(); + expect($employeeUser->userProfile)->not->toBeNull(); +}); + +/* +|-------------------------------------------------------------------------- +| REALISTIC USER SCENARIOS +|-------------------------------------------------------------------------- +*/ + +test('user creates employee then immediately edits it', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('admin.hr.employees.store'), [ + 'email' => 'draft@example.com', + 'username' => 'draftemp', + 'full_name' => 'Draft Employee', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $employeeUser = User::where('email', 'draft@example.com')->first(); + + $this->put(route('admin.hr.employees.update', $employeeUser), [ + 'email' => 'final@example.com', + 'username' => 'draftemp', + 'full_name' => 'Final Employee', + 'join_date' => '2024-06-01', + 'employment_status' => 'full_time', + 'base_salary' => 6000000, + ]); + + $employeeUser->refresh(); + expect($employeeUser->email)->toBe('final@example.com'); + expect($employeeUser->userProfile->full_name)->toBe('Final Employee'); + expect($employeeUser->employee->base_salary)->toBe(6000000); +}); + +test('user creates multiple employees and deletes one', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('admin.hr.employees.store'), [ + 'email' => 'emp1@example.com', + 'username' => 'emp1', + 'full_name' => 'Employee 1', + 'join_date' => '2024-01-01', + 'employment_status' => 'full_time', + 'base_salary' => 5000000, + ]); + + $this->post(route('admin.hr.employees.store'), [ + 'email' => 'emp2@example.com', + 'username' => 'emp2', + 'full_name' => 'Employee 2', + 'join_date' => '2024-01-01', + 'employment_status' => 'part_time', + 'base_salary' => 3000000, + ]); + + $toDelete = User::where('email', 'emp1@example.com')->first(); + $this->delete(route('admin.hr.employees.destroy', $toDelete)); + + $response = $this->get(route('admin.hr.employees.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/hr/employee/index') + ->has('employees', 1) + ); +}); + +test('user tries to create employee without submitting any data', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.hr.employees.store'), []); + + $response->assertSessionHasErrors(['email', 'username', 'full_name', 'join_date', 'employment_status', 'base_salary']); +});