1192 lines
44 KiB
Svelte
Raw Normal View History

<script>
import { onMount } from 'svelte';
import { fetchTests, createTest, updateTest, deleteTest } from '$lib/api/tests.js';
import { fetchDisciplines, fetchDepartments } from '$lib/api/organization.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Filter, X, PlusCircle, Calculator, Ruler, FileText, Search } from 'lucide-svelte';
let loading = $state(false);
let tests = $state([]);
let disciplines = $state([]);
let departments = $state([]);
let modalOpen = $state(false);
// Pagination state
let currentPage = $state(1);
let perPage = $state(20);
let totalItems = $state(0);
let totalPages = $state(1);
let modalMode = $state('create');
let saving = $state(false);
let activeTab = $state('basic'); // 'basic' or 'refrange'
// Filter states
let selectedType = $state('');
let searchQuery = $state('');
// Form data with all fields
let formData = $state({
// Basic fields
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
// Type-specific fields
Unit: '',
Formula: '',
// Reference ranges
refnum: [],
reftxt: [],
refRangeType: 'none' // 'none', 'numeric', 'text'
});
// Delete modal state
let deleteModalOpen = $state(false);
let testToDelete = $state(null);
let deleting = $state(false);
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'
};
// 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'
);
const columns = [
{ key: 'TestSiteCode', label: 'Code', class: 'font-medium' },
{ key: 'TestSiteName', label: 'Name' },
{ key: 'TestType', label: 'Type', class: 'w-32' },
{ key: 'Unit', label: 'Unit', class: 'w-24' },
{ key: 'DisciplineName', label: 'Discipline' },
{ key: 'DepartmentName', label: 'Department' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
];
onMount(async () => {
await Promise.all([
loadTests(),
loadDisciplines(),
loadDepartments()
]);
});
async function loadTests() {
loading = true;
try {
const params = { page: currentPage, perPage };
if (selectedType) params.TestType = selectedType;
if (searchQuery.trim()) params.search = searchQuery.trim();
const response = await fetchTests(params);
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;
// 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;
}
} 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 = [];
}
}
function openCreateModal() {
modalMode = 'create';
activeTab = 'basic';
formData = {
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
Unit: '',
Formula: '',
refnum: [],
reftxt: [],
refRangeType: 'none'
};
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
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';
}
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',
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
};
modalOpen = true;
}
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;
}
async function handleSave() {
// 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;
}
}
}
saving = true;
try {
// 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;
if (modalMode === 'create') {
await createTest(payload);
toastSuccess('Test created successfully');
} else {
await updateTest(payload);
toastSuccess('Test updated successfully');
}
modalOpen = false;
await loadTests();
} catch (err) {
toastError(err.message || 'Failed to save test');
} finally {
saving = false;
}
}
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;
}
}
function handleFilter() {
currentPage = 1;
loadTests();
}
function handleSearch() {
currentPage = 1;
loadTests();
}
function handlePageChange(newPage) {
if (newPage >= 1 && newPage <= totalPages) {
currentPage = newPage;
loadTests();
}
}
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 }))
);
</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">
<div class="flex-1 relative">
<input
type="text"
placeholder="Search by code or name..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
</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>
<button class="btn btn-sm btn-ghost text-error" onclick={() => openDeleteModal(row)}>
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{value || '-'}
{/if}
{/snippet}
</DataTable>
{#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}
</div>
</div>
<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>
</div>
<!-- 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>
</div>
<!-- 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..."
/>
</div>
<!-- Type-specific fields -->
{#if canHaveUnit}
<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}
<!-- 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>
</div>
</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>
</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="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>
</label>
</div>
</div>
<!-- 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}
</div>
{/if}
{#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>
<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>