# Modul Products

File terkait: [ProductList.jsx](../../src/pages/products/ProductList.jsx), [ProductForm.jsx](../../src/pages/products/ProductForm.jsx), [productService.js](../../src/services/productService.js)

Ini modul paling kompleks di sistem — satu form produk sebenarnya menulis ke **5 endpoint berbeda** secara berurutan saat disimpan.

## List — `ProductList.jsx`

- `GET /v1/products` dipanggil dengan `params: { per_page: 1000, branch_id? }` — pada dasarnya **ambil semua produk sekaligus** (per_page besar = workaround tidak ada true server pagination), lalu filter/cari/paging **semua dilakukan di client** (search nama, filter kategori, filter status aktif, filter tipe stok `recipe`/`manual`).
- `queryKey: ['products', activeBranch?.id]` — ganti cabang di topbar otomatis refetch produk untuk cabang itu.
- **Toggle aktif/nonaktif** langsung di tabel (tanpa masuk form): `PUT /v1/products/{id}` body `{ is_active: bool }` — optimistic update ke cache lokal duluan, baru invalidate.
- **Hapus** per item: `DELETE /v1/products/{id}` (dengan dialog konfirmasi `useConfirm`). **Hapus massal**: loop `Promise.all` memanggil `destroy` untuk tiap id terpilih — **bukan** endpoint bulk-delete khusus.
- **Export** Excel/PDF: `GET /v1/products/export/excel|pdf` (blob, langsung trigger download browser).
- **Import** Excel: modal terpisah (`ImportModal`) — download template dulu (`GET /v1/products/import/template`), lalu upload file terisi (`POST /v1/products/import`, multipart field `file`).

## Form — `ProductForm.jsx`

### Field & section

| Section | Field |
|---|---|
| Informasi Dasar | gambar (upload), `sku*`, `name*`, `category_id*`, `unit_id*`, `description` (max 500) |
| Harga | `price*` (number, prefix "Rp") |
| Modifier | daftar `modifier_id` yang dipasang ke produk ini (dropdown dari modifier list) |
| Cabang | multi-select cabang tempat produk ini dijual — kosong = "Semua Cabang (default)" |
| Waktu Aktif | jadwal per hari (Senin–Minggu): toggle aktif + tipe `all_day` atau `custom` (jam mulai/selesai) |
| Stok | radio `manual` (diatur manual di halaman Inventory) vs `recipe` (stok otomatis dihitung dari ketersediaan bahan baku) — kalau `recipe`, tampil tabel resep: bahan baku + jumlah + satuan |

**Validasi sebelum submit (manual, di `handleSave`):** `sku`, `name`, `category_id`, `unit_id`, `price` wajib — kalau salah satu kosong → toast "Mohon lengkapi field yang wajib diisi", stop.

### Flow submit — urutan 5 panggilan API berantai

```
1. POST /v1/products            (create)  atau  PUT /v1/products/{id}  (update)
   → body dasar: sku, name, category_id, unit_id, description, price, is_active, stock_type, modifier_ids
   → kalau ada gambar baru: dikirim sebagai multipart (FormData), field "image"
   → dapat productId dari hasil create (untuk edit, pakai id yang sudah ada)

2. PUT /v1/products/{productId}/branches
   → body: { branches: [{ id, is_active: true }, ...] }
   → kalau user tidak pilih cabang spesifik di form, FE otomatis kirim SEMUA cabang
     (effectiveBranchIds = branches.length ? branches : semua branch.id) — jadi
     "kosong = semua cabang" itu di-resolve eksplisit di FE, bukan dikirim array kosong.

3. PUT /v1/products/{productId}/schedule
   → body: { schedules: [{ day_of_week, start_time, end_time, is_active: true }, ...] }
   → hanya hari yang toggle "aktif" yang dikirim. Tipe "Berlaku tiap jam" dikonversi
     jadi start_time="00:00", end_time="23:59" di payload.

4. PUT /v1/products/{productId}/recipe   (HANYA kalau stockType === 'recipe' dan ada baris resep)
   → body: { materials: [{ raw_material_id, quantity, unit_id }, ...] }

5. (implisit, di onSuccess) invalidateQueries(['products']) dan (['stock-products'])
```

**Implikasi penting:** karena ini 4 request HTTP sequential (bukan transaksi atom di FE), kalau request #1 sukses tapi #2/#3 gagal (mis. koneksi putus), produk akan **tersimpan tapi tanpa data cabang/jadwal lengkap** — tidak ada rollback otomatis. Untuk debugging "produk kok cabangnya kosong", cek apakah salah satu dari 4 request ini gagal di tengah jalan.

### Upload gambar

- Gambar baru → kirim base payload sebagai `FormData` multipart (bukan JSON), field `image`.
- Hapus gambar yang sudah ada tanpa upload baru → kirim `{ image: null }` di JSON biasa (hanya kalau user klik tombol hapus, `imageRemoved` flag).
- Edit dengan multipart → trik `_method=PUT` di FormData (POST asli ke backend, karena PHP tak bisa parse multipart via method PUT).

### Endpoint ringkasan

| Aksi | Method | Endpoint | Body utama |
|---|---|---|---|
| List | GET | `/v1/products` | `per_page=1000, branch_id?` |
| Detail | GET | `/v1/products/{id}` | — |
| Create | POST | `/v1/products` | base fields (lihat di atas) |
| Update | PUT/POST+`_method` | `/v1/products/{id}` | base fields |
| Delete | DELETE | `/v1/products/{id}` | — |
| Set cabang | PUT | `/v1/products/{id}/branches` | `{ branches: [{id, is_active}] }` |
| Set jadwal | PUT | `/v1/products/{id}/schedule` | `{ schedules: [...] }` |
| Get resep | GET | `/v1/products/{id}/recipe` | — |
| Set resep | PUT | `/v1/products/{id}/recipe` | `{ materials: [...] }` |
| Export | GET | `/v1/products/export/excel`, `/export/pdf` | blob |
| Import | GET/POST | `/v1/products/import/template`, `/v1/products/import` | blob / multipart `file` |

### Expected response detail produk (`GET /v1/products/{id}`)

```json
{
  "data": {
    "id": "...", "sku": "...", "name": "...", "category_id": "...", "unit_id": "...",
    "description": "...", "price": 0, "is_active": true, "stock_type": "manual|recipe",
    "image": "url|null",
    "modifiers": [{ "id": "..." }],
    "branches": [{ "id": "...", "name": "..." }],
    "schedules": [{ "day_of_week": 0, "start_time": "08:00:00", "end_time": "16:00:00" }],
    "materials": [{ "raw_material_id": "...", "raw_material": {...}, "quantity": 0, "unit_id": "..." }]
  }
}
```
