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.

This commit is contained in:
Yoga Pangestu 2026-06-10 01:16:15 +07:00
parent 960bd71fd0
commit 926da48a8e
22 changed files with 1183 additions and 9 deletions

View File

@ -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',
};
}
}

View File

@ -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',
};
}
}

View File

@ -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',
};
}
}

View File

@ -0,0 +1,274 @@
<?php
namespace App\Http\Controllers\Admin\Hr;
use App\Enums\EmployeeStatus;
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Hr\EmployeeRequest;
use App\Models\Employee;
use App\Models\User;
use App\Models\UserProfile;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Inertia\Response;
class EmployeeController extends Controller
{
use ParsesDataTableQuery;
public function index(Request $request): Response
{
$tableQuery = $this->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<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\Request;
trait ParsesDataTableQuery
{
/**
* @return array{search: string, sort: string, direction: 'asc'|'desc'}
*/
protected function parseDataTableQuery(Request $request): array
{
return [
'search' => $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);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Admin\Hr;
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class EmployeeRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
protected function prepareForValidation(): void
{
$this->merge([
'base_salary' => $this->integer('base_salary'),
]);
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}

View File

@ -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);
}
}

View File

@ -29,4 +29,9 @@ public function profile(): HasOne
{
return $this->hasOne(UserProfile::class);
}
public function employee(): HasOne
{
return $this->hasOne(Employee::class);
}
}

View File

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

View File

@ -0,0 +1,35 @@
<?php
namespace App\Observers;
use App\Models\Employee;
use Illuminate\Support\Facades\DB;
class EmployeeObserver
{
public function creating(Employee $employee): void
{
if (filled($employee->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);
});
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Traits;
trait ProvidesEnumOptions
{
public static function options(): array
{
return collect(static::cases())
->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();
}
}

View File

@ -0,0 +1,195 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { RupiahInput } from '@/components/ui/rupiah-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { EmployeeFormData, EnumOption } from '@/types/employee';
const props = withDefaults(
defineProps<{
genders: EnumOption[];
employmentStatuses: EnumOption[];
initialData?: Partial<EmployeeFormData>;
submitUrl: string;
method?: 'post' | 'put';
submitLabel?: string;
employeeId?: number;
employeeName?: string | null;
}>(),
{
method: 'post',
submitLabel: 'Simpan',
},
);
const form = useForm<EmployeeFormData>({
email: props.initialData?.email ?? '',
username: props.initialData?.username ?? '',
full_name: props.initialData?.full_name ?? '',
phone_number: props.initialData?.phone_number ?? '',
gender: props.initialData?.gender ?? '',
birth_date: props.initialData?.birth_date ?? '',
address: props.initialData?.address ?? '',
join_date: props.initialData?.join_date ?? '',
employment_status: props.initialData?.employment_status ?? 'full_time',
base_salary: props.initialData?.base_salary ?? '',
});
function submit() {
const options = {
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
};
if (props.method === 'put') {
form.put(props.submitUrl, options);
} else {
form.post(props.submitUrl, options);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>
<CardHeader class="flex flex-row items-start justify-between gap-4">
<div class="space-y-1">
<CardTitle>Data Akun</CardTitle>
</div>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="email" required>Email</FieldLabel>
<Input id="email" v-model="form.email" type="email" placeholder="nama@perusahaan.com" />
<FieldError :errors="form.errors.email ? [form.errors.email] : []" />
</Field>
<Field>
<FieldLabel for="username" required>Username</FieldLabel>
<Input id="username" v-model="form.username" type="text" placeholder="username" />
<FieldError :errors="form.errors.username ? [form.errors.username] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data Pribadi</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field class="md:col-span-3">
<FieldLabel for="full_name" required>Nama Lengkap</FieldLabel>
<Input id="full_name" v-model="form.full_name" type="text"
placeholder="Nama lengkap pegawai" />
<FieldError :errors="form.errors.full_name ? [form.errors.full_name] : []" />
</Field>
<Field>
<FieldLabel for="phone_number">Nomor Telepon</FieldLabel>
<PhoneNumberInput id="phone_number" v-model="form.phone_number" />
<FieldError :errors="form.errors.phone_number ? [form.errors.phone_number] : []" />
</Field>
<Field>
<FieldLabel for="birth_date">Tanggal Lahir</FieldLabel>
<DatePicker id="birth_date" v-model="form.birth_date"
placeholder="Pilih tanggal lahir" />
<FieldError :errors="form.errors.birth_date ? [form.errors.birth_date] : []" />
</Field>
<Field>
<FieldLabel>Jenis Kelamin</FieldLabel>
<RadioGroup v-model="form.gender" class="flex flex-wrap gap-4 pt-1">
<div v-for="option in genders" :key="option.value" class="flex items-center gap-2">
<RadioGroupItem :id="`gender-${option.value}`" :value="option.value" />
<Label :for="`gender-${option.value}`" class="font-normal">
{{ option.label }}
</Label>
</div>
</RadioGroup>
<FieldError :errors="form.errors.gender ? [form.errors.gender] : []" />
</Field>
<Field class="md:col-span-3">
<FieldLabel for="address">Alamat</FieldLabel>
<Textarea id="address" v-model="form.address" placeholder="Alamat lengkap" rows="3" />
<FieldError :errors="form.errors.address ? [form.errors.address] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data Kepegawaian</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel for="join_date" required>Tanggal Bergabung</FieldLabel>
<DatePicker id="join_date" v-model="form.join_date"
placeholder="Pilih tanggal bergabung" />
<FieldError :errors="form.errors.join_date ? [form.errors.join_date] : []" />
</Field>
<Field>
<FieldLabel for="base_salary" required>Gaji Pokok</FieldLabel>
<RupiahInput id="base_salary" v-model="form.base_salary" />
<FieldError :errors="form.errors.base_salary ? [form.errors.base_salary] : []" />
</Field>
<Field>
<FieldLabel for="employment_status" required>Status Kepegawaian</FieldLabel>
<Select v-model="form.employment_status">
<SelectTrigger id="employment_status" class="w-full">
<SelectValue placeholder="Pilih status kepegawaian" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in employmentStatuses" :key="option.value"
:value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FieldError
:errors="form.errors.employment_status ? [form.errors.employment_status] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<div class="flex items-center justify-end gap-2">
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</div>
</div>
</form>
</template>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
const props = defineProps<{
employeeId: number;
employeeName?: string | null;
}>();
const open = ref(false);
const processing = ref(false);
function resetPassword() {
processing.value = true;
router.post(`/admin/hr/employees/${props.employeeId}/reset-password`, {}, {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal mereset kata sandi.');
},
onFinish: () => {
processing.value = false;
},
});
}
</script>
<template>
<AlertDialog v-model:open="open">
<AlertDialogTrigger as-child>
<Button variant="outline" type="button">
Reset Kata Sandi
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset kata sandi pegawai?</AlertDialogTitle>
<AlertDialogDescription>
Kata sandi
<span class="text-foreground font-medium">{{ employeeName ?? 'pegawai' }}</span>
akan direset ke kata sandi default sistem. Pengguna akan otomatis logout dari
semua sesi aktif.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel :disabled="processing">
Batal
</AlertDialogCancel>
<AlertDialogAction
:disabled="processing"
@click.prevent="resetPassword"
>
{{ processing ? 'Memproses...' : 'Reset Kata Sandi' }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@ -0,0 +1,146 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import DataTableActions from '@/components/hr/employees/data-table-actions.vue';
import EmployeeStatusToggle from '@/components/hr/employees/employee-status-toggle.vue';
import { Badge } from '@/components/ui/badge';
import type { EmployeeListItem } from '@/types/employee';
function employeeStatusVariant(status: string): 'default' | 'secondary' | 'destructive' {
if (status === 'active') {
return 'default';
}
if (status === 'resigned') {
return 'destructive';
}
return 'secondary';
}
function formatDate(value: string | null): string {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
export const columns: ColumnDef<EmployeeListItem>[] = [
{
accessorKey: 'employee_code',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Kode', column: 'employee_code' }),
cell: ({ row }) => h('span', { class: 'font-medium' }, row.getValue('employee_code')),
},
{
accessorKey: 'full_name',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'full_name' }),
},
{
id: 'account',
accessorKey: 'email',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Akun', column: 'email' }),
cell: ({ row }) => h('div', { class: 'space-y-0.5' }, [
h('div', { class: 'font-medium' }, row.original.email ?? '-'),
h('div', { class: 'text-muted-foreground text-xs' }, row.original.username ?? '-'),
]),
},
{
accessorKey: 'phone_number',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Telepon', column: 'phone_number' }),
cell: ({ row }) => row.original.phone_number ?? '-',
},
{
accessorKey: 'gender_label',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Jenis Kelamin', column: 'gender' }),
cell: ({ row }) => row.original.gender_label ?? '-',
},
{
accessorKey: 'birth_date',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Tanggal Lahir', column: 'birth_date' }),
cell: ({ row }) => formatDate(row.original.birth_date),
},
{
accessorKey: 'address',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Alamat', column: 'address' }),
cell: ({ row }) => h(
'span',
{ class: 'block max-w-[200px] truncate', title: row.original.address ?? undefined },
row.original.address ?? '-',
),
},
{
id: 'employment_dates',
accessorKey: 'join_date',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Masa Kepegawaian', column: 'join_date' }),
cell: ({ row }) => h('div', { class: 'space-y-0.5 text-sm' }, [
h('div', {}, [
h('span', { class: 'text-muted-foreground' }, 'Bergabung: '),
formatDate(row.original.join_date),
]),
h('div', {}, [
h('span', { class: 'text-muted-foreground' }, 'Resign: '),
row.original.resign_date ?? '-',
]),
]),
},
{
accessorKey: 'employment_status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status Kepegawaian', column: 'employment_status' }),
},
{
accessorKey: 'employee_status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status Pegawai', column: 'employee_status' }),
cell: ({ row }) => h(
Badge,
{ variant: employeeStatusVariant(row.original.employee_status) },
() => row.original.employee_status_label,
),
},
{
accessorKey: 'base_salary_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, {
title: 'Gaji Pokok',
column: 'base_salary',
class: 'justify-end',
}),
cell: ({ row }) => h(
'div',
{ class: 'text-right' },
`Rp ${row.original.base_salary_formatted}`,
),
},
{
id: 'status_toggle',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'is_active' }),
cell: ({ row }) => h(EmployeeStatusToggle, { employee: row.original }),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, { employee: row.original }),
},
];

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
}>();
const isActive = ref(props.employee.is_active);
const processing = ref(false);
watch(
() => props.employee.is_active,
(value) => {
isActive.value = value;
},
);
function toggleStatus(checked: boolean) {
const previous = isActive.value;
isActive.value = checked;
processing.value = true;
router.patch(`/admin/hr/employees/${props.employee.id}/toggle-status`, {
is_active: checked,
}, {
preserveScroll: true,
onError: () => {
isActive.value = previous;
toast.error('Gagal memperbarui status pegawai.');
},
onFinish: () => {
processing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center gap-2">
<Switch
:model-value="isActive"
:disabled="processing || employee.employee_status === 'resigned'"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>
</div>
</template>

View File

@ -11,7 +11,7 @@ const props = defineProps<{
<main
data-slot="sidebar-inset"
:class="cn(
'bg-background relative flex w-full flex-1 flex-col',
'bg-background relative flex w-full min-w-0 flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
props.class,
)"

View File

@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue"

View File

@ -30,7 +30,7 @@ const page = usePage();
<UserNav />
</div>
</header>
<div class="flex flex-1 flex-col gap-4 p-4">
<div class="flex min-w-0 flex-1 flex-col gap-4 p-4">
<slot />
</div>
</SidebarInset>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import EmployeeForm from '@/components/hr/EmployeeForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption } from '@/types/employee';
defineProps<{
genders: EnumOption[];
employmentStatuses: EnumOption[];
}>();
</script>
<template>
<Head title="Tambah Pegawai" />
<AdminLayout title="Tambah Pegawai">
<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">
Tambah Pegawai
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/hr/employees">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<EmployeeForm submit-url="/admin/hr/employees" method="post" submit-label="Simpan" :genders="genders"
:employment-statuses="employmentStatuses" />
</AdminLayout>
</template>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import EmployeeForm from '@/components/hr/EmployeeForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EmployeeListItem, EnumOption } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
genders: EnumOption[];
employmentStatuses: EnumOption[];
}>();
const initialData = computed(() => ({
email: props.employee.email ?? '',
username: props.employee.username ?? '',
full_name: props.employee.full_name ?? '',
phone_number: props.employee.phone_number ?? '',
gender: props.employee.gender ?? '',
birth_date: props.employee.birth_date ?? '',
address: props.employee.address ?? '',
join_date: props.employee.join_date ?? '',
employment_status: props.employee.employment_status,
base_salary: String(props.employee.base_salary),
}));
</script>
<template>
<Head title="Ubah Pegawai" />
<AdminLayout title="Ubah Pegawai">
<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">
Ubah Pegawai
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/hr/employees">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<EmployeeForm :submit-url="`/admin/hr/employees/${employee.id}`" method="put" submit-label="Perbarui"
:initial-data="initialData" :employee-id="employee.id" :employee-name="employee.full_name"
:genders="genders" :employment-statuses="employmentStatuses" />
</AdminLayout>
</template>

View File

@ -0,0 +1,125 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/hr/employees/columns';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import type { EnumOption, PaginatedEmployees } from '@/types/employee';
const props = defineProps<{
employees: PaginatedEmployees;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
employment_status?: string;
employee_status?: string;
};
employmentStatuses: EnumOption[];
employeeStatuses: EnumOption[];
}>();
const search = ref(props.filters.search ?? '');
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/hr/employees',
initial: { ...props.filters },
filterKeys: ['employment_status', 'employee_status'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const filterDefs = computed<DataTableFilterDef[]>(() => [
{
key: 'employment_status',
label: 'Status Kepegawaian',
type: 'select',
options: props.employmentStatuses,
},
{
key: 'employee_status',
label: 'Status Pegawai',
type: 'select',
options: props.employeeStatuses,
},
]);
const filterValues = computed(() => ({
employment_status: query.value.employment_status ?? '',
employee_status: query.value.employee_status ?? '',
}));
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.employees.current_page,
perPage: props.employees.per_page,
lastPage: props.employees.last_page,
total: props.employees.total,
}));
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Pegawai" />
<AdminLayout>
<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>
<Button as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/hr/employees/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
</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"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use Illuminate\Support\Facades\Route;
@ -17,5 +18,15 @@
Route::prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::prefix('hr')->name('hr.')->group(function () {
Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])
->name('employees.reset-password');
Route::patch('employees/{user}/toggle-status', [EmployeeController::class, 'toggleStatus'])
->name('employees.toggle-status');
Route::resource('employees', EmployeeController::class)
->except(['show'])
->parameters(['employees' => 'user']);
});
});
});