- Updated CustomerService to simplify getAll method. - Refactored ProductService to utilize HasRoleChecks trait and improved role verification logic. - Enhanced ProductVariantService with new methods for fetching data for restocking and transactions. - Cleaned up RawMaterialService by removing unused methods and improving data retrieval. - Adjusted SupplierService to streamline getAll method. - Refactored RoleService to use Spatie's Role model and improved role filtering logic. - Updated NotificationService to handle role labels more effectively. - Improved StockMutationService by removing redundant paginated method. - Cleaned up various frontend components to directly accept necessary props instead of nested data objects. - Updated tests to reflect changes in service method names and ensure proper notification handling.
53 KiB
53 KiB
Code Conventions
Aturan-aturan yang WAJIB diikuti saat menulis kode.
Namespace & Import Convention
Backend → SELALU pakai use import di atas file
// ✅ 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
// ✅ 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:
// ✅ 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:
// ✅ 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
// ✅ SELALU pakai #[Guarded(['id'])]
#[Guarded(['id'])]
class Product extends Model { ... }
// ❌ JANGAN pakai $fillable
protected $fillable = ['name', 'status']; // JANGAN
Urutan Isi Model
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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// 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
// ✅ 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
// ✅ Return type wajib ada di SEMUA method — controller & service
// ✅ Response untuk controller, void/array/bool untuk service
ZONK — Tidak Ada Logic di Controller
// ✅ 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
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()
// ✅ 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
// ❌ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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)
// ✅ 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
// ✅ 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
// ✅ 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
// Credit (DEPOSIT) → tambah saldo
$this->creditCash($cashAccount, $amount, $description);
// Debit (EXPENSE/WITHDRAWAL) → kurangi saldo + validasi
$this->debitCash($cashAccount, $amount, $description);
Stock Adjustment Pattern
// 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
// ✅ 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
// ✅ 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
// ✅ 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
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
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
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
// ✅ 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
// ✅ 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
// ✅ 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
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
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
'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
// ✅ 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)
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)
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
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
// 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)
// 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(),
],
]);
// 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)
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
// ✅ 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
// Controller: kirim ke view
'filterOptions' => [
'statusOptions' => OrderStatus::toSelect(),
],
// View: gunakan options
{filterOptions.statusOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
Format Functions
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
// 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
// Hampir semua model PAKAI SoftDeletes
$table->softDeletes();
// Kecuali: Attendance, StokOpnameItem, RetailStockHistory, StockMutation
Timestamps
// 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 typevoid - 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
Roleenum untuk role checking & notifikasi (bukan string) - Selalu pakai
syncPhoto/syncReceiptuntuk 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
FormDialoguntuk 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
Roleenum) - Jangan handle media sync manual (pakai
syncPhoto/syncReceipt) - Jangan lupa buat scope untuk data opsi, gunakan
#[Scope]+ return typevoid - Jangan pakai
public functionuntuk scope (pakaiprotected 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 (pakaiback()) - 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>