feat: add role selection to employee create and edit forms with conditional fields

This commit is contained in:
Yoga Pangestu 2026-08-04 22:51:56 +07:00
parent 318afca796
commit 81f8135273
4 changed files with 358 additions and 195 deletions

View File

@ -10,6 +10,7 @@
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Spatie\Permission\Models\Role;
class EmployeeController extends Controller class EmployeeController extends Controller
{ {
@ -30,13 +31,15 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/hr/employee/create'); return Inertia::render('admin/hr/employee/create', [
'roles' => Role::where('name', '!=', 'Developer')->get(['id', 'name']),
]);
} }
public function store(EmployeeRequest $request): RedirectResponse public function store(EmployeeRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn() => $this->service->create($request->validated()),
'Pegawai berhasil ditambahkan.', 'Pegawai berhasil ditambahkan.',
'admin.hr.employees.index' 'admin.hr.employees.index'
); );
@ -44,17 +47,18 @@ public function store(EmployeeRequest $request): RedirectResponse
public function edit(User $user): Response public function edit(User $user): Response
{ {
$user->load(['userProfile', 'employee']); $user->load(['userProfile', 'employee', 'roles']);
return Inertia::render('admin/hr/employee/edit', [ return Inertia::render('admin/hr/employee/edit', [
'employee' => $user, 'employee' => $user,
'roles' => Role::where('name', '!=', 'Developer')->get(['id', 'name']),
]); ]);
} }
public function update(EmployeeRequest $request, User $user): RedirectResponse public function update(EmployeeRequest $request, User $user): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->update($user, $request->validated()), fn() => $this->service->update($user, $request->validated()),
'Pegawai berhasil diperbarui.', 'Pegawai berhasil diperbarui.',
'admin.hr.employees.index' 'admin.hr.employees.index'
); );
@ -63,7 +67,7 @@ public function update(EmployeeRequest $request, User $user): RedirectResponse
public function destroy(User $user): RedirectResponse public function destroy(User $user): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($user), fn() => $this->service->delete($user),
'Pegawai berhasil dihapus.', 'Pegawai berhasil dihapus.',
'admin.hr.employees.index' 'admin.hr.employees.index'
); );
@ -82,7 +86,7 @@ public function toggleActive(User $user): RedirectResponse
public function resetPassword(User $user): RedirectResponse public function resetPassword(User $user): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->resetPassword($user), fn() => $this->service->resetPassword($user),
'Kata sandi pegawai berhasil direset ke kata sandi default.', 'Kata sandi pegawai berhasil direset ke kata sandi default.',
'admin.hr.employees.index' 'admin.hr.employees.index'
); );

View File

@ -15,6 +15,7 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
$userId = $this->route('user')?->id; $userId = $this->route('user')?->id;
$isOwner = $this->input('role') === 'owner';
return [ return [
'email' => [ 'email' => [
@ -30,15 +31,30 @@ public function rules(): array
'alpha_dash', 'alpha_dash',
Rule::unique('users', 'username')->ignore($userId, 'id'), Rule::unique('users', 'username')->ignore($userId, 'id'),
], ],
'role' => ['required', 'string', Rule::exists('roles', 'name')],
'full_name' => ['required', 'string', 'max:200'], 'full_name' => ['required', 'string', 'max:200'],
'phone_number' => ['nullable', 'string', 'max:20'], 'phone_number' => ['nullable', 'string', 'max:20'],
'gender' => ['nullable', 'in:male,female'], 'gender' => ['nullable', 'in:male,female'],
'birth_date' => ['nullable', 'date'], 'birth_date' => ['nullable', 'date'],
'address' => ['nullable', 'string'], 'address' => ['nullable', 'string'],
'join_date' => ['required', 'date'], 'join_date' => [
'resign_date' => ['nullable', 'date', 'after_or_equal:join_date'], 'date',
'employment_status' => ['required', Rule::in(['full_time', 'part_time', 'contract', 'internship', 'resigned'])], Rule::requiredIf(! $isOwner),
'base_salary' => ['required', 'integer', 'min:0'], ],
'resign_date' => [
'nullable',
'date',
'after_or_equal:join_date',
],
'employment_status' => [
Rule::requiredIf(! $isOwner),
Rule::in(['full_time', 'part_time', 'contract', 'internship', 'resigned']),
],
'base_salary' => [
Rule::requiredIf(! $isOwner),
'integer',
'min:0',
],
]; ];
} }
@ -47,6 +63,7 @@ public function attributes(): array
return [ return [
'email' => 'Email', 'email' => 'Email',
'username' => 'Username', 'username' => 'Username',
'role' => 'Role',
'full_name' => 'Nama Lengkap', 'full_name' => 'Nama Lengkap',
'phone_number' => 'No. Telepon', 'phone_number' => 'No. Telepon',
'gender' => 'Jenis Kelamin', 'gender' => 'Jenis Kelamin',

View File

@ -8,6 +8,14 @@ import { RupiahInput } from '@/components/rupiah-input';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@ -21,9 +29,19 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { index as employeeIndex, store } from '@/routes/admin/hr/employees'; import { index as employeeIndex, store } from '@/routes/admin/hr/employees';
export default function EmployeeCreate() { type Role = {
id: number;
name: string;
};
type Props = {
roles: Role[];
};
export default function EmployeeCreate({ roles }: Props) {
const [joinDate, setJoinDate] = useState<Date | undefined>(undefined); const [joinDate, setJoinDate] = useState<Date | undefined>(undefined);
const [resignDate, setResignDate] = useState<Date | undefined>(undefined); const [resignDate, setResignDate] = useState<Date | undefined>(undefined);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
return ( return (
<> <>
@ -55,12 +73,17 @@ export default function EmployeeCreate() {
<Form action={store()}> <Form action={store()}>
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
<input
type="hidden"
name="role"
value={selectedRole?.name ?? ''}
/>
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Akun</CardTitle> <CardTitle>Akun</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"> <Label htmlFor="email">
Email{' '} Email{' '}
@ -94,6 +117,46 @@ export default function EmployeeCreate() {
message={errors.username} message={errors.username}
/> />
</div> </div>
<div className="grid gap-2">
<Label>
Role{' '}
<span className="text-destructive">
*
</span>
</Label>
<Combobox
items={roles}
itemToStringLabel={(r) => r.name}
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value)
}
>
<ComboboxInput
placeholder="Cari role..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada role
ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(role) => (
<ComboboxItem
key={role.id}
value={role}
>
{role.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.role}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -191,94 +254,100 @@ export default function EmployeeCreate() {
</CardContent> </CardContent>
</Card> </Card>
<Card> {selectedRole?.name !== 'owner' && (
<CardHeader> <Card>
<CardTitle>Pegawai</CardTitle> <CardHeader>
</CardHeader> <CardTitle>Pegawai</CardTitle>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> </CardHeader>
<div className="grid gap-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Label> <div className="grid gap-2">
Tanggal Masuk{' '} <Label>
<span className="text-destructive"> Tanggal Masuk{' '}
* <span className="text-destructive">
</span> *
</Label> </span>
<DatePicker </Label>
name="join_date" <DatePicker
value={joinDate} name="join_date"
onChange={setJoinDate} value={joinDate}
placeholder="Pilih tanggal masuk" onChange={setJoinDate}
/> placeholder="Pilih tanggal masuk"
<InputError />
message={errors.join_date} <InputError
/> message={errors.join_date}
</div> />
<div className="grid gap-2"> </div>
<Label>Tanggal Keluar</Label> <div className="grid gap-2">
<DatePicker <Label>Tanggal Keluar</Label>
name="resign_date" <DatePicker
value={resignDate} name="resign_date"
onChange={setResignDate} value={resignDate}
placeholder="Pilih tanggal keluar" onChange={setResignDate}
/> placeholder="Pilih tanggal keluar"
<InputError />
message={errors.resign_date} <InputError
/> message={
</div> errors.resign_date
<div className="grid gap-2"> }
<Label> />
Status Kepegawaian{' '} </div>
<span className="text-destructive"> <div className="grid gap-2">
* <Label>
</span> Status Kepegawaian{' '}
</Label> <span className="text-destructive">
<Select *
name="employment_status" </span>
defaultValue="full_time" </Label>
> <Select
<SelectTrigger className="w-full"> name="employment_status"
<SelectValue placeholder="Pilih status kepegawaian" /> defaultValue="full_time"
</SelectTrigger> >
<SelectContent> <SelectTrigger className="w-full">
<SelectItem value="full_time"> <SelectValue placeholder="Pilih status kepegawaian" />
Full Time </SelectTrigger>
</SelectItem> <SelectContent>
<SelectItem value="part_time"> <SelectItem value="full_time">
Part Time Full Time
</SelectItem> </SelectItem>
<SelectItem value="contract"> <SelectItem value="part_time">
Kontrak Part Time
</SelectItem> </SelectItem>
<SelectItem value="internship"> <SelectItem value="contract">
Magang Kontrak
</SelectItem> </SelectItem>
<SelectItem value="resigned"> <SelectItem value="internship">
Keluar Magang
</SelectItem> </SelectItem>
</SelectContent> <SelectItem value="resigned">
</Select> Keluar
<InputError </SelectItem>
message={ </SelectContent>
errors.employment_status </Select>
} <InputError
/> message={
</div> errors.employment_status
<div className="grid gap-2"> }
<Label htmlFor="base_salary"> />
Gaji Pokok{' '} </div>
<span className="text-destructive"> <div className="grid gap-2">
* <Label htmlFor="base_salary">
</span> Gaji Pokok{' '}
</Label> <span className="text-destructive">
<RupiahInput *
name="base_salary" </span>
/> </Label>
<InputError <RupiahInput
message={errors.base_salary} name="base_salary"
/> />
</div> <InputError
</CardContent> message={
</Card> errors.base_salary
}
/>
</div>
</CardContent>
</Card>
)}
</div> </div>
<div className="mt-6 flex items-center gap-4"> <div className="mt-6 flex items-center gap-4">

View File

@ -1,12 +1,20 @@
import { Form, Head } from '@inertiajs/react'; import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input'; import { PhoneNumberInput } from '@/components/phone-number-input';
import { RupiahInput } from '@/components/rupiah-input'; import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@ -20,10 +28,16 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { index as employeeIndex, update } from '@/routes/admin/hr/employees'; import { index as employeeIndex, update } from '@/routes/admin/hr/employees';
type Role = {
id: number;
name: string;
};
type EmployeeData = { type EmployeeData = {
id: number; id: number;
email: string; email: string;
username: string; username: string;
roles?: { name: string }[];
user_profile: { user_profile: {
full_name: string; full_name: string;
phone_number: string | null; phone_number: string | null;
@ -41,9 +55,16 @@ type EmployeeData = {
type Props = { type Props = {
employee: EmployeeData; employee: EmployeeData;
roles: Role[];
}; };
export default function EmployeeEdit({ employee }: Props) { export default function EmployeeEdit({ employee, roles }: Props) {
const currentRole = useMemo(
() =>
roles.find((r) => r.name === employee.roles?.[0]?.name) ?? null,
[roles, employee.roles],
);
const [joinDate, setJoinDate] = useState<Date | undefined>( const [joinDate, setJoinDate] = useState<Date | undefined>(
employee.employee?.join_date employee.employee?.join_date
? new Date(employee.employee.join_date) ? new Date(employee.employee.join_date)
@ -59,6 +80,7 @@ export default function EmployeeEdit({ employee }: Props) {
? new Date(employee.user_profile.birth_date) ? new Date(employee.user_profile.birth_date)
: undefined, : undefined,
); );
const [selectedRole, setSelectedRole] = useState<Role | null>(currentRole);
return ( return (
<> <>
@ -85,12 +107,17 @@ export default function EmployeeEdit({ employee }: Props) {
> >
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
<input
type="hidden"
name="role"
value={selectedRole?.name ?? ''}
/>
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Akun</CardTitle> <CardTitle>Akun</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"> <Label htmlFor="email">
Email{' '} Email{' '}
@ -126,6 +153,46 @@ export default function EmployeeEdit({ employee }: Props) {
message={errors.username} message={errors.username}
/> />
</div> </div>
<div className="grid gap-2">
<Label>
Role{' '}
<span className="text-destructive">
*
</span>
</Label>
<Combobox
items={roles}
itemToStringLabel={(r) => r.name}
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value)
}
>
<ComboboxInput
placeholder="Cari role..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada role
ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(role) => (
<ComboboxItem
key={role.id}
value={role}
>
{role.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.role}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -235,98 +302,104 @@ export default function EmployeeEdit({ employee }: Props) {
</CardContent> </CardContent>
</Card> </Card>
<Card> {selectedRole?.name !== 'owner' && (
<CardHeader> <Card>
<CardTitle>Pegawai</CardTitle> <CardHeader>
</CardHeader> <CardTitle>Pegawai</CardTitle>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> </CardHeader>
<div className="grid gap-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Label> <div className="grid gap-2">
Tanggal Masuk{' '} <Label>
<span className="text-destructive"> Tanggal Masuk{' '}
* <span className="text-destructive">
</span> *
</Label> </span>
<DatePicker </Label>
name="join_date" <DatePicker
value={joinDate} name="join_date"
onChange={setJoinDate} value={joinDate}
placeholder="Pilih tanggal masuk" onChange={setJoinDate}
/> placeholder="Pilih tanggal masuk"
<InputError />
message={errors.join_date} <InputError
/> message={errors.join_date}
</div> />
<div className="grid gap-2"> </div>
<Label>Tanggal Keluar</Label> <div className="grid gap-2">
<DatePicker <Label>Tanggal Keluar</Label>
name="resign_date" <DatePicker
value={resignDate} name="resign_date"
onChange={setResignDate} value={resignDate}
placeholder="Pilih tanggal keluar" onChange={setResignDate}
/> placeholder="Pilih tanggal keluar"
<InputError />
message={errors.resign_date} <InputError
/> message={
</div> errors.resign_date
<div className="grid gap-2"> }
<Label> />
Status Kepegawaian{' '} </div>
<span className="text-destructive"> <div className="grid gap-2">
* <Label>
</span> Status Kepegawaian{' '}
</Label> <span className="text-destructive">
<Select *
name="employment_status" </span>
defaultValue={ </Label>
employee.employee <Select
?.employment_status ?? name="employment_status"
'full_time' defaultValue={
} employee.employee
> ?.employment_status ??
<SelectTrigger className="w-full"> 'full_time'
<SelectValue placeholder="Pilih status kepegawaian" /> }
</SelectTrigger> >
<SelectContent> <SelectTrigger className="w-full">
<SelectItem value="full_time"> <SelectValue placeholder="Pilih status kepegawaian" />
Full Time </SelectTrigger>
</SelectItem> <SelectContent>
<SelectItem value="part_time"> <SelectItem value="full_time">
Part Time Full Time
</SelectItem> </SelectItem>
<SelectItem value="contract"> <SelectItem value="part_time">
Kontrak Part Time
</SelectItem> </SelectItem>
<SelectItem value="internship"> <SelectItem value="contract">
Magang Kontrak
</SelectItem> </SelectItem>
<SelectItem value="resigned"> <SelectItem value="internship">
Keluar Magang
</SelectItem> </SelectItem>
</SelectContent> <SelectItem value="resigned">
</Select> Keluar
<InputError </SelectItem>
message={ </SelectContent>
errors.employment_status </Select>
} <InputError
/> message={
</div> errors.employment_status
<div className="grid gap-2"> }
<Label htmlFor="base_salary"> />
Gaji Pokok{' '} </div>
<span className="text-destructive"> <div className="grid gap-2">
* <Label htmlFor="base_salary">
</span> Gaji Pokok{' '}
</Label> <span className="text-destructive">
<RupiahInput *
name="base_salary" </span>
/> </Label>
<InputError <RupiahInput
message={errors.base_salary} name="base_salary"
/> />
</div> <InputError
</CardContent> message={
</Card> errors.base_salary
}
/>
</div>
</CardContent>
</Card>
)}
</div> </div>
<div className="mt-6 flex items-center gap-4"> <div className="mt-6 flex items-center gap-4">