● refactor: update API responses to use {field}Label format

  - Transform coded fields to lowercase with Label suffix for display text                                                                                                                                              - Controllers: OrderTestController, DemoOrderController, SpecimenController,
    SpecimenStatusController, SpecimenCollectionController, ContainerDefController,
    ContactController, TestMapController
  - Example: Priority: "R" → priority: "R", priorityLabel: "Routine"
  - Update api-docs.yaml with new OpenAPI schema definitions
  - Add API docs reminder to CLAUDE.md
This commit is contained in:
mahdahar 2026-01-28 17:34:11 +07:00
commit 212ab4e80a
40 changed files with 11752 additions and 26764 deletions

6
.gitignore vendored
View File

@ -126,7 +126,5 @@ _modules/*
/phpunit*.xml
/public/.htaccess
#-------------------------
# Claude
#-------------------------
.claude
.serena/
.claude/

View File

@ -54,7 +54,10 @@ $routes->group('v2', ['filter' => 'auth'], function ($routes) {
// Master Data - Tests & ValueSets
$routes->get('master/tests', 'PagesController::masterTests');
$routes->get('master/valuesets', 'PagesController::masterValueSets');
$routes->get('valueset', 'PagesController::valueSetLibrary');
$routes->get('result/valueset', 'PagesController::resultValueSet');
$routes->get('result/valuesetdef', 'PagesController::resultValueSetDef');
});
// Faker
@ -161,12 +164,22 @@ $routes->group('api', function ($routes) {
$routes->delete('items/(:num)', 'ValueSetController::deleteItem/$1');
});
$routes->group('valuesetdef', function ($routes) {
$routes->get('/', 'ValueSetDefController::index');
$routes->get('(:num)', 'ValueSetDefController::show/$1');
$routes->post('/', 'ValueSetDefController::create');
$routes->put('(:num)', 'ValueSetDefController::update/$1');
$routes->delete('(:num)', 'ValueSetDefController::delete/$1');
$routes->group('result', function ($routes) {
$routes->group('valueset', function ($routes) {
$routes->get('/', 'Result\ResultValueSetController::index');
$routes->get('(:num)', 'Result\ResultValueSetController::show/$1');
$routes->post('/', 'Result\ResultValueSetController::create');
$routes->put('(:num)', 'Result\ResultValueSetController::update/$1');
$routes->delete('(:num)', 'Result\ResultValueSetController::delete/$1');
});
$routes->group('valuesetdef', function ($routes) {
$routes->get('/', 'ValueSetDefController::index');
$routes->get('(:num)', 'ValueSetDefController::show/$1');
$routes->post('/', 'ValueSetDefController::create');
$routes->put('(:num)', 'ValueSetDefController::update/$1');
$routes->delete('(:num)', 'ValueSetDefController::delete/$1');
});
});
// Counter

View File

@ -32,16 +32,21 @@ class AreaGeoController extends BaseController {
public function getProvinces() {
$rows = $this->model->getProvinces();
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "data not found", 'data' => '' ], 200); }
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200);
$transformed = array_map(function($row) {
return ['value' => $row['AreaGeoID'], 'label' => $row['AreaName']];
}, $rows);
if (empty($transformed)) { return $this->respond([ 'status' => 'success', 'data' => [] ], 200); }
return $this->respond([ 'status' => 'success', 'data' => $transformed ], 200);
}
public function getCities() {
$filter = [ 'Parent' => $this->request->getVar('Parent') ?? null ];
$rows = $this->model->getCities($filter);
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "data not found", 'data' => [] ], 200); }
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200);
$transformed = array_map(function($row) {
return ['value' => $row['AreaGeoID'], 'label' => $row['AreaName']];
}, $rows);
if (empty($transformed)) { return $this->respond([ 'status' => 'success', 'data' => [] ], 200); }
return $this->respond([ 'status' => 'success', 'data' => $transformed ], 200);
}
}

View File

@ -155,13 +155,35 @@ class PagesController extends BaseController
}
/**
* Master Data - Value Sets
* Value Set Library - Read-only
*/
public function masterValueSets()
public function valueSetLibrary()
{
return view('v2/master/valuesets/valuesets_index', [
'pageTitle' => 'Value Sets',
'activePage' => 'master-valuesets'
return view('v2/valueset/valueset_index', [
'pageTitle' => 'Value Set Library',
'activePage' => 'valueset-library'
]);
}
/**
* Result Valueset - CRUD for valueset table
*/
public function resultValueSet()
{
return view('v2/result/valueset/resultvalueset_index', [
'pageTitle' => 'Result Valuesets',
'activePage' => 'result-valueset'
]);
}
/**
* Result Valueset Definition - CRUD for valuesetdef table
*/
public function resultValueSetDef()
{
return view('v2/result/valuesetdef/resultvaluesetdef_index', [
'pageTitle' => 'Valueset Definitions',
'activePage' => 'result-valuesetdef'
]);
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Controllers\Result;
use App\Models\ValueSet\ValueSetModel;
use CodeIgniter\API\ResponseTrait;
class ResultValueSetController extends \CodeIgniter\Controller
{
use ResponseTrait;
protected $dbModel;
public function __construct()
{
$this->dbModel = new ValueSetModel();
}
public function index()
{
$search = $this->request->getGet('search') ?? $this->request->getGet('param') ?? null;
$VSetID = $this->request->getGet('VSetID') ?? null;
$rows = $this->dbModel->getValueSets($search, $VSetID);
return $this->respond([
'status' => 'success',
'data' => $rows
], 200);
}
public function show($id = null)
{
$row = $this->dbModel->getValueSet($id);
if (!$row) {
return $this->failNotFound("ValueSet item not found: $id");
}
return $this->respond([
'status' => 'success',
'data' => $row
], 200);
}
public function create()
{
$input = $this->request->getJSON(true);
if (!$input) {
return $this->failValidationErrors(['Invalid JSON input']);
}
$data = [
'SiteID' => $input['SiteID'] ?? 1,
'VSetID' => $input['VSetID'] ?? null,
'VOrder' => $input['VOrder'] ?? 0,
'VValue' => $input['VValue'] ?? '',
'VDesc' => $input['VDesc'] ?? '',
'VCategory' => $input['VCategory'] ?? null
];
if ($data['VSetID'] === null) {
return $this->failValidationErrors(['VSetID is required']);
}
try {
$id = $this->dbModel->insert($data, true);
if (!$id) {
return $this->failValidationErrors($this->dbModel->errors());
}
$newRow = $this->dbModel->getValueSet($id);
return $this->respondCreated([
'status' => 'success',
'message' => 'ValueSet item created',
'data' => $newRow
]);
} catch (\Exception $e) {
return $this->failServerError('Failed to create: ' . $e->getMessage());
}
}
public function update($id = null)
{
$input = $this->request->getJSON(true);
if (!$input) {
return $this->failValidationErrors(['Invalid JSON input']);
}
$existing = $this->dbModel->getValueSet($id);
if (!$existing) {
return $this->failNotFound("ValueSet item not found: $id");
}
$data = [];
if (isset($input['VSetID'])) $data['VSetID'] = $input['VSetID'];
if (isset($input['VOrder'])) $data['VOrder'] = $input['VOrder'];
if (isset($input['VValue'])) $data['VValue'] = $input['VValue'];
if (isset($input['VDesc'])) $data['VDesc'] = $input['VDesc'];
if (isset($input['SiteID'])) $data['SiteID'] = $input['SiteID'];
if (isset($input['VCategory'])) $data['VCategory'] = $input['VCategory'];
if (empty($data)) {
return $this->respond([
'status' => 'success',
'message' => 'No changes to update',
'data' => $existing
], 200);
}
try {
$updated = $this->dbModel->update($id, $data);
if (!$updated) {
return $this->failValidationErrors($this->dbModel->errors());
}
$newRow = $this->dbModel->getValueSet($id);
return $this->respond([
'status' => 'success',
'message' => 'ValueSet item updated',
'data' => $newRow
], 200);
} catch (\Exception $e) {
return $this->failServerError('Failed to update: ' . $e->getMessage());
}
}
public function delete($id = null)
{
$existing = $this->dbModel->getValueSet($id);
if (!$existing) {
return $this->failNotFound("ValueSet item not found: $id");
}
try {
$this->dbModel->delete($id);
return $this->respond([
'status' => 'success',
'message' => 'ValueSet item deleted'
], 200);
} catch (\Exception $e) {
return $this->failServerError('Failed to delete: ' . $e->getMessage());
}
}
}

View File

@ -158,12 +158,13 @@ class TestsController extends BaseController
$row['refnum'] = array_map(function ($r) {
return [
'RefNumID' => $r['RefNumID'],
'NumRefType' => $r['NumRefType'],
'NumRefTypeVValue' => ValueSet::getLabel('numeric_ref_type', $r['NumRefType']),
'RangeTypeVValue' => ValueSet::getLabel('range_type', $r['RangeType']),
'SexVValue' => ValueSet::getLabel('gender', $r['Sex']),
'LowSignVValue' => ValueSet::getLabel('math_sign', $r['LowSign']),
'HighSignVValue' => ValueSet::getLabel('math_sign', $r['HighSign']),
'NumRefTypeKey' => $r['NumRefType'],
'NumRefType' => ValueSet::getLabel('numeric_ref_type', $r['NumRefType']),
'RangeType' => ValueSet::getLabel('range_type', $r['RangeType']),
'SexKey' => $r['Sex'],
'Sex' => ValueSet::getLabel('gender', $r['Sex']),
'LowSign' => ValueSet::getLabel('math_sign', $r['LowSign']),
'HighSign' => ValueSet::getLabel('math_sign', $r['HighSign']),
'High' => $r['High'] !== null ? (int) $r['High'] : null,
'Flag' => $r['Flag']
];
@ -183,10 +184,10 @@ class TestsController extends BaseController
$row['reftxt'] = array_map(function ($r) {
return [
'RefTxtID' => $r['RefTxtID'],
'TxtRefType' => $r['TxtRefType'],
'TxtRefTypeVValue' => ValueSet::getLabel('text_ref_type', $r['TxtRefType']),
'Sex' => $r['Sex'],
'SexVValue' => ValueSet::getLabel('gender', $r['Sex']),
'TxtRefTypeKey' => $r['TxtRefType'],
'TxtRefType' => ValueSet::getLabel('text_ref_type', $r['TxtRefType']),
'SexKey' => $r['Sex'],
'Sex' => ValueSet::getLabel('gender', $r['Sex']),
'AgeStart' => (int) $r['AgeStart'],
'AgeEnd' => (int) $r['AgeEnd'],
'RefTxt' => $r['RefTxt'],

View File

@ -19,10 +19,19 @@ class ValueSetController extends \CodeIgniter\Controller
public function index(?string $lookupName = null)
{
$search = $this->request->getGet('search') ?? null;
if ($lookupName === null) {
$all = ValueSet::getAll();
$result = [];
foreach ($all as $name => $entry) {
if ($search) {
$nameLower = strtolower($name);
$searchLower = strtolower($search);
if (strpos($nameLower, $searchLower) === false) {
continue;
}
}
$count = count($entry['values'] ?? []);
$result[$name] = $count;
}

View File

@ -68,7 +68,9 @@ class ValueSet {
foreach ($data as &$row) {
foreach ($fieldMappings as $field => $lookupName) {
if (isset($row[$field]) && $row[$field] !== null) {
$row[$field . 'Text'] = self::getLabel($lookupName, $row[$field]) ?? '';
$keyValue = $row[$field];
$row[$field . 'Key'] = $keyValue;
$row[$field] = self::getLabel($lookupName, $keyValue) ?? '';
}
}
}

View File

@ -44,7 +44,7 @@ class PatientModel extends BaseModel {
$rows = $this->findAll();
$rows = ValueSet::transformLabels($rows, [
'Sex' => 'gender',
'Sex' => 'sex',
]);
return $rows;
}
@ -81,7 +81,7 @@ class PatientModel extends BaseModel {
unset($patient['Comment']);
$patient = ValueSet::transformLabels([$patient], [
'Sex' => 'gender',
'Sex' => 'sex',
'Country' => 'country',
'Race' => 'race',
'Religion' => 'religion',

View File

@ -173,14 +173,39 @@
</a>
</li>
<!-- Value Sets -->
<!-- Value Sets (Nested Group) -->
<li>
<a href="<?= base_url('/v2/master/valuesets') ?>"
:class="isActive('master/valuesets') ? 'active' : ''"
class="group">
<i class="fa-solid fa-list-check w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Value Sets</span>
</a>
<div x-data="{
isOpen: valuesetOpen,
toggle() { this.isOpen = !this.isOpen; $root.layout().valuesetOpen = this.isOpen }
}" x-init="$watch('valuesetOpen', v => isOpen = v)">
<button @click="isOpen = !isOpen"
class="group w-full flex items-center justify-between"
:class="isParentActive('valueset') ? 'text-primary font-medium' : ''">
<div class="flex items-center gap-3">
<i class="fa-solid fa-list-check w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Value Sets</span>
</div>
<i x-show="sidebarOpen" class="fa-solid fa-chevron-down text-xs transition-transform" :class="isOpen && 'rotate-180'"></i>
</button>
<ul x-show="isOpen && sidebarOpen" x-collapse class="ml-8 mt-2 space-y-1">
<li>
<a href="<?= base_url('/v2/valueset') ?>"
:class="isActive('valueset') && !isParentActive('result/valueset') && !isParentActive('result/valuesetdef') ? 'active' : ''"
class="text-sm">Library Valuesets</a>
</li>
<li>
<a href="<?= base_url('/v2/result/valueset') ?>"
:class="isActive('result/valueset') ? 'active' : ''"
class="text-sm">Result Valuesets</a>
</li>
<li>
<a href="<?= base_url('/v2/result/valuesetdef') ?>"
:class="isActive('result/valuesetdef') ? 'active' : ''"
class="text-sm">Valueset Definitions</a>
</li>
</ul>
</div>
</li>
<!-- Settings -->
@ -319,6 +344,7 @@
lightMode: localStorage.getItem('theme') !== 'dark',
orgOpen: false,
specimenOpen: false,
valuesetOpen: false,
currentPath: window.location.pathname,
init() {
@ -333,6 +359,7 @@
// Auto-expand menus based on active path
this.orgOpen = this.currentPath.includes('organization');
this.specimenOpen = this.currentPath.includes('specimen');
this.valuesetOpen = this.currentPath.includes('valueset');
// Watch sidebar state to persist
this.$watch('sidebarOpen', val => localStorage.setItem('sidebarOpen', val));

View File

@ -1,363 +0,0 @@
<!-- Nested ValueSet CRUD Modal -->
<div
x-show="showValueSetModal"
x-cloak
class="modal-overlay"
style="z-index: 1000;"
@click.self="$root.closeValueSetModal()"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div
class="modal-content p-0 max-w-5xl w-full max-h-[90vh] overflow-hidden"
@click.stop
x-data="valueSetItems()"
x-init="selectedDef = $root.selectedDef; if(selectedDef) { fetchList(1); fetchDefsList(); }"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="p-6 border-b flex items-center justify-between" style="background: rgb(var(--color-bg)); border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-md" style="background: rgb(var(--color-primary));">
<i class="fa-solid fa-list-ul"></i>
</div>
<div>
<h3 class="font-bold text-lg" style="color: rgb(var(--color-text));" x-text="selectedDef?.VSName || 'Value Items'"></h3>
<p class="text-xs uppercase font-bold opacity-40" style="color: rgb(var(--color-text-muted));">Manage Category Items</p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i> Add Item
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="$root.closeValueSetModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>
<!-- Search Bar -->
<div class="p-4 border-b" style="border-color: rgb(var(--color-border));">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm w-full pl-10"
x-model="keyword"
@keyup.enter="fetchList(1)"
/>
</div>
</div>
<!-- Content Area -->
<div class="overflow-y-auto" style="max-height: calc(90vh - 200px);">
<!-- Loading Overlay -->
<div x-show="loading" class="py-20 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading items...</p>
</div>
<!-- Table Section -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-20">ID</th>
<th>Value / Key</th>
<th>Definition</th>
<th class="text-center">Order</th>
<th class="text-center w-32">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="5" class="py-20 text-center">
<div class="flex flex-col items-center gap-2 opacity-30" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-4xl"></i>
<p class="font-bold italic">No items found in this category</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Item
</button>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template x-for="v in list" :key="v.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="v.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="v.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="v.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="v.VOrder || 0"></span>
</td>
<td>
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editValue(v.VID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(v)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Stats Footer -->
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));" x-show="list && list.length > 0">
<span class="text-sm" style="color: rgb(var(--color-text-muted));" x-text="list.length + ' items'"></span>
</div>
</div>
<!-- Item Form Dialog -->
<?= $this->include('v2/master/valuesets/valueset_dialog') ?>
<!-- Delete Modal -->
<div x-show="showDeleteModal" x-cloak class="modal-overlay" style="z-index: 1100;">
<div
class="card p-8 max-w-md w-full shadow-2xl"
x-show="showDeleteModal"
x-transition
>
<div class="w-16 h-16 rounded-2xl bg-rose-500/10 flex items-center justify-center text-rose-500 mx-auto mb-6">
<i class="fa-solid fa-triangle-exclamation text-2xl"></i>
</div>
<h3 class="text-xl font-bold text-center mb-2" style="color: rgb(var(--color-text));">Confirm Removal</h3>
<p class="text-center text-sm mb-8" style="color: rgb(var(--color-text-muted));">
Are you sure you want to delete <span class="font-bold text-rose-500" x-text="deleteTarget?.VValue"></span>?
</p>
<div class="flex gap-3">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1 bg-rose-600 text-white hover:bg-rose-700 shadow-lg shadow-rose-600/20" @click="deleteValue()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm !border-white/20 !border-t-white"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function valueSetItems() {
return {
loading: false,
list: [],
selectedDef: null,
keyword: "",
totalItems: 0,
// For dropdown population
defsList: [],
loadingDefs: false,
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async fetchList(page = 1) {
if (!this.selectedDef) return;
this.loading = true;
try {
const params = new URLSearchParams();
params.append('VSetID', this.selectedDef.VSetID);
if (this.keyword) params.append('search', this.keyword);
if (this.selectedDef) params.append('VSetID', this.selectedDef.VSetID);
const res = await fetch(`${BASEURL}api/valueset/items?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
this.totalItems = this.list.length;
} catch (err) {
console.error(err);
this.list = [];
this.totalItems = 0;
this.showToast('Failed to load items', 'error');
} finally {
this.loading = false;
}
},
async fetchDefsList() {
this.loadingDefs = true;
try {
const res = await fetch(`${BASEURL}api/valuesetdef?limit=1000`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defsList = data.data || [];
} catch (err) {
console.error('Failed to fetch defs list:', err);
this.defsList = [];
} finally {
this.loadingDefs = false;
}
},
showForm() {
this.isEditing = false;
this.form = {
VID: null,
VSetID: this.selectedDef?.VSetID || "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.errors = {};
// If no selectedDef, we need to load all defs for dropdown
if (!this.selectedDef && this.defsList.length === 0) {
this.fetchDefsList();
}
this.showModal = true;
},
async editValue(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/valueset/items/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load item data', 'error');
}
},
validate() {
const e = {};
if (!this.form.VValue?.trim()) e.VValue = "Value is required";
if (!this.form.VSetID) e.VSetID = "Category is required";
this.errors = e;
return Object.keys(e).length === 0;
},
closeModal() {
this.showModal = false;
this.errors = {};
},
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PUT' : 'POST';
const url = this.isEditing ? `${BASEURL}api/valueset/items/${this.form.VID}` : `${BASEURL}api/valueset/items`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
if (res.ok) {
this.closeModal();
await this.fetchList(1);
this.showToast(this.isEditing ? 'Item updated successfully' : 'Item created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.errors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error('Save failed:', err);
this.errors = { general: err.message || 'An error occurred while saving' };
this.showToast('Failed to save item', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(v) {
this.deleteTarget = v;
this.showDeleteModal = true;
},
async deleteValue() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/valueset/items/${this.deleteTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList(1);
this.showToast('Item deleted successfully', 'success');
} else {
this.showToast('Failed to delete item', 'error');
}
} catch (err) {
console.error('Delete failed:', err);
this.showToast('Failed to delete item', 'error');
} finally {
this.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>

View File

@ -1,678 +0,0 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="valueSetManager()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-layer-group text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Value Set Manager</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage value set categories and their items</p>
</div>
</div>
</div>
<!-- Two Column Layout with Independent Scrolling -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- LEFT PANEL: ValueSetDef List -->
<div class="card overflow-hidden flex flex-col" style="height: calc(100vh - 280px); min-height: 400px;">
<!-- Left Panel Header -->
<div class="p-4 border-b flex items-center justify-between" style="border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: rgb(var(--color-primary));">
<i class="fa-solid fa-layer-group text-white"></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));">Categories</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Value Set Definitions</p>
</div>
</div>
<button class="btn btn-primary btn-sm" @click="showDefForm()">
<i class="fa-solid fa-plus mr-1"></i> Add
</button>
</div>
<!-- Search Bar -->
<div class="p-3 border-b" style="border-color: rgb(var(--color-border));">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400" style="z-index: 10;"></i>
<input
type="text"
placeholder="Search categories..."
class="input input-sm w-full input-with-icon"
x-model="defKeyword"
@keyup.enter="fetchDefs()"
/>
</div>
</div>
<!-- Loading State -->
<div x-show="defLoading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading categories...</p>
</div>
<!-- Def List Table -->
<div class="overflow-y-auto flex-1" x-show="!defLoading" x-cloak>
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category Name</th>
<th class="w-20 text-center">Items</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!defList || defList.length === 0">
<tr>
<td colspan="4" class="text-center py-12">
<div class="flex flex-col items-center gap-2" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-folder-open text-4xl opacity-40"></i>
<p class="text-sm">No categories found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showDefForm()">
<i class="fa-solid fa-plus mr-1"></i> Add Category
</button>
</div>
</td>
</tr>
</template>
<template x-for="def in defList" :key="def.VSetID">
<tr
class="hover:bg-opacity-50 cursor-pointer transition-colors"
:class="selectedDef?.VSetID === def.VSetID ? 'bg-primary/10' : ''"
@click="selectDef(def)"
>
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="def.VSetID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="def.VSName || '-'"></div>
<div class="text-xs opacity-50" x-text="def.VSDesc || ''"></div>
</td>
<td class="text-center">
<span class="badge badge-sm" x-text="(def.ItemCount || 0) + ' items'"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1" @click.stop>
<button class="btn btn-ghost btn-sm btn-square" @click="editDef(def.VSetID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDeleteDef(def)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Left Panel Footer -->
<div class="p-3 flex items-center justify-between text-xs" style="border-top: 1px solid rgb(var(--color-border));" x-show="defList && defList.length > 0">
<span style="color: rgb(var(--color-text-muted));" x-text="defList.length + ' categories'"></span>
</div>
</div>
<!-- RIGHT PANEL: ValueSet Items -->
<div class="card overflow-hidden flex flex-col" style="height: calc(100vh - 280px); min-height: 400px;">
<!-- Right Panel Header -->
<div class="p-4 border-b flex items-center justify-between" style="border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: rgb(var(--color-secondary));">
<i class="fa-solid fa-list-ul text-white"></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));">Items</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">
<template x-if="selectedDef">
<span x-text="selectedDef.VSName + ' Items'"></span>
</template>
<template x-if="!selectedDef">
<span>Select a category to view items</span>
</template>
</p>
</div>
</div>
<button
class="btn btn-primary btn-sm"
@click="showValueForm()"
:disabled="!selectedDef"
>
<i class="fa-solid fa-plus mr-1"></i> Add Item
</button>
</div>
<!-- Search Bar (Right Panel) -->
<div class="p-3 border-b" style="border-color: rgb(var(--color-border));" x-show="selectedDef">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400" style="z-index: 10;"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm w-full input-with-icon"
x-model="valueKeyword"
@keyup.enter="fetchValues()"
/>
</div>
</div>
<!-- Empty State - No Selection -->
<div x-show="!selectedDef" class="p-16 text-center" x-cloak>
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-hand-pointer text-5xl opacity-30"></i>
<p class="text-lg font-medium">Select a category</p>
<p class="text-sm opacity-60">Click on a category from the left panel to view and manage its items</p>
</div>
</div>
<!-- Loading State -->
<div x-show="valueLoading && selectedDef" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading items...</p>
</div>
<!-- Value List Table -->
<div class="overflow-y-auto flex-1" x-show="!valueLoading && selectedDef" x-cloak>
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Value</th>
<th>Description</th>
<th class="w-16 text-center">Order</th>
<th class="w-20 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!valueList || valueList.length === 0">
<tr>
<td colspan="5" class="text-center py-12">
<div class="flex flex-col items-center gap-2" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-4xl opacity-40"></i>
<p class="text-sm">No items found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showValueForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Item
</button>
</div>
</td>
</tr>
</template>
<template x-for="value in valueList" :key="value.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="value.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="value.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="value.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="value.VOrder || 0"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editValue(value.VID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDeleteValue(value)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Right Panel Footer -->
<div class="p-3 flex items-center justify-between text-xs" style="border-top: 1px solid rgb(var(--color-border));" x-show="valueList && valueList.length > 0 && selectedDef">
<span style="color: rgb(var(--color-text-muted));" x-text="valueList.length + ' items'"></span>
</div>
</div>
</div>
<!-- Include Definition Form Dialog -->
<?= $this->include('v2/master/valuesets/valuesetdef_dialog') ?>
<!-- Include Value Form Dialog -->
<?= $this->include('v2/master/valuesets/valueset_dialog') ?>
<!-- Delete Category Confirmation Modal -->
<div
x-show="showDeleteDefModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteDefModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete category <strong x-text="deleteDefTarget?.VSName"></strong>?
This will also delete all items in this category and cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteDefModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteDef()" :disabled="deletingDef">
<span x-show="deletingDef" class="spinner spinner-sm"></span>
<span x-show="!deletingDef">Delete</span>
</button>
</div>
</div>
</div>
<!-- Delete Value Confirmation Modal -->
<div
x-show="showDeleteValueModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteValueModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete item <strong x-text="deleteValueTarget?.VValue"></strong>?
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteValueModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteValue()" :disabled="deletingValue">
<span x-show="deletingValue" class="spinner spinner-sm"></span>
<span x-show="!deletingValue">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function valueSetManager() {
return {
// State - Definitions
defLoading: false,
defList: [],
defKeyword: "",
// State - Values
valueLoading: false,
valueList: [],
valueKeyword: "",
selectedDef: null,
// Definition Form
showDefModal: false,
isEditingDef: false,
savingDef: false,
defErrors: {},
defForm: {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
},
// Value Form
showValueModal: false,
isEditingValue: false,
savingValue: false,
valueErrors: {},
valueForm: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
// Delete Definition
showDeleteDefModal: false,
deleteDefTarget: null,
deletingDef: false,
// Delete Value
showDeleteValueModal: false,
deleteValueTarget: null,
deletingValue: false,
// Dropdown data
defsList: [],
// Lifecycle
async init() {
await this.fetchDefs();
},
// ==================== DEFINITION METHODS ====================
async fetchDefs() {
this.defLoading = true;
try {
const params = new URLSearchParams();
if (this.defKeyword) params.append('search', this.defKeyword);
const res = await fetch(`${BASEURL}api/valuesetdef?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defList = data.data || [];
if (this.selectedDef) {
const updated = this.defList.find(d => d.VSetID === this.selectedDef.VSetID);
if (updated) {
this.selectedDef = updated;
}
}
} catch (err) {
console.error(err);
this.defList = [];
this.showToast('Failed to load categories', 'error');
} finally {
this.defLoading = false;
}
},
showDefForm() {
this.isEditingDef = false;
this.defForm = {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
};
this.defErrors = {};
this.showDefModal = true;
},
async editDef(id) {
this.isEditingDef = true;
this.defErrors = {};
try {
const res = await fetch(`${BASEURL}api/valuesetdef/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.defForm = { ...this.defForm, ...data.data };
this.showDefModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load category data', 'error');
}
},
validateDef() {
const e = {};
if (!this.defForm.VSName?.trim()) e.VSName = "Category name is required";
this.defErrors = e;
return Object.keys(e).length === 0;
},
closeDefModal() {
this.showDefModal = false;
this.defErrors = {};
},
async saveDef() {
if (!this.validateDef()) return;
this.savingDef = true;
try {
const method = this.isEditingDef ? 'PUT' : 'POST';
const url = this.isEditingDef ? `${BASEURL}api/valuesetdef/${this.defForm.VSetID}` : `${BASEURL}api/valuesetdef`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.defForm),
credentials: 'include'
});
if (res.ok) {
this.closeDefModal();
await this.fetchDefs();
this.showToast(this.isEditingDef ? 'Category updated successfully' : 'Category created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.defErrors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.defErrors = { general: 'Failed to save category' };
this.showToast('Failed to save category', 'error');
} finally {
this.savingDef = false;
}
},
confirmDeleteDef(def) {
this.deleteDefTarget = def;
this.showDeleteDefModal = true;
},
async deleteDef() {
if (!this.deleteDefTarget) return;
this.deletingDef = true;
try {
const res = await fetch(`${BASEURL}api/valuesetdef/${this.deleteDefTarget.VSetID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteDefModal = false;
if (this.selectedDef?.VSetID === this.deleteDefTarget.VSetID) {
this.selectedDef = null;
this.valueList = [];
}
await this.fetchDefs();
this.showToast('Category deleted successfully', 'success');
} else {
this.showToast('Failed to delete category', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete category', 'error');
} finally {
this.deletingDef = false;
this.deleteDefTarget = null;
}
},
// ==================== VALUE METHODS ====================
selectDef(def) {
this.selectedDef = def;
this.fetchValues();
},
async fetchValues() {
if (!this.selectedDef) return;
this.valueLoading = true;
try {
const params = new URLSearchParams();
params.append('VSetID', this.selectedDef.VSetID);
if (this.valueKeyword) params.append('search', this.valueKeyword);
const res = await fetch(`${BASEURL}api/valueset/items?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.valueList = data.data || [];
} catch (err) {
console.error(err);
this.valueList = [];
this.showToast('Failed to load items', 'error');
} finally {
this.valueLoading = false;
}
},
async fetchDefsList() {
try {
const res = await fetch(`${BASEURL}api/valuesetdef?limit=1000`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defsList = data.data || [];
} catch (err) {
console.error('Failed to fetch defs list:', err);
this.defsList = [];
}
},
showValueForm() {
if (!this.selectedDef) {
this.showToast('Please select a category first', 'warning');
return;
}
this.isEditingValue = false;
this.valueForm = {
VID: null,
VSetID: this.selectedDef.VSetID,
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.valueErrors = {};
this.showValueModal = true;
},
async editValue(id) {
this.isEditingValue = true;
this.valueErrors = {};
try {
const res = await fetch(`${BASEURL}api/valueset/items/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.valueForm = { ...this.valueForm, ...data.data };
this.showValueModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load item data', 'error');
}
},
validateValue() {
const e = {};
if (!this.valueForm.VValue?.trim()) e.VValue = "Value is required";
if (!this.valueForm.VSetID) e.VSetID = "Category is required";
this.valueErrors = e;
return Object.keys(e).length === 0;
},
closeValueModal() {
this.showValueModal = false;
this.valueErrors = {};
},
async saveValue() {
if (!this.validateValue()) return;
this.savingValue = true;
try {
const method = this.isEditingValue ? 'PUT' : 'POST';
const url = this.isEditingValue ? `${BASEURL}api/valueset/items/${this.valueForm.VID}` : `${BASEURL}api/valueset/items`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.valueForm),
credentials: 'include'
});
if (res.ok) {
this.closeValueModal();
await this.fetchValues();
await this.fetchDefs();
this.showToast(this.isEditingValue ? 'Item updated successfully' : 'Item created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.valueErrors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.valueErrors = { general: 'Failed to save item' };
this.showToast('Failed to save item', 'error');
} finally {
this.savingValue = false;
}
},
confirmDeleteValue(value) {
this.deleteValueTarget = value;
this.showDeleteValueModal = true;
},
async deleteValue() {
if (!this.deleteValueTarget) return;
this.deletingValue = true;
try {
const res = await fetch(`${BASEURL}api/valueset/items/${this.deleteValueTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteValueModal = false;
await this.fetchValues();
await this.fetchDefs();
this.showToast('Item deleted successfully', 'success');
} else {
this.showToast('Failed to delete item', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete item', 'error');
} finally {
this.deletingValue = false;
this.deleteValueTarget = null;
}
},
// ==================== UTILITIES ====================
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,4 +1,4 @@
<!-- Value Set Item Form Modal -->
<!-- Result Value Set Item Form Modal -->
<div
x-show="showModal"
x-cloak
@ -21,7 +21,6 @@
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-list-plus" style="color: rgb(var(--color-primary));"></i>
@ -32,10 +31,8 @@
</button>
</div>
<!-- Form -->
<div class="space-y-6">
<!-- General Error -->
<div x-show="errors.general" class="p-4 rounded-lg bg-rose-50 border border-rose-200" style="display: none;">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-rose-500"></i>
@ -43,8 +40,7 @@
</div>
</div>
<!-- Category Selection (only show if no selectedDef) -->
<div x-show="!selectedDef" class="space-y-4">
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Category Assignment</h4>
<div>
@ -63,7 +59,6 @@
</div>
</div>
<!-- Basic Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Item Details</h4>
@ -109,7 +104,6 @@
</div>
</div>
<!-- System Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">System Information</h4>
@ -143,7 +137,6 @@
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-primary flex-1" @click="save()" :disabled="saving">

View File

@ -0,0 +1,322 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="resultValueSet()" x-init="init()">
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-600 to-orange-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-list-ul text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Result Valuesets</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage valueset items from database</p>
</div>
</div>
</div>
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search valuesets..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Item
</button>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading valuesets...</p>
</div>
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category</th>
<th>Value</th>
<th>Description</th>
<th class="w-20 text-center">Order</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No valuesets found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Item
</button>
</div>
</td>
</tr>
</template>
<template x-for="item in list" :key="item.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="item.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.VCategoryName || '-'"></div>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="item.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="item.VOrder || 0"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editItem(item.VID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(item)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<?= $this->include('v2/result/valueset/resultvalueset_dialog') ?>
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete item <strong x-text="deleteTarget?.VValue"></strong>?
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteItem()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function resultValueSet() {
return {
loading: false,
list: [],
keyword: "",
defsList: [],
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async init() {
await this.fetchList();
await this.fetchDefsList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/result/valueset?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
this.showToast('Failed to load valuesets', 'error');
} finally {
this.loading = false;
}
},
async fetchDefsList() {
try {
const res = await fetch(`${BASEURL}api/result/valuesetdef?limit=1000`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defsList = data.data || [];
} catch (err) {
console.error('Failed to fetch defs list:', err);
this.defsList = [];
}
},
showForm() {
this.isEditing = false;
this.form = {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.errors = {};
this.showModal = true;
},
async editItem(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/result/valueset/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load item data', 'error');
}
},
validate() {
const e = {};
if (!this.form.VValue?.trim()) e.VValue = "Value is required";
if (!this.form.VSetID) e.VSetID = "Category is required";
this.errors = e;
return Object.keys(e).length === 0;
},
closeModal() {
this.showModal = false;
this.errors = {};
},
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PUT' : 'POST';
const url = this.isEditing ? `${BASEURL}api/result/valueset/${this.form.VID}` : `${BASEURL}api/result/valueset`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
if (res.ok) {
this.closeModal();
await this.fetchList();
this.showToast(this.isEditing ? 'Item updated successfully' : 'Item created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.errors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.errors = { general: 'Failed to save item' };
this.showToast('Failed to save item', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(item) {
this.deleteTarget = item;
this.showDeleteModal = true;
},
async deleteItem() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/result/valueset/${this.deleteTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
this.showToast('Item deleted successfully', 'success');
} else {
this.showToast('Failed to delete item', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete item', 'error');
} finally {
this.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,4 +1,4 @@
<!-- Value Set Definition Form Modal -->
<!-- Result Value Set Definition Form Modal -->
<div
x-show="showModal"
x-cloak
@ -21,7 +21,6 @@
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-layer-group-plus" style="color: rgb(var(--color-primary));"></i>
@ -32,10 +31,8 @@
</button>
</div>
<!-- Form -->
<div class="space-y-6">
<!-- General Error -->
<div x-show="errors.general" class="p-4 rounded-lg bg-rose-50 border border-rose-200" style="display: none;">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-rose-500"></i>
@ -43,7 +40,6 @@
</div>
</div>
<!-- Basic Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Basic Information</h4>
@ -75,7 +71,6 @@
</div>
</div>
<!-- Additional Info Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">System Information</h4>
@ -109,7 +104,6 @@
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-primary flex-1" @click="save()" :disabled="saving">

View File

@ -0,0 +1,298 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="resultValueSetDef()" x-init="init()">
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-rose-600 to-pink-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-layer-group text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Valueset Definitions</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage valueset categories and definitions</p>
</div>
</div>
</div>
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search definitions..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Category
</button>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading definitions...</p>
</div>
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category Name</th>
<th>Description</th>
<th class="w-20 text-center">Items</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!list || list.length === 0">
<tr>
<td colspan="5" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-folder-open text-5xl opacity-40"></i>
<p class="text-lg">No definitions found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Category
</button>
</div>
</td>
</tr>
</template>
<template x-for="def in list" :key="def.VSetID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="def.VSetID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="def.VSName || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="def.VSDesc || '-'"></span>
</td>
<td class="text-center">
<span class="badge badge-sm" x-text="(def.ItemCount || 0) + ' items'"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editItem(def.VSetID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(def)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<?= $this->include('v2/result/valuesetdef/resultvaluesetdef_dialog') ?>
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete category <strong x-text="deleteTarget?.VSName"></strong>?
This will also delete all items in this category and cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteItem()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function resultValueSetDef() {
return {
loading: false,
list: [],
keyword: "",
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async init() {
await this.fetchList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/result/valuesetdef?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
this.showToast('Failed to load definitions', 'error');
} finally {
this.loading = false;
}
},
showForm() {
this.isEditing = false;
this.form = {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
};
this.errors = {};
this.showModal = true;
},
async editItem(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/result/valuesetdef/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load category data', 'error');
}
},
validate() {
const e = {};
if (!this.form.VSName?.trim()) e.VSName = "Category name is required";
this.errors = e;
return Object.keys(e).length === 0;
},
closeModal() {
this.showModal = false;
this.errors = {};
},
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PUT' : 'POST';
const url = this.isEditing ? `${BASEURL}api/result/valuesetdef/${this.form.VSetID}` : `${BASEURL}api/result/valuesetdef`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
if (res.ok) {
this.closeModal();
await this.fetchList();
this.showToast(this.isEditing ? 'Category updated successfully' : 'Category created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.errors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.errors = { general: 'Failed to save category' };
this.showToast('Failed to save category', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(def) {
this.deleteTarget = def;
this.showDeleteModal = true;
},
async deleteItem() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/result/valuesetdef/${this.deleteTarget.VSetID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
this.showToast('Category deleted successfully', 'success');
} else {
this.showToast('Failed to delete category', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete category', 'error');
} finally {
this.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,371 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="valueSetLibrary()" x-init="init()" class="relative">
<!-- Header & Stats -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-600 to-teal-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-layer-group text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Value Set Library</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Browse predefined value sets from library</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-2xl font-bold" style="color: rgb(var(--color-primary));" x-text="Object.keys(list).length"></p>
<p class="text-xs uppercase tracking-wider opacity-60">Value Sets</p>
</div>
<div class="w-px h-8 bg-current opacity-10"></div>
<div class="text-center">
<p class="text-2xl font-bold" style="color: rgb(var(--color-secondary));" x-text="totalItems"></p>
<p class="text-xs uppercase tracking-wider opacity-60">Total Items</p>
</div>
</div>
</div>
</div>
<!-- 2-Column Layout: Left Sidebar (Categories) + Right Content (Values) -->
<div class="grid grid-cols-12 gap-4" style="height: calc(100vh - 200px);">
<!-- LEFT PANEL: Value Set Categories -->
<div class="col-span-4 xl:col-span-3 flex flex-col card-glass overflow-hidden">
<!-- Left Panel Header -->
<div class="p-4 border-b shrink-0" style="border-color: rgb(var(--color-border));">
<h3 class="font-semibold text-sm uppercase tracking-wider opacity-60 mb-3">Categories</h3>
<div class="flex items-center gap-2 bg-base-200 rounded-lg px-3 border border-dashed border-base-content/20">
<i class="fa-solid fa-search text-xs opacity-50"></i>
<input
type="text"
placeholder="Search categories..."
class="input input-sm bg-transparent border-0 p-2 flex-1 min-w-0 focus:outline-none"
x-model.debounce.300ms="keyword"
@input="fetchList()"
/>
<button
x-show="keyword"
@click="keyword = ''; fetchList()"
class="btn btn-ghost btn-xs btn-square"
x-cloak
>
<i class="fa-solid fa-times text-xs"></i>
</button>
</div>
</div>
<!-- Categories List -->
<div class="flex-1 overflow-y-auto">
<!-- Skeleton Loading -->
<div x-show="loading && !Object.keys(list).length" class="p-4 space-y-2" x-cloak>
<template x-for="i in 5">
<div class="p-3 animate-pulse rounded-lg bg-current opacity-5">
<div class="h-4 w-3/4 rounded bg-current opacity-10 mb-2"></div>
<div class="h-3 w-1/4 rounded bg-current opacity-10"></div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="!loading && !Object.keys(list).length" class="p-8 text-center opacity-40" x-cloak>
<i class="fa-solid fa-folder-open text-3xl mb-2"></i>
<p class="text-sm">No categories found</p>
</div>
<!-- Category Items -->
<div x-show="!loading && Object.keys(list).length > 0" class="p-2" x-cloak>
<template x-for="(count, name) in filteredList" :key="name">
<div
class="p-3 rounded-lg cursor-pointer transition-all mb-1 group"
:class="selectedCategory === name ? 'bg-primary/10 border border-primary/30' : 'hover:bg-black/5 border border-transparent'"
@click="selectCategory(name)"
>
<div class="flex items-center justify-between">
<div class="truncate flex-1">
<div
class="font-medium text-sm transition-colors"
:class="selectedCategory === name ? 'text-primary' : 'opacity-80'"
x-text="formatName(name)"
></div>
<div class="text-xs opacity-40 font-mono truncate" x-text="name"></div>
</div>
<div class="flex items-center gap-2 ml-2">
<span
class="badge badge-sm"
:class="selectedCategory === name ? 'badge-primary' : 'badge-ghost'"
x-text="count"
></span>
<i
class="fa-solid fa-chevron-right text-xs transition-transform"
:class="selectedCategory === name ? 'opacity-100 rotate-0' : 'opacity-0 group-hover:opacity-50'"
></i>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Left Panel Footer -->
<div class="p-3 border-t text-xs text-center opacity-40" style="border-color: rgb(var(--color-border));">
<span x-text="Object.keys(list).length"></span> categories
</div>
</div>
<!-- RIGHT PANEL: Value Set Values -->
<div class="col-span-8 xl:col-span-9 flex flex-col card-glass overflow-hidden">
<!-- Right Panel Header -->
<div class="p-4 border-b shrink-0" style="border-color: rgb(var(--color-border));">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-lg flex items-center justify-center transition-transform"
:class="selectedCategory ? 'bg-primary/10' : 'bg-black/5'"
>
<i
class="fa-solid text-lg"
:class="selectedCategory ? 'fa-table-list text-primary' : 'fa-list opacity-20'"
></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));" x-text="selectedCategory ? formatName(selectedCategory) : 'Select a Category'"></h3>
<p x-show="selectedCategory" class="text-xs font-mono opacity-50" x-text="selectedCategory"></p>
</div>
</div>
</div>
<!-- Filter Input (when category selected) -->
<div x-show="selectedCategory" class="mt-3" x-transition>
<div class="flex items-center gap-2 bg-black/5 rounded-lg px-3 border border-dashed">
<i class="fa-solid fa-filter text-xs opacity-40"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm bg-transparent border-0 p-2 flex-1 focus:outline-none"
x-model="itemFilter"
/>
</div>
</div>
</div>
<!-- Right Panel Content -->
<div class="flex-1 overflow-y-auto">
<!-- No Category Selected State -->
<div x-show="!selectedCategory" class="h-full flex flex-col items-center justify-center opacity-30" x-cloak>
<i class="fa-solid fa-arrow-left text-5xl mb-4"></i>
<p class="text-lg">Select a category from the left to view values</p>
</div>
<!-- Loading State -->
<div x-show="itemLoading" class="h-full flex flex-col items-center justify-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading items...</p>
</div>
<!-- Values Table -->
<div x-show="!itemLoading && selectedCategory">
<template x-if="!items[selectedCategory]?.length">
<div class="h-full flex flex-col items-center justify-center opacity-30" x-cloak>
<i class="fa-solid fa-box-open text-5xl mb-4"></i>
<p>This category has no items</p>
</div>
</template>
<template x-if="items[selectedCategory]?.length">
<table class="table table-zebra w-full">
<thead class="sticky top-0 bg-inherit shadow-sm z-10">
<tr>
<th class="w-24">Key</th>
<th>Value / Label</th>
<th class="w-20 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-for="item in filteredItems" :key="item.value">
<tr class="group">
<td class="font-mono text-xs">
<span class="badge badge-ghost px-2 py-1" x-text="item.value || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.label || '-'"></div>
</td>
<td class="text-center">
<button
class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity"
@click="copyToClipboard(item.label)"
title="Copy label"
>
<i class="fa-solid fa-copy"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<!-- Filter Empty State -->
<template x-if="filteredItems.length === 0 && items[selectedCategory]?.length && itemFilter">
<div class="p-12 text-center opacity-40" x-cloak>
<i class="fa-solid fa-magnifying-glass text-4xl mb-3"></i>
<p>No items match your filter</p>
</div>
</template>
</div>
</div>
<!-- Right Panel Footer -->
<div x-show="selectedCategory" class="p-3 border-t text-xs text-center opacity-40" style="border-color: rgb(var(--color-border));" x-transition>
Showing <span x-text="filteredItems.length"></span> of <span x-text="items[selectedCategory]?.length || 0"></span> items
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function valueSetLibrary() {
return {
loading: false,
itemLoading: false,
list: {},
items: {},
keyword: "",
sortBy: 'name',
selectedCategory: null,
itemFilter: "",
get totalItems() {
return Object.values(this.list).reduce((acc, count) => acc + count, 0);
},
get filteredList() {
if (!this.keyword) return this.list;
const filter = this.keyword.toLowerCase();
return Object.fromEntries(
Object.entries(this.list).filter(([name]) =>
this.matchesPartial(name, filter)
)
);
},
matchesPartial(name, filter) {
const nameLower = name.toLowerCase().replace(/_/g, ' ');
let nameIndex = 0;
for (let i = 0; i < filter.length; i++) {
const char = filter[i];
const foundIndex = nameLower.indexOf(char, nameIndex);
if (foundIndex === -1) return false;
nameIndex = foundIndex + 1;
}
return true;
},
get filteredItems() {
if (!this.items[this.selectedCategory]) return [];
const filter = this.itemFilter.toLowerCase();
return this.items[this.selectedCategory].filter(item => {
const label = (item.label || "").toLowerCase();
const value = (item.value || "").toLowerCase();
return label.includes(filter) || value.includes(filter);
});
},
async init() {
await this.fetchList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/valueset?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || {};
this.sortList();
} catch (err) {
console.error(err);
this.list = {};
this.showToast('Failed to load value sets', 'error');
} finally {
this.loading = false;
}
},
sortList() {
const entries = Object.entries(this.list);
entries.sort((a, b) => {
if (this.sortBy === 'count') return b[1] - a[1];
return a[0].localeCompare(b[0]);
});
this.list = Object.fromEntries(entries);
},
async selectCategory(name) {
if (this.selectedCategory === name) {
return;
}
this.selectedCategory = name;
this.itemFilter = "";
if (!this.items[name]) {
await this.fetchItems(name);
}
},
async fetchItems(name) {
this.itemLoading = true;
try {
const res = await fetch(`${BASEURL}api/valueset/${name}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.items[name] = data.data || [];
} catch (err) {
console.error(err);
this.items[name] = [];
this.showToast('Failed to load items', 'error');
} finally {
this.itemLoading = false;
}
},
formatName(name) {
if (!name) return '';
return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
},
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('Copied to clipboard', 'success');
} catch (err) {
console.error(err);
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Hello World Page</title>
</head>
<body>
<h1>Hello World!</h1>
<p>This is a simple HTML page.</p>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Hello World Page</title>
</head>
<body>
<h1>Hello World!</h1>
<p>This is a simple HTML page.</p>
</body>
</html>

1129
docs/ERD_EXTRACT.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

854
docs/clqms_database.dbml Normal file
View File

@ -0,0 +1,854 @@
// CLQMS Database Schema
// Generated from ERD_EXTRACT.md
// Database Markup Language (DBML) for dbdiagram.io and other tools
// ============================================
// TABLE 1: Organization Structure
// ============================================
Table account {
AccountID int [pk]
AccountName varchar(255)
ParentAccountID int
CreateDate datetime
EndDate datetime
}
Table site {
SiteID int [pk]
AccountID int
SiteName varchar(255)
Location varchar(255)
CreateDate datetime
EndDate datetime
}
Table discipline {
DisciplineID int [pk]
DisciplineName varchar(255)
CreateDate datetime
EndDate datetime
}
Table department {
DepartmentID int [pk]
DepartmentName varchar(255)
DisciplineID int
CreateDate datetime
EndDate datetime
}
Table workstation {
WorkstationID int [pk]
SiteID int
DepartmentID int
WorkstationName varchar(255)
LocalDB boolean
CreateDate datetime
EndDate datetime
}
Table instrument {
InstrumentID int [pk]
SiteID int
WorkstationID int
InstrumentAlias varchar(255)
InstrumentName varchar(255)
InstrumentType varchar(255)
CreateDate datetime
EndDate datetime
}
Table personnel {
PersonnelID int [pk]
SiteID int
PersonnelName varchar(255)
Position varchar(255)
CreateDate datetime
EndDate datetime
}
Table personneldocument {
DocID int [pk]
PersonnelID int
DocType varchar(255)
DocFile blob
ExpiryDate datetime
CreateDate datetime
}
Table personnelaccess {
AccessID int [pk]
PersonnelID int
Role varchar(255)
Permissions text
CreateDate datetime
}
Table location {
LocationID int [pk]
SiteID int
ParentLocationID int
LocationTypeID int
LocationName varchar(255)
CreateDate datetime
EndDate datetime
}
Table locationaddress {
AddressID int [pk]
LocationID int
AddressLine1 varchar(255)
AddressLine2 varchar(255)
City varchar(100)
PostalCode varchar(20)
CreateDate datetime
}
Table patient {
PatientID int [pk]
SiteID int
InternalPID int
FirstName varchar(255)
LastName varchar(255)
DateOfBirth datetime
Sex varchar(10)
Race varchar(50)
Ethnicity varchar(50)
Religion varchar(50)
CreateDate datetime
DelDate datetime
}
Table patientcontact {
ContactID int [pk]
InternalPID int
ContactType varchar(50)
ContactValue varchar(255)
CreateDate datetime
}
Table patientinsurance {
InsuranceID int [pk]
InternalPID int
InsuranceProvider varchar(255)
PolicyNumber varchar(100)
GroupNumber varchar(100)
EffectiveDate datetime
ExpiryDate datetime
CreateDate datetime
}
Table patientvisit {
VisitID int [pk]
InternalPID int
SiteID int
VisitClass varchar(50)
VisitType varchar(50)
VisitDate datetime
DischargeDate datetime
CreateDate datetime
}
Table admission {
AdmissionID int [pk]
VisitID int
PatientID int
SiteID int
AdmissionDate datetime
DischargeDate datetime
ADTCode varchar(50)
ReferringParty varchar(255)
BillingAccount varchar(255)
AttendingDoctor varchar(255)
ReferringDoctor varchar(255)
VitalSigns text
CreateDate datetime
}
Table admissionlocation {
ID int [pk]
AdmissionID int
LocationID int
TransferDate datetime
CreateDate datetime
}
Table testorder {
OrderID varchar(13) [pk]
SiteID int
PatientID int
VisitID int
OrderDate datetime
Urgency varchar(50)
Status varchar(50)
OrderingProvider varchar(255)
ProductionSiteID int
CreateDate datetime
EndDate datetime
}
Table testorderdetail {
OrderDetailID int [pk]
OrderID varchar(13)
TestID int
Priority int
Status varchar(50)
CreateDate datetime
}
Table specimen {
SID varchar(17) [pk]
OrderID varchar(13)
SpecimenDefID int
ParentSID varchar(17)
SpecimenType varchar(50)
SpecimenRole varchar(50)
CollectionDate datetime
CollectionSite int
CollectedBy int
ContainerType varchar(50)
Additive varchar(50)
CollectionMethod varchar(50)
BodySite varchar(50)
SpecimenCondition varchar(50)
Status varchar(50)
CreateDate datetime
EndDate datetime
}
Table specimencollection {
ID int [pk]
SID varchar(17)
Activity varchar(50)
ActivityName varchar(100)
ActRes varchar(50)
LocationID int
EquipmentID int
PersonnelID int
ActivityDate datetime
Notes text
CreateDate datetime
}
Table specimentransport {
TransportID int [pk]
SID varchar(17)
SenderID int
ReceiverID int
TransportDate datetime
Condition text
PackagingID varchar(50)
FromLocation int
ToLocation int
CreateDate datetime
}
Table specimenstorage {
StorageID int [pk]
SID varchar(17)
LocationID int
StorageTemperature decimal(10,2)
StorageDate datetime
ThawCount int
ExpiryDate datetime
CreateDate datetime
}
Table testdef {
TestID int [pk]
TestName varchar(255)
TestCode varchar(50)
LOINCCode varchar(50)
TestType varchar(50)
DisciplineID int
SpecimenTypeID int
ContainerTypeID int
ResultType varchar(50)
ResultUnit varchar(50)
Methodology varchar(255)
CreateDate datetime
EndDate datetime
}
Table testdefsite {
ID int [pk]
TestID int
SiteID int
TestNameLocal varchar(255)
TestCodeLocal varchar(50)
WorkstationID int
InstrumentID int
Active boolean
CreateDate datetime
}
Table testdeftech {
ID int [pk]
TestID int
InstrumentID int
InstrumentTestCode varchar(50)
TestMapping varchar(255)
Active boolean
CreateDate datetime
}
Table calculatedtest {
CalculatedTestID int [pk]
TestID int
Formula text
ParamTestID1 int
ParamTestID2 int
ParamTestID3 int
ParamTestID4 int
CreateDate datetime
}
Table grouptest {
GroupTestID int [pk]
GroupTestName varchar(255)
GroupTestType varchar(50)
CreateDate datetime
}
Table grouptestmember {
ID int [pk]
GroupTestID int
TestID int
Sequence int
CreateDate datetime
}
Table panel {
PanelID int [pk]
PanelName varchar(255)
PanelType varchar(50)
ParentPanelID int
DisciplineID int
CreateDate datetime
EndDate datetime
}
Table panelmember {
ID int [pk]
PanelID int
TestID int
Sequence int
CreateDate datetime
}
Table referencerangenumeric {
RefRangeID int [pk]
TestID int
AgeFrom int
AgeTo int
Sex varchar(10)
LowValue decimal(10,2)
HighValue decimal(10,2)
Unit varchar(20)
SpecimenTypeID int
SiteID int
EffectiveDate datetime
ExpiryDate datetime
CreateDate datetime
}
Table referencerangethreshold {
RefRangeID int [pk]
TestID int
AgeFrom int
AgeTo int
Sex varchar(10)
CutOffLow decimal(10,2)
CutOffHigh decimal(10,2)
GrayZoneLow decimal(10,2)
GrayZoneHigh decimal(10,2)
SpecimenTypeID int
SiteID int
EffectiveDate datetime
ExpiryDate datetime
CreateDate datetime
}
Table referencerangetext {
RefRangeID int [pk]
TestID int
AgeFrom int
AgeTo int
Sex varchar(10)
TextValue text
SpecimenTypeID int
SiteID int
EffectiveDate datetime
ExpiryDate datetime
CreateDate datetime
}
Table calibrator {
CalibratorID int [pk]
CalibratorName varchar(255)
Manufacturer varchar(255)
LotNumber varchar(50)
ExpiryDate datetime
TestID int
CreateDate datetime
}
Table calibration {
CalibrationID int [pk]
InstrumentID int
TestID int
CalibratorID int
Level int
CalibrationDate datetime
Factor decimal(10,4)
Absorbance decimal(10,4)
TargetValue decimal(10,4)
TargetUnit varchar(20)
PersonnelID int
Status varchar(50)
CreateDate datetime
}
Table calparinst {
CalParInstID int [pk]
EquipmentID int
Calibrator varchar(255)
LotNo varchar(50)
ExpiryDate datetime
TestInstID1 int
SampleType varchar(50)
Level int
Concentration decimal(10,4)
CalUnit varchar(20)
CreateDate datetime
}
Table qcmaterial {
QCMaterialID int [pk]
MaterialName varchar(255)
Manufacturer varchar(255)
LotNumber varchar(50)
ExpiryDate datetime
Level int
TestID int
TargetMean decimal(10,4)
TargetSD decimal(10,4)
TargetCV decimal(10,4)
CreateDate datetime
}
Table qcresult {
QCResultID int [pk]
InstrumentID int
TestID int
QCMaterialID int
Level int
QCDate datetime
ResultValue decimal(10,4)
Mean decimal(10,4)
SD decimal(10,4)
CV decimal(10,4)
Sigma decimal(10,4)
ZScore decimal(10,4)
Flag varchar(10)
PersonnelID int
Status varchar(50)
CreateDate datetime
}
Table qcstatistic {
StatisticID int [pk]
InstrumentID int
TestID int
QCMaterialID int
Level int
StatisticDate datetime
Mean decimal(10,4)
SD decimal(10,4)
CV decimal(10,4)
SampleSize int
CreateDate datetime
}
Table patres {
ResultID int [pk]
SID varchar(17)
TestID int
OrderID varchar(13)
ResultValue varchar(100)
ResultNumeric decimal(15,5)
ResultText text
ResultUnit varchar(20)
ResultStatus varchar(50)
PersonnelID int
VerificationDate datetime
VerificationPersonnel int
CreateDate datetime
EndDate datetime
}
Table patrestech {
TechResultID int [pk]
ResultID int
InstrumentID int
RawResult text
ResultDate datetime
RerunCount int
Dilution decimal(10,4)
CreateDate datetime
}
Table patresflag {
FlagID int [pk]
ResultID int
FlagType varchar(10)
FlagDescription varchar(255)
CreateDate datetime
}
Table resultdistribution {
DistributionID int [pk]
ResultID int
RecipientType varchar(50)
RecipientID int
DistributionDate datetime
DistributionMethod varchar(50)
Status varchar(50)
CreateDate datetime
}
Table valuesetmember {
MemberID int [pk]
ValueSetID int
MemberCode varchar(50)
MemberValue varchar(255)
DisplayOrder int
Active boolean
CreateDate datetime
}
Table reagent {
ReagentID int [pk]
ReagentName varchar(255)
Manufacturer varchar(255)
CatalogNumber varchar(100)
LotNumber varchar(50)
ExpiryDate datetime
TestID int
InstrumentID int
CreateDate datetime
}
Table reagentusage {
UsageID int [pk]
ReagentID int
TestID int
UsageDate datetime
QuantityUsed decimal(10,2)
PersonnelID int
OrderID varchar(13)
SID varchar(17)
CreateDate datetime
}
Table product {
ProductID int [pk]
CatalogID int
SiteID int
LotNumber varchar(50)
ExpiryDate datetime
Quantity int
ReorderLevel int
LocationID int
CreateDate datetime
}
Table inventorytransaction {
TransactionID int [pk]
ProductID int
TransactionType varchar(50)
Quantity int
TransactionDate datetime
PersonnelID int
ReferenceID varchar(100)
Notes text
CreateDate datetime
}
Table equipment {
EquipmentID int [pk]
EquipmentName varchar(255)
EquipmentType varchar(50)
Manufacturer varchar(255)
Model varchar(100)
SerialNumber varchar(100)
SiteID int
LocationID int
Status varchar(50)
InstallDate datetime
DecommissionDate datetime
CreateDate datetime
}
Table equipmentmaintenance {
MaintenanceID int [pk]
EquipmentID int
MaintenanceType varchar(100)
MaintenanceDate datetime
Description text
PerformedBy varchar(255)
NextMaintenanceDate datetime
CreateDate datetime
}
Table equipmentactivity {
ActivityID int [pk]
EquipmentID int
ActivityType varchar(100)
ActivityDate datetime
ActivityResult varchar(50)
PersonnelID int
Notes text
CreateDate datetime
}
Table equipmenttestcount {
ID int [pk]
EquipmentID int
TestDate datetime
TestType varchar(50)
TestCount int
CreateDate datetime
}
Table doctor {
DoctorID int [pk]
DoctorName varchar(255)
DoctorCode varchar(50)
Specialty varchar(100)
SIP varchar(50)
PracticeLocation varchar(255)
ContactID int
CreateDate datetime
EndDate datetime
}
Table contactdetail {
DetailID int [pk]
ContactID int
DetailType varchar(50)
DetailValue varchar(255)
CreateDate datetime
}
Table auditarchive {
ArchiveID int [pk]
AuditID int
ArchiveDate datetime
ArchiveLocation varchar(255)
CreateDate datetime
}
Table user {
UserID int [pk]
Username varchar(100)
PasswordHash varchar(255)
PersonnelID int
Role varchar(50)
Status varchar(20)
LastLogin datetime
CreateDate datetime
}
Table usersession {
SessionID int [pk]
UserID int
SessionToken varchar(255)
ExpiryDate datetime
IPAddress varchar(50)
CreateDate datetime
}
Table reporttemplate {
TemplateID int [pk]
TemplateName varchar(255)
TemplateType varchar(50)
DisciplineID int
TemplateConfig text
CreateDate datetime
EndDate datetime
}
Table reportoutput {
OutputID int [pk]
TemplateID int
OrderID varchar(13)
OutputFormat varchar(50)
OutputData blob
GeneratedDate datetime
PersonnelID int
CreateDate datetime
}
Table hosttestmapping {
MappingID int [pk]
HostID int
HostTestCode varchar(50)
LocalTestID int
CreateDate datetime
}
Table hostsynclog {
LogID int [pk]
HostID int
SyncDate datetime
RecordsProcessed int
Errors int
Status varchar(20)
Details text
CreateDate datetime
}
// ============================================
// RELATIONSHIPS
// ============================================
// Organization Structure
Ref: account.ParentAccountID > account.AccountID [delete: cascade]
Ref: site.AccountID > account.AccountID
Ref: department.DisciplineID > discipline.DisciplineID
Ref: workstation.SiteID > site.SiteID
Ref: workstation.DepartmentID > department.DepartmentID
Ref: instrument.SiteID > site.SiteID
Ref: instrument.WorkstationID > workstation.WorkstationID
// Personnel
Ref: personnel.SiteID > site.SiteID
Ref: personneldocument.PersonnelID > personnel.PersonnelID
Ref: personnelaccess.PersonnelID > personnel.PersonnelID
// Location Management
Ref: location.SiteID > site.SiteID
Ref: location.ParentLocationID > location.LocationID
Ref: locationaddress.LocationID > location.LocationID
// Patient Registration
Ref: patient.SiteID > site.SiteID
Ref: patientcontact.InternalPID > patient.InternalPID
Ref: patientinsurance.InternalPID > patient.InternalPID
Ref: patientvisit.InternalPID > patient.InternalPID
Ref: patientvisit.SiteID > site.SiteID
// Patient Admission
Ref: admission.VisitID > patientvisit.VisitID
Ref: admission.PatientID > patient.PatientID
Ref: admission.SiteID > site.SiteID
Ref: admissionlocation.AdmissionID > admission.AdmissionID
Ref: admissionlocation.LocationID > location.LocationID
// Test Ordering
Ref: testorder.SiteID > site.SiteID
Ref: testorder.PatientID > patient.PatientID
Ref: testorder.VisitID > patientvisit.VisitID
Ref: testorder.ProductionSiteID > site.SiteID
Ref: testorderdetail.OrderID > testorder.OrderID
// Specimen Management
Ref: specimen.OrderID > testorder.OrderID
Ref: specimencollection.SID > specimen.SID
Ref: specimencollection.LocationID > location.LocationID
Ref: specimencollection.EquipmentID > instrument.InstrumentID
Ref: specimencollection.PersonnelID > personnel.PersonnelID
Ref: specimentransport.SID > specimen.SID
Ref: specimentransport.SenderID > personnel.PersonnelID
Ref: specimentransport.ReceiverID > personnel.PersonnelID
Ref: specimenstorage.SID > specimen.SID
Ref: specimenstorage.LocationID > location.LocationID
// Test Management
Ref: testdef.DisciplineID > discipline.DisciplineID
Ref: testdefsite.TestID > testdef.TestID
Ref: testdefsite.SiteID > site.SiteID
Ref: testdefsite.WorkstationID > workstation.WorkstationID
Ref: testdefsite.InstrumentID > instrument.InstrumentID
Ref: testdeftech.TestID > testdef.TestID
Ref: testdeftech.InstrumentID > instrument.InstrumentID
Ref: calculatedtest.TestID > testdef.TestID
Ref: grouptestmember.GroupTestID > grouptest.GroupTestID
Ref: grouptestmember.TestID > testdef.TestID
Ref: panel.ParentPanelID > panel.PanelID
Ref: panel.DisciplineID > discipline.DisciplineID
Ref: panelmember.PanelID > panel.PanelID
Ref: panelmember.TestID > testdef.TestID
// Reference Range
Ref: referencerangenumeric.TestID > testdef.TestID
Ref: referencerangenumeric.SiteID > site.SiteID
Ref: referencerangethreshold.TestID > testdef.TestID
Ref: referencerangethreshold.SiteID > site.SiteID
Ref: referencerangetext.TestID > testdef.TestID
Ref: referencerangetext.SiteID > site.SiteID
// Calibration
Ref: calibrator.TestID > testdef.TestID
Ref: calibration.InstrumentID > instrument.InstrumentID
Ref: calibration.TestID > testdef.TestID
Ref: calibration.CalibratorID > calibrator.CalibratorID
Ref: calibration.PersonnelID > personnel.PersonnelID
Ref: calparinst.EquipmentID > instrument.InstrumentID
// Quality Control
Ref: qcmaterial.TestID > testdef.TestID
Ref: qcresult.InstrumentID > instrument.InstrumentID
Ref: qcresult.TestID > testdef.TestID
Ref: qcresult.QCMaterialID > qcmaterial.QCMaterialID
Ref: qcresult.PersonnelID > personnel.PersonnelID
Ref: qcstatistic.InstrumentID > instrument.InstrumentID
Ref: qcstatistic.TestID > testdef.TestID
Ref: qcstatistic.QCMaterialID > qcmaterial.QCMaterialID
// Test Results
Ref: patres.SID > specimen.SID
Ref: patres.TestID > testdef.TestID
Ref: patres.OrderID > testorder.OrderID
Ref: patres.PersonnelID > personnel.PersonnelID
Ref: patrestech.ResultID > patres.ResultID
Ref: patrestech.InstrumentID > instrument.InstrumentID
Ref: patresflag.ResultID > patres.ResultID
Ref: resultdistribution.ResultID > patres.ResultID
// Reagent & Inventory
Ref: reagent.TestID > testdef.TestID
Ref: reagent.InstrumentID > instrument.InstrumentID
Ref: reagentusage.ReagentID > reagent.ReagentID
Ref: reagentusage.TestID > testdef.TestID
Ref: reagentusage.PersonnelID > personnel.PersonnelID
Ref: product.SiteID > site.SiteID
Ref: product.LocationID > location.LocationID
Ref: inventorytransaction.ProductID > product.ProductID
Ref: inventorytransaction.PersonnelID > personnel.PersonnelID
// Equipment Management
Ref: equipment.SiteID > site.SiteID
Ref: equipment.LocationID > location.LocationID
Ref: equipmentmaintenance.EquipmentID > equipment.EquipmentID
Ref: equipmentactivity.EquipmentID > equipment.EquipmentID
Ref: equipmentactivity.PersonnelID > personnel.PersonnelID
Ref: equipmenttestcount.EquipmentID > equipment.EquipmentID
// User & Authentication
Ref: user.PersonnelID > personnel.PersonnelID
Ref: usersession.UserID > user.UserID
// Visualization & Reporting
Ref: reporttemplate.DisciplineID > discipline.DisciplineID
Ref: reportoutput.TemplateID > reporttemplate.TemplateID
Ref: reportoutput.OrderID > testorder.OrderID
Ref: reportoutput.PersonnelID > personnel.PersonnelID
// Host System Integration
Ref: hosttestmapping.LocalTestID > testdef.TestID

3287
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

17
public/docs.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Elements in HTML</title>
<!-- Embed elements Elements via Web Component -->
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
</head>
<body>
<elements-api apiDescriptionUrl="openapi.yaml" router="hash" layout="sidebar" />
</body>
</html>

3287
public/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,325 +0,0 @@
<?php
namespace Tests\Support\v2;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use Firebase\JWT\JWT;
/**
* Base test case for v2 Master Data tests
*
* Provides common setup, authentication, and helper methods
* for all v2 master test feature and unit tests.
*/
abstract class MasterTestCase extends CIUnitTestCase
{
use FeatureTestTrait;
/**
* JWT token for authentication
*/
protected ?string $token = null;
/**
* Test site ID
*/
protected int $testSiteId = 1;
/**
* Test site code
*/
protected string $testSiteCode = 'TEST01';
/**
* Valueset IDs for test types
*/
public const VALUESET_TEST_TYPE = 27; // VSetID for Test Types
public const VALUESET_RESULT_TYPE = 43; // VSetID for Result Types
public const VALUESET_REF_TYPE = 44; // VSetID for Reference Types
public const VALUESET_ENTITY_TYPE = 39; // VSetID for Entity Types
/**
* Test Type VIDs
*/
public const TEST_TYPE_TEST = 1; // VID for TEST
public const TEST_TYPE_PARAM = 2; // VID for PARAM
public const TEST_TYPE_CALC = 3; // VID for CALC
public const TEST_TYPE_GROUP = 4; // VID for GROUP
public const TEST_TYPE_TITLE = 5; // VID for TITLE
/**
* Setup test environment
*/
protected function setUp(): void
{
parent::setUp();
$this->token = $this->generateTestToken();
}
/**
* Cleanup after test
*/
protected function tearDown(): void
{
parent::tearDown();
}
/**
* Generate JWT token for testing
*/
protected function generateTestToken(): string
{
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
return JWT::encode($payload, $key, 'HS256');
}
/**
* Make authenticated GET request
*/
protected function get(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('get', $path, $options);
}
/**
* Make authenticated POST request
*/
protected function post(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('post', $path, $options);
}
/**
* Make authenticated PUT request
*/
protected function put(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('put', $path, $options);
}
/**
* Make authenticated DELETE request
*/
protected function delete(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('delete', $path, $options);
}
/**
* Create a TEST type test definition
*/
protected function createTestData(): array
{
return [
'SiteID' => 1,
'TestSiteCode' => $this->testSiteCode,
'TestSiteName' => 'Test Definition ' . time(),
'TestType' => self::TEST_TYPE_TEST,
'Description' => 'Test description',
'SeqScr' => 10,
'SeqRpt' => 10,
'IndentLeft' => 0,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'ResultType' => 1, // Numeric
'RefType' => 1, // NMRC
'Unit1' => 'mg/dL',
'Decimal' => 2,
'Method' => 'Test Method',
'ExpectedTAT' => 60
],
'testmap' => [
[
'HostType' => 'HIS',
'HostID' => 'TEST001',
'HostTestCode' => 'TEST001',
'HostTestName' => 'Test (HIS)'
]
]
];
}
/**
* Create a PARAM type test definition
*/
protected function createParamData(): array
{
return [
'SiteID' => 1,
'TestSiteCode' => 'PARM' . substr(time(), -4),
'TestSiteName' => 'Parameter Test ' . time(),
'TestType' => self::TEST_TYPE_PARAM,
'Description' => 'Parameter test description',
'SeqScr' => 5,
'SeqRpt' => 5,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'ResultType' => 1,
'RefType' => 1,
'Unit1' => 'unit',
'Decimal' => 1,
'Method' => 'Parameter Method'
]
];
}
/**
* Create a GROUP type test definition with members
*/
protected function createGroupData(array $memberIds = []): array
{
return [
'SiteID' => 1,
'TestSiteCode' => 'GRUP' . substr(time(), -4),
'TestSiteName' => 'Group Test ' . time(),
'TestType' => self::TEST_TYPE_GROUP,
'Description' => 'Group test description',
'SeqScr' => 100,
'SeqRpt' => 100,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'Members' => $memberIds ?: [1, 2],
'testmap' => [
[
'HostType' => 'LIS',
'HostID' => 'LIS001',
'HostTestCode' => 'PANEL',
'HostTestName' => 'Test Panel (LIS)'
]
]
];
}
/**
* Create a CALC type test definition
*/
protected function createCalcData(): array
{
return [
'SiteID' => 1,
'TestSiteCode' => 'CALC' . substr(time(), -4),
'TestSiteName' => 'Calculated Test ' . time(),
'TestType' => self::TEST_TYPE_CALC,
'Description' => 'Calculated test description',
'SeqScr' => 50,
'SeqRpt' => 50,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaInput' => '["TEST1", "TEST2"]',
'FormulaCode' => 'TEST1 + TEST2',
'FormulaLang' => 'SQL',
'RefType' => 1,
'Unit1' => 'mg/dL',
'Decimal' => 0,
'Method' => 'Calculation Method'
],
'testmap' => [
[
'HostType' => 'LIS',
'HostID' => 'LIS001',
'HostTestCode' => 'CALCR',
'HostTestName' => 'Calculated Result (LIS)'
]
]
];
}
/**
* Assert API response has success status
*/
protected function assertSuccessResponse($response, string $message = 'Response should be successful'): void
{
$body = json_decode($response->response()->getBody(), true);
$this->assertArrayHasKey('status', $body, $message);
$this->assertEquals('success', $body['status'], $message);
}
/**
* Assert API response has error status
*/
protected function assertErrorResponse($response, string $message = 'Response should be an error'): void
{
$body = json_decode($response->response()->getBody(), true);
$this->assertArrayHasKey('status', $body, $message);
$this->assertNotEquals('success', $body['status'], $message);
}
/**
* Assert response has data key
*/
protected function assertHasData($response, string $message = 'Response should have data'): void
{
$body = json_decode($response->response()->getBody(), true);
$this->assertArrayHasKey('data', $body, $message);
}
/**
* Get test type name from VID
*/
protected function getTestTypeName(int $vid): string
{
return match ($vid) {
self::TEST_TYPE_TEST => 'TEST',
self::TEST_TYPE_PARAM => 'PARAM',
self::TEST_TYPE_CALC => 'CALC',
self::TEST_TYPE_GROUP => 'GROUP',
self::TEST_TYPE_TITLE => 'TITLE',
default => 'UNKNOWN'
};
}
/**
* Skip test if database not available
*/
protected function requireDatabase(): void
{
$db = \Config\Database::connect();
try {
$db->connect();
} catch (\Exception $e) {
$this->markTestSkipped('Database not available: ' . $e->getMessage());
}
}
/**
* Skip test if required seeded data not found
*/
protected function requireSeededData(): void
{
$db = \Config\Database::connect();
$count = $db->table('valueset')
->where('VSetID', self::VALUESET_TEST_TYPE)
->countAllResults();
if ($count === 0) {
$this->markTestSkipped('Test type valuesets not seeded');
}
}
}

View File

@ -1,328 +0,0 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for CALC type test definitions
*
* Tests CALC-specific functionality including formula configuration
*/
class TestDefCalcTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test create CALC with formula
*/
public function testCreateCalcWithFormula(): void
{
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CALC' . substr(time(), -4),
'TestSiteName' => 'Calculated Test ' . time(),
'TestType' => $this::TEST_TYPE_CALC,
'Description' => 'Calculated test with formula',
'SeqScr' => 50,
'SeqRpt' => 50,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaInput' => '["CHOL", "HDL", "TG"]',
'FormulaCode' => 'CHOL - HDL - (TG / 5)',
'FormulaLang' => 'SQL',
'RefType' => 1, // NMRC
'Unit1' => 'mg/dL',
'Decimal' => 0,
'Method' => 'Friedewald Formula'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
// Verify calc details were created
$calcId = $body['data']['TestSiteId'];
$showResult = $this->get($this->endpoint . '/' . $calcId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null) {
$this->assertArrayHasKey('testdefcal', $showBody['data']);
}
}
}
/**
* Test CALC with different formula languages
*/
public function testCalcWithDifferentFormulaLanguages(): void
{
$languages = ['Phyton', 'CQL', 'FHIRP', 'SQL'];
foreach ($languages as $lang) {
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'C' . substr(time(), -5) . strtoupper(substr($lang, 0, 1)),
'TestSiteName' => "Calc with $lang",
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["TEST1"]',
'FormulaCode' => 'TEST1 * 2',
'FormulaLang' => $lang
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"CALC with $lang: Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test CALC with JSON formula input
*/
public function testCalcWithJsonFormulaInput(): void
{
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CJSN' . substr(time(), -3),
'TestSiteName' => 'Calc with JSON Input',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["parameter1", "parameter2", "parameter3"]',
'FormulaCode' => '(param1 + param2) / param3',
'FormulaLang' => 'FHIRP'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test CALC with complex formula
*/
public function testCalcWithComplexFormula(): void
{
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CCMP' . substr(time(), -3),
'TestSiteName' => 'Calc with Complex Formula',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["WBC", "NEUT", "LYMPH", "MONO", "EOS", "BASO"]',
'FormulaCode' => 'if WBC > 0 then (NEUT + LYMPH + MONO + EOS + BASO) / WBC * 100 else 0',
'FormulaLang' => 'Phyton'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test update CALC formula
*/
public function testUpdateCalcFormula(): void
{
// Create a CALC first
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'UPCL' . substr(time(), -4),
'TestSiteName' => 'Update Calc Test',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["A", "B"]',
'FormulaCode' => 'A + B',
'FormulaLang' => 'SQL'
]
];
$createResult = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$calcId = $createBody['data']['TestSiteId'] ?? null;
if ($calcId) {
// Update formula
$updateData = [
'TestSiteName' => 'Updated Calc Test Name',
'details' => [
'FormulaInput' => '["A", "B", "C"]',
'FormulaCode' => 'A + B + C'
]
];
$updateResult = $this->put($this->endpoint . '/' . $calcId, ['body' => json_encode($updateData)]);
$updateStatus = $updateResult->response()->getStatusCode();
$this->assertTrue(
in_array($updateStatus, [200, 400, 500]),
"Expected 200, 400, or 500, got $updateStatus"
);
}
}
}
/**
* Test CALC has correct TypeCode in response
*/
public function testCalcTypeCodeInResponse(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=CALC');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$calc = $indexBody['data'][0];
// Verify TypeCode is CALC
$this->assertEquals('CALC', $calc['TypeCode'] ?? '');
}
}
/**
* Test CALC details structure
*/
public function testCalcDetailsStructure(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=CALC');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$calc = $indexBody['data'][0];
$calcId = $calc['TestSiteID'] ?? null;
if ($calcId) {
$showResult = $this->get($this->endpoint . '/' . $calcId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null && isset($showBody['data']['testdefcal'])) {
$calcDetails = $showBody['data']['testdefcal'];
if (is_array($calcDetails) && !empty($calcDetails)) {
$firstDetail = $calcDetails[0];
// Check required fields in calc structure
$this->assertArrayHasKey('TestCalID', $firstDetail);
$this->assertArrayHasKey('TestSiteID', $firstDetail);
$this->assertArrayHasKey('FormulaInput', $firstDetail);
$this->assertArrayHasKey('FormulaCode', $firstDetail);
// Check for joined discipline/department
if (isset($firstDetail['DisciplineName'])) {
$this->assertArrayHasKey('DepartmentName', $firstDetail);
}
}
}
}
}
}
/**
* Test CALC delete cascades to details
*/
public function testCalcDeleteCascadesToDetails(): void
{
// Create a CALC
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CDEL' . substr(time(), -4),
'TestSiteName' => 'Calc to Delete',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["TEST1"]',
'FormulaCode' => 'TEST1 * 2'
]
];
$createResult = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$calcId = $createBody['data']['TestSiteId'] ?? null;
if ($calcId) {
// Delete the CALC
$deleteResult = $this->delete($this->endpoint . '/' . $calcId);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
// Verify CALC details are also soft deleted
$showResult = $this->get($this->endpoint . '/' . $calcId);
$showBody = json_decode($showResult->response()->getBody(), true);
// CALC should show EndDate set
if ($showBody['data'] !== null) {
$this->assertNotNull($showBody['data']['EndDate']);
}
}
}
}
}
/**
* Test CALC with result unit configuration
*/
public function testCalcWithResultUnit(): void
{
$units = ['mg/dL', 'g/L', 'mmol/L', '%', 'IU/L'];
foreach ($units as $unit) {
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CUNT' . substr(time(), -3) . substr($unit, 0, 1),
'TestSiteName' => "Calc with $unit",
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'Unit1' => $unit,
'Decimal' => 2,
'FormulaInput' => '["TEST1"]',
'FormulaCode' => 'TEST1'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"CALC with unit $unit: Expected 201, 400, or 500, got $status"
);
}
}
}

View File

@ -1,291 +0,0 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for GROUP type test definitions
*
* Tests GROUP-specific functionality including member management
*/
class TestDefGroupTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test create GROUP with members
*/
public function testCreateGroupWithMembers(): void
{
// Get existing test IDs to use as members
$memberIds = $this->getExistingTestIds();
$groupData = [
'SiteID' => 1,
'TestSiteCode' => 'GRUP' . substr(time(), -4),
'TestSiteName' => 'Test Group ' . time(),
'TestType' => $this::TEST_TYPE_GROUP,
'Description' => 'Group test with members',
'SeqScr' => 100,
'SeqRpt' => 100,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'Members' => $memberIds
];
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
// Verify members were created
$groupId = $body['data']['TestSiteId'];
$showResult = $this->get($this->endpoint . '/' . $groupId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null) {
$this->assertArrayHasKey('testdefgrp', $showBody['data']);
}
}
}
/**
* Test create GROUP without members
*/
public function testCreateGroupWithoutMembers(): void
{
$groupData = [
'SiteID' => 1,
'TestSiteCode' => 'GREM' . substr(time(), -4),
'TestSiteName' => 'Empty Group ' . time(),
'TestType' => $this::TEST_TYPE_GROUP,
'Members' => [] // Empty members
];
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
// Should still succeed but with warning or empty members
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400]),
"Expected 201 or 400, got $status"
);
}
/**
* Test update GROUP members
*/
public function testUpdateGroupMembers(): void
{
// Create a group first
$memberIds = $this->getExistingTestIds();
$groupData = $this->createGroupData($memberIds);
$groupData['TestSiteCode'] = 'UPMB' . substr(time(), -4);
$createResult = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$groupId = $createBody['data']['TestSiteId'] ?? null;
if ($groupId) {
// Update with new members
$updateData = [
'Members' => array_slice($memberIds, 0, 1) // Only one member
];
$updateResult = $this->put($this->endpoint . '/' . $groupId, ['body' => json_encode($updateData)]);
$updateStatus = $updateResult->response()->getStatusCode();
$this->assertTrue(
in_array($updateStatus, [200, 400, 500]),
"Expected 200, 400, or 500, got $updateStatus"
);
}
}
}
/**
* Test add member to existing GROUP
*/
public function testAddMemberToGroup(): void
{
// Get existing test
$indexResult = $this->get($this->endpoint . '?TestType=GROUP');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$group = $indexBody['data'][0];
$groupId = $group['TestSiteID'] ?? null;
if ($groupId) {
// Get a test ID to add
$testIds = $this->getExistingTestIds();
$newMemberId = $testIds[0] ?? 1;
$updateData = [
'Members' => [$newMemberId]
];
$result = $this->put($this->endpoint . '/' . $groupId, ['body' => json_encode($updateData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [200, 400, 500]),
"Expected 200, 400, or 500, got $status"
);
}
}
}
/**
* Test GROUP with single member
*/
public function testGroupWithSingleMember(): void
{
$memberIds = $this->getExistingTestIds();
$groupData = [
'SiteID' => 1,
'TestSiteCode' => 'GSGL' . substr(time(), -4),
'TestSiteName' => 'Single Member Group ' . time(),
'TestType' => $this::TEST_TYPE_GROUP,
'Members' => [array_slice($memberIds, 0, 1)[0] ?? 1]
];
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test GROUP members have correct structure
*/
public function testGroupMembersStructure(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=GROUP');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$group = $indexBody['data'][0];
$groupId = $group['TestSiteID'] ?? null;
if ($groupId) {
$showResult = $this->get($this->endpoint . '/' . $groupId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null && isset($showBody['data']['testdefgrp'])) {
$members = $showBody['data']['testdefgrp'];
if (is_array($members) && !empty($members)) {
$firstMember = $members[0];
// Check required fields in member structure
$this->assertArrayHasKey('TestGrpID', $firstMember);
$this->assertArrayHasKey('TestSiteID', $firstMember);
$this->assertArrayHasKey('Member', $firstMember);
// Check for joined test details (if loaded)
if (isset($firstMember['TestSiteCode'])) {
$this->assertArrayHasKey('TestSiteName', $firstMember);
}
}
}
}
}
}
/**
* Test GROUP delete cascades to members
*/
public function testGroupDeleteCascadesToMembers(): void
{
// Create a group
$memberIds = $this->getExistingTestIds();
$groupData = $this->createGroupData($memberIds);
$groupData['TestSiteCode'] = 'GDEL' . substr(time(), -4);
$createResult = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$groupId = $createBody['data']['TestSiteId'] ?? null;
if ($groupId) {
// Delete the group
$deleteResult = $this->delete($this->endpoint . '/' . $groupId);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
// Verify group members are also soft deleted
$showResult = $this->get($this->endpoint . '/' . $groupId);
$showBody = json_decode($showResult->response()->getBody(), true);
// Group should show EndDate set
if ($showBody['data'] !== null) {
$this->assertNotNull($showBody['data']['EndDate']);
}
}
}
}
}
/**
* Test GROUP type has correct TypeCode in response
*/
public function testGroupTypeCodeInResponse(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=GROUP');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$group = $indexBody['data'][0];
// Verify TypeCode is GROUP
$this->assertEquals('GROUP', $group['TypeCode'] ?? '');
}
}
/**
* Helper to get existing test IDs
*/
private function getExistingTestIds(): array
{
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
$ids = [];
if (isset($indexBody['data']) && is_array($indexBody['data'])) {
foreach ($indexBody['data'] as $item) {
if (isset($item['TestSiteID'])) {
$ids[] = $item['TestSiteID'];
}
if (count($ids) >= 3) {
break;
}
}
}
return $ids ?: [1, 2, 3];
}
}

View File

@ -1,288 +0,0 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for PARAM type test definitions
*
* Tests PARAM-specific functionality as sub-test components
*/
class TestDefParamTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test create PARAM type test
*/
public function testCreateParamTypeTest(): void
{
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PARM' . substr(time(), -4),
'TestSiteName' => 'Parameter Test ' . time(),
'TestType' => $this::TEST_TYPE_PARAM,
'Description' => 'Parameter/sub-test description',
'SeqScr' => 5,
'SeqRpt' => 5,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'ResultType' => 1, // Numeric
'RefType' => 1, // NMRC
'Unit1' => 'unit',
'Decimal' => 1,
'Method' => 'Parameter Method'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
// Verify tech details were created
$paramId = $body['data']['TestSiteId'];
$showResult = $this->get($this->endpoint . '/' . $paramId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null) {
$this->assertArrayHasKey('testdeftech', $showBody['data']);
}
}
}
/**
* Test PARAM has correct TypeCode in response
*/
public function testParamTypeCodeInResponse(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=PARAM');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$param = $indexBody['data'][0];
// Verify TypeCode is PARAM
$this->assertEquals('PARAM', $param['TypeCode'] ?? '');
}
}
/**
* Test PARAM details structure
*/
public function testParamDetailsStructure(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=PARAM');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$param = $indexBody['data'][0];
$paramId = $param['TestSiteID'] ?? null;
if ($paramId) {
$showResult = $this->get($this->endpoint . '/' . $paramId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null && isset($showBody['data']['testdeftech'])) {
$techDetails = $showBody['data']['testdeftech'];
if (is_array($techDetails) && !empty($techDetails)) {
$firstDetail = $techDetails[0];
// Check required fields in tech structure
$this->assertArrayHasKey('TestTechID', $firstDetail);
$this->assertArrayHasKey('TestSiteID', $firstDetail);
$this->assertArrayHasKey('ResultType', $firstDetail);
$this->assertArrayHasKey('RefType', $firstDetail);
// Check for joined discipline/department
if (isset($firstDetail['DisciplineName'])) {
$this->assertArrayHasKey('DepartmentName', $firstDetail);
}
}
}
}
}
}
/**
* Test PARAM with different result types
*/
public function testParamWithDifferentResultTypes(): void
{
$resultTypes = [
1 => 'NMRIC', // Numeric
2 => 'RANGE', // Range
3 => 'TEXT', // Text
4 => 'VSET' // Value Set
];
foreach ($resultTypes as $resultTypeId => $resultTypeName) {
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PR' . substr(time(), -4) . substr($resultTypeName, 0, 1),
'TestSiteName' => "Param with $resultTypeName",
'TestType' => $this::TEST_TYPE_PARAM,
'details' => [
'ResultType' => $resultTypeId,
'RefType' => 1
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"PARAM with ResultType $resultTypeName: Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test PARAM with different reference types
*/
public function testParamWithDifferentRefTypes(): void
{
$refTypes = [
1 => 'NMRC', // Numeric
2 => 'TEXT' // Text
];
foreach ($refTypes as $refTypeId => $refTypeName) {
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PR' . substr(time(), -4) . 'R' . substr($refTypeName, 0, 1),
'TestSiteName' => "Param with RefType $refTypeName",
'TestType' => $this::TEST_TYPE_PARAM,
'details' => [
'ResultType' => 1,
'RefType' => $refTypeId
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"PARAM with RefType $refTypeName: Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test PARAM delete cascades to details
*/
public function testParamDeleteCascadesToDetails(): void
{
// Create a PARAM
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PDEL' . substr(time(), -4),
'TestSiteName' => 'Param to Delete',
'TestType' => $this::TEST_TYPE_PARAM,
'details' => [
'ResultType' => 1,
'RefType' => 1
]
];
$createResult = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$paramId = $createBody['data']['TestSiteId'] ?? null;
if ($paramId) {
// Delete the PARAM
$deleteResult = $this->delete($this->endpoint . '/' . $paramId);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
// Verify PARAM details are also soft deleted
$showResult = $this->get($this->endpoint . '/' . $paramId);
$showBody = json_decode($showResult->response()->getBody(), true);
// PARAM should show EndDate set
if ($showBody['data'] !== null) {
$this->assertNotNull($showBody['data']['EndDate']);
}
}
}
}
}
/**
* Test PARAM visibility settings
*/
public function testParamVisibilitySettings(): void
{
$visibilityCombinations = [
['VisibleScr' => 1, 'VisibleRpt' => 1],
['VisibleScr' => 1, 'VisibleRpt' => 0],
['VisibleScr' => 0, 'VisibleRpt' => 1],
['VisibleScr' => 0, 'VisibleRpt' => 0]
];
foreach ($visibilityCombinations as $vis) {
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PVIS' . substr(time(), -4),
'TestSiteName' => 'Visibility Test',
'TestType' => $this::TEST_TYPE_PARAM,
'VisibleScr' => $vis['VisibleScr'],
'VisibleRpt' => $vis['VisibleRpt']
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"PARAM visibility ({$vis['VisibleScr']}, {$vis['VisibleRpt']}): Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test PARAM sequence ordering
*/
public function testParamSequenceOrdering(): void
{
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PSEQ' . substr(time(), -4),
'TestSiteName' => 'Sequenced Param',
'TestType' => $this::TEST_TYPE_PARAM,
'SeqScr' => 25,
'SeqRpt' => 30
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
}

View File

@ -1,375 +0,0 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for v2 Test Definition API endpoints
*
* Tests CRUD operations for TEST, PARAM, GROUP, and CALC types
*/
class TestDefSiteTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test index endpoint returns list of tests
*/
public function testIndexReturnsTestList(): void
{
$result = $this->get($this->endpoint);
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('status', $body);
$this->assertArrayHasKey('data', $body);
$this->assertArrayHasKey('message', $body);
}
/**
* Test index with SiteID filter
*/
public function testIndexWithSiteFilter(): void
{
$result = $this->get($this->endpoint . '?SiteID=1');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test index with TestType filter
*/
public function testIndexWithTestTypeFilter(): void
{
// Filter by TEST type
$result = $this->get($this->endpoint . '?TestType=TEST');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test index with Visibility filter
*/
public function testIndexWithVisibilityFilter(): void
{
$result = $this->get($this->endpoint . '?VisibleScr=1&VisibleRpt=1');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test index with keyword search
*/
public function testIndexWithKeywordSearch(): void
{
$result = $this->get($this->endpoint . '?TestSiteName=hemoglobin');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test show endpoint returns single test
*/
public function testShowReturnsSingleTest(): void
{
// First get the list to find a valid ID
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
$testSiteID = $firstItem['TestSiteID'] ?? null;
if ($testSiteID) {
$showResult = $this->get($this->endpoint . '/' . $testSiteID);
$showResult->assertStatus(200);
$body = json_decode($showResult->response()->getBody(), true);
$this->assertArrayHasKey('data', $body);
$this->assertEquals('success', $body['status']);
// Check that related details are loaded based on TestType
if ($body['data'] !== null) {
$typeCode = $body['data']['TypeCode'] ?? '';
if ($typeCode === 'CALC') {
$this->assertArrayHasKey('testdefcal', $body['data']);
} elseif ($typeCode === 'GROUP') {
$this->assertArrayHasKey('testdefgrp', $body['data']);
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
$this->assertArrayHasKey('testdeftech', $body['data']);
}
// All types should have testmap
$this->assertArrayHasKey('testmap', $body['data']);
}
}
}
}
/**
* Test show with non-existent ID returns null data
*/
public function testShowWithInvalidIDReturnsNull(): void
{
$result = $this->get($this->endpoint . '/9999999');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('data', $body);
$this->assertNull($body['data']);
}
/**
* Test create new TEST type test definition
*/
public function testCreateTestTypeTest(): void
{
$testData = $this->createTestData();
$result = $this->post($this->endpoint, ['body' => json_encode($testData)]);
$status = $result->response()->getStatusCode();
// Expect 201 (created) or 400 (validation error) or 500 (server error)
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
$this->assertArrayHasKey('TestSiteId', $body['data']);
}
}
/**
* Test create new PARAM type test definition
*/
public function testCreateParamTypeTest(): void
{
$paramData = $this->createParamData();
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test create new GROUP type test definition
*/
public function testCreateGroupTypeTest(): void
{
// First create some member tests
$memberIds = $this->getExistingTestIds();
$groupData = $this->createGroupData($memberIds);
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test create new CALC type test definition
*/
public function testCreateCalcTypeTest(): void
{
$calcData = $this->createCalcData();
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test update existing test
*/
public function testUpdateTest(): void
{
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
$testSiteID = $firstItem['TestSiteID'] ?? null;
if ($testSiteID) {
$updateData = [
'TestSiteName' => 'Updated Test Name ' . time(),
'Description' => 'Updated description'
];
$result = $this->put($this->endpoint . '/' . $testSiteID, ['body' => json_encode($updateData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [200, 404, 500]),
"Expected 200, 404, or 500, got $status"
);
if ($status === 200) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
}
}
}
/**
* Test soft delete (disable) test
*/
public function testDeleteTest(): void
{
// Create a test first to delete
$testData = $this->createTestData();
$testData['TestSiteCode'] = 'DEL' . substr(time(), -4);
$createResult = $this->post($this->endpoint, ['body' => json_encode($testData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$testSiteID = $createBody['data']['TestSiteId'] ?? null;
if ($testSiteID) {
$deleteResult = $this->delete($this->endpoint . '/' . $testSiteID);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
$deleteBody = json_decode($deleteResult->response()->getBody(), true);
$this->assertEquals('success', $deleteBody['status']);
$this->assertArrayHasKey('EndDate', $deleteBody['data']);
}
}
}
}
/**
* Test validation - missing required fields
*/
public function testCreateValidationRequiredFields(): void
{
$invalidData = [
'TestSiteName' => 'Test without required fields'
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
}
/**
* Test that TestSiteCode is max 6 characters
*/
public function testTestSiteCodeLength(): void
{
$invalidData = [
'SiteID' => 1,
'TestSiteCode' => 'HB123456', // 8 characters - invalid
'TestSiteName' => 'Test with too long code',
'TestType' => $this::TEST_TYPE_TEST
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
}
/**
* Test that TestSiteCode is at least 3 characters
*/
public function testTestSiteCodeMinLength(): void
{
$invalidData = [
'SiteID' => 1,
'TestSiteCode' => 'HB', // 2 characters - invalid
'TestSiteName' => 'Test with too short code',
'TestType' => $this::TEST_TYPE_TEST
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
}
/**
* Test that duplicate TestSiteCode is rejected
*/
public function testDuplicateTestSiteCode(): void
{
// First create a test
$testData = $this->createTestData();
$testData['TestSiteCode'] = 'DUP' . substr(time(), -3);
$this->post($this->endpoint, ['body' => json_encode($testData)]);
// Try to create another test with the same code
$duplicateData = $testData;
$duplicateData['TestSiteName'] = 'Different Name';
$result = $this->post($this->endpoint, ['body' => json_encode($duplicateData)]);
// Should fail with 400 or 500
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [400, 500]),
"Expected 400 or 500 for duplicate, got $status"
);
}
/**
* Test filtering by multiple parameters
*/
public function testIndexWithMultipleFilters(): void
{
$result = $this->get($this->endpoint . '?SiteID=1&TestType=TEST&VisibleScr=1');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Helper method to get existing test IDs for group members
*/
private function getExistingTestIds(): array
{
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
$ids = [];
if (isset($indexBody['data']) && is_array($indexBody['data'])) {
foreach ($indexBody['data'] as $item) {
if (isset($item['TestSiteID'])) {
$ids[] = $item['TestSiteID'];
}
if (count($ids) >= 2) {
break;
}
}
}
return $ids ?: [1, 2];
}
}

View File

@ -34,13 +34,11 @@ final class HealthTest extends CIUnitTestCase
$validation->check($config->baseURL, 'valid_url'),
'baseURL "' . $config->baseURL . '" in .env is not valid URL',
);
return;
}
// Get the baseURL in app/Config/App.php
// You can't use Config\App, because phpunit.xml.dist sets app.baseURL
// If no baseURL in .env, check app/Config/App.php
$reader = new ConfigReader();
// BaseURL in app/Config/App.php is a valid URL?
$this->assertTrue(
$validation->check($reader->baseURL, 'valid_url'),
'baseURL "' . $reader->baseURL . '" in app/Config/App.php is not valid URL',

View File

@ -358,14 +358,16 @@ class ValueSetTest extends CIUnitTestCase
['Gender' => '1', 'Country' => 'IDN'],
['Gender' => '2', 'Country' => 'USA']
];
$result = ValueSet::transformLabels($data, [
'Gender' => 'sex',
'Country' => 'country'
]);
$this->assertEquals('Female', $result[0]['GenderText']);
$this->assertEquals('Male', $result[1]['GenderText']);
$this->assertEquals('Female', $result[0]['Gender']);
$this->assertEquals('1', $result[0]['GenderKey']);
$this->assertEquals('Male', $result[1]['Gender']);
$this->assertEquals('2', $result[1]['GenderKey']);
}
public function testGetOptions()

View File

@ -1,145 +0,0 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefCalModel;
/**
* Unit tests for TestDefCalModel
*
* Tests the calculation definition model for CALC type tests
*/
class TestDefCalModelTest extends CIUnitTestCase
{
protected TestDefCalModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefCalModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefcal', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestCalID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign key
$this->assertContains('TestSiteID', $allowedFields);
// Calculation fields
$this->assertContains('DisciplineID', $allowedFields);
$this->assertContains('DepartmentID', $allowedFields);
$this->assertContains('FormulaInput', $allowedFields);
$this->assertContains('FormulaCode', $allowedFields);
// Result fields
$this->assertContains('RefType', $allowedFields);
$this->assertContains('Unit1', $allowedFields);
$this->assertContains('Factor', $allowedFields);
$this->assertContains('Unit2', $allowedFields);
$this->assertContains('Decimal', $allowedFields);
$this->assertContains('Method', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test FormulaInput field is in allowed fields
*/
public function testFormulaInputFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('FormulaInput', $allowedFields);
}
/**
* Test FormulaCode field is in allowed fields
*/
public function testFormulaCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('FormulaCode', $allowedFields);
}
/**
* Test RefType field is in allowed fields
*/
public function testRefTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('RefType', $allowedFields);
}
/**
* Test Method field is in allowed fields
*/
public function testMethodFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Method', $allowedFields);
}
}

View File

@ -1,132 +0,0 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefGrpModel;
/**
* Unit tests for TestDefGrpModel
*
* Tests the group definition model for GROUP type tests
*/
class TestDefGrpModelTest extends CIUnitTestCase
{
protected TestDefGrpModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefGrpModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefgrp', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestGrpID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign keys
$this->assertContains('TestSiteID', $allowedFields);
$this->assertContains('Member', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteID field is in allowed fields
*/
public function testTestSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
}
/**
* Test Member field is in allowed fields
*/
public function testMemberFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Member', $allowedFields);
}
/**
* Test CreateDate field is in allowed fields
*/
public function testCreateDateFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('CreateDate', $allowedFields);
}
/**
* Test EndDate field is in allowed fields
*/
public function testEndDateFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('EndDate', $allowedFields);
}
}

View File

@ -1,220 +0,0 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefSiteModel;
/**
* Unit tests for TestDefSiteModel - Master Data Tests CRUD operations
*
* Tests the model configuration and behavior for test definition management
*/
class TestDefSiteModelMasterTest extends CIUnitTestCase
{
protected TestDefSiteModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefSiteModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefsite', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestSiteID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields for master data
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Core required fields
$this->assertContains('SiteID', $allowedFields);
$this->assertContains('TestSiteCode', $allowedFields);
$this->assertContains('TestSiteName', $allowedFields);
$this->assertContains('TestType', $allowedFields);
// Display and ordering fields
$this->assertContains('Description', $allowedFields);
$this->assertContains('SeqScr', $allowedFields);
$this->assertContains('SeqRpt', $allowedFields);
$this->assertContains('IndentLeft', $allowedFields);
$this->assertContains('FontStyle', $allowedFields);
// Visibility fields
$this->assertContains('VisibleScr', $allowedFields);
$this->assertContains('VisibleRpt', $allowedFields);
$this->assertContains('CountStat', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('StartDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
$this->assertEquals('StartDate', $this->model->updatedField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteCode field is in allowed fields
*/
public function testTestSiteCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteCode', $allowedFields);
}
/**
* Test TestSiteName field is in allowed fields
*/
public function testTestSiteNameFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteName', $allowedFields);
}
/**
* Test TestType field is in allowed fields
*/
public function testTestTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestType', $allowedFields);
}
/**
* Test SiteID field is in allowed fields
*/
public function testSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('SiteID', $allowedFields);
}
/**
* Test Description field is in allowed fields
*/
public function testDescriptionFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Description', $allowedFields);
}
/**
* Test SeqScr field is in allowed fields
*/
public function testSeqScrFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('SeqScr', $allowedFields);
}
/**
* Test SeqRpt field is in allowed fields
*/
public function testSeqRptFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('SeqRpt', $allowedFields);
}
/**
* Test VisibleScr field is in allowed fields
*/
public function testVisibleScrFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('VisibleScr', $allowedFields);
}
/**
* Test VisibleRpt field is in allowed fields
*/
public function testVisibleRptFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('VisibleRpt', $allowedFields);
}
/**
* Test CountStat field is in allowed fields
*/
public function testCountStatFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('CountStat', $allowedFields);
}
/**
* Test getTests method exists and is callable
*/
public function testGetTestsMethodExists(): void
{
$this->assertTrue(method_exists($this->model, 'getTests'));
$this->assertIsCallable([$this->model, 'getTests']);
}
/**
* Test getTest method exists and is callable
*/
public function testGetTestMethodExists(): void
{
$this->assertTrue(method_exists($this->model, 'getTest'));
$this->assertIsCallable([$this->model, 'getTest']);
}
}

View File

@ -1,137 +0,0 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefSiteModel;
/**
* Unit tests for TestDefSiteModel
*
* Tests the main test definition model configuration and behavior
*/
class TestDefSiteModelTest extends CIUnitTestCase
{
protected TestDefSiteModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefSiteModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefsite', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestSiteID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Core required fields
$this->assertContains('SiteID', $allowedFields);
$this->assertContains('TestSiteCode', $allowedFields);
$this->assertContains('TestSiteName', $allowedFields);
$this->assertContains('TestType', $allowedFields);
// Optional fields
$this->assertContains('Description', $allowedFields);
$this->assertContains('SeqScr', $allowedFields);
$this->assertContains('SeqRpt', $allowedFields);
$this->assertContains('IndentLeft', $allowedFields);
$this->assertContains('FontStyle', $allowedFields);
$this->assertContains('VisibleScr', $allowedFields);
$this->assertContains('VisibleRpt', $allowedFields);
$this->assertContains('CountStat', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('StartDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
$this->assertEquals('StartDate', $this->model->updatedField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteCode field is in allowed fields
*/
public function testTestSiteCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteCode', $allowedFields);
}
/**
* Test TestSiteName field is in allowed fields
*/
public function testTestSiteNameFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteName', $allowedFields);
}
/**
* Test TestType field is in allowed fields
*/
public function testTestTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestType', $allowedFields);
}
}

View File

@ -1,160 +0,0 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefTechModel;
/**
* Unit tests for TestDefTechModel
*
* Tests the technical definition model for TEST and PARAM types
*/
class TestDefTechModelTest extends CIUnitTestCase
{
protected TestDefTechModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefTechModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdeftech', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestTechID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign key
$this->assertContains('TestSiteID', $allowedFields);
// Technical fields
$this->assertContains('DisciplineID', $allowedFields);
$this->assertContains('DepartmentID', $allowedFields);
$this->assertContains('ResultType', $allowedFields);
$this->assertContains('RefType', $allowedFields);
$this->assertContains('VSet', $allowedFields);
// Quantity and units
$this->assertContains('ReqQty', $allowedFields);
$this->assertContains('ReqQtyUnit', $allowedFields);
$this->assertContains('Unit1', $allowedFields);
$this->assertContains('Factor', $allowedFields);
$this->assertContains('Unit2', $allowedFields);
$this->assertContains('Decimal', $allowedFields);
// Collection and method
$this->assertContains('CollReq', $allowedFields);
$this->assertContains('Method', $allowedFields);
$this->assertContains('ExpectedTAT', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteID field is in allowed fields
*/
public function testTestSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
}
/**
* Test ResultType field is in allowed fields
*/
public function testResultTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('ResultType', $allowedFields);
}
/**
* Test RefType field is in allowed fields
*/
public function testRefTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('RefType', $allowedFields);
}
/**
* Test Unit1 field is in allowed fields
*/
public function testUnit1FieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Unit1', $allowedFields);
}
/**
* Test Method field is in allowed fields
*/
public function testMethodFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Method', $allowedFields);
}
}

View File

@ -1,155 +0,0 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestMapModel;
/**
* Unit tests for TestMapModel
*
* Tests the test mapping model for all test types
*/
class TestMapModelTest extends CIUnitTestCase
{
protected TestMapModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestMapModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testmap', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestMapID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign key
$this->assertContains('TestSiteID', $allowedFields);
// Host system mapping
$this->assertContains('HostType', $allowedFields);
$this->assertContains('HostID', $allowedFields);
$this->assertContains('HostDataSource', $allowedFields);
$this->assertContains('HostTestCode', $allowedFields);
$this->assertContains('HostTestName', $allowedFields);
// Client system mapping
$this->assertContains('ClientType', $allowedFields);
$this->assertContains('ClientID', $allowedFields);
$this->assertContains('ClientDataSource', $allowedFields);
$this->assertContains('ConDefID', $allowedFields);
$this->assertContains('ClientTestCode', $allowedFields);
$this->assertContains('ClientTestName', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test HostType field is in allowed fields
*/
public function testHostTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('HostType', $allowedFields);
}
/**
* Test HostID field is in allowed fields
*/
public function testHostIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('HostID', $allowedFields);
}
/**
* Test HostTestCode field is in allowed fields
*/
public function testHostTestCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('HostTestCode', $allowedFields);
}
/**
* Test ClientType field is in allowed fields
*/
public function testClientTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('ClientType', $allowedFields);
}
/**
* Test TestSiteID field is in allowed fields
*/
public function testTestSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
}
}