2026-02-13 16:07:59 +07:00
< script >
import { onMount } from 'svelte';
2026-02-15 17:58:42 +07:00
import { fetchTests , createTest , updateTest , deleteTest } from '$lib/api/tests.js';
2026-02-13 16:07:59 +07:00
import { fetchDisciplines , fetchDepartments } from '$lib/api/organization.js';
2026-02-16 07:03:25 +07:00
import { success as toastSuccess , error as toastError } from '$lib/utils/toast.js';
2026-02-13 16:07:59 +07:00
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
2026-02-15 17:58:42 +07:00
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
2026-02-16 15:58:06 +07:00
import { Plus , Edit2 , Trash2 , ArrowLeft , Filter , X , PlusCircle , Calculator , Ruler , FileText , Search } from 'lucide-svelte';
2026-02-13 16:07:59 +07:00
let loading = $state(false);
let tests = $state([]);
let disciplines = $state([]);
let departments = $state([]);
let modalOpen = $state(false);
2026-02-16 15:58:06 +07:00
// Pagination state
let currentPage = $state(1);
let perPage = $state(20);
let totalItems = $state(0);
let totalPages = $state(1);
2026-02-13 16:07:59 +07:00
let modalMode = $state('create');
let saving = $state(false);
2026-02-15 17:58:42 +07:00
let activeTab = $state('basic'); // 'basic' or 'refrange'
2026-02-13 16:07:59 +07:00
// Filter states
let selectedType = $state('');
let searchQuery = $state('');
2026-02-15 17:58:42 +07:00
// Form data with all fields
2026-02-13 16:07:59 +07:00
let formData = $state({
2026-02-15 17:58:42 +07:00
// Basic fields
2026-02-13 16:07:59 +07:00
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
2026-02-15 17:58:42 +07:00
VisibleScr: true,
VisibleRpt: true,
// Type-specific fields
Unit: '',
Formula: '',
// Reference ranges
refnum: [],
reftxt: [],
refRangeType: 'none' // 'none', 'numeric', 'text'
2026-02-13 16:07:59 +07:00
});
2026-02-15 17:58:42 +07:00
// Delete modal state
let deleteModalOpen = $state(false);
let testToDelete = $state(null);
let deleting = $state(false);
2026-02-13 16:07:59 +07:00
const testTypeLabels = {
TEST: 'Test',
PARAM: 'Param',
CALC: 'Calc',
GROUP: 'Panel',
TITLE: 'Title'
};
const testTypeBadges = {
TEST: 'badge-primary',
PARAM: 'badge-secondary',
CALC: 'badge-accent',
GROUP: 'badge-info',
TITLE: 'badge-ghost'
};
2026-02-15 17:58:42 +07:00
// Sign options with display labels
const signOptions = [
{ value : 'GE' , label : '≥' , description : 'Greater than or equal to' } ,
{ value : 'GT' , label : '>' , description : 'Greater than' } ,
{ value : 'LE' , label : '≤' , description : 'Less than or equal to' } ,
{ value : 'LT' , label : '<' , description : 'Less than' }
];
// Flag options with descriptions
const flagOptions = [
{ value : 'N' , label : 'N' , description : 'Normal - within expected range' } ,
{ value : 'L' , label : 'L' , description : 'Low - below normal range' } ,
{ value : 'H' , label : 'H' , description : 'High - above normal range' } ,
{ value : 'C' , label : 'C' , description : 'Critical - requires immediate attention' }
];
// Sex options
const sexOptions = [
{ value : '2' , label : 'Male' } ,
{ value : '1' , label : 'Female' } ,
{ value : '0' , label : 'Any' }
];
// Derived values for conditional display
const canHaveRefRange = $derived(
formData.TestType === 'TEST' || formData.TestType === 'CALC'
);
const canHaveFormula = $derived(
formData.TestType === 'CALC'
);
const canHaveUnit = $derived(
formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC'
);
2026-02-13 16:07:59 +07:00
const columns = [
{ key : 'TestSiteCode' , label : 'Code' , class : 'font-medium' } ,
{ key : 'TestSiteName' , label : 'Name' } ,
{ key : 'TestType' , label : 'Type' , class : 'w-32' } ,
2026-02-15 17:58:42 +07:00
{ key : 'Unit' , label : 'Unit' , class : 'w-24' } ,
2026-02-13 16:07:59 +07:00
{ key : 'DisciplineName' , label : 'Discipline' } ,
{ key : 'DepartmentName' , label : 'Department' } ,
{ key : 'actions' , label : 'Actions' , class : 'w-24 text-center' } ,
];
onMount(async () => {
await Promise.all([
loadTests(),
loadDisciplines(),
2026-02-16 07:03:25 +07:00
loadDepartments()
2026-02-13 16:07:59 +07:00
]);
});
async function loadTests() {
loading = true;
try {
2026-02-16 15:58:06 +07:00
const params = { page : currentPage , perPage } ;
2026-02-13 16:07:59 +07:00
if (selectedType) params.TestType = selectedType;
2026-02-16 15:58:06 +07:00
if (searchQuery.trim()) params.search = searchQuery.trim();
2026-02-13 16:07:59 +07:00
const response = await fetchTests(params);
2026-02-15 17:58:42 +07:00
let allTests = Array.isArray(response.data) ? response.data : [];
// Filter only active tests (soft delete support)
allTests = allTests.filter(test => test.IsActive !== '0' & & test.IsActive !== 0 & & test.IsActive !== false);
tests = allTests;
2026-02-16 15:58:06 +07:00
// Handle pagination response
if (response.pagination) {
totalItems = response.pagination.total || 0;
totalPages = Math.ceil(totalItems / perPage) || 1;
} else if (response.total) {
totalItems = response.total || 0;
totalPages = Math.ceil(totalItems / perPage) || 1;
}
2026-02-13 16:07:59 +07:00
} catch (err) {
toastError(err.message || 'Failed to load tests');
tests = [];
} finally {
loading = false;
}
}
async function loadDisciplines() {
try {
const response = await fetchDisciplines();
disciplines = Array.isArray(response.data) ? response.data : [];
} catch (err) {
disciplines = [];
}
}
async function loadDepartments() {
try {
const response = await fetchDepartments();
departments = Array.isArray(response.data) ? response.data : [];
} catch (err) {
departments = [];
}
}
2026-02-16 07:03:25 +07:00
function openCreateModal() {
2026-02-13 16:07:59 +07:00
modalMode = 'create';
2026-02-15 17:58:42 +07:00
activeTab = 'basic';
2026-02-13 16:07:59 +07:00
formData = {
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
2026-02-15 17:58:42 +07:00
VisibleScr: true,
VisibleRpt: true,
Unit: '',
Formula: '',
refnum: [],
reftxt: [],
refRangeType: 'none'
2026-02-13 16:07:59 +07:00
};
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
2026-02-15 17:58:42 +07:00
activeTab = 'basic';
// Determine reference range type
let refRangeType = 'none';
if (row.refnum && row.refnum.length > 0) {
refRangeType = 'numeric';
} else if (row.reftxt && row.reftxt.length > 0) {
refRangeType = 'text';
}
2026-02-13 16:07:59 +07:00
formData = {
TestSiteID: row.TestSiteID,
TestSiteCode: row.TestSiteCode,
TestSiteName: row.TestSiteName,
TestType: row.TestType,
DisciplineID: row.DisciplineID,
DepartmentID: row.DepartmentID,
SeqScr: row.SeqScr || '0',
SeqRpt: row.SeqRpt || '0',
2026-02-15 17:58:42 +07:00
VisibleScr: row.VisibleScr === '1' || row.VisibleScr === 1 || row.VisibleScr === true,
VisibleRpt: row.VisibleRpt === '1' || row.VisibleRpt === 1 || row.VisibleRpt === true,
Unit: row.Unit || '',
Formula: row.Formula || '',
refnum: row.refnum || [],
reftxt: row.reftxt || [],
refRangeType: refRangeType
2026-02-13 16:07:59 +07:00
};
modalOpen = true;
}
2026-02-15 17:58:42 +07:00
function isDuplicateCode(code, excludeId = null) {
return tests.some(test =>
test.TestSiteCode.toLowerCase() === code.toLowerCase() & &
test.TestSiteID !== excludeId
);
}
// Reference Range Management
function addNumericRefRange() {
formData.refnum = [...formData.refnum, {
NumRefType: 'NMRC',
RangeType: 'REF',
Sex: '2', // Default Male
LowSign: 'GE',
HighSign: 'LE',
Low: null,
High: null,
AgeStart: 0,
AgeEnd: 120,
Flag: 'N',
Interpretation: 'Normal'
}];
}
function removeNumericRefRange(index) {
formData.refnum = formData.refnum.filter((_, i) => i !== index);
}
function addTextRefRange() {
formData.reftxt = [...formData.reftxt, {
TxtRefType: 'TEXT',
Sex: '2',
AgeStart: 0,
AgeEnd: 120,
RefTxt: '',
Flag: 'N'
}];
}
function removeTextRefRange(index) {
formData.reftxt = formData.reftxt.filter((_, i) => i !== index);
}
function updateRefRangeType(type) {
formData.refRangeType = type;
if (type === 'none') {
formData.refnum = [];
formData.reftxt = [];
} else if (type === 'numeric') {
formData.reftxt = [];
if (formData.refnum.length === 0) {
addNumericRefRange();
}
} else if (type === 'text') {
formData.refnum = [];
if (formData.reftxt.length === 0) {
addTextRefRange();
}
}
}
function getSignLabel(value) {
return signOptions.find(opt => opt.value === value)?.label || value;
}
function validateNumericRange(ref, index) {
const errors = [];
if (ref.Low !== null && ref.High !== null && ref.Low > ref.High) {
errors.push(`Range ${ index + 1 } : Low value cannot be greater than High value`);
}
if (ref.AgeStart !== null && ref.AgeEnd !== null && ref.AgeStart > ref.AgeEnd) {
errors.push(`Range ${ index + 1 } : Age start cannot be greater than Age end`);
}
return errors;
}
function validateTextRange(ref, index) {
const errors = [];
if (ref.AgeStart !== null && ref.AgeEnd !== null && ref.AgeStart > ref.AgeEnd) {
errors.push(`Range ${ index + 1 } : Age start cannot be greater than Age end`);
}
return errors;
}
2026-02-13 16:07:59 +07:00
async function handleSave() {
2026-02-15 17:58:42 +07:00
// Validate duplicate code
if (isDuplicateCode(formData.TestSiteCode, modalMode === 'edit' ? formData.TestSiteID : null)) {
toastError(`Test code '${ formData . TestSiteCode } ' already exists`);
return;
}
// Validate type-specific fields
if (canHaveFormula && !formData.Formula.trim()) {
toastError('Formula is required for calculated tests');
return;
}
// Validate reference ranges
if (formData.refRangeType === 'numeric') {
for (let i = 0; i < formData.refnum.length ; i ++) {
const errors = validateNumericRange(formData.refnum[i], i);
if (errors.length > 0) {
toastError(errors[0]);
return;
}
}
} else if (formData.refRangeType === 'text') {
for (let i = 0; i < formData.reftxt.length ; i ++) {
const errors = validateTextRange(formData.reftxt[i], i);
if (errors.length > 0) {
toastError(errors[0]);
return;
}
}
}
2026-02-13 16:07:59 +07:00
saving = true;
try {
2026-02-15 17:58:42 +07:00
// Prepare payload - clean up based on type
const payload = { ... formData } ;
// Remove fields not applicable to this type
if (!canHaveUnit) {
delete payload.Unit;
}
if (!canHaveFormula) {
delete payload.Formula;
}
if (!canHaveRefRange) {
delete payload.refnum;
delete payload.reftxt;
}
// Remove helper field
delete payload.refRangeType;
2026-02-13 16:07:59 +07:00
if (modalMode === 'create') {
2026-02-15 17:58:42 +07:00
await createTest(payload);
2026-02-13 16:07:59 +07:00
toastSuccess('Test created successfully');
} else {
2026-02-15 17:58:42 +07:00
await updateTest(payload);
2026-02-13 16:07:59 +07:00
toastSuccess('Test updated successfully');
}
modalOpen = false;
await loadTests();
} catch (err) {
toastError(err.message || 'Failed to save test');
} finally {
saving = false;
}
}
2026-02-15 17:58:42 +07:00
function openDeleteModal(row) {
testToDelete = row;
deleteModalOpen = true;
}
async function handleDelete() {
if (!testToDelete?.TestSiteID) return;
deleting = true;
try {
await deleteTest(testToDelete.TestSiteID);
toastSuccess('Test deleted successfully');
deleteModalOpen = false;
testToDelete = null;
await loadTests();
} catch (err) {
toastError(err.message || 'Failed to delete test');
} finally {
deleting = false;
}
}
2026-02-13 16:07:59 +07:00
function handleFilter() {
2026-02-16 15:58:06 +07:00
currentPage = 1;
loadTests();
}
function handleSearch() {
currentPage = 1;
2026-02-13 16:07:59 +07:00
loadTests();
}
2026-02-16 15:58:06 +07:00
function handlePageChange(newPage) {
if (newPage >= 1 && newPage < = totalPages) {
currentPage = newPage;
2026-02-13 16:07:59 +07:00
loadTests();
}
}
2026-02-16 15:58:06 +07:00
2026-02-13 16:07:59 +07:00
const disciplineOptions = $derived(
disciplines.map(d => ({ value : d.DisciplineID , label : d.DisciplineName } ))
);
const departmentOptions = $derived(
departments.map(d => ({ value : d.DepartmentID , label : d.DepartmentName } ))
);
const filteredDepartments = $derived(
formData.DisciplineID
? departments.filter(d => d.DisciplineID === formData.DisciplineID)
: departments
);
const filteredDepartmentOptions = $derived(
filteredDepartments.map(d => ({ value : d.DepartmentID , label : d.DepartmentName } ))
);
2026-02-15 17:58:42 +07:00
2026-02-13 16:07:59 +07:00
< / script >
< div class = "p-6" >
< div class = "flex items-center gap-4 mb-6" >
< a href = "/master-data" class = "btn btn-ghost btn-circle" >
< ArrowLeft class = "w-5 h-5" / >
< / a >
< div class = "flex-1" >
< h1 class = "text-3xl font-bold text-gray-800" > Test Definitions< / h1 >
< p class = "text-gray-600" > Manage laboratory tests and panels< / p >
< / div >
< button class = "btn btn-primary" onclick = { openCreateModal } >
< Plus class = "w-4 h-4 mr-2" / >
Add Test
< / button >
< / div >
<!-- Filters -->
< div class = "bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4" >
< div class = "flex flex-col sm:flex-row gap-4" >
2026-02-16 15:58:06 +07:00
< div class = "flex-1 relative" >
2026-02-13 16:07:59 +07:00
< input
type="text"
placeholder="Search by code or name..."
2026-02-16 15:58:06 +07:00
class="input input-bordered w-full pl-10"
2026-02-13 16:07:59 +07:00
bind:value={ searchQuery }
2026-02-16 15:58:06 +07:00
onkeydown={( e ) => e . key === 'Enter' && handleSearch ()}
2026-02-13 16:07:59 +07:00
/>
2026-02-16 15:58:06 +07:00
< Search class = "w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" / >
2026-02-13 16:07:59 +07:00
< / div >
< div class = "w-full sm:w-48" >
< select
class="select select-bordered w-full"
bind:value={ selectedType }
onchange={ handleFilter }
>
< option value = "" > All Types< / option >
< option value = "TEST" > Technical Test< / option >
< option value = "PARAM" > Parameter< / option >
< option value = "CALC" > Calculated< / option >
< option value = "GROUP" > Panel/Profile< / option >
< option value = "TITLE" > Section Header< / option >
< / select >
< / div >
< button class = "btn btn-outline" onclick = { handleFilter } >
< Filter class = "w-4 h-4 mr-2" / >
Filter
< / button >
< / div >
< / div >
< div class = "bg-base-100 rounded-lg shadow border border-base-200" >
< DataTable
{ columns }
data={ tests . map ( t => ({
...t,
DisciplineName: disciplines.find(d => d.DisciplineID === t.DisciplineID)?.DisciplineName || '-',
DepartmentName: departments.find(d => d.DepartmentID === t.DepartmentID)?.DepartmentName || '-'
}))}
{ loading }
emptyMessage="No tests found"
hover={ true }
bordered={ false }
>
{ # snippet cell ({ column , row , value })}
{ #if column . key === 'TestType' }
< span class = "badge { testTypeBadges [ value ] || 'badge-ghost' } " >
{ testTypeLabels [ value ] || value }
< / span >
{ :else if column . key === 'actions' }
< div class = "flex justify-center gap-2" >
< button class = "btn btn-sm btn-ghost" onclick = {() => openEditModal ( row )} >
< Edit2 class = "w-4 h-4" / >
< / button >
2026-02-15 17:58:42 +07:00
< button class = "btn btn-sm btn-ghost text-error" onclick = {() => openDeleteModal ( row )} >
< Trash2 class = "w-4 h-4" / >
< / button >
2026-02-13 16:07:59 +07:00
< / div >
{ : else }
{ value || '-' }
{ /if }
{ /snippet }
< / DataTable >
2026-02-16 15:58:06 +07:00
{ #if totalPages > 1 }
< div class = "flex items-center justify-between px-4 py-3 border-t border-base-200 bg-base-100" >
< div class = "text-sm text-gray-600" >
Showing {( currentPage - 1 ) * perPage + 1 } - { Math . min ( currentPage * perPage , totalItems )} of { totalItems }
< / div >
< div class = "flex gap-2" >
< button class = "btn btn-sm btn-ghost" onclick = {() => handlePageChange ( currentPage - 1 )} disabled= { currentPage === 1 } >
Previous
< / button >
< span class = "btn btn-sm btn-ghost no-animation" > Page { currentPage } of { totalPages } </ span >
< button class = "btn btn-sm btn-ghost" onclick = {() => handlePageChange ( currentPage + 1 )} disabled= { currentPage === totalPages } >
Next
< / button >
< / div >
< / div >
{ /if }
2026-02-13 16:07:59 +07:00
< / div >
< / div >
2026-02-15 17:58:42 +07:00
< Modal bind:open = { modalOpen } title= { modalMode === 'create' ? 'Add Test' : 'Edit Test' } size = "xl" >
<!-- Tabs -->
< div class = "tabs tabs-bordered mb-4" >
< button
type="button"
class="tab tab-lg { activeTab === 'basic' ? 'tab-active' : '' } "
onclick={() => activeTab = 'basic' }
>
Basic Information
< / button >
{ #if canHaveRefRange }
< button
type="button"
class="tab tab-lg { activeTab === 'refrange' ? 'tab-active' : '' } "
onclick={() => activeTab = 'refrange' }
>
Reference Range
{ #if formData . refnum . length > 0 || formData . reftxt . length > 0 }
< span class = "badge badge-sm badge-primary ml-2" > { formData . refnum . length + formData . reftxt . length } </ span >
{ /if }
< / button >
{ /if }
< / div >
{ #if activeTab === 'basic' }
< form class = "space-y-5" onsubmit = {( e ) => { e . preventDefault (); handleSave (); }} >
<!-- Basic Info -->
< div class = "grid grid-cols-1 md:grid-cols-2 gap-4" >
< div class = "form-control" >
< label class = "label" for = "testCode" >
< span class = "label-text font-medium" > Test Code< / span >
< span class = "label-text-alt text-error" > *< / span >
< / label >
< input
id="testCode"
type="text"
class="input input-bordered w-full"
bind:value={ formData . TestSiteCode }
placeholder="e.g., GLU"
required
/>
< / div >
< div class = "form-control" >
< label class = "label" for = "testName" >
< span class = "label-text font-medium" > Test Name< / span >
< span class = "label-text-alt text-error" > *< / span >
< / label >
< input
id="testName"
type="text"
class="input input-bordered w-full"
bind:value={ formData . TestSiteName }
placeholder="e.g., Glucose"
required
/>
< / div >
2026-02-13 16:07:59 +07:00
< / div >
2026-02-15 17:58:42 +07:00
<!-- Type and Sequence -->
< div class = "grid grid-cols-1 md:grid-cols-2 gap-4" >
< div class = "form-control" >
< label class = "label" for = "testType" >
< span class = "label-text font-medium" > Test Type< / span >
< span class = "label-text-alt text-error" > *< / span >
< / label >
< select
id="testType"
class="select select-bordered w-full"
bind:value={ formData . TestType }
required
>
< option value = "TEST" > Technical Test< / option >
< option value = "PARAM" > Parameter< / option >
< option value = "CALC" > Calculated< / option >
< option value = "GROUP" > Panel/Profile< / option >
< option value = "TITLE" > Section Header< / option >
< / select >
< / div >
< div class = "form-control" >
< label class = "label" for = "seqScr" >
< span class = "label-text font-medium" > Screen Sequence< / span >
< / label >
< input
id="seqScr"
type="number"
class="input input-bordered w-full"
bind:value={ formData . SeqScr }
placeholder="0"
/>
< / div >
2026-02-13 16:07:59 +07:00
< / div >
2026-02-15 17:58:42 +07:00
<!-- Discipline and Department -->
< div class = "grid grid-cols-1 md:grid-cols-2 gap-4" >
< SelectDropdown
label="Discipline"
name="discipline"
bind:value={ formData . DisciplineID }
options={ disciplineOptions }
placeholder="Select discipline..."
/>
< SelectDropdown
label="Department"
name="department"
bind:value={ formData . DepartmentID }
options={ filteredDepartmentOptions }
placeholder="Select department..."
2026-02-13 16:07:59 +07:00
/>
< / div >
2026-02-15 17:58:42 +07:00
<!-- Type - specific fields -->
2026-02-16 07:03:25 +07:00
{ #if canHaveUnit }
2026-02-15 17:58:42 +07:00
< div class = "grid grid-cols-1 md:grid-cols-2 gap-4" >
{ #if canHaveFormula }
< div class = "form-control" >
< label class = "label" for = "formula" >
< span class = "label-text font-medium flex items-center gap-2" >
Formula
< HelpTooltip
text="Enter a mathematical formula using test codes (e.g., BUN / Creatinine). Supported operators: +, -, *, /, parentheses."
title="Formula Help"
/>
< / span >
< span class = "label-text-alt text-error" > *< / span >
< / label >
< input
id="formula"
type="text"
class="input input-bordered w-full"
bind:value={ formData . Formula }
placeholder="e.g., BUN / Creatinine"
required={ canHaveFormula }
/>
< span class = "label-text-alt text-gray-500" > Use test codes with operators: +, -, *, /< / span >
< / div >
{ /if }
< div class = "form-control" >
< label class = "label" for = "unit2" >
< span class = "label-text font-medium" > Unit< / span >
< / label >
< input
id="unit2"
type="text"
class="input input-bordered w-full"
bind:value={ formData . Unit }
placeholder="e.g., mg/dL"
/>
< / div >
< / div >
{ /if }
2026-02-13 16:07:59 +07:00
2026-02-15 17:58:42 +07:00
<!-- Report Sequence and Visibility -->
< div class = "grid grid-cols-1 md:grid-cols-2 gap-4" >
< div class = "form-control" >
< label class = "label" for = "seqRpt" >
< span class = "label-text font-medium" > Report Sequence< / span >
< / label >
< input
id="seqRpt"
type="number"
class="input input-bordered w-full"
bind:value={ formData . SeqRpt }
placeholder="0"
/>
< / div >
< div class = "form-control" >
< span class = "label-text font-medium mb-2 block" > Visibility< / span >
< div class = "flex gap-4" >
< label class = "label cursor-pointer gap-2" >
< input type = "checkbox" class = "checkbox" bind:checked = { formData . VisibleScr } / >
< span class = "label-text" > Screen< / span >
< / label >
< label class = "label cursor-pointer gap-2" >
< input type = "checkbox" class = "checkbox" bind:checked = { formData . VisibleRpt } / >
< span class = "label-text" > Report< / span >
< / label >
< / div >
< / div >
2026-02-13 16:07:59 +07:00
< / div >
2026-02-15 17:58:42 +07:00
< / form >
{ :else if activeTab === 'refrange' && canHaveRefRange }
< div class = "space-y-6" >
<!-- Reference Range Type Selection -->
< div class = "bg-base-100 rounded-lg border border-base-200 p-4" >
< div class = "flex items-center gap-2 mb-3" >
< Ruler class = "w-5 h-5 text-primary" / >
< h3 class = "font-semibold" > Reference Range Type< / h3 >
< HelpTooltip
text="Choose how to define normal/abnormal ranges for this test. Numeric ranges use value comparisons, while text ranges use descriptive text."
title="Reference Range Help"
/>
< / div >
< div class = "flex flex-wrap gap-4" >
< label class = "label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[120px]" >
< input
type="radio"
name="refRangeType"
class="radio radio-primary"
value="none"
checked={ formData . refRangeType === 'none' }
onchange={() => updateRefRangeType ( 'none' )}
/>
< div class = "flex flex-col" >
< span class = "label-text font-medium" > None< / span >
< span class = "text-xs text-gray-500" > No reference range< / span >
< / div >
2026-02-13 16:07:59 +07:00
< / label >
2026-02-15 17:58:42 +07:00
< label class = "label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[120px]" >
< input
type="radio"
name="refRangeType"
class="radio radio-primary"
value="numeric"
checked={ formData . refRangeType === 'numeric' }
onchange={() => updateRefRangeType ( 'numeric' )}
/>
< div class = "flex flex-col" >
< span class = "label-text font-medium flex items-center gap-1" >
Numeric Range
< Calculator class = "w-3 h-3" / >
< / span >
< span class = "text-xs text-gray-500" > Value-based ranges (e.g., 70-100)< / span >
< / div >
< / label >
< label class = "label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[120px]" >
< input
type="radio"
name="refRangeType"
class="radio radio-primary"
value="text"
checked={ formData . refRangeType === 'text' }
onchange={() => updateRefRangeType ( 'text' )}
/>
< div class = "flex flex-col" >
< span class = "label-text font-medium flex items-center gap-1" >
Text Reference
< FileText class = "w-3 h-3" / >
< / span >
< span class = "text-xs text-gray-500" > Descriptive text (e.g., Negative)< / span >
< / div >
2026-02-13 16:07:59 +07:00
< / label >
< / div >
< / div >
2026-02-15 17:58:42 +07:00
<!-- Numeric Reference Ranges -->
{ #if formData . refRangeType === 'numeric' }
< div class = "space-y-4" >
< div class = "flex justify-between items-center" >
< div class = "flex items-center gap-2" >
< Calculator class = "w-5 h-5 text-primary" / >
< h3 class = "font-semibold" > Numeric Reference Ranges< / h3 >
< HelpTooltip
text="Define normal ranges using numeric comparisons. Multiple ranges can be defined for different patient demographics (sex, age). System will automatically flag results outside these ranges."
title="Numeric Ranges"
/>
< / div >
< button type = "button" class = "btn btn-sm btn-primary" onclick = { addNumericRefRange } >
< PlusCircle class = "w-4 h-4 mr-1" / >
Add Range
< / button >
< / div >
{ #if formData . refnum . length === 0 }
< div class = "text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300" >
< Calculator class = "w-12 h-12 mx-auto text-gray-400 mb-2" / >
< p class = "text-gray-500" > No numeric ranges defined< / p >
< button type = "button" class = "btn btn-sm btn-outline mt-2" onclick = { addNumericRefRange } >
< PlusCircle class = "w-4 h-4 mr-1" / >
Add First Range
< / button >
< / div >
{ /if }
{ #each formData . refnum as ref , index ( index )}
{ @const validationErrors = validateNumericRange ( ref , index )}
< div class = "card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors" >
< div class = "card-body p-4" >
<!-- Range Header -->
< div class = "flex justify-between items-center mb-4 pb-3 border-b border-base-200" >
< div class = "flex items-center gap-2" >
< span class = "badge badge-primary badge-lg" > Range { index + 1 } </ span >
{ #if validationErrors . length > 0 }
< span class = "badge badge-error badge-sm" > Invalid< / span >
{ /if }
< / div >
< button type = "button" class = "btn btn-sm btn-ghost text-error" onclick = {() => removeNumericRefRange ( index )} >
< X class = "w-4 h-4" / >
Remove
< / button >
< / div >
<!-- Patient Demographics Section -->
< div class = "mb-4" >
< h4 class = "text-sm font-medium text-gray-500 mb-2 flex items-center gap-2" >
< span class = "w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs" > 1< / span >
Patient Demographics
< / h4 >
< div class = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3" >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Range Type< / span >
< select class = "select select-sm select-bordered w-full" bind:value = { ref . NumRefType } >
< option value = "NMRC" > Normal Range< / option >
< option value = "THOLD" > Threshold< / option >
< / select >
< / div >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Sex< / span >
< select class = "select select-sm select-bordered w-full" bind:value = { ref . Sex } >
{ #each sexOptions as option ( option . value )}
< option value = { option . value } > { option . label } </option >
{ /each }
< / select >
< / div >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Age From (years)< / span >
< input
type="number"
min="0"
max="120"
class="input input-sm input-bordered w-full"
bind:value={ ref . AgeStart }
placeholder="0"
class:input-error={ ref . AgeStart !== null && ref . AgeEnd !== null && ref . AgeStart > ref . AgeEnd }
/>
< / div >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Age To (years)< / span >
< input
type="number"
min="0"
max="120"
class="input input-sm input-bordered w-full"
bind:value={ ref . AgeEnd }
placeholder="120"
class:input-error={ ref . AgeStart !== null && ref . AgeEnd !== null && ref . AgeStart > ref . AgeEnd }
/>
< / div >
< / div >
{ #if ref . AgeStart !== null && ref . AgeEnd !== null && ref . AgeStart > ref . AgeEnd }
< p class = "text-xs text-error mt-1" > Age start cannot be greater than age end< / p >
{ /if }
< / div >
<!-- Range Values Section -->
< div class = "mb-4" >
< h4 class = "text-sm font-medium text-gray-500 mb-2 flex items-center gap-2" >
< span class = "w-6 h-6 rounded-full bg-secondary/10 text-secondary flex items-center justify-center text-xs" > 2< / span >
Range Values
< HelpTooltip
text="Define the lower and upper bounds of the normal range. Use the dropdown to select comparison operators (≥, >, ≤, < )."
title="Range Values"
/>
< / h4 >
< div class = "bg-base-200 rounded-lg p-3" >
< div class = "grid grid-cols-1 sm:grid-cols-2 gap-4" >
<!-- Low Value Group -->
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Lower Bound< / span >
< div class = "flex gap-2" >
< select class = "select select-sm select-bordered w-20" bind:value = { ref . LowSign } >
{ #each signOptions as option ( option . value )}
< option value = { option . value } > { option . label } </option >
{ /each }
< / select >
< input
type="number"
step="0.01"
class="input input-sm input-bordered flex-1"
bind:value={ ref . Low }
placeholder="e.g., 70"
/>
< / div >
< span class = "text-xs text-gray-500 mt-1" >
{ signOptions . find ( o => o . value === ref . LowSign ) ? . description }
< / span >
< / div >
<!-- High Value Group -->
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Upper Bound< / span >
< div class = "flex gap-2" >
< select class = "select select-sm select-bordered w-20" bind:value = { ref . HighSign } >
{ #each signOptions as option ( option . value )}
< option value = { option . value } > { option . label } </option >
{ /each }
< / select >
< input
type="number"
step="0.01"
class="input input-sm input-bordered flex-1"
bind:value={ ref . High }
placeholder="e.g., 100"
/>
< / div >
< span class = "text-xs text-gray-500 mt-1" >
{ signOptions . find ( o => o . value === ref . HighSign ) ? . description }
< / span >
< / div >
< / div >
<!-- Visual Preview -->
{ #if ref . Low !== null && ref . High !== null }
{ @const lowLabel = getSignLabel ( ref . LowSign )}
{ @const highLabel = getSignLabel ( ref . HighSign )}
< div class = "mt-3 p-2 bg-base-100 rounded border border-base-300" >
< span class = "text-xs text-gray-500" > Preview:< / span >
< span class = "text-sm font-medium ml-2" >
{ lowLabel } { ref . Low } < span class = "text-gray-400" > and</ span > { highLabel } { ref . High }
< / span >
{ #if ref . Low > ref . High }
< span class = "text-xs text-error ml-2" > ⚠ Low value exceeds High value< / span >
{ /if }
< / div >
{ /if }
< / div >
< / div >
<!-- Results Section -->
< div >
< h4 class = "text-sm font-medium text-gray-500 mb-2 flex items-center gap-2" >
< span class = "w-6 h-6 rounded-full bg-accent/10 text-accent flex items-center justify-center text-xs" > 3< / span >
Result Interpretation
< / h4 >
< div class = "grid grid-cols-1 sm:grid-cols-2 gap-3" >
< div class = "form-control" >
< span class = "label-text text-xs mb-1 flex items-center gap-1" >
Flag
< HelpTooltip
text="The flag indicates result status: N=Normal, L=Low, H=High, C=Critical. Flags are shown on reports to highlight abnormal values."
title="Result Flag"
/>
< / span >
< select class = "select select-sm select-bordered w-full" bind:value = { ref . Flag } >
{ #each flagOptions as option ( option . value )}
< option value = { option . value } > { option . label } - { option . description } </ option >
{ /each }
< / select >
< / div >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Interpretation< / span >
< input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ ref . Interpretation }
placeholder="e.g., Normal range for adults"
/>
< / div >
< / div >
< / div >
< / div >
< / div >
{ /each }
< / div >
{ /if }
<!-- Text Reference Ranges -->
{ #if formData . refRangeType === 'text' }
< div class = "space-y-4" >
< div class = "flex justify-between items-center" >
< div class = "flex items-center gap-2" >
< FileText class = "w-5 h-5 text-primary" / >
< h3 class = "font-semibold" > Text Reference Ranges< / h3 >
< HelpTooltip
text="Define reference ranges using descriptive text. Useful for non-numeric results like 'Negative', 'Positive', or multi-value interpretations."
title="Text References"
/>
< / div >
< button type = "button" class = "btn btn-sm btn-primary" onclick = { addTextRefRange } >
< PlusCircle class = "w-4 h-4 mr-1" / >
Add Range
< / button >
< / div >
{ #if formData . reftxt . length === 0 }
< div class = "text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300" >
< FileText class = "w-12 h-12 mx-auto text-gray-400 mb-2" / >
< p class = "text-gray-500" > No text ranges defined< / p >
< button type = "button" class = "btn btn-sm btn-outline mt-2" onclick = { addTextRefRange } >
< PlusCircle class = "w-4 h-4 mr-1" / >
Add First Range
< / button >
< / div >
{ /if }
{ #each formData . reftxt as ref , index ( index )}
{ @const validationErrors = validateTextRange ( ref , index )}
< div class = "card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors" >
< div class = "card-body p-4" >
<!-- Range Header -->
< div class = "flex justify-between items-center mb-4 pb-3 border-b border-base-200" >
< div class = "flex items-center gap-2" >
< span class = "badge badge-primary badge-lg" > Range { index + 1 } </ span >
{ #if validationErrors . length > 0 }
< span class = "badge badge-error badge-sm" > Invalid< / span >
{ /if }
< / div >
< button type = "button" class = "btn btn-sm btn-ghost text-error" onclick = {() => removeTextRefRange ( index )} >
< X class = "w-4 h-4" / >
Remove
< / button >
< / div >
<!-- Patient Demographics Section -->
< div class = "mb-4" >
< h4 class = "text-sm font-medium text-gray-500 mb-2 flex items-center gap-2" >
< span class = "w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs" > 1< / span >
Patient Demographics
< / h4 >
< div class = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3" >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Reference Type< / span >
< select class = "select select-sm select-bordered w-full" bind:value = { ref . TxtRefType } >
< option value = "TEXT" > Free Text< / option >
< option value = "VSET" > Value Set< / option >
< / select >
< / div >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Sex< / span >
< select class = "select select-sm select-bordered w-full" bind:value = { ref . Sex } >
{ #each sexOptions as option ( option . value )}
< option value = { option . value } > { option . label } </option >
{ /each }
< / select >
< / div >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Age From (years)< / span >
< input
type="number"
min="0"
max="120"
class="input input-sm input-bordered w-full"
bind:value={ ref . AgeStart }
placeholder="0"
class:input-error={ ref . AgeStart !== null && ref . AgeEnd !== null && ref . AgeStart > ref . AgeEnd }
/>
< / div >
< div class = "form-control" >
< span class = "label-text text-xs mb-1" > Age To (years)< / span >
< input
type="number"
min="0"
max="120"
class="input input-sm input-bordered w-full"
bind:value={ ref . AgeEnd }
placeholder="120"
class:input-error={ ref . AgeStart !== null && ref . AgeEnd !== null && ref . AgeStart > ref . AgeEnd }
/>
< / div >
< / div >
{ #if ref . AgeStart !== null && ref . AgeEnd !== null && ref . AgeStart > ref . AgeEnd }
< p class = "text-xs text-error mt-1" > Age start cannot be greater than age end< / p >
{ /if }
< / div >
<!-- Reference Text Section -->
< div class = "mb-4" >
< h4 class = "text-sm font-medium text-gray-500 mb-2 flex items-center gap-2" >
< span class = "w-6 h-6 rounded-full bg-secondary/10 text-secondary flex items-center justify-center text-xs" > 2< / span >
Reference Text
< HelpTooltip
text="Enter the reference text. For Value Set type, use format: CODE=Description;CODE2=Description2 (e.g., NEG=Negative;POS=Positive)"
title="Reference Text Format"
/>
< / h4 >
< div class = "form-control" >
< textarea
class="textarea textarea-bordered w-full"
rows="2"
bind:value={ ref . RefTxt }
placeholder={ ref . TxtRefType === 'VSET'
? "e.g., NEG=Negative;POS=Positive;INV=Invalid"
: "e.g., Negative for glucose"}
>< / textarea >
< span class = "label-text-alt text-gray-500" >
{ #if ref . TxtRefType === 'VSET' }
Format: CODE=Description;CODE2=Description2
{ : else }
Enter descriptive text for the reference range
{ /if }
< / span >
< / div >
< / div >
<!-- Results Section -->
< div >
< h4 class = "text-sm font-medium text-gray-500 mb-2 flex items-center gap-2" >
< span class = "w-6 h-6 rounded-full bg-accent/10 text-accent flex items-center justify-center text-xs" > 3< / span >
Result Flag
< HelpTooltip
text="Select whether this reference range represents a Normal (N) or Abnormal (A) result."
title="Result Flag"
/>
< / h4 >
< div class = "form-control" >
< select class = "select select-sm select-bordered w-full sm:w-64" bind:value = { ref . Flag } >
< option value = "N" > N - Normal< / option >
< option value = "A" > A - Abnormal< / option >
< / select >
< / div >
< / div >
< / div >
< / div >
{ /each }
< / div >
{ /if }
2026-02-13 16:07:59 +07:00
< / div >
2026-02-15 17:58:42 +07:00
{ /if }
2026-02-13 16:07:59 +07:00
{ # snippet footer ()}
< button class = "btn btn-ghost" onclick = {() => ( modalOpen = false )} type="button" > Cancel</ button >
< button class = "btn btn-primary" onclick = { handleSave } disabled= { saving } type = "button" >
{ #if saving }
< span class = "loading loading-spinner loading-sm mr-2" > < / span >
{ /if }
{ saving ? 'Saving...' : 'Save' }
< / button >
{ /snippet }
< / Modal >
2026-02-15 17:58:42 +07:00
< Modal bind:open = { deleteModalOpen } title="Confirm Delete Test " size = "sm" >
< div class = "py-2" >
< p > Are you sure you want to delete this test?< / p >
< p class = "text-sm text-gray-600 mt-2" >
Code: < strong > { testToDelete ? . TestSiteCode } </ strong >< br />
Name: < strong > { testToDelete ? . TestSiteName } </ strong >
< / p >
< p class = "text-sm text-warning mt-2" >
This will deactivate the test. It will no longer appear in test lists but historical data will be preserved.
< / p >
< / div >
{ # snippet footer ()}
< button class = "btn btn-ghost" onclick = {() => ( deleteModalOpen = false )} type="button" > Cancel</ button >
< button class = "btn btn-error" onclick = { handleDelete } disabled= { deleting } type = "button" >
{ #if deleting }
< span class = "loading loading-spinner loading-sm mr-2" > < / span >
{ /if }
{ deleting ? 'Deleting...' : 'Delete' }
< / button >
{ /snippet }
< / Modal >