# Standar Input Detail (Header–Detail) Enterprise

**Aplikasi:** LPK TIP v2 · **Berlaku untuk:** semua form transaksi header-detail (Detail Pos Biaya/BKK, SPB, Transfer, PO, Opname, dll)
**Tanggal:** 2026-08-14 · **Status:** Standar ✅ (pilot: `input-pos-biaya/create`)

---

## 1. Prinsip

1. **Inline binding** — setiap kolom baris detail terikat `wire:model.live` → perubahan langsung tersimpan di state komponen & total otomatis ter-update (tanpa tombol simpan per baris).
2. **Combobox searchable** — semua kolom relasi (pos biaya, pegawai, material, gudang, satuan) memakai `x-combobox` (bisa diketik & difilter), bukan `<select>` statis panjang.
3. **Grid tabel detail** — satu area tabel: header kolom + baris dinamis + **footer total**.
4. **Server-side state** — `items` adalah array public Livewire; validasi per baris (contoh `items.*.pay_type_id`).

---

## 2. Komponen Wajib

### 2.1 `x-combobox` — dropdown searchable (reusable)
File: `resources/views/components/combobox.blade.php`

```blade
{{-- x-combobox :options (['value'=>,'label'=>]), :value, name (dot path Livewire) --}}
@props(['options' => [], 'value' => null, 'name' => '', 'placeholder' => 'Cari…', 'disabled' => false])
<div
    x-data="{
        open: false, q: '', value: @js($value), label: '',
        options: @js(array_values($options)),
        init() { this.label = this.options.find(o => String(o.value) === String(this.value))?.label ?? ''; },
        get filtered() {
            return this.options.filter(o => String(o.label).toLowerCase().includes(this.q.toLowerCase())).slice(0, 20);
        },
        select(o) {
            this.value = o.value; this.label = o.label; this.q = ''; this.open = false;
            $wire.set('{{ $name }}', o.value);
        },
        clear() { this.value = null; this.label = ''; $wire.set('{{ $name }}', null); },
    }"
    class="relative w-full"
>
    <input
        type="text"
        x-model="q"
        :value="q || label"
        @focus="open = true"
        @click.outside="open = false"
        :disabled="{{ $disabled ? 'true' : 'false' }}"
        placeholder="{{ $placeholder }}"
        class="w-full rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-200"
    >
    <span class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400">
        <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><use href="#mi-chevrons-up-down"></use></svg>
    </span>
    <div x-show="open" x-transition class="absolute left-0 z-30 mt-1 max-h-56 w-full overflow-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg">
        <template x-for="o in filtered" :key="o.value">
            <button type="button" @click="select(o)"
                class="block w-full px-3 py-1.5 text-left text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-700"
                x-text="o.label"></button>
        </template>
        <p x-show="!filtered.length" class="px-3 py-2 text-sm text-slate-400">Tidak ada hasil</p>
    </div>
</div>
```

**Aturan pakai:**
- `options` = `['value' => id, 'label' => teks]` (urutkan A→Z, batasi ±500 utk performa; pakai `pluck('name','id')`).
- `name` = path Livewire persis, termasuk indeks baris: `items.0.pay_type_id`.
- Nilai ditulis ke Livewire via `$wire.set(name, value)` → state server ter-update + re-render.

### 2.2 Pola binding inline di tabel detail
```blade
<tbody class="divide-y divide-slate-100">
    @foreach ($items as $i => $row)
        <tr>
            <td class="px-3 py-2">
                <x-combobox :options="$payTypes" :value="$row['pay_type_id'] ?? ''"
                            name="items.{{ $i }}.pay_type_id" placeholder="Cari pos biaya…" />
            </td>
            <td class="px-3 py-2 w-36">
                <input type="number" step="0.01" wire:model.live="items.{{ $i }}.amount"
                       class="w-full rounded-lg border border-slate-300 px-2.5 py-1.5 text-right text-sm tabular-nums outline-none focus:border-brand-500" />
            </td>
            <td class="px-3 py-2 text-center">
                <button type="button" wire:click="removeItemRow({{ $i }})" title="Hapus baris"
                        class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-50 text-red-600 hover:bg-red-100">
                    <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><use href="#mi-trash"></use></svg>
                </button>
            </td>
        </tr>
    @endforeach
</tbody>
<tfoot class="bg-slate-50">
    <tr>
        <td colspan="2" class="px-3 py-2 text-right text-sm font-bold text-slate-700">TOTAL</td>
        <td class="px-3 py-2 text-right text-sm font-bold text-brand-700 tabular-nums">
            {{ number_format(collect($items)->sum('amount'), 0, ',', '.') }}
        </td>
    </tr>
</tfoot>
```

**Catatan total:** hitung dari array `$items` saat render — otomatis ter-update karena `wire:model.live` memicu re-render server. (Untuk ribuan baris, pindah ke method `computed`.)

---

## 3. Checklist Komponen Detail (DoD)

| # | Aturan | ✓ |
|---|---|---|
| D1 | Semua relasi memakai `x-combobox` (searchable) — **dilarang** `<select>` panjang | |
| D2 | Kolom angka `text-right` + `tabular-nums` | |
| D3 | Kolom jumlah (Rp/qty) memakai `type=number step=0.01` + validasi `min:0` | |
| D4 | Tombol **Tambah Baris** (SVG `#mi-plus`) di atas tabel; **Hapus Baris** (SVG `#mi-trash`, merah) per baris | |
| D5 | Footer **TOTAL** rata kanan di kolom jumlah (cek `colspan` = jumlah kolom − 1) | |
| D6 | Validasi per baris `items.*.field` + pesan Bahasa Indonesia | |
| D7 | State awal: `mount()` memanggil `addItemRow()` minimal 1 baris | |
| D8 | Aksi simpan: validasi header + items → simpan di `DB::transaction` | |
| D9 | Ikon SVG (tanpa emoji), warna tombol sesuai standar (Edit=brand, Hapus=red) | |
| D10 | Responsif: `overflow-x-auto` pada pembungkus tabel | |

---

## 4. Aturan Teknis (Livewire)

- `public array $items = []` — state baris.
- `addItemRow(): void` — append baris default (`$this->items[] = ['pay_type_id' => '', 'amount' => 0, ...]`).
- `removeItemRow(int $index): void` — `unset` + `array_values` (normalisasi indeks!).
- Nama field dinamis wajib pakai **indeks loop** `{{ $i }}` dari `@foreach`, bukan `$loop->index` di beberapa tempat (konsisten).
- Saat simpan: `collect($this->items)->filter(fn($r) => $r['pay_type_id'])` → insert item (hapus-timpa: `delete()` lalu `createMany` — pola CashPaymentForm).

---

## 5. Penerapan Saat Ini (Pilot)

| Form | Status |
|---|---|
| `input-pos-biaya/create` (Detail Pos Biaya) | ✅ Standar (combobox + inline + total) |
| SPB / Transfer / PO / Opname | Mengikuti — migrate bertahap |

## 6. Acuan File
- Komponen: `resources/views/components/combobox.blade.php`
- Pilot: `app/Livewire/Payroll/CashPaymentForm.php` + `resources/views/livewire/payroll/cashpayment-form.blade.php`
- Dokumen terkait: `docs/REFACTOR-TABEL-FORM.md`, `docs/UAT-CHECKLIST-V2.md`
