diff --git a/.ai/CONTEXT.md b/.ai/CONTEXT.md new file mode 100644 index 0000000..b4c1869 --- /dev/null +++ b/.ai/CONTEXT.md @@ -0,0 +1,88 @@ +# DST Collection — Project Context + +## Tentang Project +**DST Collection** adalah aplikasi ERP untuk brand pakaian wanita (daster, dress, dll). Aplikasi ini mengelola dua area bisnis utama: + +1. **Toko** — penjualan, produk, keuangan, HR +2. **Konveksi** — bahan baku, cutting, pembelian bahan + +## Tech Stack +| Layer | Technology | +|-------|------------| +| Backend | Laravel 13, PHP 8.3 | +| Frontend | React 19, Inertia.js 3, TypeScript | +| UI | shadcn/ui, Radix UI, Tailwind CSS 4 | +| Auth | Laravel Fortify, Spatie Permission | +| DB | MySQL (SoftDeletes di semua model utama) | + +## Roles & Akses + +### role: `developer`, `owner` +- **Akses**: Semua module, semua aksi +- **Catatan**: Selalu muncul di semua sidebar + +### role: `admin-toko` +- **Akses**: Full CRUD semua module toko +- **Modules**: Kategori, Produk, Customer, Transaksi, Restock, Kas Toko, Pengeluaran, Kasbon, Gaji, Pegawai, Presensi, Cuti, Settings +- **Catatan**: TIDAK akses Supplier, Bahan Baku, Belanja, Cutting + +### role: `direktur` +- **Akses**: Readonly semua data toko + bisa ajukan kasbon +- **Modules**: Semua module toko (readonly), kecuali Supplier, Bahan Baku, Belanja, Cutting +- **Catatan**: Bisa lihat semua data pegawai toko, tapi bukan admin bahan baku + +### role: `admin-bahan-baku` +- **Akses**: Full CRUD untuk konveksi +- **Modules**: Bahan Baku, Supplier, Belanja, Cutting, Pegawai (hanya role admin-bahan-baku), Kasbon (lihat semua + ACC/bayar) +- **Catatan**: TIDAK akses module toko lainnya + +### role: `cashier` +- **Akses**: Transaksi (full CRUD), Customer (full CRUD), Produk (readonly), Kasbon (CRUD own data) +- **Modules**: Transaksi, Customer, Produk (view), Kasbon, Gaji (view), Presensi, Cuti + +### role: `marketing-offline`, `marketing-online` +- **Akses**: Transaksi (readonly yang punya dia), Produk (readonly), Customer (full CRUD) +- **Modules**: Transaksi (view only own), Produk (view), Customer, Kasbon (CRUD own data), Presensi, Cuti + +### role: `stok-opname` +- **Akses**: Stok Opname (full CRUD + submit) +- **Modules**: Stok Opname, Produk (view), Presensi, Cuti + +### role: `non-operator` +- **Akses**: Basic (dashboard, presensi, cuti, kasbon) +- **Modules**: Presensi, Cuti, Kasbon (CRUD own data) + +## Module Overview + +| # | Module | Route Group | Controller Path | Service Path | +|---|--------|-------------|-----------------|--------------| +| 1 | Kategori | `admin/master/categories` | `Admin/Master/CategoryController` | `Admin/Master/CategoryService` | +| 2 | Produk | `admin/master/products` | `Admin/Master/Product/ProductController` | `Admin/Master/Product/ProductService` | +| 3 | Bahan Baku | `admin/master/raw-materials` | `Admin/Master/RawMaterial/RawMaterialController` | `Admin/Master/RawMaterial/RawMaterialService` | +| 4 | Supplier | `admin/master/suppliers` | `Admin/Master/SupplierController` | `Admin/Master/SupplierService` | +| 5 | Customer | `admin/master/customers` | `Admin/Master/CustomerController` | `Admin/Master/CustomerService` | +| 6 | Belanja | `admin/manage/purchases` | `Admin/Manage/PurchaseController` | `Admin/Manage/PurchaseService` | +| 7 | Cutting | `admin/manage/cuttings` | `Admin/Manage/CuttingController` | `Admin/Manage/CuttingService` | +| 8 | Transaksi | `admin/manage/transactions` | `Admin/Manage/TransactionController` | `Admin/Manage/TransactionService` | +| 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` | +| 10 | Stok Opname | `admin/manage/stok-opnames` | (via StockMutationController) | — | +| 11 | Kas Toko | `admin/finance/cash-accounts` | `Admin/Finance/CashAccountController` | `Admin/Finance/CashAccountService` | +| 12 | Pengeluaran | `admin/finance/expenses` | `Admin/Finance/ExpenseController` | `Admin/Finance/ExpenseService` | +| 13 | Kasbon | `admin/finance/employee-advances` | `Admin/Finance/EmployeeAdvanceController` | `Admin/Finance/EmployeeAdvanceService` | +| 14 | Gaji | `admin/finance/payroll-periods` | `Admin/Finance/PayrollController` | `Admin/Finance/PayrollPeriodService` | +| 15 | Pegawai | `admin/hr/employees` | `Admin/HR/EmployeeController` | `Admin/HR/EmployeeService` | +| 16 | Presensi | `admin/hr/attendances` | `Admin/HR/AttendanceController` | `Admin/HR/AttendanceService` | +| 17 | Cuti | `admin/hr/leave-requests` | `Admin/HR/LeaveRequestController` | `Admin/HR/LeaveRequestService` | +| 18 | Role & Permission | `admin/settings/roles` | `Admin/RoleController` | `Admin/Settings/RoleService` | +| 19 | Pengaturan | `admin/settings` | `Admin/AdminSettingsController` | `Admin/AdminSettingsService` | + +## Statistics +| Metric | Count | +|--------|-------| +| Tables | 49 | +| Models | 42 | +| Enums | 20 + 1 trait | +| Services | 28 (including 3 concerns) | +| Controllers | 30 | +| Roles | 9 | +| Permissions | ~120 | diff --git a/.ai/CONVENTIONS.md b/.ai/CONVENTIONS.md new file mode 100644 index 0000000..d733363 --- /dev/null +++ b/.ai/CONVENTIONS.md @@ -0,0 +1,1406 @@ +# Code Conventions + +> Aturan-aturan yang WAJIB diikuti saat menulis kode. + +--- + +## 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 { ... } +} +``` + +### Casting — Wajib untuk Semua Tipe +```php +// ✅ Selalu cast kolom yang perlu — gunakan METHOD syntax +protected function casts(): array +{ + return [ + // Enum → gunakan enum class + 'status' => OrderStatus::class, + 'type' => CashTransactionType::class, + + // Currency/angka → integer (tanpa desimal) + 'amount' => 'integer', + 'price' => 'integer', + 'quantity' => 'integer', + + // Date → format Y-m-d + 'join_date' => 'date:Y-m-d', + + // Boolean + 'is_active' => 'boolean', + 'is_affiliate' => 'boolean', + + // Array (JSON) + 'marketplace_settings_snapshot' => 'array', + 'payload' => 'array', + + // DateTime + 'verified_at' => 'datetime', + 'paid_at' => 'datetime', + ]; +} + +// ❌ JANGAN pakai property syntax +protected $casts = [...]; // JANGAN + +// ❌ JANGAN biarkan kolom tanpa cast jika tipenya perlu +'amount' => 'integer', // ✅ benar +// 'amount' => '', // ❌ salah — tidak dicast +``` + +### Enum untuk Data Opsi +```php +// ✅ SELALU gunakan Enum untuk data yang punya opsi tetap +enum OrderStatus: string +{ + use HasValues; + + case PENDING = 'pending'; + case PROCESSING = 'processing'; + case COMPLETED = 'completed'; + case CANCELLED = 'cancelled'; + case REFUNDED = 'refunded'; + + public function label(): string + { + return match ($this) { + self::PENDING => 'Menunggu', + self::PROCESSING => 'Diproses', + self::COMPLETED => 'Selesai', + self::CANCELLED => 'Dibatalkan', + self::REFUNDED => 'Dikembalikan', + }; + } +} + +// ✅ JANGAN pakai string biasa untuk status +// ❌ 'status' => 'pending' // tanpa enum +// ✅ 'status' => OrderStatus::PENDING // dengan enum +``` + +### Accessor & Mutator — Nama harus BERBEDA dari kolom DB +```php +// ✅ Nama accessor TIDAK BOLEH sama dengan nama kolom di DB +// Format: prefix 'formatted_' atau suffix '_label' +// Gunakan Attribute::make(get: fn () => ...) + +use Illuminate\Database\Eloquent\Casts\Attribute; + +// Currency → prefix 'formatted_' +protected function formattedAmount(): Attribute +{ + return Attribute::make( + get: fn () => 'Rp ' . number_format($this->amount, 0, ',', '.'), + ); +} + +// Date → prefix 'formatted_' +protected function formattedJoinDate(): Attribute +{ + return Attribute::make( + get: fn () => $this->join_date?->translatedFormat('l, d F Y'), + ); +} + +// 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), + ); +} + +// 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() +``` + +### Appends — Data yang Ditampilkan +```php +// ✅ Gunakan snake_case (sama dengan nama accessor) +#[Appends(['formatted_amount', 'formatted_join_date'])] + +// ❌ JANGAN append kolom yang sudah ada di DB (hanya untuk accessor) +// ❌ Appends(['amount']) // amount sudah ada di DB +// ✅ Appends(['formatted_amount']) // formatted_amount adalah accessor +``` + +### Scopes — untuk Data Opsi +```php +// ✅ SELALU buat scope untuk data yang punya opsi +// Gunakan atribut #[Scope] dan return type void + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Attributes\Scope; + +// Untuk enum +#[Scope] +protected function pending(Builder $query): void +{ + $query->where('status', OrderStatus::PENDING); +} + +// Untuk boolean +#[Scope] +protected function active(Builder $query): void +{ + $query->where('is_active', true); +} + +// ❌ JANGAN: public function pending(Builder $query): Builder +// ❌ JANGAN: scopePending (pakai prefix 'scope') +// ✅ BENAR: #[Scope] + protected function pending(Builder $query): void +``` + +### Relationships — WAJIB 2 ARAH +```php +// ✅ Setiap relasi HARUS ditulis di KEDUA model + +// Model: User +public function userProfile(): HasOne +{ + return $this->hasOne(UserProfile::class); +} + +// Model: UserProfile (2 ARAH!) +public function user(): BelongsTo +{ + return $this->belongsTo(User::class); +} + +// ✅ Contoh relasi 2 arah: +// User ↔ UserProfile +// User ↔ Employee +// User ↔ CashAccount (created_by_id) +// CashAccount ↔ CashTransaction +// CashTransaction ↔ Expense (1:1) +// Order ↔ OrderItem +// Cutting ↔ CuttingMaterial +// dll. + +// ❌ JANGAN hanya tulis di salah 1 model +// Jika ada User::userProfile(), maka HARUS ada UserProfile::user() +``` + +### Relationship Naming +```php +// BelongsTo → singular +user() // → BelongsTo User +cashAccount() // → BelongsTo CashAccount + +// HasMany → plural +orders() // → HasMany Order +payrolls() // → HasMany Payroll + +// HasOne → singular +userProfile() // → HasOne UserProfile +employee() // → HasOne Employee + +// Custom FK → pastikan ada parameter +createdCuttings() // → HasMany Cutting, 'created_by_id' +submittedCuttings() // → HasMany Cutting, 'submitted_by_id' +``` + +### Eloquent Select & Eager Loading — Selalu Select yang Dibutuhkan +```php +// ✅ SELALU select kolom yang dibutuhkan saja — gunakan array syntax +$orders = Order::select(['id', 'order_number', 'total_amount', 'status']) + ->with(['customer:id,name', 'createdBy:id,username']) + ->get(); + +// ✅ Eager load relasi yang dibutuhkan +$products = Product::with([ + 'categories:id,name', + 'productVariants:id,product_id,name,stock', +])->get(); + +// ❌ JANGAN: Product::all() — load semua kolom + semua relasi +// ❌ JANGAN: Order::with(['customer', 'createdBy', 'orderItems', 'cashTransaction'])->get() — terlalu banyak relasi + +// ✅ Pilih relasi yang benar-benar ditampilkan di view +$orders = Order::select(['id', 'order_number', 'total_amount', 'status', 'customer_id', 'created_by_id']) + ->with([ + 'customer:id,name', + 'createdBy:id,username', + ]) + ->orderBy($sort, $direction) + ->paginate($perPage); +``` + +--- + +## Controller + +### SELALU Gunakan Return Type +```php +// ✅ Return type wajib ada di SEMUA method — controller & service +// ✅ Response untuk controller, void/array/bool untuk service +``` + +### ZONK — Tidak Ada Logic di Controller +```php +// ✅ Controller HANYA mengatur lalu lintas +// Seluruh logic ADA DI SERVICE + +class ProductController extends Controller +{ + public function __construct( + private ProductService $service + ) {} + + public function index(Request $request): Response + { + return Inertia::render('Admin/Master/Products/Index', [ + 'products' => $this->service->paginated( + perPage: $request->input('per_page', 25), + search: $request->input('search', ''), + sort: $request->input('sort', 'created_at'), + direction: $request->input('direction', 'desc'), + ), + ]); + } + + public function store(ProductRequest $request): Response + { + $this->service->store($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditambahkan']); + + return back(); + } +} + +// ❌ JANGAN ada logic di controller, SEKECIL APAPUN +// ❌ $name = strtoupper($request->name); // logic → pindah ke service +// ❌ if ($request->hasFile('photo')) { ... } // logic → pindah ke service + +// ✅ Jika ada yang看似simple seperti resetPassword, tetap pindah ke service +public function resetPassword(UserRequest $request, User $user): Response +{ + $this->service->resetPassword($user, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Password berhasil direset']); + + return back(); +} +``` + +### Urutan Method +```php +class ProductController extends Controller +{ + // 1. __construct() + public function __construct(private ProductService $service) {} + + // 2. index() + public function index(Request $request): Response { ... } + + // 3. create() + public function create(): Response { ... } + + // 4. store() + public function store(ProductRequest $request): Response { ... } + + // 5. show() — jika ada + public function show(Product $product): Response { ... } + + // 6. edit() + public function edit(Product $product): Response { ... } + + // 7. update() + public function update(ProductRequest $request, Product $product): Response { ... } + + // 8. destroy() + public function destroy(Product $product): Response { ... } + + // 9. Custom actions (jika ada, JANGAN dipaksakan) + public function toggleStatus(Product $product): Response { ... } + public function resetPassword(UserRequest $request, User $user): Response { ... } +} +``` + +### Return ke Halaman yang Sama — Gunakan `back()` +```php +// ✅ SELALU gunakan Inertia::flash() sebelum return back() +public function destroy(Product $product) +{ + $this->service->destroy($product); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus']); + + return back(); +} + +// ❌ JANGAN pakai ->with() +return back()->with('success', 'Produk berhasil dihapus'); // JANGAN + +// ✅ Untuk error di handleAction +return $this->handleAction( + fn () => $this->service->store($data), + 'Data berhasil ditambahkan', + route('admin.master.products.index') +); +// handleAction sudah handle flash otomatis + +// ✅ Untuk redirect ke halaman lain +return to_route('dashboard'); // ✅ hanya jika memang pindah halaman +``` + +### Jangan Panggil Model Langsung — Gunakan Service +```php +// ❌ JANGAN panggil Model langsung di Controller +public function index() +{ + $categories = Category::all(); // JANGAN + return Inertia::render('...', compact('categories')); +} + +// ✅ SELALU gunakan Service +public function index() +{ + $categories = $this->categoryService->getAll(); // ✅ + return Inertia::render('...', compact('categories')); +} +``` + +### DB Transaction — Gunakan `handleAction()` untuk 2+ Query +```php +// ✅ handleAction() → untuk 2+ query/operasi dalam 1 action +public function store(OrderRequest $request): Response +{ + return $this->handleAction( + fn () => $this->service->store($request->validated()), + 'Transaksi berhasil ditambahkan', + route('admin.manage.transactions.index') + ); +} + +// ✅ handleAction() otomatis wrap dalam DB::transaction() +// ✅ Jika gagal, otomatis rollback + flash error + +// ✅ 1 query → manual flash (tanpa handleAction) +public function destroy(Product $product): Response +{ + $this->service->destroy($product); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus']); + + return back(); +} +``` + +### Service Property — Constructor Promotion +```php +// ✅ Gunakan constructor promotion +public function __construct( + private ProductService $service +) {} + +// ❌ JANGAN +private ProductService $service = new ProductService; // JANGAN +public ProductService $service; // JANGAN (public) +``` + +--- + +## Service + +### ZONK — Semua Logic Ada di Sini +```php +// ✅ Service adalah tempat SELURUH logic +// Komunikasi DB, validasi bisnis, manipulasi data — semua di sini + +class ProductService +{ + public function store(array $data): Product + { + $data['slug'] = Str::slug($data['name']); + return Product::create($data); + } + + public function update(Product $product, array $data): Product + { + $product->update($data); + return $product; + } + + public function destroy(Product $product): void + { + $product->delete(); + } + + public function resetPassword(User $user, array $data): void + { + $user->update([ + 'password' => Hash::make($data['password']), + ]); + } +} +``` + +### Penamaan Method — HARUS KONSISTEN +```php +// ✅ Nama method harus KONSISTEN di SEMUA service +// Semua service HARUS punya method ini: + +// 1. paginated() — untuk paginasi +public function paginated( + int $perPage = 25, + string $search = '', + string $sort = 'created_at', + string $direction = 'desc', + array $filters = [] +): LengthAwarePaginator { + return Product::query() + ->select(['id', 'name', 'slug', 'status']) // ✅ select kolom yang dibutuhkan + ->with(['categories:id,name']) // ✅ eager load relasi + ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) + ->orderBy($sort, $direction) + ->paginate($perPage); +} + +// 2. getAll() — untuk semua data (tanpa paginasi) +public function getAll(array $filters = []): 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; + } +} +``` + +### 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; + } +} +``` + +### 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); +``` + +--- + +## Form Request + +### 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')); + } +} +``` + +### 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 +Rule::unique('products')->ignore($this->route('product')?->id) + +// ✅ Saat store, tidak perlu ignore +Rule::unique('products') +``` + +### 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 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 `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 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 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 `` diff --git a/.ai/REFERENCE.md b/.ai/REFERENCE.md new file mode 100644 index 0000000..106bb83 --- /dev/null +++ b/.ai/REFERENCE.md @@ -0,0 +1,308 @@ +# Database Reference + +> Format: `table_name` → `model_name` | columns | relationships + +--- + +## Auth & User + +### `users` → User +`id` `email`(unique) `username`(unique) `password` `is_active`(bool) `last_login_at`(datetime) `created_at` `updated_at` `deleted_at` +- Casts: email_verified_at(datetime), is_active(bool), last_login_at(datetime), password(hashed), two_factor_confirmed_at(datetime) +- Scopes: active() +- Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), ownerVerificationRequestsSubmitted(HasMany→OwnerVerificationRequest,submitted_by_id), ownerVerificationRequestsVerified(HasMany→OwnerVerificationRequest,verified_by_id), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph) + +### `user_profiles` → UserProfile +`id` `user_id`(FK→users,unique) `full_name`(200) `phone_number`(20,null) `gender`(enum,null) `birth_date`(date,null) `address`(text,null) `created_at` `updated_at` `deleted_at` +- Casts: gender(Gender), birth_date(date:Y-m-d) +- Scopes: female(), male(), hasPhoneNumber() +- Relations: user(BelongsTo→User) + +### `push_subscriptions` → PushSubscription +`id` `subscribable_type` `subscribable_id` `endpoint`(500,unique) `public_key`(null) `auth_token`(null) `content_encoding`(null) `created_at` `updated_at` +- Relations: user(MorphTo) + +### `notifications` → AppNotification +`id` `user_id`(FK→users) `title` `body`(text,null) `url`(null) `is_read`(bool,default:false) `read_at`(datetime,null) `created_at` `updated_at` +- Casts: is_read(bool), read_at(datetime) +- Relations: user(BelongsTo→User) + +--- + +## Master Data + +### `categories` → Category +`id` `name`(50) `slug`(50,unique) `created_at` `updated_at` `deleted_at` +- Relations: products(BelongsToMany→Product via product_categories) +- Accessor: formattedName → ucfirst(name) + +### `product_categories` → Category (Pivot) +`product_id`(FK→products) `category_id`(FK→categories) +- No id, no timestamps +- Relations: category(BelongsTo→Category), product(BelongsTo→Product) + +### `products` → Product +`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `created_at` `updated_at` `deleted_at` +- Casts: status(ProductStatus) +- Scopes: active(), draft(), inactive() +- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant) + +### `product_variants` → ProductVariant +`id` `product_id`(FK→products) `name`(200) `stock`(uint,default:0) `reject_stock`(uint,default:0) `retail_stock`(uint,default:0) `created_at` `updated_at` `deleted_at` +- Casts: stock(int), reject_stock(int), retail_stock(int) +- GlobalScope: orderBy(name) +- Relations: product(BelongsTo→Product), productPrices(HasMany→ProductPrice,variant_id), orderItems(HasMany→OrderItem), restockItems(HasMany→RestockItem), retailStockHistories(HasMany→RetailStockHistory), stokOpnameItems(HasMany→StokOpnameItem), stockMutations(HasMany→StockMutation,morph) + +### `product_prices` → ProductPrice +`id` `variant_id`(FK→product_variants) `type`(enum) `price`(uint) `created_at` `updated_at` +- UNIQUE(variant_id, type) +- Casts: type(PriceType), price(int) +- Scopes: retail(), wholesale() +- Relations: variant(BelongsTo→ProductVariant) + +### `customers` → Customer +`id` `name`(200) `phone_number`(20,null) `address`(text,null) `created_at` `updated_at` `deleted_at` +- Relations: orders(HasMany→Order) +- Accessor: formattedPhoneNumber (get: "0821 2121 2121", set: strip whitespace) + +### `suppliers` → Supplier +`id` `name`(200) `phone_number`(20,null) `address`(text,null) `created_at` `updated_at` `deleted_at` +- Relations: purchases(HasMany→Purchase) +- Accessor: formattedPhoneNumber (get: "0821 2121 2121", set: strip whitespace) + +### `raw_materials` → RawMaterial +`id` `name`(200) `unit`(enum) `is_active`(bool,default:true) `created_at` `updated_at` `deleted_at` +- Casts: unit(RawMaterialUnit), is_active(bool) +- Scopes: active(), nonactive(), kg(), meter(), yard() +- Relations: rawMaterialPrices(HasMany→RawMaterialPrice) + +### `raw_material_prices` → RawMaterialPrice +`id` `raw_material_id`(FK→raw_materials) `variant`(200) `price`(uint) `stock`(uint,default:0) `created_at` `updated_at` `deleted_at` +- Casts: price(int), stock(int) +- GlobalScope: orderBy(variant) +- Relations: rawMaterial(BelongsTo→RawMaterial,withTrashed), cuttingMaterials(HasMany→CuttingMaterial), purchaseItems(HasMany→PurchaseItem) +- Accessor: photo_url → first media presigned S3 URL + +--- + +## Finance + +### `cash_accounts` → CashAccount +`id` `created_by_id`(FK→users) `name`(200) `balance`(ubig,default:0) `created_at` `updated_at` `deleted_at` +- Casts: balance(int) +- Relations: cashTransactions(HasMany→CashTransaction), createdBy(BelongsTo→User) + +### `cash_transactions` → CashTransaction +`id` `cash_account_id`(FK→cash_accounts) `created_by_id`(FK→users) `reference_id`(ubig,null,morph) `reference_type`(string,null,morph) `amount`(ubig) `balance_after`(ubig) `type`(enum,default:deposit) `description`(100) `created_at` `updated_at` `deleted_at` +- Casts: type(CashTransactionType), amount(int), balance_after(int) +- Scopes: deposit(), expenseType(), transfer(), withdrawal() +- Relations: cashAccount(BelongsTo→CashAccount), createdBy(BelongsTo→User), reference(MorphTo), expense(HasOne→Expense), order(HasOne→Order), employeeAdvances(HasMany→EmployeeAdvance), payrolls(HasMany→Payroll) + +### `expenses` → Expense +`id` `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `amount`(ubig) `description`(100) `created_at` `updated_at` `deleted_at` +- Casts: amount(int), date(date:Y-m-d) +- Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User) + +### `employee_advances` → EmployeeAdvance +`id` `paid_by_id`(FK→users,null) `employee_id`(FK→employees) `verified_by_id`(FK→users,null) `repayment_cash_transaction_id`(FK→cash_transactions,unique,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `amount`(ubig) `paid_amount`(ubig,default:0) `description`(100) `due_date`(date) `status`(enum,default:pending) `verified_at`(datetime,null) `paid_at`(datetime,null) `created_at` `updated_at` `deleted_at` +- Casts: status(EmployeeAdvanceStatus), amount(int), paid_amount(int), due_date(date:Y-m-d), verified_at(datetime), paid_at(datetime) +- Scopes: approved(), cancelled(), paid(), pending(), rejected() +- Relations: employee(BelongsTo→Employee), cashTransaction(BelongsTo→CashTransaction), repaymentCashTransaction(BelongsTo→CashTransaction), paidBy(BelongsTo→User), verifiedBy(BelongsTo→User), payments(HasMany→EmployeeAdvancePayment) + +### `employee_advance_payments` → EmployeeAdvancePayment +`id` `employee_advance_id`(FK→employee_advances) `paid_by_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `amount`(ubig) `description`(100,null) `paid_at`(datetime) `created_at` `updated_at` +- No soft deletes +- Casts: amount(int), paid_at(datetime) +- Relations: employeeAdvance(BelongsTo→EmployeeAdvance), paidBy(BelongsTo→User), cashTransaction(BelongsTo→CashTransaction) + +--- + +## HR + +### `employees` → Employee +`id` `user_id`(FK→users,unique) `join_date`(date) `resign_date`(date,null) `employment_status`(enum,default:full_time) `base_salary`(uint) `created_at` `updated_at` `deleted_at` +- Casts: employment_status(EmploymentStatus), base_salary(int), join_date(date:Y-m-d), resign_date(date:Y-m-d) +- Scopes: contract(), fullTime(), internship(), partTime(), resigned() +- Relations: user(BelongsTo→User), attendances(HasMany→Attendance), employeeAdvances(HasMany→EmployeeAdvance), leaveRequests(HasMany→LeaveRequest), payrolls(HasMany→Payroll) + +### `attendances` → Attendance +`id` `employee_id`(FK→employees) `attendance_date`(date) `check_in_at`(datetime) `check_out_at`(datetime,null) `check_in_latitude`(decimal(10,7),null) `check_in_longitude`(decimal(10,7),null) `check_out_latitude`(decimal(10,7),null) `check_out_longitude`(decimal(10,7),null) `work_duration_minutes`(uint,null) `created_at` `updated_at` +- No soft deletes +- Casts: attendance_date(date:Y-m-d), check_in_at(datetime), check_out_at(datetime), check_in/out_latitude/longitude(decimal:7), work_duration_minutes(int) +- Relations: employee(BelongsTo→Employee), payrollAdjustments(HasMany→PayrollAdjustment) + +### `payroll_periods` → PayrollPeriod +`id` `closed_by_id`(FK→users,null) `year`(usmall) `month`(utiny) `status`(enum,default:open) `closed_at`(datetime,null) `created_at` `updated_at` `deleted_at` +- Casts: status(PayrollPeriodStatus), year(int), month(int), closed_at(datetime) +- Scopes: closed(), open() +- Relations: closedBy(BelongsTo→User), payrolls(HasMany→Payroll) + +### `payrolls` → Payroll +`id` `payroll_period_id`(FK→payroll_periods) `employee_id`(FK→employees) `cash_transaction_id`(FK→cash_transactions,unique,null) `paid_by_id`(FK→users,null) `base_salary`(uint) `bonus_amount`(ubig,default:0) `deduction_amount`(ubig,default:0) `total_amount`(ubig) `status`(enum,default:unpaid) `paid_at`(datetime,null) `created_at` `updated_at` `deleted_at` +- Casts: status(PayrollStatus), base_salary(int), bonus_amount(int), deduction_amount(int), total_amount(int), paid_at(datetime) +- Scopes: cancelled(), paid(), unpaid() +- Relations: payrollPeriod(BelongsTo→PayrollPeriod), employee(BelongsTo→Employee), cashTransaction(BelongsTo→CashTransaction), paidBy(BelongsTo→User), payrollAdjustments(HasMany→PayrollAdjustment) + +### `payroll_adjustments` → PayrollAdjustment +`id` `payroll_id`(FK→payrolls) `attendance_id`(FK→attendances,null) `created_by_id`(FK→users) `type`(enum) `amount`(ubig) `description`(100) `created_at` `updated_at` `deleted_at` +- Casts: type(PayrollAdjustmentType), amount(int) +- Scopes: bonus(), deduction() +- Relations: payroll(BelongsTo→Payroll), attendance(BelongsTo→Attendance), createdBy(BelongsTo→User) + +### `leave_requests` → LeaveRequest +`id` `employee_id`(FK→employees) `verified_by_id`(FK→users,null) `start_date`(date) `end_date`(date) `total_days`(uint) `status`(enum,default:pending) `verified_at`(datetime,null) `created_at` `updated_at` `deleted_at` +- Casts: status(LeaveRequestStatus), start_date(date:Y-m-d), end_date(date:Y-m-d), total_days(int), verified_at(datetime) +- Scopes: approved(), cancelled(), pending(), rejected() +- Relations: employee(BelongsTo→Employee), verifiedBy(BelongsTo→User) + +--- + +## Sales + +### `orders` → Order +`id` `customer_id`(FK→customers,null) `marketing_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `order_number`(30,unique) `channel`(enum) `price_type`(enum) `status`(enum,default:pending) `payment_type`(enum,default:cash) `is_affiliate`(bool,default:false) `tiktok_order_id`(100,null) `shopee_order_id`(100,null) `subtotal`(ubig) `discount`(ubig,default:0) `nego_price`(ubig,null) `marketplace_settings_snapshot`(json,null) `total_amount`(ubig) `cogs`(ubig,default:0) `notes`(text,null) `created_at` `updated_at` `deleted_at` +- Casts: channel(OrderChannel), price_type(PriceType), status(OrderStatus), payment_type(PaymentType), is_affiliate(bool), subtotal(int), discount(int), nego_price(int), total_amount(int), cogs(int), marketplace_settings_snapshot(array) +- Scopes: cancelled(), cash(), completed(), pending(), processing(), qris(), refunded(), retail(), shopee(), store(), tiktok(), transfer(), wholesale() +- Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User), customer(BelongsTo→Customer), marketing(BelongsTo→User), orderItems(HasMany→OrderItem) + +### `order_items` → OrderItem +`id` `order_id`(FK→orders,null) `user_id`(FK→users,null) `product_variant_id`(FK→product_variants) `stock_quality`(enum,default:good) `quantity`(uint) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at` +- Casts: stock_quality(ProductStockQuality), quantity(int), unit_price(int), subtotal(int) +- Scopes: good(), reject() +- Relations: order(BelongsTo→Order), productVariant(BelongsTo→ProductVariant), user(BelongsTo→User) + +### `rejections` → Rejection +`id` `rejectable_type`(string,null,morph) `rejectable_id`(ubig,null,morph) `rejected_by_id`(FK→users) `reason`(500) `created_at` `updated_at` `deleted_at` +- Relations: rejectable(MorphTo), rejectedBy(BelongsTo→User) + +--- + +## Production + +### `cuttings` → Cutting +`id` `created_by_id`(FK→users) `submitted_by_id`(FK→users,null) `status`(enum,default:in_progress) `description`(100,null) `total_material_cost`(ubig,null) `sewing_cost`(ubig,default:0) `other_cost`(ubig,default:0) `cost_per_unit`(ubig,null) `created_at` `updated_at` `deleted_at` +- Casts: status(CuttingStatus), total_material_cost(int), cost_per_unit(int), sewing_cost(int), other_cost(int) +- Scopes: cancelled(), completed(), inProgress() +- Relations: createdBy(BelongsTo→User), submittedBy(BelongsTo→User), cuttingMaterialCombinations(HasMany→CuttingMaterialCombination), cuttingMaterials(HasMany→CuttingMaterial), cuttingResults(HasMany→CuttingResult) + +### `cutting_material_combinations` → CuttingMaterialCombination +`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `material_result`(int,null) `created_at` `updated_at` `deleted_at` +- Casts: material_result(int) +- Relations: cutting(BelongsTo→Cutting), cuttingMaterials(HasMany→CuttingMaterial,combination_id), user(BelongsTo→User) + +### `cutting_materials` → CuttingMaterial +`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `raw_material_price_id`(FK→raw_material_prices) `combination_id`(FK→cutting_material_combinations,null) `material_usage`(int) `material_result`(int,null) `created_at` `updated_at` `deleted_at` +- Casts: material_usage(int), material_result(int) +- Relations: cutting(BelongsTo→Cutting), rawMaterialPrice(BelongsTo→RawMaterialPrice), combination(BelongsTo→CuttingMaterialCombination), user(BelongsTo→User) + +### `cutting_results` → CuttingResult +`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `product_name`(255,null) `cutting_result`(uint,null) `sample`(uint,null) `original_outside_sample`(uint,null) `created_at` `updated_at` `deleted_at` +- Casts: cutting_result(int), sample(int), original_outside_sample(int) +- Relations: cutting(BelongsTo→Cutting), user(BelongsTo→User) + +--- + +## Inventory + +### `purchases` → Purchase +`id` `supplier_id`(FK→suppliers) `created_by_id`(FK→users) `subtotal`(ubig) `discount`(ubig,default:0) `shipping_cost`(ubig,default:0) `total`(ubig) `notes`(100,null) `created_at` `updated_at` `deleted_at` +- Casts: subtotal(int), discount(int), shipping_cost(int), total(int) +- Relations: createdBy(BelongsTo→User), supplier(BelongsTo→Supplier), purchaseItems(HasMany→PurchaseItem) + +### `purchase_items` → PurchaseItem +`id` `purchase_id`(FK→purchases,null) `user_id`(FK→users,null) `raw_material_price_id`(FK→raw_material_prices) `quantity`(uint) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at` +- Casts: quantity(int), unit_price(int), subtotal(int) +- Relations: purchase(BelongsTo→Purchase), rawMaterialPrice(BelongsTo→RawMaterialPrice,withTrashed), user(BelongsTo→User) + +### `restocks` → Restock +`id` `created_by_id`(FK→users) `subtotal`(ubig) `total`(ubig) `notes`(100,null) `stock_type`(enum,default:good) `created_at` `updated_at` `deleted_at` +- Casts: stock_type(ProductStockQuality), subtotal(int), total(int) +- Scopes: good(), reject() +- Relations: createdBy(BelongsTo→User), restockItems(HasMany→RestockItem) + +### `restock_items` → RestockItem +`id` `restock_id`(FK→restocks,null) `user_id`(FK→users,null) `product_variant_id`(FK→product_variants) `quantity`(int) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at` +- Casts: quantity(int), unit_price(int), subtotal(int) +- Relations: restock(BelongsTo→Restock), productVariant(BelongsTo→ProductVariant), user(BelongsTo→User) + +### `stok_opnames` → StokOpname +`id` `created_by_id`(FK→users) `verified_by_id`(FK→users,null) `opname_date`(date) `status`(enum,default:draft) `notes`(text,null) `verification_notes`(text,null) `created_at` `updated_at` `deleted_at` +- Casts: status(StokOpnameStatus), opname_date(date:Y-m-d) +- Scopes: cancelled(), completed(), draft(), inProgress(), verified() +- Relations: createdBy(BelongsTo→User), verifiedBy(BelongsTo→User), stokOpnameItems(HasMany→StokOpnameItem) + +### `stok_opname_items` → StokOpnameItem +`id` `stok_opname_id`(FK→stok_opnames) `product_variant_id`(FK→product_variants) `stock_quality`(enum,default:good) `system_stock`(uint,default:0) `physical_stock`(uint,default:0) `difference`(int,default:0) `notes`(text,null) `created_at` `updated_at` +- No soft deletes +- Casts: stock_quality(ProductStockQuality), system_stock(int), physical_stock(int), difference(int) +- Scopes: good(), reject() +- Relations: stokOpname(BelongsTo→StokOpname), productVariant(BelongsTo→ProductVariant) + +### `retail_stock_histories` → RetailStockHistory +`id` `product_variant_id`(FK→product_variants) `user_id`(FK→users) `quantity`(uint) `stock_before`(uint) `retail_stock_before`(uint) `stock_after`(uint) `retail_stock_after`(uint) `notes`(255,null) `created_at` +- No soft deletes, no updated_at +- $timestamps = false +- Casts: quantity(int), stock_before(int), retail_stock_before(int), stock_after(int), retail_stock_after(int) +- Relations: productVariant(BelongsTo→ProductVariant), user(BelongsTo→User) + +### `stock_mutations` → StockMutation +`id` `user_id`(FK→users) `stockable_type`(string) `stockable_id`(ubig) `type`(string) `source_type`(string,null) `source_id`(ubig,null) `quantity`(int) `stock_before`(int) `stock_after`(int) `stock_quality`(string,null) `description`(string,null) `created_at` `updated_at` +- No soft deletes +- Casts: quantity(int), stock_before(int), stock_after(int) +- Relations: stockable(MorphTo), source(MorphTo), user(BelongsTo→User) + +--- + +## Settings + +### `system_configurations` → SystemConfiguration +`id` `created_at` `updated_at` +- Singleton table + +### `homepage_configurations` → HomepageConfiguration +`id` `created_at` `updated_at` +- Singleton table + +### `owner_verification_requests` → OwnerVerificationRequest +`id` `action`(50) `status`(50,default:pending) `subject_id`(ubig,null,morph) `subject_type`(string,null,morph) `submitted_by_id`(FK→users) `verified_by_id`(FK→users,null) `payload`(json) `verified_at`(datetime,null) `created_at` `updated_at` +- Casts: payload(array), verified_at(datetime) +- Relations: subject(MorphTo), submittedBy(BelongsTo→User), verifiedBy(BelongsTo→User) + +### `settings` (laravel-settings) +`id` `group`(string) `name`(string) `locked`(bool,default:false) `payload`(json) `created_at` `updated_at` +- UNIQUE(group, name) + +--- + +## Enums + +| Enum | Values | Used In | +|------|--------|---------| +| `CashTransactionType` | deposit, expense, transfer, withdrawal | cash_transactions.type | +| `CuttingStatus` | in_progress, completed, cancelled | cuttings.status | +| `EmployeeAdvanceStatus` | pending, approved, rejected, paid, cancelled | employee_advances.status | +| `EmploymentStatus` | full_time, part_time, contract, internship, resigned | employees.employment_status | +| `Gender` | male, female | user_profiles.gender | +| `LeaveRequestStatus` | pending, approved, rejected, cancelled | leave_requests.status | +| `Modules` | — | Permission module names | +| `OrderChannel` | store, shopee, tiktok | orders.channel | +| `OrderStatus` | pending, processing, completed, cancelled, refunded | orders.status | +| `PaymentType` | cash, transfer, qris | orders.payment_type | +| `PayrollAdjustmentType` | bonus, deduction | payroll_adjustments.type | +| `PayrollPeriodStatus` | open, closed | payroll_periods.status | +| `PayrollStatus` | unpaid, paid, cancelled | payrolls.status | +| `Permission` | — | Permission action names | +| `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type | +| `ProductStatus` | active, draft, inactive | products.status | +| `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality | +| `RawMaterialUnit` | kg, meter, yard | raw_materials.unit | +| `Role` | — | Role names | +| `StokOpnameStatus` | draft, in_progress, completed, verified, cancelled | stok_opnames.status | + +## Service Concerns + +| Trait | Methods | Used By | +|-------|---------|---------| +| `HandlesCashTransactions` | getCashAccount(), creditCash(), debitCash() | CashAccountService, ExpenseService, EmployeeAdvanceService, PayrollPeriodService | +| `HasStockAdjustment` | adjustStock(), adjustVariantStock(), applyStock(), reverseStock() | TransactionService, RestockService | +| `RegistersMedia` | registerMedia(), syncPhoto() | CashAccountService, CuttingService, ExpenseService, PurchaseService, RestockService, TransactionService | diff --git a/AGENTS.md b/AGENTS.md index 7ccfc30..0e483eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,957 +1,209 @@ -# AGENTS.md - Session Notes +# AGENTS.md — Module/Feature Development Checklist -## Status: In Progress +> Checklist saat membuat module atau fitur baru. + +## ⚠️ WAJIB BACA SEBELUM KERJA + +Sebelum memulai/modifikasi apapun, **WAJIB** baca file di `.ai/` folder: + +``` +.ai/ +├── CONTEXT.md # Project context, roles, module mapping +├── REFERENCE.md # Database schema (tabel, relasi, enums) +└── CONVENTIONS.md # Aturan kode (backend + frontend) +``` + +**Baca urutan:** +1. `CONTEXT.md` → pahami project, role, dan module +2. `REFERENCE.md` → pahami struktur data +3. `CONVENTIONS.md` → pahami cara nulis kode + +**Jangan pernah skip membaca `.ai/` folder** — semua aturan ada di sana. --- -## 1. Relasi Dua Arah - Perubahan di Sesi Ini - -| Model | Relasi Baru | Tipe | Inverse | -|-------|------------|------|---------| -| Cutting | submittedBy() | belongsTo(User, 'submitted_by_id') | User.submittedCuttings() | -| CashTransaction | employeeAdvances() | hasMany(EmployeeAdvance) | EmployeeAdvance.cashTransaction() | -| CashTransaction | payrolls() | hasMany(Payroll) | Payroll.cashTransaction() | -| Attendance | payrollAdjustments() | hasMany(PayrollAdjustment) | PayrollAdjustment.attendance() | -| User | cuttingMaterials() | hasMany(CuttingMaterial) | CuttingMaterial.user() | -| User | cuttingMaterialCombinations() | hasMany(CuttingMaterialCombination) | CuttingMaterialCombination.user() | -| User | cuttingResults() | hasMany(CuttingResult) | CuttingResult.user() | -| User | pushSubscriptions() | hasMany(PushSubscription) | PushSubscription.user() | - -### File diubah untuk relasi: -- app/Models/Cutting.php - tambah submittedBy() -- app/Models/CashTransaction.php - tambah employeeAdvances(), payrolls(), import HasMany -- app/Models/Attendance.php - tambah payrollAdjustments(), import HasMany -- app/Models/User.php - tambah 4 relasi - ---- - -## 2. Casting - Perubahan di Sesi Ini - -| Model | Kolom | Cast | Keterangan | -|-------|-------|------|------------| -| Cutting | sewing_cost | integer | tambah ke casts existing | -| Cutting | other_cost | integer | tambah ke casts existing | -| OwnerVerificationRequest | payload | array | tambah ke casts existing | -| Order | marketplace_settings_snapshot | array | tambah ke casts existing | -| ProductVariant | stock | integer | casts() baru dibuat | -| ProductVariant | reject_stock | integer | casts() baru dibuat | -| ProductVariant | retail_stock | integer | casts() baru dibuat | -| PayrollPeriod | year | integer | tambah ke casts existing | -| PayrollPeriod | month | integer | tambah ke casts existing | - -### File diubah untuk casting: -- app/Models/Cutting.php -- app/Models/OwnerVerificationRequest.php -- app/Models/Order.php -- app/Models/ProductVariant.php -- app/Models/PayrollPeriod.php - ---- - -## 3. Scope (Ordered by Abjad) - Perubahan di Sesi Ini - -### Model yang DITAMBAH scope-nya: -| Model | Scope Baru | Enum | -|-------|-----------|------| -| Cutting | cancelled(), completed(), inProgress() | CuttingStatus | -| RawMaterial | kg(), meter(), yard() | RawMaterialUnit | - -### Model yang SUDAH LENGKAP scopes: -| Model | Scopes (abjad) | -|-------|---------------| -| CashTransaction | deposit, expenseType, transfer, withdrawal | -| Employee | contract, fullTime, internship, partTime, resigned | -| EmployeeAdvance | approved, cancelled, paid, pending, rejected | -| LeaveRequest | approved, cancelled, pending, rejected | -| Order | cancelled, cash, completed, pending, processing, qris, refunded, retail, shopee, store, tiktok, transfer, wholesale | -| OrderItem | good, reject | -| Payroll | cancelled, paid, unpaid | -| PayrollAdjustment | bonus, deduction | -| PayrollPeriod | closed, open | -| Product | active, draft, inactive | -| ProductPrice | retail, wholesale | -| Restock | good, reject | -| StokOpname | cancelled, completed, draft, inProgress, verified | -| StokOpnameItem | good, reject | -| UserProfile | female, hasPhoneNumber, male | -| User | active | - -### Model yang TIDAK punya enum (tidak perlu scope): -AppNotification, CashAccount, Category, Customer, Expense, CuttingMaterial, CuttingMaterialCombination, CuttingResult, OwnerVerificationRequest, ProductCategory, ProductVariant, Purchase, PurchaseItem, RawMaterialPrice, Rejection, RestockItem, RetailStockHistory, StockMutation, Supplier, SystemConfiguration, HomepageConfiguration, PushSubscription - ---- - -## 4. Reorganisasi Model - Semua model diubah urutannya menjadi: -**casts → scopes (abjad) → relations (abjad)** - -### Semua 41 model sudah di-reorganize di sesi ini. - ---- - -## 5. Relasi Dua Arah - Perubahan di Sesi Ini - -### AppNotification -- belongsTo User (user_id) - -### Attendance -- belongsTo Employee (employee_id) -- hasMany PayrollAdjustment - -### CashAccount -- belongsTo User (created_by_id) -- hasMany CashTransaction - -### CashTransaction -- belongsTo CashAccount (cash_account_id) -- belongsTo User (created_by_id) -- hasMany EmployeeAdvance -- hasMany Payroll -- hasOne Expense -- hasOne Order -- morphTo Reference (reference_id, reference_type) - -### Category -- belongsToMany Product (via product_categories) - -### Cutting -- belongsTo User (created_by_id) -- belongsTo User (submitted_by_id) -- hasMany CuttingMaterialCombination -- hasMany CuttingMaterial -- hasMany CuttingResult - -### CuttingMaterial -- belongsTo CuttingMaterialCombination (combination_id) -- belongsTo Cutting (cutting_id) -- belongsTo RawMaterialPrice (raw_material_price_id) -- belongsTo User (user_id) - -### CuttingMaterialCombination -- belongsTo Cutting (cutting_id) -- hasMany CuttingMaterial (combination_id) -- belongsTo User (user_id) - -### CuttingResult -- belongsTo Cutting (cutting_id) -- belongsTo User (user_id) - -### Customer -- hasMany Order (customer_id) - -### Employee -- belongsTo User (user_id) -- hasMany Attendance -- hasMany EmployeeAdvance -- hasMany LeaveRequest -- hasMany Payroll - -### EmployeeAdvance -- belongsTo CashTransaction (cash_transaction_id) -- belongsTo Employee (employee_id) -- belongsTo User (paid_by_id) -- belongsTo CashTransaction (repayment_cash_transaction_id) -- belongsTo User (verified_by_id) - -### Expense -- belongsTo CashTransaction (cash_transaction_id) -- belongsTo User (created_by_id) - -### LeaveRequest -- belongsTo Employee (employee_id) -- belongsTo User (verified_by_id) - -### Order -- belongsTo CashTransaction (cash_transaction_id) -- belongsTo User (created_by_id) -- belongsTo Customer (customer_id) -- belongsTo User (marketing_id) -- hasMany OrderItem (order_id) - -### OrderItem -- belongsTo Order (order_id) -- belongsTo ProductVariant (product_variant_id) -- belongsTo User (user_id) - -### OwnerVerificationRequest -- morphTo Subject (subject_id, subject_type) -- belongsTo User (submitted_by_id) -- belongsTo User (verified_by_id) - -### Payroll -- belongsTo CashTransaction (cash_transaction_id) -- belongsTo Employee (employee_id) -- belongsTo User (paid_by_id) -- hasMany PayrollAdjustment (payroll_id) -- belongsTo PayrollPeriod (payroll_period_id) - -### PayrollAdjustment -- belongsTo Attendance (attendance_id) -- belongsTo User (created_by_id) -- belongsTo Payroll (payroll_id) - -### PayrollPeriod -- belongsTo User (closed_by_id) -- hasMany Payroll (payroll_period_id) - -### Product -- belongsToMany Category (via product_categories) -- hasMany ProductVariant (product_id) - -### ProductCategory (Pivot) -- belongsTo Category (category_id) -- belongsTo Product (product_id) - -### ProductPrice -- belongsTo ProductVariant (variant_id) - -### ProductVariant -- belongsTo Product (product_id) -- hasMany OrderItem (product_variant_id) -- hasMany ProductPrice (variant_id) -- hasMany RestockItem (product_variant_id) -- hasMany RetailStockHistory (product_variant_id) -- hasMany StokOpnameItem (product_variant_id) -- hasMany StockMutation (stockable_id, polymorphic) - -### Purchase -- belongsTo User (created_by_id) -- hasMany PurchaseItem (purchase_id) -- belongsTo Supplier (supplier_id) - -### PurchaseItem -- belongsTo Purchase (purchase_id) -- belongsTo RawMaterialPrice (raw_material_price_id) -- belongsTo User (user_id) - -### PushSubscription -- belongsTo User (user_id) - -### RawMaterial -- hasMany RawMaterialPrice (raw_material_id) - -### RawMaterialPrice -- belongsTo RawMaterial (raw_material_id) -- hasMany CuttingMaterial (raw_material_price_id) -- hasMany PurchaseItem (raw_material_price_id) - -### Rejection -- morphTo Rejectable (rejectable_id, rejectable_type) -- belongsTo User (rejected_by_id) - -### Restock -- belongsTo User (created_by_id) -- hasMany RestockItem (restock_id) - -### RestockItem -- belongsTo ProductVariant (product_variant_id) -- belongsTo Restock (restock_id) -- belongsTo User (user_id) - -### RetailStockHistory -- belongsTo ProductVariant (product_variant_id) -- belongsTo User (user_id) - -### StockMutation -- morphTo Source (source_id, source_type) -- morphTo Stockable (stockable_id, stockable_type) -- belongsTo User (user_id) - -### StokOpname -- belongsTo User (created_by_id) -- hasMany StokOpnameItem (stok_opname_id) -- belongsTo User (verified_by_id) - -### StokOpnameItem -- belongsTo ProductVariant (product_variant_id) -- belongsTo StokOpname (stok_opname_id) - -### Supplier -- hasMany Purchase (supplier_id) - -### User -- hasMany Attendance (user_id) -- hasMany CashAccount (created_by_id) -- hasMany CashTransaction (created_by_id) -- hasMany Cutting (created_by_id) -- hasMany Cutting (submitted_by_id) -- hasMany CuttingMaterialCombination (user_id) -- hasMany CuttingMaterial (user_id) -- hasMany CuttingResult (user_id) -- hasMany Expense (created_by_id) -- hasMany Order (created_by_id) -- hasMany Order (marketing_id) -- hasMany OrderItem (user_id) -- hasMany OwnerVerificationRequest (submitted_by_id) -- hasMany OwnerVerificationRequest (verified_by_id) -- hasMany Payroll (paid_by_id) -- hasMany PayrollAdjustment (created_by_id) -- hasMany PayrollPeriod (closed_by_id) -- hasMany Purchase (created_by_id) -- hasMany PurchaseItem (user_id) -- hasMany PushSubscription (user_id) -- hasMany Rejection (rejected_by_id) -- hasMany Restock (created_by_id) -- hasMany RestockItem (user_id) -- hasMany RetailStockHistory (user_id) -- hasMany StockMutation (user_id) -- hasMany StokOpname (created_by_id) -- hasMany StokOpname (verified_by_id) -- hasOne Employee (user_id) -- hasOne UserProfile (user_id) - -### UserProfile -- belongsTo User (user_id) - ---- - -## 6. Complete Casting Map - -### Attendance -- attendance_date -> date:Y-m-d, check_in_at -> datetime, check_out_at -> datetime -- check_in_latitude -> decimal:7, check_in_longitude -> decimal:7 -- check_out_latitude -> decimal:7, check_out_longitude -> decimal:7 -- work_duration_minutes -> integer - -### CashAccount -- balance -> integer - -### CashTransaction -- type -> CashTransactionType enum, amount -> integer, balance_after -> integer - -### Cutting -- status -> CuttingStatus enum, total_material_cost -> integer, cost_per_unit -> integer -- sewing_cost -> integer, other_cost -> integer - -### CuttingMaterial -- material_usage -> integer, material_result -> integer - -### CuttingMaterialCombination -- material_result -> integer - -### CuttingResult -- cutting_result -> integer, sample -> integer, original_outside_sample -> integer - -### Employee -- employment_status -> EmploymentStatus enum, base_salary -> integer -- join_date -> date:Y-m-d, resign_date -> date:Y-m-d - -### EmployeeAdvance -- status -> EmployeeAdvanceStatus enum, amount -> integer, paid_amount -> integer -- due_date -> date:Y-m-d, verified_at -> datetime, paid_at -> datetime - -### Order -- channel -> OrderChannel enum, price_type -> PriceType enum -- status -> OrderStatus enum, payment_type -> PaymentType enum -- is_affiliate -> boolean, subtotal -> integer, discount -> integer -- nego_price -> integer, total_amount -> integer, cogs -> integer -- marketplace_settings_snapshot -> array - -### OrderItem -- stock_quality -> ProductStockQuality enum, quantity -> integer -- unit_price -> integer, subtotal -> integer - -### OwnerVerificationRequest -- payload -> array, verified_at -> datetime - -### Payroll -- status -> PayrollStatus enum, base_salary -> integer -- bonus_amount -> integer, deduction_amount -> integer -- total_amount -> integer, paid_at -> datetime - -### PayrollAdjustment -- type -> PayrollAdjustmentType enum, amount -> integer - -### PayrollPeriod -- status -> PayrollPeriodStatus enum, year -> integer -- month -> integer, closed_at -> datetime - -### Product -- status -> ProductStatus enum - -### ProductPrice -- type -> PriceType enum, price -> integer - -### ProductVariant -- stock -> integer, reject_stock -> integer, retail_stock -> integer - -### RawMaterial -- unit -> RawMaterialUnit enum, is_active -> boolean - -### RawMaterialPrice -- price -> integer, stock -> integer - -### User -- email_verified_at -> datetime, is_active -> boolean -- last_login_at -> datetime, password -> hashed -- two_factor_confirmed_at -> datetime - ---- - -## 5. Migration Foreign Keys - -| Table | FK Column | References | -|-------|-----------|------------| -| user_profiles | user_id | users(id) | -| employees | user_id | users(id) | -| product_categories | product_id | products(id) | -| product_categories | category_id | categories(id) | -| product_variants | product_id | products(id) | -| raw_material_prices | raw_material_id | raw_materials(id) | -| cash_accounts | created_by_id | users(id) | -| cash_transactions | cash_account_id | cash_accounts(id) | -| cash_transactions | created_by_id | users(id) | -| expenses | cash_transaction_id | cash_transactions(id) | -| expenses | created_by_id | users(id) | -| employee_advances | paid_by_id | users(id) | -| employee_advances | employee_id | employees(id) | -| employee_advances | verified_by_id | users(id) | -| employee_advances | repayment_cash_transaction_id | cash_transactions(id) | -| employee_advances | cash_transaction_id | cash_transactions(id) | -| rejections | rejected_by_id | users(id) | -| attendances | employee_id | employees(id) | -| payroll_periods | closed_by_id | users(id) | -| payrolls | payroll_period_id | payroll_periods(id) | -| payrolls | employee_id | employees(id) | -| payrolls | cash_transaction_id | cash_transactions(id) | -| payrolls | paid_by_id | users(id) | -| payroll_adjustments | payroll_id | payrolls(id) | -| payroll_adjustments | attendance_id | attendances(id) | -| payroll_adjustments | created_by_id | users(id) | -| leave_requests | employee_id | employees(id) | -| leave_requests | verified_by_id | users(id) | -| purchases | supplier_id | suppliers(id) | -| purchases | created_by_id | users(id) | -| purchase_items | purchase_id | purchases(id) | -| purchase_items | user_id | users(id) | -| purchase_items | raw_material_price_id | raw_material_prices(id) | -| orders | customer_id | customers(id) | -| orders | marketing_id | users(id) | -| orders | cash_transaction_id | cash_transactions(id) | -| orders | created_by_id | users(id) | -| order_items | order_id | orders(id) | -| order_items | user_id | users(id) | -| order_items | product_variant_id | product_variants(id) | -| cuttings | created_by_id | users(id) | -| cuttings | submitted_by_id | users(id) | -| cutting_material_combinations | user_id | users(id) | -| cutting_material_combinations | cutting_id | cuttings(id) | -| cutting_materials | user_id | users(id) | -| cutting_materials | cutting_id | cuttings(id) | -| cutting_materials | raw_material_price_id | raw_material_prices(id) | -| cutting_materials | combination_id | cutting_material_combinations(id) | -| cutting_results | user_id | users(id) | -| cutting_results | cutting_id | cuttings(id) | -| product_prices | variant_id | product_variants(id) | -| owner_verification_requests | submitted_by_id | users(id) | -| owner_verification_requests | verified_by_id | users(id) | -| notifications | user_id | users(id) | -| employee_advance_payments | employee_advance_id | employee_advances(id) | -| employee_advance_payments | paid_by_id | users(id) | -| employee_advance_payments | cash_transaction_id | cash_transactions(id) | -| retail_stock_histories | product_variant_id | product_variants(id) | -| retail_stock_histories | user_id | users(id) | -| stok_opnames | created_by_id | users(id) | -| stok_opnames | verified_by_id | users(id) | -| stok_opname_items | stok_opname_id | stok_opnames(id) | -| stok_opname_items | product_variant_id | product_variants(id) | -| restocks | created_by_id | users(id) | -| restock_items | restock_id | restocks(id) | -| restock_items | user_id | users(id) | -| restock_items | product_variant_id | product_variants(id) | -| stock_mutations | user_id | users(id) | - ---- - -## 8. Accessor & Mutator - Perubahan di Sesi Ini - -### A. Currency Format (Get: `Rp X.XXX`) -| Model | Kolom | -|-------|-------| -| CashAccount | balance | -| CashTransaction | amount | -| Cutting | total_material_cost, cost_per_unit, sewing_cost, other_cost | -| Employee | base_salary | -| EmployeeAdvance | amount, paid_amount | -| Expense | amount | -| Order | subtotal, discount, nego_price, total_amount, cogs | -| OrderItem | unit_price, subtotal | -| Payroll | base_salary, bonus_amount, deduction_amount, total_amount | -| PayrollAdjustment | amount | -| ProductPrice | price | -| Purchase | subtotal, discount, shipping_cost, total | -| PurchaseItem | unit_price, subtotal | -| RawMaterialPrice | price | -| Restock | subtotal, total | -| RestockItem | unit_price, subtotal | - -### B. Phone Format `0821 2121 2121` (Get/Set) -| Model | Kolom | -|-------|-------| -| UserProfile | phone_number | -| Customer | phone_number | -| Supplier | phone_number | - -### C. String Format (Get: ucfirst) -| Model | Kolom | -|-------|-------| -| CashAccount | name | -| Category | name | -| Customer | name | -| Product | name | -| ProductVariant | name | -| RawMaterial | name | -| Supplier | name | -| UserProfile | first_name, last_name | - -### D. Full Name (Computed) -| Model | Kolom | Get | -|-------|-------|-----| -| UserProfile | full_name | first_name . ' ' . last_name | -| User | fullName | userProfile full_name atau username | - -### E. Email (Set: lowercase) -| Model | Kolom | Set | -|-------|-------|-----| -| User | email | strtolower | - -### F. Date Format Indonesia (Get: `l, d F Y`) -| Model | Kolom | -|-------|-------| -| Attendance | attendance_date | -| Employee | join_date, resign_date | -| EmployeeAdvance | due_date | -| LeaveRequest | start_date, end_date | -| StokOpname | opname_date | -| UserProfile | birth_date | - -### G. Status Label (Get: `->label()`) -| Model | Kolom | -|-------|-------| -| CashTransaction | typeLabel (type) | -| Cutting | statusLabel (status) | -| Employee | employmentStatusLabel (employment_status) | -| EmployeeAdvance | statusLabel (status) | -| LeaveRequest | statusLabel (status) | -| Order | statusLabel (status), channelLabel (channel), paymentTypeLabel (payment_type) | -| OrderItem | stockQualityLabel (stock_quality) | -| Payroll | statusLabel (status) | -| PayrollAdjustment | typeLabel (type) | -| PayrollPeriod | statusLabel (status) | -| Product | statusLabel (status) | -| ProductPrice | typeLabel (type) | -| RawMaterial | unitLabel (unit) | -| Restock | stockTypeLabel (stock_type) | -| StokOpname | statusLabel (status) | -| StokOpnameItem | stockQualityLabel (stock_quality) | -| UserProfile | genderLabel (gender) | - ---- - -## 9. Enum Label - Perubahan di Sesi Ini - -### Enum yang ditambah `label()` method: -| Enum | Labels | -|------|--------| -| CashTransactionType | Setoran, Pengeluaran, Transfer, Penarikan | -| CuttingStatus | Dibatalkan, Selesai, Dalam Proses | -| EmployeeAdvanceStatus | Disetujui, Dibatalkan, Dibayar, Menunggu, Ditolak | -| EmploymentStatus | Kontrak, Penuh Waktu, Magang, Paruh Waktu, Keluar | -| Gender | Perempuan, Laki-laki | -| LeaveRequestStatus | Disetujui, Dibatalkan, Menunggu, Ditolak | -| PayrollAdjustmentType | Bonus, Potongan | -| PayrollPeriodStatus | Tutup, Buka | -| PayrollStatus | Dibatalkan, Dibayar, Belum Dibayar | -| RawMaterialUnit | Kg, Meter, Yard | -| StokOpnameStatus | Dibatalkan, Selesai, Draft, Dalam Proses, Terverifikasi | - -### Enum yang SUDAH punya `label()`: -OrderChannel, OrderStatus, PaymentType, PriceType, ProductStatus, ProductStockQuality - -### HasValues Trait - `toSelect()` Method -Semua enum yang pakai `HasValues` trait bisa panggil `EnumName::toSelect()` untuk return `Collection`. - +## Backend + +### 1. Migration +```bash +php artisan make:migration create_{table}_table +``` +- [ ] Kolom sesuai kebutuhan +- [ ] Foreign key → `$table->foreignId('user_id')->constrained()->cascadeOnDelete()` +- [ ] Custom FK → `$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete()` +- [ ] Soft deletes → `$table->softDeletes()` (kecuali: Attendance, StokOpnameItem, RetailStockHistory, StockMutation) +- [ ] Timestamps → `$table->timestamps()` + +### 2. Model +```bash +php artisan make:model {Model} -m +``` +- [ ] `#[Guarded(['id'])]` — jangan pakai $fillable +- [ ] `#[Appends([...])]` — di atas class declaration +- [ ] `protected function casts(): array` — method syntax, bukan property +- [ ] Scopes — `#[Scope]` + `protected function name(Builder $query): void` +- [ ] Accessors — `Attribute::make(get: fn () => ...)`, NAMA BERBEDA dari kolom DB +- [ ] Relasi — WAJIB 2 ARAH di kedua model +- [ ] Select — `select(['id', 'name', ...])` + eager load relasi + +**Urutan isi model:** +1. `casts()` +2. Scopes (abjad) +3. Accessors (abjad) +4. Relations (abjad) + +### 3. Enum (jika perlu) +```bash +php artisan make:enum {EnumName} +``` +- [ ] Pakai `HasValues` trait +- [ ] Buat `label(): string` method +- [ ] Buat scopes untuk setiap case + +### 4. Service +```bash +# Manual create +app/Services/Admin/{Module}/{Model}Service.php +``` +- [ ] Return type di SEMUA method +- [ ] Method wajib: `paginated()`, `getAll()`, `store()`, `update()`, `destroy()` +- [ ] Select kolom yang dibutuhkan + eager load relasi +- [ ] Gunakan traits jika perlu: `HandlesCashTransactions`, `HasStockAdjustment`, `RegistersMedia` + +### 5. Form Request +```bash +php artisan make:request {Model}Request +``` +- [ ] `authorize(): bool` → return true +- [ ] `rules(): array` → validasi sesuai kebutuhan +- [ ] `attributes(): array` → label Bahasa Indonesia +- [ ] `prepareForValidation()` → strip currency jika ada input Rupiah +- [ ] Unique ignore → `Rule::unique('table')->ignore($this->route('model')?->id)` + +### 6. Controller +```bash +php artisan make:controller Admin/{Module}/{Model}Controller +``` +- [ ] Constructor promotion → `public function __construct(private {Model}Service $service) {}` +- [ ] Return type di SEMUA method (`Response`) +- [ ] Zero logic — semua di service +- [ ] Flash → `Inertia::flash('toast', ['type' => 'success', 'message' => '...'])` sebelum `return back()` +- [ ] `handleAction()` untuk 2+ query +- [ ] Method order: `__construct`, `index`, `create`, `store`, `show`, `edit`, `update`, `destroy`, custom actions + +### 7. Route ```php -// Sebelum ( verbose ): -collect(OrderStatus::cases())->map(fn ($s) => ['value' => $s->value, 'label' => $s->label()])->values() - -// Sesudah ( clean ): -OrderStatus::toSelect() - -// Dengan filter: -PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values() +// routes/web.php +Route::resource('{module}', {Model}Controller::class) + ->parameters(['{module}' => '{model}']); ``` +- [ ] Route group sesuai module +- [ ] Middleware `permission:{permission}` jika perlu -**File yang sudah di-refactor:** -- `app/Http/Controllers/Admin/Finance/CashAccountController.php` - `CashTransactionType::toSelect()` -- `app/Http/Controllers/Admin/HR/LeaveRequestController.php` - `LeaveRequestStatus::toSelect()` -- `app/Services/Admin/Manage/TransactionService.php` - `OrderStatus::toSelect()`, `OrderChannel::toSelect()`, `PaymentType::toSelect()`, `PriceType::toSelect()` - ---- - -## 10. Appends - Perubahan di Sesi Ini - -### Catatan Penting: -- **Accessor tidak boleh sama nama dengan kolom database** → gunakan prefix `formatted_` untuk menghindari bentrok -- Label accessors (status_label, type_label, dll) tidak bentrok karena bukan kolom DB - -### Model yang ditambah `#[Appends([...])]`: - -| Model | Appends | -|-------|---------| -| CashAccount | `['formatted_balance', 'formatted_name']` | -| CashTransaction | `['formatted_amount', 'formatted_balance_after', 'formatted_created_at', 'type_label']` | -| Category | `['formatted_name']` | -| Cutting | `['formatted_cost_per_unit', 'formatted_other_cost', 'formatted_sewing_cost', 'status_label', 'formatted_total_material_cost']` | -| Customer | `['formatted_name', 'formatted_phone_number']` | -| Employee | `['formatted_base_salary', 'employment_status_label', 'formatted_join_date', 'formatted_resign_date']` | -| EmployeeAdvance | `['formatted_amount', 'formatted_created_at', 'formatted_due_date', 'formatted_paid_amount', 'status_label']` | -| Expense | `['formatted_amount', 'formatted_created_at', 'formatted_date']` | -| LeaveRequest | `['formatted_end_date', 'formatted_start_date', 'status_label']` | -| Order | `['channel_label', 'formatted_cogs', 'formatted_discount', 'formatted_nego_price', 'payment_type_label', 'status_label', 'formatted_subtotal', 'formatted_total_amount']` | -| OrderItem | `['stock_quality_label', 'formatted_subtotal', 'formatted_unit_price']` | -| Payroll | `['formatted_base_salary', 'formatted_bonus_amount', 'formatted_deduction_amount', 'status_label', 'formatted_total_amount']` | -| PayrollAdjustment | `['formatted_amount', 'type_label']` | -| PayrollPeriod | `['status_label']` | -| Product | `['formatted_name', 'status_label']` | -| ProductPrice | `['formatted_price', 'type_label']` | -| ProductVariant | `['formatted_name']` | -| Purchase | `['formatted_discount', 'formatted_shipping_cost', 'formatted_subtotal', 'formatted_total']` | -| PurchaseItem | `['formatted_subtotal', 'formatted_unit_price']` | -| RawMaterial | `['formatted_name', 'unit_label']` | -| RawMaterialPrice | `['formatted_price']` | -| Restock | `['stock_type_label', 'formatted_subtotal', 'formatted_total']` | -| RestockItem | `['formatted_subtotal', 'formatted_unit_price']` | -| StokOpname | `['formatted_opname_date', 'status_label']` | -| StokOpnameItem | `['stock_quality_label']` | -| Supplier | `['formatted_name', 'formatted_phone_number']` | -| UserProfile | `['formatted_birth_date', 'gender_label', 'formatted_phone_number']` | -| User | `['full_name', 'name']` | - -### Yang TIDAK di-append: -- `User.email` - set-only accessor (tidak ada get) - -### Aturan Penamaan Accessor: -| Tipe | Lama (BENTROK) | Baru (AMAN) | -|------|---------------|-------------| -| Currency | `amount()`, `price()`, `subtotal()` | `formattedAmount()`, `formattedPrice()`, `formattedSubtotal()` | -| Date | `joinDate()`, `dueDate()` | `formattedJoinDate()`, `formattedDueDate()` | -| String | `name()` | `formattedName()` | -| Phone | `phoneNumber()` | `formattedPhoneNumber()` | -| Label | `statusLabel()`, `typeLabel()` | TIDAK berubah (tidak bentrok) | - -### Aturan Accessor Pattern: -- **Gunakan `$this->`** untuk mengakses attribute, bukan parameter `$value` -- Contoh: `get: fn () => 'Rp ' . number_format($this->amount, 0, ',', '.')` -- Null-safe: `get: fn () => $this->join_date?->translatedFormat('l, d F Y')` - ---- - -## 11. Controller Conventions - Perubahan di Sesi Ini - -### Base Controller (`Controller.php`) -- `handleAction(callable $action, string $successMessage, string $redirectRoute, ?string $errorRoute = null, array $parameters = []): RedirectResponse` -- `handleToggle(callable $action, string $successMessage, string $redirectRoute): RedirectResponse` - -### Aturan Penamaan Controller: -| Pattern | Keterangan | -|---------|------------| -| `handleAction()` | Untuk 2+ query/operasi dalam 1 action (try-catch) | -| `handleToggle()` | Untuk toggle operations (simple, non-dynamic message) | -| Manual flash | Untuk 1 query/operasi (single query) | - -### Controller Property: -- Service properties harus `private readonly XService $service` - -### Method Ordering: -1. `__construct` -2. `index` -3. `show` (jika ada) -4. `create` -5. `store` -6. `edit` -7. `update` -8. `destroy` -9. Custom actions (toggleStatus, approve, reject, dll) - -### Perubahan di Sesi Ini: - -| Controller | Perubahan | -|------------|-----------| -| StockMutationController | Fix inline instantiation → `private readonly StockMutationService $service` | -| ProfileController | Manual flash → `handleAction()` (2 queries: user save + profile updateOrCreate) | -| 23 controllers | Tambah `readonly` ke service properties | - -### Catatan Penting: -- **`handleAction()` hanya untuk 2+ query/operasi** dalam 1 action -- **1 query = manual flash** (lebih simpel, tidak perlu try-catch overhead) -- Contoh 2+ query: `ProfileController::update()` → user save + profile updateOrCreate - ---- - -## 12. Service Conventions - Perubahan di Sesi Ini - -### Traits (Shared Concerns) - -| Trait | Methods | Digunakan Oleh | -|-------|---------|----------------| -| `HandlesCashTransactions` | `getCashAccount()`, `creditCash()`, `debitCash()` | CashAccountService, ExpenseService, EmployeeAdvanceService, PayrollPeriodService | -| `HasStockAdjustment` | `adjustStock()`, `adjustVariantStock()`, `applyStock()`, `reverseStock()` | TransactionService, RestockService | -| `RegistersMedia` | `registerMedia()`, `syncPhoto()` | CashAccountService, CuttingService, ExpenseService, PurchaseService, RestockService, TransactionService | - -### Aturan Penamaan Service: -| Pattern | Keterangan | -|---------|------------| -| `private readonly XService $service` | Service property harus readonly | -| `= new XService` dilarang | Gunakan dependency injection, bukan default value | -| `getAll(array $filters = []): Collection` | Standar method listing | -| `paginated(int $perPage, string $search, string $sort, string $direction, array $filters): LengthAwarePaginator` | Standar method pagination | - -### Service Method Signatures: +### 8. Permission ```php -// Standard - dengan filter -getAll(array $filters = []): Collection -paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator - -// Standard - tanpa filter (tetap terima $filters untuk konsistensi) -getAll(): Collection -paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator +// database/seeders/RolePermissionSeeder.php ``` - -### Cash Transaction Patterns: -- **Credit (DEPOSIT)**: `$this->creditCash(amount, description)` → tambah saldo -- **Debit (EXPENSE/WITHDRAWAL)**: `$this->debitCash(amount, description)` → kurangi saldo + validasi - -### Stock Adjustment Patterns: -- **ProductVariant**: `$this->applyStock(items, stockType, sign)` → increment/decrement stock/reject_stock -- **RawMaterialPrice**: `$this->adjustStock(model, field, quantity, sign)` → increment/decrement - -### Service Files Diubah di Sesi Ini: -- `app/Services/Admin/Finance/CashAccountService.php` - hapus `= new`, tambah HandlesCashTransactions -- `app/Services/Admin/Finance/ExpenseService.php` - hapus `= new`, tambah HandlesCashTransactions -- `app/Services/Admin/Finance/EmployeeAdvanceService.php` - tambah HandlesCashTransactions -- `app/Services/Admin/Finance/PayrollPeriodService.php` - tambah HandlesCashTransactions -- `app/Services/Admin/Manage/TransactionService.php` - hapus `= new`, tambah HasStockAdjustment -- `app/Services/Admin/Manage/RestockService.php` - hapus `= new`, tambah HasStockAdjustment -- `app/Services/Admin/Manage/CuttingService.php` - hapus `= new`, hapus syncCuttingPhoto -- `app/Services/Admin/Manage/PurchaseService.php` - hapus `= new`, hapus syncPurchasePhoto -- `app/Services/Admin/Master/Product/ProductService.php` - hapus `= new` -- `app/Services/Admin/Master/Product/ProductVariantService.php` - hapus `= new` -- `app/Services/Admin/Master/RawMaterial/RawMaterialService.php` - hapus `= new` -- `app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php` - hapus `= new` -- `app/Services/Admin/AdminSettingsService.php` - hapus `= new` -- `app/Services/Admin/Settings/RoleService.php` - tambah $filters ke paginated() -- `app/Services/Admin/Master/CategoryService.php` - tambah $filters -- `app/Services/Admin/Master/CustomerService.php` - tambah $filters -- `app/Services/Admin/Master/SupplierService.php` - tambah $filters - -### Trait Files Baru: -- `app/Services/Concerns/HandlesCashTransactions.php` -- `app/Services/Concerns/HasStockAdjustment.php` +- [ ] Tambah permissions: `{model}.view`, `{model}.create`, `{model}.update`, `{model}.delete` +- [ ] Assign ke role yang sesuai --- -## 13. Form Request Conventions - Perubahan di Sesi Ini +## Frontend -### Traits (Shared Concerns) - -| Trait | Methods | Digunakan Oleh | -|-------|---------|----------------| -| `CurrencyStripping` | `stripCurrencyDot(array $data, string ...$fields): array` | 11 Form Requests | - -### Aturan Form Request: -| Pattern | Keterangan | -|---------|------------| -| `authorize()` wajib ada | Selalu return `true` (authorization di controller/middleware) | -| `attributes()` dalam Bahasa Indonesia | Label untuk semua field | -| `prepareForValidation()` | Strip dot currency via `CurrencyStripping` trait | -| Shared Store/Update | Gunakan 1 request dengan `sometimes` + if/else | -| Unique ignore | `Rule::unique('table')->ignore($this->route('model')?->id)` | - -### Currency Stripping Pattern: -```php -use App\Concerns\CurrencyStripping; - -class SomeRequest extends FormRequest -{ - use CurrencyStripping; - - public function prepareForValidation(): void - { - $this->merge($this->stripCurrencyDot($this->validated(), 'amount', 'discount', 'variants.*.price')); - } -} +### 9. Columns +```bash +resources/js/pages/admin/{module}/{model}/columns.tsx ``` +- [ ] Type definition → `export type {Model} = { ... }` +- [ ] Column factory → `export function create{Model}Columns(params): ColumnDef<{Model}>[]` +- [ ] Gunakan `formatted_*` accessor dari model, bukan format di TypeScript +- [ ] Actions column → conditional on `can('{model}.update')` / `can('{model}.delete')` -### Form Request Files Diubah di Sesi Ini: +### 10. Index Page +```bash +resources/js/pages/admin/{module}/{model}/index.tsx +``` +- [ ] Simple CRUD → `DataTable` + `FormDialog` + `DeleteConfirmDialog` +- [ ] Complex list → `CardTable` + `FilterPopover` + `useCardTableExpand` +- [ ] Hooks: `useCan()`, `useServerTable()` +- [ ] Permission check → `can('{model}.create')` untuk tombolTambah -#### Tambah `authorize()`: -- `app/Http/Requests/Settings/ProfileUpdateRequest.php` -- `app/Http/Requests/Settings/ProfileDeleteRequest.php` -- `app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php` -- `app/Http/Requests/Settings/PasswordUpdateRequest.php` +### 11. Create/Edit Page +```bash +resources/js/pages/admin/{module}/{model}/create.tsx +resources/js/pages/admin/{module}/{model}/edit.tsx +``` +- [ ] Simple CRUD → inline `FormDialog` di index page +- [ ] Complex form → full-page `` + draft saving +- [ ] Gunakan `RupiahInput`, `NumberInput`, `FileUpload`, `Combobox` sesuai kebutuhan +- [ ] Route helpers → Ziggy (`route('admin.{module}.{model}.store')`) -#### Tambah `CurrencyStripping` trait: -- `app/Http/Requests/Admin/Manage/TransactionRequest.php` - `discount`, `nego_price` -- `app/Http/Requests/Admin/Manage/PurchaseRequest.php` - `variants.*.price`, `discount`, `shipping_cost` -- `app/Http/Requests/Admin/Master/RawMaterial/RawMaterialRequest.php` - `variants.*.price` -- `app/Http/Requests/Admin/Master/RawMaterial/RawMaterialVariantRequest.php` - `price` -- `app/Http/Requests/Admin/Master/Product/ProductRequest.php` - `shared_prices.*.price`, `variants.*.prices.*.price` -- `app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php` - `prices.*.price` -- `app/Http/Requests/Admin/Finance/CashTransactionRequest.php` - `amount` -- `app/Http/Requests/Admin/Finance/ExpenseRequest.php` - `amount` -- `app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php` - `amount` -- `app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php` - `amount` -- `app/Http/Requests/Admin/Settings/UpdateHRRequest.php` - `late_penalty_amount`, `absent_penalty_amount` - -### Trait Files Baru: -- `app/Concerns/CurrencyStripping.php` - -### Catatan Penting: -- **Shared Store/Update** sudah cukup pakai `sometimes` + if/else untuk field yang berbeda -- **Split hanya jika** ruleset store dan update sangat berbeda (jarang terjadi) -- **Currency stripping** selalu di `prepareForValidation()`, sebelum validasi jalan +### 12. Sidebar +```tsx +// resources/js/components/app-sidebar.tsx +``` +- [ ] Tambah item di `masterItems` / `manageItems` / `financeItems` / `hrItems` +- [ ] Permission → `permission: '{model}.view'` +- [ ] Icon → dari `lucide-react` --- -## 14. View Conventions - Perubahan di Sesi Ini +## Testing -### Tech Stack -- **Framework**: Inertia.js + React + TypeScript -- **UI Components**: shadcn/ui (customized) -- **Styling**: Tailwind CSS -- **Routing**: Ziggy (type-safe routes) +### 13. Manual Test +- [ ] Create — form validasi, flash message, redirect +- [ ] Read — data muncul di index, pagination, search +- [ ] Update — form terisi data lama, flash message +- [ ] Delete — konfirmasi, flash message +- [ ] Permission — user tanpa akses tidak bisa akses +- [ ] Relasi — eager load tidak N+1 -### Directory Structure +--- + +## Update `.ai/` Files + +> **WAJIB** update `.ai/` files saat ada perubahan supaya tetap updated. + +### Kapan harus update? +- [ ] Tambah kolom baru → update `REFERENCE.md` (tambah kolom + type + relation) +- [ ] Tambah migration baru → update `REFERENCE.md` (tambah tabel baru) +- [ ] Tambah relasi baru → update `REFERENCE.md` (tambah relasi di 2 model) +- [ ] Tambah model baru → update `CONTEXT.md` (tambah ke Module Overview) +- [ ] Tambah role/permission baru → update `CONTEXT.md` (tambah ke Roles & Akses) +- [ ] Tambah enum baru → update `REFERENCE.md` (tambah ke tabel Enums) +- [ ] Tambah service trait baru → update `REFERENCE.md` (tambah ke Service Concerns) +- [ ] Tambah accessor baru → update `CONVENTIONS.md` jika ada pattern baru + +### Checklist update `.ai/`: ``` -resources/js/ -├── pages/ # Page components (Inertia) -├── components/ # Shared UI components -├── hooks/ # Custom React hooks -├── lib/ # Utility functions -└── routes/ # Type-safe route definitions +□ REFERENCE.md — kolom/tabel/relasi/enum sudah sesuai? +□ CONTEXT.md — module mapping sudah sesuai? +□ CONVENTIONS.md — ada pattern baru yang perlu ditambah? ``` -### Page Patterns +--- -#### Simple CRUD (Category, Customer, Supplier) -```tsx -// State: createOpen, editing, deleting -// Components: PageHeader, FormDialog, DataTable, DeleteConfirmDialog -// Hook: useServerTable untuk pagination/search -``` +## Quick Reference -#### Complex List (Transaction, Restock, Purchase, Cutting) -```tsx -// Components: PageHeader, CardTable (bukan DataTable) -// Features: FilterPopover, expandable rows, card + sub-row -// Hook: useServerTable + useCardTableExpand -``` +### File Paths +| Item | Path | +|------|------| +| Migration | `database/migrations/` | +| Model | `app/Models/` | +| Enum | `app/Enums/` | +| Service | `app/Services/Admin/{Module}/` | +| Form Request | `app/Http/Requests/Admin/{Module}/` | +| Controller | `app/Http/Controllers/Admin/{Module}/` | +| Route | `routes/web.php` | +| Permission | `database/seeders/RolePermissionSeeder.php` | +| Columns | `resources/js/pages/admin/{module}/{model}/columns.tsx` | +| Index | `resources/js/pages/admin/{module}/{model}/index.tsx` | +| Create | `resources/js/pages/admin/{module}/{model}/create.tsx` | +| Edit | `resources/js/pages/admin/{module}/{model}/edit.tsx` | +| Sidebar | `resources/js/components/app-sidebar.tsx` | -### Columns Pattern -```tsx -// Type definition untuk entity -export type Category = { id: number; name: string; }; - -// Factory function untuk columns -export function createCategoryColumns(params: CreateColumnsParams): ColumnDef[] { - return [ - { accessorKey: 'name', header: ..., cell: ... }, - { id: 'actions', cell: ... RowActions ... } - ]; -} -``` - -### Enum Options dari Controller -```php -// Controller: kirim enum options ke view (pakai toSelect() dari HasValues trait) -'filterOptions' => [ - 'statusOptions' => OrderStatus::toSelect(), -], -``` - -```tsx -// View: gunakan options dari controller -{filterOptions.statusOptions.map((opt) => ( - - {opt.label} - -))} -``` - -### Draft Pattern -```tsx -// Hook: useXxxDraftSave (generated via createDraftHook) -export const useTransactionDraftSave = createDraftHook({ - save: saveTransactionDraft, - clear: clearTransactionDraft, -}); - -// Usage -useTransactionDraftSave('create', draftData, userId); -``` - -### Columns Pattern - Model Accessors -```tsx -// ❌ Jangan format di TypeScript -import { formatCurrency } from '@/lib/utils'; -{ - accessorKey: 'amount', - cell: ({ row }) => {formatCurrency(row.getValue('amount') as number)} -} - -// ✅ Gunakan formatted_ accessor dari model -{ - accessorKey: 'formatted_amount', - cell: ({ row }) => {row.getValue('formatted_amount') as string} -} - -// ✅ Tetap format di TS untuk computed values (bukan dari DB) -{ - accessorKey: 'payrolls_sum_total_amount', - cell: ({ row }) => {formatCurrency(row.getValue('payrolls_sum_total_amount') as number)} -} - -// ✅ Tetap format di TS untuk nested objects (bukan model attribute) -{ - cell: ({ row }) => {formatCurrency(row.original.adjustment.amount)} -} -``` - -### 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 Agust 2026" -formatDateTime(dateString) // "23 Agustus 2026 08:40" -formatCurrency(1000000) // "Rp 1.000.000" -formatNumber(1000000) // "1.000.000" -``` - -### Shared Components -| Component | Digunakan Untuk | -|-----------|----------------| -| `FormDialog` | Modal form (create/edit) untuk simple CRUD | -| `DataTable` | Table dengan server-side pagination | -| `CardTable` | Card-based list dengan expandable rows | -| `PageHeader` | Header halaman dengan title + actions | -| `DeleteConfirmDialog` | Konfirmasi hapus | -| `FilterPopover` | Filter toolbar | -| `RowActions` | Action dropdown (edit/hapus) | -| `FileUpload` | Upload file ke S3 | -| `RupiahInput` | Input currency formatting | -| `NumberInput` | Input angka | - -### Hooks -| Hook | Fungsi | -|------|--------| -| `useServerTable` | Pagination, search, filter server-side | -| `useCardTableExpand` | Expand/collapse rows | -| `useXxxDraft` | Auto-save draft (transaction, product, restock, purchase, cutting, raw-material) | - -### Catatan Penting: -- **Enum options** harus dikirim dari controller, bukan hardcoded di view -- **Format data** gunakan `#[Appends]` attributes dari model -- **Type definitions** ada di `columns.tsx` bersama column definitions -- **Draft hooks** menggunakan `createDraftHook` factory \ No newline at end of file +### Conventions +- Baca `.ai/CONVENTIONS.md` untuk aturan lengkap +- Baca `.ai/REFERENCE.md` untuk database schema +- Baca `.ai/CONTEXT.md` untuk project context & roles