1703 lines
53 KiB
Markdown
1703 lines
53 KiB
Markdown
# Code Conventions
|
|
|
|
> Aturan-aturan yang WAJIB diikuti saat menulis kode.
|
|
|
|
---
|
|
|
|
## Namespace & Import Convention
|
|
|
|
### Backend → SELALU pakai `use` import di atas file
|
|
```php
|
|
// ✅ SELALU import class di atas file, gunakan nama pendek di kode
|
|
use App\Models\Product;
|
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
#[Guarded(['id'])]
|
|
class Product extends Model
|
|
{
|
|
#[Scope]
|
|
protected function active(Builder $query): void { ... }
|
|
}
|
|
|
|
// ❌ JANGAN tulis namespace lengkap di deklarasi atribut/class
|
|
#[\Illuminate\Database\Eloquent\Attributes\Scope] // JANGAN
|
|
protected function active(\Illuminate\Database\Eloquent\Builder $query): void { ... }
|
|
|
|
// ❌ JANGAN import di tengah file atau inline
|
|
class Product extends \App\Models\Model { ... } // JANGAN
|
|
```
|
|
|
|
### Aturan Import
|
|
```php
|
|
// ✅ Urutan import (group by namespace):
|
|
// 1. PHP built-in (DateTime, etc)
|
|
// 2. Laravel/Framework (Illuminate\*)
|
|
// 3. App (App\Models, App\Services, etc)
|
|
// 4. Third-party (Spatie, etc)
|
|
|
|
// ✅ Untuk atribut PHP 8+ → gunakan short name
|
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
|
|
#[Scope] // ✅ short name
|
|
#[Appends] // ✅ short name
|
|
|
|
// ✅ Untuk type hint → gunakan short name
|
|
public function __construct(
|
|
private ProductService $service, // ✅ short name
|
|
) {}
|
|
|
|
// ❌ JANGAN
|
|
public function __construct(
|
|
private \App\Services\Admin\Master\Product\ProductService $service, // JANGAN
|
|
) {}
|
|
```
|
|
|
|
---
|
|
|
|
## Language Convention
|
|
|
|
### Backend → English
|
|
Variabel, function, kolom database, class, method — **SEMUA pakai English**:
|
|
|
|
```php
|
|
// ✅ Variable
|
|
$orderId, $totalAmount, $customerName
|
|
|
|
// ✅ Function/Method
|
|
public function getOrders(): Collection
|
|
public function calculateTotal(): int
|
|
|
|
// ✅ Database columns
|
|
$table->string('order_number');
|
|
$table->decimal('total_amount');
|
|
|
|
// ✅ Class names
|
|
class OrderService { ... }
|
|
class OrderRequest extends FormRequest { ... }
|
|
```
|
|
|
|
### Frontend (UI) → Indonesian
|
|
Teks yang ditampilkan ke user — **pakai Bahasa Indonesia**:
|
|
|
|
```tsx
|
|
// ✅ Labels
|
|
<Label>Nama Produk</Label>
|
|
<Label>Harga Jual</Label>
|
|
|
|
// ✅ Placeholders
|
|
<Input placeholder="Masukkan nama" />
|
|
|
|
// ✅ Buttons
|
|
<Button>Tambah</Button>
|
|
<Button>Simpan</Button>
|
|
<Button>Hapus</Button>
|
|
|
|
// ✅ Flash messages
|
|
'Produk berhasil ditambahkan'
|
|
'Data berhasil dihapus'
|
|
|
|
// ✅ Status labels (di Badge)
|
|
<OrderStatusBadge status={order.status} /> // "Menunggu", "Selesai"
|
|
|
|
// ✅ Page titles
|
|
<Head title="Daftar Produk" />
|
|
<Head title="Tambah Transaksi" />
|
|
```
|
|
|
|
### Contoh Perbandingan
|
|
| Elemen | English (Backend) | Indonesian (UI) |
|
|
|--------|-------------------|-----------------|
|
|
| Variable | `$customerName` | — |
|
|
| DB column | `customer_name` | — |
|
|
| Method | `getCustomerName()` | — |
|
|
| Label | — | `Nama Customer` |
|
|
| Placeholder | — | `Masukkan nama` |
|
|
| Button | — | `Simpan`, `Hapus` |
|
|
| Flash | — | `Berhasil disimpan` |
|
|
| Status | `OrderStatus::PENDING` | `Menunggu` |
|
|
|
|
---
|
|
|
|
## Model
|
|
|
|
### Mass Assignment
|
|
```php
|
|
// ✅ SELALU pakai #[Guarded(['id'])]
|
|
#[Guarded(['id'])]
|
|
class Product extends Model { ... }
|
|
|
|
// ❌ JANGAN pakai $fillable
|
|
protected $fillable = ['name', 'status']; // JANGAN
|
|
```
|
|
|
|
### Urutan Isi Model
|
|
```php
|
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
|
|
// Attributes di ATAS class declaration
|
|
#[Guarded(['id'])]
|
|
#[Appends(['formatted_name'])]
|
|
class Product extends Model
|
|
{
|
|
use HasFactory, SoftDeletes;
|
|
|
|
// 1. Casts (method, wajib untuk semua tipe yang perlu casting)
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'status' => ProductStatus::class,
|
|
'is_active' => 'boolean',
|
|
];
|
|
}
|
|
|
|
// 2. Scopes (order abjad, gunakan atribut #[Scope])
|
|
#[Scope]
|
|
protected function active(Builder $query): void
|
|
{
|
|
$query->where('is_active', true);
|
|
}
|
|
|
|
#[Scope]
|
|
protected function draft(Builder $query): void
|
|
{
|
|
$query->where('status', ProductStatus::DRAFT);
|
|
}
|
|
|
|
// 3. Mutators & Accessors (order abjad, NAMA HARUS BERBEDA dari kolom DB)
|
|
// TIDAK perlu accessor untuk label — Enum sudah handle via ->label()
|
|
protected function formattedName(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => ucfirst($this->name),
|
|
);
|
|
}
|
|
|
|
// 4. Relationships (order abjad, WAJIB 2 ARAH)
|
|
public function categories(): BelongsToMany { ... }
|
|
public function productVariants(): HasMany { ... }
|
|
}
|
|
|
|
// ✅ Trait stacking — gabungkan dengan koma
|
|
use HasFactory, SoftDeletes; // 1 baris, pisah koma
|
|
```
|
|
|
|
### Casting — Wajib untuk Semua Tipe
|
|
```php
|
|
// ✅ Selalu cast kolom yang perlu — gunakan METHOD syntax
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
// Enum → gunakan enum class
|
|
'status' => OrderStatus::class,
|
|
'type' => CashTransactionType::class,
|
|
|
|
// Currency/angka → integer (tanpa desimal)
|
|
'amount' => 'integer',
|
|
'price' => 'integer',
|
|
'quantity' => 'integer',
|
|
|
|
// Date → format Y-m-d
|
|
'join_date' => 'date:Y-m-d',
|
|
|
|
// Boolean
|
|
'is_active' => 'boolean',
|
|
'is_affiliate' => 'boolean',
|
|
|
|
// Array (JSON)
|
|
'payload' => 'array',
|
|
|
|
// DateTime
|
|
'verified_at' => 'datetime',
|
|
'paid_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
// ❌ JANGAN pakai property syntax
|
|
protected $casts = [...]; // JANGAN
|
|
|
|
// ❌ JANGAN biarkan kolom tanpa cast jika tipenya perlu
|
|
'amount' => 'integer', // ✅ benar
|
|
// 'amount' => '', // ❌ salah — tidak dicast
|
|
```
|
|
|
|
### Enum untuk Data Opsi
|
|
```php
|
|
// ✅ SELALU gunakan Enum untuk data yang punya opsi tetap
|
|
enum OrderStatus: string
|
|
{
|
|
use HasValues;
|
|
|
|
case PENDING = 'pending';
|
|
case PROCESSING = 'processing';
|
|
case COMPLETED = 'completed';
|
|
case CANCELLED = 'cancelled';
|
|
case REFUNDED = 'refunded';
|
|
|
|
public function label(): string
|
|
{
|
|
return match ($this) {
|
|
self::PENDING => 'Menunggu',
|
|
self::PROCESSING => 'Diproses',
|
|
self::COMPLETED => 'Selesai',
|
|
self::CANCELLED => 'Dibatalkan',
|
|
self::REFUNDED => 'Dikembalikan',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ✅ JANGAN pakai string biasa untuk status
|
|
// ❌ 'status' => 'pending' // tanpa enum
|
|
// ✅ 'status' => OrderStatus::PENDING // dengan enum
|
|
```
|
|
|
|
### Accessor & Mutator — Nama harus BERBEDA dari kolom DB
|
|
```php
|
|
// ✅ Nama accessor TIDAK BOLEH sama dengan nama kolom di DB
|
|
// Format: prefix 'formatted_' atau suffix '_label'
|
|
// Gunakan Attribute::make(get: fn () => ...)
|
|
|
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
|
|
// Currency → prefix 'formatted_' + 'Rp ' prefix
|
|
protected function formattedAmount(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => 'Rp ' . number_format($this->amount, 0, ',', '.'),
|
|
);
|
|
}
|
|
|
|
// Number (bukan rupiah) → prefix 'formatted_'
|
|
protected function formattedStock(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => number_format($this->stock, 0, ',', '.'),
|
|
);
|
|
}
|
|
|
|
// Date → prefix 'formatted_' + format 'l, d F Y'
|
|
protected function formattedJoinDate(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->join_date?->translatedFormat('l, d F Y'),
|
|
);
|
|
}
|
|
|
|
// DateTime → prefix 'formatted_' + format 'l, d F Y H:i'
|
|
protected function formattedCheckInAt(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->check_in_at?->translatedFormat('l, d F Y H:i'),
|
|
);
|
|
}
|
|
|
|
// String → prefix 'formatted_'
|
|
protected function formattedName(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => ucfirst($this->name),
|
|
);
|
|
}
|
|
|
|
// Phone → prefix 'formatted_' (get + set)
|
|
protected function formattedPhoneNumber(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->phone_number
|
|
? preg_replace('/(\d{4})(?=\d)/', '$1 ', $this->phone_number)
|
|
: null,
|
|
set: fn ($value) => preg_replace('/\s/', '', $value),
|
|
);
|
|
}
|
|
|
|
// Month name → suffix '_name'
|
|
protected function monthName(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->month ? Carbon::create()->month($this->month)->translatedFormat('F') : null,
|
|
);
|
|
}
|
|
|
|
// Label → suffix '_label' (TIDAK pakai formatted_)
|
|
// TIDAK PERLU accessor manual — Enum sudah punya method label()
|
|
// Cukup panggil langsung: $this->status->label()
|
|
// Tidak perlu buat statusLabel() accessor
|
|
|
|
// ❌ JANGAN: amount() — bentrok dengan kolom 'amount'
|
|
// ❌ JANGAN: name() — bentrok dengan kolom 'name'
|
|
// ✅ BENAR: formattedAmount(), formattedName()
|
|
```
|
|
|
|
### Accessor Format Rules
|
|
```php
|
|
// ✅ Format yang WAJIB diikuti untuk accessor:
|
|
|
|
// 1. Date (kolom date) → 'l, d F Y'
|
|
// Contoh: "Selasa, 25 Agu 2026"
|
|
$this->join_date?->translatedFormat('l, d F Y')
|
|
|
|
// 2. DateTime (kolom datetime) → 'l, d F Y H:i'
|
|
// Contoh: "Selasa, 25 Agu 2026 12:12"
|
|
$this->check_in_at?->translatedFormat('l, d F Y H:i')
|
|
|
|
// 3. Currency (rupiah) → 'Rp ' + number_format(0, ',', '.')
|
|
// Contoh: "Rp 1.000.000"
|
|
'Rp ' . number_format($this->amount, 0, ',', '.')
|
|
|
|
// 4. Number (angka biasa) → number_format(0, ',', '.')
|
|
// Contoh: "1.000"
|
|
number_format($this->stock, 0, ',', '.')
|
|
|
|
// 5. Month name → Carbon translatedFormat('F')
|
|
// Contoh: "Januari", "Februari"
|
|
Carbon::create()->month($this->month)->translatedFormat('F')
|
|
```
|
|
|
|
### Appends — Data yang Ditampilkan
|
|
```php
|
|
// ✅ Gunakan snake_case (sama dengan nama accessor)
|
|
#[Appends(['formatted_amount', 'formatted_join_date'])]
|
|
|
|
// ❌ JANGAN append kolom yang sudah ada di DB (hanya untuk accessor)
|
|
// ❌ Appends(['amount']) // amount sudah ada di DB
|
|
// ✅ Appends(['formatted_amount']) // formatted_amount adalah accessor
|
|
```
|
|
|
|
### Scopes — untuk Data Opsi
|
|
```php
|
|
// ✅ SELALU buat scope untuk data yang punya opsi
|
|
// Gunakan atribut #[Scope] dan return type void
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
|
|
|
// Untuk enum
|
|
#[Scope]
|
|
protected function pending(Builder $query): void
|
|
{
|
|
$query->where('status', OrderStatus::PENDING);
|
|
}
|
|
|
|
// Untuk boolean
|
|
#[Scope]
|
|
protected function active(Builder $query): void
|
|
{
|
|
$query->where('is_active', true);
|
|
}
|
|
|
|
// ❌ JANGAN: public function pending(Builder $query): Builder
|
|
// ❌ JANGAN: scopePending (pakai prefix 'scope')
|
|
// ✅ BENAR: #[Scope] + protected function pending(Builder $query): void
|
|
```
|
|
|
|
### Relationships — WAJIB 2 ARAH
|
|
```php
|
|
// ✅ Setiap relasi HARUS ditulis di KEDUA model
|
|
|
|
// Model: User
|
|
public function userProfile(): HasOne
|
|
{
|
|
return $this->hasOne(UserProfile::class);
|
|
}
|
|
|
|
// Model: UserProfile (2 ARAH!)
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
// ✅ Contoh relasi 2 arah:
|
|
// User ↔ UserProfile
|
|
// User ↔ Employee
|
|
// User ↔ CashAccount (created_by_id)
|
|
// CashAccount ↔ CashTransaction
|
|
// CashTransaction ↔ Expense (1:1)
|
|
// Order ↔ OrderItem
|
|
// Cutting ↔ CuttingMaterial
|
|
// dll.
|
|
|
|
// ❌ JANGAN hanya tulis di salah 1 model
|
|
// Jika ada User::userProfile(), maka HARUS ada UserProfile::user()
|
|
```
|
|
|
|
### Relationship Naming
|
|
```php
|
|
// BelongsTo → singular
|
|
user() // → BelongsTo User
|
|
cashAccount() // → BelongsTo CashAccount
|
|
|
|
// HasMany → plural
|
|
orders() // → HasMany Order
|
|
payrolls() // → HasMany Payroll
|
|
|
|
// HasOne → singular
|
|
userProfile() // → HasOne UserProfile
|
|
employee() // → HasOne Employee
|
|
|
|
// Custom FK → pastikan ada parameter
|
|
createdCuttings() // → HasMany Cutting, 'created_by_id'
|
|
```
|
|
|
|
### Eloquent Select & Eager Loading — Selalu Select yang Dibutuhkan
|
|
```php
|
|
// ✅ SELALU select kolom yang dibutuhkan saja — gunakan array syntax
|
|
$orders = Order::select(['id', 'order_number', 'total_amount', 'status'])
|
|
->with(['customer:id,name', 'createdBy:id,username'])
|
|
->get();
|
|
|
|
// ✅ Eager load relasi yang dibutuhkan
|
|
$products = Product::with([
|
|
'categories:id,name',
|
|
'productVariants:id,product_id,name,stock',
|
|
])->get();
|
|
|
|
// ❌ JANGAN: Product::all() — load semua kolom + semua relasi
|
|
// ❌ JANGAN: Order::with(['customer', 'createdBy', 'orderItems', 'cashTransaction'])->get() — terlalu banyak relasi
|
|
|
|
// ✅ Pilih relasi yang benar-benar ditampilkan di view
|
|
$orders = Order::select(['id', 'order_number', 'total_amount', 'status', 'customer_id', 'created_by_id'])
|
|
->with([
|
|
'customer:id,name',
|
|
'createdBy:id,username',
|
|
])
|
|
->orderBy($sort, $direction)
|
|
->paginate($perPage);
|
|
```
|
|
|
|
---
|
|
|
|
## Controller
|
|
|
|
### SELALU Gunakan Return Type
|
|
```php
|
|
// ✅ Return type wajib ada di SEMUA method — controller & service
|
|
// ✅ Response untuk controller, void/array/bool untuk service
|
|
```
|
|
|
|
### ZONK — Tidak Ada Logic di Controller
|
|
```php
|
|
// ✅ Controller HANYA mengatur lalu lintas
|
|
// Seluruh logic ADA DI SERVICE
|
|
|
|
class ProductController extends Controller
|
|
{
|
|
public function __construct(
|
|
private ProductService $service
|
|
) {}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
return Inertia::render('Admin/Master/Products/Index', [
|
|
'products' => $this->service->paginated(
|
|
perPage: $request->input('per_page', 25),
|
|
search: $request->input('search', ''),
|
|
sort: $request->input('sort', 'created_at'),
|
|
direction: $request->input('direction', 'desc'),
|
|
),
|
|
]);
|
|
}
|
|
|
|
public function store(ProductRequest $request): Response
|
|
{
|
|
$this->service->store($request->validated());
|
|
|
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditambahkan'])->back();
|
|
}
|
|
}
|
|
|
|
// ❌ JANGAN ada logic di controller, SEKECIL APAPUN
|
|
// ❌ $name = strtoupper($request->name); // logic → pindah ke service
|
|
// ❌ if ($request->hasFile('photo')) { ... } // logic → pindah ke service
|
|
|
|
// ✅ Jika ada yang看似simple seperti resetPassword, tetap pindah ke service
|
|
public function resetPassword(UserRequest $request, User $user): Response
|
|
{
|
|
$this->service->resetPassword($user, $request->validated());
|
|
|
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Password berhasil direset'])->back();
|
|
}
|
|
```
|
|
|
|
### Urutan Method
|
|
```php
|
|
class ProductController extends Controller
|
|
{
|
|
// 1. __construct()
|
|
public function __construct(private ProductService $service) {}
|
|
|
|
// 2. index()
|
|
public function index(Request $request): Response { ... }
|
|
|
|
// 3. create()
|
|
public function create(): Response { ... }
|
|
|
|
// 4. store()
|
|
public function store(ProductRequest $request): Response { ... }
|
|
|
|
// 5. show() — jika ada
|
|
public function show(Product $product): Response { ... }
|
|
|
|
// 6. edit()
|
|
public function edit(Product $product): Response { ... }
|
|
|
|
// 7. update()
|
|
public function update(ProductRequest $request, Product $product): Response { ... }
|
|
|
|
// 8. destroy()
|
|
public function destroy(Product $product): Response { ... }
|
|
|
|
// 9. Custom actions (jika ada, JANGAN dipaksakan)
|
|
public function toggleStatus(Product $product): Response { ... }
|
|
public function resetPassword(UserRequest $request, User $user): Response { ... }
|
|
}
|
|
```
|
|
|
|
### Return ke Halaman yang Sama — Gunakan `back()`
|
|
```php
|
|
// ✅ SELALU gunakan Inertia::flash() sebelum return back()
|
|
public function destroy(Product $product)
|
|
{
|
|
$this->service->destroy($product);
|
|
|
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus'])->back();
|
|
}
|
|
|
|
// ❌ JANGAN pakai ->with()
|
|
return back()->with('success', 'Produk berhasil dihapus'); // JANGAN
|
|
|
|
// ✅ Untuk error di handleAction
|
|
return $this->handleAction(
|
|
fn () => $this->service->store($data),
|
|
'Data berhasil ditambahkan',
|
|
route('admin.master.products.index')
|
|
);
|
|
// handleAction sudah handle flash otomatis
|
|
|
|
// ✅ Untuk redirect ke halaman lain
|
|
return to_route('dashboard'); // ✅ hanya jika memang pindah halaman
|
|
```
|
|
|
|
### Jangan Panggil Model Langsung — Gunakan Service
|
|
```php
|
|
// ❌ JANGAN panggil Model langsung di Controller
|
|
public function index()
|
|
{
|
|
$categories = Category::all(); // JANGAN
|
|
return Inertia::render('...', compact('categories'));
|
|
}
|
|
|
|
// ✅ SELALU gunakan Service
|
|
public function index()
|
|
{
|
|
$categories = $this->categoryService->getAll(); // ✅
|
|
return Inertia::render('...', compact('categories'));
|
|
}
|
|
```
|
|
|
|
### DB Transaction — Gunakan `handleAction()` untuk 2+ Query
|
|
```php
|
|
// ✅ handleAction() → untuk 2+ query/operasi dalam 1 action
|
|
public function store(OrderRequest $request): Response
|
|
{
|
|
return $this->handleAction(
|
|
fn () => $this->service->store($request->validated()),
|
|
'Transaksi berhasil ditambahkan',
|
|
route('admin.manage.transactions.index')
|
|
);
|
|
}
|
|
|
|
// ✅ handleAction() otomatis wrap dalam DB::transaction()
|
|
// ✅ Jika gagal, otomatis rollback + flash error
|
|
|
|
// ✅ 1 query → manual flash (tanpa handleAction)
|
|
public function destroy(Product $product): Response
|
|
{
|
|
$this->service->destroy($product);
|
|
|
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus'])->back();
|
|
}
|
|
```
|
|
|
|
### Service Property — Constructor Promotion
|
|
```php
|
|
// ✅ Gunakan constructor promotion
|
|
public function __construct(
|
|
private ProductService $service
|
|
) {}
|
|
|
|
// ❌ JANGAN
|
|
private ProductService $service = new ProductService; // JANGAN
|
|
public ProductService $service; // JANGAN (public)
|
|
```
|
|
|
|
---
|
|
|
|
## Service
|
|
|
|
### ZONK — Semua Logic Ada di Sini
|
|
```php
|
|
// ✅ Service adalah tempat SELURUH logic
|
|
// Komunikasi DB, validasi bisnis, manipulasi data — semua di sini
|
|
|
|
class ProductService
|
|
{
|
|
public function store(array $data): Product
|
|
{
|
|
$data['slug'] = Str::slug($data['name']);
|
|
return Product::create($data);
|
|
}
|
|
|
|
public function update(Product $product, array $data): Product
|
|
{
|
|
$product->update($data);
|
|
return $product;
|
|
}
|
|
|
|
public function destroy(Product $product): void
|
|
{
|
|
$product->delete();
|
|
}
|
|
|
|
public function resetPassword(User $user, array $data): void
|
|
{
|
|
$user->update([
|
|
'password' => Hash::make($data['password']),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Penamaan Method — HARUS KONSISTEN
|
|
```php
|
|
// ✅ Nama method harus KONSISTEN di SEMUA service
|
|
// Semua service HARUS punya method ini:
|
|
|
|
// 1. paginated() — untuk paginasi
|
|
public function paginated(
|
|
int $perPage = 25,
|
|
string $search = '',
|
|
string $sort = 'created_at',
|
|
string $direction = 'desc',
|
|
array $filters = []
|
|
): LengthAwarePaginator {
|
|
return Product::query()
|
|
->select(['id', 'name', 'slug', 'status']) // ✅ select kolom yang dibutuhkan
|
|
->with(['categories:id,name']) // ✅ eager load relasi
|
|
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
|
|
->orderBy($sort, $direction)
|
|
->paginate($perPage);
|
|
}
|
|
|
|
// 2. getAll() — untuk semua data (tanpa paginasi)
|
|
public function getAll(): Collection
|
|
{
|
|
return Product::query()
|
|
->select(['id', 'name'])
|
|
->get();
|
|
}
|
|
|
|
// 3. store() — untuk create
|
|
public function store(array $data): Product
|
|
{
|
|
return Product::create($data);
|
|
}
|
|
|
|
// 4. update() — untuk update
|
|
public function update(Product $product, array $data): Product
|
|
{
|
|
$product->update($data);
|
|
return $product;
|
|
}
|
|
|
|
// 5. destroy() — untuk delete
|
|
public function destroy(Product $product): void
|
|
{
|
|
$product->delete();
|
|
}
|
|
|
|
// ❌ JANGAN beda-beda nama untuk fungsi yang sama
|
|
// ❌ getProducts(), fetchProducts(), listProducts()
|
|
// ✅ paginated() — 1 nama untuk 1 fungsi
|
|
```
|
|
|
|
### Return Type — Wajib di Semua Method
|
|
```php
|
|
// ✅ Return type wajib ada di SEMUA method
|
|
public function paginated(...): LengthAwarePaginator { ... }
|
|
public function getAll(...): Collection { ... }
|
|
public function store(array $data): Product { ... }
|
|
public function update(Product $product, array $data): Product { ... }
|
|
public function destroy(Product $product): void { ... }
|
|
```
|
|
|
|
### Property Declaration
|
|
```php
|
|
// ✅ Di service, property declaration seperti biasa (private readonly)
|
|
class ProductService
|
|
{
|
|
private readonly CuttingService $cuttingService;
|
|
|
|
public function __construct(CuttingService $cuttingService)
|
|
{
|
|
$this->cuttingService = $cuttingService;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Urutan Method — KONSISTEN
|
|
```php
|
|
// ✅ Urutan method di service WAJIB konsisten:
|
|
// 1. CRUD (paginated, getAll, store, update, destroy)
|
|
// 2. Custom methods (business logic)
|
|
// 3. Private helpers (di paling bawah)
|
|
|
|
class ProductService
|
|
{
|
|
// 1. CRUD
|
|
public function paginated(...): LengthAwarePaginator { ... }
|
|
public function getAll(...): Collection { ... }
|
|
public function store(array $data): Product { ... }
|
|
public function update(Product $product, array $data): Product { ... }
|
|
public function destroy(Product $product): bool { ... }
|
|
|
|
// 2. Custom methods
|
|
public function getNames(): array { ... }
|
|
public function getForEdit(Product $product): array { ... }
|
|
public function toggleStatus(Product $product): void { ... }
|
|
public function approve(Product $product): void { ... }
|
|
public function reject(Product $product, string $reason): void { ... }
|
|
public function resubmit(Product $product): void { ... }
|
|
|
|
// 3. Private helpers (paling bawah)
|
|
private function canVerify(): bool { ... }
|
|
private function assertNotPending(Product $product): void { ... }
|
|
}
|
|
```
|
|
|
|
### Service Concerns (Traits)
|
|
```php
|
|
// ✅ Gunakan traits untuk kode yang berulang di banyak service
|
|
|
|
// 1. HandlesCashTransactions — untuk operasi kas
|
|
use App\Services\Concerns\HandlesCashTransactions;
|
|
|
|
class ExpenseService
|
|
{
|
|
use HandlesCashTransactions;
|
|
|
|
public function store(array $data): Expense
|
|
{
|
|
$cashAccount = $this->getCashAccount($data['cash_account_id']);
|
|
$this->debitCash($cashAccount, $data['amount'], $data['description']);
|
|
return Expense::create($data);
|
|
}
|
|
}
|
|
|
|
// 2. HasStockAdjustment — untuk operasi stok
|
|
use App\Services\Concerns\HasStockAdjustment;
|
|
|
|
class TransactionService
|
|
{
|
|
use HasStockAdjustment;
|
|
|
|
public function store(array $data): Order
|
|
{
|
|
$this->applyStock($data['items'], $data['stock_type'], -1); // kurangi stok
|
|
return Order::create($data);
|
|
}
|
|
}
|
|
|
|
// 3. RegistersMedia — untuk upload file
|
|
use App\Services\Concerns\RegistersMedia;
|
|
|
|
class RestockService
|
|
{
|
|
use RegistersMedia;
|
|
|
|
public function store(array $data): Restock
|
|
{
|
|
$restock = Restock::create($data);
|
|
$this->registerMedia($restock, $data['photo'] ?? null, 'restock');
|
|
return $restock;
|
|
}
|
|
}
|
|
|
|
// 4. HasRoleChecks — untuk role checking
|
|
use App\Concerns\HasRoleChecks;
|
|
|
|
class ProductService
|
|
{
|
|
use HasRoleChecks;
|
|
|
|
public function store(array $data): Product
|
|
{
|
|
$status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
|
|
? ProductStatus::ACTIVE
|
|
: ProductStatus::PENDING;
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
### Trait Stacking — Gunakan Koma
|
|
```php
|
|
// ✅ Gabungkan multiple traits dengan koma dalam 1 baris
|
|
class PayrollPeriodService
|
|
{
|
|
use HandlesCashTransactions, HasRoleChecks;
|
|
}
|
|
|
|
class AttendanceService
|
|
{
|
|
use RegistersMedia, HasRoleChecks;
|
|
}
|
|
|
|
// ❌ JANGAN pisah per baris
|
|
class PayrollPeriodService
|
|
{
|
|
use HandlesCashTransactions;
|
|
use HasRoleChecks; // JANGAN
|
|
}
|
|
```
|
|
|
|
### Role Checking — Gunakan RoleEnum
|
|
```php
|
|
// ✅ SELALU gunakan Role enum, JANGAN string
|
|
use App\Concerns\HasRoleChecks;
|
|
use App\Enums\Role;
|
|
|
|
class ProductService
|
|
{
|
|
use HasRoleChecks;
|
|
|
|
public function store(array $data): Product
|
|
{
|
|
// ✅ BENAR — pakai Role enum
|
|
if (self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) { ... }
|
|
|
|
// ❌ SALAH — pakai string
|
|
if (auth()->user()->hasAnyRole(['developer', 'owner'])) { ... }
|
|
}
|
|
}
|
|
```
|
|
|
|
### Cash Transaction Pattern
|
|
```php
|
|
// Credit (DEPOSIT) → tambah saldo
|
|
$this->creditCash($cashAccount, $amount, $description);
|
|
|
|
// Debit (EXPENSE/WITHDRAWAL) → kurangi saldo + validasi
|
|
$this->debitCash($cashAccount, $amount, $description);
|
|
```
|
|
|
|
### Stock Adjustment Pattern
|
|
```php
|
|
// ProductVariant — increment/decrement stock
|
|
$this->applyStock($items, $stockType, $sign); // $sign: 1 (tambah) atau -1 (kurangi)
|
|
|
|
// RawMaterialPrice — increment/decrement
|
|
$this->adjustStock($model, $field, $quantity, $sign);
|
|
```
|
|
|
|
### NotificationService — Gunakan RoleEnum
|
|
```php
|
|
// ✅ SELALU gunakan Role enum untuk roles parameter
|
|
use App\Enums\Role;
|
|
use App\Services\NotificationService;
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
|
|
title: 'Judul Notifikasi',
|
|
body: 'Isi notifikasi',
|
|
url: route('admin.module.index'),
|
|
);
|
|
|
|
// ❌ JANGAN pakai string
|
|
NotificationService::notify(
|
|
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], // JANGAN
|
|
...
|
|
);
|
|
```
|
|
|
|
### Media Sync — Gunakan syncPhoto/syncReceipt
|
|
```php
|
|
// ✅ SELALU gunakan syncPhoto untuk photo_key, syncReceipt untuk receipt_key
|
|
// Trait: App\Services\Concerns\RegistersMedia
|
|
|
|
// Photo — otomatis handle comparison + clear + register
|
|
$this->syncPhoto($model, $data, 'photos');
|
|
|
|
// Receipt — otomatis handle comparison + cache clearing + clear + register
|
|
$this->syncReceipt($model, $data['receipt_key'] ?? null, 'receipts', 'cache_prefix', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
|
|
|
// ❌ JANGAN handle manual
|
|
if (! empty($data['photo_key'])) {
|
|
$model->clearMediaCollection('photos');
|
|
$this->registerMedia($model, $data['photo_key'], 'photos'); // JANGAN
|
|
}
|
|
```
|
|
|
|
|
|
|
|
---
|
|
|
|
## Form Request
|
|
|
|
### Validasi harus SESUAI dengan DB Schema
|
|
```php
|
|
// ✅ SELALU samakan max length dengan DB column
|
|
// DB: varchar(50) → max:50
|
|
'name' => ['required', 'string', 'max:50'],
|
|
|
|
// DB: varchar(200) → max:200
|
|
'name' => ['required', 'string', 'max:200'],
|
|
|
|
// DB: varchar(100) → max:100
|
|
'description' => ['required', 'string', 'max:100'],
|
|
|
|
// DB: text → tidak perlu max (atau max sesuai UI)
|
|
'address' => ['nullable', 'string'],
|
|
|
|
// DB: decimal(10,7) → numeric + min/max range
|
|
'latitude' => ['required', 'numeric', 'min:-90', 'max:90'],
|
|
'longitude' => ['required', 'numeric', 'min:-180', 'max:180'],
|
|
|
|
// DB: uint → integer, min:0
|
|
'base_salary' => ['required', 'integer', 'min:0'],
|
|
|
|
// DB: ubig → integer, min:1 (untuk amount)
|
|
'amount' => ['required', 'integer', 'min:1'],
|
|
|
|
// DB: enum → Rule::in(Enum::values()) atau Rule::in(['val1', 'val2'])
|
|
'status' => ['required', Rule::in(OrderStatus::values())],
|
|
'gender' => ['nullable', 'in:male,female'],
|
|
|
|
// DB: date → 'date'
|
|
'join_date' => ['required', 'date'],
|
|
|
|
// DB: datetime → 'date' (Laravel handle)
|
|
'paid_at' => ['nullable', 'date'],
|
|
```
|
|
|
|
### Struktur Dasar
|
|
```php
|
|
class ProductRequest extends FormRequest
|
|
{
|
|
use CurrencyStripping;
|
|
|
|
public function authorize(): bool
|
|
{
|
|
return true; // authorization di controller/middleware
|
|
}
|
|
|
|
public function rules(): array
|
|
{
|
|
return [
|
|
'name' => ['required', 'string', 'max:200'],
|
|
'price' => ['required', 'integer'],
|
|
];
|
|
}
|
|
|
|
public function attributes(): array
|
|
{
|
|
return [
|
|
'name' => 'Nama Produk',
|
|
'price' => 'Harga',
|
|
];
|
|
}
|
|
|
|
public function prepareForValidation(): void
|
|
{
|
|
$this->merge($this->stripCurrencyDot($this->validated(), 'price', 'discount'));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Rule Classes — Gunakan `Illuminate\Validation\Rule`
|
|
```php
|
|
use Illuminate\Validation\Rule;
|
|
|
|
// ✅ SELALU gunakan Rule classes, JANGAN string-based rules
|
|
|
|
// exists → Rule::exists('table', 'column')
|
|
'category_ids.*' => [Rule::exists('categories', 'id')],
|
|
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')],
|
|
'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
|
|
|
|
// required_if → Rule::requiredIf(fn () => ...)
|
|
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array'],
|
|
'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],
|
|
|
|
// required_unless → Rule::requiredUnless(fn () => ...)
|
|
'name' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
|
|
'unit' => [$this->isMethod('post') ? Rule::requiredUnless(fn () => $this->input('mode') === 'existing') : 'nullable', Rule::in(RawMaterialUnit::values())],
|
|
|
|
// in → Rule::in(Enum::values()) atau Rule::in(['val1', 'val2'])
|
|
'status' => ['required', Rule::in(OrderStatus::values())],
|
|
'gender' => ['nullable', 'in:male,female'],
|
|
|
|
// unique → Rule::unique('table')->ignore($id)
|
|
'name' => ['required', 'string', 'max:100', Rule::unique('categories')->ignore($this->route('category')?->id)],
|
|
|
|
// ❌ JANGAN pakai string-based rules
|
|
'category_ids.*' => ['exists:categories,id'], // JANGAN
|
|
'shared_prices' => ['required_if:use_same_price,true', ...], // JANGAN
|
|
'name' => ['required_unless:mode,existing', ...], // JANGAN
|
|
```
|
|
|
|
### Currency Stripping
|
|
```php
|
|
use App\Concerns\CurrencyStripping;
|
|
|
|
// ✅ Strip dot dari input Rupiah sebelum validasi
|
|
// Input: "1.000.000" → Output: 1000000
|
|
$this->merge($this->stripCurrencyDot($this->validated(), 'price', 'discount'));
|
|
|
|
// ✅ Untuk nested array
|
|
$this->merge($this->stripCurrencyDot($this->validated(), 'variants.*.price'));
|
|
```
|
|
|
|
### Unique Ignore
|
|
```php
|
|
// ✅ Saat update, ignore ID sendiri — gunakan Rule::unique()
|
|
'name' => ['required', 'string', 'max:100', Rule::unique('categories')->ignore($this->route('category')?->id)],
|
|
|
|
// ✅ Saat store, tidak perlu ignore
|
|
'name' => ['required', 'string', 'max:100', Rule::unique('categories')],
|
|
```
|
|
|
|
### Shared Store/Update Request
|
|
```php
|
|
// ✅ Gunakan 1 request dengan sometimes untuk store & update
|
|
public function rules(): array
|
|
{
|
|
$productId = $this->route('product')?->id;
|
|
|
|
return [
|
|
'name' => ['required', 'string', 'max:200', Rule::unique('products')->ignore($productId)],
|
|
'price' => ['required', 'integer'],
|
|
'description' => ['nullable', 'string'],
|
|
];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Traits & Concerns — Reuse Kode yang Berulang
|
|
|
|
```php
|
|
// ✅ SELALU cari kode yang berulang, jadikan Trait/Concern
|
|
|
|
// Contoh: currency formatting berulang di banyak model
|
|
// ✅ Buat Trait
|
|
namespace App\Concerns;
|
|
|
|
trait CurrencyFormatting
|
|
{
|
|
protected function formatCurrency($value): string
|
|
{
|
|
return 'Rp ' . number_format($value, 0, ',', '.');
|
|
}
|
|
}
|
|
|
|
// ✅ Gunakan di model
|
|
use App\Concerns\CurrencyFormatting;
|
|
|
|
#[Guarded(['id'])]
|
|
#[Appends(['formatted_amount'])]
|
|
class Order extends Model
|
|
{
|
|
use HasFactory, SoftDeletes, CurrencyFormatting;
|
|
|
|
protected function formattedAmount(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->formatCurrency($this->amount),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ✅ Contoh Traits yang sudah ada:
|
|
// - CurrencyStripping → untuk FormRequest
|
|
// - HandlesCashTransactions → untuk Service
|
|
// - HasStockAdjustment → untuk Service
|
|
// - RegistersMedia → untuk Service
|
|
// - HasValues → untuk Enum
|
|
|
|
// ✅ Jika menemukan pola berulang, buat Trait baru:
|
|
// - SoftDeletesScope (untuk scope yang sama di banyak model)
|
|
// - HasFormattedDates (untuk accessor tanggal)
|
|
// - HasFormattedCurrency (untuk accessor currency)
|
|
```
|
|
|
|
---
|
|
|
|
## Frontend (React + TypeScript + Inertia)
|
|
|
|
### Page Patterns
|
|
|
|
#### A. Simple CRUD Index (DataTable)
|
|
Digunakan untuk: Category, Customer, Supplier, Employee, Leave Request, Expense, Cash Account
|
|
|
|
```tsx
|
|
import { Head } from '@inertiajs/react';
|
|
import { useState } from 'react';
|
|
import { useCan } from '@/hooks/use-can';
|
|
import { useServerTable } from '@/hooks/use-server-table';
|
|
import { PageHeader } from '@/components/page-header';
|
|
import { DataTable } from '@/components/data-table';
|
|
import { FormDialog } from '@/components/form-dialog';
|
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { createCategoryColumns, type Category } from './columns';
|
|
|
|
// Props dari controller (Inertia render)
|
|
type Props = {
|
|
categories: { data: Category[]; current_page: number; last_page: number; per_page: number; total: number };
|
|
};
|
|
|
|
export default function CategoryIndex({ categories }: Props) {
|
|
const { can } = useCan();
|
|
const [createOpen, setCreateOpen] = useState<boolean>(false);
|
|
const [editing, setEditing] = useState<Category | null>(null);
|
|
const [deleting, setDeleting] = useState<Category | null>(null);
|
|
|
|
const { search, handlePageChange, handlePerPageChange, handleSearchChange } = useServerTable({
|
|
route: () => route('admin.master.categories.index'),
|
|
pagination: categories,
|
|
});
|
|
|
|
const columns = createCategoryColumns({
|
|
handleEdit: (cat) => setEditing(cat),
|
|
handleDeleteClick: (cat) => setDeleting(cat),
|
|
can,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Head title="Kategori" />
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<PageHeader title="Kategori" actions={can('categories.create') ? <Button onClick={() => setCreateOpen(true)}>Tambah</Button> : undefined} />
|
|
|
|
{/* Create Dialog */}
|
|
<FormDialog open={createOpen} onOpenChange={setCreateOpen} title="Tambah Kategori" action={route('admin.master.categories.store')} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
|
{({ errors }) => (
|
|
<div className="grid gap-2">
|
|
<Label>Nama</Label>
|
|
<Input name="name" placeholder="Nama kategori" />
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
)}
|
|
</FormDialog>
|
|
|
|
{/* Edit Dialog */}
|
|
<FormDialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)} title="Edit Kategori" action={editing ? route('admin.master.categories.update', editing.id) : ''} resetOnSuccess onSuccess={() => setEditing(null)}>
|
|
{({ errors }) => editing && (
|
|
<div className="grid gap-2">
|
|
<Label>Nama</Label>
|
|
<Input name="name" defaultValue={editing.name} />
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
)}
|
|
</FormDialog>
|
|
|
|
{/* DataTable */}
|
|
<DataTable columns={columns} data={categories.data} searchKey="name" pagination={categories} onPageChange={handlePageChange} onPerPageChange={handlePerPageChange} onSearchChange={handleSearchChange} searchValue={search} />
|
|
|
|
{/* Delete Confirmation */}
|
|
<DeleteConfirmDialog target={deleting} onOpenChange={(open) => !open && setDeleting(null)} title="Hapus Kategori" description={(cat) => `Hapus kategori "${cat.name}"?`} onConfirm={() => { if (deleting) router.delete(route('admin.master.categories.destroy', deleting.id), { onSuccess: () => setDeleting(null) }); }} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### B. Complex List Index (CardTable)
|
|
Digunakan untuk: Transaction, Cutting, Purchase, Restock
|
|
|
|
```tsx
|
|
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
|
import { useServerTable } from '@/hooks/use-server-table';
|
|
import { CardTable } from '@/components/card-table';
|
|
import { FilterPopover } from '@/components/filter-popover';
|
|
|
|
type Props = {
|
|
transactions: { data: Transaction[]; current_page: number; last_page: number; per_page: number; total: number };
|
|
summary: TransactionSummary;
|
|
filters: Record<string, string | undefined>;
|
|
filterOptions: {
|
|
statusOptions: Array<{ value: string; label: string }>;
|
|
channelOptions: Array<{ value: string; label: string }>;
|
|
};
|
|
};
|
|
|
|
export default function TransactionIndex({ transactions, summary, filters, filterOptions }: Props) {
|
|
const expand = useCardTableExpand(true);
|
|
const { search, handlePageChange, handlePerPageChange, handleSearchChange, applyFilter, clearFilters } = useServerTable({
|
|
route: () => route('admin.manage.transactions.index'),
|
|
pagination: transactions,
|
|
filters,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Head title="Transaksi" />
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<PageHeader title="Transaksi" actions={...} />
|
|
|
|
{/* Summary Card */}
|
|
<TransactionSummaryCard summary={summary} />
|
|
|
|
{/* CardTable with Filters */}
|
|
<CardTable
|
|
data={transactions.data}
|
|
getItemKey={(t) => t.id}
|
|
expandedKeys={expand.expandedKeys}
|
|
onToggleExpand={expand.toggleExpand}
|
|
searchValue={search}
|
|
onSearchChange={handleSearchChange}
|
|
toolbar={<FilterPopover filters={filters} filterOptions={filterOptions} onApply={applyFilter} onClear={clearFilters} />}
|
|
pagination={transactions}
|
|
onPageChange={handlePageChange}
|
|
onPerPageChange={handlePerPageChange}
|
|
renderCard={({ item, isExpanded, onToggleExpand }) => <TransactionCardRow transaction={item} isExpanded={isExpanded} onToggleExpand={onToggleExpand} />}
|
|
renderSubContent={(t) => <TransactionItemSubRow transaction={t} />}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### C. Create/Edit Page (Full-page Form)
|
|
Digunakan untuk: Transaction create/edit, Cutting create/edit, Purchase create/edit
|
|
|
|
```tsx
|
|
'use no memo'; // untuk complex forms
|
|
|
|
import { Head, Link, router } from '@inertiajs/react';
|
|
import { Form } from '@inertiajs/forms';
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { loadTransactionDraft, useTransactionDraftSave } from '@/hooks/use-transaction-draft';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { RupiahInput } from '@/components/rupiah-input';
|
|
import { NumberInput } from '@/components/number-input';
|
|
import { FileUpload } from '@/components/file-upload';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
|
|
type Props = { data: CreateData; auth: { user: User } };
|
|
|
|
export default function TransactionCreate({ data, auth }: Props) {
|
|
const draft = loadTransactionDraft('create', auth.user.id);
|
|
const [customer, setCustomer] = useState(draft?.customer_id ?? null);
|
|
const [items, setItems] = useState(draft?.items ?? []);
|
|
const [subtotal, setSubtotal] = useState(0);
|
|
|
|
useTransactionDraftSave('create', { customer_id: items, ... }, auth.user.id);
|
|
|
|
// ... useEffect, useCallback untuk computed values
|
|
|
|
return (
|
|
<>
|
|
<Head title="Tambah Transaksi" />
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-semibold tracking-tight">Tambah Transaksi</h2>
|
|
<Button asChild variant="outline">
|
|
<Link href={route('admin.manage.transactions.index')}>
|
|
<ArrowLeft className="h-4 w-4" /> Kembali
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Form action={route('admin.manage.transactions.store')} transform={(formData) => ({ ...formData, customer_id: customer, items, subtotal })}>
|
|
{({ errors, processing }) => (
|
|
<div className="grid gap-6 md:grid-cols-3">
|
|
<div className="space-y-6 md:col-span-2">
|
|
{/* Main content */}
|
|
</div>
|
|
<div className="space-y-6 md:col-span-1">
|
|
<Card className="sticky top-6">
|
|
<CardHeader><CardTitle>Ringkasan</CardTitle></CardHeader>
|
|
<CardContent>{/* Summary */}</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Column Definitions
|
|
File: `columns.tsx` — colocated bersama page
|
|
|
|
```tsx
|
|
// ✅ Type definitions — export di columns.tsx
|
|
export type Category = {
|
|
id: number;
|
|
name: string;
|
|
formatted_name: string; // accessor dari model
|
|
status_label: string; // label dari enum
|
|
};
|
|
|
|
// ✅ Column factory function
|
|
type CreateColumnsParams = {
|
|
handleEdit: (entity: Category) => void;
|
|
handleDeleteClick: (entity: Category) => void;
|
|
can: (permission: string) => boolean;
|
|
};
|
|
|
|
export function createCategoryColumns(params: CreateColumnsParams): ColumnDef<Category>[] {
|
|
const { handleEdit, handleDeleteClick, can } = params;
|
|
|
|
const columns: ColumnDef<Category>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: () => <span>Nama</span>,
|
|
cell: ({ row }) => <span className="font-medium">{row.getValue('name')}</span>,
|
|
},
|
|
// ✅ Gunakan accessor dari model, BUKAN format di TypeScript
|
|
{
|
|
accessorKey: 'formatted_name',
|
|
header: () => <span>Nama</span>,
|
|
cell: ({ row }) => <span>{row.getValue('formatted_name')}</span>,
|
|
},
|
|
// ✅ Label dari enum, BUKAN format di TypeScript
|
|
{
|
|
accessorKey: 'status_label',
|
|
header: () => <span>Status</span>,
|
|
cell: ({ row }) => <Badge>{row.getValue('status_label')}</Badge>,
|
|
},
|
|
];
|
|
|
|
// ✅ Actions column — conditional on permissions
|
|
if (can('categories.update') || can('categories.delete')) {
|
|
columns.push({
|
|
id: 'actions',
|
|
header: () => <span className="block text-center">Aksi</span>,
|
|
meta: { className: 'w-[100px] text-center' },
|
|
cell: ({ row }) => (
|
|
<RowActions actions={[
|
|
{ label: 'Edit', icon: <Pencil />, show: can('categories.update'), onClick: () => handleEdit(row.original) },
|
|
{ label: 'Hapus', icon: <Trash2 className="text-destructive" />, show: can('categories.delete'), onClick: () => handleDeleteClick(row.original) },
|
|
]} />
|
|
),
|
|
});
|
|
}
|
|
|
|
return columns;
|
|
}
|
|
```
|
|
|
|
### Form Handling
|
|
|
|
#### FormDialog (Simple CRUD — Inline)
|
|
```tsx
|
|
import { FormDialog } from '@/components/form-dialog';
|
|
|
|
// ✅ Create
|
|
<FormDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
title="Tambah Category"
|
|
action={route('admin.master.categories.store')} // Inertia route
|
|
resetOnSuccess // Reset form setelah success
|
|
onSuccess={() => setCreateOpen(false)} // Tutup dialog setelah success
|
|
>
|
|
{({ errors, processing }) => (
|
|
<div className="grid gap-2">
|
|
<Label>Nama <span className="text-destructive">*</span></Label>
|
|
<Input name="name" placeholder="Nama kategori" />
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
)}
|
|
</FormDialog>
|
|
|
|
// ✅ Edit — gunakan defaultValue
|
|
<FormDialog
|
|
open={editing !== null}
|
|
onOpenChange={(open) => !open && setEditing(null)}
|
|
title="Edit Category"
|
|
action={editing ? route('admin.master.categories.update', editing.id) : ''}
|
|
resetOnSuccess
|
|
onSuccess={() => setEditing(null)}
|
|
>
|
|
{({ errors }) => editing && (
|
|
<div className="grid gap-2">
|
|
<Label>Nama</Label>
|
|
<Input name="name" defaultValue={editing.name} />
|
|
<InputError message={errors.name} />
|
|
</div>
|
|
)}
|
|
</FormDialog>
|
|
```
|
|
|
|
#### Full-page Form (Complex Create/Edit)
|
|
```tsx
|
|
import { Form } from '@inertiajs/forms';
|
|
import { RupiahInput } from '@/components/rupiah-input';
|
|
import { NumberInput } from '@/components/number-input';
|
|
import { FileUpload } from '@/components/file-upload';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
|
|
<Form action={route('admin.manage.transactions.store')} transform={(formData) => ({ ...formData, items })}>
|
|
{({ errors, processing }) => (
|
|
<div>
|
|
{/* Controlled fields — gunakan useState */}
|
|
<RupiahInput value={amount} onValueChange={setAmount} />
|
|
<NumberInput value={qty} onValueChange={setQty} />
|
|
<FileUpload value={photo} onChange={setPhoto} folder="transaction" />
|
|
|
|
{/* Uncontrolled fields — gunakan name */}
|
|
<Input name="description" defaultValue={editing?.description} />
|
|
|
|
{/* Combobox untuk dropdown */}
|
|
<Combobox items={customers} value={customer} onValueChange={setCustomer}>
|
|
<ComboboxInput placeholder="Pilih customer" />
|
|
<ComboboxContent>
|
|
<ComboboxList>{(item) => <ComboboxItem value={item}>{item.name}</ComboboxItem>}</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
|
|
<Button type="submit" disabled={processing}>Simpan</Button>
|
|
</div>
|
|
)}
|
|
</Form>
|
|
```
|
|
|
|
### Hooks
|
|
|
|
```tsx
|
|
import { useCan } from '@/hooks/use-can';
|
|
import { useServerTable } from '@/hooks/use-server-table';
|
|
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
|
|
|
// ✅ useCan — permission checking
|
|
const { can, canAny, hasRole, hasRoleAny } = useCan();
|
|
can('categories.create') // boolean
|
|
hasRole('developer') // boolean
|
|
|
|
// ✅ useServerTable — pagination, search, filter
|
|
const { search, filterOpen, setFilterOpen, handlePageChange, handlePerPageChange, handleSearchChange, applyFilter, clearFilters } = useServerTable({
|
|
route: () => route('admin.manage.transactions.index'),
|
|
pagination: transactions,
|
|
filters: filters,
|
|
});
|
|
|
|
// ✅ useCardTableExpand — expand/collapse rows
|
|
const expand = useCardTableExpand(true); // true = expand all by default
|
|
// Returns: { expandedKeys, toggleExpand, expandAll, collapseAll }
|
|
|
|
// ✅ Draft System — auto-save form draft ke localStorage
|
|
import { useTransactionDraftSave, loadTransactionDraft } from '@/hooks/use-transaction-draft';
|
|
const draft = loadTransactionDraft('create', userId);
|
|
const [field, setField] = useState(draft?.field ?? defaultValue);
|
|
useTransactionDraftSave('create', draftData, userId);
|
|
// Auto-save 500ms debounce, cleared on success
|
|
```
|
|
|
|
### Components
|
|
|
|
| Component | Digunakan Untuk |
|
|
|-----------|----------------|
|
|
| `PageHeader` | Header halaman: title + description + actions |
|
|
| `DataTable` | Table server-side pagination + search (simple CRUD) |
|
|
| `CardTable` | Card-based list + expandable rows (complex entities) |
|
|
| `FormDialog` | Dialog + Inertia Form wrapper (inline create/edit) |
|
|
| `DeleteConfirmDialog` | Konfirmasi hapus (target-based open state) |
|
|
| `RowActions` | Action dropdown (edit/hapus/custom) |
|
|
| `FilterPopover` | Filter toolbar dengan badge jumlah active filters |
|
|
| `RupiahInput` | Input currency "Rp X.XXX" (controlled) |
|
|
| `NumberInput` | Input angka dengan locale formatting |
|
|
| `FileUpload` | Upload file ke S3 dengan preview |
|
|
| `PhoneNumberInput` | Input nomor telepon |
|
|
| `StatusBadge` | Badge status dengan color mapping |
|
|
|
|
### Permission System
|
|
```tsx
|
|
// Backend: kirim permission ke view via Spatie
|
|
// Frontend: check dengan useCan()
|
|
|
|
const { can, canAny, hasRole, hasRoleAny } = useCan();
|
|
|
|
// Permission string: {entity}.{action}
|
|
can('categories.create') // boolean
|
|
can('categories.update') // boolean
|
|
can('categories.delete') // boolean
|
|
can('orders.create') // boolean
|
|
|
|
// Roles bypass all permissions:
|
|
// 'developer' dan 'owner' selalu akses semua
|
|
```
|
|
|
|
### Data Flow (Controller → Frontend)
|
|
```php
|
|
// Controller: kirim data via Inertia::render()
|
|
return Inertia::render('Admin/Master/Products/Index', [
|
|
'products' => $this->service->paginated(...), // paginated data
|
|
'filterOptions' => [ // filter options dari enum
|
|
'statusOptions' => OrderStatus::toSelect(),
|
|
'channelOptions' => OrderChannel::toSelect(),
|
|
],
|
|
]);
|
|
```
|
|
|
|
```tsx
|
|
// Frontend: terima sebagai page props
|
|
type Props = {
|
|
products: { data: Product[]; current_page: number; last_page: number; per_page: number; total: number };
|
|
filterOptions: { statusOptions: Array<{ value: string; label: string }> };
|
|
};
|
|
```
|
|
|
|
### Routing (Ziggy)
|
|
```tsx
|
|
import { index, store, update, destroy } from '@/routes/admin/master/categories';
|
|
|
|
// Ziggy route helpers
|
|
index.url() // GET /admin/master/categories
|
|
store() // POST /admin/master/categories
|
|
update(editing.id) // PUT /admin/master/categories/{id}
|
|
destroy(deleting.id) // DELETE /admin/master/categories/{id}
|
|
|
|
// With parameters
|
|
route('admin.manage.transactions.show', transaction.id) // URL
|
|
route('admin.manage.transactions.update', transaction.id)
|
|
```
|
|
|
|
### Model Accessors → Gunakan `formatted_` dari Backend
|
|
```tsx
|
|
// ✅ Gunakan accessor dari model
|
|
{ row.original.formatted_amount }
|
|
{ row.original.status_label }
|
|
|
|
// ❌ Jangan format di TypeScript
|
|
import { formatCurrency } from '@/lib/utils';
|
|
{ formatCurrency(row.original.amount) } // JANGAN
|
|
```
|
|
|
|
### Enum Options → Dari Controller
|
|
```php
|
|
// Controller: kirim ke view
|
|
'filterOptions' => [
|
|
'statusOptions' => OrderStatus::toSelect(),
|
|
],
|
|
```
|
|
|
|
```tsx
|
|
// View: gunakan options
|
|
{filterOptions.statusOptions.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
))}
|
|
```
|
|
|
|
### Format Functions
|
|
```tsx
|
|
import { formatDate, formatShortDate, formatDateTime } from '@/lib/format';
|
|
import { formatCurrency, formatNumber } from '@/lib/utils';
|
|
|
|
formatDate(dateString) // "23 Agustus 2026 08:40"
|
|
formatShortDate(dateString) // "23 Agst 2026"
|
|
formatDateTime(dateString) // "23 Agustus 2026 08:40"
|
|
formatCurrency(1000000) // "Rp 1.000.000"
|
|
formatNumber(1000000) // "1.000.000"
|
|
```
|
|
|
|
---
|
|
|
|
## Database
|
|
|
|
### Foreign Key Convention
|
|
```php
|
|
// BelongsTo → foreign key = model_name_id
|
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
|
|
|
// Custom FK
|
|
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
|
|
|
|
// Polymorphic
|
|
$table->nullableMorphs('reference');
|
|
```
|
|
|
|
### SoftDeletes
|
|
```php
|
|
// Hampir semua model PAKAI SoftDeletes
|
|
$table->softDeletes();
|
|
|
|
// Kecuali: Attendance, StokOpnameItem, RetailStockHistory, StockMutation
|
|
```
|
|
|
|
### Timestamps
|
|
```php
|
|
// Default: created_at + updated_at
|
|
$table->timestamps();
|
|
|
|
// RetailStockHistory: hanya created_at (set $timestamps = false di model)
|
|
```
|
|
|
|
---
|
|
|
|
## Do's and Don'ts
|
|
|
|
### ✅ Do
|
|
- Selalu pakai `#[Guarded(['id'])]` di model
|
|
- Selalu cast kolom yang perlu (Enum, integer, boolean, array, datetime)
|
|
- Selalu buat Enum untuk data opsi (status, type, dll)
|
|
- Selalu buat scope untuk data opsi, gunakan atribut `#[Scope]` + return type `void`
|
|
- Selalu tulis relasi 2 ARAH di kedua model
|
|
- Selalu pakai `formatted_*` accessor untuk display (NAMA BERBEDA dari kolom DB)
|
|
- Tidak perlu accessor untuk label — Enum sudah handle via `->label()`
|
|
- Selalu select kolom yang dibutuhkan + eager load relasi
|
|
- Selalu strip currency di FormRequest sebelum validasi
|
|
- Selalu pakai `Rule::exists()`, `Rule::requiredIf()`, `Rule::requiredUnless()` (bukan string-based rules)
|
|
- Selalu pakai constructor promotion di controller (`public function __construct(private XService $service) {}`)
|
|
- Selalu kirim enum options dari controller ke view (`Enum::toSelect()`)
|
|
- Selalu pakai `handleAction()` untuk 2+ query
|
|
- Selalu pakai `back()` + `Inertia::flash('toast', [...])` sebelum return back()
|
|
- Selalu pindahkan logic ke Service (controller = lalu lintas saja)
|
|
- Selalu pakai nama method yang konsisten di semua service (`paginated`, `getAll`, `store`, `update`, `destroy`)
|
|
- Selalu cari kode berulang → jadikan Trait/Concern
|
|
- Selalu pakai `Role` enum untuk role checking & notifikasi (bukan string)
|
|
- Selalu pakai `syncPhoto`/`syncReceipt` untuk media handling (bukan manual)
|
|
- Selalu pakai `withTrashed()` untuk relasi ke model yang mungkin di-soft-delete
|
|
- Selalu pakai return type di SEMUA method (controller & service)
|
|
- **Frontend**: Selalu gunakan `formatted_*` accessor dari model, bukan format di TypeScript
|
|
- **Frontend**: Selalu kirim enum options dari controller, jangan hardcoded di view
|
|
- **Frontend**: Selalu gunakan Ziggy route helpers, jangan hardcoded URLs
|
|
- **Frontend**: Selalu gunakan `useCan()` untuk permission checking
|
|
- **Frontend**: Selalu definisikan type di `columns.tsx` (colocated)
|
|
- **Frontend**: Selalu gunakan `FormDialog` untuk simple CRUD, `<Form>` untuk complex
|
|
|
|
### ❌ Don't
|
|
- Jangan pakai `$fillable` (pakai `#[Guarded]`)
|
|
- Jangan biarkan kolom tanpa cast
|
|
- Jangan pakai string biasa untuk data opsi (pakai Enum)
|
|
- Jangan pakai string untuk role checking (pakai `Role` enum)
|
|
- Jangan handle media sync manual (pakai `syncPhoto`/`syncReceipt`)
|
|
- Jangan lupa buat scope untuk data opsi, gunakan `#[Scope]` + return type `void`
|
|
- Jangan pakai `public function` untuk scope (pakai `protected function`)
|
|
- Jangan lupa tulis relasi 2 ARAH
|
|
- Jangan pakai nama accessor yang sama dengan kolom DB (`amount()` → bentrok)
|
|
- Jangan hardcode enum values di view (ambil dari controller)
|
|
- Jangan pakai `= new XService` (pakai dependency injection)
|
|
- Jangan lupa strip currency saat input Rupiah
|
|
- Jangan pakai string-based rules seperti `'exists:table,id'`, `'required_if:field,value'`, `'required_unless:field,value'` (pakai Rule classes)
|
|
- Jangan lupa `authorize()` di FormRequest (return true)
|
|
- Jangan lupa `attributes()` di FormRequest (label Bahasa Indonesia)
|
|
- JANGAN ada logic di controller (SEKECIL APAPUN)
|
|
- Jangan panggil Model langsung di controller (pakai Service)
|
|
- Jangan pakai `to_route()` untuk kembali ke halaman yang sama (pakai `back()`)
|
|
- Jangan pakai nama method yang beda-beda untuk fungsi yang sama
|
|
- **Frontend**: Jangan format currency/angka di TypeScript (pakai accessor dari model)
|
|
- **Frontend**: Jangan hardcode select options di view (ambil dari controller via `Enum::toSelect()`)
|
|
- **Frontend**: Jangan hardcoded URLs (pakai Ziggy route helpers)
|
|
- **Frontend**: Jangan lupa permission check dengan `can()` di setiap action
|
|
- **Frontend**: Jangan lupa return type di semua method
|
|
- **Frontend**: Jangan gunakan `router.post()` untuk navigate, gunakan Inertia `<Form>`
|