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

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

{
    "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_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