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:
parent
a96a6ee279
commit
99d622ad05
193
AGENTS.md
193
AGENTS.md
@ -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
|
||||
|
||||
432
backup/tests_backup/+page.svelte
Normal file
432
backup/tests_backup/+page.svelte
Normal 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>
|
||||
@ -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}
|
||||
@ -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>
|
||||
@ -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}
|
||||
1581
docs/FRONTEND_TEST_MANAGEMENT_PROMPT.md
Normal file
1581
docs/FRONTEND_TEST_MANAGEMENT_PROMPT.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
26
src/lib/types/index.ts
Normal 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
306
src/lib/types/test.types.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user