Frontend Development Guide¶
Overview¶
This guide explains how to design and build admin pages in TQPro's tqweb-adm application. It covers the UI design system, component patterns, layout conventions, and data flow -- everything needed to build a feature from an empty HTML file to a fully functional admin interface.
For the basic page setup (module structure, authentication, script loading order), see the companion Admin Page Development guide. This document focuses on what to build and which patterns to use.
The guide is derived from the Cruise Management and Inbound Groups implementations, which represent the current standard for new features.
Table of Contents¶
- Technology Stack
- Design System
- Page Types and When to Use Them
- HTML Page Skeleton
- JavaScript Module Architecture
- API Communication
- Data Tables
- Forms and CRUD
- Modals and Dialogs
- Notifications
- Navigation and Routing
- Dropdowns and Autocomplete
- Loading States
- Status Badges and Visual Indicators
- Multi-Currency and Exchange Rates
- Card Layouts
- Print Support
- Security Considerations
- Module Structure Checklist
1. Technology Stack¶
| Layer | Technology | Notes |
|---|---|---|
| CSS Framework | Bootstrap 5.3 | CDN-loaded. All new pages must use Bootstrap 5 |
| Icons | Bootstrap Icons 1.11 + Font Awesome 6 | Prefer Bootstrap Icons (bi-*) for new code |
| DOM Library | jQuery 3.7 | Used for DOM manipulation, AJAX, event delegation |
| JavaScript | ES6 Modules | One module per page, no bundler |
| Components | TQ Component Library (tqpro-components.js) |
Notifications, confirmations, loading states, pagination, validation |
| Auth | OIDC (oidc-client-ts via Keycloak) |
Managed by auth-service.js |
Not used: Alpine.js, React, Vue, Angular, or any SPA framework. Pages are multi-page application (MPA) with ES6 modules and jQuery.
2. Design System¶
Canonical reference: See
doc/design-system/for the full design system documentation: -tq-tokens.css— All design tokens -tq-components.md— Canonical HTML snippets -tq-templates.md— Page skeleton templates
CSS Custom Properties¶
All admin pages share a design token system defined in css/tqadmin.css. Use these variables instead of hardcoded colors:
:root {
--primary-color: #362c5d; /* Deep purple -- brand identity */
--primary-dark: #2a2149;
--secondary-color: #FFC166; /* Gold/amber -- accents, highlights */
--success-color: #198754; /* Bootstrap 5 green */
--warning-color: #ffc107; /* Bootstrap 5 amber */
--alert-color: #dc3545; /* Bootstrap 5 red */
--info-color: #0dcaf0; /* Bootstrap 5 cyan */
--light-bg: #f8f9fa;
--border-color: #dee2e6; /* Bootstrap 5 default border */
--shadow-sm: 0 0.125rem 0.25rem rgba(0,0,0,0.075); /* Bootstrap standard */
--shadow-md: 0 0.5rem 1rem rgba(0,0,0,0.15);
--shadow-lg: 0 8px 16px rgba(0,0,0,0.15);
--transition: all 0.2s ease;
}
Module-specific CSS files (e.g., cruise.css, booking.css) should reference the shared tokens above for brand colors, shadows, and borders. Only domain-specific tokens (e.g., booking status colors, service type colors) should use module-prefixed names:
:root {
/* Booking lifecycle status colors (domain-specific) */
--blm-enquiry: #0d6efd;
--blm-confirmed: #198754;
--blm-cancelled: #dc3545;
/* TripMaker service type colors (domain-specific) */
--tm-flight: #0d6efd;
--tm-hotel: #198754;
}
Unified Component Classes¶
All modules share these component classes defined in tqadmin.css:
| Class | Purpose |
|---|---|
.tq-breadcrumb |
Page breadcrumb navigation bar |
.tq-filter-bar |
Search/filter controls container |
.tq-kpi-card |
KPI stat card (border-left accent, compact) |
.tq-empty-state |
Empty state panel (icon + heading + message) |
.content-card |
Section card (white bg, shadow, rounded) |
.data-table-wrapper |
Table container (shadow, rounded) |
Typography¶
- Body text:
0.875rem(14px),#2d3748 - Table headers:
0.75rem, uppercase,letter-spacing: 0.05em,#4a5568 - Table body:
0.8rem - Card titles:
1rem,font-weight: 600 - KPI values:
1.5rem,font-weight: 700
Spacing Conventions¶
Use Bootstrap 5 spacing utilities (mb-3, p-3, g-3, gx-3) consistently:
- Between major page sections:
mb-4 - Between cards in a grid:
g-3org-4 - Between form fields in a row:
g-2org-3 - Page top margin (below fixed header):
margin-top: 80px
Required Stylesheets¶
Every new page needs these CSS files in this order:
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="css/tqapp.css">
<link rel="stylesheet" href="css/tqadmin.css">
<link href="css/fontawesome.min.css" rel="stylesheet">
<link href="css/solid.css" rel="stylesheet">
<link href="css/your-module.css" rel="stylesheet">
3. Page Types and When to Use Them¶
Based on existing implementations, there are five page archetypes. Choose the one that matches your feature's needs:
3a. List Page (Dashboard)¶
Use when: Users need to browse, filter, and select from a collection of top-level entities.
Examples: cruise-dash.html (sailing dashboard), groups-list.html (group list)
Characteristics: - KPI summary cards at the top (counts, totals) - Filter bar (dropdowns, search) - Card grid or table listing entities - Click-through to detail pages - Settings modal for reference data CRUD
Layout:
┌──────────────────────────────────────────────┐
│ Breadcrumb │
├──────────────────────────────────────────────┤
│ KPI Cards (3-5 cards in a row) │
├──────────────────────────────────────────────┤
│ Filter Bar (dropdowns + search) │
├──────────────────────────────────────────────┤
│ Entity Cards (grid) or Schedule Table │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Card │ │Card │ │Card │ ... │
│ └─────┘ └─────┘ └─────┘ │
└──────────────────────────────────────────────┘
3b. Detail Page (Two-Pane)¶
Use when: Users need to view and edit a single entity with related sub-entities.
Examples: cruise-itin.html (itinerary detail), groups-summary.html (group summary)
Characteristics: - Entity header with name, status badge, key metadata - Two-column layout: left pane for one concern, right pane for another - Inline tables for child entities - Action buttons per section (Add, Edit, Delete)
Layout:
┌──────────────────────────────────────────────┐
│ Breadcrumb │
├──────────────────────────────────────────────┤
│ Entity Header (name, status, actions) │
├──────────────────┬───────────────────────────┤
│ Left Pane │ Right Pane │
│ (Templates, │ (Pricing, Charges, │
│ Itinerary...) │ Sailings...) │
│ │ │
│ ┌─────────────┐ │ ┌──────────────────────┐ │
│ │ Table │ │ │ Table │ │
│ │ + Add btn │ │ │ + Add btn │ │
│ └─────────────┘ │ └──────────────────────┘ │
└──────────────────┴───────────────────────────┘
3c. Entity Management Page (Table-Centric)¶
Use when: Users need to manage a flat list of related entities (passengers, transports, activities).
Examples: groups-passengers.html, groups-transport.html, groups-activities.html
Characteristics: - Content card with title bar and action buttons (Add, Import, Export) - Group context strip (parent entity name, status, counts) - Full-width data table - Empty state when no data - Back button to parent page
Layout:
┌──────────────────────────────────────────────┐
│ Breadcrumb │
├──────────────────────────────────────────────┤
│ Content Card Header │
│ ┌────────────────────────────────────────┐ │
│ │ Title [Import] [Export] [+Add]│ │
│ ├────────────────────────────────────────┤ │
│ │ Group: ABC Tours Status: Active 12pax│ │
│ └────────────────────────────────────────┘ │
├──────────────────────────────────────────────┤
│ Data Table (full width) │
│ ┌────────────────────────────────────────┐ │
│ │ Name │ Type │ Date │ Actions │ │
│ ├─────────┼────────┼────────┼────────────┤ │
│ │ Row 1 │ │ │ Edit | Del │ │
│ │ Row 2 │ │ │ Edit | Del │ │
│ └────────────────────────────────────────┘ │
├──────────────────────────────────────────────┤
│ [← Back to Summary] │
└──────────────────────────────────────────────┘
3d. Card Grid Page¶
Use when: Entities are better represented as visual cards than table rows (room assignments, visual items).
Examples: groups-rooming.html, groups-activities.html
Characteristics:
- Responsive card grid (col-md-6 col-lg-4 col-xl-3)
- Each card shows entity data with inline actions
- Cards may contain interactive elements (assign buttons, remove tags)
3e. Calendar Page¶
Use when: Data is time-based and users need a monthly/weekly overview.
Examples: groups-calendar.html
Characteristics: - 7-column CSS grid - Day cells with color-coded event overlays - Month navigation (prev/next) - Click-through to event details
4. HTML Page Skeleton¶
Every new page should follow this template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Title - TQPro Admin</title>
<!-- CSS: Bootstrap → App → Module -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="css/tqapp.css">
<link rel="stylesheet" href="css/tqadmin.css">
<link href="css/fontawesome.min.css" rel="stylesheet">
<link href="css/solid.css" rel="stylesheet">
<link href="css/your-module.css" rel="stylesheet">
</head>
<body class="your-module-page" style="visibility:hidden">
<!-- JS: jQuery → Bootstrap (loaded early, before content) -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Shared header (loaded from template) -->
<header id="pgheader" data-load-template="header_bootstrap.html"></header>
<!-- ============ MODALS ============ -->
<section role="dialog">
<!-- Define all modals here -->
</section>
<!-- ============ MAIN CONTENT ============ -->
<div class="container-fluid your-module-container" style="margin-top: 80px;">
<!-- Breadcrumb -->
<nav class="your-module-breadcrumb" aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="index.html"><i class="bi bi-house-door"></i> Dashboard</a>
</li>
<li class="breadcrumb-item">
<a href="your-module-list.html"><i class="bi bi-collection"></i> Module</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Current Page</li>
</ol>
</nav>
<!-- Page content goes here -->
</div>
<!-- ============ SCRIPTS ============ -->
<script src="js/pageutil.js"></script>
<script src="js/tqpro-compat.js"></script>
<script>loadBootstrapTemplates();</script>
<script type="module">
import * as $page from './js/modules/your-module-page.js';
import * as $site from './js/modules/globals.js';
$(document).ready(async function() {
await $site.setGlobalHandlers({ requireAuth: true });
$page.initializePage();
// Expose needed functions to global scope for inline handlers
window.YourModule = {
editItem: $page.editItem,
deleteItem: $page.deleteItem
};
});
</script>
</body>
</html>
Key conventions:
- body starts hidden (visibility:hidden) to prevent content flash before auth completes
- jQuery and Bootstrap JS are loaded in <body> before content (not in <head>)
- Modals are defined inline in a <section role="dialog"> block before the main content
- The main container uses container-fluid with margin-top: 80px to clear the fixed header
- Scripts follow a strict loading order: pageutil.js → tqpro-compat.js → loadBootstrapTemplates() → ES6 module
5. JavaScript Module Architecture¶
Module File Structure¶
For a new feature called "inventory", create:
tqweb-adm/js/modules/
├── inventory-common.js # Shared API client, data cache, utilities
├── inventory-list.js # List page logic
├── inventory-detail.js # Detail page logic
└── ...
Common Module Pattern¶
The common module provides the API client, shared data cache, and UI helpers that all page modules in the feature need:
// inventory-common.js
import { tlinq, getUserSession, escapeHtml, notify } from './globals.js';
// ==================== API Client ====================
export const InventoryAPI = {
async request(endpoint, data = {}) {
const session = getUserSession();
return tlinq(`inventory${endpoint}`, { ...data, session });
},
// Entity CRUD
async listItems() { return this.request('/item/list'); },
async readItem(itemId) { return this.request('/item/read', { itemId }); },
async writeItem(data) { return this.request('/item/write', data); },
async deleteItem(itemId) { return this.request('/item/delete', { itemId }); },
// Reference data
async listCategories() { return this.request('/category/list'); },
async listWarehouses() { return this.request('/warehouse/list'); },
};
// ==================== Shared Data Cache ====================
let _categories = [], _warehouses = [];
export async function initCommon() {
const [categories, warehouses] = await Promise.all([
InventoryAPI.listCategories(),
InventoryAPI.listWarehouses()
]);
_categories = categories || [];
_warehouses = warehouses || [];
}
export function getCategories() { return _categories; }
export function getWarehouses() { return _warehouses; }
export function findCategory(id) {
return _categories.find(c => c.categoryId === id);
}
export async function reloadCategories() {
_categories = (await InventoryAPI.listCategories()) || [];
}
// ==================== Form Helpers ====================
export function populateSelect(selectId, items, valueField, labelField,
placeholder) {
const labelFn = typeof labelField === 'function'
? labelField
: item => item[labelField];
const options = `<option value="">-- ${placeholder} --</option>` +
items.map(item =>
`<option value="${item[valueField]}">${escapeHtml(labelFn(item))}</option>`
).join('');
$(selectId).html(options);
}
export function populateForm(fields, data = {}) {
fields.forEach(f => {
const val = data[f.prop] !== undefined ? data[f.prop] : (f.default ?? '');
if (f.type === 'bool') $(f.selector).prop('checked', !!val);
else $(f.selector).val(val);
});
}
export function extractFormData(fields) {
const result = {};
fields.forEach(f => {
if (f.type === 'bool') {
result[f.prop] = $(f.selector).is(':checked');
return;
}
let val = $(f.selector).val();
if (f.type === 'int') val = val ? parseInt(val, 10) : null;
else if (f.type === 'float') val = val ? parseFloat(val) : (f.default ?? 0);
else if (f.trim !== false) val = val ? val.trim() : '';
result[f.prop] = val;
});
return result;
}
// ==================== Delete Helper ====================
export function performDelete(confirmMsg, apiCall, successMsg, reloadFn) {
showConfirmDialog(confirmMsg, async () => {
try {
await apiCall();
notify.success(successMsg);
if (reloadFn) await reloadFn();
} catch (err) {
notify.error('Delete failed: ' + (err.errorMessage || err));
}
});
}
// ==================== Status Badge ====================
const statusColors = {
'Active': 'success', 'Draft': 'secondary', 'Cancelled': 'danger',
'Confirmed': 'primary', 'Pending': 'warning'
};
export function statusBadge(status, colorMap) {
const color = (colorMap || statusColors)[status] || 'secondary';
return `<span class="badge bg-${color}">${escapeHtml(status || '')}</span>`;
}
Page Module Pattern¶
Each page module follows this structure:
// inventory-list.js
import { escapeHtml, notify } from './globals.js';
import { InventoryAPI, initCommon, getCategories, findCategory,
populateSelect, statusBadge, performDelete } from './inventory-common.js';
// ==================== Module State ====================
let items = [];
let selectedItemId = null;
// ==================== Field Configs ====================
const ITEM_FIELDS = [
{ selector: '#item_id', prop: 'itemId', type: 'int' },
{ selector: '#item_name', prop: 'itemName', type: 'string' },
{ selector: '#item_category', prop: 'categoryId', type: 'int' },
{ selector: '#item_qty', prop: 'quantity', type: 'float', default: 0 },
{ selector: '#item_active', prop: 'active', type: 'bool' },
];
// ==================== Initialization ====================
export async function initializePage() {
await initCommon();
await loadItems();
populateFilterDropdowns();
renderTable();
setupEventListeners();
}
// ==================== Data Loading ====================
async function loadItems() {
try {
items = (await InventoryAPI.listItems()) || [];
} catch (err) {
notify.error('Failed to load items: ' + (err.errorMessage || err));
}
}
// ==================== Rendering ====================
function renderTable() {
const tbody = $('#items_tbody');
tbody.empty();
if (items.length === 0) {
tbody.append(
'<tr><td colspan="5" class="text-center text-muted py-3">' +
'No items found.</td></tr>');
return;
}
items.forEach(item => {
const cat = findCategory(item.categoryId);
tbody.append(`<tr class="clickable" data-id="${item.itemId}">
<td>${escapeHtml(item.itemName)}</td>
<td>${escapeHtml(cat ? cat.categoryName : '-')}</td>
<td class="text-end">${item.quantity}</td>
<td>${statusBadge(item.status)}</td>
<td class="actions">
<button class="btn btn-sm btn-primary item-edit"
data-id="${item.itemId}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-danger item-delete"
data-id="${item.itemId}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`);
});
}
// ==================== Event Listeners ====================
function setupEventListeners() {
// Use event delegation for dynamically rendered content
$('#items_tbody').on('click', '.item-edit', function() {
editItem(parseInt($(this).data('id')));
});
$('#items_tbody').on('click', '.item-delete', function() {
deleteItem(parseInt($(this).data('id')));
});
// Static buttons
$('#btn_new_item').on('click', newItem);
$('#btn_save_item').on('click', saveItem);
}
Data Model Classes (Groups Pattern)¶
For complex entities with multiple related objects, define model classes in the common module:
// groups-core.js pattern
export class GroupTrip {
constructor(data = {}) {
this.tripGroupId = data.tripGroupId || null;
this.groupName = data.groupName || '';
this.status = data.status || 'DRAFT';
this.numPax = parseInt(data.numPax) || 0;
// ...
}
update(obj) {
if ('groupName' in obj) this.groupName = obj.groupName;
if ('status' in obj) this.status = obj.status;
// ...
}
}
Use model classes when:
- The entity has complex field parsing or type coercion
- Multiple pages need to create/update the same entity
- You need update() methods for partial updates
Use plain objects when: - The entity is simple (few fields, all strings/numbers) - Only one page interacts with the entity
6. API Communication¶
The tlinq() Function¶
All API calls go through tlinq() from globals.js:
import { tlinq } from './globals.js';
// tlinq(endpoint, requestBody) → Promise<apiData>
const products = await tlinq('product/list', { session: '', categoryId: 5 });
Key behaviors:
- All requests are HTTP POST to /tlinq-api/{endpoint} with JSON body
- Automatically attaches Authorization: Bearer <token> from OIDC
- Resolves directly to apiData (the data array/object), NOT the full response envelope
- Rejects with apiStatus object on API errors
- On 401/403, automatically redirects to login
Correct usage:
tlinq('inventory/item/list', { session: '' }).then(
(data) => {
items = data || []; // 'data' IS the array directly
},
(err) => {
notify.error('Load failed: ' + (err.errorMessage || err));
}
);
Wrong -- do not destructure .data:
// WRONG: result.data is undefined
tlinq('inventory/item/list', { session: '' }).then(result => {
items = result.data || []; // BROKEN
});
API Client Object Pattern¶
Group all API calls in a named object in the common module:
export const InventoryAPI = {
async request(endpoint, data = {}) {
const session = getUserSession();
return tlinq(`inventory${endpoint}`, { ...data, session });
},
async listItems() { return this.request('/item/list'); },
async writeItem(data) { return this.request('/item/write', data); },
async deleteItem(itemId) { return this.request('/item/delete', { itemId }); },
};
This pattern: - Centralizes the session token injection - Provides a clear namespace for all module API calls - Makes it easy to find which endpoints a module uses
Parallel Data Loading¶
Load independent data sources concurrently using Promise.all():
export async function initCommon() {
const [categories, warehouses, suppliers] = await Promise.all([
InventoryAPI.listCategories(),
InventoryAPI.listWarehouses(),
InventoryAPI.listSuppliers()
]);
_categories = categories || [];
_warehouses = warehouses || [];
_suppliers = suppliers || [];
}
Expanded Data Loading (Groups Pattern)¶
When a page needs a parent entity plus all its children, use a single "expanded" API call instead of multiple individual requests:
const data = await GroupsAPI.loadGroupExpanded(groupId);
// Returns: { passengers[], hotels[], rooms[], transports[], services[] }
This reduces HTTP round-trips and ensures consistent data.
7. Data Tables¶
Table Structure¶
Use the module-specific table CSS class (e.g., itin-table, data-table) rather than Bootstrap's default table classes. These provide compact, admin-appropriate styling:
<table class="itin-table" id="items_table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th class="text-end">Qty</th>
<th>Status</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="items_tbody"></tbody>
</table>
Define the table styles in your module CSS:
.itin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.itin-table th {
background: #f7fafc;
font-weight: 600;
color: #4a5568;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 8px 10px;
border-bottom: 2px solid #e2e8f0;
}
.itin-table td {
padding: 6px 10px;
border-bottom: 1px solid #edf2f7;
vertical-align: middle;
}
.itin-table tr:hover {
background-color: #f7fafc;
}
Dynamic Table Rendering¶
Always use escapeHtml() for user-supplied data. Build rows using template literals:
function renderTable() {
const tbody = $('#items_tbody');
tbody.empty();
if (items.length === 0) {
tbody.append(
'<tr><td colspan="5" class="text-center text-muted py-3">' +
'No items found.</td></tr>');
return;
}
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
sorted.forEach(item => {
tbody.append(`<tr>
<td>${escapeHtml(item.name)}</td>
<td>${escapeHtml(item.category || '-')}</td>
<td class="text-end">${item.quantity}</td>
<td>${statusBadge(item.status)}</td>
<td class="actions">
<button class="btn btn-sm btn-primary item-edit"
data-id="${item.itemId}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-danger item-delete"
data-id="${item.itemId}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`);
});
}
Clickable / Selectable Rows¶
For master-detail tables where clicking a row selects it:
tbody.append(`<tr class="clickable${isSelected ? ' selected' : ''}"
data-id="${item.itemId}">
<td>${escapeHtml(item.name)}</td>
</tr>`);
// Event handler
$('#items_tbody').on('click', 'tr.clickable', function() {
const id = parseInt($(this).data('id'));
selectItem(id);
});
CSS for clickable rows:
tr.clickable { cursor: pointer; }
tr.clickable:hover { background-color: #f7fafc; }
tr.selected {
background-color: #edf2f7;
border-left: 3px solid var(--primary-color);
}
Table with Footer Totals¶
const tfoot = $('<tfoot>').appendTo('#items_table');
tfoot.append(`<tr>
<td><strong>${items.length} item(s)</strong></td>
<td></td>
<td class="text-end"><strong>${totalQty}</strong></td>
<td></td>
<td></td>
</tr>`);
Empty State¶
When there is no data, show a helpful empty state instead of an empty table:
<div id="emptyState" class="text-center py-5 d-none">
<i class="bi bi-inbox" style="font-size: 3rem; color: #cbd5e0;"></i>
<p class="text-muted mt-2">No items yet</p>
<button class="btn btn-primary btn-sm" id="btnAddFirst">
<i class="bi bi-plus-lg"></i> Add First Item
</button>
</div>
Toggle visibility based on data:
$('#emptyState').toggleClass('d-none', items.length > 0);
$('#items_table').toggleClass('d-none', items.length === 0);
8. Forms and CRUD¶
Field Configuration Arrays¶
Define a declarative mapping between form fields and entity properties:
const ITEM_FIELDS = [
{ selector: '#item_id', prop: 'itemId', type: 'int' },
{ selector: '#item_name', prop: 'itemName', type: 'string' },
{ selector: '#item_category', prop: 'categoryId', type: 'int' },
{ selector: '#item_qty', prop: 'quantity', type: 'float', default: 0 },
{ selector: '#item_color', prop: 'color', type: 'string', default: '#000000' },
{ selector: '#item_active', prop: 'active', type: 'bool' },
];
Supported types:
- 'int' -- parseInt(val, 10), null if empty
- 'float' -- parseFloat(val), falls back to default or 0
- 'string' -- trimmed, empty string if blank
- 'bool' -- checkbox .is(':checked')
Then use the generic helpers populateForm() and extractFormData() from the common module.
CRUD Lifecycle¶
Create (New):
export function newItem() {
$('#item_dlg_title').text('New Item');
populateForm(ITEM_FIELDS); // Clear to defaults
populateSelect('#item_category', getCategories(),
'categoryId', 'categoryName', 'Select Category');
modalShow('item_dlg');
}
Read (Edit):
export function editItem(itemId) {
const item = items.find(i => i.itemId === itemId);
if (!item) return;
$('#item_dlg_title').text('Edit Item');
populateForm(ITEM_FIELDS, item); // Fill from data
populateSelect('#item_category', getCategories(),
'categoryId', 'categoryName', 'Select Category');
$('#item_category').val(item.categoryId);
modalShow('item_dlg');
}
Save (Create or Update):
export function saveItem() {
const data = extractFormData(ITEM_FIELDS);
// Validate required fields
if (!data.itemName) {
notify.error('Item name is required');
return;
}
// 0 or null means new, positive means update
data.itemId = data.itemId || 0;
InventoryAPI.writeItem(data).then(
() => {
notify.success('Item saved successfully');
modalHide('item_dlg');
loadItems().then(() => renderTable());
},
(err) => notify.error('Save failed: ' + (err.errorMessage || err))
);
}
Delete with Confirmation:
export function deleteItem(itemId) {
performDelete(
'Are you sure you want to delete this item?',
() => InventoryAPI.deleteItem(itemId),
'Item deleted',
async () => { await loadItems(); renderTable(); }
);
}
Data-Attribute Form Binding (Groups Pattern)¶
For more complex forms, use FormBinder from groups-core.js which binds via HTML data attributes:
<input type="text" id="paxFirstName"
data-entity-name="paxRecord"
data-entity-field="paxFirstName" required>
const formBinder = new FormBinder('#paxForm');
formBinder.populate('paxRecord', passenger); // Entity → form
const data = formBinder.extract('paxRecord'); // Form → object
formBinder.clear('paxRecord'); // Clear all fields
The FormBinder handles type-specific logic: checkboxes use .prop('checked'), date and datetime-local fields are auto-formatted.
Config-Driven Settings CRUD¶
For reference data tables (areas, ports, categories) where you have multiple similar entities managed in a single settings dialog, use a config-driven approach:
const SETTINGS_CONFIG = {
category: {
entityLabel: 'Category',
idField: 'categoryId',
listFn: getCategories,
reloadFn: reloadCategories,
writeFn: data => InventoryAPI.writeCategory(data),
deleteFn: id => InventoryAPI.deleteCategory(id),
tbodyId: 'categories_tbody',
dialogId: 'category_dlg',
titleId: 'category_dlg_title',
fields: [
{ domId: 'cat_code', entityField: 'categoryCode', required: true },
{ domId: 'cat_name', entityField: 'categoryName', required: true }
],
afterModify: () => { populateFilterDropdowns(); }
},
warehouse: { /* same pattern */ }
};
This eliminates duplication when managing multiple simple reference entities.
Validation¶
Manual validation (simple and preferred for most cases):
Component library validation (for complex forms):
const { valid, errors } = validateForm('#itemForm', {
itemName: { required: true, message: 'Name is required' },
email: { required: true, type: 'email' },
quantity: { type: 'number', min: 0, max: 10000 }
});
if (!valid) return;
Available built-in validators: email, phone, date, number, url. Uses Bootstrap is-invalid class and invalid-feedback elements.
Visual feedback for lookup fields:
.lookup-matched { border-color: #198754 !important; } /* Green = valid match */
.lookup-unmatched {
background-color: #fff3cd !important;
border-color: #ffc107 !important; /* Yellow = no match */
}
9. Modals and Dialogs¶
Bootstrap Modal Structure¶
All modals use Bootstrap 5 with static backdrop (no close on outside click):
<div class="modal fade" id="item_dlg" tabindex="-1"
data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-box"></i>
<span id="item_dlg_title">New Item</span>
</h5>
<button type="button" class="btn-close"
data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Hidden ID field -->
<input type="hidden" id="item_id">
<!-- Form fields in responsive grid -->
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name *</label>
<input type="text" class="form-control"
id="item_name" required>
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<select class="form-select" id="item_category">
</select>
</div>
</div>
</div>
<div class="modal-footer">
<small class="text-muted me-auto">* Required</small>
<button class="btn btn-secondary"
data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-success" id="item_save">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
</div>
</div>
Modal Size¶
Use Bootstrap modal size classes on modal-dialog:
- Default -- forms with up to 6-8 fields
modal-lg-- settings with tabs, wider formsmodal-xl-- complex dialogs with tables or two-column content
Show / Hide Helpers¶
Use the universal helpers from pageutil.js:
modalShow('item_dlg'); // Opens modal by element ID
modalHide('item_dlg'); // Closes modal by element ID
Confirmation Dialogs¶
For simple confirmations, use TQ.confirm():
const confirmed = await TQ.confirm('Delete Item',
'Are you sure you want to delete this item?', {
confirmText: 'Delete',
confirmClass: 'btn-danger'
});
if (confirmed) {
await InventoryAPI.deleteItem(itemId);
notify.success('Item deleted');
}
For domain-specific confirmations with customizable buttons, use the showConfirmDialog() pattern from the common module:
showConfirmDialog('Change status to Active?', async () => {
await InventoryAPI.updateStatus(itemId, 'Active');
notify.success('Status updated');
await reload();
}, {
title: 'Change Status',
buttonText: 'Activate',
buttonClass: 'btn-success',
buttonIcon: 'bi-check-circle'
});
Tabs in Modals¶
For settings dialogs with multiple entity types, use Bootstrap tabs:
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab"
data-bs-target="#categories-panel">Categories</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab"
data-bs-target="#warehouses-panel">Warehouses</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="categories-panel">
<table class="itin-table">...</table>
</div>
<div class="tab-pane fade" id="warehouses-panel">
<table class="itin-table">...</table>
</div>
</div>
10. Notifications¶
Use the TQ.notify / notify toast system for all user feedback:
import { notify } from './globals.js';
// Success -- green toast, 4s auto-hide
notify.success('Item saved successfully');
// Error -- red toast, 6s auto-hide
notify.error('Failed to save: ' + (err.errorMessage || err));
// Warning -- yellow toast, 5s auto-hide
notify.warning('Some items could not be processed');
// Info -- blue toast, 4s auto-hide
notify.info('Loading data...');
When to use each type:
| Type | Use for |
|---|---|
success |
Completed CRUD operations, successful actions |
error |
API failures, validation errors, unexpected issues |
warning |
Partial successes, items needing attention |
info |
Informational messages, background process updates |
Pattern for API error messages:
Always provide context in the message (what action failed) plus the server error message.
11. Navigation and Routing¶
Breadcrumbs¶
Every page has a breadcrumb showing the navigation hierarchy:
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="index.html"><i class="bi bi-house-door"></i> Dashboard</a>
</li>
<li class="breadcrumb-item">
<a href="inventory-list.html">
<i class="bi bi-box-seam"></i> Inventory
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
Item Detail
</li>
</ol>
</nav>
Page-to-Page Navigation¶
Pass entity IDs via URL query parameters:
// Navigate from list to detail
window.location.href = `inventory-detail.html?itemId=${item.itemId}`;
// Read the parameter on the target page
const params = new URLSearchParams(window.location.search);
const itemId = parseInt(params.get('itemId'));
Cross-Page State¶
For data that needs to survive page navigation, use sessionStorage:
// Save context before navigating
sessionStorage.setItem('inventory_currentItem', JSON.stringify(item));
// Read on target page (with fallback to API)
const cached = sessionStorage.getItem('inventory_currentItem');
if (cached) {
currentItem = JSON.parse(cached);
} else {
currentItem = await InventoryAPI.readItem(itemId);
}
Use URL parameters as the primary mechanism, sessionStorage as a cache. Never rely solely on sessionStorage since the user might bookmark or share the URL.
Hub-and-Spoke Navigation¶
For features with a summary page and multiple detail pages (like Groups), use a hub-and-spoke pattern:
list.html → summary.html → passengers.html
→ accommodation.html
→ transport.html
→ activities.html
→ calendar.html
Each detail page has a "Back to Summary" button:
<a href="#" class="btn btn-outline-secondary" id="btnBack">
<i class="bi bi-arrow-left"></i> Back to Summary
</a>
Horizontal Selection Strips¶
For selecting among peer entities (sailings, groups), use a scrollable horizontal strip:
<div class="sailing-strip-wrapper">
<button class="scroll-btn scroll-left" onclick="scrollStrip(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<div class="sailing-strip" id="sailing_strip">
<!-- Dynamically rendered pills/chips -->
</div>
<button class="scroll-btn scroll-right" onclick="scrollStrip(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
function scrollStrip(direction) {
const strip = document.getElementById('sailing_strip');
strip.scrollBy({ left: direction * 300, behavior: 'smooth' });
}
12. Dropdowns and Autocomplete¶
Static Select Dropdowns¶
Use populateSelect() for simple dropdowns:
With a custom label function:
populateSelect('#item_warehouse', getWarehouses(),
'warehouseId',
w => `${w.warehouseCode} - ${w.warehouseName}`,
'Select Warehouse');
Country/Nationality Autocomplete¶
For country or nationality fields, use the shared autocomplete setup from cruise-common.js:
setupCountryAutocomplete('#nationality_input', '#nationality_list',
(selected) => {
selectedCountryCode = selected.alpha2;
}
);
HTML:
<input type="text" class="form-control" id="nationality_input"
placeholder="Type to search..." autocomplete="off">
<ul class="nationality-autocomplete" id="nationality_list"
style="display:none;"></ul>
Lookup Fields with Visual Feedback¶
For fields that search and match against an API (partner lookup, hotel lookup):
async function onPartnerInput(event) {
const searchTerm = $(event.target).val();
if (searchTerm.length < 3) return;
const results = await InventoryAPI.supplierSearch(searchTerm);
lookupResults = results || [];
// Populate a <datalist> or custom dropdown
const datalist = $('#supplierList');
datalist.empty();
lookupResults.forEach(r => {
datalist.append(
`<option value="${escapeHtml(r.supplierName)}"
data-id="${r.supplierId}">`);
});
}
function onPartnerCheck() {
const inputVal = $('#supplier_input').val();
const match = lookupResults.find(r => r.supplierName === inputVal);
const $input = $('#supplier_input');
if (match) {
$('#supplierId').val(match.supplierId);
$input.removeClass('lookup-unmatched').addClass('lookup-matched');
} else {
$input.removeClass('lookup-matched').addClass('lookup-unmatched');
}
}
Declarative ForeignKeySelect¶
For simple read-only dropdowns, use the data-fk-* attributes with PageData:
<select data-fk-api="inventory/category/list"
data-fk-id="categoryId"
data-fk-display="categoryName"
data-fk-placeholder="-- Select Category --">
</select>
This auto-loads from the API and populates on page init. Supports parent-child dependencies via data-fk-depends.
13. Loading States¶
Full-Page Loading¶
Show a loading overlay during initial data fetch:
<div id="loadingIndicator" class="d-none">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted mt-2">Loading data...</p>
</div>
</div>
function showLoading() { $('#loadingIndicator').removeClass('d-none'); }
function hideLoading() { $('#loadingIndicator').addClass('d-none'); }
Button Loading State¶
Use TQ.loading for save/delete buttons:
// Wrap an async operation -- auto-manages spinner
await TQ.loading.wrap('#btn_save', InventoryAPI.writeItem(data));
// Or manual control
TQ.loading.button('#btn_save'); // Show spinner, disable
// ... do work ...
TQ.loading.buttonReset('#btn_save'); // Restore
Wait Dialog¶
For longer operations, use the full-screen wait dialog:
showWaitDialog('primary', '#waitDialog', 'Importing data...');
// ... long operation ...
hideWaitDialog('#waitDialog');
14. Status Badges and Visual Indicators¶
Status Badges¶
Use the statusBadge() helper for consistent status rendering:
const statusColors = {
'Active': 'success',
'Draft': 'secondary',
'Pending': 'warning',
'Confirmed': 'primary',
'Cancelled': 'danger'
};
function statusBadge(status) {
const color = statusColors[status] || 'secondary';
return `<span class="badge bg-${color}">${escapeHtml(status || '')}</span>`;
}
Status Dropdown Menus¶
For status transitions, use a dropdown button with allowed transitions:
const TRANSITIONS = {
'Draft': ['Active', 'Cancelled'],
'Active': ['Cancelled'],
'Cancelled': ['Draft']
};
function populateStatusMenu(menuSelector, currentStatus) {
const menu = $(menuSelector);
menu.empty();
const allowed = TRANSITIONS[currentStatus] || [];
allowed.forEach(status => {
menu.append(`<li><a class="dropdown-item status-change"
data-status="${status}"
href="#">${escapeHtml(status)}</a></li>`);
});
}
KPI Cards¶
For dashboard pages, show key metrics:
<div class="kpi-cards mb-3">
<div class="kpi-card primary">
<div class="kpi-icon"><i class="bi bi-box-seam"></i></div>
<div class="kpi-value" id="kpi_total">0</div>
<div class="kpi-title">Total Items</div>
</div>
<div class="kpi-card success">
<div class="kpi-icon"><i class="bi bi-check-circle"></i></div>
<div class="kpi-value" id="kpi_active">0</div>
<div class="kpi-title">Active</div>
</div>
</div>
Preparedness Icons¶
For multi-step workflows, show completion status with colored icons:
const hotelReady = data.hotels.length > 0 &&
data.hotels.every(h => !!h.confirmationNumber);
const statusClass = data.hotels.length === 0
? 'status-none'
: (hotelReady ? 'status-ok' : 'status-warning');
// Render icon
`<i class="bi bi-building status-icon ${statusClass}"
title="Accommodation"></i>`
.status-icon.status-ok { color: #198754; } /* Green */
.status-icon.status-warning { color: #ffc107; } /* Yellow */
.status-icon.status-none { color: #adb5bd; } /* Gray */
15. Multi-Currency and Exchange Rates¶
Import the exchange rate service for features involving money:
import { loadCurrencyConfig, fetchRates, convertToLocal,
populateCurrencySelect } from './exchange-rate-service.js';
// Initialize
const currencyConfig = await loadCurrencyConfig();
const rates = await fetchRates(currencyConfig.localCurrency);
// Populate currency dropdown
populateCurrencySelect('#item_currency', currencyConfig);
// Auto-fill exchange rate on currency change
$('#item_currency').on('change', function() {
const currency = $(this).val();
const localCurr = currencyConfig.localCurrency;
if (currency === localCurr) {
$('#exchange_rate').val(1.0);
} else if (rates[currency]) {
$('#exchange_rate').val(rates[currency]);
}
});
Exchange rates are fetched from open.er-api.com with 24-hour localStorage caching.
16. Card Layouts¶
Entity Cards (Dashboard Grid)¶
For browsable entity collections:
function renderItemCards() {
const container = $('#itemCards');
container.empty();
items.forEach(item => {
container.append(`
<div class="col-md-4 col-lg-3">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h6 class="card-title">${escapeHtml(item.name)}</h6>
<p class="text-muted small mb-2">
${escapeHtml(item.category)}
</p>
<div class="d-flex justify-content-between align-items-center">
${statusBadge(item.status)}
<small class="text-muted">Qty: ${item.quantity}</small>
</div>
</div>
<div class="card-footer bg-transparent">
<a href="inventory-detail.html?itemId=${item.itemId}"
class="btn btn-sm btn-outline-primary w-100">
<i class="bi bi-eye"></i> View
</a>
</div>
</div>
</div>
`);
});
}
Room / Assignment Cards¶
For visual assignment interfaces (rooming, scheduling):
Each card shows capacity, assigned items with remove buttons, and an assign action:
const card = `
<div class="col-md-6 col-lg-4 col-xl-3">
<div class="room-card">
<div class="room-card-header d-flex justify-content-between">
<span>Room ${room.roomNumber}</span>
<span class="badge bg-light text-dark">${assigned}/${capacity}</span>
</div>
<div class="room-card-body">
${assignedItems.map(item => `
<span class="room-pax-tag">
${escapeHtml(item.name)}
<a href="#" class="remove-assignment"
data-room="${room.roomId}" data-item="${item.id}">
<i class="bi bi-x"></i>
</a>
</span>
`).join('')}
${assigned < capacity ? `
<button class="btn btn-sm btn-outline-primary w-100 mt-2"
onclick="assignToRoom(${room.roomId})">
<i class="bi bi-plus"></i> Assign
</button>
` : ''}
</div>
</div>
</div>
`;
Compact Card Strip¶
For horizontally scrollable selection of entities:
.card-strip {
display: flex;
gap: 12px;
overflow-x: auto;
scroll-behavior: smooth;
padding: 8px 0;
}
.card-strip::-webkit-scrollbar { display: none; }
.group-card-compact {
min-width: 220px;
max-width: 220px;
flex-shrink: 0;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.group-card-compact.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(54, 44, 93, 0.2);
}
17. Print Support¶
For pages that need print functionality:
-
Add a print button:
-
Support URL-triggered printing:
-
Add print CSS rules in your module stylesheet:
18. Security Considerations¶
XSS Prevention¶
Always use escapeHtml() when inserting user-supplied data into HTML:
import { escapeHtml } from './globals.js';
// CORRECT
tbody.append(`<td>${escapeHtml(item.name)}</td>`);
// WRONG -- XSS vulnerability
tbody.append(`<td>${item.name}</td>`);
Fields that are system-generated (IDs, status codes from a known set) do not need escaping, but when in doubt, escape.
Authentication¶
Every page that requires login must call setGlobalHandlers() with requireAuth: true:
This:
- Initializes the OIDC auth service
- Checks that the user is authenticated
- Redirects to login if not
- Reveals the page body (removes visibility:hidden)
- Starts session heartbeat
Session Tokens¶
Use getUserSession() to get the session token for API calls. Never hardcode or expose tokens in HTML.
19. Module Structure Checklist¶
When building a new feature, use this checklist:
Files to Create¶
- [ ]
css/your-module.css-- Module-specific styles with CSS custom properties - [ ]
js/modules/your-module-common.js-- API client, shared data cache, form helpers - [ ]
js/modules/your-module-list.js-- List/dashboard page logic - [ ]
js/modules/your-module-detail.js-- Detail page logic (per page type) - [ ]
your-module-list.html-- List page HTML - [ ]
your-module-detail.html-- Detail page HTML
HTML Page Checklist¶
- [ ] Bootstrap 5.3 CSS + Icons loaded from CDN
- [ ]
css/tqapp.css+css/tqadmin.css+ module CSS loaded - [ ]
bodyhasstyle="visibility:hidden"and module-specific class - [ ] jQuery + Bootstrap JS loaded in body, before content
- [ ]
<header id="pgheader" data-load-template="header_bootstrap.html"> - [ ] Modals defined in
<section role="dialog"> - [ ] Main content in
<div class="container-fluid" style="margin-top: 80px;"> - [ ] Breadcrumb navigation
- [ ] Scripts:
pageutil.js→tqpro-compat.js→loadBootstrapTemplates()→ ES6 module - [ ] Module init in
$(document).ready()withsetGlobalHandlers({ requireAuth: true }) - [ ] Functions exposed to
windowscope for anyonclickhandlers
JS Common Module Checklist¶
- [ ] API client object with
request()base method and typed endpoint methods - [ ]
initCommon()loads all reference data viaPromise.all() - [ ] Getter functions for cached data (
getCategories()) - [ ] Reload functions for refreshing cached data (
reloadCategories()) - [ ] Lookup helpers (
findCategory(id)) - [ ]
populateSelect(),populateForm(),extractFormData()if using field configs - [ ]
statusBadge()with module-specific color map - [ ]
performDelete()combining confirmation + API call + notification
JS Page Module Checklist¶
- [ ] Module-level state variables for page data
- [ ] Field config arrays for each entity form
- [ ]
initializePage()as the single entry point (exported) - [ ] Data loading functions with error handling
- [ ] Render functions that use
escapeHtml()and handle empty state - [ ]
setupEventListeners()using event delegation for dynamic content - [ ] CRUD functions: new, edit, save, delete
- [ ] No business logic in the module -- delegate to API