feat(organization): add coding system, host app, and host compara sub-pages

- Add new organization sub-pages: codingsys, hostapp, hostcompara
- Update organization API client and main page
- Update Sidebar navigation for organization section
- Remove deprecated backup test files
- Update testmap and tests components
This commit is contained in:
mahdahar 2026-02-27 16:32:55 +07:00
parent 9eef675a52
commit b693f279e8
24 changed files with 2525 additions and 2557 deletions

View File

@ -1,432 +0,0 @@
<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>

View File

@ -1,153 +0,0 @@
<script>
import Modal from '$lib/components/Modal.svelte';
import BasicInfoForm from './test-modal/BasicInfoForm.svelte';
import ReferenceRangeSection from './test-modal/ReferenceRangeSection.svelte';
import TechnicalConfigForm from './test-modal/TechnicalConfigForm.svelte';
import GroupMembersTab from './test-modal/GroupMembersTab.svelte';
/**
* @typedef {Object} Props
* @property {boolean} open - Whether modal is open
* @property {string} mode - 'create' or 'edit'
* @property {Object} formData - Form data object
* @property {boolean} canHaveRefRange - Whether test can have reference ranges
* @property {boolean} canHaveFormula - Whether test can have a formula
* @property {boolean} canHaveTechnical - Whether test can have technical config
* @property {boolean} isGroupTest - Whether test is a group test
* @property {Array<{value: string, label: string}>} disciplineOptions - Discipline dropdown options
* @property {Array<{value: string, label: string}>} departmentOptions - Department dropdown options
* @property {Array} availableTests - Available tests for group member selection
* @property {boolean} [saving] - Whether save is in progress
*/
/** @type {Props & { onsave?: () => void, oncancel?: () => void, onupdateFormData?: (formData: Object) => void }} */
let {
open = $bindable(false),
mode = 'create',
formData = $bindable({}),
canHaveRefRange = false,
canHaveFormula = false,
canHaveTechnical = false,
isGroupTest = false,
disciplineOptions = [],
departmentOptions = [],
availableTests = [],
saving = false,
onsave = () => {},
oncancel = () => {},
onupdateFormData = () => {}
} = $props();
// Local state
let activeTab = $state('basic');
function handleCancel() {
activeTab = 'basic';
oncancel();
}
function handleSave() {
onsave();
}
// Reactive update when modal opens
$effect(() => {
if (open) {
activeTab = 'basic';
}
});
// Get tab count badge for reference range
function getRefRangeCount() {
return (formData.refnum?.length || 0) + (formData.reftxt?.length || 0);
}
// Get tab count badge for group members
function getGroupMemberCount() {
return formData.groupMembers?.length || 0;
}
</script>
<Modal bind:open title={mode === 'create' ? 'Add Test' : 'Edit Test'} size="xl" position="top">
<!-- 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 canHaveTechnical}
<button
type="button"
class="tab tab-lg {activeTab === 'technical' ? 'tab-active' : ''}"
onclick={() => activeTab = 'technical'}
>
Technical
</button>
{/if}
{#if canHaveRefRange}
<button
type="button"
class="tab tab-lg {activeTab === 'refrange' ? 'tab-active' : ''}"
onclick={() => activeTab = 'refrange'}
>
Reference Range
{#if getRefRangeCount() > 0}
<span class="badge badge-sm badge-primary ml-2">{getRefRangeCount()}</span>
{/if}
</button>
{/if}
{#if isGroupTest}
<button
type="button"
class="tab tab-lg {activeTab === 'members' ? 'tab-active' : ''}"
onclick={() => activeTab = 'members'}
>
Group Members
{#if getGroupMemberCount() > 0}
<span class="badge badge-sm badge-primary ml-2">{getGroupMemberCount()}</span>
{/if}
</button>
{/if}
</div>
{#if activeTab === 'basic'}
<BasicInfoForm
bind:formData
{canHaveFormula}
{disciplineOptions}
{departmentOptions}
onsave={handleSave}
/>
{:else if activeTab === 'technical' && canHaveTechnical}
<TechnicalConfigForm
bind:formData
testType={formData.TestType}
{onupdateFormData}
/>
{:else if activeTab === 'refrange' && canHaveRefRange}
<ReferenceRangeSection
bind:formData
testType={formData.TestType}
{onupdateFormData}
/>
{:else if activeTab === 'members' && isGroupTest}
<GroupMembersTab
bind:formData
{availableTests}
{onupdateFormData}
/>
{/if}
{#snippet footer()}
<button class="btn btn-ghost" onclick={handleCancel} 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>

View File

@ -1,119 +0,0 @@
// Reference Range Management Functions
export 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' }
];
export const flagOptions = [
{ value: 'N', label: 'N', description: 'Normal' },
{ value: 'L', label: 'L', description: 'Low' },
{ value: 'H', label: 'H', description: 'High' },
{ value: 'C', label: 'C', description: 'Critical' }
];
export const refTypeOptions = [
{ value: 'REF', label: 'REF', description: 'Reference Range' },
{ value: 'CRTC', label: 'CRTC', description: 'Critical Range' },
{ value: 'VAL', label: 'VAL', description: 'Validation Range' },
{ value: 'RERUN', label: 'RERUN', description: 'Rerun Range' },
{ value: 'THOLD', label: 'THOLD', description: 'Threshold Range' }
];
export const textRefTypeOptions = [
{ value: 'TEXT', label: 'TEXT', description: 'Text Reference' },
{ value: 'VSET', label: 'VSET', description: 'Value Set Reference' }
];
export const sexOptions = [
{ value: '2', label: 'Male' },
{ value: '1', label: 'Female' },
{ value: '0', label: 'Any' }
];
export function createNumRef() {
return {
RefType: 'REF',
Sex: '0',
LowSign: 'GE',
HighSign: 'LE',
Low: null,
High: null,
AgeStart: 0,
AgeEnd: 120,
Flag: 'N',
Interpretation: '',
SpcType: '',
Criteria: ''
};
}
export function createTholdRef() {
return {
RefType: 'THOLD',
Sex: '0',
LowSign: 'GE',
HighSign: 'LE',
Low: null,
High: null,
AgeStart: 0,
AgeEnd: 120,
Flag: 'N',
Interpretation: '',
SpcType: '',
Criteria: ''
};
}
export function createTextRef() {
return {
RefType: 'TEXT',
Sex: '0',
AgeStart: 0,
AgeEnd: 120,
RefTxt: '',
Flag: 'N',
SpcType: '',
Criteria: ''
};
}
export function createVsetRef() {
return {
RefType: 'VSET',
Sex: '0',
AgeStart: 0,
AgeEnd: 120,
RefTxt: '',
Flag: 'N',
SpcType: '',
Criteria: ''
};
}
export 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;
}
// Alias for threshold validation (same logic)
export const validateTholdRange = validateNumericRange;
export 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;
}
// Alias for value set validation (same logic)
export const validateVsetRange = validateTextRange;

View File

@ -1,181 +0,0 @@
<script>
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
/**
* @typedef {Object} Props
* @property {Object} formData - Form data object
* @property {boolean} canHaveFormula - Whether test can have a formula
* @property {Array<{value: string, label: string}>} disciplineOptions - Discipline dropdown options
* @property {Array<{value: string, label: string}>} departmentOptions - Department dropdown options
* @property {() => void} onsave - Save handler
*/
/** @type {Props} */
let {
formData = $bindable({}),
canHaveFormula = false,
disciplineOptions = [],
departmentOptions = [],
onsave = () => {}
} = $props();
const typeLabels = {
TEST: 'Test',
PARAM: 'Parameter',
CALC: 'Calculated',
GROUP: 'Panel'
};
function handleSubmit(e) {
e.preventDefault();
onsave();
}
</script>
<form class="space-y-3" onsubmit={handleSubmit}>
<!-- Test Type Header -->
<div class="bg-base-200 rounded-lg px-4 py-3 mb-4">
<span class="text-sm text-gray-500">Test Type</span>
<div class="font-semibold text-lg">{typeLabels[formData.TestType]}</div>
</div>
<!-- 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 text-sm font-medium">Test Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="testCode"
type="text"
class="input input-sm 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 text-sm font-medium">Test Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="testName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.TestSiteName}
placeholder="e.g., Glucose"
required
/>
</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={departmentOptions}
placeholder="Select department..."
/>
</div>
<!-- Type-specific fields -->
{#if canHaveFormula}
<div class="grid grid-cols-1 md:grid-cols-1 gap-4">
<div class="form-control">
<label class="label" for="formula">
<span class="label-text text-sm 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-xs text-error">*</span>
</label>
<input
id="formula"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Formula}
placeholder="e.g., BUN / Creatinine"
required={canHaveFormula}
/>
<span class="label-text-alt text-xs text-gray-500">Use test codes with operators: +, -, *, /</span>
</div>
</div>
{/if}
<!-- Description -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text text-sm font-medium">Description</span>
</label>
<textarea
id="description"
class="textarea textarea-bordered w-full"
bind:value={formData.Description}
placeholder="Enter test description..."
rows="3"
></textarea>
</div>
<!-- Screen Sequence, Report Sequence, Visibility, and Statistics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label" for="seqScr">
<span class="label-text text-sm font-medium">Screen Sequence</span>
</label>
<input
id="seqScr"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.SeqScr}
placeholder="0"
/>
</div>
<div class="form-control">
<label class="label" for="seqRpt">
<span class="label-text text-sm font-medium">Report Sequence</span>
</label>
<input
id="seqRpt"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.SeqRpt}
placeholder="0"
/>
</div>
<div class="form-control">
<span class="label-text text-sm 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 class="form-control">
<span class="label-text text-sm font-medium mb-2 block">Statistics</span>
<label class="label cursor-pointer gap-2">
<input type="checkbox" class="checkbox" bind:checked={formData.CountStat} />
<span class="label-text">Count in Statistics</span>
</label>
</div>
</div>
</form>

View File

@ -1,158 +0,0 @@
<script>
import { Search, Plus, X, Microscope } from 'lucide-svelte';
/**
* @typedef {Object} Props
* @property {Object} formData - Form data object
* @property {Array} availableTests - Available tests for selection
* @property {(formData: Object) => void} onupdateFormData - Update handler
*/
/** @type {Props} */
let {
formData = $bindable({}),
availableTests = [],
onupdateFormData = () => {}
} = $props();
let searchQuery = $state('');
// Filter out the current test and already selected tests
let filteredTests = $derived(
availableTests.filter(test =>
test.TestSiteID !== formData.TestSiteID &&
!formData.groupMembers?.some(member => member.TestSiteID === test.TestSiteID) &&
(test.TestSiteName.toLowerCase().includes(searchQuery.toLowerCase()) ||
test.TestSiteCode.toLowerCase().includes(searchQuery.toLowerCase()))
)
);
function addMember(test) {
const newMembers = [
...(formData.groupMembers || []),
{
TestSiteID: test.TestSiteID,
TestSiteCode: test.TestSiteCode,
TestSiteName: test.TestSiteName,
TestType: test.TestType
}
];
onupdateFormData({ ...formData, groupMembers: newMembers });
searchQuery = '';
}
function removeMember(index) {
const newMembers = formData.groupMembers.filter((_, i) => i !== index);
onupdateFormData({ ...formData, groupMembers: newMembers });
}
function getTestTypeBadge(testType) {
const badges = {
'TEST': 'badge-primary',
'PARAM': 'badge-secondary',
'CALC': 'badge-accent',
'GROUP': 'badge-info',
'TITLE': 'badge-ghost'
};
return badges[testType] || 'badge-ghost';
}
</script>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 h-[500px]">
<!-- Left: Search for Tests -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4 flex flex-col">
<div class="flex items-center gap-2 mb-4">
<Microscope class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Add Group Members</h3>
</div>
<div class="form-control mb-3">
<div class="relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
class="input input-sm input-bordered w-full pl-10"
bind:value={searchQuery}
placeholder="Search by test name or code..."
/>
</div>
</div>
<div class="flex-1 overflow-y-auto border border-base-200 rounded-lg">
{#if searchQuery}
{#if filteredTests.length === 0}
<div class="p-4 text-center text-gray-500">
No tests found matching "{searchQuery}"
</div>
{:else}
{#each filteredTests as test}
<button
type="button"
class="w-full text-left p-3 hover:bg-base-200 flex items-center justify-between group border-b border-base-200 last:border-0"
onclick={() => addMember(test)}
>
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-gray-600">{test.TestSiteCode}</span>
<span class="font-medium">{test.TestSiteName}</span>
<span class="badge badge-sm {getTestTypeBadge(test.TestType)}">
{test.TestType}
</span>
</div>
<Plus class="w-4 h-4 text-gray-400 group-hover:text-primary" />
</button>
{/each}
{/if}
{:else}
<div class="p-8 text-center text-gray-400">
<Search class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Type to search for tests</p>
</div>
{/if}
</div>
</div>
<!-- Right: Selected Members -->
<div class="bg-base-100 rounded-lg border border-base-200 p-2 flex flex-col">
<div class="flex items-center justify-between mb-2 px-1">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm">Group Members</span>
<span class="badge badge-xs badge-primary">{formData.groupMembers?.length || 0}</span>
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if !formData.groupMembers || formData.groupMembers.length === 0}
<div class="h-full flex flex-col items-center justify-center text-center py-6 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
<Microscope class="w-8 h-8 text-gray-400 mb-1" />
<p class="text-gray-500 text-sm">No members added yet</p>
<p class="text-xs text-gray-400 mt-0.5">Search and add tests from the left</p>
</div>
{:else}
<div class="space-y-1">
{#each formData.groupMembers as member, index (`${member.TestSiteID}-${index}`)}
<div class="flex items-center gap-2 px-2 py-1.5 bg-base-100 border border-base-200 rounded hover:border-primary/50 transition-colors">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs text-gray-600">{member.TestSiteCode}</span>
<span class="text-sm truncate">{member.TestSiteName}</span>
</div>
</div>
<span class="badge badge-xs {getTestTypeBadge(member.TestType)}">
{member.TestType}
</span>
<button
type="button"
class="btn btn-xs btn-ghost text-error p-0 min-h-0 h-6 w-6"
onclick={() => removeMember(index)}
>
<X class="w-3 h-3" />
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>

View File

@ -1,245 +0,0 @@
<script>
import { PlusCircle, Calculator, X, ChevronDown, ChevronUp, Beaker } from 'lucide-svelte';
import { refTypeOptions, createNumRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
* @property {Array} refnum - Numeric reference ranges array
* @property {(refnum: Array) => void} onupdateRefnum - Update handler
*/
/** @type {Props} */
let {
refnum = [],
onupdateRefnum = () => {}
} = $props();
let showAdvanced = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createNumRef();
newRef.Sex = '0';
newRef.Flag = 'N';
onupdateRefnum([...refnum, newRef]);
}
function removeRefRange(index) {
onupdateRefnum(refnum.filter((_, i) => i !== index));
delete showAdvanced[index];
}
function toggleAdvanced(index) {
showAdvanced[index] = !showAdvanced[index];
}
function hasAdvancedData(ref) {
return ref.Interpretation || ref.SpcType || ref.Criteria ||
ref.Sex !== '0' || ref.Flag !== 'N' ||
(ref.AgeStart !== 0 || ref.AgeEnd !== 120);
}
</script>
<div class="space-y-2">
<!-- Header -->
<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>
{#if refnum?.length > 0}
<span class="badge badge-sm badge-ghost">{refnum.length}</span>
{/if}
</div>
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add Range
</button>
</div>
<!-- Empty State -->
{#if 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={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add First Range
</button>
</div>
{/if}
<!-- Range List - Table-like rows -->
{#if refnum?.length > 0}
<div class="border border-base-300 rounded-lg overflow-hidden">
<!-- Table Header -->
<div class="bg-base-200 px-4 py-3 grid grid-cols-12 gap-3 text-xs font-medium text-gray-600 border-b border-base-300">
<div class="col-span-1">#</div>
<div class="col-span-3">Type</div>
<div class="col-span-3">Low</div>
<div class="col-span-3">High</div>
<div class="col-span-2"></div>
</div>
<!-- Table Rows -->
{#each refnum || [] as ref, index (index)}
<div class="border-b border-base-200 last:border-b-0">
<!-- Main Row -->
<div class="px-4 py-3 grid grid-cols-12 gap-3 items-center hover:bg-base-100">
<!-- Row Number -->
<div class="col-span-1 flex items-center gap-1">
<span class="text-xs text-gray-500">{index + 1}</span>
</div>
<!-- Type Dropdown -->
<div class="col-span-3">
<select class="select select-sm select-bordered w-full" bind:value={ref.RefType}>
{#each refTypeOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Low Input -->
<div class="col-span-3">
<input
type="number"
step="0.01"
class="input input-sm input-bordered w-full text-right"
bind:value={ref.Low}
placeholder="-"
/>
</div>
<!-- High Input -->
<div class="col-span-3">
<input
type="number"
step="0.01"
class="input input-sm input-bordered w-full text-right"
bind:value={ref.High}
placeholder="-"
/>
</div>
<!-- More & Delete Buttons -->
<div class="col-span-2 flex items-center justify-end gap-1">
<button
type="button"
class="btn btn-xs btn-ghost btn-square relative"
onclick={() => toggleAdvanced(index)}
title={showAdvanced[index] ? 'Hide details' : 'Show details'}
>
{#if hasAdvancedData(ref)}
<span class="badge badge-xs badge-primary absolute -top-1 -right-1 w-2 h-2 p-0 min-w-0"></span>
{/if}
{#if showAdvanced[index]}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
<button
type="button"
class="btn btn-xs btn-ghost text-error btn-square"
onclick={() => removeRefRange(index)}
title="Remove"
>
<X class="w-4 h-4" />
</button>
</div>
</div>
<!-- Advanced Section (Expanded) -->
{#if showAdvanced[index]}
<div class="px-4 pb-4 pt-2 bg-base-100/50 border-t border-base-200">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Sex -->
<div class="form-control">
<span class="label-text text-xs text-gray-500">Sex</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
<option value="0">Any</option>
<option value="1">Female</option>
<option value="2">Male</option>
</select>
</div>
<!-- Age Range -->
<div class="form-control">
<span class="label-text text-xs text-gray-500">Age Range</span>
<div class="flex items-center gap-2">
<input
type="number"
min="0"
max="120"
class="input input-sm input-bordered w-20 text-right"
bind:value={ref.AgeStart}
/>
<span class="text-gray-400">to</span>
<input
type="number"
min="0"
max="120"
class="input input-sm input-bordered w-20 text-right"
bind:value={ref.AgeEnd}
/>
<span class="text-xs text-gray-400">years</span>
</div>
</div>
<!-- Specimen -->
<div class="form-control md:col-span-2">
<span class="label-text text-xs text-gray-500 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Interpretation -->
<div class="form-control md:col-span-2">
<span class="label-text text-xs text-gray-500">Interpretation</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Interpretation}
placeholder="e.g., Normal fasting glucose range"
/>
</div>
<!-- Criteria -->
<div class="form-control md:col-span-2">
<span class="label-text text-xs text-gray-500">Criteria</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@ -1,285 +0,0 @@
<script>
import { Ruler, Info } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import NumericRefRange from './NumericRefRange.svelte';
import TextRefRange from './TextRefRange.svelte';
import { createNumRef, createTholdRef, createTextRef, createVsetRef } from '../referenceRange.js';
/**
* @typedef {Object} Props
* @property {Object} formData - Form data object
* @property {string} testType - Test type
* @property {(formData: Object) => void} onupdateFormData - Update handler
*/
/** @type {Props} */
let {
formData = $bindable({}),
testType = '',
onupdateFormData = () => {}
} = $props();
// Map refRangeType to RefType labels for display
const refTypeLabels = {
'none': 'None',
'num': 'RANGE - Range',
'thold': 'Threshold (THOLD)',
'text': 'Text (TEXT)',
'vset': 'Value Set (VSET)'
};
// Filter options based on TestType
let refTypeOptions = $derived(() => {
const baseOptions = [
{ value: 'none', label: 'None - No reference range' }
];
if (testType === 'GROUP' || testType === 'TITLE') {
return baseOptions;
}
// TEST and PARAM can have all types
if (testType === 'TEST' || testType === 'PARAM') {
return [
...baseOptions,
{ value: 'num', label: 'RANGE - Range' },
{ value: 'thold', label: 'Threshold (THOLD) - Limit values' },
{ value: 'text', label: 'Text (TEXT) - Descriptive' },
{ value: 'vset', label: 'Value Set (VSET) - Predefined values' }
];
}
// CALC only allows RANGE (num)
if (testType === 'CALC') {
return [
...baseOptions,
{ value: 'num', label: 'RANGE - Range' }
];
}
return baseOptions;
});
// Ensure all reference range items have defined values, never undefined
function normalizeRefNum(ref) {
return {
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 ?? ''
};
}
function normalizeRefTxt(ref) {
return {
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 ?? ''
};
}
// Reactive normalized data - filter by RefType
let allRefnum = $derived((formData.refnum || []).map(normalizeRefNum));
let allReftxt = $derived((formData.reftxt || []).map(normalizeRefTxt));
// Filtered arrays for display
let normalizedRefnum = $derived(allRefnum.filter(ref => ref.RefType !== 'THOLD'));
let normalizedRefthold = $derived(allRefnum.filter(ref => ref.RefType === 'THOLD'));
let normalizedReftxt = $derived(allReftxt.filter(ref => ref.RefType !== 'VSET'));
let normalizedRefvset = $derived(allReftxt.filter(ref => ref.RefType === 'VSET'));
// Sync refRangeType with ResultType
$effect(() => {
const resultType = formData.ResultType;
const currentRefRangeType = formData.refRangeType;
if (testType === 'GROUP' || testType === 'TITLE') {
// GROUP and TITLE should have no reference range
if (currentRefRangeType !== 'none') {
onupdateFormData({ ...formData, refRangeType: 'none', RefType: 'NOREF' });
}
return;
}
// Map ResultType to refRangeType
let expectedRefRangeType = 'none';
if (resultType === 'NMRIC' || resultType === 'RANGE') {
expectedRefRangeType = 'num';
} else if (resultType === 'VSET') {
expectedRefRangeType = 'vset';
} else if (resultType === 'TEXT') {
expectedRefRangeType = 'text';
}
// Auto-sync if they don't match and we're not in the middle of editing
if (expectedRefRangeType !== 'none' && currentRefRangeType !== expectedRefRangeType) {
// Initialize the appropriate reference array if empty
const currentRefnum = formData.refnum || [];
const currentReftxt = formData.reftxt || [];
if (expectedRefRangeType === 'num' && normalizedRefnum.length === 0) {
onupdateFormData({
...formData,
refRangeType: expectedRefRangeType,
RefType: 'RANGE',
refnum: [...currentRefnum, createNumRef()]
});
} else if (expectedRefRangeType === 'vset' && normalizedRefvset.length === 0) {
onupdateFormData({
...formData,
refRangeType: expectedRefRangeType,
RefType: 'VSET',
reftxt: [...currentReftxt, createVsetRef()]
});
} else if (expectedRefRangeType === 'text' && normalizedReftxt.length === 0) {
onupdateFormData({
...formData,
refRangeType: expectedRefRangeType,
RefType: 'TEXT',
reftxt: [...currentReftxt, createTextRef()]
});
} else {
onupdateFormData({ ...formData, refRangeType: expectedRefRangeType });
}
}
});
function updateRefRangeType(type) {
// Initialize arrays if they don't exist
const currentRefnum = formData.refnum || [];
const currentReftxt = formData.reftxt || [];
if (type === 'num') {
// Add numeric range to refnum
onupdateFormData({
...formData,
refRangeType: type,
RefType: 'NMRC',
refnum: [...currentRefnum, createNumRef()]
});
} else if (type === 'thold') {
// Add threshold range to refnum
onupdateFormData({
...formData,
refRangeType: type,
RefType: 'THOLD',
refnum: [...currentRefnum, createTholdRef()]
});
} else if (type === 'text') {
// Add text range to reftxt
onupdateFormData({
...formData,
refRangeType: type,
RefType: 'TEXT',
reftxt: [...currentReftxt, createTextRef()]
});
} else if (type === 'vset') {
// Add value set range to reftxt
onupdateFormData({
...formData,
refRangeType: type,
RefType: 'VSET',
reftxt: [...currentReftxt, createVsetRef()]
});
} else {
// None selected
onupdateFormData({
...formData,
refRangeType: type,
RefType: ''
});
}
}
function updateRefnum(refnum) {
onupdateFormData({ ...formData, refnum });
}
function updateRefthold(refthold) {
// Merge thold items back into refnum
const nonThold = (formData.refnum || []).filter(ref => ref.RefType !== 'THOLD');
onupdateFormData({ ...formData, refnum: [...nonThold, ...refthold] });
}
function updateReftxt(reftxt) {
onupdateFormData({ ...formData, reftxt });
}
function updateRefvset(refvset) {
// Merge vset items back into reftxt
const nonVset = (formData.reftxt || []).filter(ref => ref.RefType !== 'VSET');
onupdateFormData({ ...formData, reftxt: [...nonVset, ...refvset] });
}
</script>
<div class="space-y-3">
<!-- 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."
title="Reference Range Help"
/>
</div>
<!-- Dropdown Select -->
<div class="form-control">
<select
class="select select-bordered w-full"
value={formData.refRangeType || 'none'}
onchange={(e) => updateRefRangeType(e.target.value)}
disabled={testType === 'GROUP' || testType === 'TITLE'}
>
{#each refTypeOptions() as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Show selected RefType info -->
{#if formData.refRangeType && formData.refRangeType !== 'none'}
<div class="mt-3 flex items-center gap-2 p-2 bg-info/10 rounded-lg border border-info/20">
<Info class="w-4 h-4 text-info" />
<span class="text-sm">
<span class="font-medium">Selected:</span>
{refTypeLabels[formData.refRangeType]}
</span>
</div>
{/if}
</div>
<!-- Numeric Reference Ranges -->
{#if formData.refRangeType === 'num'}
<NumericRefRange refnum={normalizedRefnum} onupdateRefnum={updateRefnum} />
{/if}
<!-- Threshold Reference Ranges (uses same component as numeric) -->
{#if formData.refRangeType === 'thold'}
<NumericRefRange refnum={normalizedRefthold} onupdateRefnum={updateRefthold} />
{/if}
<!-- Text Reference Ranges -->
{#if formData.refRangeType === 'text'}
<TextRefRange reftxt={normalizedReftxt} onupdateReftxt={updateReftxt} />
{/if}
<!-- Value Set Reference Ranges (uses same component as text) -->
{#if formData.refRangeType === 'vset'}
<TextRefRange reftxt={normalizedRefvset} onupdateReftxt={updateRefvset} />
{/if}
</div>

View File

@ -1,277 +0,0 @@
<script>
import { onMount } from 'svelte';
import { FlaskConical, Ruler, Clock, Beaker } from 'lucide-svelte';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
/**
* @typedef {Object} Props
* @property {Object} formData - Form data object
* @property {string} testType - Test type (TEST, PARAM, CALC, GROUP, TITLE)
* @property {(formData: Object) => void} onupdateFormData - Update handler
*/
/** @type {Props} */
let {
formData = $bindable({}),
testType = '',
onupdateFormData = () => {}
} = $props();
// Value set options
let resultTypeOptions = $state([]);
let loading = $state(true);
onMount(async () => {
try {
loading = true;
const resultTypeRes = await fetchValueSetByKey('result_type');
console.log('result_type response:', resultTypeRes);
// Handle different response structures
const resultItems = resultTypeRes.data?.items || resultTypeRes.data?.ValueSetItems || (Array.isArray(resultTypeRes.data) ? resultTypeRes.data : []) || [];
resultTypeOptions = resultItems
.map(item => ({
value: item.value || item.itemCode || item.ItemCode || item.code || item.Code,
label: item.label || item.itemValue || item.ItemValue || item.value || item.Value || item.description || item.Description
}))
.filter(opt => opt.value)
.filter(opt => {
// Filter ResultType based on TestType
if (testType === 'CALC') {
// CALC only allows NMRIC
return opt.value === 'NMRIC';
} else if (testType === 'GROUP' || testType === 'TITLE') {
// GROUP and TITLE only allow NORES
return opt.value === 'NORES';
} else if (testType === 'TEST' || testType === 'PARAM') {
// TEST and PARAM allow NMRIC, RANGE, TEXT, VSET
return ['NMRIC', 'RANGE', 'TEXT', 'VSET'].includes(opt.value);
}
return true;
});
console.log('resultTypeOptions:', resultTypeOptions);
} catch (err) {
console.error('Failed to load value sets:', err);
} finally {
loading = false;
}
});
function updateField(field, value) {
onupdateFormData({ ...formData, [field]: value });
}
function handleResultTypeChange(value) {
// Update ResultType and set appropriate RefType
let newRefType = formData.RefType;
if (value === 'NMRIC' || value === 'RANGE') {
newRefType = 'RANGE';
} else if (value === 'VSET') {
newRefType = 'VSET';
} else if (value === 'TEXT') {
newRefType = 'TEXT';
} else if (value === 'NORES') {
newRefType = 'NOREF';
}
onupdateFormData({ ...formData, ResultType: value, RefType: newRefType });
}
// Check if test is calculated type (doesn't have specimen requirements)
const isCalculated = $derived(formData.TestType === 'CALC');
</script>
{#if loading}
<div class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="space-y-3">
<!-- Result Configuration -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<FlaskConical class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Result Configuration</h3>
</div>
<div class="form-control max-w-md">
<label class="label" for="resultType">
<span class="label-text text-sm font-medium">Result Type</span>
</label>
<select
id="resultType"
class="select select-sm select-bordered w-full"
bind:value={formData.ResultType}
onchange={(e) => handleResultTypeChange(e.target.value)}
disabled={testType === 'GROUP' || testType === 'TITLE'}
>
{#if testType !== 'GROUP' && testType !== 'TITLE'}
<option value="">Select result type...</option>
{/if}
{#each resultTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
<!-- Units and Precision -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<Ruler class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Units and Precision</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label" for="unit1">
<span class="label-text text-sm font-medium">Unit 1</span>
</label>
<input
id="unit1"
type="text"
class="input input-sm input-bordered w-full"
value={formData.Unit1}
oninput={(e) => updateField('Unit1', e.target.value)}
placeholder="e.g., mg/dL"
/>
</div>
<div class="form-control">
<label class="label" for="factor">
<span class="label-text text-sm font-medium">Factor</span>
</label>
<input
id="factor"
type="number"
step="0.000001"
class="input input-sm input-bordered w-full"
value={formData.Factor}
oninput={(e) => updateField('Factor', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="Conversion factor"
/>
</div>
<div class="form-control">
<label class="label" for="unit2">
<span class="label-text text-sm font-medium">Unit 2</span>
</label>
<input
id="unit2"
type="text"
class="input input-sm input-bordered w-full"
value={formData.Unit2}
oninput={(e) => updateField('Unit2', e.target.value)}
placeholder="e.g., mmol/L"
/>
</div>
<div class="form-control">
<label class="label" for="decimal">
<span class="label-text text-sm font-medium">Decimal Places</span>
</label>
<input
id="decimal"
type="number"
min="0"
max="6"
class="input input-sm input-bordered w-full"
value={formData.Decimal}
oninput={(e) => updateField('Decimal', parseInt(e.target.value) || 0)}
placeholder="0-6"
/>
</div>
</div>
{#if formData.Factor}
<div class="mt-3 text-sm text-gray-600 bg-base-200 p-2 rounded">
Formula: {formData.Unit1 || 'Unit1'} × {formData.Factor} = {formData.Unit2 || 'Unit2'}
</div>
{/if}
</div>
<!-- Specimen Requirements -->
{#if !isCalculated}
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<Beaker class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Specimen Requirements</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="reqQty">
<span class="label-text text-sm font-medium">Required Quantity</span>
</label>
<input
id="reqQty"
type="number"
step="0.01"
class="input input-sm input-bordered w-full"
value={formData.ReqQty}
oninput={(e) => updateField('ReqQty', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="Amount"
/>
</div>
<div class="form-control">
<label class="label" for="reqQtyUnit">
<span class="label-text text-sm font-medium">Quantity Unit</span>
</label>
<input
id="reqQtyUnit"
type="text"
class="input input-sm input-bordered w-full"
value={formData.ReqQtyUnit}
oninput={(e) => updateField('ReqQtyUnit', e.target.value)}
placeholder="e.g., mL"
/>
</div>
</div>
</div>
{/if}
<!-- Method and TAT -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<Clock class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Method and Turnaround</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="method">
<span class="label-text text-sm font-medium">Method</span>
</label>
<input
id="method"
type="text"
class="input input-sm input-bordered w-full"
value={formData.Method}
oninput={(e) => updateField('Method', e.target.value)}
placeholder="e.g., Enzymatic"
/>
</div>
<div class="form-control">
<label class="label" for="expectedTAT">
<span class="label-text text-sm font-medium">Expected TAT (minutes)</span>
</label>
<input
id="expectedTAT"
type="number"
min="0"
class="input input-sm input-bordered w-full"
value={formData.ExpectedTAT}
oninput={(e) => updateField('ExpectedTAT', e.target.value ? parseInt(e.target.value) : null)}
placeholder="e.g., 60"
/>
</div>
</div>
</div>
</div>
{/if}

View File

@ -1,43 +0,0 @@
<script>
import { Microscope, Variable, Calculator, Box } from 'lucide-svelte';
/**
* @typedef {Object} Props
* @property {(type: string) => void} onselect - Selection handler
* @property {() => void} oncancel - Cancel handler
*/
/** @type {Props} */
let {
onselect = () => {},
oncancel = () => {}
} = $props();
const types = [
{ value: 'TEST', label: 'Single Test', icon: Microscope, color: 'text-primary', bg: 'bg-primary/10' },
{ value: 'PARAM', label: 'Parameter', icon: Variable, color: 'text-secondary', bg: 'bg-secondary/10' },
{ value: 'CALC', label: 'Calculated', icon: Calculator, color: 'text-accent', bg: 'bg-accent/10' },
{ value: 'GROUP', label: 'Panel', icon: Box, color: 'text-info', bg: 'bg-info/10' }
];
</script>
<div class="space-y-4">
<p class="text-center text-gray-600">Select test type to create</p>
<div class="grid grid-cols-2 gap-4">
{#each types as type}
<button
type="button"
class="card bg-base-100 border border-base-200 hover:border-primary hover:shadow-md transition-all p-6 flex flex-col items-center gap-3"
onclick={() => onselect(type.value)}
>
<div class="w-12 h-12 rounded-full {type.bg} flex items-center justify-center">
<svelte:component this={type.icon} class="w-6 h-6 {type.color}" />
</div>
<span class="font-medium">{type.label}</span>
</button>
{/each}
</div>
<div class="flex justify-center pt-2">
<button class="btn btn-ghost btn-sm" onclick={oncancel} type="button">Cancel</button>
</div>
</div>

View File

@ -1,173 +0,0 @@
<script>
import { PlusCircle, FileText, X, ChevronDown, ChevronUp, Beaker, Filter } from 'lucide-svelte';
import { sexOptions, createTextRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
* @property {Array} reftxt - Text reference ranges array
* @property {(reftxt: Array) => void} onupdateReftxt - Update handler
*/
/** @type {Props} */
let {
reftxt = [],
onupdateReftxt = () => {}
} = $props();
let expandedRanges = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createTextRef();
onupdateReftxt([...reftxt, newRef]);
expandedRanges[reftxt.length] = { specimen: false };
}
function removeRefRange(index) {
onupdateReftxt(reftxt.filter((_, i) => i !== index));
delete expandedRanges[index];
}
function toggleSpecimenExpand(index) {
expandedRanges[index] = { ...expandedRanges[index], specimen: !expandedRanges[index]?.specimen };
}
</script>
<div class="space-y-3">
<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>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add Range
</button>
</div>
{#if 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={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add First Range
</button>
</div>
{/if}
{#each reftxt || [] as ref, index (index)}
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
<div class="card-body p-4">
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
<X class="w-4 h-4" />
Remove
</button>
</div>
<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">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</span>
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
</div>
<div class="form-control">
<span class="label-text text-xs mb-1">Age To</span>
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
</div>
<div class="form-control">
<span class="label-text text-xs mb-1">Flag</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
<option value="N">N - Normal</option>
<option value="A">A - Abnormal</option>
</select>
</div>
</div>
<div class="form-control mt-3">
<span class="label-text text-xs mb-1">Reference Text</span>
<textarea class="textarea textarea-bordered w-full" rows="2" bind:value={ref.RefTxt} placeholder="e.g., Negative for glucose"></textarea>
</div>
<!-- Expandable: Specimen and Criteria -->
<div class="border border-base-200 rounded-lg overflow-hidden mt-3">
<button
type="button"
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
onclick={() => toggleSpecimenExpand(index)}
>
<span class="flex items-center gap-2">
<Beaker class="w-3 h-3" />
<span class="text-xs">Specimen & Criteria</span>
{#if ref.SpcType || ref.Criteria}
<span class="text-xs text-primary">(Custom)</span>
{:else}
<span class="text-xs text-gray-500">(Optional)</span>
{/if}
</span>
{#if expandedRanges[index]?.specimen}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
{#if expandedRanges[index]?.specimen}
<div class="p-3 bg-base-100 space-y-3">
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Filter class="w-3 h-3" />
Criteria
</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>

View File

@ -1,200 +0,0 @@
<script>
import { PlusCircle, Ruler, X, ChevronDown, ChevronUp, Beaker, Filter } from 'lucide-svelte';
import { signOptions, flagOptions, sexOptions, createTholdRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
* @property {Array} refthold - Threshold reference ranges array
* @property {(refthold: Array) => void} onupdateRefthold - Update handler
*/
/** @type {Props} */
let {
refthold = [],
onupdateRefthold = () => {}
} = $props();
let expandedRanges = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createTholdRef();
onupdateRefthold([...refthold, newRef]);
expandedRanges[refthold.length] = { specimen: false };
}
function removeRefRange(index) {
onupdateRefthold(refthold.filter((_, i) => i !== index));
delete expandedRanges[index];
}
function toggleSpecimenExpand(index) {
expandedRanges[index] = { ...expandedRanges[index], specimen: !expandedRanges[index]?.specimen };
}
</script>
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<Ruler class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Threshold Reference Ranges</h3>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add Range
</button>
</div>
{#if refthold?.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
<Ruler class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-gray-500">No threshold ranges defined</p>
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add First Range
</button>
</div>
{/if}
{#each refthold || [] as ref, index (index)}
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
<div class="card-body p-4">
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
<X class="w-4 h-4" />
Remove
</button>
</div>
<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">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</span>
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
</div>
<div class="form-control">
<span class="label-text text-xs mb-1">Age To</span>
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
</div>
<div class="form-control">
<span class="label-text text-xs mb-1">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>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
<div class="form-control">
<span class="label-text text-xs mb-1">Lower Value</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., 5" />
</div>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1">Upper Value</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., 10" />
</div>
</div>
</div>
<div class="form-control mt-3">
<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., Alert threshold" />
</div>
<!-- Expandable: Specimen and Criteria -->
<div class="border border-base-200 rounded-lg overflow-hidden mt-3">
<button
type="button"
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
onclick={() => toggleSpecimenExpand(index)}
>
<span class="flex items-center gap-2">
<Beaker class="w-3 h-3" />
<span class="text-xs">Specimen & Criteria</span>
{#if ref.SpcType || ref.Criteria}
<span class="text-xs text-primary">(Custom)</span>
{:else}
<span class="text-xs text-gray-500">(Optional)</span>
{/if}
</span>
{#if expandedRanges[index]?.specimen}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
{#if expandedRanges[index]?.specimen}
<div class="p-3 bg-base-100 space-y-3">
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Filter class="w-3 h-3" />
Criteria
</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>

View File

@ -1,174 +0,0 @@
<script>
import { PlusCircle, Box, X, ChevronDown, ChevronUp, Beaker, Filter } from 'lucide-svelte';
import { sexOptions, createVsetRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
* @property {Array} refvset - Value set reference ranges array
* @property {(refvset: Array) => void} onupdateRefvset - Update handler
*/
/** @type {Props} */
let {
refvset = [],
onupdateRefvset = () => {}
} = $props();
let expandedRanges = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createVsetRef();
onupdateRefvset([...refvset, newRef]);
expandedRanges[refvset.length] = { specimen: false };
}
function removeRefRange(index) {
onupdateRefvset(refvset.filter((_, i) => i !== index));
delete expandedRanges[index];
}
function toggleSpecimenExpand(index) {
expandedRanges[index] = { ...expandedRanges[index], specimen: !expandedRanges[index]?.specimen };
}
</script>
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<Box class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Value Set Reference Ranges</h3>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add Range
</button>
</div>
{#if refvset?.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
<Box class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-gray-500">No value set ranges defined</p>
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
<PlusCircle class="w-4 h-4 mr-1" />
Add First Range
</button>
</div>
{/if}
{#each refvset || [] as ref, index (index)}
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
<div class="card-body p-4">
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
<X class="w-4 h-4" />
Remove
</button>
</div>
<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">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</span>
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
</div>
<div class="form-control">
<span class="label-text text-xs mb-1">Age To</span>
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
</div>
<div class="form-control">
<span class="label-text text-xs mb-1">Flag</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
<option value="N">N - Normal</option>
<option value="A">A - Abnormal</option>
</select>
</div>
</div>
<div class="form-control mt-3">
<span class="label-text text-xs mb-1">Value Set</span>
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.RefTxt} placeholder="e.g., Positive, Negative, Borderline" />
<span class="label-text-alt text-xs text-gray-500 mt-1">Comma-separated list of allowed values</span>
</div>
<!-- Expandable: Specimen and Criteria -->
<div class="border border-base-200 rounded-lg overflow-hidden mt-3">
<button
type="button"
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
onclick={() => toggleSpecimenExpand(index)}
>
<span class="flex items-center gap-2">
<Beaker class="w-3 h-3" />
<span class="text-xs">Specimen & Criteria</span>
{#if ref.SpcType || ref.Criteria}
<span class="text-xs text-primary">(Custom)</span>
{:else}
<span class="text-xs text-gray-500">(Optional)</span>
{/if}
</span>
{#if expandedRanges[index]?.specimen}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
{#if expandedRanges[index]?.specimen}
<div class="p-3 bg-base-100 space-y-3">
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Filter class="w-3 h-3" />
Criteria
</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>

707
docs/organization.yaml Normal file
View File

@ -0,0 +1,707 @@
/api/organization/account/{id}:
get:
tags: [Organization]
summary: Get account by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Account details
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/Account'
/api/organization/site:
get:
tags: [Organization]
summary: List sites
security:
- bearerAuth: []
responses:
'200':
description: List of sites
post:
tags: [Organization]
summary: Create site
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/Site'
responses:
'201':
description: Site created
patch:
tags: [Organization]
summary: Update site
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
SiteName:
type: string
SiteCode:
type: string
AccountID:
type: integer
responses:
'200':
description: Site updated
delete:
tags: [Organization]
summary: Delete site
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
responses:
'200':
description: Site deleted
/api/organization/site/{id}:
get:
tags: [Organization]
summary: Get site by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Site details
/api/organization/discipline:
get:
tags: [Organization]
summary: List disciplines
security:
- bearerAuth: []
responses:
'200':
description: List of disciplines
post:
tags: [Organization]
summary: Create discipline
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/Discipline'
responses:
'201':
description: Discipline created
patch:
tags: [Organization]
summary: Update discipline
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
DisciplineName:
type: string
DisciplineCode:
type: string
responses:
'200':
description: Discipline updated
delete:
tags: [Organization]
summary: Delete discipline
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
responses:
'200':
description: Discipline deleted
/api/organization/discipline/{id}:
get:
tags: [Organization]
summary: Get discipline by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Discipline details
/api/organization/department:
get:
tags: [Organization]
summary: List departments
security:
- bearerAuth: []
responses:
'200':
description: List of departments
post:
tags: [Organization]
summary: Create department
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/Department'
responses:
'201':
description: Department created
patch:
tags: [Organization]
summary: Update department
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
DeptName:
type: string
DeptCode:
type: string
SiteID:
type: integer
responses:
'200':
description: Department updated
delete:
tags: [Organization]
summary: Delete department
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
responses:
'200':
description: Department deleted
/api/organization/department/{id}:
get:
tags: [Organization]
summary: Get department by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Department details
/api/organization/workstation:
get:
tags: [Organization]
summary: List workstations
security:
- bearerAuth: []
responses:
'200':
description: List of workstations
post:
tags: [Organization]
summary: Create workstation
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/Workstation'
responses:
'201':
description: Workstation created
patch:
tags: [Organization]
summary: Update workstation
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
WorkstationName:
type: string
WorkstationCode:
type: string
SiteID:
type: integer
DepartmentID:
type: integer
responses:
'200':
description: Workstation updated
delete:
tags: [Organization]
summary: Delete workstation
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
responses:
'200':
description: Workstation deleted
/api/organization/workstation/{id}:
get:
tags: [Organization]
summary: Get workstation by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Workstation details
# HostApp
/api/organization/hostapp:
get:
tags: [Organization]
summary: List host applications
security:
- bearerAuth: []
parameters:
- name: HostAppID
in: query
schema:
type: string
- name: HostAppName
in: query
schema:
type: string
responses:
'200':
description: List of host applications
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/organization.yaml#/HostApp'
post:
tags: [Organization]
summary: Create host application
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/HostApp'
responses:
'201':
description: Host application created
patch:
tags: [Organization]
summary: Update host application
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- HostAppID
properties:
HostAppID:
type: string
HostAppName:
type: string
SiteID:
type: integer
responses:
'200':
description: Host application updated
delete:
tags: [Organization]
summary: Delete host application (soft delete)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- HostAppID
properties:
HostAppID:
type: string
responses:
'200':
description: Host application deleted
/api/organization/hostapp/{id}:
get:
tags: [Organization]
summary: Get host application by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Host application details
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/HostApp'
# HostComPara
/api/organization/hostcompara:
get:
tags: [Organization]
summary: List host communication parameters
security:
- bearerAuth: []
parameters:
- name: HostAppID
in: query
schema:
type: string
- name: HostIP
in: query
schema:
type: string
responses:
'200':
description: List of host communication parameters
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/organization.yaml#/HostComPara'
post:
tags: [Organization]
summary: Create host communication parameters
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/HostComPara'
responses:
'201':
description: Host communication parameters created
patch:
tags: [Organization]
summary: Update host communication parameters
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- HostAppID
properties:
HostAppID:
type: string
HostIP:
type: string
HostPort:
type: string
HostPwd:
type: string
responses:
'200':
description: Host communication parameters updated
delete:
tags: [Organization]
summary: Delete host communication parameters (soft delete)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- HostAppID
properties:
HostAppID:
type: string
responses:
'200':
description: Host communication parameters deleted
/api/organization/hostcompara/{id}:
get:
tags: [Organization]
summary: Get host communication parameters by HostAppID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Host communication parameters details
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/HostComPara'
# CodingSys
/api/organization/codingsys:
get:
tags: [Organization]
summary: List coding systems
security:
- bearerAuth: []
parameters:
- name: CodingSysAbb
in: query
schema:
type: string
- name: FullText
in: query
schema:
type: string
responses:
'200':
description: List of coding systems
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/organization.yaml#/CodingSys'
post:
tags: [Organization]
summary: Create coding system
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/CodingSys'
responses:
'201':
description: Coding system created
patch:
tags: [Organization]
summary: Update coding system
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- CodingSysID
properties:
CodingSysID:
type: integer
CodingSysAbb:
type: string
FullText:
type: string
Description:
type: string
responses:
'200':
description: Coding system updated
delete:
tags: [Organization]
summary: Delete coding system (soft delete)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- CodingSysID
properties:
CodingSysID:
type: integer
responses:
'200':
description: Coding system deleted
/api/organization/codingsys/{id}:
get:
tags: [Organization]
summary: Get coding system by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Coding system details
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/CodingSys'

View File

@ -65,3 +65,143 @@ export async function updateDepartment(data) {
export async function deleteDepartment(id) {
return del('/api/organization/department', { id });
}
// Sites
export async function fetchSites(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/organization/site?${query}` : '/api/organization/site');
}
export async function fetchSite(id) {
return get(`/api/organization/site/${id}`);
}
export async function createSite(data) {
const payload = {
SiteCode: data.SiteCode,
SiteName: data.SiteName,
};
return post('/api/organization/site', payload);
}
export async function updateSite(data) {
const payload = {
id: data.SiteID,
SiteCode: data.SiteCode,
SiteName: data.SiteName,
};
return patch('/api/organization/site', payload);
}
export async function deleteSite(id) {
return del('/api/organization/site', { id });
}
// Workstations
export async function fetchWorkstations(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/organization/workstation?${query}` : '/api/organization/workstation');
}
export async function fetchWorkstation(id) {
return get(`/api/organization/workstation/${id}`);
}
// HostApps
export async function fetchHostApps(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/organization/hostapp?${query}` : '/api/organization/hostapp');
}
export async function fetchHostApp(id) {
return get(`/api/organization/hostapp/${id}`);
}
export async function createHostApp(data) {
const payload = {
HostAppName: data.HostAppName,
SiteID: data.SiteID,
};
return post('/api/organization/hostapp', payload);
}
export async function updateHostApp(data) {
const payload = {
id: data.HostAppID,
HostAppName: data.HostAppName,
SiteID: data.SiteID,
};
return patch('/api/organization/hostapp', payload);
}
export async function deleteHostApp(id) {
return del('/api/organization/hostapp', { id });
}
// HostComParas
export async function fetchHostComParas(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/organization/hostcompara?${query}` : '/api/organization/hostcompara');
}
export async function fetchHostComPara(id) {
return get(`/api/organization/hostcompara/${id}`);
}
export async function createHostComPara(data) {
const payload = {
HostAppID: data.HostAppID,
HostIP: data.HostIP,
HostPort: data.HostPort,
HostPwd: data.HostPwd,
};
return post('/api/organization/hostcompara', payload);
}
export async function updateHostComPara(data) {
const payload = {
id: data.HostComParaID,
HostAppID: data.HostAppID,
HostIP: data.HostIP,
HostPort: data.HostPort,
HostPwd: data.HostPwd,
};
return patch('/api/organization/hostcompara', payload);
}
export async function deleteHostComPara(id) {
return del('/api/organization/hostcompara', { id });
}
// CodingSystems
export async function fetchCodingSystems(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/organization/codingsys?${query}` : '/api/organization/codingsys');
}
export async function fetchCodingSystem(id) {
return get(`/api/organization/codingsys/${id}`);
}
export async function createCodingSystem(data) {
const payload = {
CodingSysAbb: data.CodingSysAbb,
FullText: data.FullText,
Description: data.Description,
};
return post('/api/organization/codingsys', payload);
}
export async function updateCodingSystem(data) {
const payload = {
id: data.CodingSysID,
CodingSysAbb: data.CodingSysAbb,
FullText: data.FullText,
Description: data.Description,
};
return patch('/api/organization/codingsys', payload);
}
export async function deleteCodingSystem(id) {
return del('/api/organization/codingsys', { id });
}

View File

@ -24,7 +24,10 @@ import {
LandPlot,
Monitor,
Activity,
User
User,
Server,
Network,
FileCode
} from 'lucide-svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
@ -257,6 +260,9 @@ function toggleLaboratory() {
<li><a href="/master-data/organization/discipline" class="submenu-link"><Building2 size={16} /> Discipline</a></li>
<li><a href="/master-data/organization/workstation" class="submenu-link"><Monitor size={16} /> Workstation</a></li>
<li><a href="/master-data/organization/instrument" class="submenu-link"><Activity size={16} /> Instrument</a></li>
<li><a href="/master-data/organization/hostapp" class="submenu-link"><Server size={16} /> Host Application</a></li>
<li><a href="/master-data/organization/hostcompara" class="submenu-link"><Network size={16} /> Host Connection</a></li>
<li><a href="/master-data/organization/codingsys" class="submenu-link"><FileCode size={16} /> Coding System</a></li>
</ul>
{/if}
</li>

View File

@ -6,7 +6,10 @@
Users,
Building2,
Monitor,
Activity
Activity,
Server,
Network,
FileCode
} from 'lucide-svelte';
import { ArrowLeft } from 'lucide-svelte';
@ -53,6 +56,27 @@
href: '/master-data/organization/instrument',
color: 'bg-orange-500',
},
{
title: 'Host Application',
description: 'Manage host applications and systems',
icon: Server,
href: '/master-data/organization/hostapp',
color: 'bg-rose-500',
},
{
title: 'Host Connection',
description: 'Manage host connection parameters',
icon: Network,
href: '/master-data/organization/hostcompara',
color: 'bg-teal-500',
},
{
title: 'Coding System',
description: 'Manage coding systems and terminologies',
icon: FileCode,
href: '/master-data/organization/codingsys',
color: 'bg-amber-500',
},
];
</script>

View File

@ -0,0 +1,296 @@
<script>
import { onMount } from 'svelte';
import {
fetchCodingSystems,
createCodingSystem,
updateCodingSystem,
deleteCodingSystem,
} 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 { Plus, Edit2, Trash2, ArrowLeft, Search, FileCode } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
CodingSysID: null,
CodingSysAbb: '',
FullText: '',
Description: '',
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'CodingSysAbb', label: 'Abbreviation', class: 'font-medium w-32' },
{ key: 'FullText', label: 'Full Text' },
{ key: 'Description', label: 'Description', class: 'text-gray-600' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadItems();
});
async function loadItems() {
loading = true;
try {
const response = await fetchCodingSystems();
items = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load coding systems');
items = [];
} finally {
loading = false;
}
}
const filteredItems = $derived(
items.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.CodingSysAbb && item.CodingSysAbb.toLowerCase().includes(query)) ||
(item.FullText && item.FullText.toLowerCase().includes(query)) ||
(item.Description && item.Description.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { CodingSysID: null, CodingSysAbb: '', FullText: '', Description: '' };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
CodingSysID: row.CodingSysID,
CodingSysAbb: row.CodingSysAbb,
FullText: row.FullText,
Description: row.Description || '',
};
modalOpen = true;
}
async function handleSave() {
if (!formData.CodingSysAbb.trim()) {
toastError('Abbreviation is required');
return;
}
if (!formData.FullText.trim()) {
toastError('Full text is required');
return;
}
saving = true;
try {
if (modalMode === 'create') {
await createCodingSystem(formData);
toastSuccess('Coding system created successfully');
} else {
await updateCodingSystem(formData);
toastSuccess('Coding system updated successfully');
}
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save coding system');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteCodingSystem(deleteItem.CodingSysID);
toastSuccess('Coding system deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete coding system');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" 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">Coding Systems</h1>
<p class="text-sm text-gray-600">Manage coding systems and terminologies</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Coding System
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by abbreviation, text or description..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<FileCode class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No coding systems found' : 'No coding systems yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No coding systems match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first coding system.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Coding System
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No coding systems found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.CodingSysAbb}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.CodingSysAbb}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Coding System' : 'Edit Coding System'} size="md">
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="codingSysAbb">
<span class="label-text text-sm font-medium">Abbreviation</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="codingSysAbb"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.CodingSysAbb}
placeholder="e.g., LOINC"
required
/>
<span class="label-text-alt text-xs text-gray-500">Short code for this coding system</span>
</div>
<div class="form-control">
<label class="label" for="fullText">
<span class="label-text text-sm font-medium">Full Text</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="fullText"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.FullText}
placeholder="e.g., Logical Observation Identifiers Names and Codes"
required
/>
<span class="label-text-alt text-xs text-gray-500">Complete name of the coding system</span>
</div>
</div>
<div class="form-control">
<label class="label" for="description">
<span class="label-text text-sm font-medium">Description</span>
</label>
<textarea
id="description"
class="textarea textarea-bordered w-full"
bind:value={formData.Description}
placeholder="Enter description..."
rows="3"
></textarea>
<span class="label-text-alt text-xs text-gray-500">Additional information about this coding system</span>
</div>
</form>
{#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={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete the following coding system?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.CodingSysAbb}</p>
<p class="text-sm text-base-content/60">{deleteItem?.FullText}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} 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>

View File

@ -0,0 +1,298 @@
<script>
import { onMount } from 'svelte';
import {
fetchHostApps,
createHostApp,
updateHostApp,
deleteHostApp,
fetchSites,
} 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 { Plus, Edit2, Trash2, ArrowLeft, Search, Server } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let sites = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
HostAppID: null,
HostAppName: '',
SiteID: null,
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'HostAppName', label: 'Name', class: 'font-medium' },
{ key: 'SiteName', label: 'Site', class: 'w-48' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadSites();
await loadItems();
});
async function loadSites() {
try {
const response = await fetchSites();
sites = Array.isArray(response.data) ? response.data : [];
} catch (err) {
sites = [];
}
}
async function loadItems() {
loading = true;
try {
const response = await fetchHostApps();
items = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load host applications');
items = [];
} finally {
loading = false;
}
}
const itemsWithSiteName = $derived(
items.map((item) => ({
...item,
SiteName: sites.find((s) => s.SiteID === item.SiteID)?.SiteName || '-',
}))
);
const filteredItems = $derived(
itemsWithSiteName.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.HostAppName && item.HostAppName.toLowerCase().includes(query)) ||
(item.SiteName && item.SiteName.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { HostAppID: null, HostAppName: '', SiteID: null };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
HostAppID: row.HostAppID,
HostAppName: row.HostAppName,
SiteID: row.SiteID,
};
modalOpen = true;
}
async function handleSave() {
if (!formData.HostAppName.trim()) {
toastError('Host application name is required');
return;
}
if (!formData.SiteID) {
toastError('Site is required');
return;
}
saving = true;
try {
if (modalMode === 'create') {
await createHostApp(formData);
toastSuccess('Host application created successfully');
} else {
await updateHostApp(formData);
toastSuccess('Host application updated successfully');
}
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save host application');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteHostApp(deleteItem.HostAppID);
toastSuccess('Host application deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete host application');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" 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">Host Applications</h1>
<p class="text-sm text-gray-600">Manage host applications</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Host Application
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by name or site..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<Server class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No host applications found' : 'No host applications yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No host applications match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first host application.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Host Application
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No host applications found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.HostAppName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.HostAppName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Host Application' : 'Edit Host Application'} size="md">
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="form-control">
<label class="label" for="hostAppName">
<span class="label-text text-sm font-medium">Host Application Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="hostAppName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.HostAppName}
placeholder="e.g., Main LIS"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name for this host application</span>
</div>
<div class="form-control">
<label class="label" for="site">
<span class="label-text text-sm font-medium">Site</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<select
id="site"
class="select select-sm select-bordered w-full"
bind:value={formData.SiteID}
required
>
<option value={null}>Select site...</option>
{#each sites as site}
<option value={site.SiteID}>{site.SiteName}</option>
{/each}
</select>
<span class="label-text-alt text-xs text-gray-500">The site this host application belongs to</span>
</div>
</form>
{#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={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete the following host application?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.HostAppName}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} 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>

View File

@ -0,0 +1,340 @@
<script>
import { onMount } from 'svelte';
import {
fetchHostComParas,
createHostComPara,
updateHostComPara,
deleteHostComPara,
fetchHostApps,
} 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 { Plus, Edit2, Trash2, ArrowLeft, Search, Network } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let hostApps = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
HostComParaID: null,
HostAppID: null,
HostIP: '',
HostPort: '',
HostPwd: '',
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'HostAppName', label: 'Host Application', class: 'font-medium' },
{ key: 'HostIP', label: 'IP Address', class: 'w-40' },
{ key: 'HostPort', label: 'Port', class: 'w-24' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadHostApps();
await loadItems();
});
async function loadHostApps() {
try {
const response = await fetchHostApps();
hostApps = Array.isArray(response.data) ? response.data : [];
} catch (err) {
hostApps = [];
}
}
async function loadItems() {
loading = true;
try {
const response = await fetchHostComParas();
items = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load host connection parameters');
items = [];
} finally {
loading = false;
}
}
const itemsWithHostAppName = $derived(
items.map((item) => ({
...item,
HostAppName: hostApps.find((ha) => ha.HostAppID === item.HostAppID)?.HostAppName || '-',
}))
);
const filteredItems = $derived(
itemsWithHostAppName.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.HostAppName && item.HostAppName.toLowerCase().includes(query)) ||
(item.HostIP && item.HostIP.toLowerCase().includes(query)) ||
(item.HostPort && item.HostPort.toString().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { HostComParaID: null, HostAppID: null, HostIP: '', HostPort: '', HostPwd: '' };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
HostComParaID: row.HostComParaID || row.id,
HostAppID: row.HostAppID,
HostIP: row.HostIP,
HostPort: row.HostPort,
HostPwd: row.HostPwd || '',
};
modalOpen = true;
}
async function handleSave() {
if (!formData.HostAppID) {
toastError('Host application is required');
return;
}
if (!formData.HostIP.trim()) {
toastError('IP address is required');
return;
}
if (!formData.HostPort.trim()) {
toastError('Port is required');
return;
}
saving = true;
try {
if (modalMode === 'create') {
await createHostComPara(formData);
toastSuccess('Host connection parameters created successfully');
} else {
await updateHostComPara(formData);
toastSuccess('Host connection parameters updated successfully');
}
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save host connection parameters');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
const id = deleteItem.HostComParaID || deleteItem.id || deleteItem.HostAppID;
await deleteHostComPara(id);
toastSuccess('Host connection parameters deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete host connection parameters');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" 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">Host Connection Parameters</h1>
<p class="text-sm text-gray-600">Manage host connection settings</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Connection Parameters
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by application, IP or port..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<Network class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No connection parameters found' : 'No connection parameters yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No connection parameters match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first host connection parameters.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Connection Parameters
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No connection parameters found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.HostAppName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.HostAppName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Connection Parameters' : 'Edit Connection Parameters'} size="md">
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="form-control">
<label class="label" for="hostApp">
<span class="label-text text-sm font-medium">Host Application</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<select
id="hostApp"
class="select select-sm select-bordered w-full"
bind:value={formData.HostAppID}
required
>
<option value={null}>Select host application...</option>
{#each hostApps as hostApp}
<option value={hostApp.HostAppID}>{hostApp.HostAppName}</option>
{/each}
</select>
<span class="label-text-alt text-xs text-gray-500">The host application for these connection parameters</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="hostIP">
<span class="label-text text-sm font-medium">IP Address</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="hostIP"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.HostIP}
placeholder="e.g., 192.168.1.100"
required
/>
<span class="label-text-alt text-xs text-gray-500">IP address of the host</span>
</div>
<div class="form-control">
<label class="label" for="hostPort">
<span class="label-text text-sm font-medium">Port</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="hostPort"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.HostPort}
placeholder="e.g., 8080"
required
/>
<span class="label-text-alt text-xs text-gray-500">Port number</span>
</div>
</div>
<div class="form-control">
<label class="label" for="hostPwd">
<span class="label-text text-sm font-medium">Password</span>
</label>
<input
id="hostPwd"
type="password"
class="input input-sm input-bordered w-full"
bind:value={formData.HostPwd}
placeholder="Enter password..."
/>
<span class="label-text-alt text-xs text-gray-500">Connection password (optional)</span>
</div>
</form>
{#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={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete the following connection parameters?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.HostAppName}</p>
<p class="text-sm text-base-content/60">IP: {deleteItem?.HostIP}:{deleteItem?.HostPort}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} 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>

View File

@ -182,14 +182,22 @@
<div class="flex items-center gap-2">
<Server class="w-4 h-4 text-primary flex-shrink-0" />
<div class="font-medium text-sm">
{#if row.HostType}
{row.HostType} - {row.HostName || row.HostID || '-'}
{:else}
{row.HostName || row.HostID || '-'}
{/if}
</div>
</div>
{:else if column.key === 'ClientInfo'}
<div class="flex items-center gap-2">
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
<div class="font-medium text-sm">
{#if row.ClientType}
{row.ClientType} - {row.ClientName || row.ClientID || '-'}
{:else}
{row.ClientName || row.ClientID || '-'}
{/if}
</div>
</div>
{:else if column.key === 'actions'}

View File

@ -10,6 +10,8 @@
batchUpdateTestMapDetails,
batchDeleteTestMapDetails,
} from '$lib/api/testmap.js';
import { fetchHostApps, fetchSites, fetchWorkstations } from '$lib/api/organization.js';
import { fetchEquipmentList } from '$lib/api/equipment.js';
import {
Plus,
Trash2,
@ -45,37 +47,111 @@
// Track previous mode and groupData to detect actual changes
let previousMode = $state(mode);
let previousGroupData = $state(null);
let previousHostType = $state('');
let previousClientType = $state('');
// Host apps for HIS dropdown
let hostApps = $state([]);
// Dropdown data from APIs
let sites = $state([]);
let workstations = $state([]);
let instruments = $state([]);
const hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
const clientTypes = ['HIS', 'SITE', 'WST', 'INST'];
async function fetchDropdownData() {
try {
const [hostAppsRes, sitesRes, workstationsRes, instrumentsRes] = await Promise.all([
fetchHostApps(),
fetchSites(),
fetchWorkstations(),
fetchEquipmentList(),
]);
hostApps = hostAppsRes.data?.map((h) => ({ id: h.HostAppID, name: h.HostAppName })) || [];
sites = sitesRes.data?.map((s) => ({ id: s.SiteID?.toString(), name: s.SiteName })) || [];
workstations = workstationsRes.data?.map((w) => ({ id: w.WorkstationID?.toString(), name: w.WorkstationName })) || [];
instruments = instrumentsRes.data?.map((i) => ({ id: i.EID?.toString(), name: i.InstrumentName || i.IEID })) || [];
} catch (err) {
console.error('Error fetching dropdown data:', err);
toastError('Failed to load dropdown options');
}
}
function getClientOptions(clientType) {
switch (clientType) {
case 'HIS': return hostApps;
case 'SITE': return sites;
case 'WST': return workstations;
case 'INST': return instruments;
default: return [];
}
}
function getHostOptions(hostType) {
switch (hostType) {
case 'HIS': return hostApps;
case 'SITE': return sites;
case 'WST': return workstations;
case 'INST': return instruments;
default: return [];
}
}
// Track if modal was ever opened
let hasInitialized = $state(false);
// Initialize modal when open changes to true, or when mode/groupData actually change
$effect(() => {
const isOpen = open;
const currentMode = mode;
const currentGroupData = groupData;
if (!isOpen) return;
if (!isOpen) {
hasInitialized = false;
return;
}
// Only initialize if:
// 1. Just opened (open changed from false to true)
// 2. Mode actually changed
// 3. GroupData actually changed (different reference)
// Initialize on first open or when mode/groupData changes
const shouldInitialize =
!hasInitialized ||
currentMode !== untrack(() => previousMode) ||
currentGroupData !== untrack(() => previousGroupData);
if (shouldInitialize) {
hasInitialized = true;
previousMode = currentMode;
previousGroupData = currentGroupData;
initializeModal();
}
});
function initializeModal() {
// Clear HostID when HostType changes in create mode
$effect(() => {
const currentHostType = modalContext.HostType;
if (mode === 'create' && currentHostType !== previousHostType) {
previousHostType = currentHostType;
modalContext.HostID = '';
}
});
// Clear ClientID when ClientType changes in create mode
$effect(() => {
const currentClientType = modalContext.ClientType;
if (mode === 'create' && currentClientType !== previousClientType) {
previousClientType = currentClientType;
modalContext.ClientID = '';
}
});
async function initializeModal() {
formErrors = {};
originalRows = [];
// Fetch all dropdown data
await fetchDropdownData();
if (mode === 'edit' && groupData) {
// Edit mode with group data - load all mappings in the group
modalContext = {
@ -410,6 +486,18 @@
<span class="label-text">ID</span>
<span class="label-text-alt text-error">*</span>
</label>
{#if mode === 'create' && ['HIS', 'SITE', 'WST', 'INST'].includes(modalContext.HostType)}
<select
id="hostID"
class="select select-xs select-bordered"
bind:value={modalContext.HostID}
>
<option value="">Select...</option>
{#each getHostOptions(modalContext.HostType) as option (option.id)}
<option value={option.id}>{option.name} ({option.id})</option>
{/each}
</select>
{:else}
<input
id="hostID"
type="text"
@ -418,11 +506,15 @@
placeholder="Host ID"
disabled={mode === 'edit'}
/>
{/if}
{#if formErrors.HostID}
<span class="text-xs text-error">{formErrors.HostID}</span>
{/if}
</div>
</div>
{#if mode === 'edit' && groupData?.HostName}
<div class="text-xs text-gray-600 mt-1">{groupData.HostName}</div>
{/if}
</div>
<!-- Client Section -->
@ -459,6 +551,18 @@
<span class="label-text">ID</span>
<span class="label-text-alt text-error">*</span>
</label>
{#if mode === 'create' && ['HIS', 'SITE', 'WST', 'INST'].includes(modalContext.ClientType)}
<select
id="clientID"
class="select select-xs select-bordered"
bind:value={modalContext.ClientID}
>
<option value="">Select...</option>
{#each getClientOptions(modalContext.ClientType) as option (option.id)}
<option value={option.id}>{option.name} ({option.id})</option>
{/each}
</select>
{:else}
<input
id="clientID"
type="text"
@ -467,11 +571,15 @@
placeholder="Client ID"
disabled={mode === 'edit'}
/>
{/if}
{#if formErrors.ClientID}
<span class="text-xs text-error">{formErrors.ClientID}</span>
{/if}
</div>
</div>
{#if mode === 'edit' && groupData?.ClientName}
<div class="text-xs text-gray-600 mt-1">{groupData.ClientName}</div>
{/if}
</div>
</div>
</div>
@ -490,9 +598,9 @@
<tr>
<th class="text-xs px-2">Host Test Code</th>
<th class="text-xs px-2">Host Test Name</th>
<th class="text-xs w-48 px-2">Container</th>
<th class="text-xs px-2">Client Test Code</th>
<th class="text-xs px-2">Client Test Name</th>
<th class="text-xs w-48 px-2">Container</th>
<th class="text-xs w-10 px-2"></th>
</tr>
</thead>
@ -515,29 +623,6 @@
placeholder="Name"
/>
</td>
<td class="px-2">
{#if modalContext.ClientType === 'INST'}
<select
class="select select-xs select-bordered w-full m-0"
value={row.ConDefID}
onchange={(e) => updateRowField(index, 'ConDefID', e.target.value === '' ? null : parseInt(e.target.value))}
>
<option value="">Select container...</option>
{#each containers as container (container.ConDefID)}
<option value={container.ConDefID} selected={container.ConDefID === row.ConDefID}>
{container.ConName}
</option>
{/each}
</select>
{#if formErrors.rows?.[index]?.ConDefID}
<span class="text-xs text-error">{formErrors.rows[index].ConDefID}</span>
{/if}
{:else}
<select class="select select-xs select-bordered w-full m-0" disabled>
<option>Only for INST</option>
</select>
{/if}
</td>
<td class="px-2">
<input
type="text"
@ -554,6 +639,27 @@
placeholder="Name"
/>
</td>
<td class="px-2">
{#if modalContext.ClientType === 'INST'}
<select
class="select select-xs select-bordered w-full m-0"
value={row.ConDefID}
onchange={(e) => updateRowField(index, 'ConDefID', e.target.value === '' ? null : parseInt(e.target.value))}
>
<option value="">Select container...</option>
{#each containers as container (container.ConDefID)}
<option value={parseInt(container.ConDefID)}>
{container.ConName}
</option>
{/each}
</select>
{#if formErrors.rows?.[index]?.ConDefID}
<span class="text-xs text-error">{formErrors.rows[index].ConDefID}</span>
{/if}
{:else}
<span></span>
{/if}
</td>
<td class="px-2">
<button
class="btn btn-ghost btn-xs text-error"

View File

@ -7,13 +7,20 @@
import Modal from '$lib/components/Modal.svelte';
import TestFormModal from './test-modal/TestFormModal.svelte';
import TestTypePickerModal from './test-modal/modals/TestTypePickerModal.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users } from 'lucide-svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users, ChevronLeft, ChevronRight } from 'lucide-svelte';
// Pagination and search state
let loading = $state(false);
let tests = $state([]);
let disciplines = $state([]);
let departments = $state([]);
let searchQuery = $state('');
let searchType = $state('all'); // 'all', 'code', 'name'
let currentPage = $state(1);
let perPage = $state(25);
let totalItems = $state(0);
let totalPages = $state(0);
let hasMore = $state(false);
let modalOpen = $state(false);
let modalMode = $state('create');
let selectedTestId = $state(null);
@ -22,6 +29,7 @@
let deleteItem = $state(null);
let deleting = $state(false);
let testTypePickerOpen = $state(false);
let searchDebounceTimer = $state(null);
const testTypeConfig = {
TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' },
@ -41,33 +49,121 @@
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' }
];
// Client-side filter for already loaded data (secondary filter)
const filteredTests = $derived.by(() => {
if (!searchQuery.trim()) return tests;
const query = searchQuery.toLowerCase().trim();
return tests.filter(test => {
const code = (test.TestSiteCode || '').toLowerCase();
const name = (test.TestSiteName || '').toLowerCase();
return code.includes(query) || name.includes(query);
});
return tests;
});
function getSearchParams() {
const params = { page: currentPage, perPage };
const query = searchQuery.trim();
if (query) {
if (searchType === 'code') {
params.testCode = query;
} else if (searchType === 'name') {
params.testName = query;
} else {
params.search = query;
}
}
return params;
}
function handleSearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
currentPage = 1;
loadTests();
}, 300);
}
onMount(async () => {
await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]);
});
async function loadTests() {
function handleSearchInput() {
handleSearch();
}
function handleSearchTypeChange(newType) {
searchType = newType;
handleSearch();
}
function clearSearch() {
searchQuery = '';
currentPage = 1;
loadTests();
}
async function loadTests(reset = false) {
if (reset) {
currentPage = 1;
}
loading = true;
try {
const response = await fetchTests();
tests = Array.isArray(response.data) ? response.data.filter(t => t.IsActive !== '0' && t.IsActive !== 0) : [];
const params = getSearchParams();
const response = await fetchTests(params);
if (response.data && Array.isArray(response.data.data)) {
tests = response.data.data.filter(t => t.IsActive !== '0' && t.IsActive !== 0);
totalItems = response.data.total || 0;
totalPages = Math.ceil(totalItems / perPage);
hasMore = currentPage < totalPages;
} else if (Array.isArray(response.data)) {
// Fallback for old API format
tests = response.data.filter(t => t.IsActive !== '0' && t.IsActive !== 0);
totalItems = tests.length;
totalPages = 1;
hasMore = false;
} else {
tests = [];
totalItems = 0;
totalPages = 0;
hasMore = false;
}
} catch (err) {
toastError(err.message || 'Failed to load tests');
tests = [];
totalItems = 0;
totalPages = 0;
hasMore = false;
} finally {
loading = false;
}
}
async function loadNextPage() {
if (currentPage < totalPages && !loading) {
currentPage++;
loading = true;
try {
const params = getSearchParams();
const response = await fetchTests(params);
if (response.data && Array.isArray(response.data.data)) {
const newTests = response.data.data.filter(t => t.IsActive !== '0' && t.IsActive !== 0);
tests = [...tests, ...newTests];
totalItems = response.data.total || 0;
totalPages = Math.ceil(totalItems / perPage);
hasMore = currentPage < totalPages;
}
} catch (err) {
toastError(err.message || 'Failed to load more tests');
currentPage--;
} finally {
loading = false;
}
}
}
function goToPage(page) {
if (page >= 1 && page <= totalPages && page !== currentPage) {
currentPage = page;
loadTests();
}
}
async function loadDisciplines() {
try {
const response = await fetchDisciplines();
@ -144,26 +240,50 @@
</button>
</div>
<div class="mb-4">
<div class="max-w-md">
<div class="mb-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div class="flex gap-2">
<button
class="btn btn-sm {searchType === 'all' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => handleSearchTypeChange('all')}
>
All
</button>
<button
class="btn btn-sm {searchType === 'code' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => handleSearchTypeChange('code')}
>
Code
</button>
<button
class="btn btn-sm {searchType === 'name' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => handleSearchTypeChange('name')}
>
Name
</button>
</div>
<div class="flex-1 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
placeholder={searchType === 'code' ? 'Search by code...' : searchType === 'name' ? 'Search by name...' : 'Search by code or name...'}
bind:value={searchQuery}
oninput={handleSearchInput}
/>
{#if searchQuery}
<button
class="btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
onclick={clearSearch}
>
×
</button>
{/if}
</label>
</div>
<div class="text-sm text-gray-600">
{totalItems} total
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
@ -196,8 +316,8 @@
<DataTable
{columns}
data={filteredTests}
loading={false}
emptyMessage="No tests found"
loading={loading}
emptyMessage={searchQuery ? 'No tests found matching your search' : 'No tests yet'}
hover={true}
bordered={false}
>
@ -228,6 +348,64 @@
{/if}
{/snippet}
</DataTable>
<!-- Pagination Controls -->
{#if totalPages > 1}
<div class="flex items-center justify-between px-4 py-3 border-t border-base-200">
<div class="text-sm text-gray-600">
Showing {(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-sm btn-ghost"
onclick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1 || loading}
>
<ChevronLeft class="w-4 h-4" />
</button>
<div class="flex gap-1">
{#each Array(Math.min(5, totalPages)) as _, i (i)}
{@const pageNum = currentPage <= 3
? i + 1
: currentPage >= totalPages - 2
? totalPages - 4 + i
: currentPage - 2 + i}
{#if pageNum <= totalPages}
<button
class="btn btn-sm {pageNum === currentPage ? 'btn-primary' : 'btn-ghost'}"
onclick={() => goToPage(pageNum)}
disabled={loading}
>
{pageNum}
</button>
{/if}
{/each}
</div>
<button
class="btn btn-sm btn-ghost"
onclick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages || loading}
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
{:else if hasMore}
<div class="flex justify-center py-4 border-t border-base-200">
<button
class="btn btn-outline"
onclick={loadNextPage}
disabled={loading}
>
{#if loading}
<Loader2 class="w-4 h-4 animate-spin mr-2" />
Loading...
{:else}
Load More
{/if}
</button>
</div>
{/if}
{/if}
</div>
</div>

View File

@ -379,6 +379,7 @@
<MappingsTab
bind:formData
bind:isDirty
testCode={formData.TestSiteCode}
/>
{:else if currentTab === 'refnum'}
<RefNumTab

View File

@ -1,21 +1,43 @@
<script>
import { onMount } from 'svelte';
import { Plus, Edit2, Trash2, Link } from 'lucide-svelte';
import Modal from '$lib/components/Modal.svelte';
import { get } from '$lib/api/client.js';
import { error as toastError } from '$lib/utils/toast.js';
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
let { formData = $bindable(), isDirty = $bindable(false), testCode = '' } = $props();
let modalOpen = $state(false);
let modalMode = $state('create');
let selectedMapping = $state(null);
let loading = $state(false);
let mappingsData = $state([]);
// Dropdown data
let sites = $state([]);
let workstations = $state([]);
let departments = $state([]);
let equipment = $state([]);
let containers = $state([]);
let hostApps = $state([]);
// Cache for names
let namesCache = $state({
sites: {},
workstations: {},
departments: {},
equipment: {},
containers: {},
hostApps: {}
});
let editingMapping = $state({
HostType: '',
HostID: '',
HostDataSource: '',
HostTestCode: '',
HostTestName: '',
ClientType: '',
ClientID: '',
ClientDataSource: '',
ConDefID: '',
ClientTestCode: '',
ClientTestName: ''
@ -24,6 +46,242 @@
const hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
const clientTypes = ['HIS', 'SITE', 'WST', 'INST'];
// Fetch mappings when testCode changes
$effect(() => {
if (testCode) {
fetchMappings();
}
});
onMount(() => {
if (testCode) {
fetchMappings();
}
// Load dropdown data
loadDropdownData();
});
async function loadDropdownData() {
try {
// Load sites
const sitesRes = await get('/api/organization/site');
if (sitesRes.status === 'success' && sitesRes.data) {
sites = sitesRes.data;
sites.forEach(s => namesCache.sites[s.SiteID] = s.SiteName);
}
// Load workstations
const wstRes = await get('/api/organization/workstation');
if (wstRes.status === 'success' && wstRes.data) {
workstations = wstRes.data;
workstations.forEach(w => namesCache.workstations[w.WorkstationID] = w.WorkstationName);
}
// Load departments
const deptRes = await get('/api/organization/department');
if (deptRes.status === 'success' && deptRes.data) {
departments = deptRes.data;
departments.forEach(d => namesCache.departments[d.DepartmentID] = d.DeptName);
}
// Load equipment
const equipRes = await get('/api/equipmentlist');
if (equipRes.status === 'success' && equipRes.data) {
equipment = equipRes.data;
equipment.forEach(e => namesCache.equipment[e.EID] = e.InstrumentName || e.IEID);
}
// Load containers
const contRes = await get('/api/specimen/container');
if (contRes.status === 'success' && contRes.data) {
containers = contRes.data;
containers.forEach(c => namesCache.containers[c.ConDefID] = c.ConName);
}
// Load host applications (HIS)
const hostRes = await get('/api/organization/hostapp');
if (hostRes.status === 'success' && hostRes.data) {
hostApps = hostRes.data;
hostApps.forEach(h => namesCache.hostApps[h.HostAppID] = h.HostAppName);
}
} catch (err) {
console.error('Failed to load dropdown data:', err);
}
}
function getHostOptions() {
switch (editingMapping.HostType) {
case 'HIS':
return hostApps.map(h => ({ value: h.HostAppID, label: h.HostAppName }));
case 'SITE':
return sites.map(s => ({ value: s.SiteID, label: s.SiteName }));
case 'WST':
return workstations.map(w => ({ value: w.WorkstationID, label: w.WorkstationName }));
case 'DEPT':
return departments.map(d => ({ value: d.DepartmentID, label: d.DeptName }));
case 'INST':
return equipment.map(e => ({ value: e.EID, label: e.InstrumentName || e.IEID }));
default:
return [];
}
}
function getClientOptions() {
switch (editingMapping.ClientType) {
case 'HIS':
return hostApps.map(h => ({ value: h.HostAppID, label: h.HostAppName }));
case 'SITE':
return sites.map(s => ({ value: s.SiteID, label: s.SiteName }));
case 'WST':
return workstations.map(w => ({ value: w.WorkstationID, label: w.WorkstationName }));
case 'DEPT':
return departments.map(d => ({ value: d.DepartmentID, label: d.DeptName }));
case 'INST':
return equipment.map(e => ({ value: e.EID, label: e.InstrumentName || e.IEID }));
default:
return [];
}
}
async function fetchEntityName(type, id) {
if (!id) return null;
// Check cache first
if (type === 'HIS' && namesCache.hostApps[id]) return namesCache.hostApps[id];
if (type === 'SITE' && namesCache.sites[id]) return namesCache.sites[id];
if (type === 'WST' && namesCache.workstations[id]) return namesCache.workstations[id];
if (type === 'DEPT' && namesCache.departments[id]) return namesCache.departments[id];
if (type === 'INST' && namesCache.equipment[id]) return namesCache.equipment[id];
try {
let response;
switch (type) {
case 'HIS':
response = await get(`/api/organization/hostapp/${id}`);
if (response.status === 'success' && response.data) {
namesCache.hostApps[id] = response.data.HostAppName || `HIS ${id}`;
return namesCache.hostApps[id];
}
break;
case 'SITE':
response = await get(`/api/organization/site/${id}`);
if (response.status === 'success' && response.data) {
namesCache.sites[id] = response.data.SiteName || `Site ${id}`;
return namesCache.sites[id];
}
break;
case 'WST':
response = await get(`/api/organization/workstation/${id}`);
if (response.status === 'success' && response.data) {
namesCache.workstations[id] = response.data.WorkstationName || `Workstation ${id}`;
return namesCache.workstations[id];
}
break;
case 'DEPT':
response = await get(`/api/organization/department/${id}`);
if (response.status === 'success' && response.data) {
namesCache.departments[id] = response.data.DeptName || `Department ${id}`;
return namesCache.departments[id];
}
break;
case 'INST':
response = await get(`/api/equipmentlist/${id}`);
if (response.status === 'success' && response.data) {
namesCache.equipment[id] = response.data.InstrumentName || response.data.IEID || `Equipment ${id}`;
return namesCache.equipment[id];
}
break;
}
} catch (err) {
console.error(`Failed to fetch ${type} name for ID ${id}:`, err);
}
return `${type} ${id}`;
}
async function fetchContainerName(conDefId) {
if (!conDefId) return null;
if (namesCache.containers[conDefId]) return namesCache.containers[conDefId];
try {
const response = await get(`/api/specimen/container/${conDefId}`);
if (response.status === 'success' && response.data) {
namesCache.containers[conDefId] = response.data.ConName || `Container ${conDefId}`;
return namesCache.containers[conDefId];
}
} catch (err) {
console.error(`Failed to fetch container name for ID ${conDefId}:`, err);
}
return `Container ${conDefId}`;
}
async function fetchMappingDetails(testMapId) {
try {
const response = await get(`/api/test/testmap/detail/by-testmap/${testMapId}`);
if (response.status === 'success' && response.data && response.data.length > 0) {
return response.data[0];
}
} catch (err) {
console.error(`Failed to fetch mapping details for TestMapID ${testMapId}:`, err);
}
return null;
}
async function fetchMappings() {
if (!testCode) return;
loading = true;
try {
const response = await get(`/api/test/testmap/by-testcode/${testCode}`);
if (response.status === 'success' && response.data) {
const transformedData = await Promise.all(response.data.map(async item => {
const [hostName, clientName, details] = await Promise.all([
fetchEntityName(item.HostType, item.HostID),
fetchEntityName(item.ClientType, item.ClientID),
fetchMappingDetails(item.TestMapID)
]);
let containerName = null;
if (details && details.ConDefID) {
containerName = await fetchContainerName(details.ConDefID);
}
return {
TestMapID: item.TestMapID,
TestSiteID: item.TestSiteID,
HostType: item.HostType,
HostID: item.HostID,
HostName: hostName,
HostTypeLabel: item.HostTypeLabel,
ClientType: item.ClientType,
ClientID: item.ClientID,
ClientName: clientName,
ClientTypeLabel: item.ClientTypeLabel,
ConDefID: details?.ConDefID || null,
ContainerName: containerName,
CreateDate: item.CreateDate,
EndDate: item.EndDate,
HostTestCode: details?.HostTestCode || '',
HostTestName: details?.HostTestName || '',
ClientTestCode: details?.ClientTestCode || '',
ClientTestName: details?.ClientTestName || ''
};
}));
mappingsData = transformedData;
formData.testmap = [...mappingsData];
} else {
mappingsData = [];
formData.testmap = [];
}
} catch (err) {
toastError(err.message || 'Failed to load mappings');
mappingsData = [];
formData.testmap = [];
} finally {
loading = false;
}
}
function handleFieldChange() {
isDirty = true;
}
@ -33,12 +291,10 @@
editingMapping = {
HostType: '',
HostID: '',
HostDataSource: '',
HostTestCode: '',
HostTestName: '',
ClientType: '',
ClientID: '',
ClientDataSource: '',
ConDefID: '',
ClientTestCode: '',
ClientTestName: ''
@ -54,20 +310,26 @@
}
function removeMapping(index) {
const newMappings = formData.testmap?.filter((_, i) => i !== index) || [];
formData.testmap = newMappings;
const newMappings = mappingsData.filter((_, i) => i !== index);
mappingsData = newMappings;
formData.testmap = [...newMappings];
handleFieldChange();
}
function saveMapping() {
if (modalMode === 'create') {
formData.testmap = [...(formData.testmap || []), { ...editingMapping }];
const newMapping = {
...editingMapping,
TestMapID: `temp-${Date.now()}`
};
mappingsData = [...mappingsData, newMapping];
} else {
const newMappings = formData.testmap?.map(m =>
const newMappings = mappingsData.map(m =>
m === selectedMapping ? { ...editingMapping } : m
) || [];
formData.testmap = newMappings;
);
mappingsData = newMappings;
}
formData.testmap = [...mappingsData];
modalOpen = false;
handleFieldChange();
}
@ -84,9 +346,13 @@
</div>
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Mappings ({formData.testmap?.length || 0})</h3>
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Mappings ({mappingsData?.length || 0})</h3>
{#if !formData.testmap || formData.testmap.length === 0}
{#if loading}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md text-primary"></span>
</div>
{:else if !mappingsData || mappingsData.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg">
<Link class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500">No mappings configured</p>
@ -98,37 +364,28 @@
<thead>
<tr class="bg-base-200">
<th>Host System</th>
<th>Host Code</th>
<th>Client System</th>
<th>Client Code</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each formData.testmap as mapping, idx (idx)}
{#each mappingsData as mapping, idx (idx)}
<tr class="hover:bg-base-100">
<td>
<div class="text-sm">
<div class="font-medium">{mapping.HostType}</div>
<div class="text-xs text-gray-500">ID: {mapping.HostID || '-'}</div>
<div class="font-medium">{mapping.HostName || mapping.HostTypeLabel || mapping.HostType || '-'}</div>
<div class="text-xs text-gray-500">{mapping.HostTypeLabel || mapping.HostType} (ID: {mapping.HostID || '-'})</div>
</div>
</td>
<td>
<div class="text-sm">
<div class="font-mono">{mapping.HostTestCode || '-'}</div>
<div class="text-xs text-gray-500 truncate max-w-[150px]">{mapping.HostTestName || '-'}</div>
<div class="font-medium">{mapping.ClientName || mapping.ClientTypeLabel || mapping.ClientType || '-'}</div>
<div class="text-xs text-gray-500">
{mapping.ClientTypeLabel || mapping.ClientType} (ID: {mapping.ClientID || '-'})
{#if mapping.ClientType === 'INST' && mapping.ContainerName}
<span class="text-primary">{mapping.ContainerName}</span>
{/if}
</div>
</td>
<td>
<div class="text-sm">
<div class="font-medium">{mapping.ClientType}</div>
<div class="text-xs text-gray-500">ID: {mapping.ClientID || '-'}</div>
</div>
</td>
<td>
<div class="text-sm">
<div class="font-mono">{mapping.ClientTestCode || '-'}</div>
<div class="text-xs text-gray-500 truncate max-w-[150px]">{mapping.ClientTestName || '-'}</div>
</div>
</td>
<td>
@ -173,7 +430,11 @@
<div class="form-control">
<label class="label text-sm">Type</label>
<select class="select select-sm select-bordered" bind:value={editingMapping.HostType}>
<select
class="select select-sm select-bordered"
bind:value={editingMapping.HostType}
disabled={modalMode === 'edit'}
>
<option value="">Select type...</option>
{#each hostTypes as type (type)}
<option value={type}>{type}</option>
@ -183,12 +444,28 @@
<div class="form-control">
<label class="label text-sm">ID</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostID} placeholder="System ID" />
</div>
<div class="form-control">
<label class="label text-sm">Data Source</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostDataSource} placeholder="e.g., DB, API" />
{#if modalMode === 'create' && editingMapping.HostType}
<select class="select select-sm select-bordered" bind:value={editingMapping.HostID}>
<option value="">Select {editingMapping.HostType}...</option>
{#each getHostOptions() as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{:else if modalMode === 'edit'}
<input
type="text"
class="input input-sm input-bordered"
value={editingMapping.HostName || `${editingMapping.HostType} ${editingMapping.HostID}`}
disabled
/>
{:else}
<input
type="text"
class="input input-sm input-bordered"
bind:value={editingMapping.HostID}
placeholder="System ID"
/>
{/if}
</div>
<div class="form-control">
@ -207,7 +484,11 @@
<div class="form-control">
<label class="label text-sm">Type</label>
<select class="select select-sm select-bordered" bind:value={editingMapping.ClientType}>
<select
class="select select-sm select-bordered"
bind:value={editingMapping.ClientType}
disabled={modalMode === 'edit'}
>
<option value="">Select type...</option>
{#each clientTypes as type (type)}
<option value={type}>{type}</option>
@ -217,18 +498,41 @@
<div class="form-control">
<label class="label text-sm">ID</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientID} placeholder="System ID" />
</div>
<div class="form-control">
<label class="label text-sm">Data Source</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientDataSource} placeholder="e.g., DB, API" />
{#if modalMode === 'create' && editingMapping.ClientType}
<select class="select select-sm select-bordered" bind:value={editingMapping.ClientID}>
<option value="">Select {editingMapping.ClientType}...</option>
{#each getClientOptions() as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{:else if modalMode === 'edit'}
<input
type="text"
class="input input-sm input-bordered"
value={editingMapping.ClientName || `${editingMapping.ClientType} ${editingMapping.ClientID}`}
disabled
/>
{:else}
<input
type="text"
class="input input-sm input-bordered"
bind:value={editingMapping.ClientID}
placeholder="System ID"
/>
{/if}
</div>
{#if editingMapping.ClientType === 'INST'}
<div class="form-control">
<label class="label text-sm">Container</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ConDefID} placeholder="Container definition" />
<select class="select select-sm select-bordered" bind:value={editingMapping.ConDefID}>
<option value="">Select container...</option>
{#each containers as container (container.ConDefID)}
<option value={container.ConDefID}>{container.ConName}</option>
{/each}
</select>
</div>
{/if}
<div class="form-control">
<label class="label text-sm">Test Code</label>