Refactor lecturer management to support multiple departments
Some checks failed
tests / ci (pull_request) Has been cancelled

- Updated LecturerController to load multiple departments for lecturers.
- Modified LecturerRequest to accept an array of department IDs instead of a single department ID.
- Changed Department model to establish a many-to-many relationship with Lecturer.
- Adjusted Lecturer model to reflect the new many-to-many relationship with Department.
- Updated LecturerService to handle multiple departments during creation and updates.
- Created LecturerFactory and StudentFactory for generating test data.
- Added a migration for the new lecturer_department pivot table.
- Refactored seeder classes to accommodate the new structure for lecturers and departments.
- Enhanced frontend components for creating and editing lecturers to support multiple department selection.
This commit is contained in:
Yoga Pangestu 2026-08-30 12:18:13 +07:00
parent bf6f4b19b7
commit 07a00a92a2
18 changed files with 757 additions and 203 deletions

View File

@ -58,7 +58,7 @@ public function map($user): array
$user->username, $user->username,
$user->email, $user->email,
$user->profile?->phone_number ?? '-', $user->profile?->phone_number ?? '-',
$user->lecturer?->department?->name ?? '-', $user->lecturer?->departments->pluck('name')->join(', ') ?: '-',
$user->profile?->gender?->label() ?? '-', $user->profile?->gender?->label() ?? '-',
$user->profile?->birth_place ?? '-', $user->profile?->birth_place ?? '-',
$user->profile?->birth_date?->format('d M Y') ?? '-', $user->profile?->birth_date?->format('d M Y') ?? '-',

View File

@ -83,11 +83,11 @@ private function studentRoleData(User $user): array
private function lecturerRoleData(User $user): array private function lecturerRoleData(User $user): array
{ {
$user->load(['profile', 'lecturer.department']); $user->load(['profile', 'lecturer.departments']);
return ['dosen', $user->lecturer ? [ return ['dosen', $user->lecturer ? [
'lecturer_number' => $user->lecturer->lecturer_number, 'lecturer_number' => $user->lecturer->lecturer_number,
'department' => $user->lecturer->department?->name, 'department' => $user->lecturer->departments->pluck('name')->join(', ') ?: null,
] : null]; ] : null];
} }

View File

@ -54,7 +54,7 @@ public function store(LecturerRequest $request): RedirectResponse
public function edit(User $user): Response public function edit(User $user): Response
{ {
$user->load(['profile', 'lecturer.department']); $user->load(['profile', 'lecturer.departments']);
return Inertia::render('admin/users/lecturers/edit', [ return Inertia::render('admin/users/lecturers/edit', [
'user' => $user, 'user' => $user,

View File

@ -53,11 +53,8 @@ public function rules(): array
'max:20', 'max:20',
Rule::unique('lecturers')->ignore($userId, 'user_id'), Rule::unique('lecturers')->ignore($userId, 'user_id'),
], ],
'department_id' => [ 'department_ids' => ['required', 'array', 'min:1'],
'required', 'department_ids.*' => ['integer', Rule::exists('departments', 'id')],
'integer',
Rule::exists('departments', 'id'),
],
]; ];
} }
} }

View File

@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@ -14,9 +15,9 @@ class Department extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
public function lecturers(): HasMany public function lecturers(): BelongsToMany
{ {
return $this->hasMany(Lecturer::class); return $this->belongsToMany(Lecturer::class, 'lecturer_department');
} }
public function students(): HasMany public function students(): HasMany

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@ -19,9 +20,9 @@ public function user(): BelongsTo
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function department(): BelongsTo public function departments(): BelongsToMany
{ {
return $this->belongsTo(Department::class); return $this->belongsToMany(Department::class, 'lecturer_department');
} }
public function advisees(): HasMany public function advisees(): HasMany

View File

@ -41,8 +41,8 @@ private function filteredQuery(string $search, ?string $gender, ?int $department
return User::select(['id', 'username', 'email', 'is_active']) return User::select(['id', 'username', 'email', 'is_active'])
->with([ ->with([
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address', 'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
'lecturer:id,user_id,lecturer_number,department_id', 'lecturer:id,user_id,lecturer_number',
'lecturer.department:id,name', 'lecturer.departments:id,name',
]) ])
->whereHas('roles', fn ($q) => $q->where('name', 'dosen')) ->whereHas('roles', fn ($q) => $q->where('name', 'dosen'))
->when($search, fn ($q) => $q->where(function ($query) use ($search) { ->when($search, fn ($q) => $q->where(function ($query) use ($search) {
@ -52,7 +52,7 @@ private function filteredQuery(string $search, ?string $gender, ?int $department
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%")); ->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"));
})) }))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender))) ->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->when($departmentId, fn ($q) => $q->whereHas('lecturer', fn ($q) => $q->where('department_id', $departmentId))); ->when($departmentId, fn ($q) => $q->whereHas('lecturer.departments', fn ($q) => $q->where('departments.id', $departmentId)));
} }
public function create(array $data): User public function create(array $data): User
@ -75,11 +75,12 @@ public function create(array $data): User
'birth_place' => $data['birth_place'], 'birth_place' => $data['birth_place'],
]); ]);
$user->lecturer()->create([ $lecturer = $user->lecturer()->create([
'lecturer_number' => $data['lecturer_number'], 'lecturer_number' => $data['lecturer_number'],
'department_id' => $data['department_id'],
]); ]);
$lecturer->departments()->sync($data['department_ids']);
return $user; return $user;
}); });
} }
@ -101,13 +102,14 @@ public function update(User $user, array $data): User
'birth_place' => $data['birth_place'], 'birth_place' => $data['birth_place'],
]); ]);
$user->lecturer()->updateOrCreate([], [ $lecturer = $user->lecturer()->updateOrCreate([], [
'lecturer_number' => $data['lecturer_number'], 'lecturer_number' => $data['lecturer_number'],
'department_id' => $data['department_id'],
]); ]);
$lecturer->departments()->sync($data['department_ids']);
}); });
return $user->fresh(['profile', 'lecturer.department', 'roles']); return $user->fresh(['profile', 'lecturer.departments', 'roles']);
} }
public function delete(User $user): void public function delete(User $user): void

View File

@ -0,0 +1,21 @@
<?php
namespace Database\Factories;
use App\Models\Lecturer;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Lecturer>
*/
class LecturerFactory extends Factory
{
protected $model = Lecturer::class;
public function definition(): array
{
return [
'lecturer_number' => fake()->unique()->numerify('##########'),
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Enums\StudentStatus;
use App\Models\Department;
use App\Models\Student;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Student>
*/
class StudentFactory extends Factory
{
protected $model = Student::class;
public function definition(): array
{
return [
'student_number' => fake()->unique()->numerify('########'),
'department_id' => fn () => Department::query()->inRandomOrder()->value('id'),
'enrollment_year' => fake()->numberBetween(now()->year - 4, now()->year),
'academic_advisor_id' => null,
'status' => StudentStatus::Active,
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Enums\Gender;
use App\Models\UserProfile;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<UserProfile>
*/
class UserProfileFactory extends Factory
{
protected $model = UserProfile::class;
public function definition(): array
{
$gender = fake()->randomElement(Gender::cases());
$faker = fake('id_ID');
return [
'full_name' => $faker->name($gender === Gender::Male ? 'male' : 'female'),
'phone_number' => '08'.fake()->numerify('##########'),
'address' => $faker->address(),
'gender' => $gender,
'birth_date' => fake()->dateTimeBetween('-55 years', '-18 years')->format('Y-m-d'),
'birth_place' => $faker->city(),
];
}
}

View File

@ -12,7 +12,6 @@ public function up(): void
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('lecturer_number', 10)->unique(); $table->string('lecturer_number', 10)->unique();
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps(); $table->timestamps();
$table->softDeletes(); $table->softDeletes();
}); });

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('lecturer_department', function (Blueprint $table) {
$table->id();
$table->foreignId('lecturer_id')->constrained()->cascadeOnDelete();
$table->foreignId('department_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['lecturer_id', 'department_id']);
});
}
public function down(): void
{
Schema::dropIfExists('lecturer_department');
}
};

View File

@ -14,8 +14,8 @@ public function run(): void
$this->call([ $this->call([
RolePermissionSeeder::class, RolePermissionSeeder::class,
DepartmentSeeder::class, DepartmentSeeder::class,
UserSeeder::class,
AcademicTermSeeder::class, AcademicTermSeeder::class,
UserSeeder::class,
DepartmentLeadershipSeeder::class, DepartmentLeadershipSeeder::class,
CourseSeeder::class, CourseSeeder::class,
CourseClassSeeder::class, CourseClassSeeder::class,

View File

@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Department;
use App\Models\DepartmentLeadership; use App\Models\DepartmentLeadership;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@ -9,31 +10,21 @@ class DepartmentLeadershipSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
DepartmentLeadership::insert([ Department::all()->each(function (Department $department) {
[ $lecturer = $department->lecturers()
'department_id' => 1, ->whereHas('user.roles', fn ($q) => $q->where('name', 'kaprodi'))
'lecturer_id' => 1, ->first();
'started_at' => '2024-01-10',
if (! $lecturer) {
return;
}
DepartmentLeadership::create([
'department_id' => $department->id,
'lecturer_id' => $lecturer->id,
'started_at' => now()->subYear(),
'ended_at' => null, 'ended_at' => null,
'created_at' => now(), ]);
'updated_at' => now(), });
],
[
'department_id' => 2,
'lecturer_id' => 2,
'started_at' => '2024-01-10',
'ended_at' => null,
'created_at' => now(),
'updated_at' => now(),
],
[
'department_id' => 3,
'lecturer_id' => 3,
'started_at' => '2023-06-01',
'ended_at' => null,
'created_at' => now(),
'updated_at' => now(),
],
]);
} }
} }

View File

@ -2,8 +2,14 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\Semester;
use App\Enums\StudentStatus; use App\Enums\StudentStatus;
use App\Models\AcademicTerm;
use App\Models\Department;
use App\Models\Lecturer;
use App\Models\Student;
use App\Models\User; use App\Models\User;
use App\Models\UserProfile;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -12,57 +18,6 @@ class UserSeeder extends Seeder
public function run(): void public function run(): void
{ {
$users = [ $users = [
[
'username' => 'dwi',
'email' => 'dwi@lecturer.itmpwk.ac.id',
'roles' => ['dosen'],
'profile' => [
'full_name' => 'Dwi Saputra',
'phone_number' => '081234567801',
'address' => 'Jl. Kampus No. 1, Purwakarta',
'gender' => 'male',
'birth_place' => 'Bandung',
'birth_date' => '1985-03-15',
],
'lecturer' => [
'lecturer_number' => '0012345601',
'department_id' => 1,
],
],
[
'username' => 'rina',
'email' => 'rina@lecturer.itmpwk.ac.id',
'roles' => ['dosen'],
'profile' => [
'full_name' => 'Rina Marlina',
'phone_number' => '081234567802',
'address' => 'Jl. Kampus No. 1, Purwakarta',
'gender' => 'female',
'birth_place' => 'Jakarta',
'birth_date' => '1987-07-20',
],
'lecturer' => [
'lecturer_number' => '0012345602',
'department_id' => 2,
],
],
[
'username' => 'budi',
'email' => 'budi@lecturer.itmpwk.ac.id',
'roles' => ['dosen'],
'profile' => [
'full_name' => 'Budi Hartono',
'phone_number' => '081234567803',
'address' => 'Jl. Kampus No. 1, Purwakarta',
'gender' => 'male',
'birth_place' => 'Surabaya',
'birth_date' => '1983-11-10',
],
'lecturer' => [
'lecturer_number' => '0012345603',
'department_id' => 3,
],
],
[ [
'username' => 'pangestu', 'username' => 'pangestu',
'email' => 'pangestu@student.itmpwk.ac.id', 'email' => 'pangestu@student.itmpwk.ac.id',
@ -79,7 +34,7 @@ public function run(): void
'student_number' => '23010001', 'student_number' => '23010001',
'department_id' => 1, 'department_id' => 1,
'enrollment_year' => 2023, 'enrollment_year' => 2023,
'academic_advisor_id' => 1, 'academic_advisor_id' => null,
'status' => StudentStatus::Active, 'status' => StudentStatus::Active,
], ],
], ],
@ -97,9 +52,9 @@ public function run(): void
], ],
'student' => [ 'student' => [
'student_number' => '23020002', 'student_number' => '23020002',
'department_id' => 2, 'department_id' => 1,
'enrollment_year' => 2023, 'enrollment_year' => 2023,
'academic_advisor_id' => 2, 'academic_advisor_id' => null,
'status' => StudentStatus::Active, 'status' => StudentStatus::Active,
], ],
], ],
@ -119,7 +74,7 @@ public function run(): void
'student_number' => '24010003', 'student_number' => '24010003',
'department_id' => 1, 'department_id' => 1,
'enrollment_year' => 2024, 'enrollment_year' => 2024,
'academic_advisor_id' => 1, 'academic_advisor_id' => null,
'status' => StudentStatus::Active, 'status' => StudentStatus::Active,
], ],
], ],
@ -137,9 +92,9 @@ public function run(): void
], ],
'student' => [ 'student' => [
'student_number' => '24020004', 'student_number' => '24020004',
'department_id' => 2, 'department_id' => 1,
'enrollment_year' => 2024, 'enrollment_year' => 2024,
'academic_advisor_id' => 2, 'academic_advisor_id' => null,
'status' => StudentStatus::Active, 'status' => StudentStatus::Active,
], ],
], ],
@ -164,12 +119,134 @@ public function run(): void
$user->assignRole($roles); $user->assignRole($roles);
if ($lecturer) { if ($lecturer) {
$user->lecturer()->create($lecturer); $departmentId = $lecturer['department_id'];
unset($lecturer['department_id']);
$createdLecturer = $user->lecturer()->create($lecturer);
$createdLecturer->departments()->attach($departmentId);
} }
if ($student) { if ($student) {
$user->student()->create($student); $user->student()->create($student);
} }
} }
$this->seedRandomLecturers();
$this->seedRandomStudents();
$this->seedOtherStaff();
}
/**
* 5-8 dosen acak per jurusan. Sebagian dosen diafiliasikan ke lebih dari
* satu jurusan lewat pivot lecturer_department, sebagian cukup 1 jurusan.
*/
private function seedRandomLecturers(): void
{
$departments = Department::all();
foreach ($departments as $department) {
$count = fake()->numberBetween(5, 8);
$lecturers = collect();
for ($i = 0; $i < $count; $i++) {
$lecturer = $this->createLecturer($department);
$lecturers->push($lecturer);
if (fake()->boolean(35)) {
$otherDepartmentIds = Department::query()
->where('id', '!=', $department->id)
->inRandomOrder()
->limit(fake()->numberBetween(1, 2))
->pluck('id');
$lecturer->departments()->attach($otherDepartmentIds);
}
}
$lecturers->random()->user->assignRole('kaprodi');
}
}
private function createLecturer(Department $department): Lecturer
{
$user = User::factory()->create();
$user->profile()->save(UserProfile::factory()->make());
$user->assignRole('dosen');
$lecturer = $user->lecturer()->save(Lecturer::factory()->makeOne());
$lecturer->departments()->attach($department->id);
return $lecturer;
}
/**
* 8-10 mahasiswa acak per jurusan untuk setiap periode akademik, supaya
* enrollment_year-nya mengikuti tahun ajaran yang benar-benar ada.
*/
private function seedRandomStudents(): void
{
$departments = Department::all();
foreach (AcademicTerm::all() as $term) {
[$startYear, $endYear] = explode('/', $term->name);
$enrollmentYear = $term->semester === Semester::even ? (int) $endYear : (int) $startYear;
foreach ($departments as $department) {
$count = fake()->numberBetween(8, 10);
for ($i = 0; $i < $count; $i++) {
$this->createStudent($department, $enrollmentYear);
}
}
}
}
private function createStudent(Department $department, int $enrollmentYear): Student
{
$user = User::factory()->create();
$user->profile()->save(UserProfile::factory()->make());
$user->assignRole('mahasiswa');
$advisorId = Lecturer::query()
->whereRelation('departments', 'departments.id', $department->id)
->inRandomOrder()
->value('id');
$status = fake()->randomElement([
StudentStatus::Active,
StudentStatus::Active,
StudentStatus::Active,
StudentStatus::OnLeave,
StudentStatus::Graduated,
StudentStatus::DroppedOut,
]);
return $user->student()->save(Student::factory()->makeOne([
'department_id' => $department->id,
'enrollment_year' => $enrollmentYear,
'academic_advisor_id' => $advisorId,
'status' => $status,
]));
}
/**
* Sejumlah kecil akun staf lain supaya tiap role punya data acak juga.
*/
private function seedOtherStaff(): void
{
$roleCounts = [
'staff-admin' => 2,
'staff-keuangan' => 2,
'developer' => 2,
];
foreach ($roleCounts as $role => $count) {
for ($i = 0; $i < $count; $i++) {
$user = User::factory()->create();
$user->profile()->save(UserProfile::factory()->make());
$user->assignRole($role);
}
}
} }
} }

View File

@ -12,7 +12,7 @@ export type Lecturer = {
profile: { full_name: string; phone_number: string; gender: string } | null; profile: { full_name: string; phone_number: string; gender: string } | null;
lecturer: { lecturer: {
lecturer_number: string; lecturer_number: string;
department: { name: string } | null; departments: { id: number; name: string }[];
} | null; } | null;
}; };
@ -59,9 +59,17 @@ export function createLecturerColumns(
), ),
}, },
{ {
accessorKey: 'lecturer.department.name', id: 'departments',
header: () => <span>Jurusan</span>, header: () => <span>Jurusan</span>,
cell: ({ row }) => row.original.lecturer?.department?.name ?? '-', cell: ({ row }) => {
const departments = row.original.lecturer?.departments ?? [];
return departments.length > 0
? departments
.map((department) => department.name)
.join(', ')
: '-';
},
}, },
{ {
accessorKey: 'profile.phone_number', accessorKey: 'profile.phone_number',

View File

@ -7,11 +7,21 @@ import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
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,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
useComboboxAnchor,
} 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 { PhoneInput } from '@/components/ui/phone-input'; import { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; 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 { Textarea } from '@/components/ui/textarea';
import { index, store } from '@/routes/admin/users/lecturers'; import { index, store } from '@/routes/admin/users/lecturers';
@ -24,6 +34,10 @@ type Props = {
export default function LecturerCreate({ departments }: Props) { export default function LecturerCreate({ departments }: Props) {
const [gender, setGender] = useState(''); const [gender, setGender] = useState('');
const [birthDate, setBirthDate] = useState<Date | undefined>(); const [birthDate, setBirthDate] = useState<Date | undefined>();
const [selectedDepartments, setSelectedDepartments] = useState<
Department[]
>([]);
const departmentAnchor = useComboboxAnchor();
return ( return (
<> <>
@ -52,16 +66,31 @@ export default function LecturerCreate({ departments }: Props) {
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username"> <Label htmlFor="username">
Username <span className="text-red-500">*</span> Username{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="username" name="username" placeholder="Masukkan username" /> <Input
id="username"
name="username"
placeholder="Masukkan username"
/>
<InputError message={errors.username} /> <InputError message={errors.username} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"> <Label htmlFor="email">
Email <span className="text-red-500">*</span> Email{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="email" name="email" type="email" placeholder="Masukkan email" /> <Input
id="email"
name="email"
type="email"
placeholder="Masukkan email"
/>
<InputError message={errors.email} /> <InputError message={errors.email} />
</div> </div>
</CardContent> </CardContent>
@ -74,57 +103,138 @@ export default function LecturerCreate({ departments }: Props) {
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="full_name"> <Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span> Nama Lengkap{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="full_name" name="full_name" placeholder="Masukkan nama lengkap" /> <Input
<InputError message={errors.full_name} /> id="full_name"
name="full_name"
placeholder="Masukkan nama lengkap"
/>
<InputError
message={errors.full_name}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone_number"> <Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span> Nomor Telepon{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<PhoneInput id="phone_number" name="phone_number" placeholder="08xx xxxx xxxx" /> <PhoneInput
<InputError message={errors.phone_number} /> id="phone_number"
name="phone_number"
placeholder="08xx xxxx xxxx"
/>
<InputError
message={errors.phone_number}
/>
</div> </div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3"> <div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jenis Kelamin <span className="text-red-500">*</span> Jenis Kelamin{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<input type="hidden" name="gender" value={gender} /> <input
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4"> type="hidden"
name="gender"
value={gender}
/>
<RadioGroup
value={gender}
onValueChange={setGender}
className="flex gap-4"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" /> <RadioGroupItem
<Label htmlFor="male" className="font-normal">Laki-laki</Label> value="male"
id="male"
/>
<Label
htmlFor="male"
className="font-normal"
>
Laki-laki
</Label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" /> <RadioGroupItem
<Label htmlFor="female" className="font-normal">Perempuan</Label> value="female"
id="female"
/>
<Label
htmlFor="female"
className="font-normal"
>
Perempuan
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.gender} /> <InputError
message={errors.gender}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Tempat Lahir <span className="text-red-500">*</span> Tempat Lahir{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input name="birth_place" placeholder="Masukkan tempat lahir" /> <Input
<InputError message={errors.birth_place} /> name="birth_place"
placeholder="Masukkan tempat lahir"
/>
<InputError
message={errors.birth_place}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Tanggal Lahir <span className="text-red-500">*</span> Tanggal Lahir{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} /> <input
<DatePicker value={birthDate} onChange={setBirthDate} /> type="hidden"
<InputError message={errors.birth_date} /> name="birth_date"
value={
birthDate
? format(
birthDate,
'yyyy-MM-dd',
)
: ''
}
/>
<DatePicker
value={birthDate}
onChange={setBirthDate}
/>
<InputError
message={errors.birth_date}
/>
</div> </div>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="address"> <Label htmlFor="address">
Alamat <span className="text-red-500">*</span> Alamat{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Textarea id="address" name="address" placeholder="Masukkan alamat" /> <Textarea
id="address"
name="address"
placeholder="Masukkan alamat"
/>
<InputError message={errors.address} /> <InputError message={errors.address} />
</div> </div>
</CardContent> </CardContent>
@ -137,29 +247,94 @@ export default function LecturerCreate({ departments }: Props) {
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="lecturer_number"> <Label htmlFor="lecturer_number">
NIDN <span className="text-red-500">*</span> NIDN{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="lecturer_number" name="lecturer_number" placeholder="Masukkan NIDN" /> <Input
<InputError message={errors.lecturer_number} /> id="lecturer_number"
name="lecturer_number"
placeholder="Masukkan NIDN"
/>
<InputError
message={errors.lecturer_number}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jurusan <span className="text-red-500">*</span> Jurusan{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<input type="hidden" name="department_id" /> {selectedDepartments.map((dept) => (
<Select name="department_id"> <input
<SelectTrigger className="w-full"> key={dept.id}
<SelectValue placeholder="Pilih jurusan" /> type="hidden"
</SelectTrigger> name="department_ids[]"
<SelectContent> value={dept.id}
{departments.map((dept) => ( />
<SelectItem key={dept.id} value={String(dept.id)}> ))}
{dept.name} <Combobox
</SelectItem> items={departments}
))} multiple
</SelectContent> value={selectedDepartments}
</Select> onValueChange={
<InputError message={errors.department_id} /> setSelectedDepartments
}
itemToStringLabel={(dept) =>
dept.name
}
isItemEqualToValue={(a, b) =>
a.id === b.id
}
>
<ComboboxChips
ref={departmentAnchor}
>
{selectedDepartments.map(
(dept) => (
<ComboboxChip
key={dept.id}
aria-label={
dept.name
}
>
{dept.name}
</ComboboxChip>
),
)}
<ComboboxChipsInput
placeholder={
selectedDepartments.length ===
0
? 'Pilih jurusan'
: ''
}
/>
</ComboboxChips>
<ComboboxContent
anchor={departmentAnchor}
>
<ComboboxEmpty>
Jurusan tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{departments.map((dept) => (
<ComboboxItem
key={dept.id}
value={dept}
>
{dept.name}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.department_ids}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -7,11 +7,21 @@ import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
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,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
useComboboxAnchor,
} 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 { PhoneInput } from '@/components/ui/phone-input'; import { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; 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 { Textarea } from '@/components/ui/textarea';
import { index, update } from '@/routes/admin/users/lecturers'; import { index, update } from '@/routes/admin/users/lecturers';
@ -21,8 +31,18 @@ type User = {
id: number; id: number;
username: string; username: string;
email: string; email: string;
profile: { full_name: string; phone_number: string; address: string; gender: string; birth_date: string; birth_place: string } | null; profile: {
lecturer: { lecturer_number: string; department_id: number } | null; full_name: string;
phone_number: string;
address: string;
gender: string;
birth_date: string;
birth_place: string;
} | null;
lecturer: {
lecturer_number: string;
departments: { id: number; name: string }[];
} | null;
}; };
type Props = { type Props = {
@ -33,8 +53,14 @@ type Props = {
export default function LecturerEdit({ user, departments }: Props) { export default function LecturerEdit({ user, departments }: Props) {
const [gender, setGender] = useState(user.profile?.gender ?? ''); const [gender, setGender] = useState(user.profile?.gender ?? '');
const [birthDate, setBirthDate] = useState<Date | undefined>( const [birthDate, setBirthDate] = useState<Date | undefined>(
user.profile?.birth_date ? new Date(user.profile.birth_date) : undefined user.profile?.birth_date
? new Date(user.profile.birth_date)
: undefined,
); );
const [selectedDepartments, setSelectedDepartments] = useState<
Department[]
>(user.lecturer?.departments ?? []);
const departmentAnchor = useComboboxAnchor();
return ( return (
<> <>
@ -63,16 +89,31 @@ export default function LecturerEdit({ user, departments }: Props) {
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username"> <Label htmlFor="username">
Username <span className="text-red-500">*</span> Username{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="username" name="username" defaultValue={user.username} /> <Input
id="username"
name="username"
defaultValue={user.username}
/>
<InputError message={errors.username} /> <InputError message={errors.username} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"> <Label htmlFor="email">
Email <span className="text-red-500">*</span> Email{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="email" name="email" type="email" defaultValue={user.email} /> <Input
id="email"
name="email"
type="email"
defaultValue={user.email}
/>
<InputError message={errors.email} /> <InputError message={errors.email} />
</div> </div>
</CardContent> </CardContent>
@ -85,57 +126,147 @@ export default function LecturerEdit({ user, departments }: Props) {
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="full_name"> <Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span> Nama Lengkap{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="full_name" name="full_name" defaultValue={user.profile?.full_name ?? ''} /> <Input
<InputError message={errors.full_name} /> id="full_name"
name="full_name"
defaultValue={
user.profile?.full_name ?? ''
}
/>
<InputError
message={errors.full_name}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone_number"> <Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span> Nomor Telepon{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<PhoneInput id="phone_number" name="phone_number" value={user.profile?.phone_number ?? ''} /> <PhoneInput
<InputError message={errors.phone_number} /> id="phone_number"
name="phone_number"
value={
user.profile?.phone_number ?? ''
}
/>
<InputError
message={errors.phone_number}
/>
</div> </div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3"> <div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jenis Kelamin <span className="text-red-500">*</span> Jenis Kelamin{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<input type="hidden" name="gender" value={gender} /> <input
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4"> type="hidden"
name="gender"
value={gender}
/>
<RadioGroup
value={gender}
onValueChange={setGender}
className="flex gap-4"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" /> <RadioGroupItem
<Label htmlFor="male" className="font-normal">Laki-laki</Label> value="male"
id="male"
/>
<Label
htmlFor="male"
className="font-normal"
>
Laki-laki
</Label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" /> <RadioGroupItem
<Label htmlFor="female" className="font-normal">Perempuan</Label> value="female"
id="female"
/>
<Label
htmlFor="female"
className="font-normal"
>
Perempuan
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.gender} /> <InputError
message={errors.gender}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Tempat Lahir <span className="text-red-500">*</span> Tempat Lahir{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input name="birth_place" defaultValue={user.profile?.birth_place ?? ''} /> <Input
<InputError message={errors.birth_place} /> name="birth_place"
defaultValue={
user.profile?.birth_place ??
''
}
/>
<InputError
message={errors.birth_place}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Tanggal Lahir <span className="text-red-500">*</span> Tanggal Lahir{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} /> <input
<DatePicker value={birthDate} onChange={setBirthDate} /> type="hidden"
<InputError message={errors.birth_date} /> name="birth_date"
value={
birthDate
? format(
birthDate,
'yyyy-MM-dd',
)
: ''
}
/>
<DatePicker
value={birthDate}
onChange={setBirthDate}
/>
<InputError
message={errors.birth_date}
/>
</div> </div>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="address"> <Label htmlFor="address">
Alamat <span className="text-red-500">*</span> Alamat{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Textarea id="address" name="address" defaultValue={user.profile?.address ?? ''} /> <Textarea
id="address"
name="address"
defaultValue={
user.profile?.address ?? ''
}
/>
<InputError message={errors.address} /> <InputError message={errors.address} />
</div> </div>
</CardContent> </CardContent>
@ -148,28 +279,97 @@ export default function LecturerEdit({ user, departments }: Props) {
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="lecturer_number"> <Label htmlFor="lecturer_number">
NIDN <span className="text-red-500">*</span> NIDN{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Input id="lecturer_number" name="lecturer_number" defaultValue={user.lecturer?.lecturer_number ?? ''} /> <Input
<InputError message={errors.lecturer_number} /> id="lecturer_number"
name="lecturer_number"
defaultValue={
user.lecturer
?.lecturer_number ?? ''
}
/>
<InputError
message={errors.lecturer_number}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jurusan <span className="text-red-500">*</span> Jurusan{' '}
<span className="text-red-500">
*
</span>
</Label> </Label>
<Select name="department_id" defaultValue={user.lecturer?.department_id ? String(user.lecturer.department_id) : undefined}> {selectedDepartments.map((dept) => (
<SelectTrigger className="w-full"> <input
<SelectValue placeholder="Pilih jurusan" /> key={dept.id}
</SelectTrigger> type="hidden"
<SelectContent> name="department_ids[]"
{departments.map((dept) => ( value={dept.id}
<SelectItem key={dept.id} value={String(dept.id)}> />
{dept.name} ))}
</SelectItem> <Combobox
))} items={departments}
</SelectContent> multiple
</Select> value={selectedDepartments}
<InputError message={errors.department_id} /> onValueChange={
setSelectedDepartments
}
itemToStringLabel={(dept) =>
dept.name
}
isItemEqualToValue={(a, b) =>
a.id === b.id
}
>
<ComboboxChips
ref={departmentAnchor}
>
{selectedDepartments.map(
(dept) => (
<ComboboxChip
key={dept.id}
aria-label={
dept.name
}
>
{dept.name}
</ComboboxChip>
),
)}
<ComboboxChipsInput
placeholder={
selectedDepartments.length ===
0
? 'Pilih jurusan'
: ''
}
/>
</ComboboxChips>
<ComboboxContent
anchor={departmentAnchor}
>
<ComboboxEmpty>
Jurusan tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{departments.map((dept) => (
<ComboboxItem
key={dept.id}
value={dept}
>
{dept.name}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.department_ids}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>