Admin Module Pattern
Most management sections in EMPS follow a consistent three-file pattern: a PHP controller, a Smarty/Vue template, and a JS mixin file. This page shows a complete skeleton that you can use as a starting point for any CRUD admin module.
The example module manages a list of "items" at the URL /manage-items/.
File Structure
modules/
manage/
items/
items.php ← PHP controller (handles all requests for this page)
items.nn.htm ← Smarty + Vue template
items.js ← Vue mixin
sql/
module.sql ← SQLSync table definition
URL mapping: /manage-items/ → modules/manage/items/items.php
SQL File
modules/manage/items/sql/module.sql:
-- table
CREATE TEMPORARY TABLE `temp_items` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`status` int(11) NOT NULL DEFAULT 0,
`cdt` bigint NOT NULL,
`dt` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `status` (`status`),
KEY `dt` (`dt`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Run /sqlsync/manage-items/ once to create the table. See SQLSync.
PHP Controller (items.php)
<?php
if ($emps->auth->credentials("admin")) {
$emps->page_property("vuejs", 1);
$emps->page_property("mainapp", true);
$perpage = 50;
$start = intval($start); // pagination offset — from URL variable
$item_id = intval($key); // current item id — from URL variable
// ── Load list ────────────────────────────────────────────────────────────
if ($_GET['load_list'] ?? false) {
$r = $emps->db->query(
"select SQL_CALC_FOUND_ROWS * from " . TP . "items
order by id desc limit {$start}, {$perpage}"
);
$pages = $emps->count_pages($emps->db->found_rows($r));
$lst = [];
while ($ra = $emps->db->fetch_named($r)) {
$ra = $emps->db->row_types("items", $ra); // cast ints/floats
$emps->loadvars();
$key = $ra['id'];
$ss = "info";
$ra['elink'] = $emps->elink();
$lst[] = $ra;
}
$emps->loadvars(); // restore URL vars after the loop!
$emps->json_ok(['lst' => $lst, 'pages' => $pages]); exit;
}
// ── Load single row ───────────────────────────────────────────────────────
if ($_GET['load_row'] ?? false) {
$row = $emps->db->get_row("items", "id = {$item_id}");
if (!$row) { $emps->json_error("Not found"); exit; }
$row = $emps->db->row_types("items", $row);
$emps->loadvars(); $key = ""; $ss = "";
$row['back_link'] = $emps->elink();
$emps->loadvars();
$emps->json_ok(['row' => $row]); exit;
}
// ── Create ────────────────────────────────────────────────────────────────
if ($_POST['post_create'] ?? false) {
$nr = ['name' => $_POST['payload']['name']];
$emps->db->sql_insert_row("items", ['SET' => $nr]);
$id = $emps->db->last_insert();
$emps->loadvars(); $key = $id; $ss = "info";
$emps->json_ok(['link' => $emps->elink()]); exit;
}
// ── Save ──────────────────────────────────────────────────────────────────
if ($_POST['post_save'] ?? false) {
$nr = $_POST['payload'];
$id = intval($nr['id']);
unset($nr['id'], $nr['cdt'], $nr['dt']);
$emps->db->sql_update_row("items", ['SET' => $nr], "id = {$id}");
$emps->json_ok(); exit;
}
// ── Delete selected ───────────────────────────────────────────────────────
if ($_POST['post_remove_selected'] ?? false) {
foreach ($_POST['payload'] as $raw_id) {
$id = intval($raw_id);
if ($id > 0) $emps->db->query("delete from " . TP . "items where id={$id}");
}
$emps->json_ok(); exit;
}
} else {
$emps->deny_access("AdminNeeded");
}
Key points:
row_types("items", $ra)— always call this afterfetch_named()orget_row(). It casts columns to their proper PHP types (int, float, etc.) so thatenum_val()comparisons and JSON encoding work correctly.$emps->loadvars()after the while loop — the loop modifies$keyand$ssto buildelinkURLs; restoring them prevents URL state leaking out of the loop.unset($nr['id'], $nr['cdt'], $nr['dt'])before update — never let the client overwrite auto-managed fields.
JS Mixin (items.js)
emps_scripts.push(function () {
window.main_app_mixins.push({
mixins: [table_mixin, navigation_mixin],
data: function () {
return {
lst: [],
row: {},
selected_row: {},
pages: {},
enums: {},
newrow: { name: '' }
};
},
mounted: function () {
$(".app-loading").hide();
EMPS.vue_load_enums(this, "item_status");
this.navigate(window.location.href);
},
methods: {
create_item: function () {
this.create_new_row({ name: this.newrow.name });
this.newrow.name = '';
this.close_modal("modalCreate");
}
}
});
});
table_mixin (from /mjs/comp-table/table.js) provides: load_list, load_row,
save_row, create_new_row, remove_selected, select_row, list_mode.
navigation_mixin (from /mjs/comp-navigation/navigation.js) provides:
navigate(url), path (parsed URL variables object).
Smarty Template (items.nn.htm)
{{capture append="vue_out"}}
{{script src="/mjs/comp-mixins/common.js" defer=1}}
{{script src="/mjs/comp-table/table.js" defer=true}}
{{script src="/mjs/comp-navigation/navigation.js" defer=1}}
{{script src="/mjs/manage-items/items.js" defer=1}}
{{include file="db:_comp/selector"}}
{{include file="db:_comp/modal"}}
{{/capture}}
<div>
<template v-if="list_mode">
{{* ── List view ── *}}
<div class="level">
<div class="level-left">
<h1 class="title">Items</h1>
</div>
<div class="level-right">
<button class="button is-primary" @click="open_modal('modalCreate')">
+ New Item
</button>
</div>
</div>
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th><input type="checkbox" @change="toggle_select_all($event)"></th>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="row in lst" :key="row.id" @click="navigate(row.elink)" style="cursor:pointer">
<td @click.stop><input type="checkbox" v-model="row._selected"></td>
<td>{{ row.name }}</td>
<td>{{ enum_val('item_status', row.status) }}</td>
</tr>
</tbody>
</table>
{{include file="db:page/vue_paginator"}}
<button class="button is-danger" v-if="selected_count > 0"
@click="remove_selected">
Delete selected ({{ selected_count }})
</button>
</template>
<template v-else>
{{* ── Item view ── *}}
<div class="tabs">
<ul>
<li :class="{'is-active': path.ss == 'info'}">
<a @click="navigate(EMPS.elink({ss:'info'},[]))">Info</a>
</li>
</ul>
</div>
<template v-if="path.ss == 'info'">
{{include file="db:_manage/items,info"}}
</template>
</template>
{{* ── Create modal ── *}}
<modal id="modalCreate">
<template slot="header">New Item</template>
<div class="field">
<label class="label">Name</label>
<input class="input" type="text" v-model="newrow.name" @keyup.enter="create_item">
</div>
<template slot="actions">
<button class="button is-primary" @click="create_item">Create</button>
</template>
</modal>
</div>
<div class="app-loading">{{include file="db:inc/spinner"}}</div>
Info Tab Include (items_info.nn.htm)
Create modules/manage/items/items_info.nn.htm (referenced as
db:_manage/items,info):
<div class="box">
<div class="field">
<label class="label">Name</label>
<input class="input" type="text" v-model="row.name">
</div>
<div class="field">
<label class="label">Status</label>
<selector type="item_status" v-model="row.status"></selector>
</div>
<div class="buttons">
<button class="button is-primary" @click="save_row">Save</button>
<a class="button" :href="row.back_link">Back to list</a>
</div>
</div>
URL Structure
| URL | $key |
$ss |
What is shown |
|---|---|---|---|
/manage-items/ |
— | — | List of items |
/manage-items/42/-/info/ |
42 |
info |
Item 42, Info tab |
list_mode (from table_mixin) returns true when $key is empty.
Adding More Tabs
To add a second tab (e.g. "Notes"), add the tab to the template and create a new include file:
<li :class="{'is-active': path.ss == 'notes'}">
<a @click="navigate(EMPS.elink({ss:'notes'},[]))">Notes</a>
</li>
...
<template v-if="path.ss == 'notes'">
{{include file="db:_manage/items,notes"}}
</template>
The PHP controller can add separate load_notes / post_save_note handlers
following the same if ($_GET['load_notes'] ?? false) pattern.