siakad-itm/docs/code-style.md
Yoga Pangestu d59e19ed3e
Some checks failed
tests / ci (pull_request) Has been cancelled
feat: implement student status update functionality and validation
2026-08-26 01:13:18 +07:00

13 KiB

Panduan Gaya Kode

Dokumen ini mencatat konvensi penulisan kode di proyek ini, supaya kontributor (termasuk AI assistant) mengikuti pola yang sama dan tidak menulis ulang solusi yang sudah ada dengan cara berbeda-beda di tiap file.

Setiap bagian punya status:

  • Selesai — sudah diterapkan di seluruh kode yang relevan.
  • 🚧 Sebagian — sudah ada polanya, tapi belum diterapkan di semua tempat.
  • 📝 Rencana — baru berupa kesepakatan, belum diterapkan.

1. Enum

Aturan: setiap backed enum (enum X: string) wajib pakai trait App\Enums\Concerns\HasValues. Jangan pernah menulis array_values(X::cases()) atau array_column(X::cases(), 'value') secara manual — pakai X::values() (atau X::options() kalau butuh label untuk dropdown/select).

// app/Enums/Concerns/HasValues.php
trait HasValues
{
    public static function values(): array   // ['male', 'female']
    public static function options(): array   // [{value: 'male', label: 'Laki-laki'}, ...]
}

Struktur enum standar:

enum Gender: string
{
    use HasValues;

    case Male = 'male';
    case Female = 'female';

    public function label(): string
    {
        return match ($this) {
            self::Male => 'Laki-laki',
            self::Female => 'Perempuan',
        };
    }
}

Sebelum → Sesudah

// ❌ Jangan
'gender' => ['required', 'string', Rule::in(array_values(Gender::cases()))],
'type' => ['required', 'string', Rule::in(array_column(FeedbackType::cases(), 'value'))],

// ✅ Pakai
'gender' => ['required', 'string', Rule::in(Gender::values())],
'type' => ['required', 'string', Rule::in(FeedbackType::values())],

Untuk data yang dikirim ke frontend (mis. isi <Select>), pakai options() alih-alih membangun array {value, label} manual dengan array_map:

// ❌ Jangan
'types' => array_map(fn (FeedbackType $type) => [
    'value' => $type->value,
    'label' => $type->label(),
], FeedbackType::cases()),

// ✅ Pakai
'types' => FeedbackType::options(),

Enum baru wajib langsung pakai use HasValues; sejak awal dibuat, tidak perlu menunggu ada kebutuhan values()/options() baru ditambahkan.


2. Migration

Aturan: kolom enum() di migration wajib pakai EnumClass::values(), konsisten dengan aturan Enum di atas. Kolom ->default() wajib pakai EnumClass::Case->value (string), bukan objek case-nya langsung.

// ❌ Jangan
$table->enum('status', array_values(StudentStatus::cases()))
    ->nullable()
    ->default(StudentStatus::Active); // objek enum, bukan string

// ✅ Pakai
$table->enum('status', StudentStatus::values())
    ->nullable()
    ->default(StudentStatus::Active->value);

3. Bahasa UI

Seluruh teks yang tampil ke pengguna (label, judul halaman, pesan toast, placeholder, pesan error) ditulis dalam Bahasa Indonesia. Istilah teknis dan identifier kode tetap dalam bahasa aslinya (bahasa Inggris), tapi teks yang dibaca pengguna — termasuk teks di halaman auth/settings bawaan starter-kit yang aslinya berbahasa Inggris — harus diterjemahkan supaya konsisten dengan sisa aplikasi.


4. Struktur modul Admin

Semua fitur berada di bawah /admin — tidak ada lagi controller atau halaman yang menggantung di top-level App\Http\Controllers / resources/js/pages/ di luar folder admin/.

Aturan utama: namespace/folder PHP (Controller, Request, Service) dan folder halaman Inertia (resources/js/pages/admin/...) SELALU mengikuti prefix route di routes/admin.php — satu grup di sidebar (app-sidebar.tsx) = satu prefix route = satu namespace. Jangan menebak dari label sidebar; selalu cek dulu prefix route-nya sebelum menaruh file baru.

Grup sidebar Namespace Controller/Request/Service Prefix route
Pengguna Admin\Users admin/users/*
Master Admin\Master admin/master/*
Kelas Admin\AcademicClasses admin/academic-classes/*
Kelola Admin\Manage admin/manage/*
Pengumuman Admin\Announcements admin/announcements
Keuangan Admin\Finances admin/finances/*
Layanan Admin\Services admin/services/*
Pengaturan Admin\Settings admin/settings/*
Resource mandiri (navSecondary, bukan grup di navMain) Admin (langsung, tanpa sub-namespace) admin/<resource>

"Resource mandiri" dipakai untuk fitur yang tidak masuk ke grup sidebar manapun di navMain — misalnya Admin\FeedbackController di admin/feedback (dari menu navSecondary, data personal milik user yang sedang login, bukan data akademik yang dikelola admin atas user lain).

Nama route mengikuti pola admin.<grup>.<resource>.<action> (atau admin.<resource>.<action> untuk resource mandiri, atau admin.<grup>.<action> langsung kalau grup itu cuma punya satu resource yang namanya sama dengan grupnya sendiri — lihat pola Pengumuman/AnnouncementController di routes/admin.php, supaya URL-nya tidak jadi admin/announcements/announcements yang berulang). File Wayfinder hasil generate mengikuti struktur folder yang sama di resources/js/routes/admin/<grup>/<resource>/, dan folder halaman Inertia-nya di resources/js/pages/admin/<grup>/<resource>/.

Kalau satu Service dipakai lintas grup (mis. CourseClassService — miliknya grup Kelola — dipakai juga oleh controller-controller di grup Kelas untuk mengisi dropdown), itu boleh, tetap use dari namespace aslinya (App\Services\Admin\Manage\CourseClassService) — jangan dipindah/duplikasi hanya karena dipakai controller di grup lain.

Halaman CRUD frontend memakai pola: PageHeader (judul + tombol Tambah) → FormDialog (Create/Edit) → DataTable + columns.tsxDeleteConfirmDialog.


5. Service

Method listing berpaginasi wajib bernama paginated(), bukan getPaginated() atau nama lain. Signature-nya seragam di seluruh Service (kecuali ada parameter domain tambahan di depan, seperti User $user di FeedbackService):

public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator

Jangan tambahkan parameter array $filters = [] kalau tidak benar-benar dipakai di dalam method — PaginatedRequest::validatedWithDefaults() cuma menghasilkan perPage/search/sort/direction, jadi parameter $filters di banyak Service sebelumnya selalu kosong dan tidak pernah terisi oleh controller manapun.

Controller yang memanggilnya selalu pakai PaginatedRequest + spread:

'items' => $this->service->paginated(...$request->validatedWithDefaults()),

Method yang mengembalikan daftar penuh tanpa paginasi dibedakan sesuai kegunaannya:

  • getAllForSelect() — dipakai kalau hasilnya untuk mengisi dropdown <Select> di form fitur lain (mis. DepartmentService::getAllForSelect() dipakai di form Course/Lecturer/Student, bukan di halaman Department sendiri).
  • Nama sesuai domain — kalau daftar itu justru jadi listing utama halaman itu sendiri (bukan sumber dropdown fitur lain) dan memang tidak butuh paginasi, pakai nama yang menjelaskan isinya, mis. ScheduleService::all() (listing halaman jadwal) atau AttendanceService::sessions() (listing sesi presensi).

Jangan pernah menulis getAll() polos — nama itu ambigu antara dua kasus di atas.

Urutan method standar dalam satu Service: getAllForSelect() (kalau ada) → paginated()create()update()delete() → helper private di paling bawah.


6. Filter dropdown di halaman listing

Tombol filter (ikon corong di samping search box) memakai dialog, bukan popover — komponennya resources/js/components/filter-dialog.tsx (<FilterDialog fields={...} activeFilters={filters} onApply={applyFilters} /> dilewatkan lewat prop toolbar milik <DataTable>, otomatis nongol di sebelah kanan search box).

Sudah diterapkan di semua halaman listing yang punya field layak difilter:

Halaman Field filter
Administrator/Dosen/Mahasiswa Jenis Kelamin (+ Jurusan, Status untuk Dosen/Mahasiswa)
Periode Akademik Semester, Status Aktif
Registrasi KRS Status, Periode Akademik
Materi, Tugas Kelas
Mata Kuliah Jurusan
Kelas Mata Kuliah Periode Akademik, Metode
Pengumuman Jurusan
Tagihan Periode Akademik
Surat Permohonan Status
Bimbingan Akademik Dosen
Kritik dan Saran Jenis, Status

Halaman tanpa field yang layak difilter (mis. Jurusan/Master — tabelnya kecil, tidak butuh filter) sengaja tidak diberi FilterDialog.

Perilaku UI (jangan diubah tanpa alasan kuat):

  • Langsung diterapkan — begitu satu field di dalam dialog dipilih (onValueChange), filter langsung jalan (navigasi Inertia), tidak ada tombol "Terapkan" terpisah yang harus diklik dulu.
  • Field filter disusun 2 kolom per baris (grid grid-cols-2 gap-4) kalau field-nya lebih dari satu; kalau cuma 1 field, 1 kolom saja (grid gap-4) — jangan sisakan slot kosong di grid.
  • Tombol "Hapus Filter" ada DI LUAR dialog, sejajar di samping tombol ikon filter (bukan di footer dialog), berupa tombol teks (variant="ghost"
    • label "Hapus Filter"), dan cuma muncul kalau ada filter yang aktif.

Backend:

  1. Field filter (mis. gender, department_id, status) didaftarkan di App\Http\Requests\PaginatedRequest::rules() sebagai nullable — request ini dipakai bersama oleh semua halaman listing, jadi field filter yang sifatnya umum (dipakai lebih dari satu fitur) taruh di sini alih-alih bikin FormRequest baru per halaman.

  2. Service::paginated() menerima parameter filter tambahan sebagai parameter bernama opsional di akhir signature (setelah $perPage/$search/$sort/$direction), mis. paginated(..., ?string $gender = null, ?int $departmentId = null), lalu diterapkan dengan ->when($gender, fn ($q) => ...).

  3. Controller memanggilnya secara eksplisit per parameter — jangan nge-spread seluruh $request->validated() mentah-mentah ke paginated(), karena tidak semua Service menerima semua field filter (bisa error "Unknown named parameter"):

    'students' => $this->service->paginated(
        ...$request->validatedWithDefaults(),
        gender: $request->validated('gender'),
        departmentId: $request->validated('department_id'),
        status: $request->validated('status'),
    ),
    'filters' => $request->only(['gender', 'department_id', 'status']),
    

Frontend:

  1. Props.filters menampung nilai filter yang sedang aktif (dari query string, dikirim controller lewat $request->only([...])).
  2. useServerTable({..., filters}) — prop filters diteruskan apa adanya dari Props.filters (bukan useState terpisah, karena Inertia sudah selalu mengirim prop terbaru setiap navigasi).
  3. Definisikan filterFields: FilterField[] (key, label, options) sesuai data yang tersedia di halaman itu (mis. departments dari prop untuk Select jurusan), lalu render <FilterDialog fields={filterFields} activeFilters={filters} onApply={applyFilters} /> sebagai toolbar di <DataTable>.

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.

// ❌ 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:

php artisan wayfinder:generate --with-form

vite.config.ts sudah diset wayfinder({ formVariants: true }) sehingga dev-server otomatis benar. Flag --with-form hanya wajib diingat kalau generate manual lewat CLI.