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\Http\Requests\PaginatedRequest;
use App\Models\Department; use App\Models\Department;
use App\Services\Admin\Master\DepartmentService; use App\Services\Admin\Master\DepartmentService;
use App\Services\Admin\Users\LecturerService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -15,12 +16,14 @@ class DepartmentController extends Controller
{ {
public function __construct( public function __construct(
private readonly DepartmentService $service, private readonly DepartmentService $service,
private readonly LecturerService $lecturerService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/master/departments/index', [ return Inertia::render('admin/master/departments/index', [
'departments' => $this->service->paginated(...$request->validatedWithDefaults()), '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'], 'name' => ['required', 'string', 'max:100'],
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])], '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; namespace App\Services\Admin\Master;
use App\Models\Department; use App\Models\Department;
use App\Models\DepartmentLeadership;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -17,6 +18,7 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
{ {
return Department::query() return Department::query()
->select(['id', 'code', 'name', 'degree_level']) ->select(['id', 'code', 'name', 'degree_level'])
->with(['currentLeader.lecturer.user.profile'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage);
@ -24,7 +26,15 @@ public function paginated(int $perPage = 25, string $search = ''): LengthAwarePa
public function create(array $data): Department 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 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->degree_level = $data['degree_level'] ?? null;
$department->update(); $department->update();
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
return $department; 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 public function delete(Department $department): bool
{ {
return $department->delete(); 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) { if (canUpdate || canDelete) {

View File

@ -1,13 +1,23 @@
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog'; import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error'; 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 {
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 { import {
@ -26,7 +36,7 @@ import {
update, update,
} from '@/routes/admin/master/departments'; } from '@/routes/admin/master/departments';
import { DegreeLevels } from '@/types/department'; import { DegreeLevels } from '@/types/department';
import type { Department } from '@/types/department'; import type { Department, DepartmentLecturer } from '@/types/department';
import { createDepartmentColumns } from './columns'; import { createDepartmentColumns } from './columns';
type Props = { type Props = {
@ -37,10 +47,19 @@ type Props = {
per_page: number; per_page: number;
total: number; total: number;
}; };
lecturers: DepartmentLecturer[];
highlight?: number; 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 [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Department | null>(null); const [editing, setEditing] = useState<Department | null>(null);
const [deleting, setDeleting] = 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 <EditForm
key={editing?.id} key={editing?.id}
@ -138,6 +161,7 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
} }
}} }}
editing={editing} editing={editing}
lecturers={lecturers}
/> />
<DataTable <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({ function CreateForm({
open, open,
onOpenChange, onOpenChange,
lecturers,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
lecturers: DepartmentLecturer[];
}) { }) {
return ( return (
<FormDialog <FormDialog
@ -222,6 +347,7 @@ function CreateForm({
</Select> </Select>
<InputError message={errors.degree_level} /> <InputError message={errors.degree_level} />
</div> </div>
<KaprodiFields errors={errors} lecturers={lecturers} />
</div> </div>
)} )}
</FormDialog> </FormDialog>
@ -232,10 +358,12 @@ function EditForm({
open, open,
onOpenChange, onOpenChange,
editing, editing,
lecturers,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
editing: Department | null; editing: Department | null;
lecturers: DepartmentLecturer[];
}) { }) {
return ( return (
<FormDialog <FormDialog
@ -297,6 +425,11 @@ function EditForm({
</Select> </Select>
<InputError message={errors.degree_level} /> <InputError message={errors.degree_level} />
</div> </div>
<KaprodiFields
errors={errors}
lecturers={lecturers}
editing={editing}
/>
</div> </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 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 = { export type Department = {
id: number; id: number;
code: string; code: string;
name: string; name: string;
degree_level: DegreeLevel | null; degree_level: DegreeLevel | null;
current_leader: DepartmentLeadership | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };