- 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
432 lines
22 KiB
Svelte
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> |