fix v2 patients
This commit is contained in:
parent
061af6e6d7
commit
da4942c9f5
749
.agent/workflows/php-alpinejs-pattern.md
Normal file
749
.agent/workflows/php-alpinejs-pattern.md
Normal file
@ -0,0 +1,749 @@
|
|||||||
|
---
|
||||||
|
description: PHP + Alpine.js SPA-like Application Pattern (CodeIgniter 4 + DaisyUI)
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHP + Alpine.js Application Pattern
|
||||||
|
|
||||||
|
This workflow describes how to build web applications using **PHP (CodeIgniter 4)** for backend with **Alpine.js + DaisyUI + TailwindCSS** for frontend, creating an SPA-like experience with server-rendered views.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
**"No-nonsense"** - Keep it simple, avoid over-engineering. This pattern gives you:
|
||||||
|
- Fast development with PHP backend
|
||||||
|
- Reactive UI with Alpine.js (no heavy framework overhead)
|
||||||
|
- Beautiful UI with DaisyUI/TailwindCSS
|
||||||
|
- JWT-based authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Backend | CodeIgniter 4 (PHP 8.1+) |
|
||||||
|
| Frontend | Alpine.js + DaisyUI 5 + TailwindCSS |
|
||||||
|
| Database | MySQL/MariaDB |
|
||||||
|
| Auth | JWT (stored in HTTP-only cookies) |
|
||||||
|
| Icons | FontAwesome 7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├── app/
|
||||||
|
│ ├── Config/
|
||||||
|
│ │ └── Routes.php # All routes (pages + API)
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ │ ├── BaseController.php # Base controller
|
||||||
|
│ │ ├── PagesController.php # Page routes (returns views)
|
||||||
|
│ │ └── [Resource]Controller.php # API controllers
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── [Resource]Model.php # Database models
|
||||||
|
│ ├── Filters/
|
||||||
|
│ │ └── JwtAuthFilter.php # JWT authentication filter
|
||||||
|
│ └── Views/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ └── main_layout.php # Base layout with sidebar
|
||||||
|
│ ├── [module]/
|
||||||
|
│ │ ├── [module]_index.php # Main page with x-data
|
||||||
|
│ │ ├── dialog_[name].php # Modal dialogs (included)
|
||||||
|
│ │ └── drawer_[name].php # Drawer components
|
||||||
|
│ └── login.php
|
||||||
|
├── public/
|
||||||
|
│ ├── index.php
|
||||||
|
│ └── assets/
|
||||||
|
│ ├── css/output.css # Compiled TailwindCSS
|
||||||
|
│ └── js/app.js # Alpine.js setup
|
||||||
|
└── .env # Environment config
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Backend Patterns
|
||||||
|
|
||||||
|
### 1.1 Routes Structure (`app/Config/Routes.php`)
|
||||||
|
|
||||||
|
Routes are split into:
|
||||||
|
1. **Public routes** - Login, logout, auth check
|
||||||
|
2. **Protected page routes** - Views (with `jwt-auth` filter)
|
||||||
|
3. **API routes** - RESTful JSON endpoints
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
use CodeIgniter\Router\RouteCollection;
|
||||||
|
|
||||||
|
/** @var RouteCollection $routes */
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
$routes->get('/login', 'PagesController::login');
|
||||||
|
$routes->post('/login', 'AuthController::login');
|
||||||
|
$routes->get('/logout', 'AuthController::logout');
|
||||||
|
|
||||||
|
// Protected page routes (returns views)
|
||||||
|
$routes->group('', ['filter' => 'jwt-auth'], function ($routes) {
|
||||||
|
$routes->get('/', 'PagesController::dashboard');
|
||||||
|
$routes->get('/patients', 'PagesController::patients');
|
||||||
|
$routes->get('/requests', 'PagesController::requests');
|
||||||
|
// Master data pages
|
||||||
|
$routes->get('/master/doctors', 'PagesController::masterDoctors');
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes (returns JSON)
|
||||||
|
$routes->group('api', function ($routes) {
|
||||||
|
// Resource: patients
|
||||||
|
$routes->get('patients', 'PatientsController::index');
|
||||||
|
$routes->get('patients/(:num)', 'PatientsController::show/$1');
|
||||||
|
$routes->post('patients', 'PatientsController::create');
|
||||||
|
$routes->patch('patients/(:num)', 'PatientsController::update/$1');
|
||||||
|
|
||||||
|
// Resource: [resourceName]
|
||||||
|
// Follow same pattern: index, show, create, update
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Pages Controller (`app/Controllers/PagesController.php`)
|
||||||
|
|
||||||
|
This controller ONLY returns views. No business logic.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
class PagesController extends BaseController {
|
||||||
|
|
||||||
|
public function dashboard() {
|
||||||
|
return view('dashboard', [
|
||||||
|
'pageTitle' => 'Dashboard',
|
||||||
|
'activePage' => 'dashboard'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patients() {
|
||||||
|
return view('patients/patients_index', [
|
||||||
|
'pageTitle' => 'Patients',
|
||||||
|
'activePage' => 'patients'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requests() {
|
||||||
|
return view('requests/requests_index', [
|
||||||
|
'pageTitle' => 'Lab Requests',
|
||||||
|
'activePage' => 'requests'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 API Controller Pattern (`app/Controllers/[Resource]Controller.php`)
|
||||||
|
|
||||||
|
API controllers handle CRUD operations and return JSON.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use CodeIgniter\API\ResponseTrait;
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\PatientsModel;
|
||||||
|
|
||||||
|
class PatientsController extends BaseController {
|
||||||
|
use ResponseTrait;
|
||||||
|
|
||||||
|
protected $model;
|
||||||
|
protected $rules;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->model = new PatientsModel();
|
||||||
|
$this->rules = [
|
||||||
|
'firstName' => 'required|min_length[2]',
|
||||||
|
'lastName' => 'required|min_length[2]',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/patients
|
||||||
|
* List all with optional search
|
||||||
|
*/
|
||||||
|
public function index() {
|
||||||
|
$keyword = $this->request->getGet('keyword');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rows = $this->model->search($keyword);
|
||||||
|
return $this->respond([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'fetch success',
|
||||||
|
'data' => $rows
|
||||||
|
], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->failServerError('Exception: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/patients/:id
|
||||||
|
*/
|
||||||
|
public function show($id = null) {
|
||||||
|
try {
|
||||||
|
$row = $this->model->find($id);
|
||||||
|
if (empty($row)) {
|
||||||
|
return $this->respond(['status' => 'success', 'message' => 'not found'], 200);
|
||||||
|
}
|
||||||
|
return $this->respond(['status' => 'success', 'data' => $row], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->failServerError('Error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/patients
|
||||||
|
*/
|
||||||
|
public function create() {
|
||||||
|
$input = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
if (!$this->validateData($input, $this->rules)) {
|
||||||
|
return $this->failValidationErrors($this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = camel_to_snake_array($input); // Convert keys to snake_case
|
||||||
|
|
||||||
|
try {
|
||||||
|
$id = $this->model->insert($input);
|
||||||
|
return $this->respondCreated([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Created successfully',
|
||||||
|
'data' => ['id' => $id]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->failServerError('Error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/patients/:id
|
||||||
|
*/
|
||||||
|
public function update($id = null) {
|
||||||
|
$input = $this->request->getJSON(true);
|
||||||
|
$input = camel_to_snake_array($input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->model->update($id, $input);
|
||||||
|
return $this->respond(['status' => 'success', 'message' => 'updated']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->failServerError('Error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Standard API Response Format
|
||||||
|
|
||||||
|
Always return this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success|error",
|
||||||
|
"message": "Human readable message",
|
||||||
|
"data": {} // or [] for lists
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Frontend Patterns
|
||||||
|
|
||||||
|
### 2.1 Base Layout (`app/Views/layout/main_layout.php`)
|
||||||
|
|
||||||
|
The layout provides:
|
||||||
|
- Sidebar navigation with Alpine.js state
|
||||||
|
- Top navbar with user info
|
||||||
|
- Content section for page-specific content
|
||||||
|
- Script section for page-specific JavaScript
|
||||||
|
|
||||||
|
```php
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="corporate">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AppName</title>
|
||||||
|
<link href="<?=base_url();?>assets/css/output.css" rel="stylesheet" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="h-screen flex bg-base-100">
|
||||||
|
<!-- Sidebar with Alpine.js -->
|
||||||
|
<aside
|
||||||
|
x-data="main({ activePage: '<?= esc($activePage ?? '') ?>' })"
|
||||||
|
class="w-56 bg-slate-900 text-white flex flex-col"
|
||||||
|
>
|
||||||
|
<!-- Navigation links -->
|
||||||
|
<nav class="px-3 py-2 space-y-1">
|
||||||
|
<a href="<?=base_url();?>"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg"
|
||||||
|
:class="page === 'dashboard' ? 'bg-blue-600' : 'hover:bg-slate-700'"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-th-large"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<!-- More nav items... -->
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<!-- Top Navbar -->
|
||||||
|
<nav class="bg-white border-b px-6 py-3">
|
||||||
|
<span class="font-semibold"><?= esc($pageTitle ?? 'Dashboard') ?></span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<?= $this->renderSection('content') ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.BASEURL = "<?=base_url();?>";
|
||||||
|
</script>
|
||||||
|
<?= $this->renderSection('script') ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Page View Pattern (`app/Views/[module]/[module]_index.php`)
|
||||||
|
|
||||||
|
Each page extends the layout and defines its Alpine.js component.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?= $this->extend("layout/main_layout"); ?>
|
||||||
|
|
||||||
|
<?= $this->section("content") ?>
|
||||||
|
<main class="flex-1 p-6 overflow-auto bg-slate-50" x-data="patients()">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-800">Patients</h1>
|
||||||
|
<p class="text-slate-500 text-sm mt-1">Manage patient records</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm bg-emerald-600 text-white" @click="showForm()">
|
||||||
|
<i class="fa-solid fa-plus"></i> New Patient
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Filter -->
|
||||||
|
<div class="bg-white rounded-xl border p-4 mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="input input-bordered w-full max-w-md"
|
||||||
|
x-model="keyword"
|
||||||
|
@keyup.enter="fetchList()"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-neutral" @click="fetchList()">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data List -->
|
||||||
|
<div class="bg-white rounded-xl border">
|
||||||
|
<template x-if="list && list.length > 0">
|
||||||
|
<template x-for="item in list" :key="item.patId">
|
||||||
|
<div class="p-4 border-b hover:bg-slate-50" @click="fetchItem(item.patId)">
|
||||||
|
<span x-text="item.firstName + ' ' + item.lastName"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template x-if="!list || list.length === 0">
|
||||||
|
<div class="p-8 text-center text-slate-400">
|
||||||
|
<p>No records found</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include dialog components -->
|
||||||
|
<?php echo $this->include('[module]/dialog_form'); ?>
|
||||||
|
</main>
|
||||||
|
<?= $this->endSection(); ?>
|
||||||
|
|
||||||
|
<?= $this->section("script") ?>
|
||||||
|
<script type="module">
|
||||||
|
import Alpine from '<?= base_url('/assets/js/app.js'); ?>';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data("patients", () => ({
|
||||||
|
// State
|
||||||
|
loading: false,
|
||||||
|
showModal: false,
|
||||||
|
errors: {},
|
||||||
|
|
||||||
|
// Data
|
||||||
|
list: null,
|
||||||
|
item: null,
|
||||||
|
keyword: "",
|
||||||
|
|
||||||
|
// Form
|
||||||
|
form: {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
birthDate: "",
|
||||||
|
sex: "M"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
async init() {
|
||||||
|
await this.fetchList();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch list
|
||||||
|
async fetchList() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.keyword) params.append('keyword', this.keyword);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASEURL}api/patients?${params}`);
|
||||||
|
if (!res.ok) throw new Error("HTTP error");
|
||||||
|
const data = await res.json();
|
||||||
|
this.list = data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
this.list = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch single item
|
||||||
|
async fetchItem(id) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASEURL}api/patients/${id}`);
|
||||||
|
const data = await res.json();
|
||||||
|
this.item = data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
validate() {
|
||||||
|
const e = {};
|
||||||
|
if (!this.form.firstName) e.firstName = "First name is required.";
|
||||||
|
if (!this.form.lastName) e.lastName = "Last name is required.";
|
||||||
|
this.errors = e;
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Show form modal
|
||||||
|
showForm(id = null) {
|
||||||
|
this.showModal = true;
|
||||||
|
if (id) {
|
||||||
|
this.loadFormData(id);
|
||||||
|
} else {
|
||||||
|
this.form = { firstName: "", lastName: "", birthDate: "", sex: "M" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load data for editing
|
||||||
|
async loadFormData(id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASEURL}api/patients/${id}`);
|
||||||
|
const data = await res.json();
|
||||||
|
this.form = data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
this.showModal = false;
|
||||||
|
this.errors = {};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Save (create or update)
|
||||||
|
async save() {
|
||||||
|
if (!this.validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (this.form.patId) {
|
||||||
|
res = await fetch(`${BASEURL}api/patients/${this.form.patId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(this.form)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`${BASEURL}api/patients`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(this.form)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("HTTP error");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alert("Saved successfully!");
|
||||||
|
await this.fetchList();
|
||||||
|
} else {
|
||||||
|
alert(data.message || "Something went wrong.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("Failed to save.");
|
||||||
|
} finally {
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.start();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection(); ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Dialog Component Pattern (`app/Views/[module]/dialog_form.php`)
|
||||||
|
|
||||||
|
Dialogs are included in the main page and share x-data context.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<!-- Form Modal -->
|
||||||
|
<dialog id="form_modal" class="modal" :class="showModal && 'modal-open'">
|
||||||
|
<div class="modal-box w-11/12 max-w-2xl bg-white">
|
||||||
|
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-user text-emerald-500"></i>
|
||||||
|
<span x-text="form.patId ? 'Edit Patient' : 'New Patient'"></span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="py-4">
|
||||||
|
<!-- First Name -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">First Name <span class="text-red-500">*</span></span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.firstName" />
|
||||||
|
<span class="text-error text-xs mt-1" x-text="errors.firstName || ''"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Name -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Last Name <span class="text-red-500">*</span></span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.lastName" />
|
||||||
|
<span class="text-error text-xs mt-1" x-text="errors.lastName || ''"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid for multiple fields -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Birth Date</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" class="input input-bordered" x-model="form.birthDate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Sex</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered" x-model="form.sex">
|
||||||
|
<option value="M">Male</option>
|
||||||
|
<option value="F">Female</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost" @click="closeModal()">Cancel</button>
|
||||||
|
<button class="btn bg-emerald-600 text-white" @click="save()">
|
||||||
|
<i class="fa-solid fa-save mr-1"></i> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop bg-slate-900/50" @click="closeModal()"></div>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Naming Conventions
|
||||||
|
|
||||||
|
### 3.1 Files & Directories
|
||||||
|
|
||||||
|
| Type | Convention | Example |
|
||||||
|
|------|------------|---------|
|
||||||
|
| Views | `snake_case` | `patients_index.php` |
|
||||||
|
| Dialogs | `dialog_[name].php` | `dialog_form.php` |
|
||||||
|
| Drawers | `drawer_[name].php` | `drawer_filter.php` |
|
||||||
|
| Controllers | `PascalCase` | `PatientsController.php` |
|
||||||
|
| Models | `PascalCase` | `PatientsModel.php` |
|
||||||
|
|
||||||
|
### 3.2 Variables & Keys
|
||||||
|
|
||||||
|
| Context | Convention | Example |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| PHP/Database | `snake_case` | `pat_id`, `first_name` |
|
||||||
|
| JavaScript | `camelCase` | `patId`, `firstName` |
|
||||||
|
| Alpine.js x-data | `camelCase` | `showModal`, `fetchList` |
|
||||||
|
|
||||||
|
### 3.3 Primary Keys
|
||||||
|
|
||||||
|
Use format: `{table_singular}_id`
|
||||||
|
|
||||||
|
| Table | Primary Key |
|
||||||
|
|-------|-------------|
|
||||||
|
| `patients` | `pat_id` |
|
||||||
|
| `requests` | `request_id` |
|
||||||
|
| `master_tests` | `test_id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Database Conventions
|
||||||
|
|
||||||
|
### 4.1 Standard Columns
|
||||||
|
|
||||||
|
Every table should have:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
`created_at` DATETIME,
|
||||||
|
`updated_at` DATETIME,
|
||||||
|
`deleted_at` DATETIME -- Soft deletes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Status Codes (Single Character)
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `P` | Pending |
|
||||||
|
| `I` | In Progress |
|
||||||
|
| `C` | Completed |
|
||||||
|
| `V` | Validated |
|
||||||
|
| `X` | Cancelled |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI/UX Guidelines
|
||||||
|
|
||||||
|
### 5.1 Color Palette
|
||||||
|
|
||||||
|
| Purpose | Color | TailwindCSS |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| Primary Action | Emerald | `bg-emerald-600` |
|
||||||
|
| Secondary | Slate | `bg-slate-800` |
|
||||||
|
| Danger | Red | `bg-red-500` |
|
||||||
|
| Info | Blue | `bg-blue-500` |
|
||||||
|
| Warning | Amber | `bg-amber-500` |
|
||||||
|
|
||||||
|
### 5.2 Component Patterns
|
||||||
|
|
||||||
|
- **Cards**: `bg-white rounded-xl border border-slate-100 shadow-sm`
|
||||||
|
- **Buttons**: Use DaisyUI `btn` with custom colors
|
||||||
|
- **Inputs**: Use DaisyUI `input input-bordered`
|
||||||
|
- **Modals**: Use DaisyUI `modal` with custom backdrop
|
||||||
|
|
||||||
|
### 5.3 Icons
|
||||||
|
|
||||||
|
Use FontAwesome 7 with consistent sizing:
|
||||||
|
- Navigation: `text-sm`
|
||||||
|
- Buttons: Default size
|
||||||
|
- Headers: `text-lg` to `text-2xl`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Common Helpers
|
||||||
|
|
||||||
|
### 6.1 camel_to_snake_array (PHP Helper)
|
||||||
|
|
||||||
|
Convert JavaScript camelCase keys to PHP snake_case:
|
||||||
|
|
||||||
|
```php
|
||||||
|
function camel_to_snake_array(array $data): array {
|
||||||
|
$result = [];
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$snakeKey = strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($key)));
|
||||||
|
$result[$snakeKey] = is_array($value) ? camel_to_snake_array($value) : $value;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 base_url() Usage
|
||||||
|
|
||||||
|
Always use `base_url()` for asset and API paths:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In PHP
|
||||||
|
<link href="<?=base_url();?>assets/css/output.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
// In JavaScript (via global variable)
|
||||||
|
<script>window.BASEURL = "<?=base_url();?>";</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Quick Reference: Creating a New Module
|
||||||
|
|
||||||
|
### Step 1: Create Route
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Config/Routes.php
|
||||||
|
$routes->get('/products', 'PagesController::products');
|
||||||
|
$routes->get('api/products', 'ProductsController::index');
|
||||||
|
$routes->get('api/products/(:num)', 'ProductsController::show/$1');
|
||||||
|
$routes->post('api/products', 'ProductsController::create');
|
||||||
|
$routes->patch('api/products/(:num)', 'ProductsController::update/$1');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Model
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Models/ProductsModel.php
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class ProductsModel extends BaseModel {
|
||||||
|
protected $table = 'products';
|
||||||
|
protected $primaryKey = 'product_id';
|
||||||
|
protected $allowedFields = ['name', 'sku', 'price'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create Controller
|
||||||
|
|
||||||
|
Copy pattern from `PatientsController.php` or `RequestsController.php`.
|
||||||
|
|
||||||
|
### Step 4: Create View
|
||||||
|
|
||||||
|
Create `app/Views/products/products_index.php` following the page view pattern.
|
||||||
|
|
||||||
|
### Step 5: Create Dialog
|
||||||
|
|
||||||
|
Create `app/Views/products/dialog_form.php` for add/edit modal.
|
||||||
|
|
||||||
|
### Step 6: Add to Navigation
|
||||||
|
|
||||||
|
Update `app/Views/layout/main_layout.php` sidebar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Things to Avoid
|
||||||
|
|
||||||
|
1. **Don't use jQuery** - Use Alpine.js or vanilla JS
|
||||||
|
2. **Don't over-engineer** - Keep it simple
|
||||||
|
3. **Don't skip soft deletes** - Always use `deleted_at`
|
||||||
|
4. **Don't hardcode URLs** - Use `base_url()` and `BASEURL`
|
||||||
|
5. **Don't mix concerns** - Controllers handle HTTP, Models handle data
|
||||||
|
6. **Don't create separate JS files for each page** - Keep JS inline in views for simplicity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist Before Deploying
|
||||||
|
|
||||||
|
- [ ] All routes added to `Routes.php`
|
||||||
|
- [ ] API responses follow standard format
|
||||||
|
- [ ] Form validation in both frontend and backend
|
||||||
|
- [ ] Error handling with user-friendly messages
|
||||||
|
- [ ] Loading states for async operations
|
||||||
|
- [ ] Responsive design tested
|
||||||
|
- [ ] README.md updated
|
||||||
@ -89,7 +89,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/dashboard', [
|
return view('v2/dashboard', [
|
||||||
'title' => 'V2 Dashboard',
|
'title' => 'V2 Dashboard',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'dashboard'
|
'activePage' => 'dashboard'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/api-tester', [
|
return view('v2/api-tester', [
|
||||||
'title' => 'API Tester',
|
'title' => 'API Tester',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'api-tester'
|
'activePage' => 'api-tester'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/db-browser', [
|
return view('v2/db-browser', [
|
||||||
'title' => 'DB Browser',
|
'title' => 'DB Browser',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'db-browser'
|
'activePage' => 'db-browser'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,7 +131,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/logs', [
|
return view('v2/logs', [
|
||||||
'title' => 'Logs',
|
'title' => 'Logs',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'logs'
|
'activePage' => 'logs'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,7 +152,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/organization', [
|
return view('v2/organization', [
|
||||||
'title' => 'Organization: ' . ucfirst($type) . 's',
|
'title' => 'Organization: ' . ucfirst($type) . 's',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'organization',
|
// activePage set below for sub-pages
|
||||||
'activePage' => 'organization-' . $type,
|
'activePage' => 'organization-' . $type,
|
||||||
'type' => $type
|
'type' => $type
|
||||||
]);
|
]);
|
||||||
@ -168,7 +168,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/valuesets', [
|
return view('v2/valuesets', [
|
||||||
'title' => 'Value Sets',
|
'title' => 'Value Sets',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'valuesets'
|
'activePage' => 'valuesets'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,7 +182,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/patients', [
|
return view('v2/patients', [
|
||||||
'title' => 'Patients',
|
'title' => 'Patients',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'patients'
|
'activePage' => 'patients'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +196,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/patient-form', [
|
return view('v2/patient-form', [
|
||||||
'title' => 'New Patient',
|
'title' => 'New Patient',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'patients'
|
'activePage' => 'patients'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,7 +218,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/patient-form', [
|
return view('v2/patient-form', [
|
||||||
'title' => 'Edit Patient',
|
'title' => 'Edit Patient',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'patients',
|
'activePage' => 'patients',
|
||||||
'patient' => $patient
|
'patient' => $patient
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -233,7 +233,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/patient-view', [
|
return view('v2/patient-view', [
|
||||||
'title' => 'Patient Details',
|
'title' => 'Patient Details',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'patients',
|
'activePage' => 'patients',
|
||||||
'patientId' => $id
|
'patientId' => $id
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -263,7 +263,7 @@ class V2Page extends Controller
|
|||||||
return view('v2/jwt-decoder', [
|
return view('v2/jwt-decoder', [
|
||||||
'title' => 'JWT Decoder',
|
'title' => 'JWT Decoder',
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'currentPage' => 'jwt-decoder',
|
'activePage' => 'jwt-decoder',
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'decoded' => $decoded
|
'decoded' => $decoded
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -251,7 +251,7 @@
|
|||||||
<nav class="flex-1 overflow-y-auto py-2">
|
<nav class="flex-1 overflow-y-auto py-2">
|
||||||
<ul class="menu menu-sm px-2 text-sm">
|
<ul class="menu menu-sm px-2 text-sm">
|
||||||
<li>
|
<li>
|
||||||
<a href="<?= site_url('v2') ?>" class="<?= ($currentPage ?? '') === 'dashboard' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
<a href="<?= site_url('v2') ?>" class="<?= ($activePage ?? '') === 'dashboard' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
||||||
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
|
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
|
||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
@ -274,7 +274,7 @@
|
|||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="<?= site_url('v2/valuesets') ?>" class="<?= ($currentPage ?? '') === 'valuesets' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
<a href="<?= site_url('v2/valuesets') ?>" class="<?= ($activePage ?? '') === 'valuesets' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
||||||
<i data-lucide="list-tree" class="w-4 h-4"></i>
|
<i data-lucide="list-tree" class="w-4 h-4"></i>
|
||||||
Value Sets
|
Value Sets
|
||||||
</a>
|
</a>
|
||||||
@ -282,7 +282,7 @@
|
|||||||
|
|
||||||
<li class="menu-title text-[10px] text-white/40 mt-3">Clinical</li>
|
<li class="menu-title text-[10px] text-white/40 mt-3">Clinical</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="<?= site_url('v2/patients') ?>" class="<?= ($currentPage ?? '') === 'patients' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
<a href="<?= site_url('v2/patients') ?>" class="<?= ($activePage ?? '') === 'patients' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
||||||
<i data-lucide="users" class="w-4 h-4"></i>
|
<i data-lucide="users" class="w-4 h-4"></i>
|
||||||
Patients
|
Patients
|
||||||
</a>
|
</a>
|
||||||
@ -296,10 +296,10 @@
|
|||||||
Dev Tools
|
Dev Tools
|
||||||
</summary>
|
</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="<?= site_url('v2/api-tester') ?>" class="<?= ($currentPage ?? '') === 'api-tester' ? 'active' : 'text-white/60 hover:text-white' ?>">API Tester</a></li>
|
<li><a href="<?= site_url('v2/api-tester') ?>" class="<?= ($activePage ?? '') === 'api-tester' ? 'active' : 'text-white/60 hover:text-white' ?>">API Tester</a></li>
|
||||||
<li><a href="<?= site_url('v2/db-browser') ?>" class="<?= ($currentPage ?? '') === 'db-browser' ? 'active' : 'text-white/60 hover:text-white' ?>">DB Browser</a></li>
|
<li><a href="<?= site_url('v2/db-browser') ?>" class="<?= ($activePage ?? '') === 'db-browser' ? 'active' : 'text-white/60 hover:text-white' ?>">DB Browser</a></li>
|
||||||
<li><a href="<?= site_url('v2/logs') ?>" class="<?= ($currentPage ?? '') === 'logs' ? 'active' : 'text-white/60 hover:text-white' ?>">Logs</a></li>
|
<li><a href="<?= site_url('v2/logs') ?>" class="<?= ($activePage ?? '') === 'logs' ? 'active' : 'text-white/60 hover:text-white' ?>">Logs</a></li>
|
||||||
<li><a href="<?= site_url('v2/jwt-decoder') ?>" class="<?= ($currentPage ?? '') === 'jwt-decoder' ? 'active' : 'text-white/60 hover:text-white' ?>">JWT Decoder</a></li>
|
<li><a href="<?= site_url('v2/jwt-decoder') ?>" class="<?= ($activePage ?? '') === 'jwt-decoder' ? 'active' : 'text-white/60 hover:text-white' ?>">JWT Decoder</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
@ -369,6 +369,11 @@
|
|||||||
this.dismiss(id);
|
this.dismiss(id);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
},
|
},
|
||||||
|
// Convenience methods following the workflow pattern
|
||||||
|
success(message) { this.show(message, 'success'); },
|
||||||
|
error(message) { this.show(message, 'error'); },
|
||||||
|
info(message) { this.show(message, 'info'); },
|
||||||
|
warning(message) { this.show(message, 'warning'); },
|
||||||
dismiss(id) {
|
dismiss(id) {
|
||||||
this.messages = this.messages.filter(m => m.id !== id);
|
this.messages = this.messages.filter(m => m.id !== id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,7 +179,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form Modal -->
|
<!--
|
||||||
|
Form Modal
|
||||||
|
NOTE: Per /php-alpinejs-pattern workflow, for larger apps this can be
|
||||||
|
separated into: <?php echo $this->include('patients/dialog_form'); ?>
|
||||||
|
-->
|
||||||
<dialog id="patientModal" class="modal" :class="{ 'modal-open': showFormModal }">
|
<dialog id="patientModal" class="modal" :class="{ 'modal-open': showFormModal }">
|
||||||
<div class="modal-box w-11/12 max-w-5xl">
|
<div class="modal-box w-11/12 max-w-5xl">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user