refactor(tests): Move TestModal to route folder and add technical config support

- Move TestModal from lib/components to routes/(app)/master-data/tests
- Add technical configuration form (ResultType, RefType, SpcType, units, etc.)
- Add GroupMembersTab for managing group test members
- Enhance reference ranges with refvset and refthold support
- Update API to handle new test fields (ReqQty, Factor, Decimal, TAT, etc.)
- Add database schema documentation (DBML format)
- Remove old test-types-reference.md documentation
- UI improvements: compact design, updated sidebar, modal sizing
- Update DataTable, Modal, SelectDropdown components for compact style
- Enhance patient and visit modals with compact layout
This commit is contained in:
mahdahar 2026-02-18 16:31:20 +07:00
parent f0f5889df4
commit 8d77370357
40 changed files with 2161 additions and 1838 deletions

640
docs/clqms_database.dbml Normal file
View File

@ -0,0 +1,640 @@
// CLQMS Database Schema
// Generated from app/Models/ directory
// Database Markup Language (DBML) for dbdiagram.io and other tools
// ============================================
// TABLE 1: Patient Management
// ============================================
Table patient {
InternalPID int [pk, increment]
PatientID varchar(255)
AlternatePID varchar(255)
Prefix varchar(50)
NameFirst varchar(255)
NameMiddle varchar(255)
NameMaiden varchar(255)
NameLast varchar(255)
Suffix varchar(50)
NameAlias varchar(255)
Sex varchar(10)
Birthdate datetime
PlaceOfBirth varchar(255)
Street_1 varchar(255)
Street_2 varchar(255)
Street_3 varchar(255)
City varchar(100)
Province varchar(100)
ZIP varchar(20)
Country varchar(100)
EmailAddress1 varchar(255)
EmailAddress2 varchar(255)
Phone varchar(50)
MobilePhone varchar(50)
AccountNumber varchar(100)
Race varchar(50)
MaritalStatus varchar(50)
Religion varchar(50)
Ethnic varchar(50)
Citizenship varchar(100)
DeathIndicator boolean
TimeOfDeath datetime
Custodian int [ref: > patient.InternalPID]
LinkTo int [ref: > patient.InternalPID]
CreateDate datetime [not null]
DelDate datetime
}
Table patidt {
PatIdtID int [pk, increment]
InternalPID int [not null, ref: > patient.InternalPID]
IdentifierType varchar(100)
Identifier varchar(255)
EffectiveDate datetime
ExpirationDate datetime
CreateDate datetime [not null]
DelDate datetime
}
Table patcom {
PatComID int [pk, increment]
InternalPID int [not null, ref: > patient.InternalPID]
Comment text
CreateDate datetime [not null]
EndDate datetime
}
Table patatt {
PatAttID int [pk, increment]
InternalPID int [not null, ref: > patient.InternalPID]
UserID int
Address text
CreateDate datetime [not null]
DelDate datetime
}
// ============================================
// TABLE 2: Visit Management
// ============================================
Table patvisit {
InternalPVID int [pk, increment]
InternalPID int [not null, ref: > patient.InternalPID]
EpisodeID int
PVID varchar(100)
CreateDate datetime [not null]
EndDate datetime
ArchivedDate datetime
DelDate datetime
}
Table patvisitadt {
PVADTID int [pk, increment]
InternalPVID int [not null, ref: > patvisit.InternalPVID]
LocationID int [ref: > location.LocationID]
ADTCode varchar(50)
AttDoc varchar(255)
RefDoc varchar(255)
AdmDoc varchar(255)
CnsDoc varchar(255)
CreateDate datetime [not null]
EndDate datetime
ArchivedDate datetime
DelDate datetime
}
Table patdiag {
InternalPVID int [pk, increment]
InternalPVID int [not null, ref: > patvisit.InternalPVID]
InternalPID int [not null, ref: > patient.InternalPID]
DiagCode varchar(50)
Diagnosis varchar(255)
CreateDate datetime [not null]
EndDate datetime
ArchivedDate datetime
DelDate datetime
}
// ============================================
// TABLE 3: Organization Structure
// ============================================
Table account {
AccountID int [pk, increment]
AccountName varchar(255)
Initial varchar(50)
Street_1 varchar(255)
Street_2 varchar(255)
Street_3 varchar(255)
City varchar(100)
Province varchar(100)
ZIP varchar(20)
Country varchar(100)
AreaCode varchar(20)
EmailAddress1 varchar(255)
EmailAddress2 varchar(255)
Phone varchar(50)
Fax varchar(50)
Parent int [ref: > account.AccountID]
CreateDate datetime [not null]
EndDate datetime
}
Table site {
SiteID int [pk, increment]
AccountID int [not null, ref: > account.AccountID]
Parent int [ref: > site.SiteID]
SiteTypeID int
SiteClassID int
SiteCode varchar(50)
SiteName varchar(255)
ME varchar(50)
CreateDate datetime [not null]
EndDate datetime
}
Table department {
DepartmentID int [pk, increment]
DisciplineID int [ref: > discipline.DisciplineID]
SiteID int [ref: > site.SiteID]
DepartmentCode varchar(50)
DepartmentName varchar(255)
CreateDate datetime [not null]
EndDate datetime
}
Table discipline {
DisciplineID int [pk, increment]
SiteID int [ref: > site.SiteID]
Parent int [ref: > discipline.DisciplineID]
DisciplineCode varchar(50)
DisciplineName varchar(255)
CreateDate datetime [not null]
EndDate datetime
}
Table workstation {
WorkstationID int [pk, increment]
DepartmentID int [ref: > department.DepartmentID]
LinkTo int [ref: > workstation.WorkstationID]
EquipmentID int
WorkstationCode varchar(50)
WorkstationName varchar(255)
Type varchar(50)
Enable boolean
CreateDate datetime [not null]
EndDate datetime
}
// ============================================
// TABLE 4: Location Management
// ============================================
Table location {
LocationID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
Parent int [ref: > location.LocationID]
LocCode varchar(50)
LocFull varchar(255)
Description text
LocType varchar(50)
CreateDate datetime [not null]
EndDate datetime
}
Table locationaddress {
LocationID int [pk, ref: > location.LocationID]
Province int [ref: > areageo.AreaGeoID]
City int [ref: > areageo.AreaGeoID]
Street1 varchar(255)
Street2 varchar(255)
PostCode varchar(20)
GeoLocationSystem varchar(50)
GeoLocationData text
Phone varchar(50)
Email varchar(255)
CreateDate datetime [not null]
EndDate datetime
}
Table areageo {
AreaGeoID int [pk, increment]
Parent int [ref: > areageo.AreaGeoID]
AreaCode varchar(50)
Class varchar(50)
AreaName varchar(255)
}
// ============================================
// TABLE 5: Test Management
// ============================================
Table testdefsite {
TestSiteID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
TestSiteCode varchar(50)
TestSiteName varchar(255)
TestType varchar(50) // TEST, PARAM, CALC, GROUP, TITLE
Description text
SeqScr int
SeqRpt int
IndentLeft int
FontStyle varchar(50)
VisibleScr boolean
VisibleRpt boolean
CountStat boolean
CreateDate datetime [not null]
StartDate datetime
EndDate datetime
}
Table testdeftech {
TestTechID int [pk, increment]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
DisciplineID int [ref: > discipline.DisciplineID]
DepartmentID int [ref: > department.DepartmentID]
VSet int
ResultType varchar(50) // NM, TX, DT, TM, VS, HL7
RefType varchar(50) // NUM, TXT, VSET
ReqQty decimal(10,4)
ReqQtyUnit varchar(20)
Unit1 varchar(50)
Factor decimal(10,6)
Unit2 varchar(50)
Decimal int
CollReq text
Method varchar(255)
ExpectedTAT int
CreateDate datetime [not null]
EndDate datetime
}
Table testdefcal {
TestCalID int [pk, increment]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
DisciplineID int [ref: > discipline.DisciplineID]
DepartmentID int [ref: > department.DepartmentID]
FormulaInput varchar(500)
FormulaCode text
RefType varchar(50)
Unit1 varchar(50)
Factor decimal(10,6)
Unit2 varchar(50)
Decimal int
Method varchar(255)
CreateDate datetime [not null]
EndDate datetime
}
Table testdefgrp {
TestGrpID int [pk, increment]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
Member int [ref: > testdefsite.TestSiteID]
CreateDate datetime [not null]
EndDate datetime
}
Table testmap {
TestMapID int [pk, increment]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
ConDefID int [ref: > containerdef.ConDefID]
HostType varchar(50)
HostID varchar(100)
HostDataSource varchar(100)
HostTestCode varchar(100)
HostTestName varchar(255)
ClientType varchar(50)
ClientID varchar(100)
ClientDataSource varchar(100)
ClientTestCode varchar(100)
ClientTestName varchar(255)
CreateDate datetime [not null]
EndDate datetime
}
// ============================================
// TABLE 6: Reference Ranges
// ============================================
Table refnum {
RefNumID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
SpcType varchar(50)
Sex varchar(10)
Criteria varchar(255)
AgeStart int
AgeEnd int
NumRefType varchar(50) // NR, CR
RangeType varchar(50) // LL-UL, LL, UL, ABS
LowSign varchar(5)
Low decimal(15,5)
HighSign varchar(5)
High decimal(15,5)
Display varchar(50)
Flag varchar(10)
Interpretation text
Notes text
CreateDate datetime [not null]
StartDate datetime
EndDate datetime
}
Table reftxt {
RefTxtID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
SpcType varchar(50)
Sex varchar(10)
Criteria varchar(255)
AgeStart int
AgeEnd int
TxtRefType varchar(50) // TX, VS
RefTxt text
Flag varchar(10)
Notes text
CreateDate datetime [not null]
StartDate datetime
EndDate datetime
}
Table refvset {
RefVSetID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
SpcType varchar(50)
Sex varchar(10)
AgeStart int
AgeEnd int
RefTxt varchar(255)
CreateDate datetime [not null]
EndDate datetime
}
Table refthold {
RefTHoldID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
TestSiteID int [not null, ref: > testdefsite.TestSiteID]
SpcType varchar(50)
Sex varchar(10)
AgeStart int
AgeEnd int
Threshold decimal(15,5)
BelowTxt text
AboveTxt text
GrayzoneLow decimal(15,5)
GrayzoneHigh decimal(15,5)
GrayzoneTxt text
CreateDate datetime [not null]
EndDate datetime
}
// ============================================
// TABLE 7: Specimen Management
// ============================================
Table specimen {
InternalSID int [pk, increment]
SID varchar(17) [not null, unique]
SiteID int [not null, ref: > site.SiteID]
OrderID varchar(13)
ConDefID int [ref: > containerdef.ConDefID]
Parent int [ref: > specimen.InternalSID]
Qty decimal(10,4)
Unit varchar(20)
GenerateBy varchar(100)
SchDateTime datetime
CreateDate datetime [not null]
EndDate datetime
ArchiveDate datetime
}
Table specimenstatus {
SpcStaID int [pk, increment]
SID varchar(17) [not null, ref: > specimen.SID]
OrderID varchar(13)
CurrSiteID int [ref: > site.SiteID]
CurrLocID int [ref: > location.LocationID]
UserID int
SpcAct varchar(50)
ActRes varchar(50)
SpcStatus varchar(50)
Qty decimal(10,4)
Unit varchar(20)
SpcCon varchar(50)
Comment text
Origin varchar(100)
GeoLocationSystem varchar(50)
GeoLocationData text
DIDType varchar(50)
DID varchar(100)
CreateDate datetime [not null]
EndDate datetime
ArchiveDate datetime
}
Table specimencollection {
SpcColID int [pk, increment]
SpcStaID int [not null, ref: > specimenstatus.SpcStaID]
SpRole varchar(50)
ColMethod varchar(50)
BodySite varchar(50)
CntSize varchar(50)
FastingVolume decimal(10,4)
ColStart datetime
ColEnd datetime
CreateDate datetime [not null]
EndDate datetime
ArchiveDate datetime
}
Table specimenprep {
SpcPrpID int [pk, increment]
SpcStaID int [not null, ref: > specimenstatus.SpcStaID]
Description text
Method varchar(255)
Additive varchar(100)
AddQty decimal(10,4)
AddUnit varchar(20)
PrepStart datetime
PrepEnd datetime
CreateDate datetime [not null]
EndDate datetime
ArchiveDate datetime
}
Table containerdef {
ConDefID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
ConCode varchar(50)
ConName varchar(255)
ConDesc text
Additive varchar(100)
ConClass varchar(50)
Color varchar(50)
CreateDate datetime [not null]
EndDate datetime
}
// ============================================
// TABLE 8: Order Management
// ============================================
Table ordertest {
InternalOID int [pk, increment]
OrderID varchar(13) [not null, unique]
PlacerID varchar(100)
InternalPID int [not null, ref: > patient.InternalPID]
SiteID int [ref: > site.SiteID]
PVADTID int [ref: > patvisitadt.PVADTID]
ReqApp varchar(100)
Priority varchar(50)
TrnDate datetime
EffDate datetime
CreateDate datetime [not null]
EndDate datetime
ArchiveDate datetime
DelDate datetime
}
Table patres {
ResultID int [pk, increment]
SiteID int [ref: > site.SiteID]
OrderID varchar(13)
InternalSID int [ref: > specimen.InternalSID]
SID varchar(17)
SampleID varchar(100)
TestSiteID int [ref: > testdefsite.TestSiteID]
WorkstationID int [ref: > workstation.WorkstationID]
EquipmentID int
RefNumID int [ref: > refnum.RefNumID]
RefTxtID int [ref: > reftxt.RefTxtID]
TestSiteCode varchar(50)
AspCnt int
Result text
SampleType varchar(50)
ResultDateTime datetime
CreateDate datetime [not null]
EndDate datetime
ArchiveDate datetime
DelDate datetime
}
// ============================================
// TABLE 9: Contact Management
// ============================================
Table contact {
ContactID int [pk, increment]
NameFirst varchar(255)
NameLast varchar(255)
Title varchar(100)
Initial varchar(50)
Birthdate datetime
EmailAddress1 varchar(255)
EmailAddress2 varchar(255)
Phone varchar(50)
MobilePhone1 varchar(50)
MobilePhone2 varchar(50)
Specialty varchar(100)
SubSpecialty varchar(100)
CreateDate datetime [not null]
EndDate datetime
}
Table contactdetail {
ContactDetID int [pk, increment]
ContactID int [not null, ref: > contact.ContactID]
SiteID int [ref: > site.SiteID]
OccupationID int [ref: > occupation.OccupationID]
ContactCode varchar(50)
ContactEmail varchar(255)
JobTitle varchar(100)
Department varchar(100)
ContactStartDate datetime
ContactEndDate datetime
}
Table medicalspecialty {
SpecialtyID int [pk, increment]
Parent int [ref: > medicalspecialty.SpecialtyID]
SpecialtyText varchar(255)
Title varchar(100)
CreateDate datetime [not null]
EndDate datetime
}
Table occupation {
OccupationID int [pk, increment]
OccCode varchar(50)
OccText varchar(255)
Description text
CreateDate datetime [not null]
}
// ============================================
// TABLE 10: Value Sets
// ============================================
Table valuesetdef {
VSetID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
VSName varchar(255)
VSDesc text
CreateDate datetime [not null]
EndDate datetime
}
Table valueset {
VID int [pk, increment]
SiteID int [not null, ref: > site.SiteID]
VSetID int [not null, ref: > valuesetdef.VSetID]
VCategory varchar(100)
VOrder int
VValue varchar(255)
VDesc varchar(255)
CreateDate datetime [not null]
EndDate datetime
}
// ============================================
// TABLE 11: System / Counter
// ============================================
Table counter {
CounterID int [pk, increment]
CounterValue bigint
CounterStart bigint
CounterEnd bigint
CounterReset varchar(50)
CreateDate datetime [not null]
EndDate datetime
}
Table edgeres {
EdgeResID int [pk, increment]
SiteID int [ref: > site.SiteID]
InstrumentID int
SampleID varchar(100)
PatientID varchar(100)
Payload text
Status varchar(50)
AutoProcess boolean
ProcessedAt datetime
ErrorMessage text
CreateDate datetime [not null]
EndDate datetime
ArchiveDate datetime
DelDate datetime
}
Table zones {
id int [pk, increment]
name varchar(255)
code varchar(50)
type varchar(50)
description text
status varchar(50)
created_at datetime
updated_at datetime
}

View File

@ -1,417 +0,0 @@
# 📋 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

@ -37,23 +37,41 @@
--color-error: oklch(60% 0.25 25);
--color-error-content: oklch(98% 0.01 25);
/* Border radius */
--radius-selector: 0.5rem;
--radius-field: 0.375rem;
--radius-box: 0.5rem;
/* Border radius - smaller for compact look */
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.375rem;
/* Base sizes */
--size-selector: 0.25rem;
--size-field: 0.25rem;
/* Base sizes - reduced for compact UI */
--size-selector: 0.2rem;
--size-field: 0.2rem;
/* Border size */
--border: 1px;
/* Effects */
--depth: 1;
--depth: 0;
--noise: 0;
}
/* Global compact utility classes */
@layer utilities {
/* Compact spacing */
.compact-y { @apply space-y-3; }
.compact-gap { @apply gap-3; }
.compact-p { @apply p-4; }
.compact-py { @apply py-3; }
.compact-px { @apply px-3; }
/* Compact form elements */
.compact-input { @apply input-sm; }
.compact-btn { @apply btn-sm; }
.compact-select { @apply select-sm; }
/* Compact cards */
.compact-card { @apply p-4; }
}
@theme {
/* Custom color helpers */
--color-emerald-50: #ecfdf5;

View File

@ -32,9 +32,24 @@ export async function createTest(data) {
// Type-specific fields
Unit: data.Unit,
Formula: data.Formula,
// Technical Config
ResultType: data.ResultType,
RefType: data.RefType,
SpcType: data.SpcType,
ReqQty: data.ReqQty,
ReqQtyUnit: data.ReqQtyUnit,
Unit1: data.Unit1,
Factor: data.Factor,
Unit2: data.Unit2,
Decimal: data.Decimal,
CollReq: data.CollReq,
Method: data.Method,
ExpectedTAT: data.ExpectedTAT,
// Reference ranges (only for TEST and CALC)
refnum: data.refnum,
reftxt: data.reftxt,
refvset: data.refvset,
refthold: data.refthold,
};
return post('/api/tests', payload);
}
@ -54,9 +69,24 @@ export async function updateTest(data) {
// Type-specific fields
Unit: data.Unit,
Formula: data.Formula,
// Technical Config
ResultType: data.ResultType,
RefType: data.RefType,
SpcType: data.SpcType,
ReqQty: data.ReqQty,
ReqQtyUnit: data.ReqQtyUnit,
Unit1: data.Unit1,
Factor: data.Factor,
Unit2: data.Unit2,
Decimal: data.Decimal,
CollReq: data.CollReq,
Method: data.Method,
ExpectedTAT: data.ExpectedTAT,
// Reference ranges (only for TEST and CALC)
refnum: data.refnum,
reftxt: data.reftxt,
refvset: data.refvset,
refthold: data.refthold,
};
return patch('/api/tests', payload);
}

View File

@ -64,7 +64,7 @@
</script>
<div class="overflow-x-auto {className}">
<table class="table w-full" class:table-zebra={striped} class:table-bordered={bordered}>
<table class="table table-compact w-full" class:table-zebra={striped} class:table-bordered={bordered}>
<thead>
<tr class="bg-base-200">
{#each columns as column}

View File

@ -7,6 +7,7 @@
* @property {string} [title] - Modal title
* @property {string} [size] - Modal size (sm, md, lg, xl, full)
* @property {boolean} [closable] - Whether modal can be closed by clicking backdrop
* @property {'center' | 'top'} [position] - Modal position (center or top)
* @property {Function} [onClose] - Callback when modal is closed
* @property {import('svelte').Snippet} [children] - Modal content
* @property {import('svelte').Snippet} [footer] - Modal footer
@ -18,6 +19,7 @@
title = '',
size = 'md',
closable = true,
position = 'center',
onClose = null,
children,
footer,
@ -33,12 +35,12 @@
};
const widthStyles = {
sm: 'max-width: 400px;',
md: 'max-width: 500px;',
lg: 'max-width: 800px;',
xl: 'max-width: 1200px;',
sm: 'max-width: 350px;',
md: 'max-width: 450px;',
lg: 'max-width: 700px;',
xl: 'max-width: 1000px;',
full: 'max-width: 100%; width: 100%; height: 100%;',
wide: 'max-width: 90vw; width: 1200px;',
wide: 'max-width: 90vw; width: 1000px;',
};
/**
@ -77,7 +79,7 @@
<svelte:window onkeydown={handleKeydown} />
<dialog class="modal {sizeClasses[size] || ''}" class:modal-open={open}>
<div class="modal-box" style={widthStyles[size] || ''} role="dialog" aria-modal="true" aria-labelledby={title ? 'modal-title' : undefined}>
<div class="modal-box" style="{widthStyles[size] || ''} {position === 'top' ? 'align-self: flex-start; margin-top: 2rem;' : ''}" role="dialog" aria-modal="true" aria-labelledby={title ? 'modal-title' : undefined}>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
{#if title}

View File

@ -88,7 +88,7 @@
bind:value
{required}
{disabled}
class="select select-bordered w-full pr-10 {error ? 'select-error' : ''}"
class="select select-sm select-bordered w-full pr-10 bg-none {error ? 'select-error' : ''}"
class:opacity-50={loading}
>
<option value="">{placeholder}</option>

View File

@ -68,276 +68,171 @@
}
});
// Function to expand sidebar when clicking dropdown in collapsed mode
function expandSidebar() {
isOpen = true;
}
function handleLogout() {
auth.logout();
goto('/login');
}
function toggleMasterData() {
if (!isOpen) {
expandSidebar();
}
masterDataExpanded = !masterDataExpanded;
}
function toggleLaboratory() {
if (!isOpen) {
expandSidebar();
}
laboratoryExpanded = !laboratoryExpanded;
}
function toggleAdministration() {
if (!isOpen) {
expandSidebar();
}
administrationExpanded = !administrationExpanded;
}
</script>
<!-- Sidebar -->
<div
class="sidebar-container fixed lg:sticky left-0 top-0 h-screen max-h-screen z-40 bg-base-200 shadow-xl border-r border-base-300 transition-all duration-300 ease-out"
class="sidebar-container fixed lg:sticky left-0 top-0 h-screen max-h-screen z-40 bg-base-200 border-r border-base-300 transition-all duration-300 ease-out"
class:sidebar-expanded={isOpen}
class:sidebar-collapsed={!isOpen}
>
<div class="h-screen overflow-y-auto flex flex-col sidebar-content" class:expanded={isOpen} class:collapsed={!isOpen}>
<div class="p-3">
<div>
<!-- Navigation Menu -->
<ul class="menu w-full gap-1" class:menu-collapsed={!isOpen}>
{#if isOpen}
<li class="menu-title uppercase font-bold text-xs text-secondary/70 mt-2">Main</li>
{/if}
<!-- Dashboard -->
<li>
<a
href="/dashboard"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus"
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Dashboard' : ''}
>
<LayoutDashboard class="w-5 h-5 text-secondary flex-shrink-0" />
<LayoutDashboard size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Dashboard</span>
<span class="nav-text">Dashboard</span>
{/if}
</a>
</li>
<li class="collapsible-section">
<!-- Master Data -->
<li class="nav-group" class:collapsed={!isOpen}>
<button
onclick={toggleMasterData}
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus w-full text-left justify-between"
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Master Data' : ''}
>
<div class="flex items-center gap-2">
<Database class="w-5 h-5 text-secondary flex-shrink-0" />
<Database size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Master Data</span>
{/if}
</div>
{#if isOpen}
<ChevronDown class="w-4 h-4 flex-shrink-0 transition-transform duration-200 {masterDataExpanded ? 'rotate-180' : ''}" />
<span class="nav-text">Master Data</span>
<ChevronDown size={16} class="chevron {masterDataExpanded ? 'expanded' : ''}" />
{/if}
</button>
{#if isOpen && masterDataExpanded}
<ul class="ml-6 mt-1 space-y-1 collapsible-content">
<li>
<a
href="/master-data/containers"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<FlaskConical class="w-4 h-4 flex-shrink-0" />
<span>Containers</span>
</a>
</li>
<li>
<a
href="/master-data/tests"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<TestTube class="w-4 h-4 flex-shrink-0" />
<span>Test Definitions</span>
</a>
</li>
<li>
<a
href="/master-data/valuesets"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<List class="w-4 h-4 flex-shrink-0" />
<span>ValueSets</span>
</a>
</li>
<li>
<a
href="/master-data/locations"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<MapPin class="w-4 h-4 flex-shrink-0" />
<span>Locations</span>
</a>
</li>
<li>
<a
href="/master-data/contacts"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Users class="w-4 h-4 flex-shrink-0" />
<span>Contacts</span>
</a>
</li>
<li>
<a
href="/master-data/specialties"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Stethoscope class="w-4 h-4 flex-shrink-0" />
<span>Specialties</span>
</a>
</li>
<li>
<a
href="/master-data/occupations"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Briefcase class="w-4 h-4 flex-shrink-0" />
<span>Occupations</span>
</a>
</li>
<li>
<a
href="/master-data/counters"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Hash class="w-4 h-4 flex-shrink-0" />
<span>Counters</span>
</a>
</li>
<li>
<a
href="/master-data/geography"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Globe class="w-4 h-4 flex-shrink-0" />
<span>Geography</span>
</a>
</li>
<ul class="submenu">
<li><a href="/master-data/containers" class="submenu-link"><FlaskConical size={16} /> Containers</a></li>
<li><a href="/master-data/tests" class="submenu-link"><TestTube size={16} /> Test Definitions</a></li>
<li><a href="/master-data/valuesets" class="submenu-link"><List size={16} /> ValueSets</a></li>
<li><a href="/master-data/locations" class="submenu-link"><MapPin size={16} /> Locations</a></li>
<li><a href="/master-data/contacts" class="submenu-link"><Users size={16} /> Contacts</a></li>
<li><a href="/master-data/specialties" class="submenu-link"><Stethoscope size={16} /> Specialties</a></li>
<li><a href="/master-data/occupations" class="submenu-link"><Briefcase size={16} /> Occupations</a></li>
<li><a href="/master-data/counters" class="submenu-link"><Hash size={16} /> Counters</a></li>
<li><a href="/master-data/geography" class="submenu-link"><Globe size={16} /> Geography</a></li>
</ul>
{/if}
</li>
<!-- Result Entry -->
<li>
<a
href="/result-entry"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus"
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Result Entry' : ''}
>
<FileText class="w-5 h-5 text-secondary flex-shrink-0" />
<FileText size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Result Entry</span>
{/if}
</a>
</li>
<li>
<a
href="/reports"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus"
class:centered={!isOpen}
title={!isOpen ? 'Reports' : ''}
>
<Printer class="w-5 h-5 text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Reports</span>
<span class="nav-text">Result Entry</span>
{/if}
</a>
</li>
<li class="collapsible-section">
<!-- Reports -->
<li>
<a
href="/reports"
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Reports' : ''}
>
<Printer size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Reports</span>
{/if}
</a>
</li>
<!-- Laboratory -->
<li class="nav-group" class:collapsed={!isOpen}>
<button
onclick={toggleLaboratory}
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus w-full text-left justify-between"
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Laboratory' : ''}
>
<div class="flex items-center gap-2">
<FlaskConical class="w-5 h-5 text-secondary flex-shrink-0" />
<FlaskConical size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Laboratory</span>
{/if}
</div>
{#if isOpen}
<ChevronDown class="w-4 h-4 flex-shrink-0 transition-transform duration-200 {laboratoryExpanded ? 'rotate-180' : ''}" />
<span class="nav-text">Laboratory</span>
<ChevronDown size={16} class="chevron {laboratoryExpanded ? 'expanded' : ''}" />
{/if}
</button>
{#if isOpen && laboratoryExpanded}
<ul class="ml-6 mt-1 space-y-1 collapsible-content">
<li>
<a
href="/patients"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Users class="w-4 h-4 flex-shrink-0" />
<span>Patients</span>
</a>
</li>
<li>
<a
href="/orders"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<ClipboardList class="w-4 h-4 flex-shrink-0" />
<span>Orders</span>
</a>
</li>
<li>
<a
href="/specimens"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<FlaskConical class="w-4 h-4 flex-shrink-0" />
<span>Specimens</span>
</a>
</li>
<li>
<a
href="/results"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<CheckCircle2 class="w-4 h-4 flex-shrink-0" />
<span>Results</span>
</a>
</li>
<ul class="submenu">
<li><a href="/patients" class="submenu-link"><Users size={16} /> Patients</a></li>
<li><a href="/orders" class="submenu-link"><ClipboardList size={16} /> Orders</a></li>
<li><a href="/specimens" class="submenu-link"><FlaskConical size={16} /> Specimens</a></li>
<li><a href="/results" class="submenu-link"><CheckCircle2 size={16} /> Results</a></li>
</ul>
{/if}
</li>
<li class="collapsible-section">
<!-- Administration -->
<li class="nav-group" class:collapsed={!isOpen}>
<button
onclick={toggleAdministration}
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus w-full text-left justify-between"
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Administration' : ''}
>
<div class="flex items-center gap-2">
<Building2 class="w-5 h-5 text-secondary flex-shrink-0" />
<Building2 size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Administration</span>
{/if}
</div>
{#if isOpen}
<ChevronDown class="w-4 h-4 flex-shrink-0 transition-transform duration-200 {administrationExpanded ? 'rotate-180' : ''}" />
<span class="nav-text">Administration</span>
<ChevronDown size={16} class="chevron {administrationExpanded ? 'expanded' : ''}" />
{/if}
</button>
{#if isOpen && administrationExpanded}
<ul class="ml-6 mt-1 space-y-1 collapsible-content">
<li>
<a
href="/organization"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Building2 class="w-4 h-4 flex-shrink-0" />
<span>Organization</span>
</a>
</li>
<li>
<a
href="/users"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<UserCircle class="w-4 h-4 flex-shrink-0" />
<span>Users</span>
</a>
</li>
<ul class="submenu">
<li><a href="/organization" class="submenu-link"><Building2 size={16} /> Organization</a></li>
<li><a href="/users" class="submenu-link"><UserCircle size={16} /> Users</a></li>
</ul>
{/if}
</li>
@ -349,13 +244,13 @@
<li>
<button
onclick={handleLogout}
class="menu-item text-red-500 hover:bg-red-50 w-full text-left"
class="nav-link text-red-500 hover:bg-red-50"
class:centered={!isOpen}
title={!isOpen ? 'Logout' : ''}
>
<LogOut class="w-5 h-5 flex-shrink-0" />
<LogOut size={20} class="flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Logout</span>
<span class="nav-text">Logout</span>
{/if}
</button>
</li>
@ -371,11 +266,11 @@
}
.sidebar-expanded {
width: 14rem;
width: 16rem;
}
.sidebar-collapsed {
width: 4rem;
width: 3.5rem;
}
.sidebar-content {
@ -383,55 +278,97 @@
}
.sidebar-content.expanded {
width: 14rem;
width: 16rem;
}
.sidebar-content.collapsed {
width: 4rem;
width: 3.5rem;
}
.menu-item {
.nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s;
gap: 0.5rem;
color: hsl(var(--bc));
transition: background-color 0.2s;
width: 100%;
border: none !important;
outline: none !important;
}
.menu-item.centered {
.nav-link:hover {
background-color: hsl(var(--b3));
}
.nav-link.centered {
justify-content: center;
padding: 0.5rem 0;
padding: 0.5rem;
width: 2.5rem;
margin-left: auto;
margin-right: auto;
height: 2.5rem;
margin: 0 auto;
}
.menu-text {
flex: 1;
transition: opacity 300ms ease-out;
}
/* Collapsed menu styling */
.menu-collapsed :global(li > a),
.menu-collapsed :global(li > button) {
/* Fix for collapsed menu items - override DaisyUI .menu styles */
:global(.menu-collapsed li) {
display: flex !important;
justify-content: center !important;
align-items: center !important;
width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
/* Collapsible section styles */
.collapsible-section button {
:global(.menu-collapsed li > a),
:global(.menu-collapsed li > button) {
margin: 0 !important;
border: none !important;
box-sizing: border-box !important;
}
.nav-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
}
.chevron {
flex-shrink: 0;
transition: transform 0.2s;
}
.chevron.expanded {
transform: rotate(180deg);
}
.nav-group {
position: relative;
}
.submenu {
margin-left: 1.5rem;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
animation: slideDown 0.2s ease-out;
}
.submenu-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
color: hsl(var(--bc) / 0.8);
border-radius: 0.25rem;
transition: all 0.15s;
}
.collapsible-section button.centered {
justify-content: center;
}
.collapsible-content {
animation: slideDown 0.2s ease-out;
.submenu-link:hover {
background-color: hsl(var(--b3));
color: hsl(var(--bc));
}
@keyframes slideDown {

View File

@ -1,104 +0,0 @@
<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

@ -1,185 +0,0 @@
<script>
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
/**
* @typedef {Object} Props
* @property {Object} formData - Form data object
* @property {boolean} canHaveFormula - Whether test can have a formula
* @property {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

@ -1,134 +0,0 @@
<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

@ -1,210 +0,0 @@
<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

@ -1,103 +0,0 @@
<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

@ -1,134 +0,0 @@
<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

@ -1,104 +0,0 @@
<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

@ -1,26 +0,0 @@
/**
* 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' }
];

View File

@ -79,7 +79,7 @@
];
</script>
<div class="p-6">
<div class="p-4">
<h1 class="text-3xl font-bold text-gray-800 mb-2">Master Data</h1>
<p class="text-gray-600 mb-8">Manage reference data and lookup values used throughout the system</p>

View File

@ -166,7 +166,7 @@
}
</script>
<div class="p-6">
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
@ -262,7 +262,7 @@
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Contact' : 'Edit Contact'} size="md">
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<form class="space-y-3" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="initial">
@ -279,7 +279,7 @@
<input
id="initial"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Initial}
placeholder="e.g., JS, AB, MK"
maxlength="10"
@ -296,7 +296,7 @@
<input
id="title"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Title}
placeholder="e.g., Dr., Prof., Mr., Ms."
/>
@ -310,7 +310,7 @@
<input
id="nameFirst"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.NameFirst}
placeholder="Enter first name"
/>
@ -322,7 +322,7 @@
<input
id="nameLast"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.NameLast}
placeholder="Enter last name"
/>

View File

@ -146,7 +146,7 @@
}
</script>
<div class="p-6">
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
@ -249,7 +249,7 @@
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Container' : 'Edit Container'} size="md">
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<form class="space-y-3" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="code">
@ -259,7 +259,7 @@
<input
id="code"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.ConCode}
placeholder="e.g., SST, EDTA, HEP"
required
@ -274,7 +274,7 @@
<input
id="name"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.ConName}
placeholder="e.g., Serum Separator Tube"
required
@ -289,7 +289,7 @@
<input
id="desc"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.ConDesc}
placeholder="e.g., Evacuated blood collection tube with gel separator"
/>

View File

@ -197,7 +197,7 @@
}
</script>
<div class="p-6">
<div class="p-4">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
@ -327,7 +327,7 @@
<!-- Create/Edit Modal -->
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Counter' : 'Edit Counter'} size="md">
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<form class="space-y-3" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<!-- Description -->
<div class="form-control">
<label class="label" for="counterDesc">
@ -337,7 +337,7 @@
<input
id="counterDesc"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.CounterDesc}
bind:value={formData.CounterDesc}
placeholder="e.g., Sample ID Counter, Order Number"
@ -367,7 +367,7 @@
<input
id="counterValue"
type="number"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.CounterValue}
bind:value={formData.CounterValue}
placeholder="e.g., 1000"
@ -396,7 +396,7 @@
<input
id="counterStart"
type="number"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.CounterStart}
bind:value={formData.CounterStart}
placeholder="e.g., 1"
@ -424,7 +424,7 @@
<input
id="counterEnd"
type="number"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.CounterEnd}
bind:value={formData.CounterEnd}
placeholder="e.g., 9999"
@ -452,7 +452,7 @@
<select
id="counterReset"
name="counterReset"
class="select select-bordered w-full"
class="select select-sm select-bordered w-full"
bind:value={formData.CounterReset}
>
{#each resetOptions as option (option.value)}

View File

@ -183,7 +183,7 @@
});
</script>
<div class="p-6">
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
@ -244,7 +244,7 @@
</div>
{#if activeTab === 'provinces'}
<div class="space-y-4">
<div class="space-y-3">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<MapPin class="w-4 h-4 mt-0.5 shrink-0" />
@ -302,7 +302,7 @@
</div>
{:else if activeTab === 'cities'}
<div class="space-y-4">
<div class="space-y-3">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<Building2 class="w-4 h-4 mt-0.5 shrink-0" />
@ -392,7 +392,7 @@
</div>
{:else if activeTab === 'areas'}
<div class="space-y-4">
<div class="space-y-3">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<Globe class="w-4 h-4 mt-0.5 shrink-0" />

View File

@ -163,7 +163,7 @@
}
</script>
<div class="p-6">
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
@ -254,7 +254,7 @@
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Location' : 'Edit Location'} size="md">
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<form class="space-y-3" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="code">
@ -264,7 +264,7 @@
<input
id="code"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Code}
placeholder="e.g., BLDG-01, ROOM-101"
required
@ -281,7 +281,7 @@
<input
id="name"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Name}
placeholder="e.g., Main Building, Laboratory Room A"
required
@ -300,7 +300,7 @@
<select
id="type"
name="type"
class="select select-bordered w-full"
class="select select-sm select-bordered w-full"
bind:value={formData.Type}
required
>

View File

@ -154,7 +154,7 @@
}
</script>
<div class="p-6">
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
@ -260,7 +260,7 @@
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Occupation' : 'Edit Occupation'} size="md">
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<form class="space-y-3" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="occCode">

View File

@ -143,7 +143,7 @@
}
</script>
<div class="p-6">
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
@ -252,7 +252,7 @@
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Specialty' : 'Edit Specialty'} size="md">
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<form class="space-y-3" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="specialtyText">
@ -262,7 +262,7 @@
<input
id="specialtyText"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.SpecialtyText}
placeholder="e.g., Cardiology, Internal Medicine"
required
@ -278,7 +278,7 @@
<input
id="title"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Title}
placeholder="e.g., Sp. PD, Sp. A, Sp. And"
/>
@ -324,7 +324,7 @@
</Modal>
<Modal bind:open={deleteConfirmOpen} title="Confirm Deletion" size="md">
<div class="py-4 space-y-4">
<div class="py-4 space-y-3">
<div class="flex items-start gap-3">
<div class="bg-error/10 rounded-full p-2">
<Trash2 class="w-6 h-6 text-error" />

View File

@ -1,6 +1,6 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { fetchTests, createTest, updateTest, deleteTest } from '$lib/api/tests.js';
import { fetchTests, fetchTest, createTest, updateTest, deleteTest } from '$lib/api/tests.js';
import { fetchDisciplines, fetchDepartments } from '$lib/api/organization.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
@ -14,12 +14,48 @@
let currentPage = $state(1), perPage = $state(20), totalItems = $state(0), totalPages = $state(1);
let modalMode = $state('create'), saving = $state(false), selectedType = $state(''), searchQuery = $state(''), searchInputRef = $state(null);
let deleteModalOpen = $state(false), testToDelete = $state(null), deleting = $state(false);
let formData = $state({ TestSiteID: null, TestSiteCode: '', TestSiteName: '', TestType: 'TEST', DisciplineID: null, DepartmentID: null, SeqScr: '0', SeqRpt: '0', VisibleScr: true, VisibleRpt: true, Unit: '', Formula: '', refnum: [], refthold: [], reftxt: [], refvset: [], refRangeType: 'none' });
let formData = $state({
// Basic Info (testdefsite)
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
Description: '',
CountStat: false,
Unit: '',
Formula: '',
refnum: [],
refthold: [],
reftxt: [],
refvset: [],
refRangeType: 'none',
// Technical Config (testdeftech)
ResultType: '',
RefType: '',
ReqQty: null,
ReqQtyUnit: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 0,
CollReq: '',
Method: '',
ExpectedTAT: null,
// Group Members (testdefgrp)
groupMembers: []
});
const testTypeConfig = { TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' }, PARAM: { label: 'Parameter', badgeClass: 'badge-secondary', icon: Variable, color: '#3399FF', bgColor: '#F0F8FF' }, CALC: { label: 'Calculated', badgeClass: 'badge-accent', icon: Calculator, color: '#9933CC', bgColor: '#F5E6FF' }, GROUP: { label: 'Panel', badgeClass: 'badge-info', icon: Box, color: '#00AA44', bgColor: '#E6F9EE' }, TITLE: { label: 'Header', badgeClass: 'badge-ghost', icon: Layers, color: '#666666', bgColor: '#F5F5F5' } };
const canHaveRefRange = $derived(formData.TestType === 'TEST' || formData.TestType === 'CALC');
const canHaveRefRange = $derived(formData.TestType === 'TEST' || formData.TestType === 'CALC');
const canHaveFormula = $derived(formData.TestType === 'CALC');
const canHaveUnit = $derived(formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC');
const canHaveTechnical = $derived(formData.TestType === 'TEST' || formData.TestType === 'PARAM' || formData.TestType === 'CALC');
const isGroupTest = $derived(formData.TestType === 'GROUP');
const columns = [{ key: 'expand', label: '', class: 'w-8' }, { key: 'TestSiteCode', label: 'Code', class: 'font-medium w-24' }, { key: 'TestSiteName', label: 'Name', class: 'min-w-[200px]' }, { key: 'TestType', label: 'Type', class: 'w-28' }, { key: 'ReferenceRange', label: 'Reference Range', class: 'w-40' }, { key: 'Unit', label: 'Unit', class: 'w-20' }, { key: 'actions', label: 'Actions', class: 'w-24 text-center' }];
const disciplineOptions = $derived(disciplines.map(d => ({ value: d.DisciplineID, label: d.DisciplineName })));
const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName })));
@ -34,8 +70,149 @@
function getVisibleTests() { return tests.filter(t => t.IsActive !== '0' && t.IsActive !== 0); }
function getTestTypeConfig(type) { return testTypeConfig[type] || testTypeConfig.TEST; }
function formatReferenceRange(test) { return '-'; }
function openCreateModal() { modalMode = 'create'; formData = { TestSiteID: null, TestSiteCode: '', TestSiteName: '', TestType: 'TEST', DisciplineID: null, DepartmentID: null, SeqScr: '0', SeqRpt: '0', VisibleScr: true, VisibleRpt: true, Unit: '', Formula: '', refnum: [], refthold: [], reftxt: [], refvset: [], refRangeType: 'none' }; modalOpen = true; }
function openEditModal(row) { modalMode = 'edit'; let refRangeType = 'none'; if (row.refnum?.length > 0) refRangeType = 'num'; else if (row.refthold?.length > 0) refRangeType = 'thold'; else if (row.reftxt?.length > 0) refRangeType = 'text'; else if (row.refvset?.length > 0) refRangeType = 'vset'; formData = { TestSiteID: row.TestSiteID, TestSiteCode: row.TestSiteCode, TestSiteName: row.TestSiteName, TestType: row.TestType, DisciplineID: row.DisciplineID, DepartmentID: row.DepartmentID, SeqScr: row.SeqScr || '0', SeqRpt: row.SeqRpt || '0', VisibleScr: row.VisibleScr === '1' || row.VisibleScr === 1 || row.VisibleScr === true, VisibleRpt: row.VisibleRpt === '1' || row.VisibleRpt === 1 || row.VisibleRpt === true, Unit: row.Unit || '', Formula: row.Formula || '', refnum: row.refnum || [], refthold: row.refthold || [], reftxt: row.reftxt || [], refvset: row.refvset || [], refRangeType }; modalOpen = true; }
function openCreateModal() {
modalMode = 'create';
formData = {
// Basic Info
TestSiteID: null,
TestSiteCode: '',
TestSiteName: '',
TestType: 'TEST',
DisciplineID: null,
DepartmentID: null,
SeqScr: '0',
SeqRpt: '0',
VisibleScr: true,
VisibleRpt: true,
Description: '',
CountStat: false,
Unit: '',
Formula: '',
refnum: [],
refthold: [],
reftxt: [],
refvset: [],
refRangeType: 'none',
// Technical Config
ResultType: '',
RefType: '',
ReqQty: null,
ReqQtyUnit: '',
Unit1: '',
Factor: null,
Unit2: '',
Decimal: 0,
CollReq: '',
Method: '',
ExpectedTAT: null,
// Group Members
groupMembers: []
};
modalOpen = true;
}
async function openEditModal(row) {
try {
// Fetch full test details including reference ranges, technical config, group members
const response = await fetchTest(row.TestSiteID);
const testDetail = response.data;
modalMode = 'edit';
let refRangeType = 'none';
if (testDetail.refnum?.length > 0) refRangeType = 'num';
else if (testDetail.refthold?.length > 0) refRangeType = 'thold';
else if (testDetail.reftxt?.length > 0) refRangeType = 'text';
else if (testDetail.refvset?.length > 0) refRangeType = 'vset';
// Normalize reference range data to ensure all fields have values (not undefined)
const normalizeRefNum = (ref) => ({
Sex: ref.Sex ?? '2',
LowSign: ref.LowSign ?? 'GE',
HighSign: ref.HighSign ?? 'LE',
Low: ref.Low ?? null,
High: ref.High ?? null,
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
Flag: ref.Flag ?? 'N',
Interpretation: ref.Interpretation ?? 'Normal',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
const normalizeRefThold = (ref) => ({
Sex: ref.Sex ?? '2',
LowSign: ref.LowSign ?? 'GE',
HighSign: ref.HighSign ?? 'LE',
Low: ref.Low ?? null,
High: ref.High ?? null,
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
Flag: ref.Flag ?? 'N',
Interpretation: ref.Interpretation ?? 'Normal',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
const normalizeRefTxt = (ref) => ({
Sex: ref.Sex ?? '2',
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
RefTxt: ref.RefTxt ?? '',
Flag: ref.Flag ?? 'N',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
const normalizeRefVset = (ref) => ({
Sex: ref.Sex ?? '2',
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
RefTxt: ref.RefTxt ?? '',
Flag: ref.Flag ?? 'N',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
});
formData = {
// Basic Info
TestSiteID: testDetail.TestSiteID,
TestSiteCode: testDetail.TestSiteCode,
TestSiteName: testDetail.TestSiteName,
TestType: testDetail.TestType,
DisciplineID: testDetail.testdeftech?.[0]?.DisciplineID || null,
DepartmentID: testDetail.testdeftech?.[0]?.DepartmentID || null,
SeqScr: testDetail.SeqScr || '0',
SeqRpt: testDetail.SeqRpt || '0',
VisibleScr: testDetail.VisibleScr === '1' || testDetail.VisibleScr === 1 || testDetail.VisibleScr === true,
VisibleRpt: testDetail.VisibleRpt === '1' || testDetail.VisibleRpt === 1 || testDetail.VisibleRpt === true,
Description: testDetail.Description || '',
CountStat: testDetail.CountStat === '1' || testDetail.CountStat === 1 || testDetail.CountStat === true,
Unit: testDetail.Unit || '',
Formula: testDetail.Formula || '',
refnum: (testDetail.refnum || []).map(normalizeRefNum),
refthold: (testDetail.refthold || []).map(normalizeRefThold),
reftxt: (testDetail.reftxt || []).map(normalizeRefTxt),
refvset: (testDetail.refvset || []).map(normalizeRefVset),
refRangeType,
// Technical Config (from testdeftech[0])
ResultType: testDetail.testdeftech?.[0]?.ResultType || '',
RefType: testDetail.testdeftech?.[0]?.RefType || '',
ReqQty: testDetail.testdeftech?.[0]?.ReqQty || null,
ReqQtyUnit: testDetail.testdeftech?.[0]?.ReqQtyUnit || '',
Unit1: testDetail.testdeftech?.[0]?.Unit1 || '',
Factor: testDetail.testdeftech?.[0]?.Factor || null,
Unit2: testDetail.testdeftech?.[0]?.Unit2 || '',
Decimal: testDetail.testdeftech?.[0]?.Decimal || 0,
Method: testDetail.testdeftech?.[0]?.Method || '',
ExpectedTAT: testDetail.testdeftech?.[0]?.ExpectedTAT || null,
// Group Members - API returns as testdefgrp
groupMembers: testDetail.testdefgrp || []
};
modalOpen = true;
} catch (err) {
toastError(err.message || 'Failed to load test details');
console.error('Failed to fetch test details:', err);
}
}
function isDuplicateCode(code, excludeId = null) { return tests.some(test => test.TestSiteCode.toLowerCase() === code.toLowerCase() && test.TestSiteID !== excludeId); }
async function handleSave() {
@ -45,7 +222,50 @@
else if (formData.refRangeType === 'thold') { for (let i = 0; i < formData.refthold.length; i++) { const errors = validateTholdRange(formData.refthold[i], i); if (errors.length > 0) { toastError(errors[0]); return; } } }
else if (formData.refRangeType === 'text') { for (let i = 0; i < formData.reftxt.length; i++) { const errors = validateTextRange(formData.reftxt[i], i); if (errors.length > 0) { toastError(errors[0]); return; } } }
else if (formData.refRangeType === 'vset') { for (let i = 0; i < formData.refvset.length; i++) { const errors = validateVsetRange(formData.refvset[i], i); if (errors.length > 0) { toastError(errors[0]); return; } } }
saving = true; try { const payload = { ...formData }; if (!canHaveUnit) delete payload.Unit; if (!canHaveFormula) delete payload.Formula; if (!canHaveRefRange) { delete payload.refnum; delete payload.refthold; delete payload.reftxt; delete payload.refvset; } delete payload.refRangeType; if (modalMode === 'create') { await createTest(payload); toastSuccess('Test created successfully'); } else { await updateTest(payload); toastSuccess('Test updated successfully'); } modalOpen = false; await loadTests(); } catch (err) { toastError(err.message || 'Failed to save test'); } finally { saving = false; }
saving = true;
try {
const payload = { ...formData };
// Remove fields based on test type
if (!canHaveFormula) delete payload.Formula;
if (!canHaveRefRange) {
delete payload.refnum;
delete payload.refthold;
delete payload.reftxt;
delete payload.refvset;
}
if (!canHaveTechnical) {
delete payload.ResultType;
delete payload.RefType;
delete payload.Unit1;
delete payload.Factor;
delete payload.Unit2;
delete payload.Decimal;
delete payload.ReqQty;
delete payload.ReqQtyUnit;
delete payload.CollReq;
delete payload.Method;
delete payload.ExpectedTAT;
}
if (!isGroupTest) {
delete payload.groupMembers;
}
delete payload.refRangeType;
if (modalMode === 'create') {
await createTest(payload);
toastSuccess('Test created successfully');
} else {
await updateTest(payload);
toastSuccess('Test updated successfully');
}
modalOpen = false;
await loadTests();
} catch (err) {
toastError(err.message || 'Failed to save test');
} finally {
saving = false;
}
}
function openDeleteModal(row) { testToDelete = row; deleteModalOpen = true; }
@ -57,7 +277,7 @@
function toggleGroup(testId) { if (expandedGroups.has(testId)) expandedGroups.delete(testId); else expandedGroups.add(testId); expandedGroups = new Set(expandedGroups); }
</script>
<div class="p-6">
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle"><ArrowLeft class="w-5 h-5" /></a>
<div class="flex-1">
@ -74,7 +294,7 @@
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
<div class="w-full sm:w-48">
<select class="select select-bordered w-full" bind:value={selectedType} onchange={handleFilter}>
<select class="select select-sm select-bordered w-full" bind:value={selectedType} onchange={handleFilter}>
<option value="">All Types</option>
<option value="TEST">Technical Test</option>
<option value="PARAM">Parameter</option>
@ -103,7 +323,22 @@
</div>
</div>
<TestModal bind:open={modalOpen} mode={modalMode} bind:formData {canHaveRefRange} {canHaveFormula} {canHaveUnit} {disciplineOptions} departmentOptions={departmentOptions} {saving} onsave={handleSave} oncancel={() => modalOpen = false} onupdateFormData={(data) => formData = data} />
<TestModal
bind:open={modalOpen}
mode={modalMode}
bind:formData
{canHaveRefRange}
{canHaveFormula}
{canHaveTechnical}
{isGroupTest}
{disciplineOptions}
departmentOptions={departmentOptions}
availableTests={tests}
{saving}
onsave={handleSave}
oncancel={() => modalOpen = false}
onupdateFormData={(data) => formData = data}
/>
<Modal bind:open={deleteModalOpen} title="Confirm Delete Test" size="sm">
<div class="py-2">

View File

@ -2,6 +2,8 @@
import Modal from '$lib/components/Modal.svelte';
import BasicInfoForm from './test-modal/BasicInfoForm.svelte';
import ReferenceRangeSection from './test-modal/ReferenceRangeSection.svelte';
import TechnicalConfigForm from './test-modal/TechnicalConfigForm.svelte';
import GroupMembersTab from './test-modal/GroupMembersTab.svelte';
/**
* @typedef {Object} Props
@ -10,9 +12,11 @@
* @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 {boolean} canHaveTechnical - Whether test can have technical config
* @property {boolean} isGroupTest - Whether test is a group test
* @property {Array<{value: string, label: string}>} disciplineOptions - Discipline dropdown options
* @property {Array<{value: string, label: string}>} departmentOptions - Department dropdown options
* @property {Array} availableTests - Available tests for group member selection
* @property {boolean} [saving] - Whether save is in progress
*/
@ -23,9 +27,11 @@
formData = $bindable({}),
canHaveRefRange = false,
canHaveFormula = false,
canHaveUnit = false,
canHaveTechnical = false,
isGroupTest = false,
disciplineOptions = [],
departmentOptions = [],
availableTests = [],
saving = false,
onsave = () => {},
oncancel = () => {},
@ -50,9 +56,20 @@
activeTab = 'basic';
}
});
// Get tab count badge for reference range
function getRefRangeCount() {
return (formData.refnum?.length || 0) + (formData.reftxt?.length || 0) +
(formData.refthold?.length || 0) + (formData.refvset?.length || 0);
}
// Get tab count badge for group members
function getGroupMemberCount() {
return formData.groupMembers?.length || 0;
}
</script>
<Modal bind:open title={mode === 'create' ? 'Add Test' : 'Edit Test'} size="xl">
<Modal bind:open title={mode === 'create' ? 'Add Test' : 'Edit Test'} size="xl" position="top">
<!-- Tabs -->
<div class="tabs tabs-bordered mb-4">
<button
@ -62,6 +79,15 @@
>
Basic Information
</button>
{#if canHaveTechnical}
<button
type="button"
class="tab tab-lg {activeTab === 'technical' ? 'tab-active' : ''}"
onclick={() => activeTab = 'technical'}
>
Technical
</button>
{/if}
{#if canHaveRefRange}
<button
type="button"
@ -69,8 +95,20 @@
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 getRefRangeCount() > 0}
<span class="badge badge-sm badge-primary ml-2">{getRefRangeCount()}</span>
{/if}
</button>
{/if}
{#if isGroupTest}
<button
type="button"
class="tab tab-lg {activeTab === 'members' ? 'tab-active' : ''}"
onclick={() => activeTab = 'members'}
>
Group Members
{#if getGroupMemberCount() > 0}
<span class="badge badge-sm badge-primary ml-2">{getGroupMemberCount()}</span>
{/if}
</button>
{/if}
@ -80,16 +118,26 @@
<BasicInfoForm
bind:formData
{canHaveFormula}
{canHaveUnit}
{disciplineOptions}
{departmentOptions}
onsave={handleSave}
/>
{:else if activeTab === 'technical' && canHaveTechnical}
<TechnicalConfigForm
bind:formData
{onupdateFormData}
/>
{:else if activeTab === 'refrange' && canHaveRefRange}
<ReferenceRangeSection
bind:formData
{onupdateFormData}
/>
{:else if activeTab === 'members' && isGroupTest}
<GroupMembersTab
bind:formData
{availableTests}
{onupdateFormData}
/>
{/if}
{#snippet footer()}

View File

@ -30,7 +30,9 @@ export function createNumRef() {
AgeStart: 0,
AgeEnd: 120,
Flag: 'N',
Interpretation: 'Normal'
Interpretation: 'Normal',
SpcType: '',
Criteria: ''
};
}
@ -44,7 +46,9 @@ export function createTholdRef() {
AgeStart: 0,
AgeEnd: 120,
Flag: 'N',
Interpretation: 'Normal'
Interpretation: 'Normal',
SpcType: '',
Criteria: ''
};
}
@ -54,7 +58,9 @@ export function createTextRef() {
AgeStart: 0,
AgeEnd: 120,
RefTxt: '',
Flag: 'N'
Flag: 'N',
SpcType: '',
Criteria: ''
};
}
@ -63,8 +69,10 @@ export function createVsetRef() {
Sex: '2',
AgeStart: 0,
AgeEnd: 120,
valueset: '',
Flag: 'N'
RefTxt: '',
Flag: 'N',
SpcType: '',
Criteria: ''
};
}

View File

@ -6,7 +6,6 @@
* @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
@ -16,7 +15,6 @@
let {
formData = $bindable({}),
canHaveFormula = false,
canHaveUnit = false,
disciplineOptions = [],
departmentOptions = [],
onsave = () => {}
@ -28,7 +26,7 @@
}
</script>
<form class="space-y-5" onsubmit={handleSubmit}>
<form class="space-y-3" onsubmit={handleSubmit}>
<!-- Basic Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
@ -39,7 +37,7 @@
<input
id="testCode"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.TestSiteCode}
placeholder="e.g., GLU"
required
@ -53,7 +51,7 @@
<input
id="testName"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.TestSiteName}
placeholder="e.g., Glucose"
required
@ -70,7 +68,7 @@
</label>
<select
id="testType"
class="select select-bordered w-full"
class="select select-sm select-bordered w-full"
bind:value={formData.TestType}
required
>
@ -88,7 +86,7 @@
<input
id="seqScr"
type="number"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.SeqScr}
placeholder="0"
/>
@ -114,9 +112,8 @@
</div>
<!-- Type-specific fields -->
{#if canHaveUnit}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#if canHaveFormula}
<div class="grid grid-cols-1 md:grid-cols-1 gap-4">
<div class="form-control">
<label class="label" for="formula">
<span class="label-text font-medium flex items-center gap-2">
@ -131,31 +128,32 @@
<input
id="formula"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Formula}
placeholder="e.g., BUN / Creatinine"
required={canHaveFormula}
/>
<span class="label-text-alt text-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">
<!-- Description -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">Description</span>
</label>
<textarea
id="description"
class="textarea textarea-bordered w-full"
bind:value={formData.Description}
placeholder="Enter test description..."
rows="3"
></textarea>
</div>
<!-- Report Sequence, Visibility, and CountStat -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label" for="seqRpt">
<span class="label-text font-medium">Report Sequence</span>
@ -163,7 +161,7 @@
<input
id="seqRpt"
type="number"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.SeqRpt}
placeholder="0"
/>
@ -181,5 +179,12 @@
</label>
</div>
</div>
<div class="form-control">
<span class="label-text font-medium mb-2 block">Statistics</span>
<label class="label cursor-pointer gap-2">
<input type="checkbox" class="checkbox" bind:checked={formData.CountStat} />
<span class="label-text">Count in Statistics</span>
</label>
</div>
</div>
</form>

View File

@ -0,0 +1,207 @@
<script>
import { Search, Plus, X, GripVertical, Microscope } from 'lucide-svelte';
/**
* @typedef {Object} Props
* @property {Object} formData - Form data object
* @property {Array} availableTests - Available tests for selection
* @property {(formData: Object) => void} onupdateFormData - Update handler
*/
/** @type {Props} */
let {
formData = $bindable({}),
availableTests = [],
onupdateFormData = () => {}
} = $props();
let searchQuery = $state('');
let draggedIndex = $state(-1);
// Filter out the current test and already selected tests
let filteredTests = $derived(
availableTests.filter(test =>
test.TestSiteID !== formData.TestSiteID &&
!formData.groupMembers?.some(member => member.TestSiteID === test.TestSiteID) &&
(test.TestSiteName.toLowerCase().includes(searchQuery.toLowerCase()) ||
test.TestSiteCode.toLowerCase().includes(searchQuery.toLowerCase()))
)
);
function addMember(test) {
const newMembers = [
...(formData.groupMembers || []),
{
TestSiteID: test.TestSiteID,
TestSiteCode: test.TestSiteCode,
TestSiteName: test.TestSiteName,
TestType: test.TestType,
Sequence: (formData.groupMembers?.length || 0) + 1
}
];
onupdateFormData({ ...formData, groupMembers: newMembers });
searchQuery = '';
}
function removeMember(index) {
const newMembers = formData.groupMembers.filter((_, i) => i !== index);
// Reorder sequences
newMembers.forEach((member, i) => {
member.Sequence = i + 1;
});
onupdateFormData({ ...formData, groupMembers: newMembers });
}
function moveMember(fromIndex, toIndex) {
if (toIndex < 0 || toIndex >= formData.groupMembers.length) return;
const members = [...formData.groupMembers];
const [moved] = members.splice(fromIndex, 1);
members.splice(toIndex, 0, moved);
// Reorder sequences
members.forEach((member, i) => {
member.Sequence = i + 1;
});
onupdateFormData({ ...formData, groupMembers: members });
}
function handleDragStart(index) {
draggedIndex = index;
}
function handleDragOver(e, index) {
e.preventDefault();
if (draggedIndex === -1 || draggedIndex === index) return;
moveMember(draggedIndex, index);
draggedIndex = index;
}
function handleDragEnd() {
draggedIndex = -1;
}
function getTestTypeBadge(testType) {
const badges = {
'TEST': 'badge-primary',
'PARAM': 'badge-secondary',
'CALC': 'badge-accent',
'GROUP': 'badge-info',
'TITLE': 'badge-ghost'
};
return badges[testType] || 'badge-ghost';
}
</script>
<div class="space-y-3">
<!-- Add Member Section -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<Microscope class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Add Group Members</h3>
</div>
<div class="form-control">
<div class="relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
placeholder="Search by test name or code..."
/>
</div>
</div>
{#if searchQuery}
<div class="mt-2 max-h-60 overflow-y-auto border border-base-200 rounded-lg">
{#if filteredTests.length === 0}
<div class="p-4 text-center text-gray-500">
No tests found matching "{searchQuery}"
</div>
{:else}
{#each filteredTests as test}
<button
type="button"
class="w-full text-left p-3 hover:bg-base-200 flex items-center justify-between group border-b border-base-200 last:border-0"
onclick={() => addMember(test)}
>
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-gray-600">{test.TestSiteCode}</span>
<span class="font-medium">{test.TestSiteName}</span>
<span class="badge badge-sm {getTestTypeBadge(test.TestType)}">
{test.TestType}
</span>
</div>
<Plus class="w-4 h-4 text-gray-400 group-hover:text-primary" />
</button>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Selected Members -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="font-semibold">Group Members</span>
<span class="badge badge-sm badge-primary">{formData.groupMembers?.length || 0}</span>
</div>
{#if formData.groupMembers?.length > 0}
<span class="text-sm text-gray-500">Drag to reorder</span>
{/if}
</div>
{#if !formData.groupMembers || formData.groupMembers.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
<Microscope class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-gray-500">No members added yet</p>
<p class="text-sm text-gray-400 mt-1">Search and add tests above</p>
</div>
{:else}
<div class="space-y-2">
{#each formData.groupMembers as member, index (member.TestSiteID)}
<div
class="card bg-base-100 border border-base-200 hover:border-primary/50 transition-colors"
draggable="true"
ondragstart={() => handleDragStart(index)}
ondragover={(e) => handleDragOver(e, index)}
ondragend={handleDragEnd}
class:opacity-50={draggedIndex === index}
>
<div class="card-body p-3 flex flex-row items-center gap-3">
<div class="cursor-move text-gray-400 hover:text-gray-600">
<GripVertical class="w-5 h-5" />
</div>
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-sm">
{member.Sequence}
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-gray-600">{member.TestSiteCode}</span>
<span class="font-medium">{member.TestSiteName}</span>
</div>
</div>
<span class="badge badge-sm {getTestTypeBadge(member.TestType)}">
{member.TestType}
</span>
<button
type="button"
class="btn btn-sm btn-ghost text-error"
onclick={() => removeMember(index)}
>
<X class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@ -1,6 +1,8 @@
<script>
import { PlusCircle, Calculator, X, ChevronDown, ChevronUp, Info } from 'lucide-svelte';
import { signOptions, flagOptions, sexOptions, createNumRef } from '../referenceRange.js';
import { PlusCircle, Calculator, X, ChevronDown, ChevronUp, Info, Beaker, Filter } from 'lucide-svelte';
import { flagOptions, sexOptions, createNumRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
@ -16,6 +18,19 @@
// Track expanded state for each range's optional fields
let expandedRanges = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createNumRef();
@ -24,7 +39,7 @@
newRef.Flag = 'N'; // Normal
onupdateRefnum([...refnum, newRef]);
// Auto-expand the new range
expandedRanges[refnum.length] = { age: false, interpretation: false };
expandedRanges[refnum.length] = { age: false, interpretation: false, specimen: false };
}
function removeRefRange(index) {
@ -40,8 +55,8 @@
expandedRanges[index] = { ...expandedRanges[index], interpretation: !expandedRanges[index]?.interpretation };
}
function getSignLabel(value) {
return signOptions.find(o => o.value === value)?.label || value;
function toggleSpecimenExpand(index) {
expandedRanges[index] = { ...expandedRanges[index], specimen: !expandedRanges[index]?.specimen };
}
function getSexLabel(value) {
@ -72,11 +87,11 @@
let rangeText = '';
if (ref.Low !== null && ref.High !== null) {
rangeText = `${getSignLabel(ref.LowSign)}${ref.Low} to ${getSignLabel(ref.HighSign)}${ref.High}`;
rangeText = `${ref.Low} - ${ref.High}`;
} else if (ref.Low !== null) {
rangeText = `${getSignLabel(ref.LowSign)}${ref.Low}`;
rangeText = `${ref.Low}`;
} else if (ref.High !== null) {
rangeText = `${getSignLabel(ref.HighSign)}${ref.High}`;
rangeText = `${ref.High}`;
} else {
rangeText = 'Not set';
}
@ -85,7 +100,7 @@
}
</script>
<div class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<Calculator class="w-5 h-5 text-primary" />
@ -133,40 +148,26 @@
<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"
class="input input-sm input-bordered w-full"
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"
class="input input-sm input-bordered w-full"
bind:value={ref.High}
placeholder="100"
/>
</div>
</div>
</div>
<!-- Sex & Flag Row -->
<div class="grid grid-cols-2 gap-3 mb-3">
@ -228,7 +229,7 @@
</div>
<!-- Expandable: Interpretation -->
<div class="border border-base-200 rounded-lg overflow-hidden">
<div class="border border-base-200 rounded-lg overflow-hidden mb-2">
<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"
@ -260,6 +261,59 @@
</div>
{/if}
</div>
<!-- Expandable: Specimen and Criteria -->
<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={() => toggleSpecimenExpand(index)}
>
<span class="flex items-center gap-2">
<Beaker class="w-3 h-3" />
<span class="text-xs">Specimen & Criteria</span>
{#if ref.SpcType || ref.Criteria}
<span class="text-xs text-primary">(Custom)</span>
{:else}
<span class="text-xs text-gray-500">(Optional)</span>
{/if}
</span>
{#if expandedRanges[index]?.specimen}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
{#if expandedRanges[index]?.specimen}
<div class="p-3 bg-base-100 space-y-3">
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Filter class="w-3 h-3" />
Criteria
</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}

View File

@ -19,6 +19,69 @@
onupdateFormData = () => {}
} = $props();
// Ensure all reference range items have defined values, never undefined
function normalizeRefNum(ref) {
return {
Sex: ref.Sex ?? '2',
LowSign: ref.LowSign ?? 'GE',
HighSign: ref.HighSign ?? 'LE',
Low: ref.Low ?? null,
High: ref.High ?? null,
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
Flag: ref.Flag ?? 'N',
Interpretation: ref.Interpretation ?? 'Normal',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
};
}
function normalizeRefThold(ref) {
return {
Sex: ref.Sex ?? '2',
LowSign: ref.LowSign ?? 'GE',
HighSign: ref.HighSign ?? 'LE',
Low: ref.Low ?? null,
High: ref.High ?? null,
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
Flag: ref.Flag ?? 'N',
Interpretation: ref.Interpretation ?? 'Normal',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
};
}
function normalizeRefTxt(ref) {
return {
Sex: ref.Sex ?? '2',
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
RefTxt: ref.RefTxt ?? '',
Flag: ref.Flag ?? 'N',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
};
}
function normalizeRefVset(ref) {
return {
Sex: ref.Sex ?? '2',
AgeStart: ref.AgeStart ?? 0,
AgeEnd: ref.AgeEnd ?? 120,
RefTxt: ref.RefTxt ?? '',
Flag: ref.Flag ?? 'N',
SpcType: ref.SpcType ?? '',
Criteria: ref.Criteria ?? ''
};
}
// Reactive normalized data
let normalizedRefnum = $derived((formData.refnum || []).map(normalizeRefNum));
let normalizedRefthold = $derived((formData.refthold || []).map(normalizeRefThold));
let normalizedReftxt = $derived((formData.reftxt || []).map(normalizeRefTxt));
let normalizedRefvset = $derived((formData.refvset || []).map(normalizeRefVset));
function updateRefRangeType(type) {
let newFormData = {
...formData,
@ -60,7 +123,7 @@
}
</script>
<div class="space-y-6">
<div class="space-y-3">
<!-- Reference Range Type Selection -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-3">
@ -159,21 +222,21 @@
<!-- Numeric Reference Ranges -->
{#if formData.refRangeType === 'num'}
<NumericRefRange refnum={formData.refnum} onupdateRefnum={updateRefnum} />
<NumericRefRange refnum={normalizedRefnum} onupdateRefnum={updateRefnum} />
{/if}
<!-- Threshold Reference Ranges -->
{#if formData.refRangeType === 'thold'}
<ThresholdRefRange refthold={formData.refthold} onupdateRefthold={updateRefthold} />
<ThresholdRefRange refthold={normalizedRefthold} onupdateRefthold={updateRefthold} />
{/if}
<!-- Text Reference Ranges -->
{#if formData.refRangeType === 'text'}
<TextRefRange reftxt={formData.reftxt} onupdateReftxt={updateReftxt} />
<TextRefRange reftxt={normalizedReftxt} onupdateReftxt={updateReftxt} />
{/if}
<!-- Value Set Reference Ranges -->
{#if formData.refRangeType === 'vset'}
<ValueSetRefRange refvset={formData.refvset} onupdateRefvset={updateRefvset} />
<ValueSetRefRange refvset={normalizedRefvset} onupdateRefvset={updateRefvset} />
{/if}
</div>

View File

@ -0,0 +1,265 @@
<script>
import { onMount } from 'svelte';
import { FlaskConical, Ruler, Clock, Beaker } from 'lucide-svelte';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
/**
* @typedef {Object} Props
* @property {Object} formData - Form data object
* @property {(formData: Object) => void} onupdateFormData - Update handler
*/
/** @type {Props} */
let {
formData = $bindable({}),
onupdateFormData = () => {}
} = $props();
// Value set options
let resultTypeOptions = $state([]);
let refTypeOptions = $state([]);
let loading = $state(true);
onMount(async () => {
try {
loading = true;
const [resultTypeRes, refTypeRes] = await Promise.all([
fetchValueSetByKey('result_type'),
fetchValueSetByKey('reference_type')
]);
console.log('result_type response:', resultTypeRes);
console.log('reference_type response:', refTypeRes);
// Handle different response structures
const resultItems = resultTypeRes.data?.items || resultTypeRes.data?.ValueSetItems || (Array.isArray(resultTypeRes.data) ? resultTypeRes.data : []) || [];
const refItems = refTypeRes.data?.items || refTypeRes.data?.ValueSetItems || (Array.isArray(refTypeRes.data) ? refTypeRes.data : []) || [];
resultTypeOptions = resultItems.map(item => ({
value: item.value || item.itemCode || item.ItemCode || item.code || item.Code,
label: item.label || item.itemValue || item.ItemValue || item.value || item.Value || item.description || item.Description
})).filter(opt => opt.value);
refTypeOptions = refItems.map(item => ({
value: item.value || item.itemCode || item.ItemCode || item.code || item.Code,
label: item.label || item.itemValue || item.ItemValue || item.value || item.Value || item.description || item.Description
})).filter(opt => opt.value);
console.log('resultTypeOptions:', resultTypeOptions);
console.log('refTypeOptions:', refTypeOptions);
} catch (err) {
console.error('Failed to load value sets:', err);
} finally {
loading = false;
}
});
function updateField(field, value) {
onupdateFormData({ ...formData, [field]: value });
}
</script>
{#if loading}
<div class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="space-y-3">
<!-- Result Configuration -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<FlaskConical class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Result Configuration</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="resultType">
<span class="label-text font-medium">Result Type</span>
</label>
<select
id="resultType"
class="select select-sm select-bordered w-full"
bind:value={formData.ResultType}
onchange={(e) => updateField('ResultType', e.target.value)}
>
<option value="">Select result type...</option>
{#each resultTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label" for="refType">
<span class="label-text font-medium">Reference Type</span>
</label>
<select
id="refType"
class="select select-sm select-bordered w-full"
bind:value={formData.RefType}
onchange={(e) => updateField('RefType', e.target.value)}
>
<option value="">Select reference type...</option>
{#each refTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
</div>
<!-- Units and Precision -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<Ruler class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Units and Precision</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label" for="unit1">
<span class="label-text font-medium">Unit 1</span>
</label>
<input
id="unit1"
type="text"
class="input input-sm input-bordered w-full"
value={formData.Unit1}
oninput={(e) => updateField('Unit1', e.target.value)}
placeholder="e.g., mg/dL"
/>
</div>
<div class="form-control">
<label class="label" for="factor">
<span class="label-text font-medium">Factor</span>
</label>
<input
id="factor"
type="number"
step="0.000001"
class="input input-sm input-bordered w-full"
value={formData.Factor}
oninput={(e) => updateField('Factor', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="Conversion factor"
/>
</div>
<div class="form-control">
<label class="label" for="unit2">
<span class="label-text font-medium">Unit 2</span>
</label>
<input
id="unit2"
type="text"
class="input input-sm input-bordered w-full"
value={formData.Unit2}
oninput={(e) => updateField('Unit2', e.target.value)}
placeholder="e.g., mmol/L"
/>
</div>
<div class="form-control">
<label class="label" for="decimal">
<span class="label-text font-medium">Decimal Places</span>
</label>
<input
id="decimal"
type="number"
min="0"
max="6"
class="input input-sm input-bordered w-full"
value={formData.Decimal}
oninput={(e) => updateField('Decimal', parseInt(e.target.value) || 0)}
placeholder="0-6"
/>
</div>
</div>
{#if formData.Factor}
<div class="mt-3 text-sm text-gray-600 bg-base-200 p-2 rounded">
Formula: {formData.Unit1 || 'Unit1'} × {formData.Factor} = {formData.Unit2 || 'Unit2'}
</div>
{/if}
</div>
<!-- Specimen Requirements -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<Beaker class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Specimen Requirements</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="reqQty">
<span class="label-text font-medium">Required Quantity</span>
</label>
<input
id="reqQty"
type="number"
step="0.01"
class="input input-sm input-bordered w-full"
value={formData.ReqQty}
oninput={(e) => updateField('ReqQty', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="Amount"
/>
</div>
<div class="form-control">
<label class="label" for="reqQtyUnit">
<span class="label-text font-medium">Quantity Unit</span>
</label>
<input
id="reqQtyUnit"
type="text"
class="input input-sm input-bordered w-full"
value={formData.ReqQtyUnit}
oninput={(e) => updateField('ReqQtyUnit', e.target.value)}
placeholder="e.g., mL"
/>
</div>
</div>
</div>
<!-- Method and TAT -->
<div class="bg-base-100 rounded-lg border border-base-200 p-4">
<div class="flex items-center gap-2 mb-4">
<Clock class="w-5 h-5 text-primary" />
<h3 class="font-semibold">Method and Turnaround</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="method">
<span class="label-text font-medium">Method</span>
</label>
<input
id="method"
type="text"
class="input input-sm input-bordered w-full"
value={formData.Method}
oninput={(e) => updateField('Method', e.target.value)}
placeholder="e.g., Enzymatic"
/>
</div>
<div class="form-control">
<label class="label" for="expectedTAT">
<span class="label-text font-medium">Expected TAT (minutes)</span>
</label>
<input
id="expectedTAT"
type="number"
min="0"
class="input input-sm input-bordered w-full"
value={formData.ExpectedTAT}
oninput={(e) => updateField('ExpectedTAT', e.target.value ? parseInt(e.target.value) : null)}
placeholder="e.g., 60"
/>
</div>
</div>
</div>
</div>
{/if}

View File

@ -1,6 +1,8 @@
<script>
import { PlusCircle, FileText, X } from 'lucide-svelte';
import { PlusCircle, FileText, X, ChevronDown, ChevronUp, Beaker, Filter } from 'lucide-svelte';
import { sexOptions, createTextRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
@ -14,17 +16,38 @@
onupdateReftxt = () => {}
} = $props();
let expandedRanges = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createTextRef();
onupdateReftxt([...reftxt, newRef]);
expandedRanges[reftxt.length] = { specimen: false };
}
function removeRefRange(index) {
onupdateReftxt(reftxt.filter((_, i) => i !== index));
delete expandedRanges[index];
}
function toggleSpecimenExpand(index) {
expandedRanges[index] = { ...expandedRanges[index], specimen: !expandedRanges[index]?.specimen };
}
</script>
<div class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<FileText class="w-5 h-5 text-primary" />
@ -91,6 +114,59 @@
<span class="label-text text-xs mb-1">Reference Text</span>
<textarea class="textarea textarea-bordered w-full" rows="2" bind:value={ref.RefTxt} placeholder="e.g., Negative for glucose"></textarea>
</div>
<!-- Expandable: Specimen and Criteria -->
<div class="border border-base-200 rounded-lg overflow-hidden mt-3">
<button
type="button"
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
onclick={() => toggleSpecimenExpand(index)}
>
<span class="flex items-center gap-2">
<Beaker class="w-3 h-3" />
<span class="text-xs">Specimen & Criteria</span>
{#if ref.SpcType || ref.Criteria}
<span class="text-xs text-primary">(Custom)</span>
{:else}
<span class="text-xs text-gray-500">(Optional)</span>
{/if}
</span>
{#if expandedRanges[index]?.specimen}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
{#if expandedRanges[index]?.specimen}
<div class="p-3 bg-base-100 space-y-3">
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Filter class="w-3 h-3" />
Criteria
</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}

View File

@ -1,6 +1,8 @@
<script>
import { PlusCircle, Ruler, X } from 'lucide-svelte';
import { PlusCircle, Ruler, X, ChevronDown, ChevronUp, Beaker, Filter } from 'lucide-svelte';
import { signOptions, flagOptions, sexOptions, createTholdRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
@ -14,17 +16,38 @@
onupdateRefthold = () => {}
} = $props();
let expandedRanges = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createTholdRef();
onupdateRefthold([...refthold, newRef]);
expandedRanges[refthold.length] = { specimen: false };
}
function removeRefRange(index) {
onupdateRefthold(refthold.filter((_, i) => i !== index));
delete expandedRanges[index];
}
function toggleSpecimenExpand(index) {
expandedRanges[index] = { ...expandedRanges[index], specimen: !expandedRanges[index]?.specimen };
}
</script>
<div class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<Ruler class="w-5 h-5 text-primary" />
@ -118,6 +141,59 @@
<span class="label-text text-xs mb-1">Interpretation</span>
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.Interpretation} placeholder="e.g., Alert threshold" />
</div>
<!-- Expandable: Specimen and Criteria -->
<div class="border border-base-200 rounded-lg overflow-hidden mt-3">
<button
type="button"
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
onclick={() => toggleSpecimenExpand(index)}
>
<span class="flex items-center gap-2">
<Beaker class="w-3 h-3" />
<span class="text-xs">Specimen & Criteria</span>
{#if ref.SpcType || ref.Criteria}
<span class="text-xs text-primary">(Custom)</span>
{:else}
<span class="text-xs text-gray-500">(Optional)</span>
{/if}
</span>
{#if expandedRanges[index]?.specimen}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
{#if expandedRanges[index]?.specimen}
<div class="p-3 bg-base-100 space-y-3">
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Filter class="w-3 h-3" />
Criteria
</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}

View File

@ -1,6 +1,8 @@
<script>
import { PlusCircle, Box, X } from 'lucide-svelte';
import { PlusCircle, Box, X, ChevronDown, ChevronUp, Beaker, Filter } from 'lucide-svelte';
import { sexOptions, createVsetRef } from '../referenceRange.js';
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
import { onMount } from 'svelte';
/**
* @typedef {Object} Props
@ -14,17 +16,38 @@
onupdateRefvset = () => {}
} = $props();
let expandedRanges = $state({});
let specimenTypeOptions = $state([]);
onMount(async () => {
try {
const response = await fetchValueSetByKey('specimen_type');
specimenTypeOptions = response.data?.items?.map(item => ({
value: item.itemCode,
label: item.itemValue
})) || [];
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function addRefRange() {
const newRef = createVsetRef();
onupdateRefvset([...refvset, newRef]);
expandedRanges[refvset.length] = { specimen: false };
}
function removeRefRange(index) {
onupdateRefvset(refvset.filter((_, i) => i !== index));
delete expandedRanges[index];
}
function toggleSpecimenExpand(index) {
expandedRanges[index] = { ...expandedRanges[index], specimen: !expandedRanges[index]?.specimen };
}
</script>
<div class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<Box class="w-5 h-5 text-primary" />
@ -89,9 +112,62 @@
<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" />
<input type="text" class="input input-sm input-bordered w-full" bind:value={ref.RefTxt} placeholder="e.g., Positive, Negative, Borderline" />
<span class="label-text-alt text-gray-500 mt-1">Comma-separated list of allowed values</span>
</div>
<!-- Expandable: Specimen and Criteria -->
<div class="border border-base-200 rounded-lg overflow-hidden mt-3">
<button
type="button"
class="w-full px-3 py-2 bg-base-200 hover:bg-base-300 flex items-center justify-between text-sm transition-colors"
onclick={() => toggleSpecimenExpand(index)}
>
<span class="flex items-center gap-2">
<Beaker class="w-3 h-3" />
<span class="text-xs">Specimen & Criteria</span>
{#if ref.SpcType || ref.Criteria}
<span class="text-xs text-primary">(Custom)</span>
{:else}
<span class="text-xs text-gray-500">(Optional)</span>
{/if}
</span>
{#if expandedRanges[index]?.specimen}
<ChevronUp class="w-4 h-4" />
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
</button>
{#if expandedRanges[index]?.specimen}
<div class="p-3 bg-base-100 space-y-3">
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Beaker class="w-3 h-3" />
Specimen Type
</span>
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
<option value="">Any specimen</option>
{#each specimenTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<span class="label-text text-xs mb-1 flex items-center gap-1">
<Filter class="w-3 h-3" />
Criteria
</span>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={ref.Criteria}
placeholder="e.g., Fasting, Morning sample"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}

View File

@ -210,7 +210,7 @@
<p class="text-gray-500 mt-4">Loading ValueSet details...</p>
</div>
{:else if selectedValueSet}
<div class="space-y-6">
<div class="space-y-3">
<!-- Header -->
<div class="border-b border-base-300 pb-4">
<h2 class="text-2xl font-bold text-gray-800">{selectedValueSet.Name}</h2>

View File

@ -368,7 +368,7 @@
<p class="text-sm">This patient has no visit records.</p>
</div>
{:else}
<div class="space-y-4">
<div class="space-y-3">
{#each visits as visit}
<div class="card bg-base-100 shadow border border-base-200 hover:shadow-md transition-shadow">
<div class="card-body p-4">
@ -512,7 +512,7 @@
</Modal>
<Modal bind:open={dischargeModalOpen} title="Discharge Patient" size="sm">
<div class="py-2 space-y-4">
<div class="py-2 space-y-3">
<p>Discharge patient <strong>{selectedPatient ? [selectedPatient.Prefix, selectedPatient.NameFirst, selectedPatient.NameMiddle, selectedPatient.NameLast].filter(Boolean).join(' ') : ''}</strong></p>
<p class="text-sm text-gray-600">Visit ID: {visitToDischarge?.PVID || visitToDischarge?.InternalPVID}</p>
@ -524,7 +524,7 @@
<input
id="dischargeDate"
type="datetime-local"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={dischargeDate}
/>
</div>

View File

@ -267,7 +267,7 @@
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else}
<form class="space-y-6" onsubmit={(e) => e.preventDefault()}>
<form class="space-y-3" onsubmit={(e) => e.preventDefault()}>
<!-- DaisyUI Tabs -->
<div class="tabs tabs-bordered">
<button
@ -290,7 +290,7 @@
<!-- Personal Info Tab (Basic + Demographics) -->
{#if activeTab === 'personal'}
<div class="space-y-6">
<div class="space-y-3">
<!-- Basic Info Section -->
<div>
<h4 class="font-medium text-gray-700 mb-4">Basic Information</h4>
@ -303,7 +303,7 @@
<input
id="patientId"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.PatientID}
bind:value={formData.PatientID}
placeholder="Enter patient ID"
@ -332,7 +332,7 @@
<input
id="nameFirst"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.NameFirst}
bind:value={formData.NameFirst}
placeholder="Enter first name"
@ -349,7 +349,7 @@
<input
id="nameMiddle"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.NameMiddle}
placeholder="Enter middle name"
/>
@ -362,7 +362,7 @@
<input
id="nameLast"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.NameLast}
placeholder="Enter last name"
/>
@ -377,7 +377,7 @@
<input
id="nameMaiden"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.NameMaiden}
placeholder="Enter maiden name"
/>
@ -390,7 +390,7 @@
<input
id="suffix"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Suffix}
placeholder="Enter suffix (e.g., Jr, Sr, III)"
/>
@ -416,7 +416,7 @@
<input
id="birthdate"
type="date"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.Birthdate}
bind:value={formData.Birthdate}
/>
@ -432,7 +432,7 @@
<input
id="placeOfBirth"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.PlaceOfBirth}
placeholder="Enter place of birth"
/>
@ -485,7 +485,7 @@
<input
id="citizenship"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Citizenship}
placeholder="Enter citizenship"
/>
@ -497,7 +497,7 @@
<!-- Location & Contact Tab -->
{#if activeTab === 'location'}
<div class="space-y-6">
<div class="space-y-3">
<!-- Address Section -->
<div>
<h4 class="font-medium text-gray-700 mb-4">Address Information</h4>
@ -509,7 +509,7 @@
<input
id="street1"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Street_1}
placeholder="Enter street address"
/>
@ -522,7 +522,7 @@
<input
id="street2"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Street_2}
placeholder="Enter street address line 2"
/>
@ -535,7 +535,7 @@
<input
id="street3"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Street_3}
placeholder="Enter street address line 3"
/>
@ -566,7 +566,7 @@
<input
id="zip"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.ZIP}
placeholder="Enter ZIP code"
/>
@ -596,7 +596,7 @@
<input
id="phone"
type="tel"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.Phone}
placeholder="Enter phone number"
/>
@ -609,7 +609,7 @@
<input
id="mobilePhone"
type="tel"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.MobilePhone}
placeholder="Enter mobile number"
/>
@ -624,7 +624,7 @@
<input
id="email1"
type="email"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.EmailAddress1}
placeholder="Enter email address"
/>
@ -637,7 +637,7 @@
<input
id="email2"
type="email"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.EmailAddress2}
placeholder="Enter secondary email"
/>

View File

@ -197,7 +197,7 @@
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else}
<form class="space-y-6" onsubmit={(e) => e.preventDefault()}>
<form class="space-y-3" onsubmit={(e) => e.preventDefault()}>
<!-- Patient Info Display -->
{#if patient}
<div class="p-4 bg-base-200 rounded-lg">
@ -233,7 +233,7 @@
<!-- Tab: Visit Info -->
{#if activeTab === 'info'}
<div class="space-y-4">
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#if isEdit}
<div class="form-control">
@ -243,7 +243,7 @@
<input
id="pvid"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.PVID}
placeholder="Enter visit ID"
/>
@ -258,7 +258,7 @@
<input
id="patientId"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.PatientID}
bind:value={formData.PatientID}
placeholder="Enter patient ID"
@ -277,7 +277,7 @@
<input
id="visitDate"
type="datetime-local"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.PVCreateDate}
bind:value={formData.PVCreateDate}
/>
@ -350,7 +350,7 @@
<!-- Tab: Diagnosis & Status -->
{#if activeTab === 'diagnosis'}
<div class="space-y-4">
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="diagCode">
@ -359,7 +359,7 @@
<input
id="diagCode"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.DiagCode}
placeholder="Enter diagnosis code"
/>
@ -389,7 +389,7 @@
<input
id="endDate"
type="datetime-local"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.EndDate}
/>
</div>
@ -401,7 +401,7 @@
<input
id="archivedDate"
type="datetime-local"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.ArchivedDate}
/>
</div>

View File

@ -284,7 +284,7 @@
<p class="text-sm">This patient has no visit records.</p>
</div>
{:else}
<div class="space-y-4">
<div class="space-y-3">
{#each visits as visit}
<div class="card bg-base-100 shadow border border-base-200 hover:shadow-md transition-shadow">
<div class="card-body p-4">
@ -383,7 +383,7 @@
</div>
</div>
<form class="space-y-6" onsubmit={(e) => e.preventDefault()}>
<form class="space-y-3" onsubmit={(e) => e.preventDefault()}>
<!-- Tabs -->
<div class="tabs tabs-bordered">
<button
@ -406,7 +406,7 @@
<!-- Tab: Visit Info -->
{#if activeTab === 'info'}
<div class="space-y-4">
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="patientId">
@ -416,7 +416,7 @@
<input
id="patientId"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.PatientID}
bind:value={formData.PatientID}
placeholder="Enter patient ID"
@ -435,7 +435,7 @@
<input
id="visitDate"
type="datetime-local"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.PVCreateDate}
bind:value={formData.PVCreateDate}
/>
@ -508,7 +508,7 @@
<!-- Tab: Diagnosis & Status -->
{#if activeTab === 'diagnosis'}
<div class="space-y-4">
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="diagCode">
@ -517,7 +517,7 @@
<input
id="diagCode"
type="text"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.DiagCode}
placeholder="Enter diagnosis code"
/>
@ -547,7 +547,7 @@
<input
id="endDate"
type="datetime-local"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.EndDate}
/>
</div>
@ -559,7 +559,7 @@
<input
id="archivedDate"
type="datetime-local"
class="input input-bordered w-full"
class="input input-sm input-bordered w-full"
bind:value={formData.ArchivedDate}
/>
</div>