feat: implement role assignment functionality for user creation and editing via combobox component

This commit is contained in:
Yoga Pangestu 2026-04-30 14:31:17 +07:00
parent 665394d511
commit 2ea62fa033
5 changed files with 391 additions and 71 deletions

View File

@ -12,6 +12,7 @@
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Spatie\Permission\Models\Role;
class UserController extends Controller class UserController extends Controller
{ {
@ -19,13 +20,16 @@ public function index(): Response
{ {
return Inertia::render('admin/master/user/index', [ return Inertia::render('admin/master/user/index', [
'users' => User::with(['profile', 'roles'])->latest()->get(), 'users' => User::with(['profile', 'roles'])->latest()->get(),
'roles' => Role::all(),
'defaultPassword' => config('auth.password_default', 'password'), 'defaultPassword' => config('auth.password_default', 'password'),
]); ]);
} }
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/master/user/create'); return Inertia::render('admin/master/user/create', [
'roles' => Role::all(),
]);
} }
public function store(UserRequest $request): RedirectResponse public function store(UserRequest $request): RedirectResponse
@ -49,6 +53,10 @@ public function store(UserRequest $request): RedirectResponse
'birth_date' => $validated['birth_date'], 'birth_date' => $validated['birth_date'],
'base_salary' => $validated['base_salary'], 'base_salary' => $validated['base_salary'],
]); ]);
if (! empty($validated['roles'])) {
$user->assignRole($validated['roles']);
}
}); });
return redirect()->route('user.index')->with('success', 'Data berhasil disimpan'); return redirect()->route('user.index')->with('success', 'Data berhasil disimpan');
@ -67,10 +75,11 @@ public function store(UserRequest $request): RedirectResponse
public function edit(User $user): Response public function edit(User $user): Response
{ {
$user->load('profile'); $user->load(['profile', 'roles']);
return Inertia::render('admin/master/user/edit', [ return Inertia::render('admin/master/user/edit', [
'user' => $user, 'user' => $user,
'roles' => Role::all(),
]); ]);
} }
@ -94,6 +103,10 @@ public function update(UserRequest $request, User $user): RedirectResponse
'birth_date' => $validated['birth_date'], 'birth_date' => $validated['birth_date'],
'base_salary' => $validated['base_salary'], 'base_salary' => $validated['base_salary'],
]); ]);
if (isset($validated['roles'])) {
$user->syncRoles($validated['roles']);
}
}); });
return redirect()->route('user.index')->with('success', 'Data berhasil diperbarui'); return redirect()->route('user.index')->with('success', 'Data berhasil diperbarui');

View File

@ -37,6 +37,7 @@ public function rules(): array
'birth_place' => ['required', 'string', 'max:100'], 'birth_place' => ['required', 'string', 'max:100'],
'birth_date' => ['required', 'date'], 'birth_date' => ['required', 'date'],
'base_salary' => ['required', 'integer', 'min:0'], 'base_salary' => ['required', 'integer', 'min:0'],
'roles' => ['nullable', 'array'],
]; ];
} }
} }

View File

@ -1,14 +1,27 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, FieldError } from "@/components/ui/field"; import { Field, FieldError } from '@/components/ui/field';
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 { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from '@/components/ui/popover';
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from '@/components/ui/combobox';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import userRoutes from '@/routes/user'; import userRoutes from '@/routes/user';
import { Head, Link, useForm } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
@ -17,7 +30,7 @@ import React from 'react';
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function UserCreate() { export default function UserCreate({ roles }: { roles: any[] }) {
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
@ -30,6 +43,7 @@ export default function UserCreate() {
birth_place: '', birth_place: '',
birth_date: '', birth_date: '',
base_salary: 0, base_salary: 0,
roles: [] as string[],
}); });
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
@ -41,16 +55,20 @@ export default function UserCreate() {
}); });
}; };
const anchor = useComboboxAnchor();
return ( return (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
<Head title="Tambah Pegawai" /> <Head title="Tambah Pegawai" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Tambah Pegawai</h1> <h1 className="text-3xl font-bold tracking-tight">
Tambah Pegawai
</h1>
</div> </div>
<Link href={userRoutes.index().url}> <Link href={userRoutes.index().url}>
<Button variant='outline' className="gap-2"> <Button variant="outline" className="gap-2">
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Kembali Kembali
</Button> </Button>
@ -58,56 +76,88 @@ export default function UserCreate() {
</div> </div>
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6"> <div className="space-y-6 lg:col-span-2">
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm"> <Card className="overflow-hidden border-none bg-card/50 shadow-lg backdrop-blur-sm">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>Informasi Profil</CardTitle> <CardTitle>Informasi Profil</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Field> <Field>
<Label htmlFor="full_name" required>Nama Lengkap</Label> <Label htmlFor="full_name" required>
Nama Lengkap
</Label>
<Input <Input
id="full_name" id="full_name"
name="full_name" name="full_name"
value={data.full_name} value={data.full_name}
onChange={e => setData('full_name', e.target.value)} onChange={(e) =>
autoComplete='off' setData(
placeholder='Contoh: John Doe' 'full_name',
e.target.value,
)
}
autoComplete="off"
placeholder="Contoh: John Doe"
/>
<FieldError
error={errors.full_name}
label="Nama Lengkap"
className="text-xs"
/> />
<FieldError error={errors.full_name} label="Nama Lengkap" className="text-xs" />
</Field> </Field>
<Field> <Field>
<Label htmlFor="nik" required>NIK</Label> <Label htmlFor="nik" required>
NIK
</Label>
<Input <Input
id="nik" id="nik"
name="nik" name="nik"
value={data.nik} value={data.nik}
onChange={e => setData('nik', e.target.value)} onChange={(e) =>
autoComplete='off' setData('nik', e.target.value)
placeholder='Contoh: 3213051307900001' }
autoComplete="off"
placeholder="Contoh: 3213051307900001"
maxLength={16} maxLength={16}
/> />
<FieldError error={errors.nik} label="NIK" className="text-xs" /> <FieldError
error={errors.nik}
label="NIK"
className="text-xs"
/>
</Field> </Field>
<Field> <Field>
<Label htmlFor="phone_number" required>Nomor Telepon</Label> <Label htmlFor="phone_number" required>
Nomor Telepon
</Label>
<Input <Input
id="phone_number" id="phone_number"
name="phone_number" name="phone_number"
value={data.phone_number} value={data.phone_number}
onChange={e => setData('phone_number', e.target.value)} onChange={(e) =>
autoComplete='off' setData(
placeholder='Contoh: 08123456789' 'phone_number',
e.target.value,
)
}
autoComplete="off"
placeholder="Contoh: 08123456789"
/>
<FieldError
error={errors.phone_number}
label="Nomor Telepon"
className="text-xs"
/> />
<FieldError error={errors.phone_number} label="Nomor Telepon" className="text-xs" />
</Field> </Field>
<Field> <Field>
<Label htmlFor="base_salary" required>Gaji Pokok</Label> <Label htmlFor="base_salary" required>
Gaji Pokok
</Label>
<NumericFormat <NumericFormat
id="base_salary" id="base_salary"
customInput={Input} customInput={Input}
@ -116,30 +166,53 @@ export default function UserCreate() {
prefix="Rp " prefix="Rp "
value={data.base_salary} value={data.base_salary}
onValueChange={(values) => { onValueChange={(values) => {
setData('base_salary', values.floatValue || 0) setData(
'base_salary',
values.floatValue || 0,
);
}} }}
placeholder="Rp 0" placeholder="Rp 0"
autoComplete='off' autoComplete="off"
/>
<FieldError
error={errors.base_salary}
label="Gaji Pokok"
className="text-xs"
/> />
<FieldError error={errors.base_salary} label="Gaji Pokok" className="text-xs" />
</Field> </Field>
<Field> <Field>
<Label htmlFor="birth_place" required>Tempat Lahir</Label> <Label htmlFor="birth_place" required>
Tempat Lahir
</Label>
<Input <Input
id="birth_place" id="birth_place"
name="birth_place" name="birth_place"
value={data.birth_place} value={data.birth_place}
onChange={e => setData('birth_place', e.target.value)} onChange={(e) =>
autoComplete='off' setData(
placeholder='Contoh: Jakarta' 'birth_place',
e.target.value,
)
}
autoComplete="off"
placeholder="Contoh: Jakarta"
/>
<FieldError
error={errors.birth_place}
label="Tempat Lahir"
className="text-xs"
/> />
<FieldError error={errors.birth_place} label="Tempat Lahir" className="text-xs" />
</Field> </Field>
<Field> <Field>
<Label htmlFor="birth_date" required>Tanggal Lahir</Label> <Label htmlFor="birth_date" required>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}> Tanggal Lahir
</Label>
<Popover
open={isCalendarOpen}
onOpenChange={setIsCalendarOpen}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@ -147,78 +220,223 @@ export default function UserCreate() {
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
> >
{data.birth_date ? ( {data.birth_date ? (
new Intl.DateTimeFormat("id-ID", { new Intl.DateTimeFormat(
day: "numeric", 'id-ID',
month: "long", {
year: "numeric", day: 'numeric',
}).format(new Date(data.birth_date)) month: 'long',
year: 'numeric',
},
).format(
new Date(
data.birth_date,
),
)
) : ( ) : (
<span className="text-muted-foreground">Pilih Tanggal</span> <span className="text-muted-foreground">
Pilih Tanggal
</span>
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start"> <PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar <Calendar
mode="single" mode="single"
selected={data.birth_date ? new Date(data.birth_date) : undefined} selected={
defaultMonth={data.birth_date ? new Date(data.birth_date) : new Date(2000, 0, 1)} data.birth_date
? new Date(
data.birth_date,
)
: undefined
}
defaultMonth={
data.birth_date
? new Date(
data.birth_date,
)
: new Date(
2000,
0,
1,
)
}
captionLayout="dropdown" captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => { onSelect={(
selectedDate:
| Date
| undefined,
) => {
if (selectedDate) { if (selectedDate) {
setData('birth_date', selectedDate.getFullYear() + "-" + String(selectedDate.getMonth() + 1).padStart(2, '0') + "-" + String(selectedDate.getDate()).padStart(2, '0')); setData(
'birth_date',
selectedDate.getFullYear() +
'-' +
String(
selectedDate.getMonth() +
1,
).padStart(
2,
'0',
) +
'-' +
String(
selectedDate.getDate(),
).padStart(
2,
'0',
),
);
} else { } else {
setData('birth_date', ''); setData(
'birth_date',
'',
);
} }
setIsCalendarOpen(false); setIsCalendarOpen(
false,
);
}} }}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FieldError error={errors.birth_date} label="Tanggal Lahir" className="text-xs" /> <FieldError
error={errors.birth_date}
label="Tanggal Lahir"
className="text-xs"
/>
</Field> </Field>
</div> </div>
<Field> <Field>
<Label htmlFor="address" required>Alamat</Label> <Label htmlFor="address" required>
<Textarea id='address' name='address' value={data.address} onChange={e => setData('address', e.target.value)} placeholder='Contoh: Kp. Bakan Sampeu' /> Alamat
<FieldError error={errors.address} label="Alamat" className="text-xs" /> </Label>
<Textarea
id="address"
name="address"
value={data.address}
onChange={(e) =>
setData('address', e.target.value)
}
placeholder="Contoh: Kp. Bakan Sampeu"
/>
<FieldError
error={errors.address}
label="Alamat"
className="text-xs"
/>
</Field> </Field>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm"> <Card className="overflow-hidden border-none bg-card/50 shadow-lg backdrop-blur-sm">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>Kredensial Akun</CardTitle> <CardTitle>Kredensial Akun</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<Field> <Field>
<Label htmlFor="email" required>Alamat Surel</Label> <Label htmlFor="email" required>
Alamat Surel
</Label>
<Input <Input
id="email" id="email"
name="email" name="email"
type="email" type="email"
value={data.email} value={data.email}
onChange={e => setData('email', e.target.value)} onChange={(e) =>
autoComplete='off' setData('email', e.target.value)
placeholder='Contoh: john@example.com' }
autoComplete="off"
placeholder="Contoh: john@example.com"
/>
<FieldError
error={errors.email}
label="Alamat Surel"
className="text-xs"
/> />
<FieldError error={errors.email} label="Alamat Surel" className="text-xs" />
</Field> </Field>
<Field> <Field>
<Label htmlFor="username" required>Nama Pengguna</Label> <Label htmlFor="username" required>
Nama Pengguna
</Label>
<Input <Input
id="username" id="username"
name="username" name="username"
value={data.username} value={data.username}
onChange={e => setData('username', e.target.value)} onChange={(e) =>
autoComplete='off' setData('username', e.target.value)
placeholder='Contoh: johndoe' }
autoComplete="off"
placeholder="Contoh: johndoe"
/>
<FieldError
error={errors.username}
label="Nama Pengguna"
className="text-xs"
/>
</Field>
<Field>
<Label htmlFor="roles">Peran</Label>
<Combobox
multiple
autoHighlight
items={roles.map((r) => r.name)}
value={data.roles}
onValueChange={(val) =>
setData('roles', val)
}
>
<ComboboxChips
ref={anchor}
className="w-full"
>
<ComboboxValue>
{(values) => (
<React.Fragment>
{values.map(
(value: string) => (
<ComboboxChip
key={value}
value={value}
>
{value}
</ComboboxChip>
),
)}
<ComboboxChipsInput placeholder="Pilih Peran" />
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>
Peran tidak ditemukan
</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem
key={item}
value={item}
>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldError
error={errors.roles}
label="Peran"
className="text-xs"
/> />
<FieldError error={errors.username} label="Nama Pengguna" className="text-xs" />
</Field> </Field>
</CardContent> </CardContent>
</Card> </Card>
@ -226,11 +444,14 @@ export default function UserCreate() {
</div> </div>
<Button type="submit" disabled={processing}> <Button type="submit" disabled={processing}>
{processing ? <Loader className="size-4 animate-spin" /> : <Save className="size-4" />} {processing ? (
<Loader className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>
</form> </form>
</div> </div>
); );
} }

View File

@ -16,9 +16,22 @@ import { ArrowLeft, Loader, Save } from 'lucide-react';
import React from 'react'; import React from 'react';
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner'; import { toast } from 'sonner';
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from '@/components/ui/combobox';
export default function UserEdit({ user }: { user: any }) { export default function UserEdit({ user, roles }: { user: any, roles: any[] }) {
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
const anchor = useComboboxAnchor();
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
username: user.username || '', username: user.username || '',
@ -30,6 +43,7 @@ export default function UserEdit({ user }: { user: any }) {
birth_place: user.profile?.birth_place || '', birth_place: user.profile?.birth_place || '',
birth_date: user.profile?.birth_date || '', birth_date: user.profile?.birth_date || '',
base_salary: user.profile?.base_salary || 0, base_salary: user.profile?.base_salary || 0,
roles: user.roles?.map((role: any) => role.name) || [] as string[],
_method: 'PATCH', _method: 'PATCH',
}); });
@ -221,6 +235,62 @@ export default function UserEdit({ user }: { user: any }) {
/> />
<FieldError error={errors.username} label="Nama Pengguna" className="text-xs" /> <FieldError error={errors.username} label="Nama Pengguna" className="text-xs" />
</Field> </Field>
<Field>
<Label htmlFor="roles">Peran</Label>
<Combobox
multiple
autoHighlight
items={roles.map((r) => r.name)}
value={data.roles}
onValueChange={(val) =>
setData('roles', val)
}
>
<ComboboxChips
ref={anchor}
className="w-full"
>
<ComboboxValue>
{(values) => (
<React.Fragment>
{values.map(
(value: string) => (
<ComboboxChip
key={value}
value={value}
>
{value}
</ComboboxChip>
),
)}
<ComboboxChipsInput placeholder="Pilih Peran" />
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>
Peran tidak ditemukan
</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem
key={item}
value={item}
>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldError
error={errors.roles}
label="Peran"
className="text-xs"
/>
</Field>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Spatie\Permission\Models\Role;
use function Pest\Laravel\actingAs; use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\assertDatabaseHas;
@ -49,6 +50,7 @@
->assertInertia(fn ($page) => $page ->assertInertia(fn ($page) => $page
->component('admin/master/user/index') ->component('admin/master/user/index')
->has('users') ->has('users')
->has('roles')
->has('defaultPassword') ->has('defaultPassword')
); );
}); });
@ -58,6 +60,7 @@
->assertOk() ->assertOk()
->assertInertia(fn ($page) => $page ->assertInertia(fn ($page) => $page
->component('admin/master/user/create') ->component('admin/master/user/create')
->has('roles')
); );
}); });
@ -72,8 +75,11 @@
'birth_place' => 'Test City', 'birth_place' => 'Test City',
'birth_date' => '1990-01-01', 'birth_date' => '1990-01-01',
'base_salary' => 5000000, 'base_salary' => 5000000,
'roles' => ['Admin'],
]; ];
Role::create(['name' => 'Admin']);
postJson(route('user.store'), $data) postJson(route('user.store'), $data)
->assertRedirect(route('user.index')) ->assertRedirect(route('user.index'))
->assertSessionHas('success'); ->assertSessionHas('success');
@ -87,6 +93,9 @@
'nik' => '1234567890123456', 'nik' => '1234567890123456',
'full_name' => 'New User Full Name', 'full_name' => 'New User Full Name',
]); ]);
$newUser = User::where('username', 'newuser')->first();
expect($newUser->hasRole('Admin'))->toBeTrue();
}); });
it('validates user creation', function () { it('validates user creation', function () {
@ -103,6 +112,7 @@
->assertInertia(fn ($page) => $page ->assertInertia(fn ($page) => $page
->component('admin/master/user/edit') ->component('admin/master/user/edit')
->has('user') ->has('user')
->has('roles')
); );
}); });
@ -119,8 +129,11 @@
'birth_place' => 'Updated City', 'birth_place' => 'Updated City',
'birth_date' => '1995-05-05', 'birth_date' => '1995-05-05',
'base_salary' => 6000000, 'base_salary' => 6000000,
'roles' => ['Staff'],
]; ];
Role::create(['name' => 'Staff']);
patchJson(route('user.update', $user), $newData) patchJson(route('user.update', $user), $newData)
->assertRedirect(route('user.index')) ->assertRedirect(route('user.index'))
->assertSessionHas('success'); ->assertSessionHas('success');
@ -134,6 +147,8 @@
'user_id' => $user->id, 'user_id' => $user->id,
'full_name' => 'Updated Name', 'full_name' => 'Updated Name',
]); ]);
expect($user->fresh()->hasRole('Staff'))->toBeTrue();
}); });
it('can reset user password', function () { it('can reset user password', function () {