diff --git a/app/Http/Controllers/Admin/SDM/EmployeeController.php b/app/Http/Controllers/Admin/SDM/EmployeeController.php
new file mode 100644
index 0000000..e3ce296
--- /dev/null
+++ b/app/Http/Controllers/Admin/SDM/EmployeeController.php
@@ -0,0 +1,119 @@
+ $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');
+ }
+}
diff --git a/app/Http/Requests/Admin/SDM/EmployeeRequest.php b/app/Http/Requests/Admin/SDM/EmployeeRequest.php
new file mode 100644
index 0000000..e70e150
--- /dev/null
+++ b/app/Http/Requests/Admin/SDM/EmployeeRequest.php
@@ -0,0 +1,61 @@
+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',
+ ];
+ }
+}
diff --git a/app/Services/Admin/SDM/EmployeeService.php b/app/Services/Admin/SDM/EmployeeService.php
new file mode 100644
index 0000000..dc6f619
--- /dev/null
+++ b/app/Services/Admin/SDM/EmployeeService.php
@@ -0,0 +1,104 @@
+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;
+ }
+}
diff --git a/package.json b/package.json
index 8929253..3fd6cfd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ee22c54..89adcbf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx
index e23a5b2..1ebd97d 100644
--- a/resources/js/components/app-sidebar.tsx
+++ b/resources/js/components/app-sidebar.tsx
@@ -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 },
];
diff --git a/resources/js/components/ui/alert.tsx b/resources/js/components/ui/alert.tsx
index 78c6538..f99164e 100644
--- a/resources/js/components/ui/alert.tsx
+++ b/resources/js/components/ui/alert.tsx
@@ -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">) {
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({
) {
- return (
-
- )
-}
-
-export { Alert, AlertTitle, AlertDescription, AlertAction }
+export { Alert, AlertTitle, AlertDescription }
diff --git a/resources/js/components/ui/radio-group.tsx b/resources/js/components/ui/radio-group.tsx
new file mode 100644
index 0000000..20416f0
--- /dev/null
+++ b/resources/js/components/ui/radio-group.tsx
@@ -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
) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/resources/js/components/ui/select.tsx b/resources/js/components/ui/select.tsx
index 41ecc8e..067e9d3 100644
--- a/resources/js/components/ui/select.tsx
+++ b/resources/js/components/ui/select.tsx
@@ -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) {
- return (
-
- )
+ return
}
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}
-
+
)
@@ -68,18 +59,22 @@ function SelectContent({
{children}
@@ -112,14 +107,17 @@ function SelectItem({
-
+
-
+
{children}
@@ -134,10 +132,7 @@ function SelectSeparator({
return (
)
@@ -151,13 +146,12 @@ function SelectScrollUpButton({
-
+
)
}
@@ -170,13 +164,12 @@ function SelectScrollDownButton({
-
+
)
}
diff --git a/resources/js/components/ui/switch.tsx b/resources/js/components/ui/switch.tsx
new file mode 100644
index 0000000..dd2a617
--- /dev/null
+++ b/resources/js/components/ui/switch.tsx
@@ -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 & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/resources/js/pages/admin/sdm/employee/columns.tsx b/resources/js/pages/admin/sdm/employee/columns.tsx
new file mode 100644
index 0000000..517a2fc
--- /dev/null
+++ b/resources/js/pages/admin/sdm/employee/columns.tsx
@@ -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 = {
+ 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[] {
+ const { handleEdit, handleDeleteClick, handleResetPassword, toggleActiveUrl } = params;
+
+ return [
+ {
+ id: 'no',
+ header: () => No,
+ cell: ({ row }) => (
+
+ {row.index + 1}
+
+ ),
+ meta: {
+ className: 'w-[50px] text-center',
+ headerClassName: 'w-[50px] text-center',
+ },
+ },
+ {
+ accessorKey: 'user_profile.full_name',
+ id: 'full_name',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const employee = row.original;
+
+ return (
+
+
+ {employee.user_profile?.full_name ?? '-'}
+
+
+ {employee.email}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: 'username',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.getValue('username') as string}
+
+ ),
+ },
+ {
+ accessorKey: 'user_profile.phone_number',
+ id: 'phone_number',
+ header: () => No. HP,
+ cell: ({ row }) => {
+ const employee = row.original;
+
+ return {employee.user_profile?.phone_number ?? '-'};
+ },
+ },
+ {
+ accessorKey: 'employee.employment_status',
+ id: 'employment_status',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const employee = row.original;
+
+ return (
+
+ {getEmploymentStatusLabel(employee.employee?.employment_status ?? '')}
+
+ );
+ },
+ },
+ {
+ accessorKey: 'is_active',
+ header: () => Aktif,
+ meta: {
+ className: 'w-[80px] text-center',
+ headerClassName: 'w-[80px] text-center',
+ },
+ cell: ({ row }) => {
+ const employee = row.original;
+
+ return (
+
+ {
+ router.post(toggleActiveUrl(employee.id), {}, {
+ preserveScroll: true,
+ });
+ }}
+ />
+
+ );
+ },
+ },
+ {
+ id: 'actions',
+ header: () => Aksi,
+ meta: {
+ className: 'w-[120px] text-center',
+ headerClassName: 'w-[120px] text-center',
+ },
+ cell: ({ row }) => {
+ const employee = row.original;
+
+ return (
+
+
+
+
+
+
+
+ Edit
+
+
+
+
+
+
+
+
+ Reset Kata Sandi
+
+
+
+
+
+
+
+
+ Hapus
+
+
+
+
+ );
+ },
+ },
+ ];
+}
diff --git a/resources/js/pages/admin/sdm/employee/create.tsx b/resources/js/pages/admin/sdm/employee/create.tsx
new file mode 100644
index 0000000..1fe54bf
--- /dev/null
+++ b/resources/js/pages/admin/sdm/employee/create.tsx
@@ -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(undefined);
+ const [resignDate, setResignDate] = useState(undefined);
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Informasi Kata Sandi
+
+ Kata sandi default untuk akun baru adalah Minimal8@
+
+
+
+
+
+ >
+ );
+}
+
+EmployeeCreate.layout = {
+ breadcrumbs: [
+ {
+ title: 'SDM',
+ href: employeeIndex.url(),
+ },
+ {
+ title: 'Pegawai',
+ href: employeeIndex.url(),
+ },
+ {
+ title: 'Tambah',
+ href: '#',
+ },
+ ],
+};
diff --git a/resources/js/pages/admin/sdm/employee/edit.tsx b/resources/js/pages/admin/sdm/employee/edit.tsx
new file mode 100644
index 0000000..96289ca
--- /dev/null
+++ b/resources/js/pages/admin/sdm/employee/edit.tsx
@@ -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(
+ employee.employee?.join_date ? new Date(employee.employee.join_date) : undefined
+ );
+ const [resignDate, setResignDate] = useState(
+ employee.employee?.resign_date ? new Date(employee.employee.resign_date) : undefined
+ );
+ const [birthDate, setBirthDate] = useState(
+ employee.user_profile?.birth_date ? new Date(employee.user_profile.birth_date) : undefined
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
+
+EmployeeEdit.layout = {
+ breadcrumbs: [
+ {
+ title: 'SDM',
+ href: employeeIndex.url(),
+ },
+ {
+ title: 'Pegawai',
+ href: employeeIndex.url(),
+ },
+ {
+ title: 'Edit',
+ href: '#',
+ },
+ ],
+};
diff --git a/resources/js/pages/admin/sdm/employee/index.tsx b/resources/js/pages/admin/sdm/employee/index.tsx
new file mode 100644
index 0000000..73cff90
--- /dev/null
+++ b/resources/js/pages/admin/sdm/employee/index.tsx
@@ -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(null);
+ const [resetPasswordTarget, setResetPasswordTarget] = useState(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 (
+ <>
+
+
+
+
+
+
+
+
{
+ 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}
+ />
+
+ {
+ 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}
+ />
+
+ >
+ );
+}
+
+EmployeeIndex.layout = {
+ breadcrumbs: [
+ {
+ title: 'SDM',
+ href: employeeIndex.url(),
+ },
+ {
+ title: 'Pegawai',
+ href: employeeIndex.url(),
+ },
+ ],
+};
diff --git a/routes/web.php b/routes/web.php
index ba15dc1..50c0cac 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -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');