Add comprehensive test management module with reference range support
- Create TestModal component with tabbed interface for test creation/editing - Add BasicInfoForm for test metadata (name, code, category, etc.) - Implement multiple reference range types: * NumericRefRange for numeric value ranges * TextRefRange for qualitative results * ThresholdRefRange for threshold-based results * ValueSetRefRange for predefined value sets - Add ReferenceRangeSection to manage all reference range types - Create config store for application configuration management - Add static config.json for environment settings - Update DataTable styling - Refactor tests page to integrate new TestModal - Add reference range utility functions - Include comprehensive test types documentation
This commit is contained in:
parent
5aab10df04
commit
f0f5889df4
417
docs/test-types-reference.md
Normal file
417
docs/test-types-reference.md
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
# 📋 Test Types & Reference Types Guide
|
||||||
|
|
||||||
|
> **Quick Overview**: This guide helps you understand the different types of tests and how to display them in the frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 What Are Test Types?
|
||||||
|
|
||||||
|
Think of test types as "categories" that determine how a test behaves and what information it needs.
|
||||||
|
|
||||||
|
### Quick Reference Card
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬────────────────────────────┬────────────────────────┐
|
||||||
|
│ Type │ Use This For... │ Example │
|
||||||
|
├─────────────┼────────────────────────────┼────────────────────────┤
|
||||||
|
│ TEST │ Standard lab tests │ Blood Glucose, CBC │
|
||||||
|
│ PARAM │ Components of a test │ WBC count (in CBC) │
|
||||||
|
│ CALC │ Formula-based results │ BMI, eGFR │
|
||||||
|
│ GROUP │ Panels/batteries │ Lipid Panel, CMP │
|
||||||
|
└─────────────┴────────────────────────────┴────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Detailed Test Types
|
||||||
|
|
||||||
|
### 1. TEST - Standard Laboratory Test
|
||||||
|
**Icon**: 🧫 **Color**: Blue
|
||||||
|
|
||||||
|
Use this for regular tests that have:
|
||||||
|
- Reference ranges (normal values)
|
||||||
|
- Units (mg/dL, mmol/L, etc.)
|
||||||
|
- Collection requirements
|
||||||
|
|
||||||
|
**Example**: Blood Glucose, Hemoglobin, Cholesterol
|
||||||
|
|
||||||
|
**What to Display**:
|
||||||
|
- Test code and name
|
||||||
|
- Reference range
|
||||||
|
- Units
|
||||||
|
- Collection instructions
|
||||||
|
- Expected turnaround time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. PARAM - Parameter
|
||||||
|
**Icon**: 📊 **Color**: Light Blue
|
||||||
|
|
||||||
|
Use this for individual components within a larger test.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
- Complete Blood Count (GROUP) contains:
|
||||||
|
- WBC (PARAM)
|
||||||
|
- RBC (PARAM)
|
||||||
|
- Hemoglobin (PARAM)
|
||||||
|
|
||||||
|
**What to Display**:
|
||||||
|
- Same as TEST, but shown indented under parent
|
||||||
|
- Often part of a GROUP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. CALC - Calculated Test
|
||||||
|
**Icon**: 🧮 **Color**: Purple
|
||||||
|
|
||||||
|
Use this for tests computed from other test results using formulas.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
- BMI (calculated from height & weight)
|
||||||
|
- eGFR (calculated from creatinine, age, etc.)
|
||||||
|
|
||||||
|
**What to Display**:
|
||||||
|
- Formula description
|
||||||
|
- Input parameters (which tests feed into this)
|
||||||
|
- Result value
|
||||||
|
- Reference range (if applicable)
|
||||||
|
|
||||||
|
**Special Fields**:
|
||||||
|
- `FormulaInput` - What values go in?
|
||||||
|
- `FormulaCode` - How is it calculated?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. GROUP - Group Test (Panel/Battery)
|
||||||
|
**Icon**: 📦 **Color**: Green
|
||||||
|
|
||||||
|
Use this to bundle multiple related tests together.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
- Lipid Panel (GROUP) contains:
|
||||||
|
- Total Cholesterol (PARAM)
|
||||||
|
- HDL (PARAM)
|
||||||
|
- LDL (PARAM)
|
||||||
|
- Triglycerides (PARAM)
|
||||||
|
|
||||||
|
**What to Display**:
|
||||||
|
- Group name
|
||||||
|
- List of member tests
|
||||||
|
- Individual results for each member
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```
|
||||||
|
📦 Lipid Panel (GROUP)
|
||||||
|
├── Total Cholesterol (PARAM): 180 mg/dL
|
||||||
|
├── HDL (PARAM): 55 mg/dL
|
||||||
|
├── LDL (PARAM): 110 mg/dL
|
||||||
|
└── Triglycerides (PARAM): 150 mg/dL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Reference Types Explained
|
||||||
|
|
||||||
|
Reference types tell you **how to interpret the results** - what "normal" looks like.
|
||||||
|
|
||||||
|
### Choose Your Reference Type:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Reference Type? │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
│ │ │
|
||||||
|
Numbers? Text values? Threshold?
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ NMRC │ │ TEXT │ │ THOLD │
|
||||||
|
│ (Numeric) │ │ (Text) │ │ (Threshold) │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ RANGE │ │ Free │ │ Positive/ │
|
||||||
|
│ (Min-Max) │ │ Text │ │ Negative │
|
||||||
|
│ OR │ │ OR │ │ OR │
|
||||||
|
│ THOLD │ │ VSET │ │ < > = │
|
||||||
|
│ (Threshold) │ │ (Dropdown) │ │ cutoff │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reference Type Details
|
||||||
|
|
||||||
|
| Type | Visual | Example |
|
||||||
|
|------|--------|---------|
|
||||||
|
| **NMRC** - Numeric Range | `70 - 100 mg/dL` | Blood glucose: 70-100 mg/dL |
|
||||||
|
| **TEXT** - Text Value | `"Normal"` or `"Positive"` | Urinalysis: "Clear", "Cloudy" |
|
||||||
|
| **THOLD** - Threshold | `> 60` or `< 5.5` | eGFR: > 60 (normal) |
|
||||||
|
| **VSET** - Value Set | Dropdown options | Organism: [E.coli, Staph, etc.] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Frontend Display Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Test List View
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// When showing a list of tests
|
||||||
|
function renderTestCard(test) {
|
||||||
|
const typeIcon = getIcon(test.TestType);
|
||||||
|
const typeColor = getColor(test.TestType);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="test-card ${typeColor}">
|
||||||
|
<span class="icon">${typeIcon}</span>
|
||||||
|
<h3>${test.TestSiteName}</h3>
|
||||||
|
<span class="badge">${test.TestTypeLabel}</span>
|
||||||
|
<code>${test.TestSiteCode}</code>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Reference Range Display
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Show reference range based on type
|
||||||
|
function renderReferenceRange(test) {
|
||||||
|
switch(test.RefType) {
|
||||||
|
case 'NMRC':
|
||||||
|
return `${test.MinValue} - ${test.MaxValue} ${test.Unit}`;
|
||||||
|
|
||||||
|
case 'TEXT':
|
||||||
|
return test.ReferenceText || 'See report';
|
||||||
|
|
||||||
|
case 'THOLD':
|
||||||
|
return `${test.ThresholdOperator} ${test.ThresholdValue}`;
|
||||||
|
|
||||||
|
case 'VSET':
|
||||||
|
return 'Select from list';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Group Test Expansion
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Expandable group test
|
||||||
|
function renderGroupTest(test) {
|
||||||
|
return `
|
||||||
|
<div class="group-test">
|
||||||
|
<button onclick="toggleGroup(${test.TestSiteID})">
|
||||||
|
📦 ${test.TestSiteName} (${test.testdefgrp.length} tests)
|
||||||
|
</button>
|
||||||
|
<div class="members" id="group-${test.TestSiteID}">
|
||||||
|
${test.testdefgrp.map(member => renderTestRow(member)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Data Structure Visualization
|
||||||
|
|
||||||
|
### Test Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TEST DEFINITION │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ TestSiteID: 12345 │
|
||||||
|
│ TestSiteCode: GLUC │
|
||||||
|
│ TestSiteName: Glucose │
|
||||||
|
│ TestType: TEST │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ 📎 Additional Info (loaded based on TestType): │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ TestDefTech (for TEST/PARAM) │ │
|
||||||
|
│ │ - DisciplineID, DepartmentID │ │
|
||||||
|
│ │ - ResultType, RefType │ │
|
||||||
|
│ │ - Unit, Method, ExpectedTAT │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ TestDefCal (for CALC) │ │
|
||||||
|
│ │ - FormulaInput, FormulaCode │ │
|
||||||
|
│ │ - RefType, Unit, Decimal │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ TestDefGrp (for GROUP) │ │
|
||||||
|
│ │ - Array of member tests │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ TestMap (for ALL types) │ │
|
||||||
|
│ │ - Mapping to external systems │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Common Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Creating a Simple Test
|
||||||
|
|
||||||
|
**Need**: Add "Hemoglobin" test
|
||||||
|
**Choose**: TEST type with NMRC reference
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const hemoglobin = {
|
||||||
|
TestSiteCode: 'HGB',
|
||||||
|
TestSiteName: 'Hemoglobin',
|
||||||
|
TestType: 'TEST',
|
||||||
|
testdeftech: {
|
||||||
|
RefType: 'NMRC',
|
||||||
|
Unit: 'g/dL',
|
||||||
|
// Reference range: 12-16 g/dL
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Creating a Panel
|
||||||
|
|
||||||
|
**Need**: "Complete Blood Count" with multiple components
|
||||||
|
**Choose**: GROUP type with PARAM children
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const cbc = {
|
||||||
|
TestSiteCode: 'CBC',
|
||||||
|
TestSiteName: 'Complete Blood Count',
|
||||||
|
TestType: 'GROUP',
|
||||||
|
testdefgrp: [
|
||||||
|
{ TestSiteCode: 'WBC', TestType: 'PARAM' },
|
||||||
|
{ TestSiteCode: 'RBC', TestType: 'PARAM' },
|
||||||
|
{ TestSiteCode: 'HGB', TestType: 'PARAM' },
|
||||||
|
{ TestSiteCode: 'PLT', TestType: 'PARAM' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Calculated Result
|
||||||
|
|
||||||
|
**Need**: BMI from height and weight
|
||||||
|
**Choose**: CALC type with formula
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const bmi = {
|
||||||
|
TestSiteCode: 'BMI',
|
||||||
|
TestSiteName: 'Body Mass Index',
|
||||||
|
TestType: 'CALC',
|
||||||
|
testdefcal: {
|
||||||
|
FormulaInput: 'HEIGHT,WEIGHT',
|
||||||
|
FormulaCode: 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))',
|
||||||
|
RefType: 'NMRC',
|
||||||
|
Unit: 'kg/m²'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Visual Style Guide
|
||||||
|
|
||||||
|
### Color Coding
|
||||||
|
|
||||||
|
| Type | Primary Color | Background | Usage |
|
||||||
|
|------|---------------|------------|-------|
|
||||||
|
| TEST | `#0066CC` | `#E6F2FF` | Main test cards |
|
||||||
|
| PARAM | `#3399FF` | `#F0F8FF` | Component rows |
|
||||||
|
| CALC | `#9933CC` | `#F5E6FF` | Calculated fields |
|
||||||
|
| GROUP | `#00AA44` | `#E6F9EE` | Expandable panels |
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
| Type | Icon | Unicode |
|
||||||
|
|------|------|---------|
|
||||||
|
| TEST | 🧫 | `\u{1F9EB}` |
|
||||||
|
| PARAM | 📊 | `\u{1F4CA}` |
|
||||||
|
| CALC | 🧮 | `\u{1F9EE}` |
|
||||||
|
| GROUP | 📦 | `\u{1F4E6}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Quick API Reference
|
||||||
|
|
||||||
|
### Fetch Tests
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get all tests for a site
|
||||||
|
GET /api/tests?siteId=123
|
||||||
|
|
||||||
|
// Get specific test type
|
||||||
|
GET /api/tests?testType=GROUP
|
||||||
|
|
||||||
|
// Search tests
|
||||||
|
GET /api/tests?search=glucose
|
||||||
|
|
||||||
|
// Get single test with details
|
||||||
|
GET /api/tests/12345
|
||||||
|
```
|
||||||
|
|
||||||
|
### Value Set Helpers
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get human-readable labels
|
||||||
|
const labels = {
|
||||||
|
testType: valueSet.getLabel('test_type', test.TestType),
|
||||||
|
refType: valueSet.getLabel('reference_type', test.RefType),
|
||||||
|
resultType: valueSet.getLabel('result_type', test.ResultType)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get dropdown options
|
||||||
|
const testTypes = valueSet.get('test_type');
|
||||||
|
// Returns: [{value: "TEST", label: "Test"}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Value Set File Locations
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Libraries/Data/
|
||||||
|
├── test_type.json # TEST, PARAM, CALC, GROUP
|
||||||
|
├── reference_type.json # NMRC, TEXT, THOLD, VSET
|
||||||
|
├── result_type.json # NMRIC, RANGE, TEXT, VSET
|
||||||
|
├── numeric_ref_type.json # RANGE, THOLD
|
||||||
|
└── text_ref_type.json # VSET, TEXT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Checklist for Frontend Developers
|
||||||
|
|
||||||
|
- [ ] Show correct icon for each test type
|
||||||
|
- [ ] Display reference ranges based on RefType
|
||||||
|
- [ ] Handle GROUP tests as expandable panels
|
||||||
|
|
||||||
|
- [ ] Display CALC formulas when available
|
||||||
|
- [ ] Use ValueSet labels for all coded fields
|
||||||
|
- [ ] Respect VisibleScr/VisibleRpt flags
|
||||||
|
- [ ] Handle soft-deleted tests (EndDate check)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Need Help?
|
||||||
|
|
||||||
|
**Q: When do I use PARAM vs TEST?**
|
||||||
|
A: Use PARAM for sub-components of a GROUP. Use TEST for standalone tests.
|
||||||
|
|
||||||
|
**Q: What's the difference between NMRC and THOLD?**
|
||||||
|
A: NMRC shows a range (70-100). THOLD shows a threshold (> 60 or < 5.5).
|
||||||
|
|
||||||
|
**Q: Can a GROUP contain other GROUPs?**
|
||||||
|
A: Yes! Groups can be nested (though typically only 1-2 levels deep).
|
||||||
|
|
||||||
|
**Q: How do I know which details to fetch?**
|
||||||
|
A: Check `TestType` first, then look for the corresponding property:
|
||||||
|
- TEST/PARAM → `testdeftech`
|
||||||
|
- CALC → `testdefcal`
|
||||||
|
- GROUP → `testdefgrp`
|
||||||
|
- All types → `testmap`
|
||||||
|
|
||||||
@ -1,7 +1,16 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
|
import { config } from '$lib/stores/config.js';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
/**
|
||||||
|
* Get the API URL from runtime config
|
||||||
|
* Falls back to build-time env var if runtime config not available
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getApiUrl() {
|
||||||
|
const runtimeUrl = config.getApiUrl();
|
||||||
|
return runtimeUrl || import.meta.env.VITE_API_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base API client with JWT handling
|
* Base API client with JWT handling
|
||||||
@ -27,8 +36,8 @@ export async function apiClient(endpoint, options = {}) {
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build full URL
|
// Build full URL using runtime config
|
||||||
const url = `${API_URL}${endpoint}`;
|
const url = `${getApiUrl()}${endpoint}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
|||||||
@ -104,7 +104,7 @@
|
|||||||
{#each columns as column}
|
{#each columns as column}
|
||||||
<td class="{column.class || ''}">
|
<td class="{column.class || ''}">
|
||||||
{#if cell}
|
{#if cell}
|
||||||
{@render cell({ column, row, value: getValue(row, column.key) })}
|
{@render cell({ column, row, value: getValue(row, column.key), index })}
|
||||||
{:else if column.render}
|
{:else if column.render}
|
||||||
{@render column.render(row)}
|
{@render column.render(row)}
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
104
src/lib/components/TestModal.svelte
Normal file
104
src/lib/components/TestModal.svelte
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<script>
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import BasicInfoForm from '$lib/components/test-modal/BasicInfoForm.svelte';
|
||||||
|
import ReferenceRangeSection from '$lib/components/test-modal/ReferenceRangeSection.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {boolean} open - Whether modal is open
|
||||||
|
* @property {string} mode - 'create' or 'edit'
|
||||||
|
* @property {Object} formData - Form data object
|
||||||
|
* @property {boolean} canHaveRefRange - Whether test can have reference ranges
|
||||||
|
* @property {boolean} canHaveFormula - Whether test can have a formula
|
||||||
|
* @property {boolean} canHaveUnit - Whether test can have a unit
|
||||||
|
* @property {Array<{value: string, label: string}>} disciplineOptions - Discipline dropdown options
|
||||||
|
* @property {Array<{value: string, label: string}>} departmentOptions - Department dropdown options
|
||||||
|
* @property {boolean} [saving] - Whether save is in progress
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props & { onsave?: () => void, oncancel?: () => void, onupdateFormData?: (formData: Object) => void }} */
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
mode = 'create',
|
||||||
|
formData = $bindable({}),
|
||||||
|
canHaveRefRange = false,
|
||||||
|
canHaveFormula = false,
|
||||||
|
canHaveUnit = false,
|
||||||
|
disciplineOptions = [],
|
||||||
|
departmentOptions = [],
|
||||||
|
saving = false,
|
||||||
|
onsave = () => {},
|
||||||
|
oncancel = () => {},
|
||||||
|
onupdateFormData = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
let activeTab = $state('basic');
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
activeTab = 'basic';
|
||||||
|
oncancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
onsave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive update when modal opens
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
activeTab = 'basic';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open title={mode === 'create' ? 'Add Test' : 'Edit Test'} size="xl">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs tabs-bordered mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab tab-lg {activeTab === 'basic' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => activeTab = 'basic'}
|
||||||
|
>
|
||||||
|
Basic Information
|
||||||
|
</button>
|
||||||
|
{#if canHaveRefRange}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab tab-lg {activeTab === 'refrange' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => activeTab = 'refrange'}
|
||||||
|
>
|
||||||
|
Reference Range
|
||||||
|
{#if formData.refnum?.length > 0 || formData.reftxt?.length > 0}
|
||||||
|
<span class="badge badge-sm badge-primary ml-2">{(formData.refnum?.length || 0) + (formData.reftxt?.length || 0)}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === 'basic'}
|
||||||
|
<BasicInfoForm
|
||||||
|
bind:formData
|
||||||
|
{canHaveFormula}
|
||||||
|
{canHaveUnit}
|
||||||
|
{disciplineOptions}
|
||||||
|
{departmentOptions}
|
||||||
|
onsave={handleSave}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === 'refrange' && canHaveRefRange}
|
||||||
|
<ReferenceRangeSection
|
||||||
|
bind:formData
|
||||||
|
{onupdateFormData}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn btn-ghost" onclick={handleCancel} type="button">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
|
||||||
|
{#if saving}
|
||||||
|
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||||
|
{/if}
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
185
src/lib/components/test-modal/BasicInfoForm.svelte
Normal file
185
src/lib/components/test-modal/BasicInfoForm.svelte
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<script>
|
||||||
|
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
||||||
|
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Object} formData - Form data object
|
||||||
|
* @property {boolean} canHaveFormula - Whether test can have a formula
|
||||||
|
* @property {boolean} canHaveUnit - Whether test can have a unit
|
||||||
|
* @property {Array<{value: string, label: string}>} disciplineOptions - Discipline dropdown options
|
||||||
|
* @property {Array<{value: string, label: string}>} departmentOptions - Department dropdown options
|
||||||
|
* @property {() => void} onsave - Save handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
formData = $bindable({}),
|
||||||
|
canHaveFormula = false,
|
||||||
|
canHaveUnit = false,
|
||||||
|
disciplineOptions = [],
|
||||||
|
departmentOptions = [],
|
||||||
|
onsave = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
onsave();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="space-y-5" onsubmit={handleSubmit}>
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="testCode">
|
||||||
|
<span class="label-text font-medium">Test Code</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="testCode"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.TestSiteCode}
|
||||||
|
placeholder="e.g., GLU"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="testName">
|
||||||
|
<span class="label-text font-medium">Test Name</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="testName"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.TestSiteName}
|
||||||
|
placeholder="e.g., Glucose"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type and Sequence -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="testType">
|
||||||
|
<span class="label-text font-medium">Test Type</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="testType"
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={formData.TestType}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="TEST">🧫 Technical 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>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="seqScr">
|
||||||
|
<span class="label-text font-medium">Screen Sequence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="seqScr"
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.SeqScr}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Discipline and Department -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<SelectDropdown
|
||||||
|
label="Discipline"
|
||||||
|
name="discipline"
|
||||||
|
bind:value={formData.DisciplineID}
|
||||||
|
options={disciplineOptions}
|
||||||
|
placeholder="Select discipline..."
|
||||||
|
/>
|
||||||
|
<SelectDropdown
|
||||||
|
label="Department"
|
||||||
|
name="department"
|
||||||
|
bind:value={formData.DepartmentID}
|
||||||
|
options={departmentOptions}
|
||||||
|
placeholder="Select department..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type-specific fields -->
|
||||||
|
{#if canHaveUnit}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{#if canHaveFormula}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="formula">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
Formula
|
||||||
|
<HelpTooltip
|
||||||
|
text="Enter a mathematical formula using test codes (e.g., BUN / Creatinine). Supported operators: +, -, *, /, parentheses."
|
||||||
|
title="Formula Help"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="formula"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.Formula}
|
||||||
|
placeholder="e.g., BUN / Creatinine"
|
||||||
|
required={canHaveFormula}
|
||||||
|
/>
|
||||||
|
<span class="label-text-alt text-gray-500">Use test codes with operators: +, -, *, /</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="unit">
|
||||||
|
<span class="label-text font-medium">Unit</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="unit"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.Unit}
|
||||||
|
placeholder="e.g., mg/dL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Report Sequence and Visibility -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="seqRpt">
|
||||||
|
<span class="label-text font-medium">Report Sequence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="seqRpt"
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.SeqRpt}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text font-medium mb-2 block">Visibility</span>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" class="checkbox" bind:checked={formData.VisibleScr} />
|
||||||
|
<span class="label-text">Screen</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" class="checkbox" bind:checked={formData.VisibleRpt} />
|
||||||
|
<span class="label-text">Report</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
134
src/lib/components/test-modal/NumericRefRange.svelte
Normal file
134
src/lib/components/test-modal/NumericRefRange.svelte
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, Calculator, X } from 'lucide-svelte';
|
||||||
|
import { signOptions, flagOptions, sexOptions } from './refRangeConstants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} refnum - Numeric reference ranges array
|
||||||
|
* @property {(refnum: Array) => void} onupdateRefnum - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
refnum = [],
|
||||||
|
onupdateRefnum = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = {
|
||||||
|
Sex: '2',
|
||||||
|
LowSign: 'GE',
|
||||||
|
HighSign: 'LE',
|
||||||
|
Low: null,
|
||||||
|
High: null,
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
Flag: 'N',
|
||||||
|
Interpretation: 'Normal'
|
||||||
|
};
|
||||||
|
onupdateRefnum([...refnum, newRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateRefnum(refnum.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Calculator class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Numeric Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refnum?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<Calculator class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No numeric ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each refnum || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
|
||||||
|
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age From</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age To</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
{#each flagOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label} - {option.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Lower Bound</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-20" bind:value={ref.LowSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input type="number" step="0.01" class="input input-sm input-bordered flex-1" bind:value={ref.Low} placeholder="e.g., 70" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Upper Bound</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-20" bind:value={ref.HighSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input type="number" step="0.01" class="input input-sm input-bordered flex-1" bind:value={ref.High} placeholder="e.g., 100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-3">
|
||||||
|
<span class="label-text text-xs mb-1">Interpretation</span>
|
||||||
|
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.Interpretation} placeholder="e.g., Normal range" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
210
src/lib/components/test-modal/ReferenceRangeSection.svelte
Normal file
210
src/lib/components/test-modal/ReferenceRangeSection.svelte
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
<script>
|
||||||
|
import { Ruler, Calculator, FileText, Box } from 'lucide-svelte';
|
||||||
|
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
|
||||||
|
import NumericRefRange from './NumericRefRange.svelte';
|
||||||
|
import ThresholdRefRange from './ThresholdRefRange.svelte';
|
||||||
|
import TextRefRange from './TextRefRange.svelte';
|
||||||
|
import ValueSetRefRange from './ValueSetRefRange.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Object} formData - Form data object
|
||||||
|
* @property {(formData: Object) => void} onupdateFormData - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
formData = $bindable({}),
|
||||||
|
onupdateFormData = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function updateRefRangeType(type) {
|
||||||
|
let newFormData = {
|
||||||
|
...formData,
|
||||||
|
refRangeType: type,
|
||||||
|
refnum: [],
|
||||||
|
refthold: [],
|
||||||
|
reftxt: [],
|
||||||
|
refvset: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the selected type
|
||||||
|
if (type === 'num') {
|
||||||
|
newFormData.refnum = [{
|
||||||
|
Sex: '2',
|
||||||
|
LowSign: 'GE',
|
||||||
|
HighSign: 'LE',
|
||||||
|
Low: null,
|
||||||
|
High: null,
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
Flag: 'N',
|
||||||
|
Interpretation: 'Normal'
|
||||||
|
}];
|
||||||
|
} else if (type === 'thold') {
|
||||||
|
newFormData.refthold = [{
|
||||||
|
Sex: '2',
|
||||||
|
LowSign: 'GE',
|
||||||
|
HighSign: 'LE',
|
||||||
|
Low: null,
|
||||||
|
High: null,
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
Flag: 'N',
|
||||||
|
Interpretation: 'Normal'
|
||||||
|
}];
|
||||||
|
} else if (type === 'text') {
|
||||||
|
newFormData.reftxt = [{
|
||||||
|
Sex: '2',
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
RefTxt: '',
|
||||||
|
Flag: 'N'
|
||||||
|
}];
|
||||||
|
} else if (type === 'vset') {
|
||||||
|
newFormData.refvset = [{
|
||||||
|
Sex: '2',
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
valueset: '',
|
||||||
|
Flag: 'N'
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
onupdateFormData(newFormData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRefnum(refnum) {
|
||||||
|
onupdateFormData({ ...formData, refnum });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRefthold(refthold) {
|
||||||
|
onupdateFormData({ ...formData, refthold });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReftxt(reftxt) {
|
||||||
|
onupdateFormData({ ...formData, reftxt });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRefvset(refvset) {
|
||||||
|
onupdateFormData({ ...formData, refvset });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Reference Range Type Selection -->
|
||||||
|
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<Ruler class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Reference Range Type</h3>
|
||||||
|
<HelpTooltip
|
||||||
|
text="Choose how to define normal/abnormal ranges for this test."
|
||||||
|
title="Reference Range Help"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="none"
|
||||||
|
checked={formData.refRangeType === 'none'}
|
||||||
|
onchange={() => updateRefRangeType('none')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium">None</span>
|
||||||
|
<span class="text-xs text-gray-500">No reference range</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="num"
|
||||||
|
checked={formData.refRangeType === 'num'}
|
||||||
|
onchange={() => updateRefRangeType('num')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Numeric
|
||||||
|
<Calculator class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Range (e.g., 70-100)</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="thold"
|
||||||
|
checked={formData.refRangeType === 'thold'}
|
||||||
|
onchange={() => updateRefRangeType('thold')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Threshold
|
||||||
|
<Ruler class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Limit values</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="text"
|
||||||
|
checked={formData.refRangeType === 'text'}
|
||||||
|
onchange={() => updateRefRangeType('text')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Text
|
||||||
|
<FileText class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Descriptive</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="vset"
|
||||||
|
checked={formData.refRangeType === 'vset'}
|
||||||
|
onchange={() => updateRefRangeType('vset')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Value Set
|
||||||
|
<Box class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Predefined values</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Numeric Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'num'}
|
||||||
|
<NumericRefRange refnum={formData.refnum} onupdateRefnum={updateRefnum} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Threshold Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'thold'}
|
||||||
|
<ThresholdRefRange refthold={formData.refthold} onupdateRefthold={updateRefthold} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Text Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'text'}
|
||||||
|
<TextRefRange reftxt={formData.reftxt} onupdateReftxt={updateReftxt} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Value Set Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'vset'}
|
||||||
|
<ValueSetRefRange refvset={formData.refvset} onupdateRefvset={updateRefvset} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
103
src/lib/components/test-modal/TextRefRange.svelte
Normal file
103
src/lib/components/test-modal/TextRefRange.svelte
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, FileText, X } from 'lucide-svelte';
|
||||||
|
import { sexOptions } from './refRangeConstants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} reftxt - Text reference ranges array
|
||||||
|
* @property {(reftxt: Array) => void} onupdateReftxt - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
reftxt = [],
|
||||||
|
onupdateReftxt = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = {
|
||||||
|
Sex: '2',
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
RefTxt: '',
|
||||||
|
Flag: 'N'
|
||||||
|
};
|
||||||
|
onupdateReftxt([...reftxt, newRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateReftxt(reftxt.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<FileText class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Text Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if reftxt?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<FileText class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No text ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each reftxt || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
|
||||||
|
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age From</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age To</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
<option value="N">N - Normal</option>
|
||||||
|
<option value="A">A - Abnormal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-3">
|
||||||
|
<span class="label-text text-xs mb-1">Reference Text</span>
|
||||||
|
<textarea class="textarea textarea-bordered w-full" rows="2" bind:value={ref.RefTxt} placeholder="e.g., Negative for glucose"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
134
src/lib/components/test-modal/ThresholdRefRange.svelte
Normal file
134
src/lib/components/test-modal/ThresholdRefRange.svelte
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, Ruler, X } from 'lucide-svelte';
|
||||||
|
import { signOptions, flagOptions, sexOptions } from './refRangeConstants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} refthold - Threshold reference ranges array
|
||||||
|
* @property {(refthold: Array) => void} onupdateRefthold - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
refthold = [],
|
||||||
|
onupdateRefthold = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = {
|
||||||
|
Sex: '2',
|
||||||
|
LowSign: 'GE',
|
||||||
|
HighSign: 'LE',
|
||||||
|
Low: null,
|
||||||
|
High: null,
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
Flag: 'N',
|
||||||
|
Interpretation: 'Normal'
|
||||||
|
};
|
||||||
|
onupdateRefthold([...refthold, newRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateRefthold(refthold.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Ruler class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Threshold Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refthold?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<Ruler class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No threshold ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each refthold || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
|
||||||
|
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age From</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age To</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
{#each flagOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label} - {option.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Lower Value</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-20" bind:value={ref.LowSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input type="number" step="0.01" class="input input-sm input-bordered flex-1" bind:value={ref.Low} placeholder="e.g., 5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Upper Value</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-20" bind:value={ref.HighSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input type="number" step="0.01" class="input input-sm input-bordered flex-1" bind:value={ref.High} placeholder="e.g., 10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-3">
|
||||||
|
<span class="label-text text-xs mb-1">Interpretation</span>
|
||||||
|
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.Interpretation} placeholder="e.g., Alert threshold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
104
src/lib/components/test-modal/ValueSetRefRange.svelte
Normal file
104
src/lib/components/test-modal/ValueSetRefRange.svelte
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, Box, X } from 'lucide-svelte';
|
||||||
|
import { sexOptions } from './refRangeConstants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} refvset - Value set reference ranges array
|
||||||
|
* @property {(refvset: Array) => void} onupdateRefvset - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
refvset = [],
|
||||||
|
onupdateRefvset = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = {
|
||||||
|
Sex: '2',
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
valueset: '',
|
||||||
|
Flag: 'N'
|
||||||
|
};
|
||||||
|
onupdateRefvset([...refvset, newRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateRefvset(refvset.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Box class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Value Set Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refvset?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<Box class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No value set ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each refvset || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
|
||||||
|
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age From</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age To</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
<option value="N">N - Normal</option>
|
||||||
|
<option value="A">A - Abnormal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-3">
|
||||||
|
<span class="label-text text-xs mb-1">Value Set</span>
|
||||||
|
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.valueset} placeholder="e.g., Positive, Negative, Borderline" />
|
||||||
|
<span class="label-text-alt text-gray-500 mt-1">Comma-separated list of allowed values</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
26
src/lib/components/test-modal/refRangeConstants.js
Normal file
26
src/lib/components/test-modal/refRangeConstants.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Reference range shared constants
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Array<{value: string, label: string, description: string}>} */
|
||||||
|
export const signOptions = [
|
||||||
|
{ value: 'GE', label: '≥', description: 'Greater than or equal to' },
|
||||||
|
{ value: 'GT', label: '>', description: 'Greater than' },
|
||||||
|
{ value: 'LE', label: '≤', description: 'Less than or equal to' },
|
||||||
|
{ value: 'LT', label: '<', description: 'Less than' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {Array<{value: string, label: string, description: string}>} */
|
||||||
|
export const flagOptions = [
|
||||||
|
{ value: 'N', label: 'N', description: 'Normal' },
|
||||||
|
{ value: 'L', label: 'L', description: 'Low' },
|
||||||
|
{ value: 'H', label: 'H', description: 'High' },
|
||||||
|
{ value: 'C', label: 'C', description: 'Critical' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {Array<{value: string, label: string}>} */
|
||||||
|
export const sexOptions = [
|
||||||
|
{ value: '2', label: 'Male' },
|
||||||
|
{ value: '1', label: 'Female' },
|
||||||
|
{ value: '0', label: 'Any' }
|
||||||
|
];
|
||||||
58
src/lib/stores/config.js
Normal file
58
src/lib/stores/config.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create config store with runtime configuration
|
||||||
|
*/
|
||||||
|
function createConfigStore() {
|
||||||
|
const { subscribe, set, update } = writable({
|
||||||
|
apiUrl: '',
|
||||||
|
loaded: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from config.json
|
||||||
|
*/
|
||||||
|
load: async () => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/config.json');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load config: ${response.status}`);
|
||||||
|
}
|
||||||
|
const config = await response.json();
|
||||||
|
set({
|
||||||
|
apiUrl: config.apiUrl || '',
|
||||||
|
loaded: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load config:', err);
|
||||||
|
set({
|
||||||
|
apiUrl: '',
|
||||||
|
loaded: true,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current API URL
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getApiUrl: () => {
|
||||||
|
let url = '';
|
||||||
|
subscribe((config) => {
|
||||||
|
url = config.apiUrl;
|
||||||
|
})();
|
||||||
|
return url;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = createConfigStore();
|
||||||
@ -208,7 +208,7 @@
|
|||||||
<Loader2 class="w-8 h-8 animate-spin text-primary mr-3" />
|
<Loader2 class="w-8 h-8 animate-spin text-primary mr-3" />
|
||||||
<span class="text-gray-600">Loading contacts...</span>
|
<span class="text-gray-600">Loading contacts...</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if filteredContacts().length === 0}
|
{:else if filteredContacts.length === 0}
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="flex flex-col items-center justify-center py-16 px-4">
|
<div class="flex flex-col items-center justify-center py-16 px-4">
|
||||||
<div class="bg-base-200 rounded-full p-6 mb-4">
|
<div class="bg-base-200 rounded-full p-6 mb-4">
|
||||||
@ -232,7 +232,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<DataTable
|
<DataTable
|
||||||
{columns}
|
{columns}
|
||||||
data={filteredContacts().map((c) => ({
|
data={filteredContacts.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
FullName: getDisplayName(c),
|
FullName: getDisplayName(c),
|
||||||
SpecialtyLabel: specialtyMap[c.Specialty] || '-',
|
SpecialtyLabel: specialtyMap[c.Specialty] || '-',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
104
src/routes/(app)/master-data/tests/TestModal.svelte
Normal file
104
src/routes/(app)/master-data/tests/TestModal.svelte
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<script>
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import BasicInfoForm from './test-modal/BasicInfoForm.svelte';
|
||||||
|
import ReferenceRangeSection from './test-modal/ReferenceRangeSection.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {boolean} open - Whether modal is open
|
||||||
|
* @property {string} mode - 'create' or 'edit'
|
||||||
|
* @property {Object} formData - Form data object
|
||||||
|
* @property {boolean} canHaveRefRange - Whether test can have reference ranges
|
||||||
|
* @property {boolean} canHaveFormula - Whether test can have a formula
|
||||||
|
* @property {boolean} canHaveUnit - Whether test can have a unit
|
||||||
|
* @property {Array<{value: string, label: string}>} disciplineOptions - Discipline dropdown options
|
||||||
|
* @property {Array<{value: string, label: string}>} departmentOptions - Department dropdown options
|
||||||
|
* @property {boolean} [saving] - Whether save is in progress
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props & { onsave?: () => void, oncancel?: () => void, onupdateFormData?: (formData: Object) => void }} */
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
mode = 'create',
|
||||||
|
formData = $bindable({}),
|
||||||
|
canHaveRefRange = false,
|
||||||
|
canHaveFormula = false,
|
||||||
|
canHaveUnit = false,
|
||||||
|
disciplineOptions = [],
|
||||||
|
departmentOptions = [],
|
||||||
|
saving = false,
|
||||||
|
onsave = () => {},
|
||||||
|
oncancel = () => {},
|
||||||
|
onupdateFormData = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
let activeTab = $state('basic');
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
activeTab = 'basic';
|
||||||
|
oncancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
onsave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive update when modal opens
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
activeTab = 'basic';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open title={mode === 'create' ? 'Add Test' : 'Edit Test'} size="xl">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs tabs-bordered mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab tab-lg {activeTab === 'basic' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => activeTab = 'basic'}
|
||||||
|
>
|
||||||
|
Basic Information
|
||||||
|
</button>
|
||||||
|
{#if canHaveRefRange}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab tab-lg {activeTab === 'refrange' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => activeTab = 'refrange'}
|
||||||
|
>
|
||||||
|
Reference Range
|
||||||
|
{#if formData.refnum?.length > 0 || formData.reftxt?.length > 0}
|
||||||
|
<span class="badge badge-sm badge-primary ml-2">{(formData.refnum?.length || 0) + (formData.reftxt?.length || 0)}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === 'basic'}
|
||||||
|
<BasicInfoForm
|
||||||
|
bind:formData
|
||||||
|
{canHaveFormula}
|
||||||
|
{canHaveUnit}
|
||||||
|
{disciplineOptions}
|
||||||
|
{departmentOptions}
|
||||||
|
onsave={handleSave}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === 'refrange' && canHaveRefRange}
|
||||||
|
<ReferenceRangeSection
|
||||||
|
bind:formData
|
||||||
|
{onupdateFormData}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn btn-ghost" onclick={handleCancel} type="button">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
|
||||||
|
{#if saving}
|
||||||
|
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||||
|
{/if}
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
107
src/routes/(app)/master-data/tests/referenceRange.js
Normal file
107
src/routes/(app)/master-data/tests/referenceRange.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// Reference Range Management Functions
|
||||||
|
|
||||||
|
export const signOptions = [
|
||||||
|
{ value: 'GE', label: '≥', description: 'Greater than or equal to' },
|
||||||
|
{ value: 'GT', label: '>', description: 'Greater than' },
|
||||||
|
{ value: 'LE', label: '≤', description: 'Less than or equal to' },
|
||||||
|
{ value: 'LT', label: '<', description: 'Less than' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const flagOptions = [
|
||||||
|
{ value: 'N', label: 'N', description: 'Normal' },
|
||||||
|
{ value: 'L', label: 'L', description: 'Low' },
|
||||||
|
{ value: 'H', label: 'H', description: 'High' },
|
||||||
|
{ value: 'C', label: 'C', description: 'Critical' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sexOptions = [
|
||||||
|
{ value: '2', label: 'Male' },
|
||||||
|
{ value: '1', label: 'Female' },
|
||||||
|
{ value: '0', label: 'Any' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createNumRef() {
|
||||||
|
return {
|
||||||
|
Sex: '2',
|
||||||
|
LowSign: 'GE',
|
||||||
|
HighSign: 'LE',
|
||||||
|
Low: null,
|
||||||
|
High: null,
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
Flag: 'N',
|
||||||
|
Interpretation: 'Normal'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTholdRef() {
|
||||||
|
return {
|
||||||
|
Sex: '2',
|
||||||
|
LowSign: 'GE',
|
||||||
|
HighSign: 'LE',
|
||||||
|
Low: null,
|
||||||
|
High: null,
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
Flag: 'N',
|
||||||
|
Interpretation: 'Normal'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextRef() {
|
||||||
|
return {
|
||||||
|
Sex: '2',
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
RefTxt: '',
|
||||||
|
Flag: 'N'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVsetRef() {
|
||||||
|
return {
|
||||||
|
Sex: '2',
|
||||||
|
AgeStart: 0,
|
||||||
|
AgeEnd: 120,
|
||||||
|
valueset: '',
|
||||||
|
Flag: 'N'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNumericRange(ref, index) {
|
||||||
|
const errors = [];
|
||||||
|
if (ref.Low !== null && ref.High !== null && ref.Low > ref.High) {
|
||||||
|
errors.push(`Range ${index + 1}: Low value cannot be greater than High value`);
|
||||||
|
}
|
||||||
|
if (ref.AgeStart !== null && ref.AgeEnd !== null && ref.AgeStart > ref.AgeEnd) {
|
||||||
|
errors.push(`Range ${index + 1}: Age start cannot be greater than Age end`);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTholdRange(ref, index) {
|
||||||
|
const errors = [];
|
||||||
|
if (ref.Low !== null && ref.High !== null && ref.Low > ref.High) {
|
||||||
|
errors.push(`Range ${index + 1}: Low value cannot be greater than High value`);
|
||||||
|
}
|
||||||
|
if (ref.AgeStart !== null && ref.AgeEnd !== null && ref.AgeStart > ref.AgeEnd) {
|
||||||
|
errors.push(`Range ${index + 1}: Age start cannot be greater than Age end`);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTextRange(ref, index) {
|
||||||
|
const errors = [];
|
||||||
|
if (ref.AgeStart !== null && ref.AgeEnd !== null && ref.AgeStart > ref.AgeEnd) {
|
||||||
|
errors.push(`Range ${index + 1}: Age start cannot be greater than Age end`);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateVsetRange(ref, index) {
|
||||||
|
const errors = [];
|
||||||
|
if (ref.AgeStart !== null && ref.AgeEnd !== null && ref.AgeStart > ref.AgeEnd) {
|
||||||
|
errors.push(`Range ${index + 1}: Age start cannot be greater than Age end`);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@ -0,0 +1,185 @@
|
|||||||
|
<script>
|
||||||
|
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
||||||
|
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Object} formData - Form data object
|
||||||
|
* @property {boolean} canHaveFormula - Whether test can have a formula
|
||||||
|
* @property {boolean} canHaveUnit - Whether test can have a unit
|
||||||
|
* @property {Array<{value: string, label: string}>} disciplineOptions - Discipline dropdown options
|
||||||
|
* @property {Array<{value: string, label: string}>} departmentOptions - Department dropdown options
|
||||||
|
* @property {() => void} onsave - Save handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
formData = $bindable({}),
|
||||||
|
canHaveFormula = false,
|
||||||
|
canHaveUnit = false,
|
||||||
|
disciplineOptions = [],
|
||||||
|
departmentOptions = [],
|
||||||
|
onsave = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
onsave();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="space-y-5" onsubmit={handleSubmit}>
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="testCode">
|
||||||
|
<span class="label-text font-medium">Test Code</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="testCode"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.TestSiteCode}
|
||||||
|
placeholder="e.g., GLU"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="testName">
|
||||||
|
<span class="label-text font-medium">Test Name</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="testName"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.TestSiteName}
|
||||||
|
placeholder="e.g., Glucose"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type and Sequence -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="testType">
|
||||||
|
<span class="label-text font-medium">Test Type</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="testType"
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={formData.TestType}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="TEST">Technical 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>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="seqScr">
|
||||||
|
<span class="label-text font-medium">Screen Sequence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="seqScr"
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.SeqScr}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Discipline and Department -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<SelectDropdown
|
||||||
|
label="Discipline"
|
||||||
|
name="discipline"
|
||||||
|
bind:value={formData.DisciplineID}
|
||||||
|
options={disciplineOptions}
|
||||||
|
placeholder="Select discipline..."
|
||||||
|
/>
|
||||||
|
<SelectDropdown
|
||||||
|
label="Department"
|
||||||
|
name="department"
|
||||||
|
bind:value={formData.DepartmentID}
|
||||||
|
options={departmentOptions}
|
||||||
|
placeholder="Select department..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type-specific fields -->
|
||||||
|
{#if canHaveUnit}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{#if canHaveFormula}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="formula">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
Formula
|
||||||
|
<HelpTooltip
|
||||||
|
text="Enter a mathematical formula using test codes (e.g., BUN / Creatinine). Supported operators: +, -, *, /, parentheses."
|
||||||
|
title="Formula Help"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="formula"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.Formula}
|
||||||
|
placeholder="e.g., BUN / Creatinine"
|
||||||
|
required={canHaveFormula}
|
||||||
|
/>
|
||||||
|
<span class="label-text-alt text-gray-500">Use test codes with operators: +, -, *, /</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="unit">
|
||||||
|
<span class="label-text font-medium">Unit</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="unit"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.Unit}
|
||||||
|
placeholder="e.g., mg/dL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Report Sequence and Visibility -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="seqRpt">
|
||||||
|
<span class="label-text font-medium">Report Sequence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="seqRpt"
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={formData.SeqRpt}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text font-medium mb-2 block">Visibility</span>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" class="checkbox" bind:checked={formData.VisibleScr} />
|
||||||
|
<span class="label-text">Screen</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" class="checkbox" bind:checked={formData.VisibleRpt} />
|
||||||
|
<span class="label-text">Report</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@ -0,0 +1,266 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, Calculator, X, ChevronDown, ChevronUp, Info } from 'lucide-svelte';
|
||||||
|
import { signOptions, flagOptions, sexOptions, createNumRef } from '../referenceRange.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} refnum - Numeric reference ranges array
|
||||||
|
* @property {(refnum: Array) => void} onupdateRefnum - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
refnum = [],
|
||||||
|
onupdateRefnum = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Track expanded state for each range's optional fields
|
||||||
|
let expandedRanges = $state({});
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = createNumRef();
|
||||||
|
// Set smarter defaults
|
||||||
|
newRef.Sex = '0'; // Any
|
||||||
|
newRef.Flag = 'N'; // Normal
|
||||||
|
onupdateRefnum([...refnum, newRef]);
|
||||||
|
// Auto-expand the new range
|
||||||
|
expandedRanges[refnum.length] = { age: false, interpretation: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateRefnum(refnum.filter((_, i) => i !== index));
|
||||||
|
delete expandedRanges[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAgeExpand(index) {
|
||||||
|
expandedRanges[index] = { ...expandedRanges[index], age: !expandedRanges[index]?.age };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleInterpExpand(index) {
|
||||||
|
expandedRanges[index] = { ...expandedRanges[index], interpretation: !expandedRanges[index]?.interpretation };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSignLabel(value) {
|
||||||
|
return signOptions.find(o => o.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSexLabel(value) {
|
||||||
|
return sexOptions.find(o => o.value === value)?.label || 'Any';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlagColor(flag) {
|
||||||
|
const colors = {
|
||||||
|
'N': 'badge-success',
|
||||||
|
'L': 'badge-warning',
|
||||||
|
'H': 'badge-warning',
|
||||||
|
'C': 'badge-error'
|
||||||
|
};
|
||||||
|
return colors[flag] || 'badge-ghost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlagLabel(flag) {
|
||||||
|
const option = flagOptions.find(o => o.value === flag);
|
||||||
|
return option ? `${option.label} (${option.description})` : flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate human-readable preview
|
||||||
|
function getRangePreview(ref) {
|
||||||
|
const sex = getSexLabel(ref.Sex);
|
||||||
|
const ageText = (ref.AgeStart !== 0 || ref.AgeEnd !== 120)
|
||||||
|
? `Age ${ref.AgeStart}-${ref.AgeEnd}`
|
||||||
|
: 'All ages';
|
||||||
|
|
||||||
|
let rangeText = '';
|
||||||
|
if (ref.Low !== null && ref.High !== null) {
|
||||||
|
rangeText = `${getSignLabel(ref.LowSign)}${ref.Low} to ${getSignLabel(ref.HighSign)}${ref.High}`;
|
||||||
|
} else if (ref.Low !== null) {
|
||||||
|
rangeText = `${getSignLabel(ref.LowSign)}${ref.Low}`;
|
||||||
|
} else if (ref.High !== null) {
|
||||||
|
rangeText = `${getSignLabel(ref.HighSign)}${ref.High}`;
|
||||||
|
} else {
|
||||||
|
rangeText = 'Not set';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sex}, ${ageText}: ${rangeText}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Calculator class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Numeric Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refnum?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<Calculator class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No numeric ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each refnum || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<!-- Header with Preview -->
|
||||||
|
<div class="flex justify-between items-start mb-4 pb-3 border-b border-base-200">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="badge badge-primary">Range {index + 1}</span>
|
||||||
|
<span class="badge {getFlagColor(ref.Flag)}">{getFlagLabel(ref.Flag)}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Live Preview -->
|
||||||
|
<div class="text-sm text-gray-600 bg-base-200 p-2 rounded flex items-center gap-2">
|
||||||
|
<Info class="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
|
<span>{getRangePreview(ref)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error ml-2" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Fields - Range Values (Most Important) -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1 font-medium">Lower Bound</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-16" bind:value={ref.LowSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="input input-sm input-bordered flex-1"
|
||||||
|
bind:value={ref.Low}
|
||||||
|
placeholder="70"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1 font-medium">Upper Bound</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-16" bind:value={ref.HighSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="input input-sm input-bordered flex-1"
|
||||||
|
bind:value={ref.High}
|
||||||
|
placeholder="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sex & Flag Row -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
{#each flagOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label} - {option.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable: Age Range -->
|
||||||
|
<div class="border border-base-200 rounded-lg mb-2 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
|
||||||
|
onclick={() => toggleAgeExpand(index)}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="text-xs">Age Range</span>
|
||||||
|
{#if ref.AgeStart !== 0 || ref.AgeEnd !== 120}
|
||||||
|
<span class="text-xs text-primary">({ref.AgeStart}-{ref.AgeEnd})</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-gray-500">(All ages)</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if expandedRanges[index]?.age}
|
||||||
|
<ChevronUp class="w-4 h-4" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expandedRanges[index]?.age}
|
||||||
|
<div class="p-3 bg-base-100">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">From (years)</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">To (years)</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable: Interpretation -->
|
||||||
|
<div class="border border-base-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
|
||||||
|
onclick={() => toggleInterpExpand(index)}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="text-xs">Interpretation</span>
|
||||||
|
{#if ref.Interpretation}
|
||||||
|
<span class="text-xs text-primary truncate max-w-[200px]">({ref.Interpretation})</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if expandedRanges[index]?.interpretation}
|
||||||
|
<ChevronUp class="w-4 h-4" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expandedRanges[index]?.interpretation}
|
||||||
|
<div class="p-3 bg-base-100">
|
||||||
|
<div class="form-control">
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered w-full text-sm"
|
||||||
|
rows="2"
|
||||||
|
bind:value={ref.Interpretation}
|
||||||
|
placeholder="e.g., Normal fasting glucose range"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
<script>
|
||||||
|
import { Ruler, Calculator, FileText, Box } from 'lucide-svelte';
|
||||||
|
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
|
||||||
|
import NumericRefRange from './NumericRefRange.svelte';
|
||||||
|
import ThresholdRefRange from './ThresholdRefRange.svelte';
|
||||||
|
import TextRefRange from './TextRefRange.svelte';
|
||||||
|
import ValueSetRefRange from './ValueSetRefRange.svelte';
|
||||||
|
import { createNumRef, createTholdRef, createTextRef, createVsetRef } from '../referenceRange.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Object} formData - Form data object
|
||||||
|
* @property {(formData: Object) => void} onupdateFormData - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
formData = $bindable({}),
|
||||||
|
onupdateFormData = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function updateRefRangeType(type) {
|
||||||
|
let newFormData = {
|
||||||
|
...formData,
|
||||||
|
refRangeType: type,
|
||||||
|
refnum: [],
|
||||||
|
refthold: [],
|
||||||
|
reftxt: [],
|
||||||
|
refvset: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the selected type
|
||||||
|
if (type === 'num') {
|
||||||
|
newFormData.refnum = [createNumRef()];
|
||||||
|
} else if (type === 'thold') {
|
||||||
|
newFormData.refthold = [createTholdRef()];
|
||||||
|
} else if (type === 'text') {
|
||||||
|
newFormData.reftxt = [createTextRef()];
|
||||||
|
} else if (type === 'vset') {
|
||||||
|
newFormData.refvset = [createVsetRef()];
|
||||||
|
}
|
||||||
|
|
||||||
|
onupdateFormData(newFormData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRefnum(refnum) {
|
||||||
|
onupdateFormData({ ...formData, refnum });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRefthold(refthold) {
|
||||||
|
onupdateFormData({ ...formData, refthold });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReftxt(reftxt) {
|
||||||
|
onupdateFormData({ ...formData, reftxt });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRefvset(refvset) {
|
||||||
|
onupdateFormData({ ...formData, refvset });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Reference Range Type Selection -->
|
||||||
|
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<Ruler class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Reference Range Type</h3>
|
||||||
|
<HelpTooltip
|
||||||
|
text="Choose how to define normal/abnormal ranges for this test."
|
||||||
|
title="Reference Range Help"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="none"
|
||||||
|
checked={formData.refRangeType === 'none'}
|
||||||
|
onchange={() => updateRefRangeType('none')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium">None</span>
|
||||||
|
<span class="text-xs text-gray-500">No reference range</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="num"
|
||||||
|
checked={formData.refRangeType === 'num'}
|
||||||
|
onchange={() => updateRefRangeType('num')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Numeric
|
||||||
|
<Calculator class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Range (e.g., 70-100)</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="thold"
|
||||||
|
checked={formData.refRangeType === 'thold'}
|
||||||
|
onchange={() => updateRefRangeType('thold')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Threshold
|
||||||
|
<Ruler class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Limit values</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="text"
|
||||||
|
checked={formData.refRangeType === 'text'}
|
||||||
|
onchange={() => updateRefRangeType('text')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Text
|
||||||
|
<FileText class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Descriptive</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors flex-1 min-w-[100px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refRangeType"
|
||||||
|
class="radio radio-primary"
|
||||||
|
value="vset"
|
||||||
|
checked={formData.refRangeType === 'vset'}
|
||||||
|
onchange={() => updateRefRangeType('vset')}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="label-text font-medium flex items-center gap-1">
|
||||||
|
Value Set
|
||||||
|
<Box class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">Predefined values</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Numeric Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'num'}
|
||||||
|
<NumericRefRange refnum={formData.refnum} onupdateRefnum={updateRefnum} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Threshold Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'thold'}
|
||||||
|
<ThresholdRefRange refthold={formData.refthold} onupdateRefthold={updateRefthold} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Text Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'text'}
|
||||||
|
<TextRefRange reftxt={formData.reftxt} onupdateReftxt={updateReftxt} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Value Set Reference Ranges -->
|
||||||
|
{#if formData.refRangeType === 'vset'}
|
||||||
|
<ValueSetRefRange refvset={formData.refvset} onupdateRefvset={updateRefvset} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, FileText, X } from 'lucide-svelte';
|
||||||
|
import { sexOptions, createTextRef } from '../referenceRange.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} reftxt - Text reference ranges array
|
||||||
|
* @property {(reftxt: Array) => void} onupdateReftxt - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
reftxt = [],
|
||||||
|
onupdateReftxt = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = createTextRef();
|
||||||
|
onupdateReftxt([...reftxt, newRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateReftxt(reftxt.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<FileText class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Text Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if reftxt?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<FileText class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No text ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each reftxt || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
|
||||||
|
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age From</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age To</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
<option value="N">N - Normal</option>
|
||||||
|
<option value="A">A - Abnormal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-3">
|
||||||
|
<span class="label-text text-xs mb-1">Reference Text</span>
|
||||||
|
<textarea class="textarea textarea-bordered w-full" rows="2" bind:value={ref.RefTxt} placeholder="e.g., Negative for glucose"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, Ruler, X } from 'lucide-svelte';
|
||||||
|
import { signOptions, flagOptions, sexOptions, createTholdRef } from '../referenceRange.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} refthold - Threshold reference ranges array
|
||||||
|
* @property {(refthold: Array) => void} onupdateRefthold - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
refthold = [],
|
||||||
|
onupdateRefthold = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = createTholdRef();
|
||||||
|
onupdateRefthold([...refthold, newRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateRefthold(refthold.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Ruler class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Threshold Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refthold?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<Ruler class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No threshold ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each refthold || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
|
||||||
|
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age From</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age To</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
{#each flagOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label} - {option.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Lower Value</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-20" bind:value={ref.LowSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input type="number" step="0.01" class="input input-sm input-bordered flex-1" bind:value={ref.Low} placeholder="e.g., 5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Upper Value</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="select select-sm select-bordered w-20" bind:value={ref.HighSign}>
|
||||||
|
{#each signOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input type="number" step="0.01" class="input input-sm input-bordered flex-1" bind:value={ref.High} placeholder="e.g., 10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-3">
|
||||||
|
<span class="label-text text-xs mb-1">Interpretation</span>
|
||||||
|
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.Interpretation} placeholder="e.g., Alert threshold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
<script>
|
||||||
|
import { PlusCircle, Box, X } from 'lucide-svelte';
|
||||||
|
import { sexOptions, createVsetRef } from '../referenceRange.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} refvset - Value set reference ranges array
|
||||||
|
* @property {(refvset: Array) => void} onupdateRefvset - Update handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
refvset = [],
|
||||||
|
onupdateRefvset = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function addRefRange() {
|
||||||
|
const newRef = createVsetRef();
|
||||||
|
onupdateRefvset([...refvset, newRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRefRange(index) {
|
||||||
|
onupdateRefvset(refvset.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Box class="w-5 h-5 text-primary" />
|
||||||
|
<h3 class="font-semibold">Value Set Reference Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refvset?.length === 0}
|
||||||
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
||||||
|
<Box class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500">No value set ranges defined</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
||||||
|
<PlusCircle class="w-4 h-4 mr-1" />
|
||||||
|
Add First Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each refvset || [] as ref, index (index)}
|
||||||
|
<div class="card bg-base-100 border-2 border-base-200 hover:border-primary/50 transition-colors">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-3 border-b border-base-200">
|
||||||
|
<span class="badge badge-primary badge-lg">Range {index + 1}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost text-error" onclick={() => removeRefRange(index)}>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Sex</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
||||||
|
{#each sexOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age From</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeStart} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Age To</span>
|
||||||
|
<input type="number" min="0" max="120" class="input input-sm input-bordered w-full" bind:value={ref.AgeEnd} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<span class="label-text text-xs mb-1">Flag</span>
|
||||||
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Flag}>
|
||||||
|
<option value="N">N - Normal</option>
|
||||||
|
<option value="A">A - Abnormal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-3">
|
||||||
|
<span class="label-text text-xs mb-1">Value Set</span>
|
||||||
|
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.valueset} placeholder="e.g., Positive, Negative, Borderline" />
|
||||||
|
<span class="label-text-alt text-gray-500 mt-1">Comma-separated list of allowed values</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@ -1,9 +1,17 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||||
|
import { config } from '$lib/stores/config.js';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
let configLoading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await config.load();
|
||||||
|
configLoading = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -11,6 +19,15 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-base-100">
|
<div class="min-h-screen bg-base-100">
|
||||||
|
{#if configLoading}
|
||||||
|
<div class="flex min-h-screen items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
<p class="mt-4 text-base-content/60">Loading configuration...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{/if}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
3
static/config.json
Normal file
3
static/config.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"apiUrl": "http://localhost/clqms01"
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user