- CodeIgniter 4 framework setup with SQL Server database config - Models: Control, Test, Dept, Result, Daily/ Monthly entry models - Controllers: Dashboard, Control, Test, Dept, Entry, Report, API endpoints - Views: CRUD pages with modal dialogs, dashboard, reports - Database: Migrations for control test and daily/monthly result tables - Legacy v1 PHP application preserved in /v1 directory - Documentation: AGENTS.md, VIEWS_RULES.md for development guidelines
19 KiB
19 KiB
TinyLab View Rules
This document defines the view patterns and conventions used in TinyLab. Follow these rules when creating new views or modifying existing ones.
1. Directory Structure
app/Views/
├── layout/
│ ├── main_layout.php # Primary app layout (sidebar, navbar, dark mode)
│ └── form_layout.php # Lightweight layout for standalone forms
├── [module_name]/ # Feature module (e.g., patients, requests)
│ ├── index.php # Main list page
│ ├── dialog_form.php # Modal form (Create/Edit)
│ └── drawer_filter.php # Filter drawer (optional)
├── master/
│ └── [entity]/ # Master data entity (e.g., doctors, samples)
│ ├── index.php
│ └── dialog_form.php
└── errors/
└── html/
├── error_404.php
└── error_exception.php
2. Layout Extension
All pages must extend the appropriate layout:
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<!-- Page content here -->
<?= $this->endSection(); ?>
<?= $this->section("script") ?>
<!-- Alpine.js scripts here -->
<?= $this->endSection(); ?>
Use form_layout.php for standalone pages like login:
<?= $this->extend("layout/form_layout"); ?>
3. Alpine.js Integration
3.1 Component Definition
Always use alpine:init event listener:
<?= $this->section("script") ?>
<script type="module">
import Alpine from '<?= base_url('/assets/js/app.js'); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data("componentName", () => ({
// State
loading: false,
showModal: false,
list: [],
item: null,
form: {},
errors: {},
keyword: '',
// Lifecycle
init() {
this.fetchList();
},
// Methods
async fetchList() {
this.loading = true;
try {
const res = await fetch(`${window.BASEURL}/api/resource`);
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
} finally {
this.loading = false;
}
},
showForm(id = null) {
this.form = id ? this.list.find(x => x.id === id) : {};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errors = {};
},
validate() {
this.errors = {};
if (!this.form.field) this.errors.field = 'Field is required';
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
try {
const method = this.form.id ? 'PATCH' : 'POST';
const res = await fetch(`${window.BASEURL}/api/resource`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
const data = await res.json();
if (data.status === 'success') {
this.closeModal();
this.fetchList();
}
} finally {
this.loading = false;
}
}
}));
});
Alpine.start();
</script>
<?= $this->endSection(); ?>
3.2 Alpine Data Attribute
Wrap page content in the component:
<main x-data="componentName()">
<!-- Page content -->
</main>
4. Modal/Dialog Pattern
4.1 Standard Modal Structure
<!-- Backdrop -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-40"
@click="closeModal()">
</div>
<!-- Modal Panel -->
<div x-show="showModal"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl" @click.stop>
<!-- Header -->
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-800" x-text="form.id ? 'Edit' : 'New'"></h3>
<button @click="closeModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<!-- Form -->
<form @submit.prevent="save()">
<div class="p-6">
<!-- Form fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Field <span class="text-red-500">*</span></label>
<input type="text" x-model="form.field"
class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
:class="{'border-red-300 bg-red-50': errors.field}" />
<p x-show="errors.field" x-text="errors.field" class="text-red-500 text-xs mt-1"></p>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50 rounded-b-2xl">
<button type="button" @click="closeModal()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors">Cancel</button>
<button type="submit" :disabled="loading" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<template x-if="!loading"><span><i class="fa-solid fa-check mr-1"></i> Save</span></template>
<template x-if="loading"><span><svg class="animate-spin h-4 w-4 mr-1 inline" viewBox="0 0 24 24"><!-- SVG --></svg> Saving...</span></template>
</button>
</div>
</form>
</div>
</div>
5. Page Structure Pattern
5.1 Standard CRUD Page
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="componentName()">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Module Title</h1>
<p class="text-sm text-slate-500 mt-1">Module description</p>
</div>
<button @click="showForm()" class="btn btn-primary">
<i class="fa-solid fa-plus mr-2"></i>New Item
</button>
</div>
<!-- Search Card -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-4 mb-6">
<div class="flex gap-3">
<div class="flex-1 relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
<input type="text" x-model="keyword" @keyup.enter="fetchList()"
class="w-full pl-10 pr-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
placeholder="Search..." />
</div>
<button @click="fetchList()" class="btn btn-primary">
<i class="fa-solid fa-search mr-2"></i>Search
</button>
</div>
</div>
<!-- Error Alert -->
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<div class="flex items-center gap-2">
<i class="fa-solid fa-circle-exclamation"></i>
<span x-text="error"></span>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
<!-- Loading State -->
<template x-if="loading">
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-spinner fa-spin text-blue-500 text-xl"></i>
</div>
<p class="text-slate-500 text-sm">Loading...</p>
</div>
</template>
<!-- Empty State -->
<template x-if="!loading && (!list || list.length === 0)">
<div class="flex-1 flex items-center justify-center p-8">
<div class="text-center">
<div class="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mx-auto mb-3">
<i class="fa-solid fa-inbox text-slate-400"></i>
</div>
<p class="text-slate-500 text-sm">No data found</p>
</div>
</div>
</template>
<!-- Data Table -->
<template x-if="!loading && list && list.length > 0">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
<tr>
<th class="py-3 px-5 font-semibold">#</th>
<th class="py-3 px-5 font-semibold">Code</th>
<th class="py-3 px-5 font-semibold">Name</th>
<th class="py-3 px-5 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="(item, index) in list" :key="item.id">
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-5" x-text="index + 1"></td>
<td class="py-3 px-5">
<span class="font-mono text-xs bg-slate-100 text-slate-600 px-2 py-1 rounded" x-text="item.code"></span>
</td>
<td class="py-3 px-5 font-medium text-slate-800" x-text="item.name"></td>
<td class="py-3 px-5 text-right">
<button @click="showForm(item.id)" class="text-blue-600 hover:text-blue-800 mr-3">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button @click="delete(item.id)" class="text-red-600 hover:text-red-800">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Dialog Include -->
<?= $this->include('module/dialog_form'); ?>
</main>
<?= $this->endSection(); ?>
5.2 Master-Detail Pattern
<!-- Master-Detail Layout -->
<div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 min-h-0">
<!-- List Panel -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden flex flex-col">
<div class="p-4 border-b border-slate-100">
<input type="text" x-model="keyword" @keyup.enter="fetchList()" placeholder="Search..." class="w-full px-3 py-2 text-sm bg-slate-50 border border-slate-200 rounded-lg" />
</div>
<div class="flex-1 overflow-y-auto">
<template x-for="item in list" :key="item.id">
<div class="px-4 py-3 hover:bg-blue-50/50 cursor-pointer border-b border-slate-50"
:class="{'bg-blue-50': selectedId === item.id}"
@click="fetchDetail(item.id)">
<p class="font-medium text-slate-800" x-text="item.name"></p>
<p class="text-xs text-slate-500" x-text="item.code"></p>
</div>
</template>
</div>
</div>
<!-- Detail Panel -->
<div class="lg:col-span-2 bg-white rounded-xl border border-slate-100 shadow-sm">
<template x-if="!detail">
<div class="flex items-center justify-center h-full min-h-[400px] text-slate-400">
<div class="text-center">
<i class="fa-solid fa-folder-open text-4xl mb-3"></i>
<p>Select an item to view details</p>
</div>
</div>
</template>
<template x-if="detail">
<div class="p-6">
<!-- Detail content -->
</div>
</template>
</div>
</div>
6. Form Field Patterns
6.1 Input with Icon
<div class="relative">
<i class="fa-solid fa-icon absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
<input type="text" x-model="form.field"
class="w-full pl-10 pr-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
placeholder="Placeholder text" />
</div>
6.2 Select Dropdown
<select x-model="form.field" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">-- Select --</option>
<template x-for="option in options" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
6.3 Checkbox
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.field" class="checkbox checkbox-sm checkbox-primary" />
<span class="text-sm text-slate-700">Checkbox label</span>
</label>
6.4 Radio Group
<div class="flex gap-4">
<template x-for="option in options" :key="option.value">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" :name="'fieldName'" :value="option.value" x-model="form.field" class="radio radio-sm radio-primary" />
<span class="text-sm text-slate-700" x-text="option.label"></span>
</label>
</template>
</div>
7. Status Badges
<span class="px-2 py-0.5 rounded-full text-xs font-medium"
:class="{
'bg-amber-100 text-amber-700': status === 'P',
'bg-blue-100 text-blue-700': status === 'I',
'bg-emerald-100 text-emerald-700': status === 'C',
'bg-red-100 text-red-700': status === 'X'
}"
x-text="getStatusLabel(status)">
</span>
8. CSS Classes Reference
Layout
flex,flex-col,grid,grid-cols-2,gap-4w-full,max-w-2xl,min-h-0overflow-hidden,overflow-y-auto
Spacing
p-4,px-6,py-3,m-4,mb-6space-x-4,gap-3
Typography
text-sm,text-xs,text-lg,text-2xlfont-medium,font-semibold,font-boldtext-slate-500,text-slate-800
Borders & Backgrounds
bg-white,bg-slate-50,bg-blue-50border,border-slate-100,border-red-300rounded-lg,rounded-xl,rounded-2xl
Components
btn btn-primary,btn btn-ghostcheckbox checkbox-smradio radio-primaryinput,select,textarea
Icons
FontAwesome 7 via CDN: <i class="fa-solid fa-icon-name"></i>
9. API Response Format
Views expect API responses in this format:
{
"status": "success",
"message": "Operation successful",
"data": [...]
}
10. Validation Pattern
validate() {
this.errors = {};
if (!this.form.field1) {
this.errors.field1 = 'Field 1 is required';
}
if (!this.form.field2) {
this.errors.field2 = 'Field 2 is required';
}
if (this.form.field3 && !/^\d+$/.test(this.form.field3)) {
this.errors.field3 = 'Must be numeric';
}
return Object.keys(this.errors).length === 0;
}
11. Error Handling
async save() {
this.loading = true;
try {
const res = await fetch(url, { ... });
const data = await res.json();
if (data.status === 'success') {
this.showToast('Saved successfully');
this.fetchList();
} else {
this.error = data.message || 'An error occurred';
}
} catch (err) {
this.error = 'Network error. Please try again.';
console.error(err);
} finally {
this.loading = false;
}
}
12. Quick Reference: File Templates
Standard List Page Template
See: app/Views/master/doctors/doctors_index.php
Modal Form Template
See: app/Views/master/doctors/dialog_doctors_form.php
Master-Detail Page Template
See: app/Views/patients/patients_index.php
Result Entry Table Template
See: app/Views/requests/dialog_result_entry.php
13. Checklist for New Views
- Create view file in correct directory
- Extend appropriate layout (
main_layoutorform_layout) - Define
contentandscriptsections - Wrap content in
x-datacomponent - Include modal form if needed
- Use DaisyUI components for buttons, inputs, selects
- Add loading and empty states
- Implement proper error handling
- Use FontAwesome icons consistently
- Follow naming conventions (snake_case for PHP, camelCase for JS)
14. Conventions Summary
| Aspect | Convention |
|---|---|
| File naming | snake_case (e.g., patients_index.php) |
| PHP variables | snake_case |
| JavaScript variables | camelCase |
| HTML classes | kebab-case (Tailwind) |
| Icons | FontAwesome 7 |
| UI Framework | TailwindCSS + DaisyUI |
| State management | Alpine.js |
| API format | JSON with status, message, data |
| Primary key | {table}_id (e.g., pat_id) |
| Soft deletes | All tables use deleted_at |