- 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
532 lines
19 KiB
Markdown
532 lines
19 KiB
Markdown
# 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:
|
|
|
|
```php
|
|
<?= $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:
|
|
```php
|
|
<?= $this->extend("layout/form_layout"); ?>
|
|
```
|
|
|
|
## 3. Alpine.js Integration
|
|
|
|
### 3.1 Component Definition
|
|
|
|
Always use `alpine:init` event listener:
|
|
|
|
```php
|
|
<?= $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:
|
|
|
|
```php
|
|
<main x-data="componentName()">
|
|
<!-- Page content -->
|
|
</main>
|
|
```
|
|
|
|
## 4. Modal/Dialog Pattern
|
|
|
|
### 4.1 Standard Modal Structure
|
|
|
|
```php
|
|
<!-- 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
|
|
|
|
```php
|
|
<?= $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
|
|
|
|
```php
|
|
<!-- 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
|
|
```php
|
|
<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
|
|
```php
|
|
<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
|
|
```php
|
|
<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
|
|
```php
|
|
<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
|
|
|
|
```php
|
|
<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-4`
|
|
- `w-full`, `max-w-2xl`, `min-h-0`
|
|
- `overflow-hidden`, `overflow-y-auto`
|
|
|
|
### Spacing
|
|
- `p-4`, `px-6`, `py-3`, `m-4`, `mb-6`
|
|
- `space-x-4`, `gap-3`
|
|
|
|
### Typography
|
|
- `text-sm`, `text-xs`, `text-lg`, `text-2xl`
|
|
- `font-medium`, `font-semibold`, `font-bold`
|
|
- `text-slate-500`, `text-slate-800`
|
|
|
|
### Borders & Backgrounds
|
|
- `bg-white`, `bg-slate-50`, `bg-blue-50`
|
|
- `border`, `border-slate-100`, `border-red-300`
|
|
- `rounded-lg`, `rounded-xl`, `rounded-2xl`
|
|
|
|
### Components
|
|
- `btn btn-primary`, `btn btn-ghost`
|
|
- `checkbox checkbox-sm`
|
|
- `radio radio-primary`
|
|
- `input`, `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:
|
|
|
|
```json
|
|
{
|
|
"status": "success",
|
|
"message": "Operation successful",
|
|
"data": [...]
|
|
}
|
|
```
|
|
|
|
## 10. Validation Pattern
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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_layout` or `form_layout`)
|
|
- [ ] Define `content` and `script` sections
|
|
- [ ] Wrap content in `x-data` component
|
|
- [ ] 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` |
|