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) {
|
export async function deleteDepartment(id) {
|
||||||
return del('/api/organization/department', { 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,
|
LandPlot,
|
||||||
Monitor,
|
Monitor,
|
||||||
Activity,
|
Activity,
|
||||||
User
|
User,
|
||||||
|
Server,
|
||||||
|
Network,
|
||||||
|
FileCode
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
import { goto } from '$app/navigation';
|
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/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/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/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>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -6,7 +6,10 @@
|
|||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
Monitor,
|
Monitor,
|
||||||
Activity
|
Activity,
|
||||||
|
Server,
|
||||||
|
Network,
|
||||||
|
FileCode
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { ArrowLeft } from 'lucide-svelte';
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
@ -53,6 +56,27 @@
|
|||||||
href: '/master-data/organization/instrument',
|
href: '/master-data/organization/instrument',
|
||||||
color: 'bg-orange-500',
|
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>
|
</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">
|
<div class="flex items-center gap-2">
|
||||||
<Server class="w-4 h-4 text-primary flex-shrink-0" />
|
<Server class="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
<div class="font-medium text-sm">
|
<div class="font-medium text-sm">
|
||||||
{row.HostName || row.HostID || '-'}
|
{#if row.HostType}
|
||||||
|
{row.HostType} - {row.HostName || row.HostID || '-'}
|
||||||
|
{:else}
|
||||||
|
{row.HostName || row.HostID || '-'}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if column.key === 'ClientInfo'}
|
{:else if column.key === 'ClientInfo'}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
|
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
|
||||||
<div class="font-medium text-sm">
|
<div class="font-medium text-sm">
|
||||||
{row.ClientName || row.ClientID || '-'}
|
{#if row.ClientType}
|
||||||
|
{row.ClientType} - {row.ClientName || row.ClientID || '-'}
|
||||||
|
{:else}
|
||||||
|
{row.ClientName || row.ClientID || '-'}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if column.key === 'actions'}
|
{:else if column.key === 'actions'}
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
batchUpdateTestMapDetails,
|
batchUpdateTestMapDetails,
|
||||||
batchDeleteTestMapDetails,
|
batchDeleteTestMapDetails,
|
||||||
} from '$lib/api/testmap.js';
|
} from '$lib/api/testmap.js';
|
||||||
|
import { fetchHostApps, fetchSites, fetchWorkstations } from '$lib/api/organization.js';
|
||||||
|
import { fetchEquipmentList } from '$lib/api/equipment.js';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -45,37 +47,111 @@
|
|||||||
// Track previous mode and groupData to detect actual changes
|
// Track previous mode and groupData to detect actual changes
|
||||||
let previousMode = $state(mode);
|
let previousMode = $state(mode);
|
||||||
let previousGroupData = $state(null);
|
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 hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
|
||||||
const clientTypes = ['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
|
// Initialize modal when open changes to true, or when mode/groupData actually change
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const isOpen = open;
|
const isOpen = open;
|
||||||
const currentMode = mode;
|
const currentMode = mode;
|
||||||
const currentGroupData = groupData;
|
const currentGroupData = groupData;
|
||||||
|
|
||||||
if (!isOpen) return;
|
if (!isOpen) {
|
||||||
|
hasInitialized = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only initialize if:
|
// Initialize on first open or when mode/groupData changes
|
||||||
// 1. Just opened (open changed from false to true)
|
|
||||||
// 2. Mode actually changed
|
|
||||||
// 3. GroupData actually changed (different reference)
|
|
||||||
const shouldInitialize =
|
const shouldInitialize =
|
||||||
|
!hasInitialized ||
|
||||||
currentMode !== untrack(() => previousMode) ||
|
currentMode !== untrack(() => previousMode) ||
|
||||||
currentGroupData !== untrack(() => previousGroupData);
|
currentGroupData !== untrack(() => previousGroupData);
|
||||||
|
|
||||||
if (shouldInitialize) {
|
if (shouldInitialize) {
|
||||||
|
hasInitialized = true;
|
||||||
previousMode = currentMode;
|
previousMode = currentMode;
|
||||||
previousGroupData = currentGroupData;
|
previousGroupData = currentGroupData;
|
||||||
initializeModal();
|
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 = {};
|
formErrors = {};
|
||||||
originalRows = [];
|
originalRows = [];
|
||||||
|
|
||||||
|
// Fetch all dropdown data
|
||||||
|
await fetchDropdownData();
|
||||||
|
|
||||||
if (mode === 'edit' && groupData) {
|
if (mode === 'edit' && groupData) {
|
||||||
// Edit mode with group data - load all mappings in the group
|
// Edit mode with group data - load all mappings in the group
|
||||||
modalContext = {
|
modalContext = {
|
||||||
@ -410,19 +486,35 @@
|
|||||||
<span class="label-text">ID</span>
|
<span class="label-text">ID</span>
|
||||||
<span class="label-text-alt text-error">*</span>
|
<span class="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
{#if mode === 'create' && ['HIS', 'SITE', 'WST', 'INST'].includes(modalContext.HostType)}
|
||||||
id="hostID"
|
<select
|
||||||
type="text"
|
id="hostID"
|
||||||
class="input input-xs input-bordered"
|
class="select select-xs select-bordered"
|
||||||
bind:value={modalContext.HostID}
|
bind:value={modalContext.HostID}
|
||||||
placeholder="Host ID"
|
>
|
||||||
disabled={mode === 'edit'}
|
<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"
|
||||||
|
class="input input-xs input-bordered"
|
||||||
|
bind:value={modalContext.HostID}
|
||||||
|
placeholder="Host ID"
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if formErrors.HostID}
|
{#if formErrors.HostID}
|
||||||
<span class="text-xs text-error">{formErrors.HostID}</span>
|
<span class="text-xs text-error">{formErrors.HostID}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if mode === 'edit' && groupData?.HostName}
|
||||||
|
<div class="text-xs text-gray-600 mt-1">{groupData.HostName}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client Section -->
|
<!-- Client Section -->
|
||||||
@ -459,19 +551,35 @@
|
|||||||
<span class="label-text">ID</span>
|
<span class="label-text">ID</span>
|
||||||
<span class="label-text-alt text-error">*</span>
|
<span class="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
{#if mode === 'create' && ['HIS', 'SITE', 'WST', 'INST'].includes(modalContext.ClientType)}
|
||||||
id="clientID"
|
<select
|
||||||
type="text"
|
id="clientID"
|
||||||
class="input input-xs input-bordered"
|
class="select select-xs select-bordered"
|
||||||
bind:value={modalContext.ClientID}
|
bind:value={modalContext.ClientID}
|
||||||
placeholder="Client ID"
|
>
|
||||||
disabled={mode === 'edit'}
|
<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"
|
||||||
|
class="input input-xs input-bordered"
|
||||||
|
bind:value={modalContext.ClientID}
|
||||||
|
placeholder="Client ID"
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if formErrors.ClientID}
|
{#if formErrors.ClientID}
|
||||||
<span class="text-xs text-error">{formErrors.ClientID}</span>
|
<span class="text-xs text-error">{formErrors.ClientID}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if mode === 'edit' && groupData?.ClientName}
|
||||||
|
<div class="text-xs text-gray-600 mt-1">{groupData.ClientName}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -490,9 +598,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="text-xs px-2">Host Test Code</th>
|
<th class="text-xs px-2">Host Test Code</th>
|
||||||
<th class="text-xs px-2">Host Test Name</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 Code</th>
|
||||||
<th class="text-xs px-2">Client Test Name</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>
|
<th class="text-xs w-10 px-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -515,29 +623,6 @@
|
|||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
/>
|
/>
|
||||||
</td>
|
</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">
|
<td class="px-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -554,6 +639,27 @@
|
|||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
/>
|
/>
|
||||||
</td>
|
</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">
|
<td class="px-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs text-error"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
|||||||
@ -7,13 +7,20 @@
|
|||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import TestFormModal from './test-modal/TestFormModal.svelte';
|
import TestFormModal from './test-modal/TestFormModal.svelte';
|
||||||
import TestTypePickerModal from './test-modal/modals/TestTypePickerModal.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 loading = $state(false);
|
||||||
let tests = $state([]);
|
let tests = $state([]);
|
||||||
let disciplines = $state([]);
|
let disciplines = $state([]);
|
||||||
let departments = $state([]);
|
let departments = $state([]);
|
||||||
let searchQuery = $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 modalOpen = $state(false);
|
||||||
let modalMode = $state('create');
|
let modalMode = $state('create');
|
||||||
let selectedTestId = $state(null);
|
let selectedTestId = $state(null);
|
||||||
@ -22,6 +29,7 @@
|
|||||||
let deleteItem = $state(null);
|
let deleteItem = $state(null);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
let testTypePickerOpen = $state(false);
|
let testTypePickerOpen = $state(false);
|
||||||
|
let searchDebounceTimer = $state(null);
|
||||||
|
|
||||||
const testTypeConfig = {
|
const testTypeConfig = {
|
||||||
TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' },
|
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' }
|
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Client-side filter for already loaded data (secondary filter)
|
||||||
const filteredTests = $derived.by(() => {
|
const filteredTests = $derived.by(() => {
|
||||||
if (!searchQuery.trim()) return tests;
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
onMount(async () => {
|
||||||
await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]);
|
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;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetchTests();
|
const params = getSearchParams();
|
||||||
tests = Array.isArray(response.data) ? response.data.filter(t => t.IsActive !== '0' && t.IsActive !== 0) : [];
|
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) {
|
} catch (err) {
|
||||||
toastError(err.message || 'Failed to load tests');
|
toastError(err.message || 'Failed to load tests');
|
||||||
tests = [];
|
tests = [];
|
||||||
|
totalItems = 0;
|
||||||
|
totalPages = 0;
|
||||||
|
hasMore = false;
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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() {
|
async function loadDisciplines() {
|
||||||
try {
|
try {
|
||||||
const response = await fetchDisciplines();
|
const response = await fetchDisciplines();
|
||||||
@ -144,26 +240,50 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||||
<div class="max-w-md">
|
<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">
|
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||||
<Search class="w-5 h-5 text-gray-400" />
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="grow"
|
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}
|
bind:value={searchQuery}
|
||||||
|
oninput={handleSearchInput}
|
||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs btn-circle"
|
class="btn btn-ghost btn-xs btn-circle"
|
||||||
onclick={() => searchQuery = ''}
|
onclick={clearSearch}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{totalItems} total
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||||
@ -196,8 +316,8 @@
|
|||||||
<DataTable
|
<DataTable
|
||||||
{columns}
|
{columns}
|
||||||
data={filteredTests}
|
data={filteredTests}
|
||||||
loading={false}
|
loading={loading}
|
||||||
emptyMessage="No tests found"
|
emptyMessage={searchQuery ? 'No tests found matching your search' : 'No tests yet'}
|
||||||
hover={true}
|
hover={true}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
>
|
>
|
||||||
@ -228,6 +348,64 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataTable>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -379,6 +379,7 @@
|
|||||||
<MappingsTab
|
<MappingsTab
|
||||||
bind:formData
|
bind:formData
|
||||||
bind:isDirty
|
bind:isDirty
|
||||||
|
testCode={formData.TestSiteCode}
|
||||||
/>
|
/>
|
||||||
{:else if currentTab === 'refnum'}
|
{:else if currentTab === 'refnum'}
|
||||||
<RefNumTab
|
<RefNumTab
|
||||||
|
|||||||
@ -1,21 +1,43 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Plus, Edit2, Trash2, Link } from 'lucide-svelte';
|
import { Plus, Edit2, Trash2, Link } from 'lucide-svelte';
|
||||||
import Modal from '$lib/components/Modal.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 modalOpen = $state(false);
|
||||||
let modalMode = $state('create');
|
let modalMode = $state('create');
|
||||||
let selectedMapping = $state(null);
|
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({
|
let editingMapping = $state({
|
||||||
HostType: '',
|
HostType: '',
|
||||||
HostID: '',
|
HostID: '',
|
||||||
HostDataSource: '',
|
|
||||||
HostTestCode: '',
|
HostTestCode: '',
|
||||||
HostTestName: '',
|
HostTestName: '',
|
||||||
ClientType: '',
|
ClientType: '',
|
||||||
ClientID: '',
|
ClientID: '',
|
||||||
ClientDataSource: '',
|
|
||||||
ConDefID: '',
|
ConDefID: '',
|
||||||
ClientTestCode: '',
|
ClientTestCode: '',
|
||||||
ClientTestName: ''
|
ClientTestName: ''
|
||||||
@ -24,6 +46,242 @@
|
|||||||
const hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
|
const hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
|
||||||
const clientTypes = ['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() {
|
function handleFieldChange() {
|
||||||
isDirty = true;
|
isDirty = true;
|
||||||
}
|
}
|
||||||
@ -33,12 +291,10 @@
|
|||||||
editingMapping = {
|
editingMapping = {
|
||||||
HostType: '',
|
HostType: '',
|
||||||
HostID: '',
|
HostID: '',
|
||||||
HostDataSource: '',
|
|
||||||
HostTestCode: '',
|
HostTestCode: '',
|
||||||
HostTestName: '',
|
HostTestName: '',
|
||||||
ClientType: '',
|
ClientType: '',
|
||||||
ClientID: '',
|
ClientID: '',
|
||||||
ClientDataSource: '',
|
|
||||||
ConDefID: '',
|
ConDefID: '',
|
||||||
ClientTestCode: '',
|
ClientTestCode: '',
|
||||||
ClientTestName: ''
|
ClientTestName: ''
|
||||||
@ -54,20 +310,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeMapping(index) {
|
function removeMapping(index) {
|
||||||
const newMappings = formData.testmap?.filter((_, i) => i !== index) || [];
|
const newMappings = mappingsData.filter((_, i) => i !== index);
|
||||||
formData.testmap = newMappings;
|
mappingsData = newMappings;
|
||||||
|
formData.testmap = [...newMappings];
|
||||||
handleFieldChange();
|
handleFieldChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMapping() {
|
function saveMapping() {
|
||||||
if (modalMode === 'create') {
|
if (modalMode === 'create') {
|
||||||
formData.testmap = [...(formData.testmap || []), { ...editingMapping }];
|
const newMapping = {
|
||||||
|
...editingMapping,
|
||||||
|
TestMapID: `temp-${Date.now()}`
|
||||||
|
};
|
||||||
|
mappingsData = [...mappingsData, newMapping];
|
||||||
} else {
|
} else {
|
||||||
const newMappings = formData.testmap?.map(m =>
|
const newMappings = mappingsData.map(m =>
|
||||||
m === selectedMapping ? { ...editingMapping } : m
|
m === selectedMapping ? { ...editingMapping } : m
|
||||||
) || [];
|
);
|
||||||
formData.testmap = newMappings;
|
mappingsData = newMappings;
|
||||||
}
|
}
|
||||||
|
formData.testmap = [...mappingsData];
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
handleFieldChange();
|
handleFieldChange();
|
||||||
}
|
}
|
||||||
@ -84,9 +346,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<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">
|
<div class="text-center py-8 bg-base-200 rounded-lg">
|
||||||
<Link class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
<Link class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
<p class="text-sm text-gray-500">No mappings configured</p>
|
<p class="text-sm text-gray-500">No mappings configured</p>
|
||||||
@ -98,37 +364,28 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-base-200">
|
<tr class="bg-base-200">
|
||||||
<th>Host System</th>
|
<th>Host System</th>
|
||||||
<th>Host Code</th>
|
|
||||||
<th>Client System</th>
|
<th>Client System</th>
|
||||||
<th>Client Code</th>
|
|
||||||
<th class="w-24 text-center">Actions</th>
|
<th class="w-24 text-center">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each formData.testmap as mapping, idx (idx)}
|
{#each mappingsData as mapping, idx (idx)}
|
||||||
<tr class="hover:bg-base-100">
|
<tr class="hover:bg-base-100">
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<div class="font-medium">{mapping.HostType}</div>
|
<div class="font-medium">{mapping.HostName || mapping.HostTypeLabel || mapping.HostType || '-'}</div>
|
||||||
<div class="text-xs text-gray-500">ID: {mapping.HostID || '-'}</div>
|
<div class="text-xs text-gray-500">{mapping.HostTypeLabel || mapping.HostType} (ID: {mapping.HostID || '-'})</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<div class="font-mono">{mapping.HostTestCode || '-'}</div>
|
<div class="font-medium">{mapping.ClientName || mapping.ClientTypeLabel || mapping.ClientType || '-'}</div>
|
||||||
<div class="text-xs text-gray-500 truncate max-w-[150px]">{mapping.HostTestName || '-'}</div>
|
<div class="text-xs text-gray-500">
|
||||||
</div>
|
{mapping.ClientTypeLabel || mapping.ClientType} (ID: {mapping.ClientID || '-'})
|
||||||
</td>
|
{#if mapping.ClientType === 'INST' && mapping.ContainerName}
|
||||||
<td>
|
<span class="text-primary"> • {mapping.ContainerName}</span>
|
||||||
<div class="text-sm">
|
{/if}
|
||||||
<div class="font-medium">{mapping.ClientType}</div>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -173,7 +430,11 @@
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label text-sm">Type</label>
|
<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>
|
<option value="">Select type...</option>
|
||||||
{#each hostTypes as type (type)}
|
{#each hostTypes as type (type)}
|
||||||
<option value={type}>{type}</option>
|
<option value={type}>{type}</option>
|
||||||
@ -183,12 +444,28 @@
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label text-sm">ID</label>
|
<label class="label text-sm">ID</label>
|
||||||
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostID} placeholder="System ID" />
|
{#if modalMode === 'create' && editingMapping.HostType}
|
||||||
</div>
|
<select class="select select-sm select-bordered" bind:value={editingMapping.HostID}>
|
||||||
|
<option value="">Select {editingMapping.HostType}...</option>
|
||||||
<div class="form-control">
|
{#each getHostOptions() as option (option.value)}
|
||||||
<label class="label text-sm">Data Source</label>
|
<option value={option.value}>{option.label}</option>
|
||||||
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostDataSource} placeholder="e.g., DB, API" />
|
{/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>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@ -207,7 +484,11 @@
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label text-sm">Type</label>
|
<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>
|
<option value="">Select type...</option>
|
||||||
{#each clientTypes as type (type)}
|
{#each clientTypes as type (type)}
|
||||||
<option value={type}>{type}</option>
|
<option value={type}>{type}</option>
|
||||||
@ -217,18 +498,41 @@
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label text-sm">ID</label>
|
<label class="label text-sm">ID</label>
|
||||||
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientID} placeholder="System ID" />
|
{#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>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
{#if editingMapping.ClientType === 'INST'}
|
||||||
<label class="label text-sm">Data Source</label>
|
<div class="form-control">
|
||||||
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientDataSource} placeholder="e.g., DB, API" />
|
<label class="label text-sm">Container</label>
|
||||||
</div>
|
<select class="select select-sm select-bordered" bind:value={editingMapping.ConDefID}>
|
||||||
|
<option value="">Select container...</option>
|
||||||
<div class="form-control">
|
{#each containers as container (container.ConDefID)}
|
||||||
<label class="label text-sm">Container</label>
|
<option value={container.ConDefID}>{container.ConName}</option>
|
||||||
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ConDefID} placeholder="Container definition" />
|
{/each}
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label text-sm">Test Code</label>
|
<label class="label text-sm">Test Code</label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user