Last active
October 9, 2025 06:14
-
-
Save nfaiz/099a51c2fc17985a0f23807e233e2718 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> | |
| <meta name="description" content="" /> | |
| <meta name="author" content="" /> | |
| <title><?= config('MyConfig')->siteTitle; ?>::<?= esc($pageTitle ?? 'List') ?></title> | |
| <!-- Favicon--> | |
| <link rel="icon" type="image/x-icon" href="assets/favicon.ico" /> | |
| <!-- Core theme CSS (includes Bootstrap)--> | |
| <link href="<?= base_url('assets/css/styles.css') ?>" rel="stylesheet" /> | |
| <?= $this->renderSection('source_header') ?> | |
| <?= $this->renderSection('script_header') ?> | |
| </head> | |
| <body> | |
| <div class="d-flex" id="wrapper"> | |
| <?php //= $this->include('template/sidebar') ?> | |
| <?= view_cell('BootstrapCell::sidebar') ?> | |
| <!-- Page content wrapper--> | |
| <div id="page-content-wrapper"> | |
| <!-- Top navigation--> | |
| <?= $this->include('template/navbar') ?> | |
| <!-- Page content--> | |
| <div class="container-fluid"> | |
| <p> | |
| <?= $this->renderSection('content') ?> | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Bootstrap core JS--> | |
| <script src="<?= base_url('assets/js/bootstrap.bundle.min.js') ?>"></script> | |
| <script src="<?= base_url('assets/js/scripts.js') ?>"></script> | |
| <?= $this->renderSection('source_footer') ?> | |
| <?= $this->renderSection('script_footer') ?> | |
| <!-- Core theme JS--> | |
| </body> | |
| </html> | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace App\Controllers\Report; | |
| use App\Controllers\BaseController; | |
| use App\Libraries\RowFormatter; | |
| use CodeIgniter\HTTP\IncomingRequest; | |
| class CustomerController extends BaseController | |
| { | |
| use \App\Traits\DatatableTrait; | |
| public function index() | |
| { | |
| // ordered columns map key => type | |
| $columns = [ | |
| 'id' => 'number', | |
| 'name' => 'text', | |
| 'phone' => 'text', | |
| 'address' => 'text', | |
| 'state_name' => 'text', | |
| 'customer_created_at' => 'datetime', | |
| 'actions' => 'actions' | |
| ]; | |
| // Which DataTables Buttons to enable (subset of ['copy','csv','excel','print']) | |
| // Controller can change this per page; passed to view as array | |
| $buttons = ['copy','csv','excel','print']; | |
| return view('report/customer/index', [ | |
| 'pageTitle' => 'Customers', | |
| 'columns' => $columns, | |
| 'ajaxUrlBuilder' => site_url('report/customer/ajaxBuilder'), | |
| 'ajaxUrlRaw' => site_url('report/customer/ajaxRaw'), | |
| 'ajaxUrlDelete' => site_url('report/customer/delete'), | |
| 'ajaxUrlView' => site_url('customers/show'), // example | |
| 'ajaxUrlEdit' => site_url('customers/edit'), // example | |
| 'dtButtons' => $buttons | |
| ]); | |
| } | |
| public function ajaxBuilder() | |
| { | |
| /** @var IncomingRequest $request */ | |
| $request = service('request'); | |
| $db = db_connect(); | |
| // Build BaseBuilder | |
| $builder = $db->table('customers AS customers') | |
| ->select(" | |
| customers.id, | |
| customers.name, | |
| customers.phone, | |
| customers.address, | |
| states.name, | |
| customers.created_at | |
| ") | |
| ->join('states', 'states.id = customers.state_id', 'left'); | |
| //$builder->like('states.name', 'Joh', 'after'); | |
| //$builder->where('customers.id >', 1); | |
| $columnsConfig = [ | |
| ['data' => 'id', 'db' => 'customers.id', 'type' => 'number'], | |
| ['data' => 'name', 'db' => 'customers.name', 'type' => 'text'], | |
| ['data' => 'phone', 'db' => 'customers.phone', 'type' => 'text'], | |
| ['data' => 'address', 'db' => 'customers.address', 'type' => 'text'], | |
| ['data' => 'state_name', 'db' => 'states.name', 'type' => 'text'], | |
| ['data' => 'customer_created_at', 'db' => 'customers.created_at', 'type' => 'datetime'], | |
| ['data' => 'actions', 'db' => "' ' AS actions", 'type' => 'actions'], | |
| ]; | |
| $rules = [ | |
| 'customer_created_at' => 'date:d M Y H:i', | |
| 'phone' => 'phone_mask', | |
| 'actions' => [ | |
| [ | |
| 'label' => '<i class="bi bi-eye"></i> View', | |
| 'url' => site_url('customer/show/{id}'), | |
| 'class' => 'btn btn-sm btn-primary', | |
| 'attrs' => [], | |
| 'escape_label' => false | |
| ], | |
| [ | |
| 'label' => '<i class="bi bi-pencil-square"></i> Edit', | |
| 'url' => site_url('customer/edit/{id}'), | |
| 'class' => 'btn btn-sm btn-secondary', | |
| 'attrs' => [], | |
| 'escape_label' => false | |
| ], | |
| [ | |
| 'label' => '<i class="bi bi-trash"></i> Delete', | |
| 'url' => '#', | |
| 'class' => 'btn btn-sm btn-danger', | |
| 'attrs' => ['data-action' => 'ajax-delete', 'data-id' => '{id}', 'data-name' => '{name}'], | |
| 'escape_label' => false | |
| ], | |
| ], | |
| 'actions_wrapper' => 'dropdown' | |
| ]; | |
| $formatter = new RowFormatter($columnsConfig, $rules); | |
| $options = [ | |
| 'onRow' => $formatter | |
| ]; | |
| $result = $this->datatableReport($request, $builder, $columnsConfig, $options); | |
| return $this->response->setJSON($result); | |
| } | |
| public function ajaxRaw() | |
| { | |
| $request = service('request'); | |
| $sql = " | |
| SELECT | |
| customers.id, | |
| customers.name, | |
| customers.phone, | |
| customers.address, | |
| states.name, | |
| customers.created_at | |
| FROM customers | |
| LEFT JOIN states ON states.id = customers.state_id | |
| "; | |
| //WHERE states.name like ? AND customers.id > ? | |
| //$bindings = ['Joh%', 10]; | |
| $bindings = []; | |
| $columnsConfig = [ | |
| ['data' => 'id', 'db' => 'customers.id', 'type' => 'number'], | |
| ['data' => 'name', 'db' => 'customers.name', 'type' => 'text'], | |
| ['data' => 'phone', 'db' => 'customers.phone', 'type' => 'text'], | |
| ['data' => 'address', 'db' => 'customers.address', 'type' => 'text'], | |
| ['data' => 'state_name', 'db' => 'states.name', 'type' => 'text'], | |
| ['data' => 'customer_created_at', 'db' => 'customers.created_at', 'type' => 'datetime'], | |
| ['data' => 'actions', 'db' => "' ' AS actions", 'type' => 'actions'], | |
| ]; | |
| $rules = [ | |
| 'customer_created_at' => 'date:d M Y H:i', | |
| 'phone' => 'phone_mask', | |
| 'actions' => 'default', | |
| 'actions_default' => [ | |
| [ | |
| 'label' => '<i class="bi bi-eye"></i> View', | |
| 'url' => site_url('customers/show/{id}'), | |
| 'class' => 'btn btn-sm btn-primary', | |
| 'attrs' => [], | |
| 'escape_label' => false | |
| ], | |
| [ | |
| 'label' => '<i class="bi bi-pencil-square"></i> Edit', | |
| 'url' => site_url('customers/edit/{id}'), | |
| 'class' => 'btn btn-sm btn-secondary', | |
| 'attrs' => [], | |
| 'escape_label' => false | |
| ], | |
| [ | |
| 'label' => '<i class="bi bi-trash"></i> Delete', | |
| 'url' => '#', | |
| 'class' => 'btn btn-sm btn-danger', | |
| 'attrs' => ['data-action' => 'ajax-delete', 'data-id' => '{id}', 'data-name' => '{name}'], | |
| 'escape_label' => false | |
| ], | |
| ], | |
| 'actions_wrapper' => 'group' | |
| ]; | |
| $formatter = new RowFormatter($columnsConfig, $rules); | |
| $options = [ | |
| 'bindings' => $bindings, | |
| 'onRow' => $formatter | |
| ]; | |
| $result = $this->datatableReport($request, $sql, $columnsConfig, $options); | |
| return $this->response->setJSON($result); | |
| } | |
| public function delete() | |
| { | |
| $request = service('request'); | |
| $id = $request->getPost('id'); | |
| if (empty($id) || !is_numeric($id)) { | |
| return $this->response->setJSON(['success' => false, 'message' => 'Invalid id']); | |
| } | |
| // TODO: enforce authorization & CSRF (CI4 can enforce CSRF globally) | |
| $db = db_connect(); | |
| $builder = $db->table('customers'); | |
| $exists = (bool) $builder->where('id', (int)$id)->countAllResults(false); | |
| if (! $exists) { | |
| return $this->response->setJSON(['success' => false, 'message' => 'Not found']); | |
| } | |
| $deleted = $builder->where('id', (int)$id)->delete(); | |
| if ($deleted) { | |
| return $this->response->setJSON(['success' => true, 'message' => 'Deleted']); | |
| } | |
| return $this->response->setJSON(['success' => false, 'message' => 'Delete failed']); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace App\Traits; | |
| use CodeIgniter\Database\BaseBuilder; | |
| use CodeIgniter\HTTP\IncomingRequest; | |
| use Config\Database; | |
| trait DatatableTrait | |
| { | |
| public function datatableReport(IncomingRequest $request, $queryOrRaw, array $columnsConfig, array $options = []): array | |
| { | |
| $db = Database::connect(); | |
| // DataTables params | |
| $draw = (int) ($request->getPost('draw') ?? 0); | |
| $start = (int) ($request->getPost('start') ?? 0); | |
| $length = (int) ($request->getPost('length') ?? 10); | |
| $globalSearch = (string) ($request->getPost('search')['value'] ?? ''); | |
| $orderArr = $request->getPost('order') ?? []; | |
| $columnsPosted = $request->getPost('columns') ?? []; | |
| if (empty($columnsConfig) || !is_array($columnsConfig)) { | |
| throw new \InvalidArgumentException('columnsConfig required'); | |
| } | |
| // Build helper maps | |
| $dataKeys = array_map(fn($c) => $c['data'], $columnsConfig); | |
| $dataToDb = []; | |
| $typeMap = []; | |
| foreach ($columnsConfig as $c) { | |
| $k = $c['data']; | |
| $dataToDb[$k] = $c['db'] ?? $k; | |
| $typeMap[$k] = $c['type'] ?? 'text'; | |
| } | |
| $searchable = $options['search_columns'] ?? array_values(array_filter($dataKeys, fn($k) => in_array($typeMap[$k], ['text','raw','date','datetime','number','float','currency']))); | |
| $orderable = $options['orderable'] ?? $dataKeys; | |
| $defaultOrder = $options['default_order'] ?? null; | |
| $onRowSpec = $options['onRow'] ?? null; | |
| $onRowCallable = $this->resolveOnRowCallable($onRowSpec); | |
| // If Query Builder is provided, use builder flow (no getBindings) | |
| if ($queryOrRaw instanceof BaseBuilder) { | |
| /** @var BaseBuilder $builder */ | |
| $builder = $queryOrRaw; | |
| // recordsTotal: by default count on the builder as-is (controller may pass original_builder in options to override) | |
| if (!empty($options['original_builder']) && $options['original_builder'] instanceof BaseBuilder) { | |
| $totalBuilder = clone $options['original_builder']; | |
| // Ensure no limit/order when counting | |
| $recordsTotal = (int) $totalBuilder->countAllResults(false); | |
| } else { | |
| $totalBuilder = clone $builder; | |
| $recordsTotal = (int) $totalBuilder->countAllResults(false); | |
| } | |
| // Apply search: per-column and global (builder->like / orLike) | |
| if (!empty($columnsPosted)) { | |
| // per-column search | |
| foreach ($columnsPosted as $idx => $colPost) { | |
| $colSearch = $colPost['search']['value'] ?? ''; | |
| if ($colSearch === '') continue; | |
| $dataKey = $dataKeys[$idx] ?? ($colPost['data'] ?? null); | |
| if (!$dataKey) continue; | |
| if (!in_array($dataKey, $searchable, true)) continue; | |
| $dbExpr = $dataToDb[$dataKey] ?? $dataKey; | |
| // Use where with LIKE via builder; use escapeLikeString to be safe | |
| $builder->groupStart(); | |
| $builder->like($dbExpr, $colSearch); | |
| $builder->groupEnd(); | |
| } | |
| } | |
| if ($globalSearch !== '' && !empty($searchable)) { | |
| $builder->groupStart(); | |
| $first = true; | |
| foreach ($searchable as $dataKey) { | |
| $dbExpr = $dataToDb[$dataKey] ?? $dataKey; | |
| if ($first) { | |
| $builder->like($dbExpr, $globalSearch); | |
| $first = false; | |
| } else { | |
| $builder->orLike($dbExpr, $globalSearch); | |
| } | |
| } | |
| $builder->groupEnd(); | |
| } | |
| // recordsFiltered: count with the builder that now includes search filters | |
| $filteredBuilder = clone $builder; | |
| $recordsFiltered = (int) $filteredBuilder->countAllResults(false); | |
| // Ordering: map posted order to builder->orderBy | |
| if (!empty($orderArr)) { | |
| foreach ($orderArr as $ord) { | |
| $colIndex = (int) ($ord['column'] ?? 0); | |
| $dir = strtoupper($ord['dir'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC'; | |
| $dataKey = $dataKeys[$colIndex] ?? null; | |
| if (!$dataKey) continue; | |
| if (!in_array($dataKey, $orderable, true)) continue; | |
| $dbExpr = $dataToDb[$dataKey] ?? $dataKey; | |
| // If dbExpr contains AS alias, try to order by alias | |
| if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) { | |
| $orderCol = $m[2]; | |
| } else { | |
| $orderCol = $dbExpr; | |
| } | |
| $builder->orderBy($orderCol, $dir); | |
| } | |
| } elseif (!empty($defaultOrder['db'])) { | |
| $builder->orderBy($defaultOrder['db'], $defaultOrder['dir'] ?? 'DESC'); | |
| } | |
| // Limit / Offset | |
| if ($length != -1) { | |
| $builder->limit(intval($length), intval($start)); | |
| } | |
| // Execute and fetch rows | |
| $query = $builder->get(); | |
| $rows = $query->getResultArray(); | |
| // Map and onRow | |
| $outData = []; | |
| foreach ($rows as $rawRow) { | |
| $outRow = []; | |
| foreach ($columnsConfig as $colDef) { | |
| $key = $colDef['data']; | |
| if (array_key_exists($key, $rawRow)) { | |
| $outRow[$key] = $rawRow[$key]; | |
| continue; | |
| } | |
| // fallback alias resolution | |
| $dbExpr = $colDef['db'] ?? $key; | |
| $alias = null; | |
| if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) { | |
| $alias = $m[2]; | |
| } else { | |
| $parts = preg_split('/\s+/', $dbExpr); | |
| $last = end($parts); | |
| if (strpos($last, '.') !== false) { | |
| $p = explode('.', $last); | |
| $alias = end($p); | |
| } else { | |
| $alias = $last; | |
| } | |
| } | |
| $alt = str_replace('.', '_', $alias); | |
| if (array_key_exists($alias, $rawRow)) { | |
| $outRow[$key] = $rawRow[$alias]; | |
| } elseif (array_key_exists($alt, $rawRow)) { | |
| $outRow[$key] = $rawRow[$alt]; | |
| } else { | |
| $outRow[$key] = null; | |
| } | |
| } | |
| if (is_callable($onRowCallable)) { | |
| $res = $onRowCallable($outRow, $rawRow); | |
| if (is_array($res)) $outRow = $res; | |
| } | |
| $outData[] = $outRow; | |
| } | |
| return [ | |
| 'draw' => $draw, | |
| 'recordsTotal' => $recordsTotal, | |
| 'recordsFiltered' => $recordsFiltered, | |
| 'data' => $outData, | |
| ]; | |
| } | |
| // ---------- RAW SQL FLOW ---------- | |
| // $queryOrRaw expected to be raw SQL string (no trailing ;) and options['bindings'] has values | |
| $rawSql = (string) $queryOrRaw; | |
| $bindings = $options['bindings'] ?? []; | |
| // Build WHERE parts: per-column and global search (use ? placeholders) | |
| $whereParts = []; | |
| $whereBindings = []; | |
| foreach ($columnsPosted as $idx => $colPost) { | |
| $colSearch = $colPost['search']['value'] ?? ''; | |
| if ($colSearch === '') continue; | |
| $dataKey = $dataKeys[$idx] ?? ($colPost['data'] ?? null); | |
| if (!$dataKey) continue; | |
| if (!in_array($dataKey, $searchable, true)) continue; | |
| $dbExpr = $dataToDb[$dataKey] ?? $dataKey; | |
| $whereParts[] = "{$dbExpr} LIKE ?"; | |
| $whereBindings[] = '%' . $db->escapeLikeString($colSearch) . '%'; | |
| } | |
| if ($globalSearch !== '') { | |
| $gsParts = []; | |
| foreach ($searchable as $dataKey) { | |
| $dbExpr = $dataToDb[$dataKey] ?? $dataKey; | |
| $gsParts[] = "{$dbExpr} LIKE ?"; | |
| $whereBindings[] = '%' . $db->escapeLikeString($globalSearch) . '%'; | |
| } | |
| if (!empty($gsParts)) $whereParts[] = '(' . implode(' OR ', $gsParts) . ')'; | |
| } | |
| if (!empty($whereParts)) { | |
| $whereSql = implode(' AND ', $whereParts); | |
| if (stripos($rawSql, ' where ') !== false) { | |
| $rawSql .= ' AND ' . $whereSql; | |
| } else { | |
| $rawSql .= ' WHERE ' . $whereSql; | |
| } | |
| $bindings = array_merge($bindings, $whereBindings); | |
| } | |
| // recordsTotal | |
| if (!empty($options['count_sql'])) { | |
| $countTotalSql = $options['count_sql']; | |
| $recordsTotal = (int) ($db->query($countTotalSql, $options['bindings'] ?? [])->getRow()->cnt ?? 0); | |
| } else { | |
| $countTotalSql = "SELECT COUNT(*) AS cnt FROM ({$queryOrRaw}) AS dt_total"; | |
| $recordsTotal = (int) ($db->query($countTotalSql, $options['bindings'] ?? [])->getRow()->cnt ?? 0); | |
| } | |
| // recordsFiltered | |
| $countFilteredSql = "SELECT COUNT(*) AS cnt FROM ({$rawSql}) AS dt_filtered"; | |
| $recordsFiltered = (int) ($db->query($countFilteredSql, $bindings)->getRow()->cnt ?? 0); | |
| // Ordering | |
| $orderSqlParts = []; | |
| foreach ($orderArr as $ord) { | |
| $colIndex = (int) ($ord['column'] ?? 0); | |
| $dir = strtoupper($ord['dir'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC'; | |
| $dataKey = $dataKeys[$colIndex] ?? null; | |
| if (!$dataKey) continue; | |
| $dbExpr = $dataToDb[$dataKey] ?? $dataKey; | |
| if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) { | |
| $orderCol = $m[2]; | |
| } else { | |
| if (strpos($dbExpr, '.') !== false) { | |
| $parts = explode('.', $dbExpr); | |
| $orderCol = end($parts); | |
| } else { | |
| $orderCol = $dbExpr; | |
| } | |
| } | |
| if (in_array($dataKey, $orderable, true) || in_array($orderCol, $orderable, true)) { | |
| $orderSqlParts[] = "{$orderCol} {$dir}"; | |
| } | |
| } | |
| if (empty($orderSqlParts) && !empty($defaultOrder['db'])) { | |
| $orderSqlParts[] = ($defaultOrder['db'] . ' ' . ($defaultOrder['dir'] ?? 'DESC')); | |
| } | |
| if (!empty($orderSqlParts)) $rawSql .= ' ORDER BY ' . implode(', ', $orderSqlParts); | |
| if ($length != -1) { | |
| $rawSql .= ' LIMIT ' . intval($length) . ' OFFSET ' . intval($start); | |
| } | |
| $rows = $db->query($rawSql, $bindings)->getResultArray(); | |
| $outData = []; | |
| foreach ($rows as $rawRow) { | |
| $outRow = []; | |
| foreach ($columnsConfig as $colDef) { | |
| $key = $colDef['data']; | |
| if (array_key_exists($key, $rawRow)) { | |
| $outRow[$key] = $rawRow[$key]; | |
| continue; | |
| } | |
| $dbExpr = $colDef['db'] ?? $key; | |
| $alias = null; | |
| if (preg_match('/\s+AS\s+([`"]?)([a-zA-Z0-9_]+)\1$/i', $dbExpr, $m)) { | |
| $alias = $m[2]; | |
| } else { | |
| $parts = preg_split('/\s+/', $dbExpr); | |
| $last = end($parts); | |
| if (strpos($last, '.') !== false) { | |
| $p = explode('.', $last); | |
| $alias = end($p); | |
| } else { | |
| $alias = $last; | |
| } | |
| } | |
| $alt = str_replace('.', '_', $alias); | |
| if (array_key_exists($alias, $rawRow)) { | |
| $outRow[$key] = $rawRow[$alias]; | |
| } elseif (array_key_exists($alt, $rawRow)) { | |
| $outRow[$key] = $rawRow[$alt]; | |
| } else { | |
| $outRow[$key] = null; | |
| } | |
| } | |
| if (is_callable($onRowCallable)) { | |
| $res = $onRowCallable($outRow, $rawRow); | |
| if (is_array($res)) $outRow = $res; | |
| } | |
| $outData[] = $outRow; | |
| } | |
| return [ | |
| 'draw' => $draw, | |
| 'recordsTotal' => $recordsTotal, | |
| 'recordsFiltered' => $recordsFiltered, | |
| 'data' => $outData, | |
| ]; | |
| } | |
| protected function resolveOnRowCallable($spec): ?callable | |
| { | |
| if (empty($spec)) return null; | |
| if (is_callable($spec)) return $spec; | |
| if (is_object($spec)) { | |
| if (method_exists($spec, 'handle')) { | |
| return function($out, $raw) use ($spec) { return $spec->handle($out, $raw); }; | |
| } | |
| return null; | |
| } | |
| if (is_array($spec) && isset($spec['class'])) { | |
| $class = $spec['class']; | |
| $method = $spec['method'] ?? 'handle'; | |
| return function($out, $raw) use ($class, $method) { | |
| $inst = new $class(); | |
| return $inst->{$method}($out, $raw); | |
| }; | |
| } | |
| if (is_string($spec) && class_exists($spec)) { | |
| return function($out, $raw) use ($spec) { | |
| $inst = new $spec(); | |
| if (method_exists($inst, 'handle')) { | |
| return $inst->handle($out, $raw); | |
| } | |
| return $out; | |
| }; | |
| } | |
| return null; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?= $this->extend('template/bootstrap2') ?> | |
| <?php | |
| /** Generic DataTable view | |
| * Expects: | |
| * - $pageTitle | |
| * - $columns (ordered associative key => type) | |
| * - $ajaxUrlBuilder, $ajaxUrlRaw, $ajaxUrlDelete | |
| * - $dtButtons (array of button names e.g. ['copy','csv','excel','print']) | |
| */ | |
| ?> | |
| <?= $this->section('source_header') ?> | |
| <link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css"> | |
| <link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.1/css/buttons.bootstrap5.min.css"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css"> | |
| <?= $this->endSection() ?> | |
| <?= $this->section('script_header') ?> | |
| <style>.dt-actions { white-space: nowrap; }</style> | |
| <?= $this->endSection() ?> | |
| <?= $this->section('content') ?> | |
| <h3><?= esc($pageTitle ?? 'List') ?></h3> | |
| <div class="mb-3"> | |
| <button id="btn-builder" class="btn btn-sm btn-primary">Builder Mode</button> | |
| <button id="btn-raw" class="btn btn-sm btn-secondary">Raw Mode</button> | |
| </div> | |
| <table id="list" class="table table-striped" style="width:100%"> | |
| <thead> | |
| <tr> | |
| <?php if (empty($columns) || !is_array($columns)): ?> | |
| <th>ID</th><th>Name</th><th>Actions</th> | |
| <?php else: foreach ($columns as $k => $t): ?> | |
| <th><?= esc(ucwords(str_replace(['_','.'], ' ', $k))) ?></th> | |
| <?php endforeach; endif; ?> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| <?= $this->endSection() ?> | |
| <?= $this->section('source_footer') ?> | |
| <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> | |
| <script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script> | |
| <script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script> | |
| <!-- Buttons extension --> | |
| <script src="https://cdn.datatables.net/buttons/2.4.1/js/dataTables.buttons.min.js"></script> | |
| <script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.bootstrap5.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> | |
| <script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.html5.min.js"></script> | |
| <script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.print.min.js"></script> | |
| <?= $this->endSection() ?> | |
| <?= $this->section('script_footer') ?> | |
| <script> | |
| (function(){ | |
| const csrfName = $('meta[name="csrf-name"]').attr('content'); | |
| const csrfHash = $('meta[name="csrf-hash"]').attr('content'); | |
| const urlBuilder = <?= json_encode($ajaxUrlBuilder) ?>; | |
| const urlRaw = <?= json_encode($ajaxUrlRaw) ?>; | |
| const urlDelete = <?= json_encode($ajaxUrlDelete) ?>; | |
| const dtButtons = <?= json_encode($dtButtons ?? ['copy','csv','excel','print']) ?>; | |
| const columnKeys = <?= json_encode(array_keys($columns)) ?>; | |
| const dtColumns = columnKeys.map(k => { | |
| if (k === 'actions') return { data: k, name: k, orderable: false, searchable: false, className: 'dt-actions' }; | |
| return { data: k, name: k, orderable: true, searchable: true }; | |
| }); | |
| let table; | |
| function initTable(mode) { | |
| const ajaxUrl = mode === 'raw' ? urlRaw : urlBuilder; | |
| if ($.fn.dataTable.isDataTable('#list')) { | |
| $('#list').DataTable().destroy(); | |
| $('#list tbody').empty(); | |
| } | |
| // Build buttons array for DataTables | |
| const buttons = dtButtons.map(b => { | |
| if (b === 'excel') return { extend: 'excelHtml5', text: 'Excel' }; | |
| if (b === 'csv') return { extend: 'csvHtml5', text: 'CSV' }; | |
| if (b === 'print') return { extend: 'print', text: 'Print' }; | |
| if (b === 'copy') return { extend: 'copy', text: 'Copy' }; | |
| return null; | |
| }).filter(x => x !== null); | |
| table = $('#list').DataTable({ | |
| processing: true, | |
| serverSide: true, | |
| ajax: { | |
| url: ajaxUrl, | |
| type: 'POST', | |
| data: function(d) { | |
| d.mode = mode; | |
| d[csrfName] = csrfHash; | |
| }, | |
| dataSrc: function(json) { return json.data || []; } | |
| }, | |
| columns: dtColumns, | |
| order: [[0,'desc']], | |
| lengthMenu: [10,25,50], | |
| pageLength: 10, | |
| dom: buttons.length ? 'Bfrtip' : 'frtip', | |
| buttons: buttons | |
| }); | |
| } | |
| $(function(){ | |
| initTable('builder'); | |
| $('#btn-builder').on('click', () => initTable('builder')); | |
| $('#btn-raw').on('click', () => initTable('raw')); | |
| // Delegated delete action handler | |
| $('#list').on('click', '[data-action="ajax-delete"]', function(e){ | |
| e.preventDefault(); | |
| const $el = $(this); | |
| const id = $el.attr('data-id'); | |
| const name = $el.attr('data-name') || ''; | |
| if (!id) { alert('Missing id'); return; } | |
| if (!confirm(`Delete "${name}" (id: ${id})?`)) return; | |
| const payload = {}; | |
| payload[csrfName] = csrfHash; | |
| payload.id = id; | |
| $.post(urlDelete, payload, function(resp){ | |
| if (resp && resp.success) { | |
| $('#list').DataTable().ajax.reload(null, false); | |
| alert(resp.message || 'Deleted'); | |
| } else { | |
| alert(resp.message || 'Delete failed'); | |
| } | |
| }, 'json').fail(function(xhr){ | |
| alert('Request failed: ' + (xhr.responseJSON?.message ?? xhr.statusText)); | |
| }); | |
| }); | |
| }); | |
| })(); | |
| </script> | |
| <?= $this->endSection() ?> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| $routes->group('report', [ | |
| 'namespace' => 'App\Controllers\Report', | |
| 'filter' => ['authGuard', 'RoleFilter:admin,user'] | |
| ], static function ($routes) { | |
| $routes->get('customer', 'CustomerController::index'); | |
| $routes->post('customer/ajaxBuilder', 'CustomerController::ajaxBuilder'); | |
| $routes->post('customer/ajaxRaw', 'CustomerController::ajaxRaw'); | |
| $routes->post('customer/delete', 'CustomerController::delete'); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace App\Libraries; | |
| class RowFormatter | |
| { | |
| protected array $columns = []; | |
| protected array $rules = []; | |
| public function __construct(array $columnsConfig = [], array $rules = []) | |
| { | |
| $this->columns = $columnsConfig; | |
| $this->rules = $rules; | |
| } | |
| /** | |
| * Main handler called for each row. | |
| */ | |
| public function handle(array $outRow, array $rawRow): array | |
| { | |
| // Per-column formatting | |
| foreach ($this->columns as $colDef) { | |
| $key = $colDef['data']; | |
| $type = $colDef['type'] ?? 'text'; | |
| $val = $outRow[$key] ?? null; | |
| if (isset($this->rules[$key])) { | |
| $rule = $this->rules[$key]; | |
| if (is_callable($rule)) { | |
| $outRow[$key] = $rule($val, $rawRow); | |
| continue; | |
| } | |
| if (is_string($rule)) { | |
| $outRow[$key] = $this->applyShorthand($rule, $val); | |
| continue; | |
| } | |
| } | |
| $outRow[$key] = $this->formatByType($type, $val); | |
| } | |
| // Actions handling: only 'group' wrapper supported | |
| if (isset($this->rules['actions'])) { | |
| $actionsRule = $this->rules['actions']; | |
| // actions_wrapper is allowed but only 'group' will be used | |
| $wrapper = $this->rules['actions_wrapper'] ?? 'group'; | |
| if (is_callable($actionsRule)) { | |
| $html = $actionsRule($outRow, $rawRow); | |
| } elseif (is_string($actionsRule) && $actionsRule === 'default') { | |
| $defs = $this->rules['actions_default'] ?? []; | |
| $html = $this->buildActionsGroup($defs, $outRow, $rawRow); | |
| } elseif (is_array($actionsRule)) { | |
| $html = $this->buildActionsGroup($actionsRule, $outRow, $rawRow); | |
| } else { | |
| $html = ''; | |
| } | |
| $outRow['actions'] = $html; | |
| } else { | |
| // ensure actions key exists if defined in columns | |
| foreach ($this->columns as $c) { | |
| if ($c['data'] === 'actions') { | |
| $outRow['actions'] = $outRow['actions'] ?? ''; | |
| break; | |
| } | |
| } | |
| } | |
| return $outRow; | |
| } | |
| /** | |
| * Apply shorthand rule strings like date:..., phone_mask, float:N, uppercase. | |
| */ | |
| protected function applyShorthand(string $rule, $value) | |
| { | |
| if (str_starts_with($rule, 'date:')) { | |
| $fmt = substr($rule, 5) ?: 'Y-m-d'; | |
| return !empty($value) ? date($fmt, strtotime($value)) : null; | |
| } | |
| if ($rule === 'phone_mask') { | |
| return $this->maskPhone((string)$value); | |
| } | |
| if (str_starts_with($rule, 'float:')) { | |
| $dec = (int) substr($rule, 6); | |
| return is_numeric($value) ? number_format((float)$value, $dec, '.', '') : $value; | |
| } | |
| if ($rule === 'uppercase') { | |
| return is_string($value) ? mb_strtoupper($value) : $value; | |
| } | |
| return $value; | |
| } | |
| /** | |
| * Basic formatting by declared type. | |
| */ | |
| protected function formatByType(string $type, $value) | |
| { | |
| switch ($type) { | |
| case 'text': | |
| return is_null($value) ? null : esc($value); | |
| case 'date': | |
| return !empty($value) ? date('Y-m-d', strtotime($value)) : null; | |
| case 'datetime': | |
| return !empty($value) ? date('Y-m-d H:i:s', strtotime($value)) : null; | |
| case 'number': | |
| return is_numeric($value) ? (int)$value : $value; | |
| case 'float': | |
| return is_numeric($value) ? number_format((float)$value, 2, '.', '') : $value; | |
| case 'currency': | |
| return is_numeric($value) ? 'RM ' . number_format((float)$value, 2, '.', '') : $value; | |
| case 'raw': | |
| return $value; | |
| default: | |
| return $value; | |
| } | |
| } | |
| /** | |
| * Mask phone number by keeping last 4 digits. | |
| */ | |
| protected function maskPhone(string $phone): string | |
| { | |
| $digits = preg_replace('/\D+/', '', $phone); | |
| $len = strlen($digits); | |
| if ($len <= 4) { | |
| return $phone; | |
| } | |
| return str_repeat('*', max(0, $len - 4)) . substr($digits, -4); | |
| } | |
| /** | |
| * Build actions rendered as a Bootstrap button group. | |
| * | |
| * Each action def: | |
| * - label (string) | |
| * - url (string, may include {placeholders}) | |
| * - class (string) optional | |
| * - attrs (array) optional | |
| * - icon (string) optional | |
| * - escape_label (bool) optional | |
| */ | |
| protected function buildActionsGroup(array $defs, array $outRow, array $rawRow): string | |
| { | |
| $items = []; | |
| foreach ($defs as $def) { | |
| $label = $def['label'] ?? ''; | |
| $url = $def['url'] ?? '#'; | |
| $cls = $def['class'] ?? 'btn btn-sm btn-secondary'; | |
| $attrs = $def['attrs'] ?? []; | |
| $icon = $def['icon'] ?? ''; | |
| $escapeLabel = $def['escape_label'] ?? true; | |
| // Replace placeholders before escaping | |
| $url = $this->replacePlaceholders((string)$url, $outRow, $rawRow); | |
| foreach ($attrs as $k => $v) { | |
| $attrs[$k] = $this->replacePlaceholders((string)$v, $outRow, $rawRow); | |
| } | |
| // Escape URL and attributes | |
| $urlEsc = esc($url); | |
| $attrStr = ''; | |
| foreach ($attrs as $k => $v) { | |
| $attrStr .= ' ' . $k . '="' . esc($v) . '"'; | |
| } | |
| $labelHtml = $escapeLabel ? esc($label) : $label; | |
| // Build single button HTML | |
| $btnHtml = '<a href="' . $urlEsc . '" class="' . esc($cls) . '"' . $attrStr . '>' . $icon . $labelHtml . '</a>'; | |
| $items[] = $btnHtml; | |
| } | |
| // Wrap in btn-group | |
| return '<div class="btn-group" role="group">' . implode(' ', $items) . '</div>'; | |
| } | |
| /** | |
| * Replace placeholders like {id} or {user.name} using rawRow first then outRow. | |
| */ | |
| protected function replacePlaceholders(string $template, array $outRow, array $rawRow): string | |
| { | |
| // Decode URL-encoded braces if present | |
| $decodedTemplate = $template; | |
| if (strpos($template, '%7B') !== false || strpos($template, '%7b') !== false) { | |
| $decodedTemplate = rawurldecode($template); | |
| } | |
| $replaced = preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function($m) use ($outRow, $rawRow) { | |
| $path = $m[1]; | |
| $val = $this->fetchValueByPath($rawRow, $path); | |
| if ($val === null) $val = $this->fetchValueByPath($outRow, $path); | |
| return is_scalar($val) ? (string)$val : ''; | |
| }, $decodedTemplate); | |
| return $replaced ?? $template; | |
| } | |
| /** | |
| * Fetch value by dot-path from array. | |
| */ | |
| protected function fetchValueByPath($arr, string $path) | |
| { | |
| if (!is_array($arr)) return null; | |
| if ($path === '') return null; | |
| if (strpos($path, '.') === false) { | |
| if (array_key_exists($path, $arr)) return $arr[$path]; | |
| $alt = str_replace('.', '_', $path); | |
| return $arr[$alt] ?? null; | |
| } | |
| $parts = explode('.', $path); | |
| $cur = $arr; | |
| foreach ($parts as $p) { | |
| if (!is_array($cur) || !array_key_exists($p, $cur)) return null; | |
| $cur = $cur[$p]; | |
| } | |
| return $cur; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment