# 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 // ✅ Placeholders // ✅ Buttons // ✅ Flash messages 'Produk berhasil ditambahkan' 'Data berhasil dihapus' // ✅ Status labels (di Badge) // "Menunggu", "Selesai" // ✅ Page titles ``` ### 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) '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' ``` ### 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()); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditambahkan'])->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()); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Password berhasil direset'])->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); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus'])->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); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus'])->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(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(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 ( <>
setCreateOpen(true)}>Tambah : undefined} /> {/* Create Dialog */} setCreateOpen(false)}> {({ errors }) => (
)}
{/* Edit Dialog */} !open && setEditing(null)} title="Edit Kategori" action={editing ? route('admin.master.categories.update', editing.id) : ''} resetOnSuccess onSuccess={() => setEditing(null)}> {({ errors }) => editing && (
)}
{/* DataTable */} {/* Delete Confirmation */} !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) }); }} />
); } ``` #### 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; 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 ( <>
{/* Summary Card */} {/* CardTable with Filters */} t.id} expandedKeys={expand.expandedKeys} onToggleExpand={expand.toggleExpand} searchValue={search} onSearchChange={handleSearchChange} toolbar={} pagination={transactions} onPageChange={handlePageChange} onPerPageChange={handlePerPageChange} renderCard={({ item, isExpanded, onToggleExpand }) => } renderSubContent={(t) => } />
); } ``` #### 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 ( <>

Tambah Transaksi

({ ...formData, customer_id: customer, items, subtotal })}> {({ errors, processing }) => (
{/* Main content */}
Ringkasan {/* Summary */}
)}
); } ``` ### 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[] { const { handleEdit, handleDeleteClick, can } = params; const columns: ColumnDef[] = [ { accessorKey: 'name', header: () => Nama, cell: ({ row }) => {row.getValue('name')}, }, // ✅ Gunakan accessor dari model, BUKAN format di TypeScript { accessorKey: 'formatted_name', header: () => Nama, cell: ({ row }) => {row.getValue('formatted_name')}, }, // ✅ Label dari enum, BUKAN format di TypeScript { accessorKey: 'status_label', header: () => Status, cell: ({ row }) => {row.getValue('status_label')}, }, ]; // ✅ Actions column — conditional on permissions if (can('categories.update') || can('categories.delete')) { columns.push({ id: 'actions', header: () => Aksi, meta: { className: 'w-[100px] text-center' }, cell: ({ row }) => ( , show: can('categories.update'), onClick: () => handleEdit(row.original) }, { label: 'Hapus', icon: , show: can('categories.delete'), onClick: () => handleDeleteClick(row.original) }, ]} /> ), }); } return columns; } ``` ### Form Handling #### FormDialog (Simple CRUD — Inline) ```tsx import { FormDialog } from '@/components/form-dialog'; // ✅ Create setCreateOpen(false)} // Tutup dialog setelah success > {({ errors, processing }) => (
)}
// ✅ Edit — gunakan defaultValue !open && setEditing(null)} title="Edit Category" action={editing ? route('admin.master.categories.update', editing.id) : ''} resetOnSuccess onSuccess={() => setEditing(null)} > {({ errors }) => editing && (
)}
``` #### 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';
({ ...formData, items })}> {({ errors, processing }) => (
{/* Controlled fields — gunakan useState */} {/* Uncontrolled fields — gunakan name */} {/* Combobox untuk dropdown */} {(item) => {item.name}}
)}
``` ### 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) => ( {opt.label} ))} ``` ### 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, `
` 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 ``