feat: refactor EmployeeController and EmployeeService to utilize Role enum for role options; implement transaction management in EmployeeService methods for create, update, toggle status, and delete operations; enhance Employee model date formatting methods; update frontend components for improved UI and code clarity

This commit is contained in:
Yoga Pangestu 2026-07-04 12:35:18 +07:00
parent 786d0fdb39
commit f1c3658166
8 changed files with 177 additions and 119 deletions

View File

@ -4,6 +4,7 @@
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use App\Enums\Role;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
@ -46,7 +47,7 @@ public function index(Request $request): Response
'employment_status' => $employmentStatus,
'is_active' => $isActive,
]),
'roles' => $this->employeeService->assignableRoleOptions(),
'roles' => Role::assignableSelectOptions(),
'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(),
]);
@ -57,7 +58,7 @@ public function create(): Response
return Inertia::render('admin/hr/employees/Create', [
'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => $this->employeeService->assignableRoleOptions(),
'roles' => Role::assignableSelectOptions(),
]);
}
@ -77,7 +78,7 @@ public function edit(User $user): Response
return Inertia::render('admin/hr/employees/Edit', [
'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => $this->employeeService->assignableRoleOptions(),
'roles' => Role::assignableSelectOptions(),
'employee' => $editData['employee'],
'profilePhoto' => $editData['profilePhoto'],
]);

View File

@ -78,7 +78,7 @@ public function employmentStatusLabel(): Attribute
public function joinDateFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->join_date ? Carbon::parse($this->join_date)->translatedFormat('l, d F Y') : '-',
get: fn () => $this->join_date?->translatedFormat('l, d F Y') ?? '-',
);
}
@ -92,7 +92,7 @@ public function joinDateInput(): Attribute
public function resignDateFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->resign_date ? Carbon::parse($this->resign_date)->translatedFormat('l, d F Y') : null,
get: fn () => $this->resign_date?->translatedFormat('l, d F Y'),
);
}

View File

@ -6,6 +6,8 @@
use App\Models\Employee;
use App\Models\User;
use App\Models\UserProfile;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -17,6 +19,8 @@
class EmployeeService
{
use RunsInTransaction, SyncsPhotos;
public function __construct(
private readonly MediaService $mediaService,
) {}
@ -75,8 +79,8 @@ public function findForEdit(User $user): array
public function create(array $validated): void
{
try {
DB::transaction(function () use ($validated): void {
$this->runInTransaction(
function () use ($validated): void {
$user = User::create([
'email' => $validated['email'],
'username' => $validated['username'],
@ -104,26 +108,17 @@ public function create(array $validated): void
}
$user->syncRoles([$validated['role']]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
},
'Gagal membuat karyawan',
);
}
public function update(User $user, array $validated): void
{
$employee = $user->employee;
try {
DB::transaction(function () use ($validated, $user, $employee): void {
$this->runInTransaction(
function () use ($validated, $user, $employee): void {
$user->update([
'email' => $validated['email'],
'username' => $validated['username'],
@ -166,73 +161,65 @@ public function update(User $user, array $validated): void
}
$user->syncRoles([$validated['role']]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
},
'Gagal memperbarui karyawan',
);
}
public function toggleStatus(User $user, array $validated): void
{
$user->update([
'is_active' => $validated['is_active'],
]);
$this->runInTransaction(
function () use ($user, $validated): void {
$user->update([
'is_active' => $validated['is_active'],
]);
if (! $validated['is_active']) {
DB::table('sessions')->where('user_id', $user->id)->delete();
}
if (! $validated['is_active']) {
DB::table('sessions')->where('user_id', $user->id)->delete();
}
},
'Gagal memperbarui status karyawan',
);
}
public function resetPassword(User $user): void
{
$user->update([
'password' => config('auth.password_default'),
]);
$this->runInTransaction(
function () use ($user): void {
$user->update([
'password' => config('auth.password_default'),
]);
DB::table('sessions')->where('user_id', $user->id)->delete();
DB::table('sessions')->where('user_id', $user->id)->delete();
},
'Gagal mereset kata sandi karyawan',
);
}
public function delete(User $user): void
{
try {
DB::transaction(function () use ($user): void {
$this->runInTransaction(
function () use ($user): void {
$user->employee?->delete();
$user->profile?->delete();
$user->delete();
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
},
'Gagal menghapus karyawan',
);
}
private function syncProfilePhoto(UserProfile $profile, array $validated): void
{
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
$this->mediaService->syncCollection(
$this->syncPhotos(
$profile,
'profile_photo',
null,
$removeIds,
1,
s3Keys: $s3Keys,
[
'photos' => $validated['profile_photo'] ?? null,
'remove_media_ids' => $validated['remove_profile_photo_ids'] ?? null,
's3_keys' => ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null,
],
maxPhotos: 1,
required: false,
collection: 'profile_photo',
);
}

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
@ -9,12 +11,10 @@ import {
} from '@/composables/useDataTableQuery';
import { ActiveStatus } from '@/constants/active-status';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/hr/employees';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import type { EnumOption, PaginatedEmployees } from '@/types/employee';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { columns } from './table/columns';
import { index, create } from '@/routes/admin/hr/employees';
const props = defineProps<{
employees: PaginatedEmployees;
@ -112,37 +112,24 @@ watch(
</script>
<template>
<Head title="Pegawai" />
<AdminLayout>
<div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Pegawai</h2>
</div>
<CreateButton
v-if="can('employees.create')"
:href="create.url()"
/>
<CreateButton v-if="can('employees.create')" :href="create.url()" />
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable
v-model:search="search"
:columns="columns"
:data="employees.data"
:pagination="pagination"
:pagination-links="employees.links"
:sort="currentSort"
:filter-defs="filterDefs"
:filter-values="filterValues"
@sort-change="setSort"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>
<DataTable v-model:search="search" :columns="columns" :data="employees.data" :pagination="pagination"
:pagination-links="employees.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"
@filters-reset="resetFilters" />
</CardContent>
</Card>
</AdminLayout>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { PhoneNumberInput } from '@/components/form/phone-number-input';
import { RupiahInput } from '@/components/form/rupiah-input';
@ -131,8 +131,8 @@ function submit() {
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel for="email" required>Email</FieldLabel>
<Input id="email" v-model="form.email" type="email"
placeholder="Masukkan email" :maxlength="FIELD_LIMITS.email" />
<Input id="email" v-model="form.email" type="email" placeholder="Masukkan email"
:maxlength="FIELD_LIMITS.email" />
<FieldError :errors="formErrors(form, 'email')" />
</Field>
<Field>
@ -201,8 +201,7 @@ function submit() {
:errors="formErrors(form, 'profile_photo')" class="md:col-span-3" />
<Field class="md:col-span-3">
<FieldLabel for="address">Alamat</FieldLabel>
<Textarea id="address" v-model="form.address"
placeholder="Masukkan alamat" rows="3" />
<Textarea id="address" v-model="form.address" placeholder="Masukkan alamat" rows="3" />
<FieldError :errors="formErrors(form, 'address')" />
</Field>
</FieldSet>

View File

@ -2,6 +2,7 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import type { EmployeeListItem } from '@/types/employee';
import type { MediaItem } from '@/types/media';
@ -9,24 +10,24 @@ import DataTableActions from './data-table-actions.vue';
import EmployeeStatusToggle from './employee-status-toggle.vue';
export const columns: ColumnDef<EmployeeListItem>[] = [
{
id: 'photo',
enableSorting: false,
header: () => 'Foto',
cell: ({ row }) => {
const photoUrl = row.original.profile?.profile_photo_url;
const items: MediaItem[] = photoUrl
? [{ id: 0, url: photoUrl, thumb_url: photoUrl }]
: [];
return h(MediaThumbnailCell, { items, maxVisible: 1 });
},
},
{
accessorKey: 'profile.full_name',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'full_name' }),
cell: ({ row }) => row.original.profile?.full_name ?? '-',
cell: ({ row }) => {
const photoUrl = row.original.profile?.profile_photo_url;
const fullName = row.original.profile?.full_name ?? '-';
const photoEl = photoUrl
? h('div', { class: 'size-8 overflow-hidden rounded-full' }, [
h(MediaThumbnailCell, { items: [{ id: 0, url: photoUrl, thumb_url: photoUrl } as MediaItem], maxVisible: 1 }),
])
: h(Avatar, { class: 'size-8' }, () => [
h(AvatarFallback, () => (row.original.username ?? '').slice(0, 2).toUpperCase()),
]);
return h('div', { class: 'flex items-center gap-2' }, [photoEl, h('span', {}, fullName)]);
},
},
{
id: 'account',

View File

@ -628,13 +628,7 @@
Route::prefix('employees')->name('employees.')
->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)
->group(function () {
Route::post('{user}/reset-password', [EmployeeController::class, 'resetPassword'])
->middleware('permission:'.Permission::EMPLOYEES_RESET_PASSWORD->value)
->name('reset_password');
Route::patch('{user}/toggle-status', [EmployeeController::class, 'toggleStatus'])
->middleware('permission:'.Permission::EMPLOYEES_TOGGLE_STATUS->value)
->name('toggle_status');
Route::get('/', [EmployeeController::class, 'index'])->name('index');
Route::get('create', [EmployeeController::class, 'create'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
@ -656,7 +650,13 @@
->middleware('permission:'.Permission::EMPLOYEES_DELETE->value)
->name('destroy');
Route::get('/', [EmployeeController::class, 'index'])->name('index');
Route::post('{user}/reset-password', [EmployeeController::class, 'resetPassword'])
->middleware('permission:'.Permission::EMPLOYEES_RESET_PASSWORD->value)
->name('reset_password');
Route::patch('{user}/toggle-status', [EmployeeController::class, 'toggleStatus'])
->middleware('permission:'.Permission::EMPLOYEES_TOGGLE_STATUS->value)
->name('toggle_status');
});
Route::prefix('leave-requests')->name('leave_requests.')

View File

@ -6,11 +6,15 @@
use App\Models\UserProfile;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
Storage::fake($disk);
Storage::fake('public');
});
// ─── Helper ───────────────────────────────────────────────
@ -41,7 +45,7 @@ function createEmployeeWithProfile(): User
'gender' => fake()->randomElement(['male', 'female']),
]);
Employee::factory()->create(['user_id' => $user->id]);
$user->assignRole('marketing');
$user->assignRole('marketing-offline');
return $user;
}
@ -70,7 +74,7 @@ function validEmployeePayload(): array
'gender' => 'male',
'birth_date' => '1995-01-15',
'address' => 'Jl. Merdeka No. 1',
'role' => 'marketing',
'role' => 'marketing-offline',
'join_date' => '2024-01-01',
'employment_status' => 'full_time',
'base_salary' => 5000000,
@ -129,7 +133,7 @@ function validEmployeePayload(): array
createEmployeeWithProfile();
$this->actingAs($user)
->get(route('admin.hr.employees.index', ['role' => 'marketing']))
->get(route('admin.hr.employees.index', ['role' => 'marketing-offline']))
->assertOk();
});
@ -347,6 +351,24 @@ function validEmployeePayload(): array
expect($newUser->profile->full_name)->toBe('Pegawai Baru');
expect($newUser->employee->base_salary)->toBe(5000000);
});
test('creating employee with profile photo s3 key links the photo', function () {
$user = createEmployeeUserWithPermission(PermissionEnum::EMPLOYEES_VIEW, PermissionEnum::EMPLOYEES_CREATE);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$imageContent = \Illuminate\Http\UploadedFile::fake()->image('avatar.jpg')->get();
Storage::disk($disk)->put('fake-s3-key.jpg', $imageContent);
$payload = validEmployeePayload();
$payload['profile_s3_key'] = 'fake-s3-key.jpg';
$this->actingAs($user)
->post(route('admin.hr.employees.store'), $payload);
$newUser = User::where('email', 'pegawai@example.com')->first();
expect($newUser)->not->toBeNull();
expect($newUser->profile->getMedia('profile_photo')->count())->toBe(1);
});
});
// ─── Edit ─────────────────────────────────────────────────
@ -430,6 +452,34 @@ function validEmployeePayload(): array
->put(route('admin.hr.employees.update', $employee), $payload)
->assertRedirect(route('admin.hr.employees.index'));
});
test('updating employee can upload new photo and delete existing photo', function () {
$user = createEmployeeUserWithPermission(PermissionEnum::EMPLOYEES_VIEW, PermissionEnum::EMPLOYEES_UPDATE);
$employee = createEmployeeWithProfile();
$imageContent = \Illuminate\Http\UploadedFile::fake()->image('initial.jpg')->get();
Storage::disk('public')->put('initial.jpg', $imageContent);
$employee->profile->addMediaFromDisk('initial.jpg', 'public')->toMediaCollection('profile_photo');
expect($employee->profile->fresh()->getMedia('profile_photo')->count())->toBe(1);
$mediaId = $employee->profile->fresh()->getFirstMedia('profile_photo')->id;
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$imageContent2 = \Illuminate\Http\UploadedFile::fake()->image('updated.jpg')->get();
Storage::disk($disk)->put('updated.jpg', $imageContent2);
$payload = validEmployeePayload();
$payload['email'] = $employee->email;
$payload['username'] = $employee->username;
$payload['profile_s3_key'] = 'updated.jpg';
$payload['remove_profile_photo_ids'] = [$mediaId];
$this->actingAs($user)
->put(route('admin.hr.employees.update', $employee), $payload);
$freshProfile = $employee->fresh()->profile;
expect($freshProfile->getMedia('profile_photo')->count())->toBe(1);
expect($freshProfile->getFirstMedia('profile_photo')->id)->not->toBe($mediaId);
});
});
// ─── Toggle Status ────────────────────────────────────────
@ -565,3 +615,36 @@ function validEmployeePayload(): array
$this->assertSoftDeleted('employees', ['id' => $employeeId]);
});
});
// ─── Employee Model ───────────────────────────────────────
describe('Employee Model Attributes', function () {
test('join_date_formatted returns translated formatted date or fallback', function () {
$employee = Employee::factory()->create(['join_date' => '2024-01-15']);
expect($employee->join_date_formatted)->toBe('Senin, 15 Januari 2024');
$employeeNull = Employee::factory()->make(['join_date' => null]);
expect($employeeNull->join_date_formatted)->toBe('-');
});
test('resign_date_formatted returns translated formatted date or null', function () {
$employee = Employee::factory()->create(['resign_date' => '2024-06-20']);
expect($employee->resign_date_formatted)->toBe('Kamis, 20 Juni 2024');
$employeeNull = Employee::factory()->make(['resign_date' => null]);
expect($employeeNull->resign_date_formatted)->toBeNull();
});
test('join_date_input returns date formatted as Y-m-d', function () {
$employee = Employee::factory()->create(['join_date' => '2024-01-15']);
expect($employee->join_date_input)->toBe('2024-01-15');
});
test('base_salary_formatted returns formatted rupiah or fallback', function () {
$employee = Employee::factory()->create(['base_salary' => 5000000]);
expect($employee->base_salary_formatted)->toBe('Rp 5.000.000');
$employeeNull = Employee::factory()->make(['base_salary' => null]);
expect($employeeNull->base_salary_formatted)->toBe('-');
});
});