tinyqc/VIEWS_RULES.md
mahdahar ff90e0eb29 Initial commit: Add CodeIgniter 4 QC application with full MVC structure
- 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
2026-01-14 16:49:27 +07:00

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` |