refactor(tests): consolidate test management modal components

- Consolidate fragmented test modal components into unified TestFormModal.svelte
- Reorganize reference range components into organized tabs (RefNumTab, RefTxtTab)
- Add new tab components: BasicInfoTab, TechDetailsTab, CalcDetailsTab, MappingsTab, GroupMembersTab
- Move test-related type definitions to src/lib/types/test.types.ts for better type organization
- Delete old component files: TestModal.svelte and 10+ sub-components
- Add backup directory for old component preservation
- Update AGENTS.md with coding guidelines and project conventions
- Update tests.js API client with improved structure
- Add documentation for frontend test management architecture
This commit is contained in:
mahdahar 2026-02-20 13:51:54 +07:00
parent a96a6ee279
commit 99d622ad05
26 changed files with 5365 additions and 480 deletions

193
AGENTS.md
View File

@ -43,48 +43,46 @@ pnpm run prepare
```svelte
<script>
// 1. Imports first - group by: Svelte, $lib, external
// 1. Imports - Svelte, $app, $lib, external
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { auth } from '$lib/stores/auth.js';
import { login } from '$lib/api/auth.js';
import { Icon } from 'lucide-svelte';
// 2. Props (Svelte 5 runes)
let { children, data } = $props();
import { User, Lock } from 'lucide-svelte';
// 2. Props with $bindable for two-way binding
let { open = $bindable(false), title = '', children, footer } = $props();
// 3. State
let loading = $state(false);
let error = $state('');
// 4. Derived state (if needed)
let isValid = $derived(username.length > 0);
// 5. Effects
$effect(() => {
// side effects
});
// 6. Functions
function handleSubmit() {
// implementation
}
$effect(() => { /* side effects */ });
// 6. Functions - prefix handlers with 'handle'
function handleSubmit() { /* implementation */ }
</script>
```
### Naming Conventions
- **Components**: PascalCase (`LoginForm.svelte`)
- **Components**: PascalCase (`LoginForm.svelte`, `PatientFormModal.svelte`)
- **Files/Routes**: lowercase with hyphens (`+page.svelte`, `user-profile/`)
- **Variables**: camelCase (`isLoading`, `userName`)
- **Constants**: UPPER_SNAKE_CASE (`API_URL`, `STORAGE_KEY`)
- **Stores**: camelCase, descriptive (`auth`, `userStore`)
- **Event handlers**: prefix with `handle` (`handleSubmit`, `handleClick`)
- **Form state**: `formLoading`, `formError`, `deleteConfirmOpen`
### Imports Order
1. Svelte imports (`svelte`, `$app/*`)
2. $lib aliases (`$lib/stores/*`, `$lib/api/*`, `$lib/components/*`)
2. $lib aliases (`$lib/stores/*`, `$lib/api/*`, `$lib/components/*`, `$lib/utils/*`)
3. External libraries (`lucide-svelte`)
4. Relative imports (minimize these, prefer `$lib`)
@ -93,12 +91,13 @@ pnpm run prepare
```
src/
lib/
api/ # API client and endpoints
stores/ # Svelte stores
components/ # Reusable components
api/ # API client and endpoints (per feature)
stores/ # Svelte stores (auth, config, valuesets)
components/ # Reusable components (DataTable, Modal, Sidebar)
utils/ # Utilities (toast, helpers)
assets/ # Static assets
routes/ # SvelteKit routes
(app)/ # Route groups
(app)/ # Route groups (protected)
login/
dashboard/
```
@ -106,59 +105,163 @@ src/
### API Client Patterns
```javascript
// src/lib/api/client.js - Base client handles auth
import { apiClient, get, post, put, del } from '$lib/api/client.js';
// src/lib/api/client.js - Base client handles auth, 401 redirects
import { apiClient, get, post, put, patch, del } from '$lib/api/client.js';
// src/lib/api/feature.js - Feature-specific endpoints
export async function getItem(id) {
return get(`/api/items/${id}`);
// src/lib/api/feature.js - Feature-specific endpoints with JSDoc
export async function fetchItems(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/items?${query}` : '/api/items');
}
export async function createItem(data) {
return post('/api/items', data);
}
export async function updateItem(data) {
return patch('/api/items', data);
}
export async function deleteItem(id) {
return del(`/api/items/${id}`);
}
```
### Store Patterns
```javascript
// Use writable for stores with localStorage persistence
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
function createStore() {
const getInitialState = () => {
if (!browser) return { data: null };
return { data: JSON.parse(localStorage.getItem('key')) };
};
const { subscribe, set, update } = writable(getInitialState());
return {
subscribe,
setData: (data) => {
if (browser) localStorage.setItem('key', JSON.stringify(data));
set({ data });
}
};
}
```
### Component Patterns
```svelte
<!-- Use $bindable for two-way props -->
let { open = $bindable(false), selected = $bindable(null) } = $props();
<!-- Use snippets for slot content -->
{@render children?.()}
{@render footer()}
<!-- Dialog with backdrop handling -->
<dialog class="modal" class:modal-open={open}>
<div class="modal-box">
<button onclick={close}>X</button>
{@render children?.()}
</div>
<form method="dialog" class="modal-backdrop" onclick={handleBackdropClick}>
<button>close</button>
</form>
</dialog>
```
### Error Handling
```javascript
// API errors are thrown with message
try {
const result = await api.login(username, password);
const result = await api.fetchData();
toastSuccess('Operation successful');
} catch (err) {
error = err.message || 'An unexpected error occurred';
console.error('Login failed:', err);
const message = err.message || 'An unexpected error occurred';
toastError(message);
console.error('Operation failed:', err);
}
```
### Styling with Tailwind & DaisyUI
- Use Tailwind utility classes
- DaisyUI components: `btn`, `card`, `alert`, `input`, `navbar`
- Color scheme: `primary` (emerald), `base-100`, `base-200`
- Custom colors in `app.css` with `@theme`
- DaisyUI components: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`, `menu`
- Color scheme: `primary` (emerald), `secondary` (dark blue), `accent` (royal blue)
- Custom compact classes: `.compact-y`, `.compact-p`, `.compact-input`, `.compact-btn`, `.compact-card`
- Size modifiers: `.btn-sm`, `.input-sm`, `.select-sm` for compact forms
### Authentication Patterns
- Auth state in `$lib/stores/auth.js`
- Check auth in layout `onMount`
- Redirect to `/login` if unauthenticated
- API client auto-redirects on 401
- Auth state in `$lib/stores/auth.js` using writable store
- Check `$auth.isAuthenticated` in layout `onMount`
- Redirect to `/login` if unauthenticated using `goto('/login')`
- API client auto-redirects on 401 responses
### Environment Variables
### Runtime Config Pattern
```javascript
const API_URL = import.meta.env.VITE_API_URL || '';
// Use runtime config for API URL that can be changed at runtime
import { config } from '$lib/stores/config.js';
function getApiUrl() {
return config.getApiUrl() || import.meta.env.VITE_API_URL || '';
}
```
### LocalStorage
- Only access in browser: check `browser` from `$app/environment`
- Use descriptive keys: `clqms_username`, `clqms_remember`
- Use descriptive keys: `clqms_username`, `clqms_remember`, `auth_token`
### Form Handling Patterns
```javascript
// Form state with validation
let formLoading = $state(false);
let formError = $state('');
let formData = $state({ username: '', password: '' });
function validateForm() {
formError = '';
if (!formData.username.trim()) {
formError = 'Username is required';
return false;
}
return true;
}
async function handleSubmit() {
if (!validateForm()) return;
formLoading = true;
try {
await api.submit(formData);
toastSuccess('Success');
} catch (err) {
formError = err.message;
} finally {
formLoading = false;
}
}
```
### Toast/Notification System
```javascript
import { success, error, info, warning } from '$lib/utils/toast.js';
// Use toast notifications for user feedback
success('Item created successfully');
error('Failed to create item');
info('New message received');
warning('Action requires confirmation');
```
## Proxy Configuration
API requests to `/api` are proxied to `http://localhost:8000` in dev.
API requests to `/api` are proxied to `http://localhost:8000` in dev. See `vite.config.js`.
## API Documentation
@ -168,5 +271,7 @@ API Reference (Swagger UI): https://clqms01-api.services-summit.my.id/swagger/
- No ESLint or Prettier configured yet - add if needed
- No test framework configured yet
- Uses Svelte 5 runes: `$props`, `$state`, `$derived`, `$effect`
- Uses Svelte 5 runes: `$props`, `$state`, `$derived`, `$effect`, `$bindable`
- SvelteKit file-based routing with `+page.svelte`, `+layout.svelte`
- Static adapter configured for static export
- Runtime config allows API URL changes without rebuild

View File

@ -0,0 +1,432 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { fetchTests, fetchTest, createTest, updateTest, deleteTest } from '$lib/api/tests.js';
import { fetchDisciplines, fetchDepartments } from '$lib/api/organization.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import TestModal from './TestModal.svelte';
import TestTypeSelector from './test-modal/TestTypeSelector.svelte';
import { validateNumericRange, validateTextRange } from './referenceRange.js';
import { Plus, Edit2, Trash2, ArrowLeft, Filter, Search, ChevronDown, ChevronRight, Microscope, Variable, Calculator, Box, Layers } from 'lucide-svelte';
let loading = $state(false), tests = $state([]), disciplines = $state([]), departments = $state([]);
let modalOpen = $state(false), selectedRowIndex = $state(-1), expandedGroups = $state(new Set()), typeSelectorOpen = $state(false);
let currentPage = $state(1), perPage = $state(20), totalItems = $state(0), totalPages = $state(1);
let modalMode = $state('create'), saving = $state(false), selectedType = $state(''), searchQuery = $state(''), searchInputRef = $state(null);
let deleteModalOpen = $state(false), testToDelete = $state(null), deleting = $state(false);
let formData = $state({
// Basic Info (testdefsite)
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
Description: '',
CountStat: false,
Unit: '',
Formula: '',
refnum: [],
reftxt: [],
refRangeType: 'none',
// Technical Config (flat structure)
ResultType: '',
RefType: '',
ReqQty: null,
ReqQtyUnit: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 0,
CollReq: '',
Method: '',
ExpectedTAT: null,
// Group Members (testdefgrp)
groupMembers: []
});
const testTypeConfig = { TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' }, PARAM: { label: 'Parameter', badgeClass: 'badge-secondary', icon: Variable, color: '#3399FF', bgColor: '#F0F8FF' }, CALC: { label: 'Calculated', badgeClass: 'badge-accent', icon: Calculator, color: '#9933CC', bgColor: '#F5E6FF' }, GROUP: { label: 'Panel', badgeClass: 'badge-info', icon: Box, color: '#00AA44', bgColor: '#E6F9EE' }, TITLE: { label: 'Header', badgeClass: 'badge-ghost', icon: Layers, color: '#666666', bgColor: '#F5F5F5' } };
const canHaveRefRange = $derived(formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC');
const isNoResultType = $derived(formData.TestType === 'GROUP' || formData.TestType === 'TITLE');
const canHaveFormula = $derived(formData.TestType === 'CALC');
const canHaveTechnical = $derived(formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC');
const isGroupTest = $derived(formData.TestType === 'GROUP');
const columns = [{ key: 'expand', label: '', class: 'w-8' }, { key: 'TestSiteCode', label: 'Code', class: 'font-medium w-24' }, { key: 'TestSiteName', label: 'Name', class: 'min-w-[200px]' }, { key: 'TestType', label: 'Type', class: 'w-28' }, { key: 'ReferenceRange', label: 'Reference Range', class: 'w-40' }, { key: 'Unit', label: 'Unit', class: 'w-20' }, { key: 'actions', label: 'Actions', class: 'w-24 text-center' }];
const disciplineOptions = $derived(disciplines.map(d => ({ value: d.DisciplineID, label: d.DisciplineName })));
const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName })));
onMount(async () => { document.addEventListener('keydown', handleKeydown); await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]); });
onDestroy(() => document.removeEventListener('keydown', handleKeydown));
async function loadTests() { loading = true; try { const params = { page: currentPage, perPage }; if (selectedType) params.TestType = selectedType; if (searchQuery.trim()) params.search = searchQuery.trim(); const response = await fetchTests(params); let allTests = Array.isArray(response.data) ? response.data : []; allTests = allTests.filter(test => test.IsActive !== '0' && test.IsActive !== 0); tests = allTests; if (response.pagination) { totalItems = response.pagination.total || 0; totalPages = Math.ceil(totalItems / perPage) || 1; } selectedRowIndex = -1; } catch (err) { toastError(err.message || 'Failed to load tests'); tests = []; } finally { loading = false; } }
async function loadDisciplines() { try { const response = await fetchDisciplines(); disciplines = Array.isArray(response.data) ? response.data : []; } catch (err) { disciplines = []; } }
async function loadDepartments() { try { const response = await fetchDepartments(); departments = Array.isArray(response.data) ? response.data : []; } catch (err) { departments = []; } }
function handleKeydown(e) { if (e.key === '/' && !modalOpen && !deleteModalOpen) { e.preventDefault(); searchInputRef?.focus(); return; } if (e.key === 'Escape') { if (modalOpen) modalOpen = false; else if (deleteModalOpen) deleteModalOpen = false; return; } if (!modalOpen && !deleteModalOpen && document.activeElement === document.body) { const visibleTests = getVisibleTests(); if (e.key === 'ArrowDown' && selectedRowIndex < visibleTests.length - 1) selectedRowIndex++; else if (e.key === 'ArrowUp' && selectedRowIndex > 0) selectedRowIndex--; else if (e.key === 'Enter' && selectedRowIndex >= 0) openEditModal(visibleTests[selectedRowIndex]); } }
function getVisibleTests() { return tests.filter(t => t.IsActive !== '0' && t.IsActive !== 0); }
function getTestTypeConfig(type) { return testTypeConfig[type] || testTypeConfig.TEST; }
function formatReferenceRange(test) { return '-'; }
function openTypeSelector() {
typeSelectorOpen = true;
}
function handleTypeSelect(type) {
typeSelectorOpen = false;
openCreateModal(type);
}
function openCreateModal(type = 'TEST') {
modalMode = 'create';
// Determine ResultType based on TestType
const getDefaultResultType = (testType) => {
if (testType === 'GROUP' || testType === 'TITLE') return 'NORES';
if (testType === 'CALC') return 'NMRIC';
return '';
};
formData = {
// Basic Info
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: type,
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
Description: '',
CountStat: false,
Unit: '',
Formula: '',
refnum: [],
reftxt: [],
refRangeType: 'none',
// Technical Config
ResultType: getDefaultResultType(type),
RefType: type === 'CALC' ? 'RANGE' : '',
ReqQty: null,
ReqQtyUnit: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 0,
CollReq: '',
Method: '',
ExpectedTAT: null,
// Group Members
groupMembers: []
};
modalOpen = true;
}
async function openEditModal(row) {
try {
// Fetch full test details including reference ranges, technical config, group members
const response = await fetchTest(row.TestSiteID);
const testDetail = response.data;
modalMode = 'edit';
// Consolidate refthold into refnum and refvset into reftxt
const consolidatedRefnum = [
...(testDetail.refnum || []),
...(testDetail.refthold || []).map(ref => ({ ...ref, RefType: 'THOLD' }))
];
const consolidatedReftxt = [
...(testDetail.reftxt || []),
...(testDetail.refvset || []).map(ref => ({ ...ref, RefType: 'VSET' }))
];
// Determine refRangeType based on ResultType and consolidated arrays
let refRangeType = 'none';
if (testDetail.TestType === 'GROUP' || testDetail.TestType === 'TITLE') {
refRangeType = 'none';
} else if (testDetail.ResultType === 'NMRIC' || testDetail.ResultType === 'RANGE') {
const hasThold = consolidatedRefnum.some(ref => ref.RefType === 'THOLD');
refRangeType = hasThold ? 'thold' : 'num';
} else if (testDetail.ResultType === 'VSET') {
refRangeType = 'vset';
} else if (testDetail.ResultType === 'TEXT') {
refRangeType = 'text';
}
// Normalize reference range data to ensure all fields have values (not undefined)
const normalizeRefNum = (ref) => ({
RefType: ref.RefType ?? 'REF',
Sex: ref.Sex ?? '0',
LowSign: ref.LowSign ?? 'GE',
HighSign: ref.HighSign ?? 'LE',
Low: ref.Low ?? null,
High: ref.High ?? null,
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
Flag: ref.Flag ?? 'N',
Interpretation: ref.Interpretation ?? '',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
const normalizeRefTxt = (ref) => ({
RefType: ref.RefType ?? 'TEXT',
Sex: ref.Sex ?? '0',
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
RefTxt: ref.RefTxt ?? '',
Flag: ref.Flag ?? 'N',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
formData = {
// Basic Info
TestSiteID: testDetail.TestSiteID,
TestSiteCode: testDetail.TestSiteCode,
TestSiteName: testDetail.TestSiteName,
TestType: testDetail.TestType,
DisciplineID: testDetail.DisciplineID || null,
DepartmentID: testDetail.DepartmentID || null,
SeqScr: testDetail.SeqScr || '0',
SeqRpt: testDetail.SeqRpt || '0',
VisibleScr: testDetail.VisibleScr === '1' || testDetail.VisibleScr === 1 || testDetail.VisibleScr === true,
VisibleRpt: testDetail.VisibleRpt === '1' || testDetail.VisibleRpt === 1 || testDetail.VisibleRpt === true,
Description: testDetail.Description || '',
CountStat: testDetail.CountStat === '1' || testDetail.CountStat === 1 || testDetail.CountStat === true,
Unit: testDetail.Unit || '',
Formula: testDetail.Formula || '',
refnum: consolidatedRefnum.map(normalizeRefNum),
reftxt: consolidatedReftxt.map(normalizeRefTxt),
refRangeType,
// Technical Config (flat structure)
ResultType: testDetail.ResultType || (testDetail.TestType === 'GROUP' || testDetail.TestType === 'TITLE' ? 'NORES' : ''),
RefType: testDetail.RefType || (testDetail.TestType === 'GROUP' || testDetail.TestType === 'TITLE' ? 'NOREF' : ''),
ReqQty: testDetail.ReqQty || null,
ReqQtyUnit: testDetail.ReqQtyUnit || '',
Unit1: testDetail.Unit1 || '',
Factor: testDetail.Factor || null,
Unit2: testDetail.Unit2 || '',
Decimal: testDetail.Decimal || 0,
Method: testDetail.Method || '',
ExpectedTAT: testDetail.ExpectedTAT || null,
// Group Members - API returns as testdefgrp
groupMembers: testDetail.testdefgrp || []
};
modalOpen = true;
} catch (err) {
toastError(err.message || 'Failed to load test details');
console.error('Failed to fetch test details:', err);
}
}
function isDuplicateCode(code, excludeId = null) { return tests.some(test => test.TestSiteCode.toLowerCase() === code.toLowerCase() && test.TestSiteID !== excludeId); }
async function handleSave() {
if (isDuplicateCode(formData.TestSiteCode, modalMode === 'edit' ? formData.TestSiteID : null)) { toastError(`Test code '${formData.TestSiteCode}' already exists`); return; }
if (canHaveFormula && !formData.Formula.trim()) { toastError('Formula is required for calculated tests'); return; }
// Validate TestType-ResultType relationship
if (formData.TestType === 'CALC' && formData.ResultType !== 'NMRIC') {
toastError('Calculated tests must have ResultType NMRIC'); return;
}
if ((formData.TestType === 'GROUP' || formData.TestType === 'TITLE') && formData.ResultType !== 'NORES') {
toastError('Group and Title tests must have ResultType NORES'); return;
}
if ((formData.TestType === 'TEST' || formData.TestType === 'PARAM') && !['NMRIC', 'RANGE', 'TEXT', 'VSET'].includes(formData.ResultType)) {
toastError('Test and Parameter must have ResultType NMRIC, RANGE, TEXT, or VSET'); return;
}
// Validate ResultType-RefType relationship
if (formData.ResultType === 'NMRIC' || formData.ResultType === 'RANGE') {
if (!['RANGE', 'THOLD'].includes(formData.RefType)) {
toastError('NMRIC and RANGE ResultTypes must have RefType RANGE or THOLD'); return;
}
} else if (formData.ResultType === 'VSET') {
if (formData.RefType !== 'VSET') {
toastError('VSET ResultType must have RefType VSET'); return;
}
} else if (formData.ResultType === 'TEXT') {
if (formData.RefType !== 'TEXT') {
toastError('TEXT ResultType must have RefType TEXT'); return;
}
} else if (formData.ResultType === 'NORES') {
if (formData.RefType !== 'NOREF') {
toastError('NORES ResultType must have RefType NOREF'); return;
}
}
// Validate reference ranges based on type
if (formData.refRangeType === 'num' || formData.refRangeType === 'thold') {
const rangesToValidate = (formData.refnum || []).filter(ref =>
formData.refRangeType === 'num' ? ref.RefType !== 'THOLD' : ref.RefType === 'THOLD'
);
for (let i = 0; i < rangesToValidate.length; i++) {
const errors = validateNumericRange(rangesToValidate[i], i);
if (errors.length > 0) { toastError(errors[0]); return; }
}
}
else if (formData.refRangeType === 'text' || formData.refRangeType === 'vset') {
const rangesToValidate = (formData.reftxt || []).filter(ref =>
formData.refRangeType === 'text' ? ref.RefType !== 'VSET' : ref.RefType === 'VSET'
);
for (let i = 0; i < rangesToValidate.length; i++) {
const errors = validateTextRange(rangesToValidate[i], i);
if (errors.length > 0) { toastError(errors[0]); return; }
}
}
saving = true;
try {
const payload = { ...formData };
// Remove fields based on test type
if (!canHaveFormula) delete payload.Formula;
// Handle reference ranges based on ResultType
if (isNoResultType) {
// GROUP and TITLE have no reference ranges
delete payload.refnum;
delete payload.reftxt;
} else if (formData.ResultType === 'NMRIC' || formData.ResultType === 'RANGE') {
// NMRIC/RANGE uses refnum table
if (formData.RefType === 'THOLD') {
payload.refnum = (formData.refnum || []).filter(ref => ref.RefType === 'THOLD');
} else {
payload.refnum = (formData.refnum || []).filter(ref => ref.RefType !== 'THOLD');
}
delete payload.reftxt;
} else if (formData.ResultType === 'VSET') {
// VSET uses reftxt table with RefType VSET
payload.reftxt = (formData.reftxt || []).filter(ref => ref.RefType === 'VSET');
delete payload.refnum;
} else if (formData.ResultType === 'TEXT') {
// TEXT uses reftxt table with RefType TEXT
payload.reftxt = (formData.reftxt || []).filter(ref => ref.RefType === 'TEXT');
delete payload.refnum;
} else {
delete payload.refnum;
delete payload.reftxt;
}
if (!canHaveTechnical) {
delete payload.ResultType;
delete payload.RefType;
delete payload.Unit1;
delete payload.Factor;
delete payload.Unit2;
delete payload.Decimal;
delete payload.ReqQty;
delete payload.ReqQtyUnit;
delete payload.CollReq;
delete payload.Method;
delete payload.ExpectedTAT;
}
if (!isGroupTest) {
delete payload.groupMembers;
}
delete payload.refRangeType;
if (modalMode === 'create') {
await createTest(payload);
toastSuccess('Test created successfully');
} else {
await updateTest(payload);
toastSuccess('Test updated successfully');
}
modalOpen = false;
await loadTests();
} catch (err) {
toastError(err.message || 'Failed to save test');
} finally {
saving = false;
}
}
function openDeleteModal(row) { testToDelete = row; deleteModalOpen = true; }
async function handleDelete() { if (!testToDelete?.TestSiteID) return; deleting = true; try { await deleteTest(testToDelete.TestSiteID); toastSuccess('Test deleted successfully'); deleteModalOpen = false; testToDelete = null; await loadTests(); } catch (err) { toastError(err.message || 'Failed to delete test'); } finally { deleting = false; } }
function handleFilter() { currentPage = 1; loadTests(); }
function handleSearch() { currentPage = 1; loadTests(); }
function handlePageChange(newPage) { if (newPage >= 1 && newPage <= totalPages) { currentPage = newPage; selectedRowIndex = -1; loadTests(); } }
function handleRowClick(index) { selectedRowIndex = index; }
function toggleGroup(testId) { if (expandedGroups.has(testId)) expandedGroups.delete(testId); else expandedGroups.add(testId); expandedGroups = new Set(expandedGroups); }
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle"><ArrowLeft class="w-5 h-5" /></a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Test Definitions</h1>
<p class="text-sm text-gray-600">Manage laboratory tests, panels, and calculated values</p>
</div>
<button class="btn btn-primary" onclick={openTypeSelector}><Plus class="w-4 h-4 mr-2" />Add Test</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<input type="text" placeholder="Search by code or name..." class="input input-sm input-bordered w-full pl-10" bind:value={searchQuery} bind:this={searchInputRef} onkeydown={(e) => e.key === 'Enter' && handleSearch()} />
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
<div class="w-full sm:w-48">
<select class="select select-sm select-bordered w-full" bind:value={selectedType} onchange={handleFilter}>
<option value="">All Types</option>
<option value="TEST">Single Test</option>
<option value="PARAM">Parameter</option>
<option value="CALC">Calculated</option>
<option value="GROUP">Panel/Profile</option>
<option value="TITLE">Section Header</option>
</select>
</div>
<button class="btn btn-outline" onclick={handleFilter}><Filter class="w-4 h-4 mr-2" />Filter</button>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<DataTable {columns} data={getVisibleTests()} {loading} emptyMessage="No tests found" hover={true} bordered={false} onRowClick={(row, idx) => handleRowClick(idx)}>
{#snippet cell({ column, row, index })}
{@const isSelected = index === selectedRowIndex} {@const typeConfig = getTestTypeConfig(row.TestType)} {@const isGroup = row.TestType === 'GROUP'} {@const isExpanded = expandedGroups.has(row.TestSiteID)}
{#if column.key === 'expand'}{#if isGroup}<button class="btn btn-ghost btn-xs btn-circle" onclick={() => toggleGroup(row.TestSiteID)}>{#if isExpanded}<ChevronDown class="w-4 h-4" />{:else}<ChevronRight class="w-4 h-4" />{/if}</button>{:else}<span class="w-8 inline-block"></span>{/if}{/if}
{#if column.key === 'TestType'}<span class="badge {typeConfig.badgeClass} gap-1" style="background-color: {typeConfig.bgColor}; color: {typeConfig.color}; border-color: {typeConfig.color};"><svelte:component this={typeConfig.icon} class="w-3 h-3" />{typeConfig.label}</span>{/if}
{#if column.key === 'TestSiteName'}<div class="flex flex-col"><span class="font-medium">{row.TestSiteName}</span>{#if isGroup && isExpanded && row.testdefgrp}<div class="mt-2 ml-4 pl-3 border-l-2 border-base-300 space-y-1">{#each row.testdefgrp as member}{@const memberConfig = getTestTypeConfig(member.TestType)}<div class="flex items-center gap-2 text-sm text-gray-600 py-1"><svelte:component this={memberConfig.icon} class="w-3 h-3" style="color: {memberConfig.color}" /><span class="font-mono text-xs">{member.TestSiteCode}</span><span>{member.TestSiteName}</span></div>{/each}</div>{/if}</div>{/if}
{#if column.key === 'ReferenceRange'}<span class="text-sm font-mono text-gray-600">{formatReferenceRange(row)}</span>{/if}
{#if column.key === 'actions'}<div class="flex justify-center gap-1"><button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}><Edit2 class="w-4 h-4" /></button><button class="btn btn-sm btn-ghost text-error" onclick={() => openDeleteModal(row)}><Trash2 class="w-4 h-4" /></button></div>{/if}
{#if column.key === 'TestSiteCode'}<span class="font-mono text-sm">{row.TestSiteCode}</span>{/if}
{/snippet}
</DataTable>
{#if totalPages > 1}<div class="flex items-center justify-between px-4 py-3 border-t border-base-200 bg-base-100"><div class="text-sm text-gray-600">Showing {(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}</div><div class="flex gap-2"><button class="btn btn-sm btn-ghost" onclick={() => handlePageChange(currentPage - 1)} disabled={currentPage === 1}>Previous</button><span class="btn btn-sm btn-ghost no-animation">Page {currentPage} of {totalPages}</span><button class="btn btn-sm btn-ghost" onclick={() => handlePageChange(currentPage + 1)} disabled={currentPage === totalPages}>Next</button></div></div>{/if}
</div>
</div>
<Modal bind:open={typeSelectorOpen} title="Add Test" size="md">
<TestTypeSelector
onselect={handleTypeSelect}
oncancel={() => typeSelectorOpen = false}
/>
</Modal>
<TestModal
bind:open={modalOpen}
mode={modalMode}
bind:formData
{canHaveRefRange}
{canHaveFormula}
{canHaveTechnical}
{isGroupTest}
{disciplineOptions}
departmentOptions={departmentOptions}
availableTests={tests}
{saving}
onsave={handleSave}
oncancel={() => modalOpen = false}
onupdateFormData={(data) => formData = data}
/>
<Modal bind:open={deleteModalOpen} title="Confirm Delete Test" size="sm">
<div class="py-2">
<p>Are you sure you want to delete this test?</p>
<p class="text-sm text-gray-600 mt-2">Code: <strong>{testToDelete?.TestSiteCode}</strong><br/>Name: <strong>{testToDelete?.TestSiteName}</strong></p>
<p class="text-sm text-warning mt-2">This will deactivate the test. Historical data will be preserved.</p>
</div>
{#snippet footer()}<button class="btn btn-ghost" onclick={() => deleteModalOpen = false} type="button">Cancel</button><button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">{#if deleting}<span class="loading loading-spinner loading-sm mr-2"></span>{/if}{deleting ? 'Deleting...' : 'Delete'}</button>{/snippet}
</Modal>

View File

@ -124,11 +124,13 @@
{: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}

View File

@ -8,12 +8,14 @@
/**
* @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();
@ -26,13 +28,37 @@
'vset': 'Value Set (VSET)'
};
const refTypeOptions = [
{ value: 'none', label: 'None - No reference range' },
{ 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' }
];
// 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) {
@ -75,6 +101,62 @@
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 || [];
@ -157,12 +239,13 @@
<!-- Dropdown Select -->
<div class="form-control">
<select
class="select select-bordered w-full"
<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)}
{#each refTypeOptions() as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>

View File

@ -6,12 +6,14 @@
/**
* @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();
@ -29,10 +31,26 @@
// 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);
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) {
@ -46,6 +64,23 @@
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>
@ -71,9 +106,12 @@
id="resultType"
class="select select-sm select-bordered w-full"
bind:value={formData.ResultType}
onchange={(e) => updateField('ResultType', e.target.value)}
onchange={(e) => handleResultTypeChange(e.target.value)}
disabled={testType === 'GROUP' || testType === 'TITLE'}
>
<option value="">Select result type...</option>
{#if testType !== 'GROUP' && testType !== 'TITLE'}
<option value="">Select result type...</option>
{/if}
{#each resultTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}

File diff suppressed because it is too large Load Diff

View File

@ -1,100 +1,338 @@
import { get, post, patch } from './client.js';
/**
* @typedef {import('$lib/types/test.types.js').TestSummary} TestSummary
* @typedef {import('$lib/types/test.types.js').TestDetail} TestDetail
* @typedef {import('$lib/types/test.types.js').CreateTestPayload} CreateTestPayload
* @typedef {import('$lib/types/test.types.js').UpdateTestPayload} UpdateTestPayload
* @typedef {import('$lib/types/test.types.js').TestListResponse} TestListResponse
* @typedef {import('$lib/types/test.types.js').TestDetailResponse} TestDetailResponse
* @typedef {import('$lib/types/test.types.js').CreateTestResponse} CreateTestResponse
* @typedef {import('$lib/types/test.types.js').UpdateTestResponse} UpdateTestResponse
* @typedef {import('$lib/types/test.types.js').DeleteTestResponse} DeleteTestResponse
* @typedef {import('$lib/types/test.types.js').TestFilterOptions} TestFilterOptions
*/
/**
* Fetch tests list with optional filters, pagination, and search
* @param {Object} params - Query parameters
* @param {number} [params.page=1] - Page number
* @param {number} [params.perPage=20] - Items per page
* @param {string} [params.search] - Search by test code or name
* @param {string} [params.TestType] - Filter by test type (TEST, PARAM, CALC, GROUP, TITLE)
* @returns {Promise<Object>} API response with test data and pagination
* @param {TestFilterOptions & { page?: number, perPage?: number }} [params] - Query parameters
* @returns {Promise<TestListResponse>} API response with test data and pagination
*/
export async function fetchTests(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/tests?${query}` : '/api/tests');
}
/**
* Fetch a single test by ID
* @param {number} id - Test Site ID
* @returns {Promise<TestDetailResponse>} API response with test detail
*/
export async function fetchTest(id) {
return get(`/api/tests/${id}`);
}
export async function createTest(data) {
/**
* Build payload from form state for API submission
* Follows the specification: nested details object for technical fields
* @param {any} formData - The form state
* @param {boolean} isUpdate - Whether this is an update operation
* @returns {CreateTestPayload | UpdateTestPayload}
*/
function buildPayload(formData, isUpdate = false) {
/** @type {CreateTestPayload} */
const payload = {
TestSiteCode: data.TestSiteCode,
TestSiteName: data.TestSiteName,
TestType: data.TestType,
DisciplineID: data.DisciplineID,
DepartmentID: data.DepartmentID,
SeqScr: data.SeqScr,
SeqRpt: data.SeqRpt,
VisibleScr: data.VisibleScr ? '1' : '0',
VisibleRpt: data.VisibleRpt ? '1' : '0',
// Type-specific fields
Unit: data.Unit,
Formula: data.Formula,
// Technical Config
ResultType: data.ResultType,
RefType: data.RefType,
SpcType: data.SpcType,
ReqQty: data.ReqQty,
ReqQtyUnit: data.ReqQtyUnit,
Unit1: data.Unit1,
Factor: data.Factor,
Unit2: data.Unit2,
Decimal: data.Decimal,
CollReq: data.CollReq,
Method: data.Method,
ExpectedTAT: data.ExpectedTAT,
// Reference ranges (only for TEST and CALC)
refnum: data.refnum,
reftxt: data.reftxt,
refvset: data.refvset,
refthold: data.refthold,
SiteID: formData.SiteID || 1,
TestSiteCode: formData.TestSiteCode,
TestSiteName: formData.TestSiteName,
TestType: formData.TestType,
Description: formData.Description,
SeqScr: parseInt(formData.SeqScr) || 0,
SeqRpt: parseInt(formData.SeqRpt) || 0,
VisibleScr: formData.VisibleScr ? 1 : 0,
VisibleRpt: formData.VisibleRpt ? 1 : 0,
CountStat: formData.CountStat ? 1 : 0,
// StartDate is auto-set by backend (created_at)
details: {},
refnum: [],
reftxt: [],
testmap: []
};
// Add TestSiteID for updates
if (isUpdate && formData.TestSiteID) {
payload.TestSiteID = formData.TestSiteID;
}
// Build details object based on TestType
const type = formData.TestType;
// Common fields for TEST, PARAM, CALC
if (['TEST', 'PARAM', 'CALC'].includes(type)) {
payload.details = {
DisciplineID: formData.details?.DisciplineID || null,
DepartmentID: formData.details?.DepartmentID || null,
ResultType: formData.details?.ResultType || '',
RefType: formData.details?.RefType || '',
VSet: formData.details?.VSet || '',
Unit1: formData.details?.Unit1 || '',
Factor: formData.details?.Factor || null,
Unit2: formData.details?.Unit2 || '',
Decimal: formData.details?.Decimal || 2,
ReqQty: formData.details?.ReqQty || null,
ReqQtyUnit: formData.details?.ReqQtyUnit || '',
CollReq: formData.details?.CollReq || '',
Method: formData.details?.Method || '',
ExpectedTAT: formData.details?.ExpectedTAT || null
};
// Add formula fields for CALC type
if (type === 'CALC') {
payload.details.FormulaInput = formData.details?.FormulaInput || '';
payload.details.FormulaCode = formData.details?.FormulaCode || '';
}
}
// Add members for GROUP type
if (type === 'GROUP' && formData.details?.members) {
payload.details = {
members: formData.details.members
};
}
// Add reference ranges (TEST and PARAM only)
if (['TEST', 'PARAM'].includes(type)) {
if (formData.refnum && Array.isArray(formData.refnum)) {
payload.refnum = formData.refnum.map(r => ({
NumRefType: r.NumRefType,
RangeType: r.RangeType,
Sex: r.Sex,
AgeStart: parseInt(r.AgeStart) || 0,
AgeEnd: parseInt(r.AgeEnd) || 150,
LowSign: r.LowSign || null,
Low: r.Low !== null && r.Low !== undefined ? parseFloat(r.Low) : null,
HighSign: r.HighSign || null,
High: r.High !== null && r.High !== undefined ? parseFloat(r.High) : null,
Flag: r.Flag || null,
Interpretation: r.Interpretation || null
}));
}
if (formData.reftxt && Array.isArray(formData.reftxt)) {
payload.reftxt = formData.reftxt.map(r => ({
TxtRefType: r.TxtRefType,
Sex: r.Sex,
AgeStart: parseInt(r.AgeStart) || 0,
AgeEnd: parseInt(r.AgeEnd) || 150,
RefTxt: r.RefTxt || '',
Flag: r.Flag || null
}));
}
}
// Add mappings (all types)
if (formData.testmap && Array.isArray(formData.testmap)) {
payload.testmap = formData.testmap.map(m => ({
HostType: m.HostType || null,
HostID: m.HostID || null,
HostDataSource: m.HostDataSource || null,
HostTestCode: m.HostTestCode || null,
HostTestName: m.HostTestName || null,
ClientType: m.ClientType || null,
ClientID: m.ClientID || null,
ClientDataSource: m.ClientDataSource || null,
ConDefID: m.ConDefID || null,
ClientTestCode: m.ClientTestCode || null,
ClientTestName: m.ClientTestName || null
}));
}
return payload;
}
/**
* Create a new test
* @param {any} formData - The form state
* @returns {Promise<CreateTestResponse>} API response
*/
export async function createTest(formData) {
const payload = buildPayload(formData, false);
return post('/api/tests', payload);
}
export async function updateTest(data) {
const payload = {
TestSiteID: data.TestSiteID,
TestSiteCode: data.TestSiteCode,
TestSiteName: data.TestSiteName,
TestType: data.TestType,
DisciplineID: data.DisciplineID,
DepartmentID: data.DepartmentID,
SeqScr: data.SeqScr,
SeqRpt: data.SeqRpt,
VisibleScr: data.VisibleScr ? '1' : '0',
VisibleRpt: data.VisibleRpt ? '1' : '0',
// Type-specific fields
Unit: data.Unit,
Formula: data.Formula,
// Technical Config
ResultType: data.ResultType,
RefType: data.RefType,
SpcType: data.SpcType,
ReqQty: data.ReqQty,
ReqQtyUnit: data.ReqQtyUnit,
Unit1: data.Unit1,
Factor: data.Factor,
Unit2: data.Unit2,
Decimal: data.Decimal,
CollReq: data.CollReq,
Method: data.Method,
ExpectedTAT: data.ExpectedTAT,
// Reference ranges (only for TEST and CALC)
refnum: data.refnum,
reftxt: data.reftxt,
refvset: data.refvset,
refthold: data.refthold,
};
/**
* Update an existing test
* @param {any} formData - The form state
* @returns {Promise<UpdateTestResponse>} API response
*/
export async function updateTest(formData) {
const payload = buildPayload(formData, true);
return patch('/api/tests', payload);
}
/**
* Soft delete a test (set IsActive to '0')
* @param {number} id - Test Site ID
* @returns {Promise<DeleteTestResponse>} API response
*/
export async function deleteTest(id) {
// Soft delete - set IsActive to '0'
return patch('/api/tests', {
TestSiteID: id,
IsActive: '0',
});
}
/**
* Validate test type combination
* According to business rules: TestType + ResultType + RefType must be valid
* @param {string} testType - The test type
* @param {string} resultType - The result type
* @param {string} refType - The reference type
* @returns {{ valid: boolean, error?: string }}
*/
export function validateTypeCombination(testType, resultType, refType) {
// Valid combinations based on specification
const validCombinations = {
TEST: {
NMRIC: ['RANGE', 'THOLD'],
RANGE: ['RANGE', 'THOLD'],
TEXT: ['TEXT'],
VSET: ['VSET']
},
PARAM: {
NMRIC: ['RANGE', 'THOLD'],
RANGE: ['RANGE', 'THOLD'],
TEXT: ['TEXT'],
VSET: ['VSET']
},
CALC: {
NMRIC: ['RANGE', 'THOLD']
},
GROUP: {
NORES: ['NOREF']
},
TITLE: {
NORES: ['NOREF']
}
};
const validResultTypes = validCombinations[testType];
if (!validResultTypes) {
return { valid: false, error: 'Invalid test type' };
}
if (!resultType) {
return { valid: true }; // ResultType might be optional
}
const validRefTypes = validResultTypes[resultType];
if (!validRefTypes) {
return { valid: false, error: `Result type '${resultType}' is not valid for '${testType}'` };
}
if (refType && !validRefTypes.includes(refType)) {
return { valid: false, error: `Reference type '${refType}' is not valid for '${testType}' + '${resultType}'` };
}
return { valid: true };
}
/**
* Get valid result types for a test type
* @param {string} testType - The test type
* @returns {Array<{value: string, label: string}>}
*/
export function getResultTypeOptions(testType) {
switch (testType) {
case 'CALC':
return [{ value: 'NMRIC', label: 'Numeric' }];
case 'GROUP':
case 'TITLE':
return [{ value: 'NORES', label: 'No Result' }];
default:
return [
{ value: 'NMRIC', label: 'Numeric' },
{ value: 'RANGE', label: 'Range' },
{ value: 'TEXT', label: 'Text' },
{ value: 'VSET', label: 'Value Set' }
];
}
}
/**
* Get valid reference types for a result type
* @param {string} resultType - The result type
* @returns {Array<{value: string, label: string}>}
*/
export function getRefTypeOptions(resultType) {
switch (resultType) {
case 'NMRIC':
case 'RANGE':
return [
{ value: 'RANGE', label: 'Range' },
{ value: 'THOLD', label: 'Threshold' }
];
case 'VSET':
return [{ value: 'VSET', label: 'Value Set' }];
case 'TEXT':
return [{ value: 'TEXT', label: 'Text' }];
case 'NORES':
return [{ value: 'NOREF', label: 'No Reference' }];
default:
return [];
}
}
/**
* Validate test code according to specification
* - Required
* - 3-6 characters
* - Uppercase alphanumeric only
* - Regex: ^[A-Z0-9]{3,6}$
* @param {string} code - The test code
* @returns {{ valid: boolean, error?: string }}
*/
export function validateTestCode(code) {
if (!code || code.trim() === '') {
return { valid: false, error: 'Test code is required' };
}
const trimmed = code.trim();
const regex = /^[A-Z0-9]+$/;
if (!regex.test(trimmed)) {
return { valid: false, error: 'Test code must be uppercase alphanumeric' };
}
return { valid: true };
}
/**
* Validate test name according to specification
* - Required
* - 3-255 characters
* - Alphanumeric with hyphen, space, and parentheses allowed
* - Regex: ^[a-zA-Z0-9\s\-\(\)]{3,255}$
* @param {string} name - The test name
* @returns {{ valid: boolean, error?: string }}
*/
export function validateTestName(name) {
if (!name || name.trim() === '') {
return { valid: false, error: 'Test name is required' };
}
const trimmed = name.trim();
if (trimmed.length < 3) {
return { valid: false, error: 'Test name must be at least 3 characters' };
}
if (trimmed.length > 255) {
return { valid: false, error: 'Test name must be at most 255 characters' };
}
const regex = /^[a-zA-Z0-9\s\-\(\)]{3,255}$/;
if (!regex.test(trimmed)) {
return { valid: false, error: 'Test name can only contain letters, numbers, spaces, hyphens, and parentheses' };
}
return { valid: true };
}

26
src/lib/types/index.ts Normal file
View File

@ -0,0 +1,26 @@
/**
* Type Definitions Index
* Central export point for all type definitions
*/
// Test Types
export * from './test.types.js';
// Re-export specific types for convenience
export type {
TestType,
ResultType,
RefType,
TestSummary,
TestDetail,
CreateTestPayload,
UpdateTestPayload,
RefNumRange,
RefTxtRange,
TestMapping,
GroupMember,
TechDetail,
CalcDetails,
TestFormState,
TabConfig
} from './test.types.js';

306
src/lib/types/test.types.ts Normal file
View File

@ -0,0 +1,306 @@
/**
* Test Management Type Definitions
* Based on CLQMS API Specification
*/
// Test Types
export type TestType = 'TEST' | 'PARAM' | 'CALC' | 'GROUP' | 'TITLE';
export type ResultType = 'NMRIC' | 'RANGE' | 'TEXT' | 'VSET' | 'NORES';
export type RefType = 'RANGE' | 'THOLD' | 'VSET' | 'TEXT' | 'NOREF';
export type NumRefType = 'REF' | 'CRTC' | 'VAL' | 'RERUN';
export type RangeType = 'RANGE' | 'THOLD';
export type TxtRefType = 'Normal' | 'Abnormal' | 'Critical';
export type SexType = '0' | '1' | '2';
export type SexLabel = 'All' | 'Female' | 'Male';
// Base Test Summary (from list endpoint)
export interface TestSummary {
TestSiteID: number;
TestSiteCode: string;
TestSiteName: string;
TestType: TestType;
TestTypeLabel: string;
SeqScr: number;
SeqRpt: number;
VisibleScr: number | string;
VisibleRpt: number | string;
CountStat: number;
StartDate: string;
EndDate?: string;
DisciplineID?: number;
DepartmentID?: number;
DisciplineName?: string;
DepartmentName?: string;
IsActive?: string | number;
}
// Technical Details
export interface TechDetail {
DisciplineID?: number;
DepartmentID?: number;
DisciplineName?: string;
DepartmentName?: string;
ResultType?: ResultType;
RefType?: RefType;
VSet?: string;
Unit1?: string;
Factor?: number;
Unit2?: string;
Decimal?: number;
ReqQty?: number;
ReqQtyUnit?: string;
CollReq?: string;
Method?: string;
ExpectedTAT?: number;
}
// Calculation Details (extends TechDetail)
export interface CalcDetails extends TechDetail {
FormulaInput?: string;
FormulaCode?: string;
}
// Group Member
export interface GroupMember {
TestSiteID: number;
TestSiteCode: string;
TestSiteName: string;
TestType: TestType;
Seq?: number;
}
// Test Mapping
export interface TestMapping {
TestMapID?: number;
HostType?: string;
HostID?: string | number;
HostDataSource?: string;
HostTestCode?: string;
HostTestName?: string;
ClientType?: string;
ClientID?: string | number;
ClientDataSource?: string;
ConDefID?: string | number;
ClientTestCode?: string;
ClientTestName?: string;
}
// Numeric Reference Range
export interface RefNumRange {
RefNumID?: number;
NumRefType: NumRefType;
NumRefTypeLabel?: string;
RangeType: RangeType;
RangeTypeLabel?: string;
Sex: SexType;
SexLabel?: string;
AgeStart: number;
AgeEnd: number;
LowSign?: string;
LowSignLabel?: string;
Low?: number;
HighSign?: string;
HighSignLabel?: string;
High?: number;
Flag?: string;
Interpretation?: string;
}
// Text Reference Range
export interface RefTxtRange {
RefTxtID?: number;
TxtRefType: TxtRefType;
TxtRefTypeLabel?: string;
Sex: SexType;
SexLabel?: string;
AgeStart: number;
AgeEnd: number;
RefTxt: string;
Flag?: string;
}
// Complete Test Detail (from GET /api/tests/:id)
export interface TestDetail {
// Base fields
TestSiteID: number;
TestSiteCode: string;
TestSiteName: string;
TestType: TestType;
TestTypeLabel: string;
Description?: string;
SiteID: number;
SeqScr: number;
SeqRpt: number;
VisibleScr: number | string | boolean;
VisibleRpt: number | string | boolean;
CountStat: number | boolean;
StartDate?: string;
EndDate?: string;
// Technical details
DisciplineID?: number;
DepartmentID?: number;
ResultType?: ResultType;
RefType?: RefType;
VSet?: string;
Unit1?: string;
Factor?: number;
Unit2?: string;
Decimal?: number;
ReqQty?: number;
ReqQtyUnit?: string;
CollReq?: string;
Method?: string;
ExpectedTAT?: number;
// Calculated test specific
FormulaInput?: string;
FormulaCode?: string;
// Nested data
testdefcal?: CalcDetails[];
testdefgrp?: GroupMember[];
testmap?: TestMapping[];
testdeftech?: TechDetail[];
refnum?: RefNumRange[];
reftxt?: RefTxtRange[];
}
// Create/Update Test Payload
export interface CreateTestPayload {
SiteID: number;
TestSiteCode: string;
TestSiteName: string;
TestType: TestType;
Description?: string;
SeqScr?: number;
SeqRpt?: number;
VisibleScr?: number | boolean;
VisibleRpt?: number | boolean;
CountStat?: number | boolean;
StartDate?: string;
// Nested details based on TestType
details?: {
// Technical (TEST/PARAM/CALC)
DisciplineID?: number;
DepartmentID?: number;
ResultType?: ResultType;
RefType?: RefType;
VSet?: string;
Unit1?: string;
Factor?: number;
Unit2?: string;
Decimal?: number;
ReqQty?: number;
ReqQtyUnit?: string;
CollReq?: string;
Method?: string;
ExpectedTAT?: number;
// CALC only
FormulaInput?: string;
FormulaCode?: string;
// GROUP only
members?: number[];
};
// Reference ranges (TEST/PARAM)
refnum?: Omit<RefNumRange, 'RefNumID' | 'NumRefTypeLabel' | 'RangeTypeLabel' | 'SexLabel' | 'LowSignLabel' | 'HighSignLabel'>[];
reftxt?: Omit<RefTxtRange, 'RefTxtID' | 'TxtRefTypeLabel' | 'SexLabel'>[];
// Mappings (all types)
testmap?: TestMapping[];
}
// Update Test Payload (includes TestSiteID)
export interface UpdateTestPayload extends CreateTestPayload {
TestSiteID: number;
}
// Form State (for Svelte 5 runes)
export interface TestFormState {
TestSiteID?: number;
TestSiteCode: string;
TestSiteName: string;
TestType: TestType;
Description?: string;
SiteID: number;
SeqScr: number;
SeqRpt: number;
VisibleScr: boolean;
VisibleRpt: boolean;
CountStat: boolean;
StartDate?: string;
details: {
DisciplineID?: number;
DepartmentID?: number;
ResultType?: ResultType;
RefType?: RefType;
VSet?: string;
Unit1?: string;
Factor?: number;
Unit2?: string;
Decimal?: number;
ReqQty?: number;
ReqQtyUnit?: string;
CollReq?: string;
Method?: string;
ExpectedTAT?: number;
FormulaInput?: string;
FormulaCode?: string;
members?: number[];
};
refnum: RefNumRange[];
reftxt: RefTxtRange[];
testmap: TestMapping[];
}
// Tab Configuration
export interface TabConfig {
id: string;
label: string;
icon: string;
isVisible: (testType: TestType) => boolean;
}
// Test Type Configuration (for UI display)
export interface TestTypeConfig {
label: string;
badgeClass: string;
description: string;
}
// API Response Types
export interface ApiResponse<T> {
status: 'success' | 'created' | 'error';
message: string;
data: T;
}
export interface TestListResponse extends ApiResponse<TestSummary[]> {
pagination?: {
page: number;
perPage: number;
total: number;
};
}
export interface TestDetailResponse extends ApiResponse<TestDetail> {}
export interface CreateTestResponse extends ApiResponse<{ TestSiteId: number }> {
status: 'created';
}
export interface UpdateTestResponse extends ApiResponse<{ TestSiteId: number }> {}
export interface DeleteTestResponse extends ApiResponse<{ TestSiteId: number; EndDate: string }> {}
// Filter Options
export interface TestFilterOptions {
TestType?: TestType;
VisibleScr?: number;
VisibleRpt?: number;
search?: string;
}

View File

@ -1,392 +1,257 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { onMount } 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';
import TestFormModal from './test-modal/TestFormModal.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users } 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: []
});
let loading = $state(false);
let tests = $state([]);
let disciplines = $state([]);
let departments = $state([]);
let searchQuery = $state('');
let modalOpen = $state(false);
let modalMode = $state('create');
let selectedTestId = $state(null);
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
const testTypeConfig = { TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' }, PARAM: { label: 'Parameter', badgeClass: 'badge-secondary', icon: Variable, color: '#3399FF', bgColor: '#F0F8FF' }, CALC: { label: 'Calculated', badgeClass: 'badge-accent', icon: Calculator, color: '#9933CC', bgColor: '#F5E6FF' }, GROUP: { label: 'Panel', badgeClass: 'badge-info', icon: Box, color: '#00AA44', bgColor: '#E6F9EE' }, TITLE: { label: 'Header', badgeClass: 'badge-ghost', icon: Layers, color: '#666666', bgColor: '#F5F5F5' } };
const canHaveRefRange = $derived(formData.TestType === 'TEST' || formData.TestType === 'CALC');
const canHaveFormula = $derived(formData.TestType === 'CALC');
const 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';
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: '',
RefType: '',
ReqQty: null,
ReqQtyUnit: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 0,
CollReq: '',
Method: '',
ExpectedTAT: null,
// Group Members
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' }
};
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 consolidated arrays
let refRangeType = 'none';
const hasNum = consolidatedRefnum.some(ref => ref.RefType !== 'THOLD');
const hasThold = consolidatedRefnum.some(ref => ref.RefType === 'THOLD');
const hasText = consolidatedReftxt.some(ref => ref.RefType !== 'VSET');
const hasVset = consolidatedReftxt.some(ref => ref.RefType === 'VSET');
if (hasNum) refRangeType = 'num';
else if (hasThold) refRangeType = 'thold';
else if (hasText) refRangeType = 'text';
else if (hasVset) refRangeType = 'vset';
// 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 columns = [
{ 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: 'DisciplineName', label: 'Discipline', class: 'w-32' },
{ key: 'DepartmentName', label: 'Department', class: 'w-32' },
{ key: 'Visible', label: 'Visible', class: 'w-24 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' }
];
const filteredTests = $derived.by(() => {
if (!searchQuery.trim()) return tests;
const query = searchQuery.toLowerCase().trim();
return tests.filter(test => {
const code = (test.TestSiteCode || '').toLowerCase();
const name = (test.TestSiteName || '').toLowerCase();
return code.includes(query) || name.includes(query);
});
});
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 ?? ''
});
onMount(async () => {
await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]);
});
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 || '',
RefType: testDetail.RefType || '',
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 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;
async function loadTests() {
loading = true;
try {
const payload = { ...formData };
const response = await fetchTests();
tests = Array.isArray(response.data) ? response.data.filter(t => t.IsActive !== '0' && t.IsActive !== 0) : [];
} catch (err) {
toastError(err.message || 'Failed to load tests');
tests = [];
} finally {
loading = false;
}
}
// Remove fields based on test type
if (!canHaveFormula) delete payload.Formula;
if (!canHaveRefRange) {
delete payload.refnum;
delete payload.reftxt;
} else {
// Filter refnum and reftxt based on selected refRangeType
if (formData.refRangeType === 'num') {
// Keep only non-THOLD items in refnum
payload.refnum = (formData.refnum || []).filter(ref => ref.RefType !== 'THOLD');
delete payload.reftxt;
} else if (formData.refRangeType === 'thold') {
// Keep only THOLD items in refnum
payload.refnum = (formData.refnum || []).filter(ref => ref.RefType === 'THOLD');
delete payload.reftxt;
} else if (formData.refRangeType === 'text') {
// Keep only non-VSET items in reftxt
payload.reftxt = (formData.reftxt || []).filter(ref => ref.RefType !== 'VSET');
delete payload.refnum;
} else if (formData.refRangeType === 'vset') {
// Keep only VSET items in reftxt
payload.reftxt = (formData.reftxt || []).filter(ref => ref.RefType === 'VSET');
delete payload.refnum;
} else {
// No ref range type selected
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;
async function loadDisciplines() {
try {
const response = await fetchDisciplines();
disciplines = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load disciplines:', err);
disciplines = [];
}
}
if (modalMode === 'create') {
await createTest(payload);
toastSuccess('Test created successfully');
} else {
await updateTest(payload);
toastSuccess('Test updated successfully');
}
modalOpen = false;
async function loadDepartments() {
try {
const response = await fetchDepartments();
departments = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load departments:', err);
departments = [];
}
}
function openCreateModal() {
modalMode = 'create';
selectedTestId = null;
modalOpen = true;
}
async function openEditModal(row) {
modalMode = 'edit';
selectedTestId = row.TestSiteID;
modalOpen = true;
}
function getTestTypeConfig(type) {
return testTypeConfig[type] || testTypeConfig.TEST;
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteTest(deleteItem.TestSiteID);
toastSuccess('Test deleted successfully');
deleteConfirmOpen = false;
await loadTests();
} catch (err) {
toastError(err.message || 'Failed to save test');
toastError(err.message || 'Failed to delete test');
} finally {
saving = false;
deleting = 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>
<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>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Test
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<input type="text" placeholder="Search by code or name..." class="input input-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 class="mb-4">
<div class="relative max-w-md">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
class="input input-sm input-bordered w-full pl-10"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
>
×
</button>
{/if}
</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}
{#if loading}
<div class="flex items-center justify-center py-16">
<Loader2 class="w-8 h-8 animate-spin text-primary mr-3" />
<span class="text-gray-600">Loading tests...</span>
</div>
{:else if filteredTests.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
<Microscope class="w-12 h-12 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-gray-700 mb-1">
{searchQuery ? 'No tests found' : 'No tests yet'}
</h3>
<p class="text-xs text-gray-500 text-center max-w-sm mb-4">
{searchQuery
? `No tests matching "${searchQuery}". Try a different search term.`
: 'Get started by adding your first laboratory test.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add First Test
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredTests}
loading={false}
emptyMessage="No tests found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row, value })}
{@const typeConfig = getTestTypeConfig(row.TestType)}
{@const IconComponent = typeConfig.icon}
{#if column.key === 'TestType'}
<span class="badge gap-1" style="background-color: {typeConfig.bgColor}; color: {typeConfig.color}; border-color: {typeConfig.color};">
<IconComponent class="w-3 h-3" />
{typeConfig.label}
</span>
{:else if column.key === 'Visible'}
<div class="flex justify-center gap-2">
<span class="badge {row.VisibleScr === '1' || row.VisibleScr === 1 ? 'badge-success' : 'badge-ghost'} badge-sm">S</span>
<span class="badge {row.VisibleRpt === '1' || row.VisibleRpt === 1 ? 'badge-success' : 'badge-ghost'} badge-sm">R</span>
</div>
{:else if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} title="Edit test">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} title="Delete test">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{value || '-'}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={typeSelectorOpen} title="Add Test" size="md">
<TestTypeSelector
onselect={handleTypeSelect}
oncancel={() => typeSelectorOpen = false}
/>
</Modal>
<TestModal
<TestFormModal
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}
testId={selectedTestId}
{disciplines}
{departments}
{tests}
onsave={async () => {
modalOpen = false;
await loadTests();
}}
/>
<Modal bind:open={deleteModalOpen} title="Confirm Delete Test" size="sm">
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" 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>
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.TestSiteName}</strong>?
</p>
{#if deleteItem?.TestSiteCode}
<p class="text-sm text-gray-500 mt-1">Code: {deleteItem.TestSiteCode}</p>
{/if}
<p class="text-sm text-error mt-3">This action cannot be undone.</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>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,364 @@
<script>
import { onMount } from 'svelte';
import { Info, Settings, Calculator, Users, Link } from 'lucide-svelte';
import { fetchTest, createTest, updateTest, validateTestCode, validateTestName } from '$lib/api/tests.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte';
import BasicInfoTab from './tabs/BasicInfoTab.svelte';
import TechDetailsTab from './tabs/TechDetailsTab.svelte';
import CalcDetailsTab from './tabs/CalcDetailsTab.svelte';
import GroupMembersTab from './tabs/GroupMembersTab.svelte';
import MappingsTab from './tabs/MappingsTab.svelte';
let { open = $bindable(false), mode = 'create', testId = null, disciplines = [], departments = [], tests = [], onsave = null } = $props();
let currentTab = $state('basic');
let loading = $state(false);
let saving = $state(false);
let isDirty = $state(false);
let validationErrors = $state({});
let lastLoadedTestId = $state(null);
// Reference to BasicInfoTab for validation
let basicInfoTabRef = $state(null);
// Initialize form state with proper defaults
let formData = $state(getDefaultFormData());
const tabConfig = [
{ id: 'basic', label: 'Basic Info', component: Info },
{ id: 'tech', label: 'Tech Details', component: Settings },
{ id: 'calc', label: 'Calculations', component: Calculator },
{ id: 'group', label: 'Group Members', component: Users },
{ id: 'mappings', label: 'Mappings', component: Link }
];
const visibleTabs = $derived.by(() => {
const type = formData.TestType;
return tabConfig.filter(tab => {
if (tab.id === 'basic' || tab.id === 'mappings') return true;
if (tab.id === 'tech') return ['TEST', 'PARAM', 'CALC'].includes(type);
if (tab.id === 'calc') return type === 'CALC';
if (tab.id === 'group') return type === 'GROUP';
return false;
});
});
// Computed validation state
const canSave = $derived.by(() => {
const codeResult = validateTestCode(formData.TestSiteCode);
const nameResult = validateTestName(formData.TestSiteName);
return codeResult.valid && nameResult.valid && formData.TestType !== '';
});
const formErrors = $derived.by(() => {
const errors = [];
const codeResult = validateTestCode(formData.TestSiteCode);
if (!codeResult.valid) errors.push(codeResult.error);
const nameResult = validateTestName(formData.TestSiteName);
if (!nameResult.valid) errors.push(nameResult.error);
return errors;
});
// Watch for modal open changes and load data
$effect(() => {
if (open && mode === 'edit' && testId && testId !== lastLoadedTestId) {
loadTest();
} else if (open && mode === 'create' && lastLoadedTestId !== null) {
resetForm();
}
});
onMount(() => {
if (open) {
if (mode === 'edit' && testId) {
loadTest();
} else {
resetForm();
}
}
});
function getDefaultFormData() {
return {
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
Description: '',
SiteID: 1,
SeqScr: 0,
SeqRpt: 0,
VisibleScr: true,
VisibleRpt: true,
CountStat: true,
details: {
DisciplineID: null,
DepartmentID: null,
ResultType: '',
RefType: '',
VSet: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 2,
ReqQty: null,
ReqQtyUnit: '',
CollReq: '',
Method: '',
ExpectedTAT: null,
FormulaInput: '',
FormulaCode: '',
members: []
},
refnum: [],
reftxt: [],
testmap: []
};
}
function resetForm() {
formData = getDefaultFormData();
currentTab = 'basic';
isDirty = false;
validationErrors = {};
lastLoadedTestId = null;
setDefaults();
}
async function loadTest() {
if (!testId || testId === lastLoadedTestId) return;
loading = true;
try {
const response = await fetchTest(testId);
const test = response.data;
// Transform API data to form state
formData = {
TestSiteID: test.TestSiteID,
TestSiteCode: test.TestSiteCode || '',
TestSiteName: test.TestSiteName || '',
TestType: test.TestType || 'TEST',
Description: test.Description || '',
SiteID: test.SiteID || 1,
SeqScr: test.SeqScr || 0,
SeqRpt: test.SeqRpt || 0,
VisibleScr: test.VisibleScr === '1' || test.VisibleScr === 1 || test.VisibleScr === true,
VisibleRpt: test.VisibleRpt === '1' || test.VisibleRpt === 1 || test.VisibleRpt === true,
CountStat: test.CountStat === '1' || test.CountStat === 1 || test.CountStat === true,
details: {
DisciplineID: test.DisciplineID || null,
DepartmentID: test.DepartmentID || null,
ResultType: test.ResultType || '',
RefType: test.RefType || '',
VSet: test.VSet || '',
Unit1: test.Unit1 || '',
Factor: test.Factor || null,
Unit2: test.Unit2 || '',
Decimal: test.Decimal || 2,
ReqQty: test.ReqQty || null,
ReqQtyUnit: test.ReqQtyUnit || '',
CollReq: test.CollReq || '',
Method: test.Method || '',
ExpectedTAT: test.ExpectedTAT || null,
FormulaInput: test.FormulaInput || '',
FormulaCode: test.FormulaCode || '',
members: test.testdefgrp?.map(m => m.TestSiteID) || []
},
refnum: test.refnum || [],
reftxt: test.reftxt || [],
testmap: test.testmap || []
};
lastLoadedTestId = testId;
currentTab = 'basic';
isDirty = false;
} catch (err) {
toastError(err.message || 'Failed to load test');
} finally {
loading = false;
}
}
function setDefaults() {
const type = formData.TestType;
if (type === 'CALC') {
formData.details.ResultType = 'NMRIC';
formData.details.RefType = 'RANGE';
} else if (type === 'GROUP' || type === 'TITLE') {
formData.details.ResultType = 'NORES';
formData.details.RefType = 'NOREF';
} else {
formData.details.ResultType = '';
formData.details.RefType = '';
}
}
function handleTabChange(tabId) {
currentTab = tabId;
}
function handleTypeChange(newType) {
if (isDirty && !confirm('Changing test type will reset some fields. Continue?')) {
return;
}
formData.TestType = newType;
setDefaults();
isDirty = false;
}
/**
* Validate the entire form
* @returns {boolean}
*/
function validateForm() {
const errors = {};
// Validate basic info
const codeResult = validateTestCode(formData.TestSiteCode);
if (!codeResult.valid) errors.TestSiteCode = codeResult.error;
const nameResult = validateTestName(formData.TestSiteName);
if (!nameResult.valid) errors.TestSiteName = nameResult.valid;
if (!formData.TestType) {
errors.TestType = 'Test type is required';
}
validationErrors = errors;
return Object.keys(errors).length === 0;
}
async function handleSave() {
if (!validateForm()) {
toastError('Please fix validation errors');
currentTab = 'basic';
return;
}
saving = true;
try {
if (mode === 'create') {
await createTest(formData);
toastSuccess('Test created successfully');
} else {
await updateTest(formData);
toastSuccess('Test updated successfully');
}
if (onsave) onsave();
open = false;
resetForm();
} catch (err) {
toastError(err.message || 'Failed to save test');
} finally {
saving = false;
}
}
function handleClose() {
if (isDirty && !confirm('You have unsaved changes. Discard?')) {
return;
}
open = false;
resetForm();
}
</script>
<Modal bind:open size="wide" title={mode === 'create' ? 'New Test' : 'Edit Test'}>
{#if loading}
<div class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else}
{#if formErrors.length > 0}
<div class="alert alert-warning alert-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<span class="font-semibold">Please fix the following errors:</span>
<ul class="list-disc list-inside text-sm mt-1">
{#each formErrors as error}
<li>{error}</li>
{/each}
</ul>
</div>
</div>
{/if}
<div class="flex flex-col" style="height: 60vh; max-height: 500px;">
<!-- Top Tabs -->
<div class="border-b border-base-200 bg-base-50">
<div class="flex overflow-x-auto">
{#each visibleTabs as tab (tab.id)}
{@const IconComponent = tab.component}
<button
class="flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-all duration-200"
class:border-primary={currentTab === tab.id}
class:text-primary={currentTab === tab.id}
class:border-transparent={currentTab !== tab.id}
class:text-gray-600={currentTab !== tab.id}
class:hover:text-gray-800={currentTab !== tab.id}
onclick={() => handleTabChange(tab.id)}
aria-selected={currentTab === tab.id}
role="tab"
>
<IconComponent class="w-4 h-4 flex-shrink-0" />
<span>{tab.label}</span>
</button>
{/each}
</div>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-y-auto p-6">
{#if currentTab === 'basic'}
<BasicInfoTab
bind:this={basicInfoTabRef}
bind:formData
bind:isDirty
onTypeChange={handleTypeChange}
/>
{:else if currentTab === 'tech'}
<TechDetailsTab
bind:formData
{disciplines}
{departments}
bind:isDirty
/>
{:else if currentTab === 'calc'}
<CalcDetailsTab
bind:formData
{disciplines}
{departments}
bind:isDirty
/>
{:else if currentTab === 'group'}
<GroupMembersTab
bind:formData
{tests}
bind:isDirty
/>
{:else if currentTab === 'mappings'}
<MappingsTab
bind:formData
bind:isDirty
/>
{/if}
</div>
</div>
{/if}
{#snippet footer()}
<button class="btn btn-ghost" onclick={handleClose} type="button" disabled={saving || loading}>Cancel</button>
<button class="btn btn-primary" onclick={handleSave} disabled={saving || loading || !canSave} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{saving ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,264 @@
<script>
import { validateTestCode, validateTestName } from '$lib/api/tests.js';
import { AlertCircle } from 'lucide-svelte';
let { formData = $bindable(), isDirty = $bindable(false), onTypeChange = null } = $props();
let validationErrors = $state({
TestSiteCode: '',
TestSiteName: '',
TestType: ''
});
const testTypes = [
{ value: 'TEST', label: 'Test - Single Test' },
{ value: 'PARAM', label: 'Parameter - Test Parameter' },
{ value: 'CALC', label: 'Calculated - Formula-based' },
{ value: 'GROUP', label: 'Panel - Test Group' },
{ value: 'TITLE', label: 'Header - Section Header' }
];
function validateField(field) {
validationErrors[field] = '';
switch (field) {
case 'TestSiteCode':
const codeResult = validateTestCode(formData.TestSiteCode);
if (!codeResult.valid) {
validationErrors[field] = codeResult.error;
return false;
}
break;
case 'TestSiteName':
const nameResult = validateTestName(formData.TestSiteName);
if (!nameResult.valid) {
validationErrors[field] = nameResult.error;
return false;
}
break;
case 'TestType':
if (!formData.TestType) {
validationErrors[field] = 'Test type is required';
return false;
}
break;
}
return true;
}
export function validateAll() {
const fields = ['TestSiteCode', 'TestSiteName', 'TestType'];
let isValid = true;
for (const field of fields) {
if (!validateField(field)) {
isValid = false;
}
}
return isValid;
}
function handleFieldChange() {
isDirty = true;
}
function handleCodeInput(event) {
const value = event.target.value.toUpperCase();
formData.TestSiteCode = value;
handleFieldChange();
validateField('TestSiteCode');
}
function handleNameInput(event) {
handleFieldChange();
validateField('TestSiteName');
}
function handleTestTypeChange(event) {
const newType = event.target.value;
if (onTypeChange) {
onTypeChange(newType);
}
handleFieldChange();
validateField('TestType');
}
</script>
<div class="space-y-5">
<!-- Test Identity -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Test Identity</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Test Code -->
<div class="space-y-1">
<label for="testCode" class="block text-sm font-medium text-gray-700">
Test Code <span class="text-error">*</span>
</label>
<input
id="testCode"
type="text"
class="input input-sm input-bordered w-full font-mono uppercase"
class:input-error={validationErrors.TestSiteCode}
bind:value={formData.TestSiteCode}
oninput={handleCodeInput}
placeholder="e.g., CBC, HGB, WBC"
required
/>
{#if validationErrors.TestSiteCode}
<span class="text-xs text-error flex items-center gap-1">
<AlertCircle class="w-3 h-3" />
{validationErrors.TestSiteCode}
</span>
{/if}
</div>
<!-- Test Name -->
<div class="space-y-1">
<label for="testName" class="block text-sm font-medium text-gray-700">
Test Name <span class="text-error">*</span>
</label>
<input
id="testName"
type="text"
class="input input-sm input-bordered w-full"
class:input-error={validationErrors.TestSiteName}
bind:value={formData.TestSiteName}
oninput={handleNameInput}
placeholder="e.g., Complete Blood Count"
maxlength="255"
required
/>
{#if validationErrors.TestSiteName}
<span class="text-xs text-error flex items-center gap-1">
<AlertCircle class="w-3 h-3" />
{validationErrors.TestSiteName}
</span>
{:else}
<span class="text-xs text-gray-500">3-255 characters</span>
{/if}
</div>
</div>
</div>
<!-- Classification -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Classification</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Test Type -->
<div class="space-y-1">
<label for="testType" class="block text-sm font-medium text-gray-700">
Test Type <span class="text-error">*</span>
</label>
<select
id="testType"
class="select select-sm select-bordered w-full"
class:select-error={validationErrors.TestType}
bind:value={formData.TestType}
onchange={handleTestTypeChange}
>
{#each testTypes as type (type.value)}
<option value={type.value}>{type.label}</option>
{/each}
</select>
{#if validationErrors.TestType}
<span class="text-xs text-error flex items-center gap-1">
<AlertCircle class="w-3 h-3" />
{validationErrors.TestType}
</span>
{/if}
</div>
<!-- Description -->
<div class="space-y-1">
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
<input
id="description"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Description}
placeholder="Optional description..."
maxlength="500"
oninput={handleFieldChange}
/>
</div>
</div>
</div>
<!-- Display Settings -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Display Settings</h3>
<div class="grid grid-cols-5 gap-4">
<!-- Screen Sequence -->
<div class="space-y-1">
<label for="seqScr" class="block text-sm font-medium text-gray-700">Screen Seq</label>
<input
id="seqScr"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.SeqScr}
min="0"
oninput={handleFieldChange}
/>
</div>
<!-- Report Sequence -->
<div class="space-y-1">
<label for="seqRpt" class="block text-sm font-medium text-gray-700">Report Seq</label>
<input
id="seqRpt"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.SeqRpt}
min="0"
oninput={handleFieldChange}
/>
</div>
<!-- Visible Screen -->
<div class="space-y-1">
<span class="block text-sm font-medium text-gray-700">Screen</span>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
bind:checked={formData.VisibleScr}
onchange={handleFieldChange}
/>
<span class="text-sm">Visible</span>
</label>
</div>
<!-- Visible Report -->
<div class="space-y-1">
<span class="block text-sm font-medium text-gray-700">Report</span>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
bind:checked={formData.VisibleRpt}
onchange={handleFieldChange}
/>
<span class="text-sm">Visible</span>
</label>
</div>
<!-- Count Statistics -->
<div class="space-y-1">
<span class="block text-sm font-medium text-gray-700">Statistics</span>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
bind:checked={formData.CountStat}
onchange={handleFieldChange}
/>
<span class="text-sm">Count</span>
</label>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,197 @@
<script>
let { formData = $bindable(), disciplines = [], departments = [], isDirty = $bindable(false) } = $props();
const disciplineOptions = $derived(disciplines.map(d => ({ value: d.DisciplineID, label: d.DisciplineName })));
const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName })));
const refTypeOptions = [
{ value: 'RANGE', label: 'Range' },
{ value: 'THOLD', label: 'Threshold' }
];
function handleFieldChange() {
isDirty = true;
}
function validateFormula(code) {
const pattern = /\{[^}]+\}/g;
const matches = code.match(pattern) || [];
return matches.length > 0;
}
</script>
<div class="space-y-5">
<h2 class="text-lg font-semibold text-gray-800">Calculated Test Formula</h2>
<div class="alert alert-info text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div>
<strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code>
</div>
</div>
<!-- Formula Definition -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Formula Definition</h3>
<div class="space-y-4">
<div class="space-y-1">
<label for="formulaInput" class="block text-sm font-medium text-gray-700">Formula Description</label>
<input
id="formulaInput"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.FormulaInput}
placeholder="e.g., Hemoglobin plus MCV"
oninput={handleFieldChange}
/>
<span class="text-xs text-gray-500">Human-readable description of the calculation</span>
</div>
<div class="space-y-1">
<label for="formulaCode" class="block text-sm font-medium text-gray-700">
Formula Code <span class="text-error">*</span>
</label>
<textarea
id="formulaCode"
class="textarea textarea-sm textarea-bordered w-full font-mono text-sm"
bind:value={formData.details.FormulaCode}
placeholder="e.g., {HGB} + {MCV} + {MCHC}"
rows="3"
oninput={handleFieldChange}
required
></textarea>
<span class="text-xs">
{#if formData.details.FormulaCode && validateFormula(formData.details.FormulaCode)}
<span class="text-success">Valid formula syntax</span>
{:else if formData.details.FormulaCode}
<span class="text-warning">No test references found</span>
{:else}
<span class="text-gray-500">Enter formula with test code references</span>
{/if}
</span>
</div>
</div>
</div>
<!-- Categorization -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Categorization</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label for="calcDiscipline" class="block text-sm font-medium text-gray-700">Discipline</label>
<select
id="calcDiscipline"
class="select select-sm select-bordered w-full"
bind:value={formData.details.DisciplineID}
onchange={handleFieldChange}
>
<option value="">Select discipline...</option>
{#each disciplineOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<label for="calcDepartment" class="block text-sm font-medium text-gray-700">Department</label>
<select
id="calcDepartment"
class="select select-sm select-bordered w-full"
bind:value={formData.details.DepartmentID}
onchange={handleFieldChange}
>
<option value="">Select department...</option>
{#each departmentOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</div>
<!-- Method -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Method</h3>
<div class="space-y-1">
<label for="calcMethod" class="block text-sm font-medium text-gray-700">Calculation Method</label>
<input
id="calcMethod"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Method}
placeholder="e.g., Calculated from components"
oninput={handleFieldChange}
/>
</div>
</div>
<!-- Result Configuration -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Result Configuration</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-1">
<label for="calcUnit1" class="block text-sm font-medium text-gray-700">Unit 1</label>
<input
id="calcUnit1"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Unit1}
placeholder="e.g., g/dL"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="calcFactor" class="block text-sm font-medium text-gray-700">Factor</label>
<input
id="calcFactor"
type="number"
step="0.01"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Factor}
placeholder="1.0"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="calcUnit2" class="block text-sm font-medium text-gray-700">Unit 2</label>
<input
id="calcUnit2"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Unit2}
placeholder="e.g., g/L"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="calcDecimal" class="block text-sm font-medium text-gray-700">Decimal</label>
<input
id="calcDecimal"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Decimal}
min="0"
max="6"
oninput={handleFieldChange}
/>
</div>
</div>
<div class="mt-4 space-y-1">
<label for="calcRefType" class="block text-sm font-medium text-gray-700">Reference Type</label>
<select
id="calcRefType"
class="select select-sm select-bordered w-full md:w-1/2"
bind:value={formData.details.RefType}
onchange={handleFieldChange}
>
{#each refTypeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</div>

View File

@ -0,0 +1,168 @@
<script>
import { Plus, Trash2, ArrowUp, ArrowDown, Box } from 'lucide-svelte';
let { formData = $bindable(), tests = [], isDirty = $bindable(false) } = $props();
let availableTests = $state([]);
let selectedTestId = $state('');
let addMemberOpen = $state(false);
const members = $derived.by(() => {
return tests.filter(t => formData.details.members?.includes(t.TestSiteID))
.map(t => ({ ...t, seq: formData.details.members.indexOf(t.TestSiteID) }));
});
const availableOptions = $derived.by(() => {
return tests.filter(t =>
t.TestSiteID !== formData.TestSiteID &&
!formData.details.members?.includes(t.TestSiteID) &&
t.IsActive !== '0' &&
t.IsActive !== 0
).map(t => ({
value: t.TestSiteID,
label: `${t.TestSiteCode} - ${t.TestSiteName}`,
data: t
}));
});
function handleFieldChange() {
isDirty = true;
}
function addMember() {
if (!selectedTestId) return;
const newMembers = [...(formData.details.members || []), parseInt(selectedTestId)];
formData.details.members = newMembers;
selectedTestId = '';
handleFieldChange();
}
function removeMember(testId) {
const newMembers = formData.details.members?.filter(id => id !== testId) || [];
formData.details.members = newMembers;
handleFieldChange();
}
function moveMember(index, direction) {
const members = [...formData.details.members];
const newIndex = index + direction;
if (newIndex >= 0 && newIndex < members.length) {
[members[index], members[newIndex]] = [members[newIndex], members[index]];
formData.details.members = members;
handleFieldChange();
}
}
</script>
<div class="space-y-6">
<h2 class="text-lg font-semibold text-gray-800">Group Members</h2>
<div class="alert alert-info text-sm">
<Box class="w-4 h-4" />
<div>
<strong>Panel Members:</strong> Add tests, parameters, or calculated values to this panel. Order matters for display on reports.
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Members ({members.length})</h3>
{#if members.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg">
<Box class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500">No members added yet</p>
<p class="text-xs text-gray-400">Add tests to create this panel</p>
</div>
{:else}
<div class="overflow-x-auto border border-base-200 rounded-lg">
<table class="table table-sm table-compact">
<thead>
<tr class="bg-base-200">
<th class="w-12 text-center">#</th>
<th class="w-24">Code</th>
<th>Name</th>
<th class="w-20">Type</th>
<th class="w-32 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each members as member, idx (member.TestSiteID)}
<tr class="hover:bg-base-100">
<td class="text-center text-gray-500">{idx + 1}</td>
<td class="font-mono text-sm">{member.TestSiteCode}</td>
<td>{member.TestSiteName}</td>
<td>
<span class="badge badge-xs badge-ghost">{member.TestType}</span>
</td>
<td>
<div class="flex justify-center gap-1">
<button
class="btn btn-ghost btn-xs"
onclick={() => moveMember(idx, -1)}
disabled={idx === 0}
title="Move Up"
>
<ArrowUp class="w-3 h-3" />
</button>
<button
class="btn btn-ghost btn-xs"
onclick={() => moveMember(idx, 1)}
disabled={idx === members.length - 1}
title="Move Down"
>
<ArrowDown class="w-3 h-3" />
</button>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => removeMember(member.TestSiteID)}
title="Remove Member"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Add Member</h3>
{#if availableOptions.length === 0}
<div class="alert alert-warning text-sm">
<span>No available tests to add. All tests are either already members or inactive.</span>
</div>
{:else}
<div class="flex gap-2">
<select
class="select select-sm select-bordered flex-1"
bind:value={selectedTestId}
>
<option value="">Select a test to add...</option>
{#each availableOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<button
class="btn btn-sm btn-primary"
onclick={addMember}
disabled={!selectedTestId}
>
<Plus class="w-4 h-4 mr-1" />
Add
</button>
</div>
{/if}
</div>
<div class="text-xs text-gray-500 space-y-1">
<p><strong>Tip:</strong> Members will display on reports in the order shown above.</p>
<p><strong>Note:</strong> Panels cannot contain themselves or other panels (circular references).</p>
</div>
</div>

View File

@ -0,0 +1,250 @@
<script>
import { Plus, Edit2, Trash2, Link } from 'lucide-svelte';
import Modal from '$lib/components/Modal.svelte';
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
let modalOpen = $state(false);
let modalMode = $state('create');
let selectedMapping = $state(null);
let editingMapping = $state({
HostType: '',
HostID: '',
HostDataSource: '',
HostTestCode: '',
HostTestName: '',
ClientType: '',
ClientID: '',
ClientDataSource: '',
ConDefID: '',
ClientTestCode: '',
ClientTestName: ''
});
const hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
const clientTypes = ['HIS', 'SITE', 'WST', 'INST'];
function handleFieldChange() {
isDirty = true;
}
function openAddMapping() {
modalMode = 'create';
editingMapping = {
HostType: '',
HostID: '',
HostDataSource: '',
HostTestCode: '',
HostTestName: '',
ClientType: '',
ClientID: '',
ClientDataSource: '',
ConDefID: '',
ClientTestCode: '',
ClientTestName: ''
};
modalOpen = true;
}
function openEditMapping(mapping) {
modalMode = 'edit';
selectedMapping = mapping;
editingMapping = { ...mapping };
modalOpen = true;
}
function removeMapping(index) {
const newMappings = formData.testmap?.filter((_, i) => i !== index) || [];
formData.testmap = newMappings;
handleFieldChange();
}
function saveMapping() {
if (modalMode === 'create') {
formData.testmap = [...(formData.testmap || []), { ...editingMapping }];
} else {
const newMappings = formData.testmap?.map(m =>
m === selectedMapping ? { ...editingMapping } : m
) || [];
formData.testmap = newMappings;
}
modalOpen = false;
handleFieldChange();
}
</script>
<div class="space-y-6">
<h2 class="text-lg font-semibold text-gray-800">System Mappings</h2>
<div class="alert alert-info text-sm">
<Link class="w-4 h-4" />
<div>
<strong>Test Mappings:</strong> Configure how this test maps to external systems (HIS, Lab Information Systems, etc.)
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Mappings ({formData.testmap?.length || 0})</h3>
{#if !formData.testmap || formData.testmap.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg">
<Link class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500">No mappings configured</p>
<p class="text-xs text-gray-400">Add mappings to external systems</p>
</div>
{:else}
<div class="overflow-x-auto border border-base-200 rounded-lg">
<table class="table table-sm table-compact">
<thead>
<tr class="bg-base-200">
<th>Host System</th>
<th>Host Code</th>
<th>Client System</th>
<th>Client Code</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each formData.testmap as mapping, idx (idx)}
<tr class="hover:bg-base-100">
<td>
<div class="text-sm">
<div class="font-medium">{mapping.HostType}</div>
<div class="text-xs text-gray-500">ID: {mapping.HostID || '-'}</div>
</div>
</td>
<td>
<div class="text-sm">
<div class="font-mono">{mapping.HostTestCode || '-'}</div>
<div class="text-xs text-gray-500 truncate max-w-[150px]">{mapping.HostTestName || '-'}</div>
</div>
</td>
<td>
<div class="text-sm">
<div class="font-medium">{mapping.ClientType}</div>
<div class="text-xs text-gray-500">ID: {mapping.ClientID || '-'}</div>
</div>
</td>
<td>
<div class="text-sm">
<div class="font-mono">{mapping.ClientTestCode || '-'}</div>
<div class="text-xs text-gray-500 truncate max-w-[150px]">{mapping.ClientTestName || '-'}</div>
</div>
</td>
<td>
<div class="flex justify-center gap-1">
<button
class="btn btn-ghost btn-xs"
onclick={() => openEditMapping(mapping)}
title="Edit Mapping"
>
<Edit2 class="w-3 h-3" />
</button>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => removeMapping(idx)}
title="Remove Mapping"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="flex justify-end">
<button class="btn btn-sm btn-primary" onclick={openAddMapping}>
<Plus class="w-4 h-4 mr-2" />
Add Mapping
</button>
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Mapping' : 'Edit Mapping'} size="lg">
<div class="space-y-6 max-h-[500px] overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide border-b pb-2">Host System</h3>
<div class="form-control">
<label class="label text-sm">Type</label>
<select class="select select-sm select-bordered" bind:value={editingMapping.HostType}>
<option value="">Select type...</option>
{#each hostTypes as type (type)}
<option value={type}>{type}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label text-sm">ID</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostID} placeholder="System ID" />
</div>
<div class="form-control">
<label class="label text-sm">Data Source</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostDataSource} placeholder="e.g., DB, API" />
</div>
<div class="form-control">
<label class="label text-sm">Test Code</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostTestCode} placeholder="Test code in host system" />
</div>
<div class="form-control">
<label class="label text-sm">Test Name</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.HostTestName} placeholder="Test name in host system" />
</div>
</div>
<div class="space-y-3">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide border-b pb-2">Client System</h3>
<div class="form-control">
<label class="label text-sm">Type</label>
<select class="select select-sm select-bordered" bind:value={editingMapping.ClientType}>
<option value="">Select type...</option>
{#each clientTypes as type (type)}
<option value={type}>{type}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label text-sm">ID</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientID} placeholder="System ID" />
</div>
<div class="form-control">
<label class="label text-sm">Data Source</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientDataSource} placeholder="e.g., DB, API" />
</div>
<div class="form-control">
<label class="label text-sm">Container</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ConDefID} placeholder="Container definition" />
</div>
<div class="form-control">
<label class="label text-sm">Test Code</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientTestCode} placeholder="Test code in client system" />
</div>
<div class="form-control">
<label class="label text-sm">Test Name</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingMapping.ClientTestName} placeholder="Test name in client system" />
</div>
</div>
</div>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => modalOpen = false}>Cancel</button>
<button class="btn btn-primary" onclick={saveMapping}>Save</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,419 @@
<script>
import { Plus, Trash2, Hash, Calculator, Edit2 } from 'lucide-svelte';
import Modal from '$lib/components/Modal.svelte';
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
let modalOpen = $state(false);
let modalMode = $state('create');
let selectedRangeIndex = $state(null);
let validationError = $state('');
let editingRange = $state({
NumRefType: 'REF',
RangeType: 'RANGE',
Sex: '0',
AgeStart: 0,
AgeEnd: 150,
LowSign: 'GE',
Low: null,
HighSign: 'LE',
High: null,
Flag: 'N',
Interpretation: ''
});
const refTypes = [
{ value: 'REF', label: 'Reference', desc: 'Normal reference range' },
{ value: 'CRTC', label: 'Critical', desc: 'Critical value range' },
{ value: 'VAL', label: 'Validation', desc: 'Validation check range' },
{ value: 'RERUN', label: 'Rerun', desc: 'Auto-rerun condition' }
];
const rangeTypes = [
{ value: 'RANGE', label: 'Range', desc: 'Low to High range' },
{ value: 'THOLD', label: 'Threshold', desc: 'Single threshold value' }
];
const sexOptions = [
{ value: '0', label: 'All' },
{ value: '1', label: 'Female' },
{ value: '2', label: 'Male' }
];
const signOptions = [
{ value: 'EQ', label: '=' },
{ value: 'LT', label: '<' },
{ value: 'LE', label: '≤' },
{ value: 'GT', label: '>' },
{ value: 'GE', label: '≥' }
];
const flagOptions = [
{ value: 'N', label: 'N - Normal' },
{ value: 'H', label: 'H - High' },
{ value: 'L', label: 'L - Low' },
{ value: 'A', label: 'A - Abnormal' },
{ value: 'C', label: 'C - Critical' }
];
function handleFieldChange() {
isDirty = true;
}
/**
* Validate the current reference range
* @returns {{ valid: boolean, error?: string }}
*/
function validateRange() {
// Age validation per spec: AgeStart < AgeEnd
if (parseInt(editingRange.AgeStart) >= parseInt(editingRange.AgeEnd)) {
return { valid: false, error: 'Age start must be less than age end' };
}
// Age range validation per spec: 0-150
if (parseInt(editingRange.AgeStart) < 0 || parseInt(editingRange.AgeStart) > 150) {
return { valid: false, error: 'Age start must be between 0 and 150' };
}
if (parseInt(editingRange.AgeEnd) < 0 || parseInt(editingRange.AgeEnd) > 150) {
return { valid: false, error: 'Age end must be between 0 and 150' };
}
// Value validation per spec: If both Low and High present, Low < High
const low = editingRange.Low !== null && editingRange.Low !== '' ? parseFloat(editingRange.Low) : null;
const high = editingRange.High !== null && editingRange.High !== '' ? parseFloat(editingRange.High) : null;
if (low !== null && high !== null && low >= high) {
return { valid: false, error: 'Low value must be less than high value' };
}
// Sign appropriateness validation per spec
if (low !== null) {
const validLowSigns = ['EQ', 'GE', 'GT'];
if (!validLowSigns.includes(editingRange.LowSign)) {
return { valid: false, error: 'Low sign should be =, ≥, or > for low bounds' };
}
}
if (high !== null) {
const validHighSigns = ['EQ', 'LE', 'LT'];
if (!validHighSigns.includes(editingRange.HighSign)) {
return { valid: false, error: 'High sign should be =, ≤, or < for high bounds' };
}
}
// For THOLD type, validate that at least one bound is set
if (editingRange.RangeType === 'THOLD' && low === null && high === null) {
return { valid: false, error: 'Threshold requires at least one bound' };
}
// Flag validation per spec: single character
if (editingRange.Flag && editingRange.Flag.length !== 1) {
return { valid: false, error: 'Flag must be a single character' };
}
return { valid: true };
}
function openAddRange() {
modalMode = 'create';
selectedRangeIndex = null;
validationError = '';
editingRange = {
NumRefType: 'REF',
RangeType: 'RANGE',
Sex: '0',
AgeStart: 0,
AgeEnd: 150,
LowSign: 'GE',
Low: null,
HighSign: 'LE',
High: null,
Flag: 'N',
Interpretation: ''
};
modalOpen = true;
}
function openEditRange(index) {
modalMode = 'edit';
selectedRangeIndex = index;
validationError = '';
editingRange = { ...formData.refnum[index] };
modalOpen = true;
}
function removeRange(index) {
if (!confirm('Are you sure you want to delete this reference range?')) {
return;
}
const newRanges = formData.refnum?.filter((_, i) => i !== index) || [];
formData.refnum = newRanges;
handleFieldChange();
}
function saveRange() {
const validation = validateRange();
if (!validation.valid) {
validationError = validation.error;
return;
}
if (modalMode === 'create') {
formData.refnum = [...(formData.refnum || []), { ...editingRange }];
} else {
const newRanges = formData.refnum?.map((r, i) =>
i === selectedRangeIndex ? { ...editingRange } : r
) || [];
formData.refnum = newRanges;
}
modalOpen = false;
validationError = '';
handleFieldChange();
}
function getRefTypeLabel(type) {
return refTypes.find(t => t.value === type)?.label || type;
}
function getSexLabel(sex) {
return sexOptions.find(s => s.value === sex)?.label || sex;
}
function getSignLabel(sign) {
return signOptions.find(s => s.value === sign)?.label || sign;
}
/**
* Validate all reference ranges
* @returns {boolean}
*/
export function validateAll() {
// Check for duplicate ranges (same type, sex, age range)
const seen = new Set();
for (const range of formData.refnum || []) {
const key = `${range.NumRefType}-${range.Sex}-${range.AgeStart}-${range.AgeEnd}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
}
return true;
}
</script>
<div class="space-y-6">
<h2 class="text-lg font-semibold text-gray-800">Numeric Reference Ranges</h2>
<div class="alert alert-info text-sm">
<Hash class="w-4 h-4" />
<div>
<strong>Numeric Ranges:</strong> Define normal, critical, and validation ranges for numeric test results.
</div>
</div>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Ranges ({formData.refnum?.length || 0})</h3>
</div>
{#if !formData.refnum || formData.refnum.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg">
<Calculator class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500">No numeric ranges defined</p>
<p class="text-xs text-gray-400">Add reference ranges for this test</p>
</div>
{:else}
<div class="overflow-x-auto border border-base-200 rounded-lg">
<table class="table table-sm table-compact">
<thead>
<tr class="bg-base-200">
<th class="w-20">Type</th>
<th class="w-20">Range</th>
<th class="w-16">Sex</th>
<th class="w-24">Age</th>
<th>Low Bound</th>
<th>High Bound</th>
<th class="w-16">Flag</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each formData.refnum as range, idx (idx)}
<tr class="hover:bg-base-100">
<td>
<span class="badge badge-xs badge-ghost">{getRefTypeLabel(range.NumRefType)}</span>
</td>
<td>
<span class="badge badge-xs badge-outline">{range.RangeType}</span>
</td>
<td>{getSexLabel(range.Sex)}</td>
<td class="text-sm">{range.AgeStart}-{range.AgeEnd}</td>
<td class="font-mono text-sm">
{#if range.Low !== null && range.Low !== ''}
{getSignLabel(range.LowSign)} {range.Low}
{:else}
-
{/if}
</td>
<td class="font-mono text-sm">
{#if range.High !== null && range.High !== ''}
{getSignLabel(range.HighSign)} {range.High}
{:else}
-
{/if}
</td>
<td>
<span class="badge badge-xs badge-secondary">{range.Flag || '-'}</span>
</td>
<td>
<div class="flex justify-center gap-1">
<button
class="btn btn-ghost btn-xs"
onclick={() => openEditRange(idx)}
title="Edit Range"
>
<Edit2 class="w-3 h-3" />
</button>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => removeRange(idx)}
title="Remove Range"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="flex justify-end">
<button class="btn btn-sm btn-primary" onclick={openAddRange}>
<Plus class="w-4 h-4 mr-2" />
Add Range
</button>
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Numeric Range' : 'Edit Numeric Range'} size="md">
<div class="space-y-4 max-h-[500px] overflow-y-auto">
{#if validationError}
<div class="alert alert-error alert-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{validationError}</span>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label text-sm">
Reference Type
<span class="text-error">*</span>
</label>
<select class="select select-sm select-bordered" bind:value={editingRange.NumRefType}>
{#each refTypes as rt (rt.value)}
<option value={rt.value} title={rt.desc}>{rt.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label text-sm">
Range Type
<span class="text-error">*</span>
</label>
<select class="select select-sm select-bordered" bind:value={editingRange.RangeType}>
{#each rangeTypes as rt (rt.value)}
<option value={rt.value} title={rt.desc}>{rt.label}</option>
{/each}
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label text-sm">
Sex
<span class="text-error">*</span>
</label>
<select class="select select-sm select-bordered" bind:value={editingRange.Sex}>
{#each sexOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label text-sm">
Age Start
<span class="text-error">*</span>
</label>
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeStart} min="0" max="150" />
</div>
<div class="form-control">
<label class="label text-sm">
Age End
<span class="text-error">*</span>
</label>
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeEnd} min="0" max="150" />
</div>
</div>
</div>
<div class="border-t pt-4">
<h4 class="text-sm font-medium mb-3">Low Bound</h4>
<div class="grid grid-cols-3 gap-2">
<select class="select select-sm select-bordered" bind:value={editingRange.LowSign}>
{#each signOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<input type="number" step="0.01" class="input input-sm input-bordered col-span-2" bind:value={editingRange.Low} placeholder="Low value" />
</div>
</div>
<div class="border-t pt-4">
<h4 class="text-sm font-medium mb-3">High Bound</h4>
<div class="grid grid-cols-3 gap-2">
<select class="select select-sm select-bordered" bind:value={editingRange.HighSign}>
{#each signOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<input type="number" step="0.01" class="input input-sm input-bordered col-span-2" bind:value={editingRange.High} placeholder="High value" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label text-sm">
Flag
<span class="label-text-alt text-xs text-gray-500">(H, L, A, N, C)</span>
</label>
<select class="select select-sm select-bordered" bind:value={editingRange.Flag}>
{#each flagOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label text-sm">Interpretation</label>
<input type="text" class="input input-sm input-bordered" bind:value={editingRange.Interpretation} placeholder="Optional interpretation" maxlength="255" />
</div>
</div>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => { modalOpen = false; validationError = ''; }}>Cancel</button>
<button class="btn btn-primary" onclick={saveRange}>Save</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,241 @@
<script>
import { Plus, Trash2, Type } from 'lucide-svelte';
import Modal from '$lib/components/Modal.svelte';
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
let modalOpen = $state(false);
let modalMode = $state('create');
let selectedRangeIndex = $state(null);
let editingRange = $state({
TxtRefType: 'Normal',
Sex: '0',
AgeStart: 0,
AgeEnd: 150,
RefTxt: '',
Flag: 'N'
});
const refTypes = [
{ value: 'Normal', label: 'Normal' },
{ value: 'Abnormal', label: 'Abnormal' },
{ value: 'Critical', label: 'Critical' }
];
const sexOptions = [
{ value: '0', label: 'All' },
{ value: '1', label: 'Female' },
{ value: '2', label: 'Male' }
];
const flagOptions = $derived.by(() => {
const type = editingRange.TxtRefType;
if (type === 'Normal') return [{ value: 'N', label: 'N - Normal' }];
if (type === 'Abnormal') return [{ value: 'A', label: 'A - Abnormal' }];
if (type === 'Critical') return [{ value: 'C', label: 'C - Critical' }];
return [];
});
const autoFlag = $derived.by(() => {
const type = editingRange.TxtRefType;
if (type === 'Normal') return 'N';
if (type === 'Abnormal') return 'A';
if (type === 'Critical') return 'C';
return editingRange.Flag;
});
function handleFieldChange() {
isDirty = true;
}
function openAddRange() {
modalMode = 'create';
selectedRangeIndex = null;
editingRange = {
TxtRefType: 'Normal',
Sex: '0',
AgeStart: 0,
AgeEnd: 150,
RefTxt: '',
Flag: 'N'
};
modalOpen = true;
}
function openEditRange(index) {
modalMode = 'edit';
selectedRangeIndex = index;
editingRange = { ...formData.reftxt[index] };
modalOpen = true;
}
function removeRange(index) {
const newRanges = formData.reftxt?.filter((_, i) => i !== index) || [];
formData.reftxt = newRanges;
handleFieldChange();
}
function saveRange() {
if (modalMode === 'create') {
formData.reftxt = [...(formData.reftxt || []), { ...editingRange, Flag: autoFlag }];
} else {
const newRanges = formData.reftxt?.map((r, i) =>
i === selectedRangeIndex ? { ...editingRange, Flag: autoFlag } : r
) || [];
formData.reftxt = newRanges;
}
modalOpen = false;
handleFieldChange();
}
function getRefTypeLabel(type) {
return refTypes.find(t => t.value === type)?.label || type;
}
function getSexLabel(sex) {
return sexOptions.find(s => s.value === sex)?.label || sex;
}
</script>
<div class="space-y-6">
<h2 class="text-lg font-semibold text-gray-800">Text Reference Ranges</h2>
<div class="alert alert-info text-sm">
<Type class="w-4 h-4" />
<div>
<strong>Text Ranges:</strong> Define expected text values for tests with text-based results.
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Ranges ({formData.reftxt?.length || 0})</h3>
{#if !formData.reftxt || formData.reftxt.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg">
<Type class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500">No text ranges defined</p>
<p class="text-xs text-gray-400">Add reference ranges for this test</p>
</div>
{:else}
<div class="overflow-x-auto border border-base-200 rounded-lg">
<table class="table table-sm table-compact">
<thead>
<tr class="bg-base-200">
<th class="w-20">Type</th>
<th class="w-16">Sex</th>
<th class="w-24">Age</th>
<th>Reference Text</th>
<th class="w-16">Flag</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each formData.reftxt as range, idx (idx)}
<tr class="hover:bg-base-100">
<td>
<span class="badge badge-xs
{range.TxtRefType === 'Normal' ? 'badge-success' :
range.TxtRefType === 'Abnormal' ? 'badge-warning' :
'badge-error'}">
{getRefTypeLabel(range.TxtRefType)}
</span>
</td>
<td>{getSexLabel(range.Sex)}</td>
<td class="text-sm">{range.AgeStart}-{range.AgeEnd}</td>
<td class="font-mono text-sm">{range.RefTxt || '-'}</td>
<td>
<span class="badge badge-xs badge-secondary">{range.Flag}</span>
</td>
<td>
<div class="flex justify-center gap-1">
<button
class="btn btn-ghost btn-xs"
onclick={() => openEditRange(idx)}
title="Edit Range"
>
<Plus class="w-3 h-3" />
</button>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => removeRange(idx)}
title="Remove Range"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="flex justify-end">
<button class="btn btn-sm btn-primary" onclick={openAddRange}>
<Plus class="w-4 h-4 mr-2" />
Add Range
</button>
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Text Range' : 'Edit Text Range'} size="md">
<div class="space-y-4">
<div class="form-control">
<label class="label text-sm">Reference Type</label>
<select class="select select-sm select-bordered" bind:value={editingRange.TxtRefType}>
{#each refTypes as rt (rt.value)}
<option value={rt.value}>{rt.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label text-sm">Sex</label>
<select class="select select-sm select-bordered" bind:value={editingRange.Sex}>
{#each sexOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label text-sm">Age Start</label>
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeStart} min="0" max="150" />
</div>
<div class="form-control">
<label class="label text-sm">Age End</label>
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeEnd} min="0" max="150" />
</div>
</div>
<div class="form-control">
<label class="label text-sm">
Reference Text
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
type="text"
class="input input-sm input-bordered"
bind:value={editingRange.RefTxt}
placeholder="e.g., Clear, Cloudy, Bloody"
maxlength="255"
/>
</div>
<div class="form-control">
<label class="label text-sm">Flag</label>
<input type="text" class="input input-sm input-bordered" value={autoFlag} readonly />
<label class="label">
<span class="label-text-alt text-xs text-gray-500">Flag is automatically set based on reference type</span>
</label>
</div>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => modalOpen = false}>Cancel</button>
<button class="btn btn-primary" onclick={saveRange}>Save</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,306 @@
<script>
import { getResultTypeOptions, getRefTypeOptions, validateTypeCombination } from '$lib/api/tests.js';
import { AlertCircle } from 'lucide-svelte';
let { formData = $bindable(), disciplines = [], departments = [], isDirty = $bindable(false) } = $props();
let validationErrors = $state({
typeCombination: ''
});
const disciplineOptions = $derived(disciplines.map(d => ({ value: d.DisciplineID, label: d.DisciplineName })));
const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName })));
const resultTypeOptions = $derived(getResultTypeOptions(formData.TestType));
const refTypeOptions = $derived(getRefTypeOptions(formData.details?.ResultType));
const typeValidation = $derived.by(() => {
const result = validateTypeCombination(
formData.TestType,
formData.details?.ResultType,
formData.details?.RefType
);
return result;
});
function handleFieldChange() {
isDirty = true;
validationErrors.typeCombination = '';
}
function handleResultTypeChange() {
const resultType = formData.details.ResultType;
if (resultType === 'VSET') {
formData.details.RefType = 'VSET';
} else if (resultType === 'TEXT') {
formData.details.RefType = 'TEXT';
} else if (resultType === 'NORES') {
formData.details.RefType = 'NOREF';
} else if (resultType === 'NMRIC' || resultType === 'RANGE') {
formData.details.RefType = 'RANGE';
}
if (resultType !== 'VSET') {
formData.details.VSet = '';
}
handleFieldChange();
}
export function validateAll() {
validationErrors.typeCombination = '';
if (!typeValidation.valid) {
validationErrors.typeCombination = typeValidation.error;
return false;
}
if (formData.details.Decimal !== null && formData.details.Decimal !== undefined) {
if (formData.details.Decimal < 0 || formData.details.Decimal > 6) {
return false;
}
}
return true;
}
</script>
<div class="space-y-5">
<h2 class="text-lg font-semibold text-gray-800">Technical Details</h2>
{#if !typeValidation.valid}
<div class="alert alert-warning text-sm">
<AlertCircle class="w-4 h-4" />
<span>{typeValidation.error}</span>
</div>
{/if}
<!-- Categorization -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Categorization</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label for="discipline" class="block text-sm font-medium text-gray-700">Discipline</label>
<select
id="discipline"
class="select select-sm select-bordered w-full"
bind:value={formData.details.DisciplineID}
onchange={handleFieldChange}
>
<option value={null}>Select discipline...</option>
{#each disciplineOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<label for="department" class="block text-sm font-medium text-gray-700">Department</label>
<select
id="department"
class="select select-sm select-bordered w-full"
bind:value={formData.details.DepartmentID}
onchange={handleFieldChange}
>
<option value={null}>Select department...</option>
{#each departmentOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</div>
<!-- Result Configuration -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Result Configuration</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label for="resultType" class="block text-sm font-medium text-gray-700">Result Type</label>
<select
id="resultType"
class="select select-sm select-bordered w-full"
bind:value={formData.details.ResultType}
onchange={handleResultTypeChange}
>
<option value="">Select result type...</option>
{#each resultTypeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if formData.TestType === 'CALC'}
<span class="text-xs text-gray-500">CALC type always uses Numeric Reference</span>
{/if}
</div>
<div class="space-y-1">
<label for="refType" class="block text-sm font-medium text-gray-700">Reference Type</label>
<select
id="refType"
class="select select-sm select-bordered w-full"
bind:value={formData.details.RefType}
onchange={handleFieldChange}
disabled={refTypeOptions.length === 0 || refTypeOptions.length === 1}
>
<option value="">Select reference type...</option>
{#each refTypeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if refTypeOptions.length === 1}
<span class="text-xs text-gray-500">Automatically set based on Result Type</span>
{/if}
</div>
</div>
{#if formData.details.ResultType === 'VSET'}
<div class="mt-4 space-y-1">
<label for="vset" class="block text-sm font-medium text-gray-700">
Value Set <span class="text-error">*</span>
</label>
<input
id="vset"
type="text"
class="input input-sm input-bordered w-full md:w-1/2"
bind:value={formData.details.VSet}
placeholder="Enter value set key..."
oninput={handleFieldChange}
required={formData.details.ResultType === 'VSET'}
/>
<span class="text-xs text-gray-500">Required when Result Type is 'Value Set'</span>
</div>
{/if}
</div>
<!-- Units & Conversion -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Units & Conversion</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-1">
<label for="unit1" class="block text-sm font-medium text-gray-700">Unit 1</label>
<input
id="unit1"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Unit1}
placeholder="e.g., g/dL"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="factor" class="block text-sm font-medium text-gray-700">Factor</label>
<input
id="factor"
type="number"
step="0.01"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Factor}
placeholder="1.0"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="unit2" class="block text-sm font-medium text-gray-700">Unit 2</label>
<input
id="unit2"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Unit2}
placeholder="e.g., g/L"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="decimal" class="block text-sm font-medium text-gray-700">Decimal</label>
<input
id="decimal"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Decimal}
min="0"
max="6"
oninput={handleFieldChange}
/>
<span class="text-xs text-gray-500">Max 6</span>
</div>
</div>
</div>
<!-- Sample Requirements -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Sample Requirements</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label for="reqQty" class="block text-sm font-medium text-gray-700">Required Quantity</label>
<input
id="reqQty"
type="number"
step="0.1"
min="0"
class="input input-sm input-bordered w-full"
bind:value={formData.details.ReqQty}
placeholder="e.g., 5.0"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="reqQtyUnit" class="block text-sm font-medium text-gray-700">Quantity Unit</label>
<input
id="reqQtyUnit"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.ReqQtyUnit}
placeholder="e.g., mL"
oninput={handleFieldChange}
/>
</div>
</div>
<div class="mt-4 space-y-1">
<label for="collReq" class="block text-sm font-medium text-gray-700">Collection Requirements</label>
<textarea
id="collReq"
class="textarea textarea-sm textarea-bordered w-full"
bind:value={formData.details.CollReq}
placeholder="e.g., Fasting required, Collect in lavender tube..."
rows="2"
oninput={handleFieldChange}
></textarea>
</div>
</div>
<!-- Method & TAT -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Method & TAT</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label for="method" class="block text-sm font-medium text-gray-700">Method</label>
<input
id="method"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.details.Method}
placeholder="e.g., Automated Analyzer"
oninput={handleFieldChange}
/>
</div>
<div class="space-y-1">
<label for="expectedTAT" class="block text-sm font-medium text-gray-700">Expected TAT (minutes)</label>
<input
id="expectedTAT"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.details.ExpectedTAT}
min="0"
placeholder="e.g., 60"
oninput={handleFieldChange}
/>
</div>
</div>
</div>
</div>