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:
parent
9eef675a52
commit
b693f279e8
@ -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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
707
docs/organization.yaml
Normal 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'
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
296
src/routes/(app)/master-data/organization/codingsys/+page.svelte
Normal file
296
src/routes/(app)/master-data/organization/codingsys/+page.svelte
Normal 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>
|
||||
298
src/routes/(app)/master-data/organization/hostapp/+page.svelte
Normal file
298
src/routes/(app)/master-data/organization/hostapp/+page.svelte
Normal 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>
|
||||
@ -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>
|
||||
@ -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'}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -379,6 +379,7 @@
|
||||
<MappingsTab
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
testCode={formData.TestSiteCode}
|
||||
/>
|
||||
{:else if currentTab === 'refnum'}
|
||||
<RefNumTab
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user