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:
parent
a179382288
commit
887526bd18
119
app/Http/Controllers/Admin/SDM/EmployeeController.php
Normal file
119
app/Http/Controllers/Admin/SDM/EmployeeController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
61
app/Http/Requests/Admin/SDM/EmployeeRequest.php
Normal file
61
app/Http/Requests/Admin/SDM/EmployeeRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
104
app/Services/Admin/SDM/EmployeeService.php
Normal file
104
app/Services/Admin/SDM/EmployeeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
78
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
@ -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 }
|
||||
|
||||
43
resources/js/components/ui/radio-group.tsx
Normal file
43
resources/js/components/ui/radio-group.tsx
Normal 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 }
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
33
resources/js/components/ui/switch.tsx
Normal file
33
resources/js/components/ui/switch.tsx
Normal 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 }
|
||||
254
resources/js/pages/admin/sdm/employee/columns.tsx
Normal file
254
resources/js/pages/admin/sdm/employee/columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
237
resources/js/pages/admin/sdm/employee/create.tsx
Normal file
237
resources/js/pages/admin/sdm/employee/create.tsx
Normal 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: '#',
|
||||
},
|
||||
],
|
||||
};
|
||||
264
resources/js/pages/admin/sdm/employee/edit.tsx
Normal file
264
resources/js/pages/admin/sdm/employee/edit.tsx
Normal 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: '#',
|
||||
},
|
||||
],
|
||||
};
|
||||
117
resources/js/pages/admin/sdm/employee/index.tsx
Normal file
117
resources/js/pages/admin/sdm/employee/index.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user