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.
This commit is contained in:
Yoga Pangestu 2026-07-30 11:26:11 +07:00
parent 5f0b62638b
commit bc810c93d2
6 changed files with 1180 additions and 12 deletions

View File

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

View File

@ -14,7 +14,7 @@ public function authorize(): bool
public function rules(): array
{
$userId = $this->route('employee')?->id;
$userId = $this->route('user')?->id;
return [
'email' => [

View File

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

View File

@ -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<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = useState<Employee | null>(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 = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status Karyawan
</label>
<Select
value={filters.employment_status ?? 'all'}
onValueChange={(value) => applyFilter('employment_status', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="full_time">Full Time</SelectItem>
<SelectItem value="part_time">Part Time</SelectItem>
<SelectItem value="contract">Kontrak</SelectItem>
<SelectItem value="internship">Magang</SelectItem>
<SelectItem value="resigned">Keluar</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status Aktif
</label>
<Select
value={filters.is_active ?? 'all'}
onValueChange={(value) => applyFilter('is_active', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="1">Aktif</SelectItem>
<SelectItem value="0">Tidak Aktif</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Jenis Kelamin
</label>
<Select
value={filters.gender ?? 'all'}
onValueChange={(value) => applyFilter('gender', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="male">Laki-laki</SelectItem>
<SelectItem value="female">Perempuan</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
);
return (
<>
<Head title="Pegawai" />
@ -70,6 +197,7 @@ export default function EmployeeIndex({ employees }: Props) {
searchKey="full_name"
searchPlaceholder="Cari pegawai..."
emptyText="Belum ada data pegawai."
toolbar={filterToolbar}
/>
<ConfirmDialog

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Admin\Finance\ExpenseController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\HR\EmployeeController;
use App\Http\Controllers\Admin\HR\LeaveRequestController;
@ -36,9 +37,9 @@
});
Route::prefix('admin/hr')->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');

File diff suppressed because it is too large Load Diff