# 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).

```php
// 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:

```php
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**

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

```php
// ❌ 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.

```php
// ❌ 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.tsx` → `DeleteConfirmDialog`.

---

## 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`):

```php
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:

```php
'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"):

   ```php
   '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.

```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:

```bash
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.
