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:
mahdahar 2026-02-18 07:12:58 +07:00
parent 5aab10df04
commit f0f5889df4
24 changed files with 2736 additions and 1148 deletions

View 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`

View File

@ -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, {

View File

@ -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}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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();

View File

@ -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

View 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>

View 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;
}

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
View File

@ -0,0 +1,3 @@
{
"apiUrl": "http://localhost/clqms01"
}