store/BEST_PRACTICE.md

527 lines
19 KiB
Markdown

# Best Practices
## PHP
### Traits
- Gabungkan multiple `use` trait menjadi satu baris dengan koma.
- **Hindari:**
```php
use FlashesEntityMessage;
use ParsesDataTableQuery;
```
- **Gunakan:**
```php
use FlashesEntityMessage, ParsesDataTableQuery;
```
### Model Eloquent
- **Urutan Penulisan di dalam Model Class:**
Setiap model harus disusun mengikuti urutan struktur berikut dari atas ke bawah:
1. **Use Trait** (misal: `use HasFactory, SoftDeletes;`).
2. **Casting** (metode `casts()`).
3. **Scope** (metode local scope dengan PHP attribute `#[Scope]`).
4. **Attribute** (Accessors / Appends - misal format harga, format tanggal, dll. Harus dimasukkan dalam array `$appends` di atas class).
5. **Method Lainnya** (helper method, logic bisnis, dll.).
6. **Relasi (Relationships)** (diurutkan secara alfabetis berdasarkan nama method relasi).
- **Pengelompokan Scope:**
- Scope dikelompokkan berdasarkan kolom yang di-query.
- Urutkan berdasarkan nama kolom (abjad), lalu di dalam setiap Column Group, urutkan lagi berdasarkan nama method (abjad).
- **Contoh:** Jika ada kolom `is_active` dan `status`:
```php
// Column group: is_active (first alphabetical)
#[Scope]
public function active(Builder $query): void
{
$query->where('is_active', true);
}
#[Scope]
public function inactive(Builder $query): void
{
$query->where('is_active', false);
}
// Column group: status (second alphabetical)
#[Scope]
public function completed(Builder $query): void
{
$query->where('status', OrderStatus::COMPLETED);
}
#[Scope]
public function pending(Builder $query): void
{
$query->where('status', OrderStatus::PENDING);
}
```
- **Hubungan Timbal Balik (2-Way Relations):**
- Pastikan setiap relasi ditulis secara 2 arah (bi-directional).
- Jika suatu model memiliki `belongsTo` ke model lain, pastikan model lain tersebut juga mendefinisikan relasi kebalikannya (`hasMany` atau `hasOne`).
- **Casting:**
- Lakukan casting secara tepat pada kolom yang memerlukan tipe data khusus.
- Tipe data yang perlu di-cast: `enum`, `boolean`, `integer`, `decimal`, `date`, `datetime`, `array`, `json`, dll.
- **Jangan** cast kolom relasi (foreign key).
- **Contoh:**
```php
protected function casts(): array
{
return [
'status' => OrderStatus::class, // enum
'is_active' => 'boolean', // boolean
'amount' => 'integer', // integer
'price' => 'decimal:2', // decimal
'birth_date' => 'date', // date
'created_at' => 'datetime', // datetime
'settings' => 'array', // array/json
];
}
```
- **Scope untuk Kolom Opsi:**
- Semua kolom yang memiliki opsi/status tertentu (seperti `status`, `is_active`, `type`, dll.) **wajib** memiliki scope.
- Gunakan PHP attribute `#[Scope]` di atas metode scope.
- **Attribute untuk Format:**
- Kolom yang akan diformat (misalnya konversi harga ke format Rupiah atau format tanggal lokal) **wajib** menggunakan accessor dengan class `Attribute`.
- Nama atribut harus dimasukkan dalam properti `$appends` di atas class.
- **Contoh:** `amount``amount_formatted`, `status``status_label`.
- **Contoh Lengkap:**
```php
namespace App\Models;
use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['amount_formatted', 'status_label'])]
class Order extends Model
{
// 1. Use Trait
use HasFactory, SoftDeletes;
// 2. Casting
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'amount' => 'integer',
];
}
// 3. Scope (grouped by column, then alphabetical)
#[Scope]
public function completed(Builder $query): void
{
$query->where('status', OrderStatus::COMPLETED);
}
#[Scope]
public function pending(Builder $query): void
{
$query->where('status', OrderStatus::PENDING);
}
// 4. Attribute
public function amountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
);
}
public function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status->label(),
);
}
// 5. Method Lainnya
public static function mediaModuleName(): string
{
return 'order';
}
// 6. Relation
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
```
### Controller
- **Tugas Controller:**
- Controller hanya menangani **request dan response**.
- Semua logika bisnis (validasi aturan bisnis, query kompleks, transformasi data, authorization bisnis) **wajib** dipindahkan ke **Service**.
- Controller memanggil service, lalu mengembalikan `Inertia::render()`, `redirect()`, atau `response()->json()`.
- **Urutan Method:**
1. `index`
2. `create`
3. `store`
4. `show` (jika ada)
5. `edit`
6. `update`
7. `destroy`
8. Method tambahan (custom action) ditempatkan **di bawah** `destroy`.
- **Form Request:**
- **Selalu** gunakan Form Request untuk validasi, sekecil apapun validasinya.
- Authorization di Form Request menggunakan `$this->user()`, bukan helper global.
- **Authenticated User:**
- **Selalu** ambil user dari `$request->user()` pada method controller.
- **Jangan** gunakan `request()->user()`, `auth()->user()`, atau `Auth::user()`.
- **Docblock:**
- **Jangan** gunakan PHPDoc/docblock pada controller.
- **Penamaan:**
- Variable, method, file, dan elemen sistem lainnya **wajib** menggunakan **Bahasa Inggris**.
- Teks yang ditampilkan ke user (flash message, label UI) menggunakan **Bahasa Indonesia**.
- Kecuali nama modul yang memang sudah Bahasa Inggris (misal: `customer`, `cutting`, `order`).
- **Flash Message:**
- Gunakan helper dari trait `FlashesEntityMessage` untuk pesan standar CRUD:
- `flashCreated($entity)` → "{entity} berhasil ditambahkan."
- `flashUpdated($entity)` → "{entity} berhasil diperbarui."
- `flashDeleted($entity)` → "{entity} berhasil dihapus."
- `flashStatusUpdated($entity)` → "Status {entity} berhasil diperbarui."
- Gunakan `flashSuccess($message)` **hanya** untuk pesan custom (verifikasi owner, approve/reject, transisi status, dll.).
- **Struktur Folder:**
- Jika satu modul memiliki **lebih dari satu controller**, kelompokkan dalam subfolder modul.
- **Contoh:**
```
app/Http/Controllers/Admin/Manage/
Cutting/
CuttingController.php
CuttingDraftItemController.php
Order/
OrderController.php
OrderDraftItemController.php
Purchase/
PurchaseController.php
PurchaseDraftItemController.php
Stock/
StockController.php
RetailStockController.php
StokOpnameController.php ← modul dengan 1 controller tetap di level parent
OwnerVerificationController.php
```
- **Contoh:**
```php
namespace App\Http\Controllers\Admin\Manage\Order;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\OrderRequest;
use App\Models\Order;
use App\Services\Manage\OrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OrderController extends Controller
{
use FlashesEntityMessage, ParsesDataTableQuery;
public function __construct(
private readonly OrderService $orderService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/manage/orders/Index', [
'orders' => $this->orderService->paginateForIndex($tableQuery, $request->user()),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function store(OrderRequest $request): RedirectResponse
{
$this->orderService->create($request->validated(), $request->user());
$this->flashCreated('Pesanan');
return redirect()->route('admin.manage.orders.index');
}
public function destroy(Order $order): RedirectResponse
{
$this->orderService->delete($order);
$this->flashDeleted('Pesanan');
return redirect()->route('admin.manage.orders.index');
}
}
```
### Service
- **Tugas Service:**
- Service menampung **semua logika bisnis** (query kompleks, transformasi data, transaksi, authorization bisnis).
- Controller hanya memanggil service dan mengembalikan response.
- **Urutan Method:**
1. `paginateForIndex` / method index/list
2. `findForEdit` / `findForShow` (jika ada)
3. `create` / `store`
4. `update`
5. `delete` / `destroy`
6. Method tambahan (custom action)
7. Method `private` di paling bawah
- **Docblock & Comment:**
- **Jangan** gunakan PHPDoc/docblock pada service.
- **Jangan** gunakan inline comment tipe `@var Model $variable`.
- **Pagination:**
- Default pagination untuk data table: **25** (`->paginate(25)`).
- **Update Model:**
- Gunakan `$model->update([...])`, **bukan** assign property lalu `$model->save()`.
- **Hindari:**
```php
$account->balance = $newBalance;
$account->save();
```
- **Gunakan:**
```php
$account->update(['balance' => $newBalance]);
```
- **Logic di Model/Enum, Bukan Wrapper di Service:**
- Method yang hanya meneruskan ke model/enum **wajib** ditempatkan di model/enum, bukan di service.
- **Contoh:** `isEditable()`, `transitionStatusMessage()`, `ensureEditable()` → enum/model.
- Service memanggil langsung: `$order->status->isEditable()`, `$status->transitionStatusMessage()`, `$order->ensureEditable()`.
- **Penamaan:**
- Variable, method, file **Bahasa Inggris**.
- Pesan error/flash ke user **Bahasa Indonesia**.
## Database & Migrasi
### Struktur File Migrasi
- Gunakan *anonymous class* yang meng-extend `Migration`.
- Tulis *return type hint* `: void` secara eksplisit pada method `up()` dan `down()`.
- **Urutan & Pengelompokan Kolom (Ordering & Grouping):**
1. **Primary Key**: `$table->id()` ditaruh paling atas.
2. **Relasi (Foreign Keys / Morphs)**: Ditaruh tepat setelah Primary Key di bagian teratas.
3. **Kolom Data Utama**: Dikelompokkan secara logis menggunakan spasi (baris kosong) sebagai pemisah antar kelompok data.
4. **Timestamps & Soft Deletes**: Ditaruh paling bawah.
- **Gunakan:**
```php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('table_name', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->string('name', 100);
$table->string('description', 255)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('table_name');
}
};
```
### Definisi Kolom & Tipe Data
- **ID & Timestamps:**
- Gunakan `$table->id()` sebagai primary key.
- Gunakan `timestamp` eksplisit dengan default `useCurrent()` dan `useCurrentOnUpdate()`. **Hindari penggunaan `$table->timestamps()` bawaan Laravel agar format kolom presisi dan konsisten.**
- Tambahkan `$table->softDeletes()` (atau `deleted_at`) jika model terkait menggunakan trait `SoftDeletes`.
```php
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
```
- **Kolom Enum:**
- Hubungkan dengan PHP Backed Enum menggunakan `EnumName::values()`.
- **Contoh:**
```php
$table->enum('status', StatusEnum::values())->default(StatusEnum::PENDING->value);
```
### Relasi & Foreign Key
- Gunakan `$table->foreignId()`.
- Tentukan constraint relasi secara eksplisit dengan `constrained()` (dan sebutkan nama tabel target jika nama kolom tidak sesuai dengan nama tabel, misal: `constrained('users')`).
- Tentukan aksi penghapusan secara eksplisit (`cascadeOnDelete()`, `nullOnDelete()`, atau `restrictOnDelete()`) sesuai kebutuhan integritas data.
- **Contoh:**
```php
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
```
### Modifikasi Tabel (Alter Schema)
- Saat menambahkan kolom baru, gunakan modifier `->after('column_name')` agar tata letak kolom di database terstruktur dengan logis.
- Jika menambahkan kolom baru yang non-nullable pada tabel yang sudah berisi data, jalankan data migration di dalam method `up()` setelah schema builder:
```php
DB::table('table_name')->update(['column_name' => 'default_value']);
```
- Di dalam method `down()`, pastikan untuk melepas constraint foreign key terlebih dahulu sebelum menghapus kolom bersangkutan. Gunakan `dropConstrainedForeignId` atau `dropForeign` untuk cara yang aman dan ringkas.
- **Gunakan:**
```php
public function down(): void
{
Schema::table('table_name', function (Blueprint $table) {
$table->dropConstrainedForeignId('foreign_key_id');
$table->dropColumn(['column_one', 'column_two']);
});
}
```
## JavaScript / Vue
### Komponen Reusable vs Page-Specific
- `resources/js/components/` → komponen **reusable** lintas page (UI primitives, catalog POS, printer, display formatter, dll.).
- `resources/js/pages/{module}/form/` dan `table/` → komponen **khusus page** (modal form, columns, actions).
- Jika komponen dipakai **lebih dari satu modul/page**, pindahkan ke `resources/js/components/{domain}/`.
- Contoh: `PosCatalogCard`, `PosCatalogVariantThumb``components/catalog/`
- Contoh: `OrderPrintButton`, `ThermalPrinterConnectButton``components/order/`
### Format Data dan Attribute Model
- Untuk **tampilan read-only**, gunakan field `*_formatted` dari API/model accessor.
- **Jangan** format manual di view jika accessor model sudah tersedia (misal: `stock_formatted`, `price_formatted`).
- Format **live/calc** di form (cart total, input rupiah) boleh pakai `formatRupiah()` / `RupiahInput`.
- Komponen display: `RupiahText` (`amount` + optional `formatted` prop).
### Types
- Type domain di `resources/js/types/{domain}.ts`.
- Type shared di `resources/js/types/common.ts`: `Paginated<T>`, `SelectOption`, `EnumOption`.
- Hindari duplikasi type di page; import dari `@/types/*`.
### Form Request dan View Label
- Label validasi harus **sama persis** dengan `<FieldLabel>` di view.
- Gunakan `attributes()` pada Form Request (locale `id``:Attribute wajib diisi.`).
- Contoh: view `Nama Produk``'name' => 'Nama Produk'`.
- Pesan custom untuk rule nested/business logic gunakan `messages()`.
### Pemisahan File
- Pecah file besar berdasarkan **fungsi**: catalog panel, cart panel, metadata form, composable logic.
- Target ideal: **< 300 baris** per komponen.
### Struktur Folder Page-Specific Components
Komponen yang hanya digunakan oleh satu page tertentu (misalnya modal form, kolom tabel, action button) **harus ditempatkan di folder page-nya langsung** di dalam subfolder sesuai fungsinya, bukan di `resources/js/components/`.
Folder `resources/js/components/` hanya untuk komponen yang **reusable** lintas page (seperti UI primitives, DataTable, ConfirmDialog, dll).
Subfolder di dalam page folder dikelompokkan berdasarkan fungsi:
- `form/` modal form, form fields
- `table/` columns definition, data-table-actions, cell renderers
- **Hindari:**
```
resources/js/components/admin/master/categories/
CategoryFormModal.vue
columns.ts
data-table-actions.vue
resources/js/pages/admin/master/categories/
Index.vue
```
- **Gunakan:**
```
resources/js/pages/admin/master/categories/
Index.vue
form/
CategoryFormModal.vue
table/
columns.ts
data-table-actions.vue
```
### Import Path
Gunakan relative import (`./`) untuk file yang berada di folder/subfolder yang sama, bukan absolute path `@/...`.
- **Hindari:**
```ts
import CategoryFormModal from '@/components/admin/master/categories/CategoryFormModal.vue';
```
- **Gunakan:**
```ts
// Dari Index.vue ke subfolder
import CategoryFormModal from './form/CategoryFormModal.vue';
import { createColumns } from './table/columns';
// Dari columns.ts ke file satu folder
import DataTableActions from './data-table-actions.vue';
```
### Pola CRUD (Create/Edit)
Gunakan **modal** untuk CRUD yang sederhana (sedikit field, tidak ada nested data complex), dan **page terpisah** untuk CRUD yang kompleks.
- **Modal** form sederhana, biasanya 1-3 field, tidak memerlukan layout khusus:
```
pages/admin/master/categories/
Index.vue
form/
CategoryFormModal.vue modal form
table/
columns.ts
data-table-actions.vue
```
Contoh: categories (1 field), suppliers (3 field), customers (3 field)
- **Page terpisah** form kompleks, banyak field, nested data, file upload, atau memerlukan layout khusus:
```
pages/admin/master/raw-materials/
Index.vue
Create.vue halaman tambah
Edit.vue halaman ubah
form/
RawMaterialForm.vue form component
table/
RawMaterialGroupedTable.vue
data-table-actions.vue
raw-material-status-toggle.vue
```
Contoh: raw-materials (nested prices, multi-image upload, dynamic variant rows)