refactor: Replace dropdowns with select inputs and improve caching
- Replace dropdown components with select elements in department filters - Add cache-control headers to test and control API endpoints - Add merged report page for consolidated reporting - Update navigation sidebar with separate report links - Refactor AGENTS.md to concise format with Serena tools emphasis - Clean up gitignore and remove CLAUDE.md
This commit is contained in:
parent
4ae2c75fdd
commit
ef6be6522e
4
.gitignore
vendored
4
.gitignore
vendored
@ -126,5 +126,5 @@ _modules/*
|
||||
/phpunit*.xml
|
||||
|
||||
.claude/
|
||||
_bmad/
|
||||
_bmad-output/
|
||||
.serena/
|
||||
CLAUDE.md
|
||||
|
||||
666
AGENTS.md
666
AGENTS.md
@ -1,597 +1,169 @@
|
||||
# AGENTS.md - AI Agent Guidelines for [PROJECT NAME]
|
||||
# AGENTS.md - AI Agent Guidelines for TinyQC
|
||||
|
||||
## AI Agent Guidelines
|
||||
## Important: Always Use Serena Tools
|
||||
|
||||
1. **Readability**: Write code that is easy to read and understand.
|
||||
2. **Maintainability**: Write code that is easy to maintain and update.
|
||||
3. **Performance**: Write code that is fast and efficient.
|
||||
4. **Security**: Write code that is secure and protected against attacks.
|
||||
5. **Testing**: Write code that is tested and verified.
|
||||
**When editing this codebase, prefer Serena tools over regular file operations:**
|
||||
- Use `find_symbol` to locate code rather than grep/glob when possible
|
||||
- Use `replace_symbol_body` for entire method/class changes
|
||||
- Use `replace_content` with regex for targeted edits
|
||||
- Use `get_symbols_overview` before reading full files
|
||||
- Serena tools provide structured, semantically-aware code editing
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
|
||||
never start dev server just use the base_url from .env file
|
||||
|
||||
# Run all tests
|
||||
./vendor/bin/phpunit
|
||||
composer test
|
||||
|
||||
# Run single test file
|
||||
./vendor/bin/phpunit tests/unit/HealthTest.php
|
||||
|
||||
# Run with coverage
|
||||
./vendor/bin/phpunit --coverage-html coverage/
|
||||
|
||||
# Run specific test method
|
||||
./vendor/bin/phpunit --filter testMethodName
|
||||
|
||||
# Run migrations
|
||||
php spark migrate
|
||||
php spark migrate:rollback
|
||||
|
||||
# Clear cache
|
||||
php spark cache:clear
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Backend | CodeIgniter 4 (PHP 8.1+) |
|
||||
| Frontend | Alpine.js + TailwindCSS |
|
||||
| Database | MySQL/MariaDB |
|
||||
| Frontend | Alpine.js + TailwindCSS + DaisyUI |
|
||||
| Database | SQL Server |
|
||||
|
||||
## Key Files & Locations
|
||||
## Code Style Guidelines
|
||||
|
||||
### Backend
|
||||
|
||||
```
|
||||
app/Controllers/ # API & page controllers
|
||||
app/Models/ # Eloquent-style models
|
||||
app/Database/Migrations/ # Schema definitions
|
||||
app/Config/Routes.php # All routes defined here
|
||||
app/Helpers/ # Helper functions
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```
|
||||
app/Views/ # PHP views with Alpine.js
|
||||
app/Views/layout/ # Base templates
|
||||
public/ # Static assets (css, js)
|
||||
```
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### PHP / CodeIgniter 4
|
||||
|
||||
1. **Controllers** extend `BaseController` and use `ResponseTrait`
|
||||
2. **Models** extend `App\Models\BaseModel` (custom base with auto camel/snake conversion)
|
||||
3. **Soft deletes** are enabled on all tables (`deleted_at`)
|
||||
4. **Timestamps** are automatic (`created_at`, `updated_at`)
|
||||
5. **Validation** happens in controllers, not models
|
||||
6. **JSON API responses** follow this structure:
|
||||
```php
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $rows
|
||||
], 200);
|
||||
```
|
||||
|
||||
7. **Use camelCase for input/output**, snake_case for database:
|
||||
- Controllers convert camel→snake before insert/update
|
||||
- Models convert snake→camel after fetch
|
||||
- Use helper functions: `camel_to_snake()`, `camel_to_snake_array()`, `snake_to_camel()`
|
||||
|
||||
### Database
|
||||
|
||||
1. **Primary keys**: `{table_singular}_id` (e.g., `item_id`, `pat_id`)
|
||||
2. **Foreign keys**: Match the referenced primary key name
|
||||
3. **Naming**: All lowercase, underscores
|
||||
4. **Soft deletes**: All tables have `deleted_at` DATETIME column
|
||||
5. **Master data tables**: Prefix with `master_` (e.g., `master_items`)
|
||||
6. **Timestamps**: `created_at`, `updated_at` DATETIME columns
|
||||
7. **Unique constraints**: Add on code fields (e.g., `item_code`)
|
||||
|
||||
### Frontend / Alpine.js
|
||||
|
||||
1. **x-data** on container elements
|
||||
2. **Fetch API** for AJAX calls (no jQuery)
|
||||
3. **DaisyUI components** for UI elements
|
||||
4. **camelCase** for JavaScript, **snake_case** for PHP/DB
|
||||
5. **Modals** with x-show and x-transition
|
||||
6. **ES6 modules** importing Alpine from `app.js`
|
||||
|
||||
### File Naming
|
||||
|
||||
| Component | Pattern | Example |
|
||||
|-----------|---------|---------|
|
||||
| Controller | `PascalCase + Controller` | `ItemsController` |
|
||||
| Model | `PascalCase + Model` | `ItemsModel` |
|
||||
| Migration | `YYYY-MM-DD-XXXXXX_Description.php` | `2026-01-15-000001_Items.php` |
|
||||
| View | `module/action.php` | `items/index.php` |
|
||||
| Helper | `snake_case + _helper.php` | `stringcase_helper.php` |
|
||||
| Filter | `PascalCase + Filter.php` | `JwtAuthFilter.php` |
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── Config/
|
||||
│ ├── Routes.php
|
||||
│ └── Filters.php
|
||||
├── Controllers/
|
||||
│ ├── BaseController.php
|
||||
│ ├── ItemsController.php
|
||||
│ └── Master/
|
||||
│ └── ItemsController.php
|
||||
├── Database/
|
||||
│ └── Migrations/
|
||||
├── Filters/
|
||||
│ └── JwtAuthFilter.php
|
||||
├── Helpers/
|
||||
│ ├── stringcase_helper.php
|
||||
│ └── utc_helper.php
|
||||
├── Models/
|
||||
│ ├── BaseModel.php
|
||||
│ ├── ItemsModel.php
|
||||
│ └── Master/
|
||||
│ └── ItemsModel.php
|
||||
└── Views/
|
||||
├── layout/
|
||||
│ ├── main_layout.php
|
||||
│ └── form_layout.php
|
||||
├── items/
|
||||
│ ├── items_index.php
|
||||
│ └── dialog_items_form.php
|
||||
└── login.php
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Master Data Entity
|
||||
|
||||
1. Create migration in `app/Database/Migrations/`
|
||||
2. Create model in `app/Models/[Module]/`
|
||||
3. Create controller in `app/Controllers/[Module]/`
|
||||
4. Add routes in `app/Config/Routes.php`
|
||||
5. Create view in `app/Views/[module]/`
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
```php
|
||||
// In Routes.php
|
||||
$routes->get('api/resource', 'ResourceController::index');
|
||||
$routes->get('api/resource/(:num)', 'ResourceController::show/$1');
|
||||
$routes->post('api/resource', 'ResourceController::create');
|
||||
$routes->patch('api/resource/(:num)', 'ResourceController::update/$1');
|
||||
```
|
||||
|
||||
### Controller Template
|
||||
### PHP
|
||||
|
||||
**Imports** - Order: CodeIgniter, App namespace, use statements
|
||||
```php
|
||||
<?php
|
||||
namespace App\Controllers\Module;
|
||||
namespace App\Controllers\Qc;
|
||||
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\Module\ItemModel;
|
||||
use App\Models\Qc\ResultsModel;
|
||||
```
|
||||
|
||||
class ItemsController extends BaseController {
|
||||
use ResponseTrait;
|
||||
**Formatting**
|
||||
- PSR-12 standard
|
||||
- 4 spaces indentation (no tabs)
|
||||
- No type hints (follow existing patterns)
|
||||
|
||||
protected $model;
|
||||
protected $rules;
|
||||
**Naming**
|
||||
- Classes: PascalCase (e.g., `ResultsController`)
|
||||
- Methods: camelCase (e.g., `getByDateAndControl`)
|
||||
- Variables: camelCase (e.g., `$resultId`)
|
||||
- Database: snake_case (e.g., `control_id`)
|
||||
|
||||
public function __construct() {
|
||||
$this->model = new ItemsModel();
|
||||
$this->rules = [
|
||||
'itemCode' => 'required|min_length[1]',
|
||||
'itemName' => 'required',
|
||||
];
|
||||
}
|
||||
**Comments**
|
||||
- Minimal/none - write self-documenting code
|
||||
|
||||
public function index() {
|
||||
$keyword = $this->request->getGet('keyword');
|
||||
**Error Handling**
|
||||
```php
|
||||
try {
|
||||
$rows = $this->model->getItems($keyword);
|
||||
$rows = $this->model->findAll();
|
||||
return $this->respond(['status' => 'success', 'message' => 'fetch success', 'data' => $rows], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError($e->getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
### Controllers
|
||||
|
||||
**Structure**
|
||||
- Extend `BaseController`, use `ResponseTrait`
|
||||
- Define `$model` and `$rules` in `__construct()`
|
||||
- CRUD pattern: `index()`, `show($id)`, `create()`, `update($id)`, `delete($id)`
|
||||
|
||||
**Input Handling**
|
||||
```php
|
||||
$input = $this->request->getJSON(true);
|
||||
$input = camel_to_snake_array($input); // Convert before DB
|
||||
```
|
||||
|
||||
**Response Format**
|
||||
```php
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $rows
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Exception: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
public function show($id = null) {
|
||||
try {
|
||||
$rows = $this->model->where('item_id', $id)->findAll();
|
||||
if (empty($rows)) {
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'data not found.'
|
||||
], 200);
|
||||
}
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $rows
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$input = $this->request->getJSON(true);
|
||||
$input = camel_to_snake_array($input);
|
||||
**Validation**
|
||||
```php
|
||||
if (!$this->validate($this->rules)) {
|
||||
return $this->failValidationErrors($this->validator->getErrors());
|
||||
}
|
||||
try {
|
||||
$id = $this->model->insert($input, true);
|
||||
return $this->respondCreated([
|
||||
'status' => 'success',
|
||||
'message' => $id
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id = null) {
|
||||
$input = $this->request->getJSON(true);
|
||||
$input = camel_to_snake_array($input);
|
||||
if (!$this->validate($this->rules)) {
|
||||
return $this->failValidationErrors($this->validator->getErrors());
|
||||
}
|
||||
try {
|
||||
$this->model->update($id, $input);
|
||||
return $this->respondCreated([
|
||||
'status' => 'success',
|
||||
'message' => 'update success',
|
||||
'data' => $id
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Template
|
||||
### Models
|
||||
|
||||
**Structure**
|
||||
- Extend `App\Models\BaseModel` (auto camel/snake conversion)
|
||||
- Always enable: `$useSoftDeletes = true`, `$useTimestamps = true`
|
||||
- Define: `$table`, `$primaryKey`, `$allowedFields`
|
||||
|
||||
**Example**
|
||||
```php
|
||||
<?php
|
||||
namespace App\Models\Module;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
|
||||
class ItemsModel extends BaseModel {
|
||||
protected $table = 'module_items';
|
||||
protected $primaryKey = 'item_id';
|
||||
protected $allowedFields = [
|
||||
'item_code',
|
||||
'item_name',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at'
|
||||
];
|
||||
protected $useTimestamps = true;
|
||||
protected $useSoftDeletes = true;
|
||||
|
||||
public function getItems($keyword = null) {
|
||||
if ($keyword) {
|
||||
return $this->groupStart()
|
||||
->like('item_code', $keyword)
|
||||
->orLike('item_name', $keyword)
|
||||
->groupEnd()
|
||||
->findAll();
|
||||
}
|
||||
return $this->findAll();
|
||||
}
|
||||
}
|
||||
protected $table = 'results';
|
||||
protected $primaryKey = 'result_id';
|
||||
protected $allowedFields = ['control_id', 'test_id', 'res_date', 'res_value'];
|
||||
```
|
||||
|
||||
### Migration Template
|
||||
### Database Conventions
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace App\Database\Migrations;
|
||||
- Primary keys: `{table_singular}_id` (e.g., `result_id`, `control_id`)
|
||||
- Foreign keys: Match referenced PK name
|
||||
- Master tables: Prefix with `master_`
|
||||
- All tables: `created_at`, `updated_at`, `deleted_at`
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
### Frontend
|
||||
|
||||
class ModuleItems extends Migration {
|
||||
**JavaScript**
|
||||
- No jQuery - use Fetch API
|
||||
- camelCase in JavaScript, snake_case in PHP/DB
|
||||
- Use Alpine.js: `x-data`, `x-model`, `x-show`, `x-for`, `@click`
|
||||
|
||||
public function up() {
|
||||
$this->forge->addField([
|
||||
'item_id' => [
|
||||
'type' => 'int',
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true
|
||||
],
|
||||
'item_code' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50
|
||||
],
|
||||
'item_name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'deleted_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
]
|
||||
]);
|
||||
$this->forge->addKey('item_id', true);
|
||||
$this->forge->addUniqueKey('item_code');
|
||||
$this->forge->createTable('module_items');
|
||||
}
|
||||
**DaisyUI**
|
||||
- Use DaisyUI components (btn, input, modal, dropdown, etc.)
|
||||
- TailwindCSS for utilities
|
||||
|
||||
public function down() {
|
||||
$this->forge->dropTable('module_items', true);
|
||||
}
|
||||
}
|
||||
**Base URL**
|
||||
```javascript
|
||||
const BASEURL = '<?= base_url(''); ?>'; // or window.BASEURL
|
||||
```
|
||||
|
||||
### Routes Template
|
||||
## File Naming
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
$routes = get('/login', 'PagesController::login');
|
||||
$routes->post('/login', 'AuthController::login');
|
||||
$routes->get('/logout', 'AuthController::logout');
|
||||
|
||||
$routes->group('', ['filter' => 'jwt-auth'], function ($routes) {
|
||||
$routes->get('/', 'PagesController::dashboard');
|
||||
$routes->get('/module', 'PagesController::module');
|
||||
$routes->get('/master/items', 'PagesController::masterItems');
|
||||
});
|
||||
|
||||
$routes->group('api', function ($routes) {
|
||||
$routes->get('module/items', 'Module\ItemsController::index');
|
||||
$routes->get('module/items/(:num)', 'Module\ItemsController::show/$1');
|
||||
$routes->post('module/items', 'Module\ItemsController::create');
|
||||
$routes->patch('module/items/(:num)', 'Module\ItemsController::update/$1');
|
||||
});
|
||||
```
|
||||
|
||||
### View Template (Index with Alpine.js)
|
||||
|
||||
```php
|
||||
<?= $this->extend("layout/main_layout"); ?>
|
||||
|
||||
<?= $this->section("content"); ?>
|
||||
<main class="flex-1 p-6 overflow-auto bg-slate-50/50" x-data="items()">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 tracking-tight">Items</h1>
|
||||
<p class="text-slate-500 text-sm mt-1">Manage your items</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm gap-2 shadow-sm border-0 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-700 hover:to-blue-600 text-white transition-all duration-200"
|
||||
@click="showForm()"
|
||||
>
|
||||
<i class="fa-solid fa-plus"></i> New Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-4 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or code..."
|
||||
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"
|
||||
x-model="keyword"
|
||||
@keyup.enter="fetchList()"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-all duration-200 flex items-center gap-2"
|
||||
@click="fetchList()"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<template x-if="list">
|
||||
<div class="overflow-x-auto">
|
||||
<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">Code</th>
|
||||
<th class="py-3 px-5 font-semibold">Name</th>
|
||||
<th class="py-3 px-5 font-semibold text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-600 divide-y divide-slate-100">
|
||||
<template x-for="item in list" :key="item.itemId">
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<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.itemCode"></span>
|
||||
</td>
|
||||
<td class="py-3 px-5 font-medium text-slate-700" x-text="item.itemName"></td>
|
||||
<td class="py-3 px-5 text-right">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
|
||||
@click="showForm(item.itemId)"
|
||||
>
|
||||
<i class="fa-solid fa-pencil"></i> Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<?= $this->include('module/items/dialog_items_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("items", () => ({
|
||||
loading: false,
|
||||
showModal: false,
|
||||
errors: {},
|
||||
error: null,
|
||||
part: "items",
|
||||
keyword: "",
|
||||
list: null,
|
||||
item: null,
|
||||
form: {
|
||||
itemCode: "",
|
||||
itemName: "",
|
||||
},
|
||||
|
||||
async fetchList() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.list = null;
|
||||
try {
|
||||
const params = new URLSearchParams({ keyword: this.keyword });
|
||||
const response = await fetch(`${window.BASEURL}api/module/${this.part}?${params}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to load data");
|
||||
const data = await response.json();
|
||||
this.list = data.data;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadData(id) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`${window.BASEURL}api/module/${this.part}/${id}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to load item");
|
||||
const data = await response.json();
|
||||
this.form = data.data[0];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
this.form = {};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async showForm(id = null) {
|
||||
this.showModal = true;
|
||||
if (id) {
|
||||
await this.loadData(id);
|
||||
} else {
|
||||
this.form = {};
|
||||
}
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.form = {};
|
||||
},
|
||||
|
||||
validate() {
|
||||
this.errors = {};
|
||||
if (!this.form.itemCode) this.errors.itemCode = "Code is required.";
|
||||
if (!this.form.itemName) this.errors.itemName = "Name is required.";
|
||||
return Object.keys(this.errors).length === 0;
|
||||
},
|
||||
|
||||
async save() {
|
||||
if (!this.validate()) return;
|
||||
this.loading = true;
|
||||
let method = '';
|
||||
let url = '';
|
||||
if (this.form.itemId) {
|
||||
method = 'patch';
|
||||
url = `${BASEURL}api/module/${this.part}/${this.form.itemId}`;
|
||||
} else {
|
||||
method = 'post';
|
||||
url = `${BASEURL}api/module/${this.part}`;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
alert("Data saved successfully!");
|
||||
this.closeModal();
|
||||
this.list = null;
|
||||
} else {
|
||||
alert(data.message || "Something went wrong.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to save data.");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
```
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
php spark migrate # Run all pending
|
||||
php spark migrate:rollback # Rollback last batch
|
||||
php spark migrate:refresh # Rollback all + re-run
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./vendor/bin/phpunit
|
||||
|
||||
# Run specific test file
|
||||
./vendor/bin/phpunit tests/unit/SomeTest.php
|
||||
```
|
||||
| Component | Pattern | Example |
|
||||
|-----------|---------|---------|
|
||||
| Controller | `PascalCase + Controller` | `ResultsController` |
|
||||
| Model | `PascalCase + Model` | `ResultsModel` |
|
||||
| Migration | `YYYY-MM-DD-XXXXXX_Description.php` | `2026-01-15-000001_Results.php` |
|
||||
| View | `module/action.php` | `entry/daily.php` |
|
||||
|
||||
## Things to Avoid
|
||||
|
||||
1. **Don't use jQuery** - Use Alpine.js or vanilla JS
|
||||
2. **Don't over-engineer** - This is a "no-nonsense" project
|
||||
3. **Don't skip soft deletes** - Always use `deleted_at`
|
||||
4. **Don't hardcode** - Use `.env` for configuration
|
||||
5. **Don't mix concerns** - Controllers handle HTTP, Models handle data
|
||||
6. **Don't forget camel/snake conversion** - Frontend uses camelCase, DB uses snake_case
|
||||
|
||||
## Questions to Ask Before Making Changes
|
||||
|
||||
1. Does this follow the existing patterns?
|
||||
2. Is there a simpler way to do this?
|
||||
3. Did I add the route?
|
||||
4. Did I handle errors gracefully?
|
||||
5. Does the API response match the standard format?
|
||||
6. Did I convert camelCase to snake_case before DB operations?
|
||||
7. Did I convert snake_case to camelCase after fetching?
|
||||
- jQuery, over-engineering, skipping soft deletes, hardcoding
|
||||
- Don't mix concerns (Controllers handle HTTP, Models handle data)
|
||||
- Don't forget camel/snake conversion at boundaries
|
||||
- Don't add unnecessary comments
|
||||
|
||||
## Post-Change Requirements
|
||||
|
||||
**After every significant code change, update `README.md`:**
|
||||
|
||||
| Change Type | README Section to Update |
|
||||
|-------------|--------------------------|
|
||||
| New Controller | Project Structure, API Endpoints |
|
||||
| New Model | Project Structure |
|
||||
| New Migration/Table | Database Schema |
|
||||
| New View | Project Structure |
|
||||
| New Route | API Endpoints |
|
||||
| New Command | Development Commands |
|
||||
| Config Change | Setup |
|
||||
|
||||
Keep the README accurate and up-to-date with the actual codebase.
|
||||
After changes: run `./vendor/bin/phpunit`, update README.md for new features
|
||||
|
||||
178
CLAUDE.md
178
CLAUDE.md
@ -1,178 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development server
|
||||
php spark serve
|
||||
|
||||
# Database migrations
|
||||
php spark migrate # Run pending migrations
|
||||
php spark migrate:rollback # Rollback last batch
|
||||
php spark db seed CmodQcSeeder # Seed initial data
|
||||
|
||||
# Run tests
|
||||
./vendor/bin/phpunit # All tests
|
||||
./vendor/bin/phpunit tests/unit/SomeTest.php # Specific test file
|
||||
./vendor/bin/phpunit --coverage-html coverage/ # With coverage report
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a CodeIgniter 4 Quality Control management system with:
|
||||
|
||||
- **Backend**: PHP 8.1+, CodeIgniter 4
|
||||
- **Database**: SQL Server (uses `SQLSRV` driver)
|
||||
- **Frontend**: TailwindCSS + Alpine.js + DaisyUI (CDN-based, no build step)
|
||||
- **Testing**: PHPUnit 10
|
||||
- **Icons**: FontAwesome 7
|
||||
|
||||
### Key Components
|
||||
|
||||
**Models** (`app/Models/`):
|
||||
- `BaseModel` - Custom base model with automatic camelCase/snake_case conversion
|
||||
- `findAll()`, `find()`, `first()` return camelCase keys
|
||||
- `insert()`, `update()` accept camelCase, convert to snake_case for DB
|
||||
- Organized in subdirectories: `Master/`, `Qc/`
|
||||
|
||||
**Controllers** (`app/Controllers/`):
|
||||
- `PageController` - Renders page views with `main_layout`
|
||||
- `Api\*` - Consolidated entry API controllers (DashboardApi, EntryApi, ReportApi)
|
||||
- `Master\*` - CRUD for master data (MasterDepts, MasterTests, MasterControls)
|
||||
- `Qc\*` - QC domain controllers (ControlTests, Results, ResultComments)
|
||||
|
||||
**Views** (`app/Views/`):
|
||||
- PHP templates extending `layout/main_layout`
|
||||
- Alpine.js components in `x-data` blocks
|
||||
- DaisyUI components for UI
|
||||
|
||||
**Helpers** (`app/Helpers/`):
|
||||
- `stringcase_helper.php` - `camel_to_snake_array()`, `snake_to_camel_array()`
|
||||
- The `stringcase` helper is auto-loaded in `BaseController`
|
||||
|
||||
### Database Schema
|
||||
|
||||
Tables use soft deletes (`deleted_at`) and timestamps (`created_at`, `updated_at`):
|
||||
- `dict_depts`, `dict_tests`, `dict_controls` - Master data
|
||||
- `control_tests` - Control-test associations with QC parameters (mean, sd)
|
||||
- `results` - Daily test results
|
||||
- `result_comments` - Comments per result
|
||||
|
||||
## Conventions
|
||||
|
||||
### Case Convention
|
||||
- **Frontend/JS/API**: camelCase
|
||||
- **Backend PHP variables**: camelCase
|
||||
- **Database**: snake_case
|
||||
- Models handle automatic conversion; use helpers for manual conversions
|
||||
|
||||
### API Response Format
|
||||
```php
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $rows
|
||||
], 200);
|
||||
```
|
||||
|
||||
### Controller Pattern
|
||||
```php
|
||||
namespace App\Controllers\Master;
|
||||
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class DeptsController extends BaseController {
|
||||
use ResponseTrait;
|
||||
protected $model;
|
||||
protected $rules;
|
||||
|
||||
public function __construct() {
|
||||
$this->model = new MasterDeptsModel();
|
||||
$this->rules = ['name' => 'required|min_length[1]'];
|
||||
}
|
||||
|
||||
public function index() {
|
||||
$keyword = $this->request->getGet('keyword');
|
||||
$rows = $this->model->search($keyword);
|
||||
return $this->respond([...], 200);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$input = camel_to_snake_array($this->request->getJSON(true));
|
||||
if (!$this->validate($this->rules)) {
|
||||
return $this->failValidationErrors($this->validator->getErrors());
|
||||
}
|
||||
$id = $this->model->insert($input, true);
|
||||
return $this->respondCreated(['status' => 'success', 'message' => $id]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Pattern
|
||||
```php
|
||||
namespace App\Models\Master;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
|
||||
class MasterDeptsModel extends BaseModel {
|
||||
protected $table = 'dict_depts';
|
||||
protected $primaryKey = 'dept_id';
|
||||
protected $allowedFields = ['dept_name', 'deleted_at'];
|
||||
protected $useTimestamps = true;
|
||||
protected $useSoftDeletes = true;
|
||||
|
||||
public function search(?string $keyword) {
|
||||
if ($keyword) {
|
||||
$this->like('dept_name', $keyword);
|
||||
}
|
||||
return $this->findAll();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Routes Pattern
|
||||
- Page routes: `$routes->get('/path', 'PageController::method');`
|
||||
- API routes: `$routes->group('api', function($routes) { ... });`
|
||||
- API sub-groups: `api/master`, `api/qc`
|
||||
|
||||
## Frontend Patterns
|
||||
|
||||
- Alpine.js `x-data` for component state (inline or in `<script>` blocks)
|
||||
- Fetch API for AJAX (no jQuery)
|
||||
- DaisyUI components for UI
|
||||
- Modals with `x-show` and `x-transition`
|
||||
- `window.BASEURL` available globally for API calls
|
||||
- Views access page data via `$pageData['title']`, `$pageData['userInitials']`, `$pageData['userName']`, `$pageData['userRole']`
|
||||
|
||||
### View Template Pattern
|
||||
```php
|
||||
<?= $this->extend("layout/main_layout"); ?>
|
||||
<?= $this->section("content"); ?>
|
||||
<main x-data="componentName()">
|
||||
<!-- UI content -->
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
<?= $this->section("script"); ?>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("componentName", () => ({
|
||||
// state and methods
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
```
|
||||
|
||||
## Things to Avoid
|
||||
|
||||
1. Don't skip soft deletes (`deleted_at`)
|
||||
2. Don't mix concerns - controllers handle HTTP, models handle data
|
||||
3. Don't forget case conversion - use helpers or BaseModel
|
||||
|
||||
## Response Style
|
||||
|
||||
- Use emojis in responses where appropriate to add visual appeal 😊
|
||||
- Keep responses concise and helpful
|
||||
@ -18,7 +18,7 @@ $routes->get('/entry', 'PageController::entry');
|
||||
$routes->get('/entry/daily', 'PageController::entryDaily');
|
||||
$routes->get('/entry/monthly', 'PageController::entryMonthly');
|
||||
$routes->get('/report', 'PageController::report');
|
||||
$routes->get('/report/view', 'PageController::reportView');
|
||||
$routes->get('/report/merged', 'PageController::reportMerged');
|
||||
|
||||
$routes->group('api', function ($routes) {
|
||||
$routes->get('dashboard/recent', 'Api\DashboardApiController::getRecent');
|
||||
|
||||
@ -24,7 +24,7 @@ class MasterControlsController extends BaseController {
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $rows
|
||||
], 200);
|
||||
], 200)->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError($e->getMessage());
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ class MasterTestsController extends BaseController {
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $rows
|
||||
], 200);
|
||||
], 200)->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError($e->getMessage());
|
||||
}
|
||||
|
||||
@ -46,4 +46,8 @@ class PageController extends BaseController {
|
||||
public function reportView() {
|
||||
return view('report/view');
|
||||
}
|
||||
|
||||
public function reportMerged() {
|
||||
return view('report/merged');
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,39 +43,17 @@
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Department</span>
|
||||
</label>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-sm gap-2 btn-outline w-48 justify-start">
|
||||
<i class="fa-solid fa-building text-xs"></i>
|
||||
<template x-if="deptId">
|
||||
<span class="truncate" x-text="getDeptName()"></span>
|
||||
</template>
|
||||
<template x-if="!deptId">
|
||||
<span class="opacity-70">All Departments</span>
|
||||
</template>
|
||||
<i class="fa-solid fa-caret-down text-xs ml-auto"></i>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56 border border-base-300">
|
||||
<li>
|
||||
<a @click="setDeptId(null)" :class="{'active': !deptId}">
|
||||
<i class="fa-solid fa-building text-xs"></i> All Departments
|
||||
</a>
|
||||
</li>
|
||||
<select x-model="deptId" @change="setDeptId(deptId)" class="select select-bordered select-sm w-48">
|
||||
<option value="">All Departments</option>
|
||||
<template x-if="departments">
|
||||
<template x-for="dept in departments" :key="dept.deptId">
|
||||
<li>
|
||||
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
|
||||
</li>
|
||||
<option :value="dept.deptId" x-text="dept.deptName"></option>
|
||||
</template>
|
||||
</template>
|
||||
<template x-if="!departments">
|
||||
<li>
|
||||
<a class="opacity-50">
|
||||
<span class="loading loading-spinner loading-xs"></span> Loading...
|
||||
</a>
|
||||
</li>
|
||||
<option disabled>Loading...</option>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@ -212,11 +190,6 @@ document.addEventListener('alpine:init', () => {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
getDeptName() {
|
||||
if (!this.deptId || !this.departments) return '';
|
||||
const dept = this.departments.find(d => d.deptId === this.deptId);
|
||||
return dept ? dept.deptName : '';
|
||||
},
|
||||
|
||||
setDeptId(id) {
|
||||
this.deptId = id;
|
||||
|
||||
@ -53,39 +53,17 @@
|
||||
<label class="label pt-0">
|
||||
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Department</span>
|
||||
</label>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-sm gap-2 btn-outline w-48 justify-start">
|
||||
<i class="fa-solid fa-building text-xs"></i>
|
||||
<template x-if="deptId">
|
||||
<span class="truncate" x-text="getDeptName()"></span>
|
||||
</template>
|
||||
<template x-if="!deptId">
|
||||
<span class="opacity-70">All Departments</span>
|
||||
</template>
|
||||
<i class="fa-solid fa-caret-down text-xs ml-auto"></i>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56 border border-base-300">
|
||||
<li>
|
||||
<a @click="setDeptId(null)" :class="{'active': !deptId}">
|
||||
<i class="fa-solid fa-building text-xs"></i> All Departments
|
||||
</a>
|
||||
</li>
|
||||
<select x-model="deptId" @change="setDeptId(deptId)" class="select select-bordered select-sm w-48">
|
||||
<option value="">All Departments</option>
|
||||
<template x-if="departments">
|
||||
<template x-for="dept in departments" :key="dept.deptId">
|
||||
<li>
|
||||
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
|
||||
</li>
|
||||
<option :value="dept.deptId" x-text="dept.deptName"></option>
|
||||
</template>
|
||||
</template>
|
||||
<template x-if="!departments">
|
||||
<li>
|
||||
<a class="opacity-50">
|
||||
<span class="loading loading-spinner loading-xs"></span> Loading...
|
||||
</a>
|
||||
</li>
|
||||
<option disabled>Loading...</option>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
|
||||
@ -355,11 +333,6 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
getDeptName() {
|
||||
if (!this.deptId || !this.departments) return '';
|
||||
const dept = this.departments.find(d => d.deptId === this.deptId);
|
||||
return dept ? dept.deptName : '';
|
||||
},
|
||||
|
||||
setDeptId(id) {
|
||||
this.deptId = id;
|
||||
|
||||
@ -155,10 +155,17 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="mb-1 min-h-0">
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'report') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= uri_string() === 'report' ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
|
||||
href="<?= base_url('/report') ?>">
|
||||
<i class="fa-solid fa-chart-bar w-5"></i>
|
||||
Reports
|
||||
Standard Report
|
||||
</a>
|
||||
</li>
|
||||
<li class="mb-1 min-h-0">
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= uri_string() === 'report/merged' ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
|
||||
href="<?= base_url('/report/merged') ?>">
|
||||
<i class="fa-solid fa-chart-simple w-5"></i>
|
||||
Merged Report
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<template x-if="!departmentsFetchComplete">
|
||||
<template x-if="loadingDepartments">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<span class="loading loading-spinner loading-sm text-primary"></span>
|
||||
<span class="ml-2 text-xs opacity-50">Loading departments...</span>
|
||||
|
||||
@ -21,39 +21,17 @@
|
||||
class="input input-bordered input-sm w-full px-3 py-2 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
|
||||
x-model="keyword" @keyup.enter="fetchList()" />
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-sm btn-neutral gap-2">
|
||||
<i class="fa-solid fa-building text-xs"></i>
|
||||
<template x-if="deptId">
|
||||
<span x-text="getDeptName()"></span>
|
||||
</template>
|
||||
<template x-if="!deptId">
|
||||
Department
|
||||
</template>
|
||||
<i class="fa-solid fa-caret-down text-xs ml-1"></i>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-300">
|
||||
<li>
|
||||
<a @click="setDeptId(null)" :class="{'active': !deptId}">
|
||||
<i class="fa-solid fa-building text-xs"></i> All Departments
|
||||
</a>
|
||||
</li>
|
||||
<select x-model="deptId" @change="setDeptId(deptId)" class="select select-bordered select-sm">
|
||||
<option value="">All Departments</option>
|
||||
<template x-if="departments">
|
||||
<template x-for="dept in departments" :key="dept.deptId">
|
||||
<li>
|
||||
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
|
||||
</li>
|
||||
<option :value="dept.deptId" x-text="dept.deptName"></option>
|
||||
</template>
|
||||
</template>
|
||||
<template x-if="!departments">
|
||||
<li>
|
||||
<a class="opacity-50">
|
||||
<span class="loading loading-spinner loading-xs"></span> Loading...
|
||||
</a>
|
||||
</li>
|
||||
<option disabled>Loading...</option>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</select>
|
||||
<template x-if="deptId">
|
||||
<button class="btn btn-sm gap-1" @click="setDeptId(null)">
|
||||
<i class="fa-solid fa-xmark text-xs"></i> Clear
|
||||
@ -204,6 +182,7 @@
|
||||
keyword: "",
|
||||
deptId: null,
|
||||
departments: null,
|
||||
loadingDepartments: false,
|
||||
list: null,
|
||||
form: {
|
||||
controlId: null,
|
||||
@ -274,6 +253,7 @@
|
||||
},
|
||||
|
||||
async fetchDepartments() {
|
||||
this.loadingDepartments = true;
|
||||
try {
|
||||
const response = await fetch(`${BASEURL}api/master/depts`, {
|
||||
method: "GET",
|
||||
@ -285,6 +265,8 @@
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this.departments = [];
|
||||
} finally {
|
||||
this.loadingDepartments = false;
|
||||
}
|
||||
},
|
||||
|
||||
@ -311,11 +293,6 @@
|
||||
}
|
||||
},
|
||||
|
||||
getDeptName() {
|
||||
if (!this.deptId || !this.departments) return '';
|
||||
const dept = this.departments.find(d => d.deptId === this.deptId);
|
||||
return dept ? dept.deptName : '';
|
||||
},
|
||||
|
||||
setDeptId(id) {
|
||||
this.deptId = id;
|
||||
@ -323,28 +300,6 @@
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
async loadData(id) {
|
||||
|
||||
async fetchList() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.list = null;
|
||||
try {
|
||||
const params = new URLSearchParams({ keyword: this.keyword });
|
||||
const response = await fetch(`${BASEURL}api/master/controls?${params}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to load data");
|
||||
const data = await response.json();
|
||||
this.list = data.data;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadData(id) {
|
||||
this.loading = true;
|
||||
try {
|
||||
|
||||
@ -242,6 +242,9 @@
|
||||
|
||||
async showForm(id = null, prodName = null, lotName = null) {
|
||||
this.errors = {};
|
||||
|
||||
await this.fetchDropdowns();
|
||||
|
||||
if (id) {
|
||||
const item = this.list.find(i => i.controlTestId === id);
|
||||
this.form = { ...item };
|
||||
@ -280,7 +283,7 @@
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
this.closeModal();
|
||||
await this.fetchList();
|
||||
await Promise.all([this.fetchList(), this.fetchDropdowns()]);
|
||||
} else {
|
||||
alert(data.message || "Save failed");
|
||||
}
|
||||
@ -296,7 +299,7 @@
|
||||
try {
|
||||
const res = await fetch(`${BASEURL}api/qc/control-tests/${id}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') this.fetchList();
|
||||
if (data.status === 'success') await Promise.all([this.fetchList(), this.fetchDropdowns()]);
|
||||
} catch (err) {
|
||||
alert("Delete failed");
|
||||
}
|
||||
|
||||
@ -24,9 +24,9 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-action mt-6">
|
||||
<button class="btn btn-ghost opacity-70" @click="closeModal()">Cancel</button>
|
||||
<button class="btn btn-sm btn-ghost opacity-70" @click="closeModal()">Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary gap-2 shadow-lg shadow-primary/20 font-medium"
|
||||
class="btn btn-sm btn-primary gap-2 shadow-lg shadow-primary/20 font-medium"
|
||||
:class="{'loading': loading}"
|
||||
@click="save()"
|
||||
:disabled="loading"
|
||||
|
||||
@ -22,9 +22,7 @@
|
||||
class="input input-bordered input-sm w-full pl-10 pr-4 py-2.5 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
|
||||
x-model="keyword" @keyup.enter="fetchList()" />
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium bg-base-content text-base-100 rounded-lg hover:bg-base-content/90 transition-all duration-200 flex items-center gap-2"
|
||||
@click="fetchList()">
|
||||
<button class="btn btn-sm btn-neutral gap-2" @click="fetchList()">
|
||||
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
@ -40,8 +38,12 @@
|
||||
|
||||
<template x-if="!loading && error">
|
||||
<div class="p-8 text-center">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl text-error mb-2"></i>
|
||||
<p class="text-error" x-text="error"></p>
|
||||
<div class="w-12 h-12 bg-error/10 text-error rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fa-solid fa-triangle-exclamation text-xl"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-base-content">Something went wrong</h3>
|
||||
<p class="text-error/80 mt-0.5 text-sm" x-text="error"></p>
|
||||
<button @click="fetchList()" class="btn btn-sm btn-ghost mt-3 border border-base-300">Try Again</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -74,7 +76,15 @@
|
||||
</template>
|
||||
<template x-if="list.length === 0">
|
||||
<tr>
|
||||
<td colspan="2" class="py-8 text-center text-base-content/60">No data available</td>
|
||||
<td colspan="2" class="py-8 text-center">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div class="w-12 h-12 bg-base-200 text-base-content/30 rounded-full flex items-center justify-center mb-2">
|
||||
<i class="fa-solid fa-building text-xl"></i>
|
||||
</div>
|
||||
<p class="text-base-content/60 text-sm">No data available</p>
|
||||
<p class="text-base-content/40 text-xs mt-1">Try adjusting your search or add a new department</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
|
||||
@ -6,6 +6,34 @@
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<template x-if="loadingDepartments">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<span class="loading loading-spinner loading-sm text-primary"></span>
|
||||
<span class="ml-2 text-xs opacity-50">Loading departments...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="departments">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium text-base-content opacity-80">Department</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content"
|
||||
:class="{'border-error': errors.deptId}"
|
||||
x-model="form.deptId"
|
||||
placeholder="Select department">
|
||||
<option value="">Select Department</option>
|
||||
<template x-for="dept in departments" :key="dept.deptId">
|
||||
<option :value="dept.deptId" x-text="dept.deptName"></option>
|
||||
</template>
|
||||
</select>
|
||||
<template x-if="errors.deptId">
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-error" x-text="errors.deptId"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium text-base-content opacity-80">Test Code</span>
|
||||
@ -107,7 +135,7 @@
|
||||
<div class="modal-action mt-6">
|
||||
<button class="btn btn-ghost opacity-70" @click="closeModal()">Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary gap-2 shadow-lg shadow-primary/20 font-medium"
|
||||
class="btn btn-sm btn-primary gap-2 shadow-lg shadow-primary/20 font-medium"
|
||||
:class="{'loading': loading}"
|
||||
@click="save()"
|
||||
:disabled="loading"
|
||||
|
||||
@ -27,48 +27,23 @@
|
||||
@keyup.enter="fetchList()"
|
||||
/>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-sm gap-2">
|
||||
<i class="fa-solid fa-building text-xs"></i>
|
||||
<template x-if="deptId">
|
||||
<span x-text="getDeptName()"></span>
|
||||
</template>
|
||||
<template x-if="!deptId">
|
||||
Department
|
||||
</template>
|
||||
<i class="fa-solid fa-caret-down text-xs ml-1"></i>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-300">
|
||||
<li>
|
||||
<a @click="setDeptId(null)" :class="{'active': !deptId}">
|
||||
<i class="fa-solid fa-building text-xs"></i> All Departments
|
||||
</a>
|
||||
</li>
|
||||
<select x-model="deptId" @change="setDeptId(deptId)" class="select select-bordered select-sm">
|
||||
<option value="">All Departments</option>
|
||||
<template x-if="departments">
|
||||
<template x-for="dept in departments" :key="dept.deptId">
|
||||
<li>
|
||||
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
|
||||
</li>
|
||||
<option :value="dept.deptId" x-text="dept.deptName"></option>
|
||||
</template>
|
||||
</template>
|
||||
<template x-if="!departments">
|
||||
<li>
|
||||
<a class="opacity-50">
|
||||
<span class="loading loading-spinner loading-xs"></span> Loading...
|
||||
</a>
|
||||
</li>
|
||||
<option disabled>Loading...</option>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</select>
|
||||
<template x-if="deptId">
|
||||
<button class="btn btn-sm gap-1" @click="setDeptId(null)">
|
||||
<i class="fa-solid fa-xmark text-xs"></i> Clear
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium bg-base-content text-base-100 rounded-lg hover:bg-base-content/90 transition-all duration-200 flex items-center gap-2"
|
||||
@click="fetchList()"
|
||||
>
|
||||
<button class="btn btn-sm btn-neutral gap-2" @click="fetchList()">
|
||||
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
@ -84,8 +59,12 @@
|
||||
|
||||
<template x-if="!loading && error">
|
||||
<div class="p-8 text-center">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl text-error mb-2"></i>
|
||||
<p class="text-error" x-text="error"></p>
|
||||
<div class="w-12 h-12 bg-error/10 text-error rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fa-solid fa-triangle-exclamation text-xl"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-base-content">Something went wrong</h3>
|
||||
<p class="text-error/80 mt-0.5 text-sm" x-text="error"></p>
|
||||
<button @click="fetchList()" class="btn btn-sm btn-ghost mt-3 border border-base-300">Try Again</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -126,7 +105,15 @@
|
||||
</template>
|
||||
<template x-if="list.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="py-8 text-center text-base-content/60">No data available</td>
|
||||
<td colspan="5" class="py-8 text-center">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div class="w-12 h-12 bg-base-200 text-base-content/30 rounded-full flex items-center justify-center mb-2">
|
||||
<i class="fa-solid fa-flask text-xl"></i>
|
||||
</div>
|
||||
<p class="text-base-content/60 text-sm">No data available</p>
|
||||
<p class="text-base-content/40 text-xs mt-1">Try adjusting your search or add a new test</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@ -150,9 +137,11 @@
|
||||
keyword: "",
|
||||
deptId: null,
|
||||
departments: null,
|
||||
loadingDepartments: false,
|
||||
list: null,
|
||||
form: {
|
||||
testId: null,
|
||||
deptId: null,
|
||||
testCode: "",
|
||||
testName: "",
|
||||
testUnit: "",
|
||||
@ -168,6 +157,7 @@
|
||||
},
|
||||
|
||||
async fetchDepartments() {
|
||||
this.loadingDepartments = true;
|
||||
try {
|
||||
const response = await fetch(`${BASEURL}api/master/depts`, {
|
||||
method: "GET",
|
||||
@ -179,6 +169,8 @@
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this.departments = [];
|
||||
} finally {
|
||||
this.loadingDepartments = false;
|
||||
}
|
||||
},
|
||||
|
||||
@ -205,11 +197,6 @@
|
||||
}
|
||||
},
|
||||
|
||||
getDeptName() {
|
||||
if (!this.deptId || !this.departments) return '';
|
||||
const dept = this.departments.find(d => d.deptId === this.deptId);
|
||||
return dept ? dept.deptName : '';
|
||||
},
|
||||
|
||||
setDeptId(id) {
|
||||
this.deptId = id;
|
||||
@ -217,20 +204,6 @@
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
async loadData(id) {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to load data");
|
||||
const data = await response.json();
|
||||
this.list = data.data;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadData(id) {
|
||||
this.loading = true;
|
||||
try {
|
||||
@ -255,13 +228,13 @@
|
||||
if (id) {
|
||||
await this.loadData(id);
|
||||
} else {
|
||||
this.form = { testId: null, testCode: "", testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
|
||||
this.form = { testId: null, deptId: null, testCode: "", testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
|
||||
}
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.form = { testId: null, testCode: "", testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
|
||||
this.form = { testId: null, deptId: null, testCode: "", testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
|
||||
},
|
||||
|
||||
validate() {
|
||||
|
||||
814
app/Views/report/merged.php
Normal file
814
app/Views/report/merged.php
Normal file
@ -0,0 +1,814 @@
|
||||
<?= $this->extend("layout/main_layout"); ?>
|
||||
|
||||
<?= $this->section("content"); ?>
|
||||
<main class="flex-1 p-6 overflow-auto" x-data="reportMergedModule()">
|
||||
<div class="flex justify-between items-center mb-6 no-print">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-base-content tracking-tight">Merged QC Report</h1>
|
||||
<p class="text-sm mt-1 opacity-70">All controls combined in a single vertical chart</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="window.print()" class="btn btn-outline btn-sm" :disabled="!selectedTest">
|
||||
<i class="fa-solid fa-print mr-2"></i>
|
||||
Print Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6 no-print">
|
||||
<div class="flex flex-wrap gap-6 items-center">
|
||||
<div class="form-control">
|
||||
<label class="label pt-0">
|
||||
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Reporting Month</span>
|
||||
</label>
|
||||
<div class="join">
|
||||
<button @click="prevMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
|
||||
class="fa-solid fa-chevron-left"></i></button>
|
||||
<input type="month" x-model="month" @change="onMonthChange()"
|
||||
class="join-item input input-bordered input-sm w-36 text-center font-medium bg-base-200/30">
|
||||
<button @click="nextMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
|
||||
class="fa-solid fa-chevron-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
|
||||
|
||||
<div class="form-control flex-1 max-w-xs">
|
||||
<label class="label pt-0">
|
||||
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Select Test</span>
|
||||
</label>
|
||||
<select x-model="selectedTest" @change="fetchData()"
|
||||
class="select select-bordered select-sm w-full font-medium">
|
||||
<option value="">Choose a test...</option>
|
||||
<template x-for="test in tests" :key="test.testId">
|
||||
<option :value="String(test.testId)" x-text="test.testName"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-sm opacity-50 animate-pulse">Generating merged report...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && !selectedTest"
|
||||
class="bg-base-100 rounded-2xl border-2 border-dashed border-base-300 p-16 text-center">
|
||||
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fa-solid fa-chart-bar text-2xl opacity-20"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">No Test Selected</h3>
|
||||
<p class="text-base-content/60 max-w-xs mx-auto">Please select a laboratory test to generate the merged report.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Report Content -->
|
||||
<div x-show="!loading && selectedTest && controls.length > 0" class="space-y-6 animate-in fade-in duration-500">
|
||||
|
||||
<!-- Report Header (Print only) -->
|
||||
<div class="hidden print:block mb-8 border-b-2 border-base-content/20 pb-4">
|
||||
<div class="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 class="text-3xl font-black uppercase tracking-tighter" x-text="testName"></h2>
|
||||
<p class="text-sm font-bold opacity-60" x-text="departmentName"></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xl font-bold" x-text="monthDisplay"></p>
|
||||
<p class="text-xs opacity-50">Report Generated: <?= date('d M Y H:i') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="flex flex-wrap gap-3 mb-6 no-print-gap">
|
||||
<template x-for="control in processedControls" :key="control.controlId">
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden flex flex-col w-full sm:w-[calc(50%-0.75rem)] lg:w-[calc(33.333%-0.75rem)] xl:w-[calc(25%-0.75rem)] print:w-[calc(33.333%-0.5rem)] print:text-[11px]">
|
||||
<div class="bg-base-200/50 p-2 border-b border-base-300">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<h3 class="font-bold truncate text-xs" x-text="control.controlName"></h3>
|
||||
<span class="badge badge-xs badge-neutral shrink-0 print:scale-75" x-text="'Lot: ' + (control.lot || 'N/A')"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 flex-1 grid grid-cols-2 gap-2">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[9px] uppercase font-bold opacity-50">N</span>
|
||||
<span class="text-sm font-mono font-bold" x-text="control.stats.n || 0"></span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[9px] uppercase font-bold opacity-50">Mean</span>
|
||||
<span class="text-sm font-mono font-bold text-primary"
|
||||
x-text="formatNum(control.stats.mean)"></span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[9px] uppercase font-bold opacity-50">SD</span>
|
||||
<span class="text-sm font-mono font-bold" x-text="formatNum(control.stats.sd)"></span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[9px] uppercase font-bold opacity-50">% CV</span>
|
||||
<span class="text-sm font-mono font-bold"
|
||||
:class="control.stats.cv > 5 ? 'text-warning' : 'text-success'"
|
||||
x-text="formatNum(control.stats.cv, 1) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 bg-base-200/30 text-[9px] flex justify-between border-t border-base-300 font-medium">
|
||||
<span>Tgt: <span x-text="formatNum(control.mean)"></span>±<span x-text="formatNum(2*control.sd)"></span></span>
|
||||
<span x-show="control.stats.mean" :class="getBiasClass(control)"
|
||||
x-text="'B: ' + getBias(control) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Table and Chart Grid -->
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Monthly Table (Left) -->
|
||||
<div class="w-full lg:w-1/3">
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden h-full">
|
||||
<div class="p-4 border-b border-base-300 bg-base-200/50 print:p-2">
|
||||
<h3 class="font-bold text-sm uppercase tracking-widest opacity-70 print:text-[10px]">Daily Results Log</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<table class="table table-xs w-full print:table-xs">
|
||||
<thead class="bg-base-200/50 sticky top-0">
|
||||
<tr>
|
||||
<th class="w-10 text-center print:px-1">Day</th>
|
||||
<template x-for="control in controls" :key="control.controlId">
|
||||
<th class="text-center print:px-1 whitespace-normal break-words min-w-[60px] max-w-[100px] leading-tight" x-text="control.controlName"></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="day in daysInMonth" :key="day">
|
||||
<tr :class="isWeekend(day) ? 'bg-base-200/30' : ''">
|
||||
<td class="text-center font-mono font-bold print:px-1" x-text="day"></td>
|
||||
<template x-for="control in controls" :key="control.controlId">
|
||||
<td class="text-center print:px-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-mono text-[11px] print:text-[10px]"
|
||||
:class="getValueClass(control, day)"
|
||||
x-text="getResValue(control, day)"></span>
|
||||
<span x-show="getResComment(control, day)"
|
||||
class="text-[8px] opacity-40 italic block max-w-[80px] truncate mx-auto print:hidden"
|
||||
:title="getResComment(control, day)"
|
||||
x-text="getResComment(control, day)"></span>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merged Chart Section (Right) -->
|
||||
<div class="w-full lg:w-2/3">
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 h-full">
|
||||
<div class="flex justify-between items-center mb-4 print:mb-1">
|
||||
<h3 class="font-bold text-sm opacity-70 uppercase tracking-widest print:text-[10px]">
|
||||
Merged Levey-Jennings Chart (All Controls)
|
||||
</h3>
|
||||
<div class="flex gap-2 text-[10px] print:text-[8px]">
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-success"></span>In Range</span>
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-error"></span>Out Range</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[560px] w-full relative">
|
||||
<canvas id="mergedChart" class="w-full h-full"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="!loading && selectedTest && controls.length === 0"
|
||||
class="bg-base-100 rounded-2xl border-2 border-dashed border-base-300 p-16 text-center">
|
||||
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4 text-warning">
|
||||
<i class="fa-solid fa-triangle-exclamation text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">No Data Found</h3>
|
||||
<p class="text-base-content/60 max-w-xs mx-auto">There are no records found for this test and month. Please
|
||||
check the selection or enter data in the Entry module.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page {
|
||||
size: portrait;
|
||||
margin: 3mm;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.no-print,
|
||||
.navbar,
|
||||
footer,
|
||||
.drawer-side,
|
||||
.divider,
|
||||
.mb-6:first-child,
|
||||
.flex.justify-between:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.space-y-6 > div:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.space-y-6 > div:nth-child(2) {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.shadow-sm,
|
||||
.shadow,
|
||||
.shadow-md,
|
||||
.shadow-lg,
|
||||
.shadow-xl,
|
||||
.shadow-2xl {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.border,
|
||||
.border-base-300,
|
||||
.rounded-xl,
|
||||
.rounded-2xl {
|
||||
border: 1px solid #ddd !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.table-xs th, .table-xs td {
|
||||
padding: 1px !important;
|
||||
font-size: 6px !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 6px !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
height: 15px !important;
|
||||
}
|
||||
|
||||
.overflow-x-auto {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.max-h-\[600px\] {
|
||||
max-height: 500px !important;
|
||||
}
|
||||
|
||||
.flex-col.lg\:flex-row {
|
||||
flex-direction: row !important;
|
||||
display: flex !important;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.w-full.lg\:w-1\/3 {
|
||||
width: 35% !important;
|
||||
flex: 0 0 35% !important;
|
||||
max-width: 35% !important;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.w-full.lg\:w-2\/3 {
|
||||
width: 65% !important;
|
||||
flex: 0 0 65% !important;
|
||||
max-width: 65% !important;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.print\:block {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.print\:mb-6 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.space-y-6 > * + * {
|
||||
margin-top: 1px !important;
|
||||
}
|
||||
|
||||
.flex-wrap.gap-3 {
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.flex-wrap > div {
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.flex-wrap .grid-cols-2 {
|
||||
gap: 2px !important;
|
||||
}
|
||||
|
||||
.text-[8px] {
|
||||
font-size: 6px !important;
|
||||
}
|
||||
|
||||
.text-[9px] {
|
||||
font-size: 7px !important;
|
||||
}
|
||||
|
||||
.table-xs span[class*="text-"] {
|
||||
font-size: 6px !important;
|
||||
}
|
||||
|
||||
.animate-in,
|
||||
.fade-in {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.h-\[560px\],
|
||||
.h-\[560px\] canvas,
|
||||
canvas#mergedChart {
|
||||
height: 500px !important;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.print\:break-inside-avoid {
|
||||
break-inside: auto !important;
|
||||
page-break-inside: auto !important;
|
||||
}
|
||||
|
||||
.print\:p-2 {
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
.print\:text-\[10px\] {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.print\:text-\[11px\] {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.print\:scale-75 {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.print:text-\[11px\] {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.badge-xs {
|
||||
font-size: 6px !important;
|
||||
padding: 1px 3px !important;
|
||||
}
|
||||
|
||||
.text-\[9px\] {
|
||||
font-size: 7px !important;
|
||||
}
|
||||
|
||||
.text-\[10px\] {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.text-\[11px\] {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.font-bold.text-lg {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.font-bold.uppercase {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
.mb-4,
|
||||
.mb-6 {
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.mb-4,
|
||||
.mb-6 {
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.flex-col.lg\:flex-row {
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 2px !important;
|
||||
}
|
||||
|
||||
.space-y-6 > * + * {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 2px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section("script"); ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("reportMergedModule", () => ({
|
||||
tests: [],
|
||||
selectedTest: '',
|
||||
controls: [],
|
||||
processedControls: [],
|
||||
loading: false,
|
||||
month: '',
|
||||
mergedChart: null,
|
||||
lastRequestId: 0,
|
||||
controlColors: [
|
||||
{ border: '#570df8', bg: '#570df820' },
|
||||
{ border: '#ff52d9', bg: '#ff52d920' },
|
||||
{ border: '#10b981', bg: '#10b98120' },
|
||||
{ border: '#f59e0b', bg: '#f59e0b20' },
|
||||
{ border: '#ef4444', bg: '#ef444420' },
|
||||
{ border: '#06b6d4', bg: '#06b6d420' },
|
||||
],
|
||||
|
||||
init() {
|
||||
const now = new Date();
|
||||
this.month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||
this.fetchTests();
|
||||
},
|
||||
|
||||
async fetchTests() {
|
||||
try {
|
||||
const response = await fetch(`${BASEURL}api/master/tests`);
|
||||
const json = await response.json();
|
||||
this.tests = json.data || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tests:', e);
|
||||
}
|
||||
},
|
||||
|
||||
onMonthChange() {
|
||||
if (this.selectedTest) {
|
||||
this.fetchData();
|
||||
}
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
if (!this.selectedTest) return;
|
||||
|
||||
const requestId = ++this.lastRequestId;
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
test_id: this.selectedTest,
|
||||
month: this.month
|
||||
});
|
||||
const response = await fetch(`${BASEURL}api/entry/monthly?${params}`);
|
||||
const json = await response.json();
|
||||
|
||||
if (requestId !== this.lastRequestId) return;
|
||||
|
||||
if (json.status === 'success') {
|
||||
this.controls = json.data.controls || [];
|
||||
this.processedControls = this.controls.map(c => {
|
||||
const stats = this.calculateStats(c.results);
|
||||
return { ...c, stats };
|
||||
});
|
||||
this.$nextTick(() => {
|
||||
this.renderMergedChart();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId === this.lastRequestId) {
|
||||
console.error('Failed to fetch data:', e);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === this.lastRequestId) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
calculateStats(results) {
|
||||
if (!results || typeof results !== 'object') return { n: 0, mean: null, sd: null, cv: null };
|
||||
|
||||
const values = Object.values(results)
|
||||
.filter(r => r !== null && r !== undefined)
|
||||
.map(r => parseFloat(r.resValue))
|
||||
.filter(v => !isNaN(v));
|
||||
|
||||
if (values.length === 0) return { n: 0, mean: null, sd: null, cv: null };
|
||||
|
||||
const n = values.length;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / n;
|
||||
const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n > 1 ? n - 1 : 1);
|
||||
const sd = Math.sqrt(variance);
|
||||
const cv = mean !== 0 ? (sd / mean) * 100 : 0;
|
||||
|
||||
return { n, mean, sd, cv };
|
||||
},
|
||||
|
||||
renderMergedChart() {
|
||||
if (this.mergedChart) {
|
||||
this.mergedChart.destroy();
|
||||
this.mergedChart = null;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('mergedChart');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Deep clone to break reactivity
|
||||
const controls = JSON.parse(JSON.stringify(this.controls));
|
||||
const days = JSON.parse(JSON.stringify(this.daysInMonth));
|
||||
|
||||
if (!controls || controls.length === 0) return;
|
||||
|
||||
const colors = ['#570df8', '#ff52d9', '#10b981', '#f59e0b', '#ef4444', '#06b6d4'];
|
||||
const bgColors = ['#570df820', '#ff52d920', '#10b98120', '#f59e0b20', '#ef444420', '#06b6d420'];
|
||||
|
||||
const labels = days;
|
||||
const datasets = [];
|
||||
let allValues = [];
|
||||
|
||||
controls.forEach((control, index) => {
|
||||
const mean = parseFloat(control.mean) || 0;
|
||||
const sd = parseFloat(control.sd) || 0;
|
||||
const color = colors[index % colors.length];
|
||||
const bgColor = bgColors[index % bgColors.length];
|
||||
|
||||
const results = control.results || {};
|
||||
const dataPoints = days.map(day => {
|
||||
const res = results[day];
|
||||
if (res && res.resValue !== null) {
|
||||
const val = parseFloat(res.resValue);
|
||||
allValues.push(val);
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const pointColors = dataPoints.map(v => {
|
||||
if (v === null || mean === 0 || sd === 0) return color;
|
||||
return Math.abs(v - mean) > 2 * sd ? '#ef4444' : color;
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: String(control.controlName || ''),
|
||||
data: dataPoints,
|
||||
borderColor: color,
|
||||
backgroundColor: bgColor,
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: pointColors,
|
||||
pointBorderColor: pointColors,
|
||||
pointHoverRadius: 6,
|
||||
spanGaps: true,
|
||||
tension: 0.1
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate scale
|
||||
const targets = controls.map(c => parseFloat(c.mean) || 0).filter(m => m !== 0);
|
||||
const sds = controls.map(c => parseFloat(c.sd) || 0).filter(s => s !== 0);
|
||||
|
||||
let minScale = 0, maxScale = 100;
|
||||
if (allValues.length > 0 && targets.length > 0 && sds.length > 0) {
|
||||
const minVal = Math.min(...allValues);
|
||||
const maxVal = Math.max(...allValues);
|
||||
const minTgt = Math.min(...targets) - 3.5 * Math.max(...sds);
|
||||
const maxTgt = Math.max(...targets) + 3.5 * Math.max(...sds);
|
||||
minScale = Math.min(minVal, minTgt);
|
||||
maxScale = Math.max(maxVal, maxTgt);
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'line',
|
||||
font: { size: 11 }
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.dataset.label + ': ' + context.parsed.x;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: minScale,
|
||||
max: maxScale,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Value',
|
||||
font: { size: 11, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: '#e5e7eb'
|
||||
},
|
||||
ticks: {
|
||||
font: { family: 'monospace', size: 10 }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
reverse: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Day',
|
||||
font: { size: 11, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: '#e5e7eb'
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.mergedChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: chartOptions
|
||||
});
|
||||
},
|
||||
|
||||
get testName() {
|
||||
const test = this.tests.find(t => t.testId == this.selectedTest);
|
||||
return test ? test.testName : '';
|
||||
},
|
||||
|
||||
get departmentName() {
|
||||
const test = this.tests.find(t => t.testId == this.selectedTest);
|
||||
return test && test.deptName ? test.deptName : 'Laboratory Department';
|
||||
},
|
||||
|
||||
get monthDisplay() {
|
||||
if (!this.month) return '';
|
||||
const [year, month] = this.month.split('-');
|
||||
const date = new Date(year, month - 1);
|
||||
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
},
|
||||
|
||||
get daysInMonth() {
|
||||
if (!this.month) return [];
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const days = new Date(year, month, 0).getDate();
|
||||
const arr = new Array(days);
|
||||
for (let i = 0; i < days; i++) {
|
||||
arr[i] = i + 1;
|
||||
}
|
||||
return arr;
|
||||
},
|
||||
|
||||
prevMonth() {
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const date = new Date(year, month - 2, 1);
|
||||
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
|
||||
this.onMonthChange();
|
||||
},
|
||||
|
||||
nextMonth() {
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const date = new Date(year, month, 1);
|
||||
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
|
||||
this.onMonthChange();
|
||||
},
|
||||
|
||||
isWeekend(day) {
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek === 0 || dayOfWeek === 6;
|
||||
},
|
||||
|
||||
formatNum(val, dec = 2) {
|
||||
if (val === null || val === undefined) return '-';
|
||||
return parseFloat(val).toFixed(dec);
|
||||
},
|
||||
|
||||
getBias(control) {
|
||||
if (!control.stats.mean || !control.mean) return '-';
|
||||
const bias = ((control.stats.mean - control.mean) / control.mean) * 100;
|
||||
return bias > 0 ? '+' + bias.toFixed(1) : bias.toFixed(1);
|
||||
},
|
||||
|
||||
getBiasClass(control) {
|
||||
if (!control.stats.mean || !control.mean) return 'opacity-50';
|
||||
const bias = Math.abs(((control.stats.mean - control.mean) / control.mean) * 100);
|
||||
if (bias > 10) return 'text-error font-bold';
|
||||
if (bias > 5) return 'text-warning font-bold';
|
||||
return 'text-success font-bold';
|
||||
},
|
||||
|
||||
getResValue(control, day) {
|
||||
const res = control.results[day];
|
||||
return res && res.resValue !== null ? res.resValue : '-';
|
||||
},
|
||||
|
||||
getResComment(control, day) {
|
||||
const res = control.results[day];
|
||||
return res ? res.resComment : '';
|
||||
},
|
||||
|
||||
getValueClass(control, day) {
|
||||
const res = control.results[day];
|
||||
if (!res || res.resValue === null) return 'opacity-20';
|
||||
|
||||
if (control.mean === null || control.sd === null) return '';
|
||||
|
||||
const val = parseFloat(res.resValue);
|
||||
const target = parseFloat(control.mean);
|
||||
const sd = parseFloat(control.sd);
|
||||
const dev = Math.abs(val - target);
|
||||
|
||||
if (dev > 3 * sd) return 'text-error font-bold underline decoration-wavy';
|
||||
if (dev > 2 * sd) return 'text-error font-bold';
|
||||
return 'text-success';
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user