mahdahar 99d622ad05 refactor(tests): consolidate test management modal components
- Consolidate fragmented test modal components into unified TestFormModal.svelte
- Reorganize reference range components into organized tabs (RefNumTab, RefTxtTab)
- Add new tab components: BasicInfoTab, TechDetailsTab, CalcDetailsTab, MappingsTab, GroupMembersTab
- Move test-related type definitions to src/lib/types/test.types.ts for better type organization
- Delete old component files: TestModal.svelte and 10+ sub-components
- Add backup directory for old component preservation
- Update AGENTS.md with coding guidelines and project conventions
- Update tests.js API client with improved structure
- Add documentation for frontend test management architecture
2026-02-20 13:51:54 +07:00

432 lines
22 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import { fetchTests, fetchTest, 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 TestModal from './TestModal.svelte';
import TestTypeSelector from './test-modal/TestTypeSelector.svelte';
import { validateNumericRange, validateTextRange } from './referenceRange.js';
import { Plus, Edit2, Trash2, ArrowLeft, Filter, Search, ChevronDown, ChevronRight, Microscope, Variable, Calculator, Box, Layers } from 'lucide-svelte';
let loading = $state(false), tests = $state([]), disciplines = $state([]), departments = $state([]);
let modalOpen = $state(false), selectedRowIndex = $state(-1), expandedGroups = $state(new Set()), typeSelectorOpen = $state(false);
let currentPage = $state(1), perPage = $state(20), totalItems = $state(0), totalPages = $state(1);
let modalMode = $state('create'), saving = $state(false), selectedType = $state(''), searchQuery = $state(''), searchInputRef = $state(null);
let deleteModalOpen = $state(false), testToDelete = $state(null), deleting = $state(false);
let formData = $state({
// Basic Info (testdefsite)
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
Description: '',
CountStat: false,
Unit: '',
Formula: '',
refnum: [],
reftxt: [],
refRangeType: 'none',
// Technical Config (flat structure)
ResultType: '',
RefType: '',
ReqQty: null,
ReqQtyUnit: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 0,
CollReq: '',
Method: '',
ExpectedTAT: null,
// Group Members (testdefgrp)
groupMembers: []
});
const testTypeConfig = { TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' }, PARAM: { label: 'Parameter', badgeClass: 'badge-secondary', icon: Variable, color: '#3399FF', bgColor: '#F0F8FF' }, CALC: { label: 'Calculated', badgeClass: 'badge-accent', icon: Calculator, color: '#9933CC', bgColor: '#F5E6FF' }, GROUP: { label: 'Panel', badgeClass: 'badge-info', icon: Box, color: '#00AA44', bgColor: '#E6F9EE' }, TITLE: { label: 'Header', badgeClass: 'badge-ghost', icon: Layers, color: '#666666', bgColor: '#F5F5F5' } };
const canHaveRefRange = $derived(formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC');
const isNoResultType = $derived(formData.TestType === 'GROUP' || formData.TestType === 'TITLE');
const canHaveFormula = $derived(formData.TestType === 'CALC');
const canHaveTechnical = $derived(formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC');
const isGroupTest = $derived(formData.TestType === 'GROUP');
const columns = [{ key: 'expand', label: '', class: 'w-8' }, { key: 'TestSiteCode', label: 'Code', class: 'font-medium w-24' }, { key: 'TestSiteName', label: 'Name', class: 'min-w-[200px]' }, { key: 'TestType', label: 'Type', class: 'w-28' }, { key: 'ReferenceRange', label: 'Reference Range', class: 'w-40' }, { key: 'Unit', label: 'Unit', class: 'w-20' }, { key: 'actions', label: 'Actions', class: 'w-24 text-center' }];
const disciplineOptions = $derived(disciplines.map(d => ({ value: d.DisciplineID, label: d.DisciplineName })));
const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName })));
onMount(async () => { document.addEventListener('keydown', handleKeydown); await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]); });
onDestroy(() => document.removeEventListener('keydown', handleKeydown));
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 : []; allTests = allTests.filter(test => test.IsActive !== '0' && test.IsActive !== 0); tests = allTests; if (response.pagination) { totalItems = response.pagination.total || 0; totalPages = Math.ceil(totalItems / perPage) || 1; } selectedRowIndex = -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 handleKeydown(e) { if (e.key === '/' && !modalOpen && !deleteModalOpen) { e.preventDefault(); searchInputRef?.focus(); return; } if (e.key === 'Escape') { if (modalOpen) modalOpen = false; else if (deleteModalOpen) deleteModalOpen = false; return; } if (!modalOpen && !deleteModalOpen && document.activeElement === document.body) { const visibleTests = getVisibleTests(); if (e.key === 'ArrowDown' && selectedRowIndex < visibleTests.length - 1) selectedRowIndex++; else if (e.key === 'ArrowUp' && selectedRowIndex > 0) selectedRowIndex--; else if (e.key === 'Enter' && selectedRowIndex >= 0) openEditModal(visibleTests[selectedRowIndex]); } }
function getVisibleTests() { return tests.filter(t => t.IsActive !== '0' && t.IsActive !== 0); }
function getTestTypeConfig(type) { return testTypeConfig[type] || testTypeConfig.TEST; }
function formatReferenceRange(test) { return '-'; }
function openTypeSelector() {
typeSelectorOpen = true;
}
function handleTypeSelect(type) {
typeSelectorOpen = false;
openCreateModal(type);
}
function openCreateModal(type = 'TEST') {
modalMode = 'create';
// Determine ResultType based on TestType
const getDefaultResultType = (testType) => {
if (testType === 'GROUP' || testType === 'TITLE') return 'NORES';
if (testType === 'CALC') return 'NMRIC';
return '';
};
formData = {
// Basic Info
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: type,
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
Description: '',
CountStat: false,
Unit: '',
Formula: '',
refnum: [],
reftxt: [],
refRangeType: 'none',
// Technical Config
ResultType: getDefaultResultType(type),
RefType: type === 'CALC' ? 'RANGE' : '',
ReqQty: null,
ReqQtyUnit: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 0,
CollReq: '',
Method: '',
ExpectedTAT: null,
// Group Members
groupMembers: []
};
modalOpen = true;
}
async function openEditModal(row) {
try {
// Fetch full test details including reference ranges, technical config, group members
const response = await fetchTest(row.TestSiteID);
const testDetail = response.data;
modalMode = 'edit';
// Consolidate refthold into refnum and refvset into reftxt
const consolidatedRefnum = [
...(testDetail.refnum || []),
...(testDetail.refthold || []).map(ref => ({ ...ref, RefType: 'THOLD' }))
];
const consolidatedReftxt = [
...(testDetail.reftxt || []),
...(testDetail.refvset || []).map(ref => ({ ...ref, RefType: 'VSET' }))
];
// Determine refRangeType based on ResultType and consolidated arrays
let refRangeType = 'none';
if (testDetail.TestType === 'GROUP' || testDetail.TestType === 'TITLE') {
refRangeType = 'none';
} else if (testDetail.ResultType === 'NMRIC' || testDetail.ResultType === 'RANGE') {
const hasThold = consolidatedRefnum.some(ref => ref.RefType === 'THOLD');
refRangeType = hasThold ? 'thold' : 'num';
} else if (testDetail.ResultType === 'VSET') {
refRangeType = 'vset';
} else if (testDetail.ResultType === 'TEXT') {
refRangeType = 'text';
}
// Normalize reference range data to ensure all fields have values (not undefined)
const normalizeRefNum = (ref) => ({
RefType: ref.RefType ?? 'REF',
Sex: ref.Sex ?? '0',
LowSign: ref.LowSign ?? 'GE',
HighSign: ref.HighSign ?? 'LE',
Low: ref.Low ?? null,
High: ref.High ?? null,
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
Flag: ref.Flag ?? 'N',
Interpretation: ref.Interpretation ?? '',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
const normalizeRefTxt = (ref) => ({
RefType: ref.RefType ?? 'TEXT',
Sex: ref.Sex ?? '0',
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
RefTxt: ref.RefTxt ?? '',
Flag: ref.Flag ?? 'N',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
formData = {
// Basic Info
TestSiteID: testDetail.TestSiteID,
TestSiteCode: testDetail.TestSiteCode,
TestSiteName: testDetail.TestSiteName,
TestType: testDetail.TestType,
DisciplineID: testDetail.DisciplineID || null,
DepartmentID: testDetail.DepartmentID || null,
SeqScr: testDetail.SeqScr || '0',
SeqRpt: testDetail.SeqRpt || '0',
VisibleScr: testDetail.VisibleScr === '1' || testDetail.VisibleScr === 1 || testDetail.VisibleScr === true,
VisibleRpt: testDetail.VisibleRpt === '1' || testDetail.VisibleRpt === 1 || testDetail.VisibleRpt === true,
Description: testDetail.Description || '',
CountStat: testDetail.CountStat === '1' || testDetail.CountStat === 1 || testDetail.CountStat === true,
Unit: testDetail.Unit || '',
Formula: testDetail.Formula || '',
refnum: consolidatedRefnum.map(normalizeRefNum),
reftxt: consolidatedReftxt.map(normalizeRefTxt),
refRangeType,
// Technical Config (flat structure)
ResultType: testDetail.ResultType || (testDetail.TestType === 'GROUP' || testDetail.TestType === 'TITLE' ? 'NORES' : ''),
RefType: testDetail.RefType || (testDetail.TestType === 'GROUP' || testDetail.TestType === 'TITLE' ? 'NOREF' : ''),
ReqQty: testDetail.ReqQty || null,
ReqQtyUnit: testDetail.ReqQtyUnit || '',
Unit1: testDetail.Unit1 || '',
Factor: testDetail.Factor || null,
Unit2: testDetail.Unit2 || '',
Decimal: testDetail.Decimal || 0,
Method: testDetail.Method || '',
ExpectedTAT: testDetail.ExpectedTAT || null,
// Group Members - API returns as testdefgrp
groupMembers: testDetail.testdefgrp || []
};
modalOpen = true;
} catch (err) {
toastError(err.message || 'Failed to load test details');
console.error('Failed to fetch test details:', err);
}
}
function isDuplicateCode(code, excludeId = null) { return tests.some(test => test.TestSiteCode.toLowerCase() === code.toLowerCase() && test.TestSiteID !== excludeId); }
async function handleSave() {
if (isDuplicateCode(formData.TestSiteCode, modalMode === 'edit' ? formData.TestSiteID : null)) { toastError(`Test code '${formData.TestSiteCode}' already exists`); return; }
if (canHaveFormula && !formData.Formula.trim()) { toastError('Formula is required for calculated tests'); return; }
// Validate TestType-ResultType relationship
if (formData.TestType === 'CALC' && formData.ResultType !== 'NMRIC') {
toastError('Calculated tests must have ResultType NMRIC'); return;
}
if ((formData.TestType === 'GROUP' || formData.TestType === 'TITLE') && formData.ResultType !== 'NORES') {
toastError('Group and Title tests must have ResultType NORES'); return;
}
if ((formData.TestType === 'TEST' || formData.TestType === 'PARAM') && !['NMRIC', 'RANGE', 'TEXT', 'VSET'].includes(formData.ResultType)) {
toastError('Test and Parameter must have ResultType NMRIC, RANGE, TEXT, or VSET'); return;
}
// Validate ResultType-RefType relationship
if (formData.ResultType === 'NMRIC' || formData.ResultType === 'RANGE') {
if (!['RANGE', 'THOLD'].includes(formData.RefType)) {
toastError('NMRIC and RANGE ResultTypes must have RefType RANGE or THOLD'); return;
}
} else if (formData.ResultType === 'VSET') {
if (formData.RefType !== 'VSET') {
toastError('VSET ResultType must have RefType VSET'); return;
}
} else if (formData.ResultType === 'TEXT') {
if (formData.RefType !== 'TEXT') {
toastError('TEXT ResultType must have RefType TEXT'); return;
}
} else if (formData.ResultType === 'NORES') {
if (formData.RefType !== 'NOREF') {
toastError('NORES ResultType must have RefType NOREF'); return;
}
}
// Validate reference ranges based on type
if (formData.refRangeType === 'num' || formData.refRangeType === 'thold') {
const rangesToValidate = (formData.refnum || []).filter(ref =>
formData.refRangeType === 'num' ? ref.RefType !== 'THOLD' : ref.RefType === 'THOLD'
);
for (let i = 0; i < rangesToValidate.length; i++) {
const errors = validateNumericRange(rangesToValidate[i], i);
if (errors.length > 0) { toastError(errors[0]); return; }
}
}
else if (formData.refRangeType === 'text' || formData.refRangeType === 'vset') {
const rangesToValidate = (formData.reftxt || []).filter(ref =>
formData.refRangeType === 'text' ? ref.RefType !== 'VSET' : ref.RefType === 'VSET'
);
for (let i = 0; i < rangesToValidate.length; i++) {
const errors = validateTextRange(rangesToValidate[i], i);
if (errors.length > 0) { toastError(errors[0]); return; }
}
}
saving = true;
try {
const payload = { ...formData };
// Remove fields based on test type
if (!canHaveFormula) delete payload.Formula;
// Handle reference ranges based on ResultType
if (isNoResultType) {
// GROUP and TITLE have no reference ranges
delete payload.refnum;
delete payload.reftxt;
} else if (formData.ResultType === 'NMRIC' || formData.ResultType === 'RANGE') {
// NMRIC/RANGE uses refnum table
if (formData.RefType === 'THOLD') {
payload.refnum = (formData.refnum || []).filter(ref => ref.RefType === 'THOLD');
} else {
payload.refnum = (formData.refnum || []).filter(ref => ref.RefType !== 'THOLD');
}
delete payload.reftxt;
} else if (formData.ResultType === 'VSET') {
// VSET uses reftxt table with RefType VSET
payload.reftxt = (formData.reftxt || []).filter(ref => ref.RefType === 'VSET');
delete payload.refnum;
} else if (formData.ResultType === 'TEXT') {
// TEXT uses reftxt table with RefType TEXT
payload.reftxt = (formData.reftxt || []).filter(ref => ref.RefType === 'TEXT');
delete payload.refnum;
} else {
delete payload.refnum;
delete payload.reftxt;
}
if (!canHaveTechnical) {
delete payload.ResultType;
delete payload.RefType;
delete payload.Unit1;
delete payload.Factor;
delete payload.Unit2;
delete payload.Decimal;
delete payload.ReqQty;
delete payload.ReqQtyUnit;
delete payload.CollReq;
delete payload.Method;
delete payload.ExpectedTAT;
}
if (!isGroupTest) {
delete payload.groupMembers;
}
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; selectedRowIndex = -1; loadTests(); } }
function handleRowClick(index) { selectedRowIndex = index; }
function toggleGroup(testId) { if (expandedGroups.has(testId)) expandedGroups.delete(testId); else expandedGroups.add(testId); expandedGroups = new Set(expandedGroups); }
</script>
<div class="p-4">
<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-xl font-bold text-gray-800">Test Definitions</h1>
<p class="text-sm text-gray-600">Manage laboratory tests, panels, and calculated values</p>
</div>
<button class="btn btn-primary" onclick={openTypeSelector}><Plus class="w-4 h-4 mr-2" />Add Test</button>
</div>
<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-sm input-bordered w-full pl-10" bind:value={searchQuery} bind:this={searchInputRef} 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-sm select-bordered w-full" bind:value={selectedType} onchange={handleFilter}>
<option value="">All Types</option>
<option value="TEST">Single 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={getVisibleTests()} {loading} emptyMessage="No tests found" hover={true} bordered={false} onRowClick={(row, idx) => handleRowClick(idx)}>
{#snippet cell({ column, row, index })}
{@const isSelected = index === selectedRowIndex} {@const typeConfig = getTestTypeConfig(row.TestType)} {@const isGroup = row.TestType === 'GROUP'} {@const isExpanded = expandedGroups.has(row.TestSiteID)}
{#if column.key === 'expand'}{#if isGroup}<button class="btn btn-ghost btn-xs btn-circle" onclick={() => toggleGroup(row.TestSiteID)}>{#if isExpanded}<ChevronDown class="w-4 h-4" />{:else}<ChevronRight class="w-4 h-4" />{/if}</button>{:else}<span class="w-8 inline-block"></span>{/if}{/if}
{#if column.key === 'TestType'}<span class="badge {typeConfig.badgeClass} gap-1" style="background-color: {typeConfig.bgColor}; color: {typeConfig.color}; border-color: {typeConfig.color};"><svelte:component this={typeConfig.icon} class="w-3 h-3" />{typeConfig.label}</span>{/if}
{#if column.key === 'TestSiteName'}<div class="flex flex-col"><span class="font-medium">{row.TestSiteName}</span>{#if isGroup && isExpanded && row.testdefgrp}<div class="mt-2 ml-4 pl-3 border-l-2 border-base-300 space-y-1">{#each row.testdefgrp as member}{@const memberConfig = getTestTypeConfig(member.TestType)}<div class="flex items-center gap-2 text-sm text-gray-600 py-1"><svelte:component this={memberConfig.icon} class="w-3 h-3" style="color: {memberConfig.color}" /><span class="font-mono text-xs">{member.TestSiteCode}</span><span>{member.TestSiteName}</span></div>{/each}</div>{/if}</div>{/if}
{#if column.key === 'ReferenceRange'}<span class="text-sm font-mono text-gray-600">{formatReferenceRange(row)}</span>{/if}
{#if column.key === 'actions'}<div class="flex justify-center gap-1"><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>{/if}
{#if column.key === 'TestSiteCode'}<span class="font-mono text-sm">{row.TestSiteCode}</span>{/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={typeSelectorOpen} title="Add Test" size="md">
<TestTypeSelector
onselect={handleTypeSelect}
oncancel={() => typeSelectorOpen = false}
/>
</Modal>
<TestModal
bind:open={modalOpen}
mode={modalMode}
bind:formData
{canHaveRefRange}
{canHaveFormula}
{canHaveTechnical}
{isGroupTest}
{disciplineOptions}
departmentOptions={departmentOptions}
availableTests={tests}
{saving}
onsave={handleSave}
oncancel={() => modalOpen = false}
onupdateFormData={(data) => formData = data}
/>
<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. 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>