Merge pull request 'feat: implement student status update functionality and validation' (#48) from feat/implement-student-status into dev

Reviewed-on: #48
This commit is contained in:
pangestu 2026-08-26 02:13:33 +08:00
commit c321c99ac1
9 changed files with 152 additions and 37 deletions

View File

@ -5,6 +5,7 @@
use App\Enums\StudentStatus; use App\Enums\StudentStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Users\StudentRequest; use App\Http\Requests\Admin\Users\StudentRequest;
use App\Http\Requests\Admin\Users\StudentStatusRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\User; use App\Models\User;
use App\Services\Admin\Master\DepartmentService; use App\Services\Admin\Master\DepartmentService;
@ -87,4 +88,11 @@ public function resetPassword(User $user): RedirectResponse
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kata sandi berhasil direset.'])->back(); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kata sandi berhasil direset.'])->back();
} }
public function updateStatus(StudentStatusRequest $request, User $user): RedirectResponse
{
$this->service->updateStatus($user, $request->validated('status'));
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status mahasiswa berhasil diperbarui.'])->back();
}
} }

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin\Users;
use App\Enums\StudentStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StudentStatusRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['required', 'string', Rule::in(StudentStatus::values())],
];
}
}

View File

@ -87,7 +87,6 @@ public function update(User $user, array $data): User
'department_id' => $data['department_id'], 'department_id' => $data['department_id'],
'enrollment_year' => $data['enrollment_year'], 'enrollment_year' => $data['enrollment_year'],
'academic_advisor_id' => $data['academic_advisor_id'], 'academic_advisor_id' => $data['academic_advisor_id'],
'status' => $data['status'] ?? null,
]); ]);
}); });
@ -109,4 +108,9 @@ public function resetPassword(User $user): void
'password' => Hash::make(config('app.default_password')), 'password' => Hash::make(config('app.default_password')),
]); ]);
} }
public function updateStatus(User $user, string $status): void
{
$user->student()->update(['status' => $status]);
}
} }

View File

@ -286,7 +286,35 @@ ## 6. Filter dropdown di halaman listing ✅
--- ---
## 7. Wayfinder ✅ ## 7. Validasi wajib pakai FormRequest ✅
Sekecil apapun validasinya (bahkan cuma 1 field), **jangan** pakai
`$request->validate([...])` inline di controller — selalu buat class
`FormRequest` sendiri di `app/Http/Requests/<namespace-controller>/`, meski
isinya cuma satu rule. Ini menjaga controller tetap ramping dan validasi
tetap mudah ditemukan/dites secara konsisten di satu tempat.
```php
// ❌ Jangan
public function updateStatus(Request $request, User $user): RedirectResponse
{
$data = $request->validate([
'status' => ['required', 'string', Rule::in(StudentStatus::values())],
]);
// ...
}
// ✅ Pakai
public function updateStatus(StudentStatusRequest $request, User $user): RedirectResponse
{
$this->service->updateStatus($user, $request->validated('status'));
// ...
}
```
---
## 8. Wayfinder ✅
Selalu jalankan generate dengan flag form variant, supaya `<Form Selalu jalankan generate dengan flag form variant, supaya `<Form
{...Controller.method.form()}>` tidak error saat runtime: {...Controller.method.form()}>` tidak error saat runtime:

View File

@ -1,14 +1,18 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
type StatusOption = { value: string; label: string };
type StudentStatusBadgeProps = { type StudentStatusBadgeProps = {
status: string | null | undefined; status: string | null | undefined;
}; statuses: StatusOption[];
onChange: (status: string) => void;
const StudentStatusLabels: Record<string, string> = {
active: 'Aktif',
on_leave: 'Cuti',
graduated: 'Lulus',
dropped_out: 'Drop Out',
}; };
const StudentStatusVariants: Record< const StudentStatusVariants: Record<
@ -21,14 +25,42 @@ const StudentStatusVariants: Record<
dropped_out: 'destructive', dropped_out: 'destructive',
}; };
export function StudentStatusBadge({ status }: StudentStatusBadgeProps) { export function StudentStatusBadge({
if (!status) { status,
return <span className="text-muted-foreground">-</span>; statuses,
} onChange,
}: StudentStatusBadgeProps) {
const label =
statuses.find((option) => option.value === status)?.label ??
status ??
'-';
const variant = (status && StudentStatusVariants[status]) || 'outline';
return ( return (
<Badge variant={StudentStatusVariants[status] ?? 'outline'}> <DropdownMenu>
{StudentStatusLabels[status] ?? status} <DropdownMenuTrigger asChild>
</Badge> <button
type="button"
className="cursor-pointer rounded-full border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Badge variant={variant}>{label}</Badge>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={status ?? ''}
onValueChange={onChange}
>
{statuses.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
); );
} }

View File

@ -17,16 +17,26 @@ export type Student = {
} | null; } | null;
}; };
type StatusOption = { value: string; label: string };
type CreateColumnsParams = { type CreateColumnsParams = {
handleEdit: (student: Student) => void; handleEdit: (student: Student) => void;
handleDeleteClick: (student: Student) => void; handleDeleteClick: (student: Student) => void;
handleResetPassword: (student: Student) => void; handleResetPassword: (student: Student) => void;
handleStatusChange: (student: Student, status: string) => void;
statuses: StatusOption[];
}; };
export function createStudentColumns( export function createStudentColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<Student>[] { ): ColumnDef<Student>[] {
const { handleEdit, handleDeleteClick, handleResetPassword } = params; const {
handleEdit,
handleDeleteClick,
handleResetPassword,
handleStatusChange,
statuses,
} = params;
return [ return [
{ {
@ -67,7 +77,13 @@ export function createStudentColumns(
accessorKey: 'student.status', accessorKey: 'student.status',
header: () => <span>Status</span>, header: () => <span>Status</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<StudentStatusBadge status={row.original.student?.status} /> <StudentStatusBadge
status={row.original.student?.status}
statuses={statuses}
onChange={(status) =>
handleStatusChange(row.original, status)
}
/>
), ),
}, },
{ {

View File

@ -23,7 +23,7 @@ type User = {
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: { full_name: string; phone_number: string; address: string; gender: string; birth_date: string; birth_place: string } | null;
student: { student_number: string; department_id: number; enrollment_year: number; academic_advisor_id: number; status: string | null } | null; student: { student_number: string; department_id: number; enrollment_year: number; academic_advisor_id: number; } | null;
}; };
type Props = { type Props = {
@ -198,23 +198,6 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
</Select> </Select>
<InputError message={errors.academic_advisor_id} /> <InputError message={errors.academic_advisor_id} />
</div> </div>
<div className="grid gap-2">
<Label>
Status <span className="text-red-500">*</span>
</Label>
<Select name="status" defaultValue={user.student?.status ?? undefined}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="on_leave">Cuti</SelectItem>
<SelectItem value="graduated">Lulus</SelectItem>
<SelectItem value="dropped_out">Drop Out</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.status} />
</div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,5 +1,5 @@
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { Plus, RotateCcw } from 'lucide-react'; import { Info, Plus, RotateCcw } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
@ -8,6 +8,7 @@ import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog'; import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog'; import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
@ -15,6 +16,7 @@ import {
destroy, destroy,
edit, edit,
reset_password, reset_password,
update_status,
index as studentsIndex, index as studentsIndex,
} from '@/routes/admin/users/students'; } from '@/routes/admin/users/students';
import { createStudentColumns } from './columns'; import { createStudentColumns } from './columns';
@ -116,12 +118,22 @@ export default function StudentIndex({
); );
} }
function handleStatusChange(student: Student, status: string) {
router.patch(
update_status.url(student.id),
{ status },
{ preserveScroll: true },
);
}
const columns = createStudentColumns({ const columns = createStudentColumns({
handleEdit: (student) => { handleEdit: (student) => {
router.get(edit.url(student.id)); router.get(edit.url(student.id));
}, },
handleDeleteClick: (student) => setDeleting(student), handleDeleteClick: (student) => setDeleting(student),
handleResetPassword: (student) => setResetting(student), handleResetPassword: (student) => setResetting(student),
handleStatusChange,
statuses,
}); });
return ( return (
@ -141,6 +153,15 @@ export default function StudentIndex({
} }
/> />
<Alert>
<Info />
<AlertTitle>Ubah status mahasiswa</AlertTitle>
<AlertDescription>
Klik badge Status pada tabel untuk mengubah status
mahasiswa secara langsung.
</AlertDescription>
</Alert>
<DataTable <DataTable
columns={columns} columns={columns}
data={students.data} data={students.data}

View File

@ -122,6 +122,7 @@
Route::resource('students', StudentController::class)->except(['show'])->parameters(['students' => 'user']); Route::resource('students', StudentController::class)->except(['show'])->parameters(['students' => 'user']);
Route::patch('students/{user}/reset-password', [StudentController::class, 'resetPassword'])->name('students.reset_password'); Route::patch('students/{user}/reset-password', [StudentController::class, 'resetPassword'])->name('students.reset_password');
Route::patch('students/{user}/status', [StudentController::class, 'updateStatus'])->name('students.update_status');
Route::resource('administrators', AdministratorController::class)->except(['show'])->parameters(['administrators' => 'user']); Route::resource('administrators', AdministratorController::class)->except(['show'])->parameters(['administrators' => 'user']);
Route::patch('administrators/{user}/reset-password', [AdministratorController::class, 'resetPassword'])->name('administrators.reset_password'); Route::patch('administrators/{user}/reset-password', [AdministratorController::class, 'resetPassword'])->name('administrators.reset_password');