feat: implement student status update functionality and validation
Some checks failed
tests / ci (pull_request) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-08-26 01:13:18 +07:00
parent 3418b3c44e
commit d59e19ed3e
9 changed files with 152 additions and 37 deletions

View File

@ -5,6 +5,7 @@
use App\Enums\StudentStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Users\StudentRequest;
use App\Http\Requests\Admin\Users\StudentStatusRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\User;
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();
}
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'],
'enrollment_year' => $data['enrollment_year'],
'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')),
]);
}
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
{...Controller.method.form()}>` tidak error saat runtime:

View File

@ -1,14 +1,18 @@
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 = {
status: string | null | undefined;
};
const StudentStatusLabels: Record<string, string> = {
active: 'Aktif',
on_leave: 'Cuti',
graduated: 'Lulus',
dropped_out: 'Drop Out',
statuses: StatusOption[];
onChange: (status: string) => void;
};
const StudentStatusVariants: Record<
@ -21,14 +25,42 @@ const StudentStatusVariants: Record<
dropped_out: 'destructive',
};
export function StudentStatusBadge({ status }: StudentStatusBadgeProps) {
if (!status) {
return <span className="text-muted-foreground">-</span>;
}
export function StudentStatusBadge({
status,
statuses,
onChange,
}: StudentStatusBadgeProps) {
const label =
statuses.find((option) => option.value === status)?.label ??
status ??
'-';
const variant = (status && StudentStatusVariants[status]) || 'outline';
return (
<Badge variant={StudentStatusVariants[status] ?? 'outline'}>
{StudentStatusLabels[status] ?? status}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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;
};
type StatusOption = { value: string; label: string };
type CreateColumnsParams = {
handleEdit: (student: Student) => void;
handleDeleteClick: (student: Student) => void;
handleResetPassword: (student: Student) => void;
handleStatusChange: (student: Student, status: string) => void;
statuses: StatusOption[];
};
export function createStudentColumns(
params: CreateColumnsParams,
): ColumnDef<Student>[] {
const { handleEdit, handleDeleteClick, handleResetPassword } = params;
const {
handleEdit,
handleDeleteClick,
handleResetPassword,
handleStatusChange,
statuses,
} = params;
return [
{
@ -67,7 +77,13 @@ export function createStudentColumns(
accessorKey: 'student.status',
header: () => <span>Status</span>,
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;
email: string;
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 = {
@ -198,23 +198,6 @@ export default function StudentEdit({ user, departments, lecturers }: Props) {
</Select>
<InputError message={errors.academic_advisor_id} />
</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>
</Card>

View File

@ -1,5 +1,5 @@
import { Head, router } from '@inertiajs/react';
import { Plus, RotateCcw } from 'lucide-react';
import { Info, Plus, RotateCcw } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
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 { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
@ -15,6 +16,7 @@ import {
destroy,
edit,
reset_password,
update_status,
index as studentsIndex,
} from '@/routes/admin/users/students';
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({
handleEdit: (student) => {
router.get(edit.url(student.id));
},
handleDeleteClick: (student) => setDeleting(student),
handleResetPassword: (student) => setResetting(student),
handleStatusChange,
statuses,
});
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
columns={columns}
data={students.data}

View File

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