# Code Conventions

> Aturan-aturan yang WAJIB diikuti saat menulis kode.

---

## Namespace & Import Convention

### Backend → SELALU pakai `use` import di atas file
```php
// ✅ SELALU import class di atas file, gunakan nama pendek di kode
use App\Models\Product;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

#[Guarded(['id'])]
class Product extends Model
{
    #[Scope]
    protected function active(Builder $query): void { ... }
}

// ❌ JANGAN tulis namespace lengkap di deklarasi atribut/class
#[\Illuminate\Database\Eloquent\Attributes\Scope]  // JANGAN
protected function active(\Illuminate\Database\Eloquent\Builder $query): void { ... }

// ❌ JANGAN import di tengah file atau inline
class Product extends \App\Models\Model { ... }  // JANGAN
```

### Aturan Import
```php
// ✅ Urutan import (group by namespace):
// 1. PHP built-in (DateTime, etc)
// 2. Laravel/Framework (Illuminate\*)
// 3. App (App\Models, App\Services, etc)
// 4. Third-party (Spatie, etc)

// ✅ Untuk atribut PHP 8+ → gunakan short name
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Casts\Attribute;

#[Scope]      // ✅ short name
#[Appends]    // ✅ short name

// ✅ Untuk type hint → gunakan short name
public function __construct(
    private ProductService $service,  // ✅ short name
) {}

// ❌ JANGAN
public function __construct(
    private \App\Services\Admin\Master\Product\ProductService $service,  // JANGAN
) {}
```

---

## Language Convention

### Backend → English
Variabel, function, kolom database, class, method — **SEMUA pakai English**:

```php
// ✅ Variable
$orderId, $totalAmount, $customerName

// ✅ Function/Method
public function getOrders(): Collection
public function calculateTotal(): int

// ✅ Database columns
$table->string('order_number');
$table->decimal('total_amount');

// ✅ Class names
class OrderService { ... }
class OrderRequest extends FormRequest { ... }
```

### Frontend (UI) → Indonesian
Teks yang ditampilkan ke user — **pakai Bahasa Indonesia**:

```tsx
// ✅ Labels
<Label>Nama Produk</Label>
<Label>Harga Jual</Label>

// ✅ Placeholders
<Input placeholder="Masukkan nama" />

// ✅ Buttons
<Button>Tambah</Button>
<Button>Simpan</Button>
<Button>Hapus</Button>

// ✅ Flash messages
'Produk berhasil ditambahkan'
'Data berhasil dihapus'

// ✅ Status labels (di Badge)
<OrderStatusBadge status={order.status} />  // "Menunggu", "Selesai"

// ✅ Page titles
<Head title="Daftar Produk" />
<Head title="Tambah Transaksi" />
```

### Contoh Perbandingan
| Elemen | English (Backend) | Indonesian (UI) |
|--------|-------------------|-----------------|
| Variable | `$customerName` | — |
| DB column | `customer_name` | — |
| Method | `getCustomerName()` | — |
| Label | — | `Nama Customer` |
| Placeholder | — | `Masukkan nama` |
| Button | — | `Simpan`, `Hapus` |
| Flash | — | `Berhasil disimpan` |
| Status | `OrderStatus::PENDING` | `Menunggu` |

---

## Model

### Mass Assignment
```php
// ✅ SELALU pakai #[Guarded(['id'])]
#[Guarded(['id'])]
class Product extends Model { ... }

// ❌ JANGAN pakai $fillable
protected $fillable = ['name', 'status']; // JANGAN
```

### Urutan Isi Model
```php
use Illuminate\Database\Eloquent\Casts\Attribute;

// Attributes di ATAS class declaration
#[Guarded(['id'])]
#[Appends(['formatted_name'])]
class Product extends Model
{
    use HasFactory, SoftDeletes;

    // 1. Casts (method, wajib untuk semua tipe yang perlu casting)
    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'is_active' => 'boolean',
        ];
    }

    // 2. Scopes (order abjad, gunakan atribut #[Scope])
    #[Scope]
    protected function active(Builder $query): void
    {
        $query->where('is_active', true);
    }

    #[Scope]
    protected function draft(Builder $query): void
    {
        $query->where('status', ProductStatus::DRAFT);
    }

    // 3. Mutators & Accessors (order abjad, NAMA HARUS BERBEDA dari kolom DB)
    // TIDAK perlu accessor untuk label — Enum sudah handle via ->label()
    protected function formattedName(): Attribute
    {
        return Attribute::make(
            get: fn () => ucfirst($this->name),
        );
    }

    // 4. Relationships (order abjad, WAJIB 2 ARAH)
    public function categories(): BelongsToMany { ... }
    public function productVariants(): HasMany { ... }
}

// ✅ Trait stacking — gabungkan dengan koma
use HasFactory, SoftDeletes;  // 1 baris, pisah koma
```

### Casting — Wajib untuk Semua Tipe
```php
// ✅ Selalu cast kolom yang perlu — gunakan METHOD syntax
protected function casts(): array
{
    return [
        // Enum → gunakan enum class
        'status' => OrderStatus::class,
        'type' => CashTransactionType::class,

        // Currency/angka → integer (tanpa desimal)
        'amount' => 'integer',
        'price' => 'integer',
        'quantity' => 'integer',

        // Date → format Y-m-d
        'join_date' => 'date:Y-m-d',

        // Boolean
        'is_active' => 'boolean',
        'is_affiliate' => 'boolean',

        // Array (JSON)
        'marketplace_settings_snapshot' => 'array',
        'payload' => 'array',

        // DateTime
        'verified_at' => 'datetime',
        'paid_at' => 'datetime',
    ];
}

// ❌ JANGAN pakai property syntax
protected $casts = [...]; // JANGAN

// ❌ JANGAN biarkan kolom tanpa cast jika tipenya perlu
'amount' => 'integer',  // ✅ benar
// 'amount' => '',       // ❌ salah — tidak dicast
```

### Enum untuk Data Opsi
```php
// ✅ SELALU gunakan Enum untuk data yang punya opsi tetap
enum OrderStatus: string
{
    use HasValues;

    case PENDING = 'pending';
    case PROCESSING = 'processing';
    case COMPLETED = 'completed';
    case CANCELLED = 'cancelled';
    case REFUNDED = 'refunded';

    public function label(): string
    {
        return match ($this) {
            self::PENDING => 'Menunggu',
            self::PROCESSING => 'Diproses',
            self::COMPLETED => 'Selesai',
            self::CANCELLED => 'Dibatalkan',
            self::REFUNDED => 'Dikembalikan',
        };
    }
}

// ✅ JANGAN pakai string biasa untuk status
// ❌ 'status' => 'pending'  // tanpa enum
// ✅ 'status' => OrderStatus::PENDING  // dengan enum
```

### Accessor & Mutator — Nama harus BERBEDA dari kolom DB
```php
// ✅ Nama accessor TIDAK BOLEH sama dengan nama kolom di DB
// Format: prefix 'formatted_' atau suffix '_label'
// Gunakan Attribute::make(get: fn () => ...)

use Illuminate\Database\Eloquent\Casts\Attribute;

// Currency → prefix 'formatted_' + 'Rp ' prefix
protected function formattedAmount(): Attribute
{
    return Attribute::make(
        get: fn () => 'Rp ' . number_format($this->amount, 0, ',', '.'),
    );
}

// Number (bukan rupiah) → prefix 'formatted_'
protected function formattedStock(): Attribute
{
    return Attribute::make(
        get: fn () => number_format($this->stock, 0, ',', '.'),
    );
}

// Date → prefix 'formatted_' + format 'l, d F Y'
protected function formattedJoinDate(): Attribute
{
    return Attribute::make(
        get: fn () => $this->join_date?->translatedFormat('l, d F Y'),
    );
}

// DateTime → prefix 'formatted_' + format 'l, d F Y H:i'
protected function formattedCheckInAt(): Attribute
{
    return Attribute::make(
        get: fn () => $this->check_in_at?->translatedFormat('l, d F Y H:i'),
    );
}

// String → prefix 'formatted_'
protected function formattedName(): Attribute
{
    return Attribute::make(
        get: fn () => ucfirst($this->name),
    );
}

// Phone → prefix 'formatted_' (get + set)
protected function formattedPhoneNumber(): Attribute
{
    return Attribute::make(
        get: fn () => $this->phone_number
            ? preg_replace('/(\d{4})(?=\d)/', '$1 ', $this->phone_number)
            : null,
        set: fn ($value) => preg_replace('/\s/', '', $value),
    );
}

// Month name → suffix '_name'
protected function monthName(): Attribute
{
    return Attribute::make(
        get: fn () => $this->month ? Carbon::create()->month($this->month)->translatedFormat('F') : null,
    );
}

// Label → suffix '_label' (TIDAK pakai formatted_)
// TIDAK PERLU accessor manual — Enum sudah punya method label()
// Cukup panggil langsung: $this->status->label()
// Tidak perlu buat statusLabel() accessor

// ❌ JANGAN: amount() — bentrok dengan kolom 'amount'
// ❌ JANGAN: name() — bentrok dengan kolom 'name'
// ✅ BENAR: formattedAmount(), formattedName()
```

### Accessor Format Rules
```php
// ✅ Format yang WAJIB diikuti untuk accessor:

// 1. Date (kolom date) → 'l, d F Y'
//    Contoh: "Selasa, 25 Agu 2026"
$this->join_date?->translatedFormat('l, d F Y')

// 2. DateTime (kolom datetime) → 'l, d F Y H:i'
//    Contoh: "Selasa, 25 Agu 2026 12:12"
$this->check_in_at?->translatedFormat('l, d F Y H:i')

// 3. Currency (rupiah) → 'Rp ' + number_format(0, ',', '.')
//    Contoh: "Rp 1.000.000"
'Rp ' . number_format($this->amount, 0, ',', '.')

// 4. Number (angka biasa) → number_format(0, ',', '.')
//    Contoh: "1.000"
number_format($this->stock, 0, ',', '.')

// 5. Month name → Carbon translatedFormat('F')
//    Contoh: "Januari", "Februari"
Carbon::create()->month($this->month)->translatedFormat('F')
```

### Appends — Data yang Ditampilkan
```php
// ✅ Gunakan snake_case (sama dengan nama accessor)
#[Appends(['formatted_amount', 'formatted_join_date'])]

// ❌ JANGAN append kolom yang sudah ada di DB (hanya untuk accessor)
// ❌ Appends(['amount'])  // amount sudah ada di DB
// ✅ Appends(['formatted_amount'])  // formatted_amount adalah accessor
```

### Scopes — untuk Data Opsi
```php
// ✅ SELALU buat scope untuk data yang punya opsi
// Gunakan atribut #[Scope] dan return type void

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Scope;

// Untuk enum
#[Scope]
protected function pending(Builder $query): void
{
    $query->where('status', OrderStatus::PENDING);
}

// Untuk boolean
#[Scope]
protected function active(Builder $query): void
{
    $query->where('is_active', true);
}

// ❌ JANGAN: public function pending(Builder $query): Builder
// ❌ JANGAN: scopePending (pakai prefix 'scope')
// ✅ BENAR: #[Scope] + protected function pending(Builder $query): void
```

### Relationships — WAJIB 2 ARAH
```php
// ✅ Setiap relasi HARUS ditulis di KEDUA model

// Model: User
public function userProfile(): HasOne
{
    return $this->hasOne(UserProfile::class);
}

// Model: UserProfile (2 ARAH!)
public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

// ✅ Contoh relasi 2 arah:
// User ↔ UserProfile
// User ↔ Employee
// User ↔ CashAccount (created_by_id)
// CashAccount ↔ CashTransaction
// CashTransaction ↔ Expense (1:1)
// Order ↔ OrderItem
// Cutting ↔ CuttingMaterial
// dll.

// ❌ JANGAN hanya tulis di salah 1 model
// Jika ada User::userProfile(), maka HARUS ada UserProfile::user()
```

### Relationship Naming
```php
// BelongsTo → singular
user()          // → BelongsTo User
cashAccount()   // → BelongsTo CashAccount

// HasMany → plural
orders()        // → HasMany Order
payrolls()      // → HasMany Payroll

// HasOne → singular
userProfile()   // → HasOne UserProfile
employee()      // → HasOne Employee

// Custom FK → pastikan ada parameter
createdCuttings()   // → HasMany Cutting, 'created_by_id'
submittedCuttings() // → HasMany Cutting, 'submitted_by_id'
```

### Eloquent Select & Eager Loading — Selalu Select yang Dibutuhkan
```php
// ✅ SELALU select kolom yang dibutuhkan saja — gunakan array syntax
$orders = Order::select(['id', 'order_number', 'total_amount', 'status'])
    ->with(['customer:id,name', 'createdBy:id,username'])
    ->get();

// ✅ Eager load relasi yang dibutuhkan
$products = Product::with([
    'categories:id,name',
    'productVariants:id,product_id,name,stock',
])->get();

// ❌ JANGAN: Product::all() — load semua kolom + semua relasi
// ❌ JANGAN: Order::with(['customer', 'createdBy', 'orderItems', 'cashTransaction'])->get() — terlalu banyak relasi

// ✅ Pilih relasi yang benar-benar ditampilkan di view
$orders = Order::select(['id', 'order_number', 'total_amount', 'status', 'customer_id', 'created_by_id'])
    ->with([
        'customer:id,name',
        'createdBy:id,username',
    ])
    ->orderBy($sort, $direction)
    ->paginate($perPage);
```

---

## Controller

### SELALU Gunakan Return Type
```php
// ✅ Return type wajib ada di SEMUA method — controller & service
// ✅ Response untuk controller, void/array/bool untuk service
```

### ZONK — Tidak Ada Logic di Controller
```php
// ✅ Controller HANYA mengatur lalu lintas
// Seluruh logic ADA DI SERVICE

class ProductController extends Controller
{
    public function __construct(
        private ProductService $service
    ) {}

    public function index(Request $request): Response
    {
        return Inertia::render('Admin/Master/Products/Index', [
            'products' => $this->service->paginated(
                perPage: $request->input('per_page', 25),
                search: $request->input('search', ''),
                sort: $request->input('sort', 'created_at'),
                direction: $request->input('direction', 'desc'),
            ),
        ]);
    }

    public function store(ProductRequest $request): Response
    {
        $this->service->store($request->validated());

        Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditambahkan']);

        return back();
    }
}

// ❌ JANGAN ada logic di controller, SEKECIL APAPUN
// ❌ $name = strtoupper($request->name);  // logic → pindah ke service
// ❌ if ($request->hasFile('photo')) { ... }  // logic → pindah ke service

// ✅ Jika ada yang看似simple seperti resetPassword, tetap pindah ke service
public function resetPassword(UserRequest $request, User $user): Response
{
    $this->service->resetPassword($user, $request->validated());

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Password berhasil direset']);

    return back();
}
```

### Urutan Method
```php
class ProductController extends Controller
{
    // 1. __construct()
    public function __construct(private ProductService $service) {}

    // 2. index()
    public function index(Request $request): Response { ... }

    // 3. create()
    public function create(): Response { ... }

    // 4. store()
    public function store(ProductRequest $request): Response { ... }

    // 5. show() — jika ada
    public function show(Product $product): Response { ... }

    // 6. edit()
    public function edit(Product $product): Response { ... }

    // 7. update()
    public function update(ProductRequest $request, Product $product): Response { ... }

    // 8. destroy()
    public function destroy(Product $product): Response { ... }

    // 9. Custom actions (jika ada, JANGAN dipaksakan)
    public function toggleStatus(Product $product): Response { ... }
    public function resetPassword(UserRequest $request, User $user): Response { ... }
}
```

### Return ke Halaman yang Sama — Gunakan `back()`
```php
// ✅ SELALU gunakan Inertia::flash() sebelum return back()
public function destroy(Product $product)
{
    $this->service->destroy($product);

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus']);

    return back();
}

// ❌ JANGAN pakai ->with()
return back()->with('success', 'Produk berhasil dihapus');  // JANGAN

// ✅ Untuk error di handleAction
return $this->handleAction(
    fn () => $this->service->store($data),
    'Data berhasil ditambahkan',
    route('admin.master.products.index')
);
// handleAction sudah handle flash otomatis

// ✅ Untuk redirect ke halaman lain
return to_route('dashboard');  // ✅ hanya jika memang pindah halaman
```

### Jangan Panggil Model Langsung — Gunakan Service
```php
// ❌ JANGAN panggil Model langsung di Controller
public function index()
{
    $categories = Category::all();  // JANGAN
    return Inertia::render('...', compact('categories'));
}

// ✅ SELALU gunakan Service
public function index()
{
    $categories = $this->categoryService->getAll();  // ✅
    return Inertia::render('...', compact('categories'));
}
```

### DB Transaction — Gunakan `handleAction()` untuk 2+ Query
```php
// ✅ handleAction() → untuk 2+ query/operasi dalam 1 action
public function store(OrderRequest $request): Response
{
    return $this->handleAction(
        fn () => $this->service->store($request->validated()),
        'Transaksi berhasil ditambahkan',
        route('admin.manage.transactions.index')
    );
}

// ✅ handleAction() otomatis wrap dalam DB::transaction()
// ✅ Jika gagal, otomatis rollback + flash error

// ✅ 1 query → manual flash (tanpa handleAction)
public function destroy(Product $product): Response
{
    $this->service->destroy($product);

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus']);

    return back();
}
```

### Service Property — Constructor Promotion
```php
// ✅ Gunakan constructor promotion
public function __construct(
    private ProductService $service
) {}

// ❌ JANGAN
private ProductService $service = new ProductService;  // JANGAN
public ProductService $service;  // JANGAN (public)
```

---

## Service

### ZONK — Semua Logic Ada di Sini
```php
// ✅ Service adalah tempat SELURUH logic
// Komunikasi DB, validasi bisnis, manipulasi data — semua di sini

class ProductService
{
    public function store(array $data): Product
    {
        $data['slug'] = Str::slug($data['name']);
        return Product::create($data);
    }

    public function update(Product $product, array $data): Product
    {
        $product->update($data);
        return $product;
    }

    public function destroy(Product $product): void
    {
        $product->delete();
    }

    public function resetPassword(User $user, array $data): void
    {
        $user->update([
            'password' => Hash::make($data['password']),
        ]);
    }
}
```

### Penamaan Method — HARUS KONSISTEN
```php
// ✅ Nama method harus KONSISTEN di SEMUA service
// Semua service HARUS punya method ini:

// 1. paginated() — untuk paginasi
public function paginated(
    int $perPage = 25,
    string $search = '',
    string $sort = 'created_at',
    string $direction = 'desc',
    array $filters = []
): LengthAwarePaginator {
    return Product::query()
        ->select(['id', 'name', 'slug', 'status'])  // ✅ select kolom yang dibutuhkan
        ->with(['categories:id,name'])                 // ✅ eager load relasi
        ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
        ->orderBy($sort, $direction)
        ->paginate($perPage);
}

// 2. getAll() — untuk semua data (tanpa paginasi)
public function getAll(): Collection
{
    return Product::query()
        ->select(['id', 'name'])
        ->get();
}

// 3. store() — untuk create
public function store(array $data): Product
{
    return Product::create($data);
}

// 4. update() — untuk update
public function update(Product $product, array $data): Product
{
    $product->update($data);
    return $product;
}

// 5. destroy() — untuk delete
public function destroy(Product $product): void
{
    $product->delete();
}

// ❌ JANGAN beda-beda nama untuk fungsi yang sama
// ❌ getProducts(), fetchProducts(), listProducts()
// ✅ paginated() — 1 nama untuk 1 fungsi
```

### Return Type — Wajib di Semua Method
```php
// ✅ Return type wajib ada di SEMUA method
public function paginated(...): LengthAwarePaginator { ... }
public function getAll(...): Collection { ... }
public function store(array $data): Product { ... }
public function update(Product $product, array $data): Product { ... }
public function destroy(Product $product): void { ... }
```

### Property Declaration
```php
// ✅ Di service, property declaration seperti biasa (private readonly)
class ProductService
{
    private readonly CuttingService $cuttingService;

    public function __construct(CuttingService $cuttingService)
    {
        $this->cuttingService = $cuttingService;
    }
}
```

### Urutan Method — KONSISTEN
```php
// ✅ Urutan method di service WAJIB konsisten:
// 1. CRUD (paginated, getAll, store, update, destroy)
// 2. Custom methods (business logic)
// 3. Private helpers (di paling bawah)

class ProductService
{
    // 1. CRUD
    public function paginated(...): LengthAwarePaginator { ... }
    public function getAll(...): Collection { ... }
    public function store(array $data): Product { ... }
    public function update(Product $product, array $data): Product { ... }
    public function destroy(Product $product): bool { ... }

    // 2. Custom methods
    public function getNames(): array { ... }
    public function getForEdit(Product $product): array { ... }
    public function toggleStatus(Product $product): void { ... }
    public function approve(Product $product): void { ... }
    public function reject(Product $product, string $reason): void { ... }
    public function resubmit(Product $product): void { ... }

    // 3. Private helpers (paling bawah)
    private function canVerify(): bool { ... }
    private function assertNotPending(Product $product): void { ... }
}
```

### Service Concerns (Traits)
```php
// ✅ Gunakan traits untuk kode yang berulang di banyak service

// 1. HandlesCashTransactions — untuk operasi kas
use App\Services\Concerns\HandlesCashTransactions;

class ExpenseService
{
    use HandlesCashTransactions;

    public function store(array $data): Expense
    {
        $cashAccount = $this->getCashAccount($data['cash_account_id']);
        $this->debitCash($cashAccount, $data['amount'], $data['description']);
        return Expense::create($data);
    }
}

// 2. HasStockAdjustment — untuk operasi stok
use App\Services\Concerns\HasStockAdjustment;

class TransactionService
{
    use HasStockAdjustment;

    public function store(array $data): Order
    {
        $this->applyStock($data['items'], $data['stock_type'], -1); // kurangi stok
        return Order::create($data);
    }
}

// 3. RegistersMedia — untuk upload file
use App\Services\Concerns\RegistersMedia;

class RestockService
{
    use RegistersMedia;

    public function store(array $data): Restock
    {
        $restock = Restock::create($data);
        $this->registerMedia($restock, $data['photo'] ?? null, 'restock');
        return $restock;
    }
}

// 4. HasRoleChecks — untuk role checking
use App\Concerns\HasRoleChecks;

class ProductService
{
    use HasRoleChecks;

    public function store(array $data): Product
    {
        $status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
            ? ProductStatus::ACTIVE
            : ProductStatus::PENDING;
        // ...
    }
}
```

### Trait Stacking — Gunakan Koma
```php
// ✅ Gabungkan multiple traits dengan koma dalam 1 baris
class PayrollPeriodService
{
    use HandlesCashTransactions, HasRoleChecks;
}

class AttendanceService
{
    use RegistersMedia, HasRoleChecks;
}

// ❌ JANGAN pisah per baris
class PayrollPeriodService
{
    use HandlesCashTransactions;
    use HasRoleChecks;  // JANGAN
}
```

### Role Checking — Gunakan RoleEnum
```php
// ✅ SELALU gunakan Role enum, JANGAN string
use App\Concerns\HasRoleChecks;
use App\Enums\Role;

class ProductService
{
    use HasRoleChecks;

    public function store(array $data): Product
    {
        // ✅ BENAR — pakai Role enum
        if (self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) { ... }

        // ❌ SALAH — pakai string
        if (auth()->user()->hasAnyRole(['developer', 'owner'])) { ... }
    }
}
```

### Cash Transaction Pattern
```php
// Credit (DEPOSIT) → tambah saldo
$this->creditCash($cashAccount, $amount, $description);

// Debit (EXPENSE/WITHDRAWAL) → kurangi saldo + validasi
$this->debitCash($cashAccount, $amount, $description);
```

### Stock Adjustment Pattern
```php
// ProductVariant — increment/decrement stock
$this->applyStock($items, $stockType, $sign); // $sign: 1 (tambah) atau -1 (kurangi)

// RawMaterialPrice — increment/decrement
$this->adjustStock($model, $field, $quantity, $sign);
```

### NotificationService — Gunakan RoleEnum
```php
// ✅ SELALU gunakan Role enum untuk roles parameter
use App\Enums\Role;
use App\Services\NotificationService;

NotificationService::notify(
    roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
    title: 'Judul Notifikasi',
    body: 'Isi notifikasi',
    url: route('admin.module.index'),
);

// ❌ JANGAN pakai string
NotificationService::notify(
    roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],  // JANGAN
    ...
);
```

### Media Sync — Gunakan syncPhoto/syncReceipt
```php
// ✅ SELALU gunakan syncPhoto untuk photo_key, syncReceipt untuk receipt_key
// Trait: App\Services\Concerns\RegistersMedia

// Photo — otomatis handle comparison + clear + register
$this->syncPhoto($model, $data, 'photos');

// Receipt — otomatis handle comparison + cache clearing + clear + register
$this->syncReceipt($model, $data['receipt_key'] ?? null, 'receipts', 'cache_prefix', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);

// ❌ JANGAN handle manual
if (! empty($data['photo_key'])) {
    $model->clearMediaCollection('photos');
    $this->registerMedia($model, $data['photo_key'], 'photos');  // JANGAN
}
```



---

## Form Request

### Validasi harus SESUAI dengan DB Schema
```php
// ✅ SELALU samakan max length dengan DB column
// DB: varchar(50) → max:50
'name' => ['required', 'string', 'max:50'],

// DB: varchar(200) → max:200
'name' => ['required', 'string', 'max:200'],

// DB: varchar(100) → max:100
'description' => ['required', 'string', 'max:100'],

// DB: text → tidak perlu max (atau max sesuai UI)
'address' => ['nullable', 'string'],

// DB: decimal(10,7) → numeric + min/max range
'latitude' => ['required', 'numeric', 'min:-90', 'max:90'],
'longitude' => ['required', 'numeric', 'min:-180', 'max:180'],

// DB: uint → integer, min:0
'base_salary' => ['required', 'integer', 'min:0'],

// DB: ubig → integer, min:1 (untuk amount)
'amount' => ['required', 'integer', 'min:1'],

// DB: enum → Rule::in(Enum::values()) atau Rule::in(['val1', 'val2'])
'status' => ['required', Rule::in(OrderStatus::values())],
'gender' => ['nullable', 'in:male,female'],

// DB: date → 'date'
'join_date' => ['required', 'date'],

// DB: datetime → 'date' (Laravel handle)
'paid_at' => ['nullable', 'date'],
```

### Struktur Dasar
```php
class ProductRequest extends FormRequest
{
    use CurrencyStripping;

    public function authorize(): bool
    {
        return true; // authorization di controller/middleware
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:200'],
            'price' => ['required', 'integer'],
        ];
    }

    public function attributes(): array
    {
        return [
            'name' => 'Nama Produk',
            'price' => 'Harga',
        ];
    }

    public function prepareForValidation(): void
    {
        $this->merge($this->stripCurrencyDot($this->validated(), 'price', 'discount'));
    }
}
```

### Rule Classes — Gunakan `Illuminate\Validation\Rule`
```php
use Illuminate\Validation\Rule;

// ✅ SELALU gunakan Rule classes, JANGAN string-based rules

// exists → Rule::exists('table', 'column')
'category_ids.*' => [Rule::exists('categories', 'id')],
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')],
'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],

// required_if → Rule::requiredIf(fn () => ...)
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array'],
'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],

// required_unless → Rule::requiredUnless(fn () => ...)
'name' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
'unit' => [$this->isMethod('post') ? Rule::requiredUnless(fn () => $this->input('mode') === 'existing') : 'nullable', Rule::in(RawMaterialUnit::values())],

// in → Rule::in(Enum::values()) atau Rule::in(['val1', 'val2'])
'status' => ['required', Rule::in(OrderStatus::values())],
'gender' => ['nullable', 'in:male,female'],

// unique → Rule::unique('table')->ignore($id)
'name' => ['required', 'string', 'max:100', Rule::unique('categories')->ignore($this->route('category')?->id)],

// ❌ JANGAN pakai string-based rules
'category_ids.*' => ['exists:categories,id'],                    // JANGAN
'shared_prices' => ['required_if:use_same_price,true', ...],    // JANGAN
'name' => ['required_unless:mode,existing', ...],               // JANGAN
```

### Currency Stripping
```php
use App\Concerns\CurrencyStripping;

// ✅ Strip dot dari input Rupiah sebelum validasi
// Input: "1.000.000" → Output: 1000000
$this->merge($this->stripCurrencyDot($this->validated(), 'price', 'discount'));

// ✅ Untuk nested array
$this->merge($this->stripCurrencyDot($this->validated(), 'variants.*.price'));
```

### Unique Ignore
```php
// ✅ Saat update, ignore ID sendiri — gunakan Rule::unique()
'name' => ['required', 'string', 'max:100', Rule::unique('categories')->ignore($this->route('category')?->id)],

// ✅ Saat store, tidak perlu ignore
'name' => ['required', 'string', 'max:100', Rule::unique('categories')],
```

### Shared Store/Update Request
```php
// ✅ Gunakan 1 request dengan sometimes untuk store & update
public function rules(): array
{
    $productId = $this->route('product')?->id;

    return [
        'name' => ['required', 'string', 'max:200', Rule::unique('products')->ignore($productId)],
        'price' => ['required', 'integer'],
        'description' => ['nullable', 'string'],
    ];
}
```

---

## Traits & Concerns — Reuse Kode yang Berulang

```php
// ✅ SELALU cari kode yang berulang, jadikan Trait/Concern

// Contoh: currency formatting berulang di banyak model
// ✅ Buat Trait
namespace App\Concerns;

trait CurrencyFormatting
{
    protected function formatCurrency($value): string
    {
        return 'Rp ' . number_format($value, 0, ',', '.');
    }
}

// ✅ Gunakan di model
use App\Concerns\CurrencyFormatting;

#[Guarded(['id'])]
#[Appends(['formatted_amount'])]
class Order extends Model
{
    use HasFactory, SoftDeletes, CurrencyFormatting;

    protected function formattedAmount(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->formatCurrency($this->amount),
        );
    }
}

// ✅ Contoh Traits yang sudah ada:
// - CurrencyStripping → untuk FormRequest
// - HandlesCashTransactions → untuk Service
// - HasStockAdjustment → untuk Service
// - RegistersMedia → untuk Service
// - HasValues → untuk Enum

// ✅ Jika menemukan pola berulang, buat Trait baru:
// - SoftDeletesScope (untuk scope yang sama di banyak model)
// - HasFormattedDates (untuk accessor tanggal)
// - HasFormattedCurrency (untuk accessor currency)
```

---

## Frontend (React + TypeScript + Inertia)

### Page Patterns

#### A. Simple CRUD Index (DataTable)
Digunakan untuk: Category, Customer, Supplier, Employee, Leave Request, Expense, Cash Account

```tsx
import { Head } from '@inertiajs/react';
import { useState } from 'react';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table';
import { PageHeader } from '@/components/page-header';
import { DataTable } from '@/components/data-table';
import { FormDialog } from '@/components/form-dialog';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { Button } from '@/components/ui/button';
import { createCategoryColumns, type Category } from './columns';

// Props dari controller (Inertia render)
type Props = {
    categories: { data: Category[]; current_page: number; last_page: number; per_page: number; total: number };
};

export default function CategoryIndex({ categories }: Props) {
    const { can } = useCan();
    const [createOpen, setCreateOpen] = useState<boolean>(false);
    const [editing, setEditing] = useState<Category | null>(null);
    const [deleting, setDeleting] = useState<Category | null>(null);

    const { search, handlePageChange, handlePerPageChange, handleSearchChange } = useServerTable({
        route: () => route('admin.master.categories.index'),
        pagination: categories,
    });

    const columns = createCategoryColumns({
        handleEdit: (cat) => setEditing(cat),
        handleDeleteClick: (cat) => setDeleting(cat),
        can,
    });

    return (
        <>
            <Head title="Kategori" />
            <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
                <PageHeader title="Kategori" actions={can('categories.create') ? <Button onClick={() => setCreateOpen(true)}>Tambah</Button> : undefined} />

                {/* Create Dialog */}
                <FormDialog open={createOpen} onOpenChange={setCreateOpen} title="Tambah Kategori" action={route('admin.master.categories.store')} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
                    {({ errors }) => (
                        <div className="grid gap-2">
                            <Label>Nama</Label>
                            <Input name="name" placeholder="Nama kategori" />
                            <InputError message={errors.name} />
                        </div>
                    )}
                </FormDialog>

                {/* Edit Dialog */}
                <FormDialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)} title="Edit Kategori" action={editing ? route('admin.master.categories.update', editing.id) : ''} resetOnSuccess onSuccess={() => setEditing(null)}>
                    {({ errors }) => editing && (
                        <div className="grid gap-2">
                            <Label>Nama</Label>
                            <Input name="name" defaultValue={editing.name} />
                            <InputError message={errors.name} />
                        </div>
                    )}
                </FormDialog>

                {/* DataTable */}
                <DataTable columns={columns} data={categories.data} searchKey="name" pagination={categories} onPageChange={handlePageChange} onPerPageChange={handlePerPageChange} onSearchChange={handleSearchChange} searchValue={search} />

                {/* Delete Confirmation */}
                <DeleteConfirmDialog target={deleting} onOpenChange={(open) => !open && setDeleting(null)} title="Hapus Kategori" description={(cat) => `Hapus kategori "${cat.name}"?`} onConfirm={() => { if (deleting) router.delete(route('admin.master.categories.destroy', deleting.id), { onSuccess: () => setDeleting(null) }); }} />
            </div>
        </>
    );
}
```

#### B. Complex List Index (CardTable)
Digunakan untuk: Transaction, Cutting, Purchase, Restock

```tsx
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { useServerTable } from '@/hooks/use-server-table';
import { CardTable } from '@/components/card-table';
import { FilterPopover } from '@/components/filter-popover';

type Props = {
    transactions: { data: Transaction[]; current_page: number; last_page: number; per_page: number; total: number };
    summary: TransactionSummary;
    filters: Record<string, string | undefined>;
    filterOptions: {
        statusOptions: Array<{ value: string; label: string }>;
        channelOptions: Array<{ value: string; label: string }>;
    };
};

export default function TransactionIndex({ transactions, summary, filters, filterOptions }: Props) {
    const expand = useCardTableExpand(true);
    const { search, handlePageChange, handlePerPageChange, handleSearchChange, applyFilter, clearFilters } = useServerTable({
        route: () => route('admin.manage.transactions.index'),
        pagination: transactions,
        filters,
    });

    return (
        <>
            <Head title="Transaksi" />
            <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
                <PageHeader title="Transaksi" actions={...} />

                {/* Summary Card */}
                <TransactionSummaryCard summary={summary} />

                {/* CardTable with Filters */}
                <CardTable
                    data={transactions.data}
                    getItemKey={(t) => t.id}
                    expandedKeys={expand.expandedKeys}
                    onToggleExpand={expand.toggleExpand}
                    searchValue={search}
                    onSearchChange={handleSearchChange}
                    toolbar={<FilterPopover filters={filters} filterOptions={filterOptions} onApply={applyFilter} onClear={clearFilters} />}
                    pagination={transactions}
                    onPageChange={handlePageChange}
                    onPerPageChange={handlePerPageChange}
                    renderCard={({ item, isExpanded, onToggleExpand }) => <TransactionCardRow transaction={item} isExpanded={isExpanded} onToggleExpand={onToggleExpand} />}
                    renderSubContent={(t) => <TransactionItemSubRow transaction={t} />}
                />
            </div>
        </>
    );
}
```

#### C. Create/Edit Page (Full-page Form)
Digunakan untuk: Transaction create/edit, Cutting create/edit, Purchase create/edit

```tsx
'use no memo'; // untuk complex forms

import { Head, Link, router } from '@inertiajs/react';
import { Form } from '@inertiajs/forms';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { loadTransactionDraft, useTransactionDraftSave } from '@/hooks/use-transaction-draft';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RupiahInput } from '@/components/rupiah-input';
import { NumberInput } from '@/components/number-input';
import { FileUpload } from '@/components/file-upload';
import { Combobox } from '@/components/ui/combobox';

type Props = { data: CreateData; auth: { user: User } };

export default function TransactionCreate({ data, auth }: Props) {
    const draft = loadTransactionDraft('create', auth.user.id);
    const [customer, setCustomer] = useState(draft?.customer_id ?? null);
    const [items, setItems] = useState(draft?.items ?? []);
    const [subtotal, setSubtotal] = useState(0);

    useTransactionDraftSave('create', { customer_id: items, ... }, auth.user.id);

    // ... useEffect, useCallback untuk computed values

    return (
        <>
            <Head title="Tambah Transaksi" />
            <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
                <div className="flex items-center justify-between">
                    <h2 className="text-2xl font-semibold tracking-tight">Tambah Transaksi</h2>
                    <Button asChild variant="outline">
                        <Link href={route('admin.manage.transactions.index')}>
                            <ArrowLeft className="h-4 w-4" /> Kembali
                        </Link>
                    </Button>
                </div>

                <Form action={route('admin.manage.transactions.store')} transform={(formData) => ({ ...formData, customer_id: customer, items, subtotal })}>
                    {({ errors, processing }) => (
                        <div className="grid gap-6 md:grid-cols-3">
                            <div className="space-y-6 md:col-span-2">
                                {/* Main content */}
                            </div>
                            <div className="space-y-6 md:col-span-1">
                                <Card className="sticky top-6">
                                    <CardHeader><CardTitle>Ringkasan</CardTitle></CardHeader>
                                    <CardContent>{/* Summary */}</CardContent>
                                </Card>
                            </div>
                        </div>
                    )}
                </Form>
            </div>
        </>
    );
}
```

### Column Definitions
File: `columns.tsx` — colocated bersama page

```tsx
// ✅ Type definitions — export di columns.tsx
export type Category = {
    id: number;
    name: string;
    formatted_name: string;  // accessor dari model
    status_label: string;    // label dari enum
};

// ✅ Column factory function
type CreateColumnsParams = {
    handleEdit: (entity: Category) => void;
    handleDeleteClick: (entity: Category) => void;
    can: (permission: string) => boolean;
};

export function createCategoryColumns(params: CreateColumnsParams): ColumnDef<Category>[] {
    const { handleEdit, handleDeleteClick, can } = params;

    const columns: ColumnDef<Category>[] = [
        {
            accessorKey: 'name',
            header: () => <span>Nama</span>,
            cell: ({ row }) => <span className="font-medium">{row.getValue('name')}</span>,
        },
        // ✅ Gunakan accessor dari model, BUKAN format di TypeScript
        {
            accessorKey: 'formatted_name',
            header: () => <span>Nama</span>,
            cell: ({ row }) => <span>{row.getValue('formatted_name')}</span>,
        },
        // ✅ Label dari enum, BUKAN format di TypeScript
        {
            accessorKey: 'status_label',
            header: () => <span>Status</span>,
            cell: ({ row }) => <Badge>{row.getValue('status_label')}</Badge>,
        },
    ];

    // ✅ Actions column — conditional on permissions
    if (can('categories.update') || can('categories.delete')) {
        columns.push({
            id: 'actions',
            header: () => <span className="block text-center">Aksi</span>,
            meta: { className: 'w-[100px] text-center' },
            cell: ({ row }) => (
                <RowActions actions={[
                    { label: 'Edit', icon: <Pencil />, show: can('categories.update'), onClick: () => handleEdit(row.original) },
                    { label: 'Hapus', icon: <Trash2 className="text-destructive" />, show: can('categories.delete'), onClick: () => handleDeleteClick(row.original) },
                ]} />
            ),
        });
    }

    return columns;
}
```

### Form Handling

#### FormDialog (Simple CRUD — Inline)
```tsx
import { FormDialog } from '@/components/form-dialog';

// ✅ Create
<FormDialog
    open={createOpen}
    onOpenChange={setCreateOpen}
    title="Tambah Category"
    action={route('admin.master.categories.store')}  // Inertia route
    resetOnSuccess                                    // Reset form setelah success
    onSuccess={() => setCreateOpen(false)}            // Tutup dialog setelah success
>
    {({ errors, processing }) => (
        <div className="grid gap-2">
            <Label>Nama <span className="text-destructive">*</span></Label>
            <Input name="name" placeholder="Nama kategori" />
            <InputError message={errors.name} />
        </div>
    )}
</FormDialog>

// ✅ Edit — gunakan defaultValue
<FormDialog
    open={editing !== null}
    onOpenChange={(open) => !open && setEditing(null)}
    title="Edit Category"
    action={editing ? route('admin.master.categories.update', editing.id) : ''}
    resetOnSuccess
    onSuccess={() => setEditing(null)}
>
    {({ errors }) => editing && (
        <div className="grid gap-2">
            <Label>Nama</Label>
            <Input name="name" defaultValue={editing.name} />
            <InputError message={errors.name} />
        </div>
    )}
</FormDialog>
```

#### Full-page Form (Complex Create/Edit)
```tsx
import { Form } from '@inertiajs/forms';
import { RupiahInput } from '@/components/rupiah-input';
import { NumberInput } from '@/components/number-input';
import { FileUpload } from '@/components/file-upload';
import { Combobox } from '@/components/ui/combobox';

<Form action={route('admin.manage.transactions.store')} transform={(formData) => ({ ...formData, items })}>
    {({ errors, processing }) => (
        <div>
            {/* Controlled fields — gunakan useState */}
            <RupiahInput value={amount} onValueChange={setAmount} />
            <NumberInput value={qty} onValueChange={setQty} />
            <FileUpload value={photo} onChange={setPhoto} folder="transaction" />

            {/* Uncontrolled fields — gunakan name */}
            <Input name="description" defaultValue={editing?.description} />

            {/* Combobox untuk dropdown */}
            <Combobox items={customers} value={customer} onValueChange={setCustomer}>
                <ComboboxInput placeholder="Pilih customer" />
                <ComboboxContent>
                    <ComboboxList>{(item) => <ComboboxItem value={item}>{item.name}</ComboboxItem>}</ComboboxList>
                </ComboboxContent>
            </Combobox>

            <Button type="submit" disabled={processing}>Simpan</Button>
        </div>
    )}
</Form>
```

### Hooks

```tsx
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';

// ✅ useCan — permission checking
const { can, canAny, hasRole, hasRoleAny } = useCan();
can('categories.create')       // boolean
hasRole('developer')           // boolean

// ✅ useServerTable — pagination, search, filter
const { search, filterOpen, setFilterOpen, handlePageChange, handlePerPageChange, handleSearchChange, applyFilter, clearFilters } = useServerTable({
    route: () => route('admin.manage.transactions.index'),
    pagination: transactions,
    filters: filters,
});

// ✅ useCardTableExpand — expand/collapse rows
const expand = useCardTableExpand(true);  // true = expand all by default
// Returns: { expandedKeys, toggleExpand, expandAll, collapseAll }

// ✅ Draft System — auto-save form draft ke localStorage
import { useTransactionDraftSave, loadTransactionDraft } from '@/hooks/use-transaction-draft';
const draft = loadTransactionDraft('create', userId);
const [field, setField] = useState(draft?.field ?? defaultValue);
useTransactionDraftSave('create', draftData, userId);
// Auto-save 500ms debounce, cleared on success
```

### Components

| Component | Digunakan Untuk |
|-----------|----------------|
| `PageHeader` | Header halaman: title + description + actions |
| `DataTable` | Table server-side pagination + search (simple CRUD) |
| `CardTable` | Card-based list + expandable rows (complex entities) |
| `FormDialog` | Dialog + Inertia Form wrapper (inline create/edit) |
| `DeleteConfirmDialog` | Konfirmasi hapus (target-based open state) |
| `RowActions` | Action dropdown (edit/hapus/custom) |
| `FilterPopover` | Filter toolbar dengan badge jumlah active filters |
| `RupiahInput` | Input currency "Rp X.XXX" (controlled) |
| `NumberInput` | Input angka dengan locale formatting |
| `FileUpload` | Upload file ke S3 dengan preview |
| `PhoneNumberInput` | Input nomor telepon |
| `StatusBadge` | Badge status dengan color mapping |

### Permission System
```tsx
// Backend: kirim permission ke view via Spatie
// Frontend: check dengan useCan()

const { can, canAny, hasRole, hasRoleAny } = useCan();

// Permission string: {entity}.{action}
can('categories.create')    // boolean
can('categories.update')    // boolean
can('categories.delete')    // boolean
can('orders.create')        // boolean

// Roles bypass all permissions:
// 'developer' dan 'owner' selalu akses semua
```

### Data Flow (Controller → Frontend)
```php
// Controller: kirim data via Inertia::render()
return Inertia::render('Admin/Master/Products/Index', [
    'products' => $this->service->paginated(...),  // paginated data
    'filterOptions' => [                            // filter options dari enum
        'statusOptions' => OrderStatus::toSelect(),
        'channelOptions' => OrderChannel::toSelect(),
    ],
]);
```

```tsx
// Frontend: terima sebagai page props
type Props = {
    products: { data: Product[]; current_page: number; last_page: number; per_page: number; total: number };
    filterOptions: { statusOptions: Array<{ value: string; label: string }> };
};
```

### Routing (Ziggy)
```tsx
import { index, store, update, destroy } from '@/routes/admin/master/categories';

// Ziggy route helpers
index.url()                    // GET /admin/master/categories
store()                        // POST /admin/master/categories
update(editing.id)             // PUT /admin/master/categories/{id}
destroy(deleting.id)           // DELETE /admin/master/categories/{id}

// With parameters
route('admin.manage.transactions.show', transaction.id)  // URL
route('admin.manage.transactions.update', transaction.id)
```

### Model Accessors → Gunakan `formatted_` dari Backend
```tsx
// ✅ Gunakan accessor dari model
{ row.original.formatted_amount }
{ row.original.status_label }

// ❌ Jangan format di TypeScript
import { formatCurrency } from '@/lib/utils';
{ formatCurrency(row.original.amount) }  // JANGAN
```

### Enum Options → Dari Controller
```php
// Controller: kirim ke view
'filterOptions' => [
    'statusOptions' => OrderStatus::toSelect(),
],
```

```tsx
// View: gunakan options
{filterOptions.statusOptions.map((opt) => (
    <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
```

### Format Functions
```tsx
import { formatDate, formatShortDate, formatDateTime } from '@/lib/format';
import { formatCurrency, formatNumber } from '@/lib/utils';

formatDate(dateString)      // "23 Agustus 2026 08:40"
formatShortDate(dateString) // "23 Agst 2026"
formatDateTime(dateString)  // "23 Agustus 2026 08:40"
formatCurrency(1000000)     // "Rp 1.000.000"
formatNumber(1000000)       // "1.000.000"
```

---

## Database

### Foreign Key Convention
```php
// BelongsTo → foreign key = model_name_id
$table->foreignId('user_id')->constrained()->cascadeOnDelete();

// Custom FK
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();

// Polymorphic
$table->nullableMorphs('reference');
```

### SoftDeletes
```php
// Hampir semua model PAKAI SoftDeletes
$table->softDeletes();

// Kecuali: Attendance, StokOpnameItem, RetailStockHistory, StockMutation
```

### Timestamps
```php
// Default: created_at + updated_at
$table->timestamps();

// RetailStockHistory: hanya created_at (set $timestamps = false di model)
```

---

## Do's and Don'ts

### ✅ Do
- Selalu pakai `#[Guarded(['id'])]` di model
- Selalu cast kolom yang perlu (Enum, integer, boolean, array, datetime)
- Selalu buat Enum untuk data opsi (status, type, dll)
- Selalu buat scope untuk data opsi, gunakan atribut `#[Scope]` + return type `void`
- Selalu tulis relasi 2 ARAH di kedua model
- Selalu pakai `formatted_*` accessor untuk display (NAMA BERBEDA dari kolom DB)
- Tidak perlu accessor untuk label — Enum sudah handle via `->label()`
- Selalu select kolom yang dibutuhkan + eager load relasi
- Selalu strip currency di FormRequest sebelum validasi
- Selalu pakai `Rule::exists()`, `Rule::requiredIf()`, `Rule::requiredUnless()` (bukan string-based rules)
- Selalu pakai constructor promotion di controller (`public function __construct(private XService $service) {}`)
- Selalu kirim enum options dari controller ke view (`Enum::toSelect()`)
- Selalu pakai `handleAction()` untuk 2+ query
- Selalu pakai `back()` + `Inertia::flash('toast', [...])` sebelum return back()
- Selalu pindahkan logic ke Service (controller = lalu lintas saja)
- Selalu pakai nama method yang konsisten di semua service (`paginated`, `getAll`, `store`, `update`, `destroy`)
- Selalu cari kode berulang → jadikan Trait/Concern
- Selalu pakai `Role` enum untuk role checking & notifikasi (bukan string)
- Selalu pakai `syncPhoto`/`syncReceipt` untuk media handling (bukan manual)
- Selalu pakai `withTrashed()` untuk relasi ke model yang mungkin di-soft-delete
- Selalu pakai return type di SEMUA method (controller & service)
- **Frontend**: Selalu gunakan `formatted_*` accessor dari model, bukan format di TypeScript
- **Frontend**: Selalu kirim enum options dari controller, jangan hardcoded di view
- **Frontend**: Selalu gunakan Ziggy route helpers, jangan hardcoded URLs
- **Frontend**: Selalu gunakan `useCan()` untuk permission checking
- **Frontend**: Selalu definisikan type di `columns.tsx` (colocated)
- **Frontend**: Selalu gunakan `FormDialog` untuk simple CRUD, `<Form>` untuk complex

### ❌ Don't
- Jangan pakai `$fillable` (pakai `#[Guarded]`)
- Jangan biarkan kolom tanpa cast
- Jangan pakai string biasa untuk data opsi (pakai Enum)
- Jangan pakai string untuk role checking (pakai `Role` enum)
- Jangan handle media sync manual (pakai `syncPhoto`/`syncReceipt`)
- Jangan lupa buat scope untuk data opsi, gunakan `#[Scope]` + return type `void`
- Jangan pakai `public function` untuk scope (pakai `protected function`)
- Jangan lupa tulis relasi 2 ARAH
- Jangan pakai nama accessor yang sama dengan kolom DB (`amount()` → bentrok)
- Jangan hardcode enum values di view (ambil dari controller)
- Jangan pakai `= new XService` (pakai dependency injection)
- Jangan lupa strip currency saat input Rupiah
- Jangan pakai string-based rules seperti `'exists:table,id'`, `'required_if:field,value'`, `'required_unless:field,value'` (pakai Rule classes)
- Jangan lupa `authorize()` di FormRequest (return true)
- Jangan lupa `attributes()` di FormRequest (label Bahasa Indonesia)
- JANGAN ada logic di controller (SEKECIL APAPUN)
- Jangan panggil Model langsung di controller (pakai Service)
- Jangan pakai `to_route()` untuk kembali ke halaman yang sama (pakai `back()`)
- Jangan pakai nama method yang beda-beda untuk fungsi yang sama
- **Frontend**: Jangan format currency/angka di TypeScript (pakai accessor dari model)
- **Frontend**: Jangan hardcode select options di view (ambil dari controller via `Enum::toSelect()`)
- **Frontend**: Jangan hardcoded URLs (pakai Ziggy route helpers)
- **Frontend**: Jangan lupa permission check dengan `can()` di setiap action
- **Frontend**: Jangan lupa return type di semua method
- **Frontend**: Jangan gunakan `router.post()` untuk navigate, gunakan Inertia `<Form>`
