115 lines
15 KiB
Svelte
Raw Normal View History

<script>
import { onMount, onDestroy } 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 TestModal from './TestModal.svelte';
import { validateNumericRange, validateTholdRange, validateTextRange, validateVsetRange } 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());
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({ TestSiteID: null, TestSiteCode: '', TestSiteName: '', TestType: 'TEST', DisciplineID: null, DepartmentID: null, SeqScr: '0', SeqRpt: '0', VisibleScr: true, VisibleRpt: true, Unit: '', Formula: '', refnum: [], refthold: [], reftxt: [], refvset: [], refRangeType: 'none' });
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 === 'CALC');
const canHaveFormula = $derived(formData.TestType === 'CALC');
const canHaveUnit = $derived(formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC');
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 openCreateModal() { modalMode = 'create'; formData = { TestSiteID: null, TestSiteCode: '', TestSiteName: '', TestType: 'TEST', DisciplineID: null, DepartmentID: null, SeqScr: '0', SeqRpt: '0', VisibleScr: true, VisibleRpt: true, Unit: '', Formula: '', refnum: [], refthold: [], reftxt: [], refvset: [], refRangeType: 'none' }; modalOpen = true; }
function openEditModal(row) { modalMode = 'edit'; let refRangeType = 'none'; if (row.refnum?.length > 0) refRangeType = 'num'; else if (row.refthold?.length > 0) refRangeType = 'thold'; else if (row.reftxt?.length > 0) refRangeType = 'text'; else if (row.refvset?.length > 0) refRangeType = 'vset'; 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 || [], refthold: row.refthold || [], reftxt: row.reftxt || [], refvset: row.refvset || [], refRangeType }; modalOpen = true; }
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; }
if (formData.refRangeType === 'num') { 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 === 'thold') { for (let i = 0; i < formData.refthold.length; i++) { const errors = validateTholdRange(formData.refthold[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; } } }
else if (formData.refRangeType === 'vset') { for (let i = 0; i < formData.refvset.length; i++) { const errors = validateVsetRange(formData.refvset[i], i); if (errors.length > 0) { toastError(errors[0]); return; } } }
saving = true; try { const payload = { ...formData }; if (!canHaveUnit) delete payload.Unit; if (!canHaveFormula) delete payload.Formula; if (!canHaveRefRange) { delete payload.refnum; delete payload.refthold; delete payload.reftxt; delete payload.refvset; } 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-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, panels, and calculated values</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}><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-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-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={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>
<TestModal bind:open={modalOpen} mode={modalMode} bind:formData {canHaveRefRange} {canHaveFormula} {canHaveUnit} {disciplineOptions} departmentOptions={departmentOptions} {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>