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 after fetch_named() or get_row(). It casts columns to their proper PHP types (int, float, etc.) so that enum_val() comparisons and JSON encoding work correctly.
  • $emps->loadvars() after the while loop — the loop modifies $key and $ss to build elink URLs; 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.