# AgDatatable — Source of Truth

> **Scope:** Dokumen ini adalah referensi teknis definitif untuk seluruh implementasi tabel data (data grid) di proyek ini. Setiap halaman baru yang membutuhkan tabel server-side **wajib** menggunakan `AgDatatable` sesuai standar di sini.

---

## Daftar Isi

1. [Overview & Arsitektur](#1-overview--arsitektur)
2. [Dependency & Asset Registration](#2-dependency--asset-registration)
3. [Standardisasi Konfigurasi](#3-standardisasi-konfigurasi)
4. [State Persistence (LocalStorage)](#4-state-persistence-localstorage)
5. [Fitur-Fitur Utama & Contoh Kode](#5-fitur-fitur-utama--contoh-kode)
6. [Server Protocol (DataTables)](#6-server-protocol-datatables)
7. [Public API](#7-public-api)
8. [Best Practices](#8-best-practices)
9. [Boilerplate Siap Pakai](#9-boilerplate-siap-pakai)

---

## 1. Overview & Arsitektur

### Mengapa AgDatatable?

Proyek ini menggunakan **ag-Grid Community v31** sebagai engine rendering tabel karena:

- Performa tinggi pada dataset besar via **Infinite Row Model** (hanya baris yang terlihat yang di-render ke DOM).
- Native support untuk sort, filter, resize, reorder, dan pin kolom tanpa kode tambahan.
- API deterministik yang memudahkan persistensi state.

`AgDatatable` adalah **thin wrapper** di atas ag-Grid yang:

1. Mengimplementasikan **DataTables server-side protocol** (parameter `draw`, `start`, `length`, `columns[i]`, `order[0]`) agar kompatibel dengan `mimicreative/yii2-datatables` di sisi PHP.
2. Mengabstraksi boilerplate inisialisasi, pagination custom, persistensi state, dan export Excel ke satu titik (**`ag-datatable.js`**) sehingga setiap halaman hanya mendefinisikan `columnDefs` dan konfigurasi spesifiknya.
3. Menyediakan CSS standar via **`ag-datatable.css`** sehingga semua tabel memiliki tampilan yang konsisten.

### Alur Data

```
Browser                          Server (Yii2)
──────                           ──────────────
ag-Grid Infinite Datasource
  └─ getRows(params)
       └─ buildUrl()  ──────────► GET /module/datatables
                                   ?draw=N&start=0&length=15
                                   &search=...
                                   &columns[0][data]=name
                                   &columns[0][search][value]=john
                                   &order[0][column]=1&order[0][dir]=asc
                      ◄──────────  { draw, recordsTotal,
                                     recordsFiltered, data: [...] }
  └─ successCallback(rows, total)
       └─ updatePaginationInfo()
```

### File yang Relevan

| File | Deskripsi |
|------|-----------|
| `backend/web/js/ag-datatable.js` | Core library, IIFE module |
| `backend/web/css/ag-datatable.css` | Shared CSS (grid-container, pagination, cell helpers) |
| `backend/views/{module}/index.php` | View per halaman — hanya `columnDefs` + `AgDatatable.create()` |
| `backend/controllers/{Module}Controller.php` | PHP action yang menghandle request DataTables |

---

## 2. Dependency & Asset Registration

Daftarkan dependency berikut di setiap view yang menggunakan AgDatatable. Urutan registrasi penting: CSS sebelum JS.

```php
<?php
// ag-Grid CSS (theme wajib sebelum custom CSS)
$this->registerCssFile('https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-grid.css');
$this->registerCssFile('https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-theme-alpine.css');
$this->registerCssFile('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css');

// CSS shared (grid-container, pagination, cell helpers)
$this->registerCssFile('@web/css/ag-datatable.css');

// ag-Grid JS — harus sebelum ag-datatable.js
$this->registerJsFile(
    'https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/dist/ag-grid-community.min.js',
    ['depends' => [\yii\web\JqueryAsset::class]]
);
// XLSX (hanya jika halaman membutuhkan export Excel)
$this->registerJsFile(
    'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js',
    ['depends' => [\yii\web\JqueryAsset::class]]
);
// Core wrapper
$this->registerJsFile('@web/js/ag-datatable.js', ['depends' => [\yii\web\JqueryAsset::class]]);
?>
```

---

## 3. Standardisasi Konfigurasi

### `AgDatatable.create(cfg)` — Parameter Lengkap

| Parameter | Tipe | Wajib | Deskripsi |
|-----------|------|-------|-----------|
| `el` | `string\|Element` | ✅ | CSS selector atau DOM element untuk grid (`'#myGrid'`) |
| `url` | `string` | ✅ | URL endpoint DataTables PHP |
| `columnDefs` | `Array` | ✅ | Definisi kolom ag-Grid |
| `dtColumns` | `Array<{data}>` | ✅ | Mapping kolom ke DataTables protocol (diindeks berurutan) |
| `colFieldToIdx` | `Object` | ✅ | Map `field → dtColumns index` untuk sort & filter |
| `pageSize` | `number` | – | Default `15`. Ukuran halaman awal |
| `storageKey` | `string` | – | Key localStorage untuk persistensi state. `null` = nonaktif |
| `source` | `string` | – | Parameter `?source=` dikirim ke server (untuk multi-mode endpoint) |
| `exportConfig` | `Object` | – | Konfigurasi kolom export Excel (lihat §5) |
| `exportSheetName` | `string` | – | Nama sheet Excel, default `'Sheet1'` |
| `exportFilename` | `string` | – | Nama file tanpa ekstensi, default `'export'` |
| `pagination` | `Object` | – | Map ID elemen HTML pagination custom (lihat §5) |
| `defaultColDef` | `Object` | – | Override `defaultColDef` ag-Grid (di-merge dengan base) |
| `gridOptions` | `Object` | – | Override `gridOptions` ag-Grid (di-merge dengan base) |

### Default `gridOptions` yang Diaplikasikan Otomatis

Konfigurasi berikut diaktifkan oleh library tanpa perlu deklarasi ulang di setiap halaman:

```javascript
{
    rowModelType:                    'infinite',   // server-side infinite scroll
    cacheBlockSize:                  PAGE_SIZE,    // blok data per request
    cacheOverflowSize:               2,
    maxConcurrentDatasourceRequests: 1,
    infiniteInitialRowCount:         PAGE_SIZE,
    maxBlocksInCache:                10,
    pagination:                      true,
    paginationPageSize:              PAGE_SIZE,
    suppressPaginationPanel:         true,         // gunakan pagination HTML custom
    suppressColumnVirtualisation:    true,         // semua kolom di-render (export aman)
    initialState: { sort: { sortModel: [] } },     // tidak ada sort default saat load
}
```

### Default `defaultColDef` yang Diaplikasikan Otomatis

```javascript
{
    resizable:      true,
    sortable:       true,
    filter:         true,
    floatingFilter: false,
    lockPinned:     false,
    filterParams:   { filterOptions: ['contains'], maxNumConditions: 1 },
}
```

> **Catatan:** `filterOptions: ['contains']` berarti setiap kolom hanya menyediakan satu mode filter (pencarian substring). Dropdown pemilihan jenis filter disembunyikan. Ini adalah standar proyek.

---

## 4. State Persistence (LocalStorage)

### Apa yang Disimpan

Ketika `storageKey` diberikan, library secara otomatis menyimpan state berikut ke `localStorage`:

```json
{
    "columns": [ /* output gridApi.getColumnState() — posisi, lebar, pin, sort, hide */ ],
    "filters": { /* output gridApi.getFilterModel() — nilai filter per kolom */ }
}
```

### Kapan Disimpan

| Event ag-Grid | Trigger |
|---------------|---------|
| `onColumnMoved` | User menggeser kolom (reorder) |
| `onColumnResized` | User mengubah lebar kolom (hanya saat `e.finished === true`) |
| `onColumnPinned` | User men-pin/unpin kolom |
| `onSortChanged` | User mengklik header kolom untuk sort |
| `onFilterChanged` | User mengisi input filter header kolom |

### Kapan Direstore

Langsung setelah `agGrid.createGrid()` dan `setGridOption('datasource', ...)` dipanggil:

```
agGrid.createGrid()
  → setGridOption('datasource', dataSource)   // mulai fetch data
  → restoreColumnState()                      // apply state + filter dari localStorage
```

### Backward Compatibility

Format lama (sebelum filter disimpan) menyimpan state sebagai **plain Array**. Library mendeteksi format secara otomatis:

```javascript
var colState    = Array.isArray(parsed) ? parsed       : parsed.columns;
var filterState = Array.isArray(parsed) ? null         : parsed.filters;
```

### Menghapus State (Reset Manual)

Untuk mereset state tabel ke kondisi awal, hapus key dari localStorage:

```javascript
localStorage.removeItem('yourGridStorageKey');
location.reload();
```

---

## 5. Fitur-Fitur Utama & Contoh Kode

### 5.1 Definisi Kolom (`columnDefs`)

```javascript
var columnDefs = [
    // Kolom nomor urut — tidak sortable, tidak filterable
    {
        headerName: '#',
        field: 'id',
        width: 55,
        pinned: 'left',
        sortable: false,
        filter: false,
        suppressMenu: true,
        cellRenderer: function (p) {
            return p.data ? p.node.rowIndex + 1 : '<span style="color:#9ca3af;">...</span>';
        }
    },

    // Kolom teks biasa dengan flex width
    {
        headerName: 'Name',
        field: 'nama',
        minWidth: 180,
        flex: 1,
        pinned: 'left',
    },

    // Kolom dengan lebar fixed
    { headerName: 'Card No', field: 'card_no', width: 130 },

    // Kolom tanggal dengan filter placeholder
    {
        headerName: 'Birthday',
        field: 'birthday',
        width: 110,
        filterParams: {
            filterOptions: ['contains'],
            maxNumConditions: 1,
            filterPlaceholder: 'Format 2026-03-21'
        },
        cellRenderer: function (p) {
            return p.data ? (p.value ? AppFmt.date(p.value) : '-') : '...';
        }
    },

    // Kolom numerik — filter dinonaktifkan, rata kanan
    {
        headerName: 'Deposit',
        field: 'deposit',
        width: 140,
        filter: false,
        type: 'numericColumn',
        cellRenderer: function (p) {
            if (!p.data) return '...';
            return (p.value == null || p.value === '')
                ? '-'
                : '<span class="deposit-cell">' + AppFmt.currency(p.value) + '</span>';
        }
    },

    // Kolom Action — pin kanan, menu dan filter dinonaktifkan
    {
        headerName: 'Action',
        field: 'actions',
        width: 120,
        pinned: 'right',
        lockPinned: true,
        sortable: false,
        filter: false,
        suppressMenu: true,
        cellRenderer: function (p) {
            return p.data ? buildActionCell(p.value) : document.createElement('span');
        }
    },
];
```

#### Aturan Kolom

| Kondisi | Konfigurasi |
|---------|-------------|
| Kolom nomor urut (#) | `sortable: false`, `filter: false`, `suppressMenu: true` |
| Kolom Action | `sortable: false`, `filter: false`, `suppressMenu: true`, `lockPinned: true`, `pinned: 'right'` |
| Kolom yang tidak perlu filter | `filter: false` (menu kolom tetap tampil jika ada menu lain) |
| Kolom dengan filter tapi tanpa menu umum | defaultnya sudah benar via `defaultColDef` |
| Kolom angka | tambahkan `type: 'numericColumn'` untuk perataan kanan otomatis |

### 5.2 Penomoran Otomatis (Row Index)

Gunakan `p.node.rowIndex` yang disediakan ag-Grid. Nilai ini mencerminkan posisi absolut baris dalam infinite scroll, bukan index dalam halaman.

```javascript
{
    headerName: '#',
    field: 'id',
    width: 55,
    sortable: false,
    filter: false,
    suppressMenu: true,
    cellRenderer: function (p) {
        // p.data === undefined = baris placeholder (loading)
        return p.data
            ? p.node.rowIndex + 1
            : '<span style="color:#9ca3af;">...</span>';
    }
}
```

### 5.3 Custom Cell Renderer — Tombol Aksi

Cell renderer untuk kolom Action yang menampilkan link Edit dan tombol Delete dengan konfirmasi + CSRF:

```javascript
function buildActionCell(items) {
    var container = document.createElement('span');
    if (!items || !Array.isArray(items)) return container;

    var csrfParam = $('meta[name="csrf-param"]').attr('content') || '_csrf-backend';
    var csrfToken = $('meta[name="csrf-token"]').attr('content');

    items.forEach(function (item) {
        if (!item.label) return;

        // Separator (misal: karakter " | ")
        if (!item.url) {
            var sep = document.createElement('span');
            sep.className = 'action-sep';
            sep.textContent = item.label;
            container.appendChild(sep);
            return;
        }

        var dataMethod  = item.options && item.options['data-method'];
        var dataConfirm = item.options && item.options['data-confirm'];
        var a = document.createElement('a');
        a.className = 'action-link';
        a.textContent = item.label;

        if (dataMethod === 'post') {
            // Tombol Delete — POST dengan konfirmasi
            a.className += ' delete-link';
            a.href = 'javascript:void(0)';
            a.addEventListener('click', (function (url, msg, param, token) {
                return function (e) {
                    e.stopPropagation();
                    e.preventDefault();
                    if (!confirm(msg)) return;
                    var postData = {};
                    postData[param] = token;
                    $.post(url, postData, function (r) {
                        r.success ? tbl.refresh() : alert('Delete failed: ' + r.message);
                    }).fail(function (xhr) {
                        console.log('Delete failed (' + xhr.status + ').');
                    });
                };
            })(item.url, dataConfirm || 'Are you sure?', csrfParam, csrfToken));
        } else {
            // Link biasa (Edit, View, dll.)
            a.href = item.url;
        }

        container.appendChild(a);
    });

    return container;
}
```

### 5.4 Custom Cell Renderer — Badge Status

```javascript
{
    headerName: 'Status',
    field: 'status',
    width: 110,
    cellRenderer: function (p) {
        if (!p.data) return '...';
        var map = {
            'active':   ['Active',   '#dcfce7', '#16a34a'],
            'inactive': ['Inactive', '#fee2e2', '#dc2626'],
            'pending':  ['Pending',  '#fef9c3', '#ca8a04'],
        };
        var cfg = map[p.value] || [p.value, '#f3f4f6', '#6b7280'];
        return '<span style="'
            + 'display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;'
            + 'background:' + cfg[1] + ';color:' + cfg[2] + '">'
            + cfg[0] + '</span>';
    }
}
```

### 5.5 Pagination Custom (HTML)

HTML template wajib yang harus ada di view. ID elemen diserahkan ke `AgDatatable.create()` via objek `pagination`.

```html
<div class="pagination-info">
    <span>
        Showing <strong id="rowStart">0</strong>
        to <strong id="rowEnd">0</strong>
        of <strong id="totalRows">0</strong> items
    </span>
    <div class="pagination-nav">
        <label style="font-size:12px;color:#6b7280;margin:0;">Rows:&nbsp;</label>
        <select id="pageSizeSelect" class="pg-btn" style="padding:3px 6px;cursor:pointer;">
            <option value="15">15</option>
            <option value="50">50</option>
            <option value="100">100</option>
            <option value="250">250</option>
        </select>
        <button id="btnFirst" class="pg-btn" title="First Page"    disabled><i class="fas fa-angle-double-left"></i></button>
        <button id="btnPrev"  class="pg-btn" title="Previous Page" disabled><i class="fas fa-angle-left"></i></button>
        <span class="pg-label">Page <strong id="currentPage">1</strong> of <strong id="totalPages">1</strong></span>
        <button id="btnNext"  class="pg-btn" title="Next Page"     disabled><i class="fas fa-angle-right"></i></button>
        <button id="btnLast"  class="pg-btn" title="Last Page"     disabled><i class="fas fa-angle-double-right"></i></button>
    </div>
</div>
```

Objek `pagination` di `AgDatatable.create()`:

```javascript
pagination: {
    rowStart:      'rowStart',
    rowEnd:        'rowEnd',
    totalRows:     'totalRows',
    currentPage:   'currentPage',
    totalPages:    'totalPages',
    btnFirst:      'btnFirst',
    btnPrev:       'btnPrev',
    btnNext:       'btnNext',
    btnLast:       'btnLast',
    pageSizeSelect: 'pageSizeSelect',
    searchInput:   'searchInput',   // opsional, untuk Enter-key search
},
```

### 5.6 Export Excel

`exportConfig` mendefinisikan kolom apa saja yang bisa diekspor, headernya, lebar kolom Excel (`wch`), dan fungsi ekstraktor nilai dari object row data.

```javascript
exportConfig: {
    // key = colId (field name ag-Grid)
    'nama': {
        header: 'Name',
        wch: 25,    // karakter lebar kolom Excel
        get: function (d) {
            // d = object row dari server
            return Array.isArray(d.nama) && d.nama.length ? d.nama[0].label || '' : '';
        }
    },
    'card_no': { header: 'Card No',  wch: 15, get: function (d) { return d.card_no  || ''; } },
    'deposit':  { header: 'Deposit', wch: 15, get: function (d) { return parseFloat(d.deposit) || 0; } },
    // ... kolom lainnya
},
exportSheetName: 'NamaSheet',
exportFilename:  'NamaFile',   // hasil: NamaFile_2026-05-26.xlsx
```

> **Penting:** Kolom yang tidak ada di `exportConfig` (misal: kolom `#` dan `Action`) tidak akan ikut ter-export meski ditampilkan di grid. Export mengikuti **urutan kolom aktif di grid** saat tombol diklik (respects reorder & hide).

Trigger export dari HTML:

```html
<a href="javascript:void(0)" onclick="exportToExcel(event)">
    <i class="fas fa-download"></i> Export
</a>
```

```javascript
function exportToExcel(e) {
    tbl.exportExcel(e.target.closest('a,button'));
}
```

---

## 6. Server Protocol (DataTables)

### Request yang Dikirim

```
GET /module/datatables
  ?draw=1
  &start=0
  &length=15
  &source=my_source
  &search=keyword_global
  &columns[0][data]=name
  &columns[0][searchable]=true
  &columns[0][orderable]=true
  &columns[0][search][value]=per_column_filter
  &columns[0][search][regex]=false
  &columns[1][data]=card_no
  ...
  &order[0][column]=1
  &order[0][dir]=asc
```

### Response yang Diharapkan

```json
{
    "draw": 1,
    "recordsTotal": 1500,
    "recordsFiltered": 42,
    "data": [
        { "name": [...], "card_no": "...", "actions": [...], ... },
        ...
    ]
}
```

### Mapping `dtColumns` + `colFieldToIdx`

Dua parameter ini adalah **jembatan antara ag-Grid field dan DataTables column index**.

```javascript
dtColumns: [
    { data: 'name'     }, // index 0
    { data: 'card_no'  }, // index 1
    { data: 'birthday' }, // index 2
    { data: 'deposit'  }, // index 3
    { data: 'actions'  }, // index 4
],
colFieldToIdx: {
    'nama':    0,   // ag-Grid field 'nama' → dtColumns[0]
    'card_no': 1,
    'birthday':2,
    'deposit': 3,
    'actions': 4,
},
```

> **Aturan:** Urutan `dtColumns` harus konsisten dengan parameter `columns[i]` yang diterima PHP. `colFieldToIdx` memetakan nama field ag-Grid (yang bisa berbeda dari nama kolom SQL) ke index tersebut.

### Handler PHP (Yii2)

```php
// Di Controller
public function actions()
{
    return [
        'datatables' => [
            'class' => \mimicreative\datatables\actions\DataTableAction::class,
            'query' => function () { return $this->buildQuery(); },
            'applyFilter' => function ($query, $search, $columns) {
                $this->applyFilter($query, $search, $columns);
            },
            'formatData' => function ($models) { return $this->formatData($models); },
        ],
    ];
}

private function applyFilter($query, $search, $columns)
{
    // Global search (OR)
    if ($search) {
        $query->andFilterWhere(['or',
            ['like', 'name',    $search],
            ['like', 'card_no', $search],
        ]);
    }

    // Per-column filter dari header ag-Grid (AND)
    $skipCols = ['actions', 'deposit']; // kolom yang tidak di-filter
    foreach ($columns as $column) {
        if (empty($column['data'])) continue;
        if (in_array($column['data'], $skipCols)) continue;
        $val = $column['search']['value'] ?? '';
        if ($val === '') continue;
        $query->andFilterWhere(['like', $column['data'], $val]);
    }
}
```

---

## 7. Public API

Instance yang dikembalikan `AgDatatable.create()` memiliki method berikut:

| Method | Signature | Deskripsi |
|--------|-----------|-----------|
| `search` | `search(term: string)` | Set global search term dan reload data. Kosongkan dengan `search('')` |
| `refresh` | `refresh()` | Reload data dari server tanpa mengubah filter/sort |
| `exportExcel` | `exportExcel(btnEl?: Element)` | Fetch semua data (length=99999) lalu generate file `.xlsx`. `btnEl` menampilkan loading spinner selama proses |
| `getApi` | `getApi(): GridApi` | Mengembalikan raw ag-Grid `gridApi` untuk operasi lanjutan |

```javascript
var tbl = AgDatatable.create({ ... });

// Contoh penggunaan
tbl.search('john doe');          // filter global
tbl.refresh();                   // reload setelah CRUD
tbl.exportExcel(document.getElementById('btnExport'));
var api = tbl.getApi();          // akses langsung ke ag-Grid API
api.getSelectedRows();           // contoh operasi lanjutan
```

---

## 8. Best Practices

### Naming Convention

- **`storageKey`**: gunakan format `{module}GridColumnState`, contoh: `customerGridColumnState`, `productGridColumnState`.
- **`exportFilename`**: nama modul dalam PascalCase, contoh: `Customer`, `SalesReport`.

### Kolom yang Tidak Boleh Di-filter/Sort

Kolom `#` (nomor urut) dan `Action` **wajib** memiliki:

```javascript
{ sortable: false, filter: false, suppressMenu: true }
```

Tanpa `suppressMenu: true`, icon menu kolom tetap tampil meski filter dan sort dinonaktifkan.

### Kolom dengan Menu Pin tapi Tanpa Filter

Jika kolom butuh opsi pin (via menu) tapi tidak butuh filter, jangan set `filter: false`. Sebagai gantinya biarkan filter default aktif — user tidak bisa memfilter kolom yang datanya tidak didukung server, atau nonaktifkan lewat `filterParams` saja.

### cellRenderer: Selalu Handle State Loading

Setiap `cellRenderer` wajib menangani `p.data === undefined` (baris placeholder saat data belum tiba):

```javascript
cellRenderer: function (p) {
    if (!p.data) return '<span style="color:#9ca3af;">...</span>';
    return p.value || '-';
}
```

### Jangan Gunakan `params.endRow - params.startRow` untuk Length

Library sudah menggunakan variabel internal `PAGE_SIZE`. Jangan override dengan `params.endRow - params.startRow` karena nilainya akan selalu sama dengan `cacheBlockSize` (bukan ukuran halaman yang dipilih user).

### Format Angka & Tanggal

Selalu gunakan `AppFmt` untuk format angka dan tanggal di `cellRenderer` agar konsisten dengan setting cabang:

```javascript
AppFmt.currency(p.value)          // angka mata uang
AppFmt.qty(p.value)               // angka kuantitas
AppFmt.date(p.value)              // tanggal dari DB (Y-m-d)
AppFmt.datetime(p.value)          // datetime dari DB
```

---

## 9. Boilerplate Siap Pakai

Salin seluruh blok di bawah sebagai starting point untuk halaman baru. Ganti semua `{Module}` dan `{field}` sesuai konteks.

### PHP View (`views/{module}/index.php`)

```php
<?php
use yii\helpers\Url;
use yii\helpers\Html;

$this->params['breadcrumbs'][] = $this->title;

$this->registerCssFile('https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-grid.css');
$this->registerCssFile('https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-theme-alpine.css');
$this->registerCssFile('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css');
$this->registerCssFile('@web/css/ag-datatable.css');
$this->registerJsFile('https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/dist/ag-grid-community.min.js', ['depends' => [\yii\web\JqueryAsset::class]]);
$this->registerJsFile('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js', ['depends' => [\yii\web\JqueryAsset::class]]);
$this->registerJsFile('@web/js/ag-datatable.js', ['depends' => [\yii\web\JqueryAsset::class]]);

$datatablesUrl = Url::toRoute(['{module}/datatables']);
?>

<style>
    .{module}-container { padding: 12px; background: #f5f7fa; min-height: 100vh; }
    #{module}Grid { height: 600px; width: 100%; }
    @media (max-width: 768px) {
        .{module}-container { padding: 8px; }
        #{module}Grid { height: 450px; }
    }
</style>

<div class="{module}-container">
    <div class="grid-container">
        <div class="grid-header">
            <div class="grid-title">
                <div>
                    <?= Html::encode($this->title) ?>
                    <div class="grid-title-sub">Subtitle deskripsi singkat halaman ini</div>
                </div>
            </div>
            <div class="grid-actions">
                <div class="search-wrapper">
                    <input type="text" id="searchInput" placeholder="Search..." />
                    <button onclick="doSearch()"><i class="fas fa-search"></i></button>
                </div>
                <a href="javascript:void(0)" class="btn btn-sm btn-info" onclick="exportToExcel(event)" title="Export Excel">
                    <i class="fas fa-download"></i>
                </a>
                <a href="<?= Url::toRoute(['{module}/create']) ?>" class="btn btn-loader btn-sm btn-success" title="Create New">
                    <i class="fas fa-plus"></i>
                </a>
            </div>
        </div>

        <div id="{module}Grid" class="ag-theme-alpine"></div>

        <div class="pagination-info">
            <span>Showing <strong id="rowStart">0</strong> to <strong id="rowEnd">0</strong> of <strong id="totalRows">0</strong> items</span>
            <div class="pagination-nav">
                <label style="font-size:12px;color:#6b7280;margin:0;">Rows:&nbsp;</label>
                <select id="pageSizeSelect" class="pg-btn" style="padding:3px 6px;cursor:pointer;">
                    <option value="15">15</option>
                    <option value="50">50</option>
                    <option value="100">100</option>
                    <option value="250">250</option>
                </select>
                <button id="btnFirst" class="pg-btn" title="First Page"    disabled><i class="fas fa-angle-double-left"></i></button>
                <button id="btnPrev"  class="pg-btn" title="Previous Page" disabled><i class="fas fa-angle-left"></i></button>
                <span class="pg-label">Page <strong id="currentPage">1</strong> of <strong id="totalPages">1</strong></span>
                <button id="btnNext"  class="pg-btn" title="Next Page"     disabled><i class="fas fa-angle-right"></i></button>
                <button id="btnLast"  class="pg-btn" title="Last Page"     disabled><i class="fas fa-angle-double-right"></i></button>
            </div>
        </div>
    </div>
</div>

<?php
$js = <<<JS
// ── Cell renderers ────────────────────────────────────────────────────────────
function buildActionCell(items) {
    var container = document.createElement('span');
    if (!items || !Array.isArray(items)) return container;
    var csrfParam = \$('meta[name="csrf-param"]').attr('content') || '_csrf-backend';
    var csrfToken = \$('meta[name="csrf-token"]').attr('content');
    items.forEach(function (item) {
        if (!item.label) return;
        if (!item.url) {
            var sep = document.createElement('span');
            sep.className = 'action-sep';
            sep.textContent = item.label;
            container.appendChild(sep);
            return;
        }
        var dataMethod  = item.options && item.options['data-method'];
        var dataConfirm = item.options && item.options['data-confirm'];
        var a = document.createElement('a');
        a.className = 'action-link';
        a.textContent = item.label;
        if (dataMethod === 'post') {
            a.className += ' delete-link';
            a.href = 'javascript:void(0)';
            a.addEventListener('click', (function (url, msg, param, token) {
                return function (e) {
                    e.stopPropagation(); e.preventDefault();
                    if (!confirm(msg)) return;
                    var postData = {}; postData[param] = token;
                    \$.post(url, postData, function (r) {
                        r.success ? tbl.refresh() : alert('Delete failed: ' + r.message);
                    }).fail(function (xhr) { console.log('Delete failed (' + xhr.status + ').'); });
                };
            })(item.url, dataConfirm || 'Are you sure?', csrfParam, csrfToken));
        } else {
            a.href = item.url;
        }
        container.appendChild(a);
    });
    return container;
}

// ── Column definitions ────────────────────────────────────────────────────────
var loading = '<span style="color:#9ca3af;">...</span>';
var columnDefs = [
    { headerName: '#',      field: 'id',      width: 55,  pinned: 'left', sortable: false, filter: false, suppressMenu: true,
      cellRenderer: function (p) { return p.data ? p.node.rowIndex + 1 : loading; } },
    { headerName: 'Name',   field: 'name',    minWidth: 180, flex: 1,
      cellRenderer: function (p) { return p.data ? p.value || '-' : loading; } },
    { headerName: 'Field2', field: 'field2',  width: 130,
      cellRenderer: function (p) { return p.data ? p.value || '-' : loading; } },
    { headerName: 'Action', field: 'actions', width: 120, pinned: 'right', lockPinned: true,
      sortable: false, filter: false, suppressMenu: true,
      cellRenderer: function (p) { return p.data ? buildActionCell(p.value) : document.createElement('span'); } },
];

// ── Init AgDatatable ──────────────────────────────────────────────────────────
var tbl = AgDatatable.create({
    el:          '#{module}Grid',
    url:         '$datatablesUrl',
    storageKey:  '{module}GridColumnState',
    pageSize:    15,
    columnDefs:  columnDefs,
    dtColumns: [
        { data: 'name'    }, // 0
        { data: 'field2'  }, // 1
        { data: 'actions' }, // 2
    ],
    colFieldToIdx: {
        'name':    0,
        'field2':  1,
        'actions': 2,
    },
    exportConfig: {
        'name':   { header: 'Name',   wch: 25, get: function (d) { return d.name   || ''; } },
        'field2': { header: 'Field2', wch: 20, get: function (d) { return d.field2 || ''; } },
    },
    exportSheetName: '{Module}',
    exportFilename:  '{Module}',
    pagination: {
        rowStart: 'rowStart', rowEnd: 'rowEnd', totalRows: 'totalRows',
        currentPage: 'currentPage', totalPages: 'totalPages',
        btnFirst: 'btnFirst', btnPrev: 'btnPrev', btnNext: 'btnNext', btnLast: 'btnLast',
        pageSizeSelect: 'pageSizeSelect',
        searchInput: 'searchInput',
    },
});

function doSearch()      { tbl.search(document.getElementById('searchInput').value); }
function exportToExcel(e){ tbl.exportExcel(e.target.closest('a,button')); }
window.doSearch      = doSearch;
window.exportToExcel = exportToExcel;
JS;

$this->registerJs($js, \yii\web\View::POS_READY);
?>
```

### PHP Controller (`controllers/{Module}Controller.php`)

```php
public function actions()
{
    return [
        'datatables' => [
            'class' => \mimicreative\datatables\actions\DataTableAction::class,
            'query' => function () { return $this->buildQuery(); },
            'applyFilter' => function ($query, $search, $columns) {
                $this->applyFilter($query, $search, $columns);
            },
            'formatData' => function ($models) { return $this->formatData($models); },
        ],
    ];
}

private function buildQuery(): \yii\db\ActiveQuery
{
    return Module::find()
        ->select(['id', 'name', 'field2'])
        ->asArray();
}

private function applyFilter($query, string $search, array $columns): void
{
    // Global search
    if ($search) {
        $query->andFilterWhere(['or',
            ['like', 'name',   $search],
            ['like', 'field2', $search],
        ]);
    }

    // Per-column filter (AND)
    $skipCols = ['actions'];
    foreach ($columns as $column) {
        if (empty($column['data']) || in_array($column['data'], $skipCols)) continue;
        $val = $column['search']['value'] ?? '';
        if ($val === '') continue;
        $query->andFilterWhere(['like', $column['data'], $val]);
    }
}

private function formatData(array $models): array
{
    return array_map(function ($m) {
        return [
            'id'      => $m['id'],
            'name'    => $m['name'],
            'field2'  => $m['field2'],
            'actions' => \yii\helpers\Html::a('Edit',   ['{module}/update', 'id' => $m['id']]) . ' '
                       . \yii\helpers\Html::a('Delete', ['{module}/delete', 'id' => $m['id']], [
                           'data-method'  => 'post',
                           'data-confirm' => 'Delete this record?',
                       ]),
        ];
    }, $models);
}
```

---

*Dokumen ini di-generate berdasarkan implementasi aktual di `backend/web/js/ag-datatable.js` dan `backend/views/customer/index.php`. Perbarui dokumen ini setiap kali ada perubahan arsitektur pada AgDatatable.*
