feat: add lecturer management to Department module, including leadership fields and display in UI

This commit is contained in:
Yoga Pangestu 2026-09-01 20:49:04 +07:00
parent 10c3e42228
commit 2bb892ef50
6 changed files with 218 additions and 4 deletions

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Department;
use App\Services\Admin\Master\DepartmentService;
use App\Services\Admin\Users\LecturerService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,12 +16,14 @@ class DepartmentController extends Controller
{
public function __construct(
private readonly DepartmentService $service,
private readonly LecturerService $lecturerService,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/master/departments/index', [
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
'lecturers' => $this->lecturerService->getAllForSelect(),
]);
}

View File

@ -23,6 +23,14 @@ public function rules(): array
],
'name' => ['required', 'string', 'max:100'],
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])],
'lecturer_id' => [
'nullable',
Rule::requiredIf($this->route('department') !== null),
'integer',
Rule::exists('lecturer_department', 'lecturer_id')
->where('department_id', $this->route('department')?->id ?? 0),
],
'leadership_started_at' => ['nullable', 'date', 'required_with:lecturer_id'],
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Services\Admin\Master;
use App\Models\Department;
use App\Models\DepartmentLeadership;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
@ -17,6 +18,7 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
{
return Department::query()
->select(['id', 'code', 'name', 'degree_level'])
->with(['currentLeader.lecturer.user.profile'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->latest()
->paginate($perPage);
@ -24,7 +26,15 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
public function create(array $data): Department
{
return Department::create($data);
$department = Department::create([
'code' => $data['code'],
'name' => $data['name'],
'degree_level' => $data['degree_level'] ?? null,
]);
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
return $department;
}
public function update(Department $department, array $data): Department
@ -34,9 +44,38 @@ public function update(Department $department, array $data): Department
$department->degree_level = $data['degree_level'] ?? null;
$department->update();
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
return $department;
}
private function syncLeadership(Department $department, ?int $lecturerId, ?string $startedAt): void
{
$currentLeader = DepartmentLeadership::query()
->where('department_id', $department->id)
->whereNull('ended_at')
->first();
if (! $lecturerId) {
$currentLeader?->update(['ended_at' => now()]);
return;
}
if ($currentLeader && $currentLeader->lecturer_id === $lecturerId) {
$currentLeader->update(['started_at' => $startedAt ?? $currentLeader->started_at]);
return;
}
$currentLeader?->update(['ended_at' => now()]);
$department->leaderships()->create([
'lecturer_id' => $lecturerId,
'started_at' => $startedAt ?? now(),
]);
}
public function delete(Department $department): bool
{
return $department->delete();

View File

@ -55,6 +55,21 @@ export function createDepartmentColumns(
);
},
},
{
id: 'kaprodi',
header: () => <span>Kaprodi</span>,
cell: ({ row }) => {
const leader = row.original.current_leader;
return leader ? (
<span>
{leader.lecturer?.user?.profile?.full_name ?? 'N/A'}
</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
];
if (canUpdate || canDelete) {

View File

@ -1,13 +1,23 @@
import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@ -26,7 +36,7 @@ import {
update,
} from '@/routes/admin/master/departments';
import { DegreeLevels } from '@/types/department';
import type { Department } from '@/types/department';
import type { Department, DepartmentLecturer } from '@/types/department';
import { createDepartmentColumns } from './columns';
type Props = {
@ -37,10 +47,19 @@ type Props = {
per_page: number;
total: number;
};
lecturers: DepartmentLecturer[];
highlight?: number;
};
export default function DepartmentIndex({ departments, highlight }: Props) {
function lecturerLabel(lecturer: DepartmentLecturer): string {
return `${lecturer.user?.profile?.full_name ?? 'N/A'} - ${lecturer.lecturer_number}`;
}
export default function DepartmentIndex({
departments,
lecturers,
highlight,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [deleting, setDeleting] = useState<Department | null>(null);
@ -127,7 +146,11 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
}
/>
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
lecturers={lecturers}
/>
<EditForm
key={editing?.id}
@ -138,6 +161,7 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
}
}}
editing={editing}
lecturers={lecturers}
/>
<DataTable
@ -169,12 +193,113 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
);
}
function KaprodiFields({
errors,
lecturers,
editing,
}: {
errors: Record<string, string>;
lecturers: DepartmentLecturer[];
editing?: Department | null;
}) {
const [lecturer, setLecturer] = useState<DepartmentLecturer | null>(
editing?.current_leader?.lecturer ?? null,
);
const [startedAt, setStartedAt] = useState<Date | undefined>(
editing?.current_leader
? new Date(editing.current_leader.started_at)
: undefined,
);
const availableLecturers = editing
? lecturers.filter((l) =>
l.departments.some((d) => d.id === editing.id),
)
: [];
return (
<>
<div className="grid gap-2">
<Label>
Kaprodi{' '}
{editing && (
<span className="text-destructive">*</span>
)}
</Label>
<input
type="hidden"
name="lecturer_id"
value={lecturer?.id ?? ''}
/>
<Combobox
items={availableLecturers}
value={lecturer}
disabled={!editing}
onValueChange={(value) => {
setLecturer(value);
if (value && !startedAt) {
setStartedAt(new Date());
}
}}
itemToStringLabel={(l) => lecturerLabel(l)}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxInput
placeholder={
editing
? 'Pilih kaprodi'
: 'Simpan jurusan terlebih dahulu'
}
disabled={!editing}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
{editing
? 'Belum ada dosen di jurusan ini.'
: 'Simpan jurusan terlebih dahulu, lalu tambahkan dosen ke jurusan ini.'}
</ComboboxEmpty>
<ComboboxList>
{(l: DepartmentLecturer) => (
<ComboboxItem key={l.id} value={l}>
{lecturerLabel(l)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.lecturer_id} />
</div>
{lecturer && (
<div className="grid gap-2">
<Label>
Mulai Menjabat{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="leadership_started_at"
value={
startedAt ? format(startedAt, 'yyyy-MM-dd') : ''
}
/>
<DatePicker value={startedAt} onChange={setStartedAt} />
<InputError message={errors.leadership_started_at} />
</div>
)}
</>
);
}
function CreateForm({
open,
onOpenChange,
lecturers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
lecturers: DepartmentLecturer[];
}) {
return (
<FormDialog
@ -222,6 +347,7 @@ function CreateForm({
</Select>
<InputError message={errors.degree_level} />
</div>
<KaprodiFields errors={errors} lecturers={lecturers} />
</div>
)}
</FormDialog>
@ -232,10 +358,12 @@ function EditForm({
open,
onOpenChange,
editing,
lecturers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Department | null;
lecturers: DepartmentLecturer[];
}) {
return (
<FormDialog
@ -297,6 +425,11 @@ function EditForm({
</Select>
<InputError message={errors.degree_level} />
</div>
<KaprodiFields
errors={errors}
lecturers={lecturers}
editing={editing}
/>
</div>
)
}

View File

@ -2,11 +2,27 @@ export const DegreeLevels = ['D3', 'D4', 'S1', 'S2', 'S3'] as const;
export type DegreeLevel = (typeof DegreeLevels)[number];
export type DepartmentLecturer = {
id: number;
lecturer_number: string;
user: { profile: { full_name: string } | null } | null;
departments: { id: number }[];
};
export type DepartmentLeadership = {
id: number;
lecturer_id: number;
started_at: string;
ended_at: string | null;
lecturer: DepartmentLecturer | null;
};
export type Department = {
id: number;
code: string;
name: string;
degree_level: DegreeLevel | null;
current_leader: DepartmentLeadership | null;
created_at: string;
updated_at: string;
};