feat: add employee management features including create, edit, and list functionalities

- Implemented employee creation and editing forms with validation.
- Added employee listing with actions for edit, delete, and reset password.
- Integrated toggle for employee active status.
- Created reusable UI components for forms, alerts, and data tables.
- Updated routing to include employee management endpoints.
This commit is contained in:
Yoga Pangestu 2026-07-29 22:26:24 +07:00
parent a179382288
commit 887526bd18
15 changed files with 1350 additions and 47 deletions

View File

@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Admin\SDM;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\SDM\EmployeeRequest;
use App\Models\User;
use App\Services\Admin\SDM\EmployeeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class EmployeeController extends Controller
{
public function __construct(
private EmployeeService $service
) {}
public function index(): Response
{
return Inertia::render('admin/sdm/employee/index', [
'employees' => $this->service->getAll(),
]);
}
public function create(): Response
{
return Inertia::render('admin/sdm/employee/create');
}
public function store(EmployeeRequest $request): RedirectResponse
{
try {
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pegawai berhasil ditambahkan.']);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.sdm.employees.index');
}
public function edit(User $employee): Response
{
return Inertia::render('admin/sdm/employee/edit', [
'employee' => $this->service->getById($employee->id),
]);
}
public function update(EmployeeRequest $request, User $employee): RedirectResponse
{
try {
$this->service->update($employee, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pegawai berhasil diperbarui.']);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.sdm.employees.index');
}
public function destroy(User $employee): RedirectResponse
{
try {
$this->service->delete($employee);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pegawai berhasil dihapus.']);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.sdm.employees.index');
}
public function toggleActive(User $employee): RedirectResponse
{
try {
$this->service->toggleActive($employee);
$status = $employee->fresh()->is_active ? 'diaktifkan' : 'dinonaktifkan';
Inertia::flash('toast', ['type' => 'success', 'message' => "Pegawai berhasil {$status}."]);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.sdm.employees.index');
}
public function resetPassword(User $employee): RedirectResponse
{
try {
$this->service->resetPassword($employee);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kata sandi pegawai berhasil direset ke kata sandi default.']);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
}
return to_route('admin.sdm.employees.index');
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Requests\Admin\SDM;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class EmployeeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$userId = $this->route('employee')?->id;
return [
'email' => [
'required',
'email',
'max:100',
Rule::unique('users', 'email')->ignore($userId, 'id'),
],
'username' => [
'required',
'string',
'max:20',
'alpha_dash',
Rule::unique('users', 'username')->ignore($userId, 'id'),
],
'full_name' => ['required', 'string', 'max:200'],
'phone_number' => ['nullable', 'string', 'max:20'],
'gender' => ['nullable', 'in:male,female'],
'birth_date' => ['nullable', 'date'],
'address' => ['nullable', 'string'],
'join_date' => ['required', 'date'],
'resign_date' => ['nullable', 'date', 'after_or_equal:join_date'],
'employment_status' => ['required', Rule::in(['full_time', 'part_time', 'contract', 'internship', 'resigned'])],
'base_salary' => ['required', 'integer', 'min:0'],
];
}
public function attributes(): array
{
return [
'email' => 'Email',
'username' => 'Username',
'full_name' => 'Nama Lengkap',
'phone_number' => 'Nomor HP',
'gender' => 'Jenis Kelamin',
'birth_date' => 'Tanggal Lahir',
'address' => 'Alamat',
'join_date' => 'Tanggal Masuk',
'resign_date' => 'Tanggal Keluar',
'employment_status' => 'Status Kepegawaian',
'base_salary' => 'Gaji Pokok',
];
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Services\Admin\SDM;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class EmployeeService
{
public function getAll(): Collection
{
return User::whereHas('employee')
->with(['userProfile', 'employee'])
->latest()
->get();
}
public function getById(int $id): User
{
return User::with(['userProfile', 'employee'])->findOrFail($id);
}
public function create(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'email' => $data['email'],
'username' => $data['username'],
'password' => config('auth.password_default'),
'is_active' => true,
]);
$user->userProfile()->create([
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'] ?? null,
'gender' => $data['gender'] ?? null,
'birth_date' => $data['birth_date'] ?? null,
'address' => $data['address'] ?? null,
]);
$user->employee()->create([
'join_date' => $data['join_date'],
'resign_date' => $data['resign_date'] ?? null,
'employment_status' => $data['employment_status'],
'base_salary' => $data['base_salary'],
]);
return $user;
});
}
public function update(User $user, array $data): User
{
DB::transaction(function () use ($user, $data) {
$user->update([
'email' => $data['email'],
'username' => $data['username'],
]);
$user->userProfile()->updateOrCreate([], [
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'] ?? null,
'gender' => $data['gender'] ?? null,
'birth_date' => $data['birth_date'] ?? null,
'address' => $data['address'] ?? null,
]);
$user->employee()->updateOrCreate([], [
'join_date' => $data['join_date'],
'resign_date' => $data['resign_date'] ?? null,
'employment_status' => $data['employment_status'],
'base_salary' => $data['base_salary'],
]);
});
return $user->fresh(['userProfile', 'employee']);
}
public function delete(User $user): bool
{
return DB::transaction(function () use ($user) {
$user->employee()->delete();
$user->userProfile()->delete();
return $user->delete();
});
}
public function toggleActive(User $user): User
{
$user->update(['is_active' => ! $user->is_active]);
return $user;
}
public function resetPassword(User $user): User
{
$defaultPassword = config('auth.password_default', 'Minimal8@');
$user->update(['password' => $defaultPassword]);
return $user;
}
}

View File

@ -29,6 +29,9 @@
"typescript-eslint": "^8.23.0"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.3.0",
"@inertiajs/react": "^3.0.0",
"@inertiajs/vite": "^3.0.0",
@ -47,6 +50,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-table": "^8.21.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",

78
pnpm-lock.yaml generated
View File

@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.8)
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@ -62,6 +71,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.11
version: 4.3.3(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@types/react':
specifier: ^19.2.0
version: 19.2.17
@ -340,6 +352,28 @@ packages:
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true
@ -1447,6 +1481,17 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@ -4185,6 +4230,31 @@ snapshots:
'@date-fns/tz@1.5.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.8)':
dependencies:
react: 19.2.8
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.8)
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
react: 19.2.8
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.8)':
dependencies:
react: 19.2.8
tslib: 2.8.1
'@dotenvx/dotenvx@1.75.1':
dependencies:
'@dotenvx/primitives': 0.8.0
@ -5316,6 +5386,14 @@ snapshots:
tailwindcss: 4.3.3
vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0)
'@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
'@tanstack/table-core@8.21.3': {}
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3

View File

@ -41,6 +41,7 @@ import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
import { index as employeesIndex } from '@/routes/admin/sdm/employees';
import { index as leaveRequestsIndex } from '@/routes/admin/sdm/leave-requests';
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
@ -80,7 +81,7 @@ const keuanganItems: NavMenuItem[] = [
];
const sdmItems: NavMenuItem[] = [
{ title: 'Pegawai', href: '#', icon: UserCircle },
{ title: 'Pegawai', href: employeesIndex.url(), icon: UserCircle },
{ title: 'Presensi', href: '#', icon: CalendarCheck },
{ title: 'Cuti', href: leaveRequestsIndex.url(), icon: CalendarDays },
];

View File

@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5",
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
@ -55,7 +55,7 @@ function AlertDescription({
<div
data-slot="alert-description"
className={cn(
"text-xs/relaxed text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
@ -63,14 +63,4 @@ function AlertDescription({
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-1.5 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,43 @@
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -1,10 +1,8 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
@ -13,16 +11,9 @@ function Select({
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
@ -44,14 +35,14 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-3.5 text-muted-foreground" />
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
@ -68,18 +59,22 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
@ -112,14 +107,17 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@ -134,10 +132,7 @@ function SelectSeparator({
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1 my-1 h-px bg-border/50",
className
)}
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
@ -151,13 +146,12 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon
/>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
@ -170,13 +164,12 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon
/>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}

View File

@ -0,0 +1,33 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -0,0 +1,254 @@
import type { ColumnDef } from '@tanstack/react-table';
import { router } from '@inertiajs/react';
import { ArrowUpDown, KeyRound, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
export type Employee = {
id: number;
email: string;
username: string;
is_active: boolean;
user_profile: {
full_name: string;
phone_number: string | null;
} | null;
employee: {
join_date: string;
employment_status: string;
base_salary: number;
} | null;
};
function getEmploymentStatusLabel(status: string): string {
const labels: Record<string, string> = {
full_time: 'Full Time',
part_time: 'Part Time',
contract: 'Kontrak',
internship: 'Magang',
resigned: 'Keluar',
};
return labels[status] ?? status;
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
type CreateColumnsParams = {
handleEdit: (employee: Employee) => void;
handleDeleteClick: (employee: Employee) => void;
handleResetPassword: (employee: Employee) => void;
toggleActiveUrl: (id: number) => string;
};
export function createEmployeeColumns(
params: CreateColumnsParams,
): ColumnDef<Employee>[] {
const { handleEdit, handleDeleteClick, handleResetPassword, toggleActiveUrl } = params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
),
meta: {
className: 'w-[50px] text-center',
headerClassName: 'w-[50px] text-center',
},
},
{
accessorKey: 'user_profile.full_name',
id: 'full_name',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Nama</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const employee = row.original;
return (
<div className="flex flex-col">
<span className="font-medium">
{employee.user_profile?.full_name ?? '-'}
</span>
<span className="text-xs text-muted-foreground">
{employee.email}
</span>
</div>
);
},
},
{
accessorKey: 'username',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Username</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('username') as string}
</span>
),
},
{
accessorKey: 'user_profile.phone_number',
id: 'phone_number',
header: () => <span>No. HP</span>,
cell: ({ row }) => {
const employee = row.original;
return <span>{employee.user_profile?.phone_number ?? '-'}</span>;
},
},
{
accessorKey: 'employee.employment_status',
id: 'employment_status',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Status</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const employee = row.original;
return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{getEmploymentStatusLabel(employee.employee?.employment_status ?? '')}
</span>
);
},
},
{
accessorKey: 'is_active',
header: () => <span className="block text-center">Aktif</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => {
const employee = row.original;
return (
<div className="flex justify-center">
<Switch
size="sm"
checked={employee.is_active}
onCheckedChange={() => {
router.post(toggleActiveUrl(employee.id), {}, {
preserveScroll: true,
});
}}
/>
</div>
);
},
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[120px] text-center',
headerClassName: 'w-[120px] text-center',
},
cell: ({ row }) => {
const employee = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(employee)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleResetPassword(employee)}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset Kata Sandi
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(employee)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
];
}

View File

@ -0,0 +1,237 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index as employeeIndex, store } from '@/routes/admin/sdm/employees';
import { Form, Head } from '@inertiajs/react';
import { AlertCircle, ArrowLeft } from 'lucide-react';
import { useState } from 'react';
export default function EmployeeCreate() {
const [joinDate, setJoinDate] = useState<Date | undefined>(undefined);
const [resignDate, setResignDate] = useState<Date | undefined>(undefined);
return (
<>
<Head title="Tambah Pegawai" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Tambah Pegawai
</h2>
</div>
<Button asChild variant='outline'>
<a href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Informasi Kata Sandi</AlertTitle>
<AlertDescription>
Kata sandi default untuk akun baru adalah Minimal8@
</AlertDescription>
</Alert>
<Form
action={store()}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Akun</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="Masukkan email"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-destructive">*</span>
</Label>
<Input
id="username"
name="username"
placeholder="Masukkan username"
/>
<InputError message={errors.username} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span>
</Label>
<Input
id="full_name"
name="full_name"
placeholder="Masukkan nama lengkap"
/>
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor HP
</Label>
<Input
id="phone_number"
name="phone_number"
placeholder="Masukkan nomor HP"
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label>Jenis Kelamin</Label>
<RadioGroup name="gender" className="flex gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="gender-male" />
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="gender-female" />
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>Tanggal Lahir</Label>
<DatePicker
name="birth_date"
value={undefined}
onChange={() => {}}
placeholder="Pilih tanggal lahir"
/>
<InputError message={errors.birth_date} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat
</Label>
<Textarea
id="address"
name="address"
placeholder="Masukkan alamat"
rows={3}
/>
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pegawai</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Tanggal Masuk <span className="text-destructive">*</span></Label>
<DatePicker
name="join_date"
value={joinDate}
onChange={setJoinDate}
placeholder="Pilih tanggal masuk"
/>
<InputError message={errors.join_date} />
</div>
<div className="grid gap-2">
<Label>Tanggal Keluar</Label>
<DatePicker
name="resign_date"
value={resignDate}
onChange={setResignDate}
placeholder="Pilih tanggal keluar"
/>
<InputError message={errors.resign_date} />
</div>
<div className="grid gap-2">
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
<Select name="employment_status" defaultValue="full_time">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status kepegawaian" />
</SelectTrigger>
<SelectContent>
<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>
<InputError message={errors.employment_status} />
</div>
<div className="grid gap-2">
<Label htmlFor="base_salary">
Gaji Pokok <span className="text-destructive">*</span>
</Label>
<RupiahInput name="base_salary" min={1} />
<InputError message={errors.base_salary} />
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
EmployeeCreate.layout = {
breadcrumbs: [
{
title: 'SDM',
href: employeeIndex.url(),
},
{
title: 'Pegawai',
href: employeeIndex.url(),
},
{
title: 'Tambah',
href: '#',
},
],
};

View File

@ -0,0 +1,264 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index as employeeIndex, update } from '@/routes/admin/sdm/employees';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
type EmployeeData = {
id: number;
email: string;
username: string;
user_profile: {
full_name: string;
phone_number: string | null;
gender: string | null;
birth_date: string | null;
address: string | null;
} | null;
employee: {
join_date: string;
resign_date: string | null;
employment_status: string;
base_salary: number;
} | null;
};
type Props = {
employee: EmployeeData;
};
export default function EmployeeEdit({ employee }: Props) {
const [joinDate, setJoinDate] = useState<Date | undefined>(
employee.employee?.join_date ? new Date(employee.employee.join_date) : undefined
);
const [resignDate, setResignDate] = useState<Date | undefined>(
employee.employee?.resign_date ? new Date(employee.employee.resign_date) : undefined
);
const [birthDate, setBirthDate] = useState<Date | undefined>(
employee.user_profile?.birth_date ? new Date(employee.user_profile.birth_date) : undefined
);
return (
<>
<Head title="Edit Pegawai" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Edit Pegawai
</h2>
</div>
<Button asChild variant='outline'>
<a href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={update(employee.id)}
resetOnSuccess={['password', 'password_confirmation']}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Akun</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="Masukkan email"
defaultValue={employee.email}
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-destructive">*</span>
</Label>
<Input
id="username"
name="username"
placeholder="Masukkan username"
defaultValue={employee.username}
/>
<InputError message={errors.username} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span>
</Label>
<Input
id="full_name"
name="full_name"
placeholder="Masukkan nama lengkap"
defaultValue={employee.user_profile?.full_name ?? ''}
/>
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor HP
</Label>
<Input
id="phone_number"
name="phone_number"
placeholder="Masukkan nomor HP"
defaultValue={employee.user_profile?.phone_number ?? ''}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label>Jenis Kelamin</Label>
<RadioGroup name="gender" defaultValue={employee.user_profile?.gender ?? ''} className="flex gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="gender-male" />
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="gender-female" />
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>Tanggal Lahir</Label>
<DatePicker
name="birth_date"
value={birthDate}
onChange={setBirthDate}
placeholder="Pilih tanggal lahir"
/>
<InputError message={errors.birth_date} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat
</Label>
<Textarea
id="address"
name="address"
placeholder="Masukkan alamat"
rows={3}
defaultValue={employee.user_profile?.address ?? ''}
/>
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pegawai</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Tanggal Masuk <span className="text-destructive">*</span></Label>
<DatePicker
name="join_date"
value={joinDate}
onChange={setJoinDate}
placeholder="Pilih tanggal masuk"
/>
<InputError message={errors.join_date} />
</div>
<div className="grid gap-2">
<Label>Tanggal Keluar</Label>
<DatePicker
name="resign_date"
value={resignDate}
onChange={setResignDate}
placeholder="Pilih tanggal keluar"
/>
<InputError message={errors.resign_date} />
</div>
<div className="grid gap-2">
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
<Select name="employment_status" defaultValue={employee.employee?.employment_status ?? 'full_time'}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status kepegawaian" />
</SelectTrigger>
<SelectContent>
<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>
<InputError message={errors.employment_status} />
</div>
<div className="grid gap-2">
<Label htmlFor="base_salary">
Gaji Pokok <span className="text-destructive">*</span>
</Label>
<RupiahInput name="base_salary" min={1} />
<InputError message={errors.base_salary} />
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
EmployeeEdit.layout = {
breadcrumbs: [
{
title: 'SDM',
href: employeeIndex.url(),
},
{
title: 'Pegawai',
href: employeeIndex.url(),
},
{
title: 'Edit',
href: '#',
},
],
};

View File

@ -0,0 +1,117 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { destroy, create as employeeCreate, edit as employeeEdit, index as employeeIndex, toggleActive, resetPassword as resetPasswordRoute } from '@/routes/admin/sdm/employees';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { Employee } from './columns';
import { createEmployeeColumns } from './columns';
type Props = {
employees: Employee[];
};
export default function EmployeeIndex({ employees }: Props) {
const [deleting, setDeleting] = useState<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = useState<Employee | null>(null);
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
function handleResetPassword() {
if (!resetPasswordTarget) {
return;
}
router.post(resetPasswordRoute.url(resetPasswordTarget.id), {}, {
onSuccess: () => setResetPasswordTarget(null),
});
}
const columns = createEmployeeColumns({
handleEdit: (employee) => {
window.location.href = employeeEdit.url(employee.id);
},
handleDeleteClick: (employee) => setDeleting(employee),
handleResetPassword: (employee) => setResetPasswordTarget(employee),
toggleActiveUrl: (id) => toggleActive.url(id),
});
return (
<>
<Head title="Pegawai" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Pegawai
</h2>
</div>
<Button asChild>
<a href={employeeCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<DataTable
columns={columns}
data={employees}
searchKey="full_name"
searchPlaceholder="Cari pegawai..."
emptyText="Belum ada data pegawai."
/>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Pegawai"
description={`Apakah Anda yakin ingin menghapus pegawai "${deleting?.user_profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
<ConfirmDialog
open={resetPasswordTarget !== null}
onOpenChange={(open) => {
if (!open) {
setResetPasswordTarget(null);
}
}}
title="Reset Kata Sandi"
description={`Apakah Anda yakin ingin mereset kata sandi pegawai "${resetPasswordTarget?.user_profile?.full_name}" ke kata sandi default?`}
confirmLabel="Reset"
variant="default"
onConfirm={handleResetPassword}
/>
</div>
</>
);
}
EmployeeIndex.layout = {
breadcrumbs: [
{
title: 'SDM',
href: employeeIndex.url(),
},
{
title: 'Pegawai',
href: employeeIndex.url(),
},
],
};

View File

@ -6,6 +6,7 @@
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\SDM\EmployeeController;
use App\Http\Controllers\Admin\SDM\LeaveRequestController;
use Illuminate\Support\Facades\Route;
@ -35,6 +36,10 @@
});
Route::prefix('admin/sdm')->name('admin.sdm.')->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('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit']);
Route::post('leave-requests/{leaveRequest}/approve', [LeaveRequestController::class, 'approve'])->name('leave-requests.approve');
Route::post('leave-requests/{leaveRequest}/reject', [LeaveRequestController::class, 'reject'])->name('leave-requests.reject');