Complete overhaul of the valueset system to use human-readable names
instead of numeric IDs for improved maintainability and API consistency.
- PatientController: Renamed 'Gender' field to 'Sex' in validation rules
- ValuesetController: Changed API endpoints from ID-based (/:num) to name-based (/:any)
- TestsController: Refactored to use ValueSet library instead of direct valueset queries
- Added ValueSet library (app/Libraries/ValueSet.php) with static lookup methods:
- getOptions() - returns dropdown format [{value, label}]
- getLabel(, ) - returns label for a value
- transformLabels(, ) - batch transform records
- get() and getRaw() for Lookups compatibility
- Added ValueSetApiController for public valueset API endpoints
- Added ValueSet refresh endpoint (POST /api/valueset/refresh)
- Added DemoOrderController for testing order creation without auth
- 2026-01-12-000001: Convert valueset references from VID to VValue
- 2026-01-12-000002: Rename patient.Gender column to Sex
- OrderTestController: Now uses OrderTestModel with proper model pattern
- TestsController: Uses ValueSet library for all lookup operations
- ValueSetController: Simplified to use name-based lookups
- Updated all organization (account/site/workstation) dialogs and index views
- Updated specimen container dialogs and index views
- Updated tests_index.php with ValueSet integration
- Updated patient dialog form and index views
- Removed .factory/config.json and CLAUDE.md (replaced by AGENTS.md)
- Consolidated lookups in Lookups.php (removed inline valueset constants)
- Updated all test files to match new field names
- 32 modified files, 17 new files, 2 deleted files
- Net: +661 insertions, -1443 deletions (significant cleanup)
758 lines
27 KiB
PHP
758 lines
27 KiB
PHP
<?= $this->extend("v2/layout/main_layout"); ?>
|
|
|
|
<?= $this->section("content") ?>
|
|
<div x-data="tests()" 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-indigo-600 to-indigo-900 flex items-center justify-center shadow-lg">
|
|
<i class="fa-solid fa-microscope text-2xl text-white"></i>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Laboratory Tests</h2>
|
|
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage test definitions, parameters, and groups
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search & Actions Bar -->
|
|
<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 tests..." class="input flex-1 sm:w-80" x-model="keyword"
|
|
@keyup.enter="fetchList()" />
|
|
<select class="select w-40" x-model="filterType" @change="fetchList()">
|
|
<option value="">All Types</option>
|
|
<template x-for="(type, index) in (testTypes || [])" :key="(type?.key ?? index)">
|
|
<option :value="type?.key" x-text="(type?.value || '')"></option>
|
|
</template>
|
|
</select>
|
|
<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 Test
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Card -->
|
|
<div class="card overflow-hidden">
|
|
<!-- Loading State -->
|
|
<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 tests...</p>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="overflow-x-auto" x-show="!loading">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Code</th>
|
|
<th>Test Name</th>
|
|
<th>Type</th>
|
|
<th>Seq</th>
|
|
<th>Visible</th>
|
|
<th class="text-center">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Empty State -->
|
|
<template x-if="!list || list.length === 0">
|
|
<tr>
|
|
<td colspan="7" 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 tests found</p>
|
|
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
|
|
<i class="fa-solid fa-plus mr-1"></i> Add First Test
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
|
|
<!-- Test Rows -->
|
|
<template x-for="test in list" :key="test.TestSiteID">
|
|
<tr class="hover:bg-opacity-50">
|
|
<td>
|
|
<span class="badge badge-ghost font-mono text-xs" x-text="test.TestSiteID || '-'"></span>
|
|
</td>
|
|
<td>
|
|
<code class="text-sm font-mono px-2 py-1 rounded"
|
|
style="background: rgb(var(--color-bg-secondary)); color: rgb(var(--color-primary));"
|
|
x-text="test.TestSiteCode || '-'"></code>
|
|
</td>
|
|
<td>
|
|
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="test.TestSiteName || '-'"></div>
|
|
<div class="text-xs mt-1" style="color: rgb(var(--color-text-muted));" x-text="test.Description || ''">
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="badge" :class="{
|
|
'badge-info': test.TypeCode === 'TEST',
|
|
'badge-success': test.TypeCode === 'PARAM',
|
|
'badge-warning': test.TypeCode === 'CALC',
|
|
'badge-primary': test.TypeCode === 'GROUP',
|
|
'badge-secondary': test.TypeCode === 'TITLE'
|
|
}" x-text="test.TypeName || test.TypeCode || '-'"></span>
|
|
</td>
|
|
<td x-text="test.SeqScr || '-'"></td>
|
|
<td>
|
|
<div class="flex gap-1">
|
|
<span class="badge badge-ghost badge-xs" title="Screen" x-show="test.VisibleScr == 1">
|
|
<i class="fa-solid fa-desktop text-green-500"></i>
|
|
</span>
|
|
<span class="badge badge-ghost badge-xs" title="Report" x-show="test.VisibleRpt == 1">
|
|
<i class="fa-solid fa-file-alt text-blue-500"></i>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="text-center">
|
|
<div class="flex items-center justify-center gap-1">
|
|
<button class="btn btn-ghost btn-sm btn-square" @click="viewTest(test.TestSiteID)"
|
|
title="View Details">
|
|
<i class="fa-solid fa-eye text-sky-500"></i>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm btn-square" @click="editTest(test.TestSiteID)" title="Edit">
|
|
<i class="fa-solid fa-pen text-indigo-500"></i>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(test)" title="Delete">
|
|
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Summary -->
|
|
<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="'Showing ' + list.length + ' tests'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Include Form Dialogs -->
|
|
<?= $this->include('v2/master/tests/test_dialog') ?>
|
|
<?= $this->include('v2/master/tests/param_dialog') ?>
|
|
<?= $this->include('v2/master/tests/calc_dialog') ?>
|
|
<?= $this->include('v2/master/tests/grp_dialog') ?>
|
|
|
|
<!-- View Details Modal -->
|
|
<div x-show="showViewModal" x-cloak class="modal-overlay" @click.self="showViewModal = false">
|
|
<div class="modal-content p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto"
|
|
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">
|
|
<!-- 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-microscope" style="color: rgb(var(--color-primary));"></i>
|
|
<span x-text="viewData?.TestSiteName || 'Test Details'"></span>
|
|
</h3>
|
|
<button class="btn btn-ghost btn-sm btn-square" @click="showViewModal = false">
|
|
<i class="fa-solid fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Details Content -->
|
|
<template x-if="viewData">
|
|
<div class="space-y-6">
|
|
<!-- Basic Info -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Code</div>
|
|
<div class="font-mono font-semibold" x-text="viewData.TestSiteCode || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Type</div>
|
|
<span class="badge" :class="{
|
|
'badge-info': viewData.TypeCode === 'TEST',
|
|
'badge-success': viewData.TypeCode === 'PARAM',
|
|
'badge-warning': viewData.TypeCode === 'CALC',
|
|
'badge-primary': viewData.TypeCode === 'GROUP',
|
|
'badge-secondary': viewData.TypeCode === 'TITLE'
|
|
}" x-text="viewData.TypeName || viewData.TypeCode || '-'"></span>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Sequence (Screen)</div>
|
|
<div class="font-semibold" x-text="viewData.SeqScr || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Sequence (Report)</div>
|
|
<div class="font-semibold" x-text="viewData.SeqRpt || '-'"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Type-specific Details -->
|
|
<template x-if="viewData.TypeCode === 'TEST' || viewData.TypeCode === 'PARAM'">
|
|
<div>
|
|
<h4 class="font-semibold mb-3 flex items-center gap-2">
|
|
<i class="fa-solid fa-flask"></i> Technical Details
|
|
</h4>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Result Type</div>
|
|
<div x-text="viewData.testdeftech?.[0]?.ResultType || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Unit</div>
|
|
<div x-text="viewData.testdeftech?.[0]?.Unit1 || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Method</div>
|
|
<div x-text="viewData.testdeftech?.[0]?.Method || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Expected TAT</div>
|
|
<div x-text="(viewData.testdeftech?.[0]?.ExpectedTAT || '-') + ' min'"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="viewData.TypeCode === 'CALC'">
|
|
<div>
|
|
<h4 class="font-semibold mb-3 flex items-center gap-2">
|
|
<i class="fa-solid fa-calculator"></i> Calculation Details
|
|
</h4>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Formula Input</div>
|
|
<div class="font-mono text-sm" x-text="viewData.testdefcal?.[0]?.FormulaInput || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Formula Code</div>
|
|
<div class="font-mono text-sm" x-text="viewData.testdefcal?.[0]?.FormulaCode || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Result Unit</div>
|
|
<div x-text="viewData.testdefcal?.[0]?.Unit1 || '-'"></div>
|
|
</div>
|
|
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Decimal</div>
|
|
<div x-text="viewData.testdefcal?.[0]?.Decimal || '-'"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="viewData.TypeCode === 'GROUP'">
|
|
<div>
|
|
<h4 class="font-semibold mb-3 flex items-center gap-2">
|
|
<i class="fa-solid fa-layer-group"></i> Group Members
|
|
</h4>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Code</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="member in (viewData.testdefgrp || [])" :key="member.TestGrpID">
|
|
<tr>
|
|
<td><code x-text="member.TestSiteCode"></code></td>
|
|
<td x-text="member.TestSiteName"></td>
|
|
<td>
|
|
<span class="badge badge-xs" :class="{
|
|
'badge-info': member.MemberTypeCode === 'TEST',
|
|
'badge-success': member.MemberTypeCode === 'PARAM'
|
|
}" x-text="member.MemberTypeCode"></span>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template x-if="!viewData.testdefgrp || viewData.testdefgrp.length === 0">
|
|
<tr>
|
|
<td colspan="3" class="text-center text-muted">No members</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Test Mappings -->
|
|
<template x-if="viewData.testmap && viewData.testmap.length > 0">
|
|
<div>
|
|
<h4 class="font-semibold mb-3 flex items-center gap-2">
|
|
<i class="fa-solid fa-link"></i> Test Mappings
|
|
</h4>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Host Type</th>
|
|
<th>Host Code</th>
|
|
<th>Client Type</th>
|
|
<th>Client Code</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="map in viewData.testmap" :key="map.TestMapID">
|
|
<tr>
|
|
<td x-text="map.HostType || '-'"></td>
|
|
<td x-text="map.HostTestCode || '-'"></td>
|
|
<td x-text="map.ClientType || '-'"></td>
|
|
<td><code x-text="map.ClientTestCode || '-'"></code></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 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="showViewModal = false">Close</button>
|
|
<button class="btn btn-primary flex-1" @click="editTest(viewData?.TestSiteID)">
|
|
<i class="fa-solid fa-edit mr-2"></i> Edit Test
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<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 test <strong x-text="deleteTarget?.TestSiteName"></strong>?
|
|
This action 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="deleteTest()"
|
|
: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 tests() {
|
|
return {
|
|
// State
|
|
loading: false,
|
|
list: [],
|
|
testTypes: [],
|
|
keyword: "",
|
|
filterType: "",
|
|
|
|
// View Modal
|
|
showViewModal: false,
|
|
viewData: null,
|
|
|
|
// Form Modal
|
|
showModal: false,
|
|
isEditing: false,
|
|
saving: false,
|
|
errors: {},
|
|
activeTab: 'basic',
|
|
|
|
// Member Selector
|
|
showMemberSelector: false,
|
|
memberSearch: '',
|
|
availableTests: [],
|
|
|
|
form: {
|
|
TestSiteID: null,
|
|
SiteID: 1,
|
|
TestSiteCode: "",
|
|
TestSiteName: "",
|
|
TestType: "",
|
|
Description: "",
|
|
SeqScr: 0,
|
|
SeqRpt: 0,
|
|
IndentLeft: 0,
|
|
VisibleScr: 1,
|
|
VisibleRpt: 1,
|
|
CountStat: 1,
|
|
// Technical fields
|
|
DisciplineID: "",
|
|
DepartmentID: "",
|
|
ResultType: "",
|
|
RefType: "",
|
|
Unit1: "",
|
|
Unit2: "",
|
|
Decimal: 2,
|
|
Method: "",
|
|
SampleType: "",
|
|
ExpectedTAT: 60,
|
|
RefMin: null,
|
|
RefMax: null,
|
|
RefText: "",
|
|
CriticalLow: null,
|
|
CriticalHigh: null,
|
|
// Calculation fields
|
|
FormulaInput: "",
|
|
FormulaCode: "",
|
|
// Value Set fields
|
|
ValueSetID: "",
|
|
DefaultValue: "",
|
|
// Group members
|
|
groupMembers: []
|
|
},
|
|
|
|
// Delete Modal
|
|
showDeleteModal: false,
|
|
deleteTarget: null,
|
|
deleting: false,
|
|
|
|
// Lifecycle
|
|
async init() {
|
|
await this.fetchTestTypes();
|
|
await this.fetchAvailableTests();
|
|
// Small delay to ensure Alpine has processed testTypes
|
|
setTimeout(() => {
|
|
this.fetchList();
|
|
}, 50);
|
|
},
|
|
|
|
// Fetch test types from valueset
|
|
async fetchTestTypes() {
|
|
try {
|
|
const res = await fetch(`${BASEURL}api/valueset/test_type`, {
|
|
credentials: 'include'
|
|
});
|
|
if (!res.ok) throw new Error("HTTP error");
|
|
const data = await res.json();
|
|
this.testTypes = data.data || [];
|
|
} catch (err) {
|
|
console.error('Failed to fetch test types:', err);
|
|
this.testTypes = [
|
|
{ key: 'TEST', value: 'Test' },
|
|
{ key: 'PARAM', value: 'Parameter' },
|
|
{ key: 'CALC', value: 'Calculated Test' },
|
|
{ key: 'GROUP', value: 'Group Test' },
|
|
{ key: 'TITLE', value: 'Title' }
|
|
];
|
|
}
|
|
},
|
|
|
|
// Fetch available tests for group members
|
|
async fetchAvailableTests() {
|
|
try {
|
|
const res = await fetch(`${BASEURL}api/tests`, {
|
|
credentials: 'include'
|
|
});
|
|
if (!res.ok) throw new Error("HTTP error");
|
|
const data = await res.json();
|
|
this.availableTests = (data.data || []).filter(t =>
|
|
t.TypeCode === 'TEST' || t.TypeCode === 'PARAM'
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to fetch available tests:', err);
|
|
this.availableTests = [];
|
|
}
|
|
},
|
|
|
|
// Get type display name
|
|
getTypeName(value) {
|
|
const typeMap = {
|
|
'TEST': 'Test',
|
|
'PARAM': 'Parameter',
|
|
'CALC': 'Calculated Test',
|
|
'GROUP': 'Group',
|
|
'TITLE': 'Title'
|
|
};
|
|
return typeMap[value] || value || 'Test';
|
|
},
|
|
|
|
// Get type code from value
|
|
getTypeCode(value) {
|
|
return value || '';
|
|
},
|
|
|
|
// Fetch test list
|
|
async fetchList() {
|
|
this.loading = true;
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (this.keyword) params.append('TestSiteName', this.keyword);
|
|
if (this.filterType) params.append('TestType', this.filterType);
|
|
|
|
const res = await fetch(`${BASEURL}api/tests?${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 = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
// View test details
|
|
async viewTest(id) {
|
|
this.viewData = null;
|
|
try {
|
|
const res = await fetch(`${BASEURL}api/tests/${id}`, {
|
|
credentials: 'include'
|
|
});
|
|
const data = await res.json();
|
|
if (data.data) {
|
|
this.viewData = data.data;
|
|
this.showViewModal = true;
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Failed to load test details');
|
|
}
|
|
},
|
|
|
|
// Show form for new test
|
|
showForm() {
|
|
this.isEditing = false;
|
|
this.activeTab = 'basic';
|
|
this.form = {
|
|
TestSiteID: null,
|
|
SiteID: 1,
|
|
TestSiteCode: "",
|
|
TestSiteName: "",
|
|
TestType: "",
|
|
Description: "",
|
|
SeqScr: 0,
|
|
SeqRpt: 0,
|
|
IndentLeft: 0,
|
|
VisibleScr: 1,
|
|
VisibleRpt: 1,
|
|
CountStat: 1,
|
|
DisciplineID: "",
|
|
DepartmentID: "",
|
|
ResultType: "",
|
|
RefType: "",
|
|
Unit1: "",
|
|
Unit2: "",
|
|
Decimal: 2,
|
|
Method: "",
|
|
SampleType: "",
|
|
ExpectedTAT: 60,
|
|
RefMin: null,
|
|
RefMax: null,
|
|
RefText: "",
|
|
CriticalLow: null,
|
|
CriticalHigh: null,
|
|
FormulaInput: "",
|
|
FormulaCode: "",
|
|
ValueSetID: "",
|
|
DefaultValue: "",
|
|
groupMembers: []
|
|
};
|
|
this.errors = {};
|
|
this.showModal = true;
|
|
},
|
|
|
|
// Edit test
|
|
async editTest(id) {
|
|
this.isEditing = true;
|
|
this.errors = {};
|
|
this.activeTab = 'basic';
|
|
try {
|
|
const res = await fetch(`${BASEURL}api/tests/${id}`, {
|
|
credentials: 'include'
|
|
});
|
|
const data = await res.json();
|
|
if (data.data) {
|
|
const testData = data.data;
|
|
this.form = {
|
|
...this.form,
|
|
...testData,
|
|
TestType: testData.TestType || '',
|
|
TypeCode: testData.TestType || '',
|
|
groupMembers: testData.testdefgrp || []
|
|
};
|
|
this.showModal = true;
|
|
this.showViewModal = false;
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Failed to load test data');
|
|
}
|
|
},
|
|
|
|
// Validate form
|
|
validate() {
|
|
const e = {};
|
|
if (!this.form.TestSiteName?.trim()) e.TestSiteName = "Test name is required";
|
|
if (!this.form.TestSiteCode?.trim()) e.TestSiteCode = "Test code is required";
|
|
if (!this.form.TestType) e.TestType = "Test type is required";
|
|
this.errors = e;
|
|
return Object.keys(e).length === 0;
|
|
},
|
|
|
|
// Close modal
|
|
closeModal() {
|
|
this.showModal = false;
|
|
this.showMemberSelector = false;
|
|
this.errors = {};
|
|
this.memberSearch = '';
|
|
},
|
|
|
|
// Toggle group member
|
|
toggleMember(test) {
|
|
if (!this.form.groupMembers) {
|
|
this.form.groupMembers = [];
|
|
}
|
|
const index = this.form.groupMembers.findIndex(m => m.TestSiteID === test.TestSiteID);
|
|
if (index > -1) {
|
|
this.form.groupMembers.splice(index, 1);
|
|
} else {
|
|
this.form.groupMembers.push({
|
|
TestSiteID: test.TestSiteID,
|
|
TestSiteCode: test.TestSiteCode,
|
|
TestSiteName: test.TestSiteName,
|
|
MemberTypeCode: test.TypeCode || 'TEST',
|
|
SeqScr: 0
|
|
});
|
|
}
|
|
this.$forceUpdate();
|
|
},
|
|
|
|
// Remove group member
|
|
removeMember(index) {
|
|
this.form.groupMembers.splice(index, 1);
|
|
this.$forceUpdate();
|
|
},
|
|
|
|
// Add common member quickly
|
|
addCommonMember(code, type) {
|
|
const test = this.availableTests.find(t => t.TestSiteCode === code);
|
|
if (test) {
|
|
if (!this.form.groupMembers) {
|
|
this.form.groupMembers = [];
|
|
}
|
|
const exists = this.form.groupMembers.some(m => m.TestSiteID === test.TestSiteID);
|
|
if (!exists) {
|
|
this.form.groupMembers.push({
|
|
TestSiteID: test.TestSiteID,
|
|
TestSiteCode: test.TestSiteCode,
|
|
TestSiteName: test.TestSiteName,
|
|
MemberTypeCode: type,
|
|
SeqScr: this.form.groupMembers.length + 1
|
|
});
|
|
this.$forceUpdate();
|
|
}
|
|
} else {
|
|
// Add as custom entry if not found
|
|
if (!this.form.groupMembers) {
|
|
this.form.groupMembers = [];
|
|
}
|
|
this.form.groupMembers.push({
|
|
TestSiteID: null,
|
|
TestSiteCode: code,
|
|
TestSiteName: code,
|
|
MemberTypeCode: type,
|
|
SeqScr: this.form.groupMembers.length + 1
|
|
});
|
|
this.$forceUpdate();
|
|
}
|
|
},
|
|
|
|
// Save test
|
|
async save() {
|
|
if (!this.validate()) return;
|
|
|
|
this.saving = true;
|
|
try {
|
|
const method = this.isEditing ? 'PATCH' : 'POST';
|
|
const payload = { ...this.form };
|
|
|
|
if (this.getTypeCode(this.form.TestType) === 'GROUP' && this.form.groupMembers?.length > 0) {
|
|
payload.groupMembers = this.form.groupMembers;
|
|
}
|
|
|
|
const res = await fetch(`${BASEURL}api/tests`, {
|
|
method: method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
credentials: 'include'
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
this.closeModal();
|
|
await this.fetchList();
|
|
} else {
|
|
alert(data.message || "Failed to save");
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to save test");
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
// Confirm delete
|
|
confirmDelete(test) {
|
|
this.deleteTarget = test;
|
|
this.showDeleteModal = true;
|
|
},
|
|
|
|
// Delete test
|
|
async deleteTest() {
|
|
if (!this.deleteTarget) return;
|
|
|
|
this.deleting = true;
|
|
try {
|
|
const res = await fetch(`${BASEURL}api/tests`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ TestSiteID: this.deleteTarget.TestSiteID }),
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (res.ok) {
|
|
this.showDeleteModal = false;
|
|
await this.fetchList();
|
|
} else {
|
|
const data = await res.json();
|
|
alert(data.message || "Failed to delete");
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to delete test");
|
|
} finally {
|
|
this.deleting = false;
|
|
this.deleteTarget = null;
|
|
}
|
|
},
|
|
|
|
}
|
|
}
|
|
</script>
|
|
<?= $this->endSection() ?>
|