329 lines
13 KiB
Markdown
329 lines
13 KiB
Markdown
# 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.
|