feat(tests): add modals directory and update test management components
This commit is contained in:
parent
99d622ad05
commit
52d4fc7322
File diff suppressed because it is too large
Load Diff
@ -108,7 +108,7 @@ function buildPayload(formData, isUpdate = false) {
|
||||
RangeType: r.RangeType,
|
||||
Sex: r.Sex,
|
||||
AgeStart: parseInt(r.AgeStart) || 0,
|
||||
AgeEnd: parseInt(r.AgeEnd) || 150,
|
||||
AgeEnd: parseInt(r.AgeEnd) || 54750, // 150 years in days
|
||||
LowSign: r.LowSign || null,
|
||||
Low: r.Low !== null && r.Low !== undefined ? parseFloat(r.Low) : null,
|
||||
HighSign: r.HighSign || null,
|
||||
@ -123,7 +123,7 @@ function buildPayload(formData, isUpdate = false) {
|
||||
TxtRefType: r.TxtRefType,
|
||||
Sex: r.Sex,
|
||||
AgeStart: parseInt(r.AgeStart) || 0,
|
||||
AgeEnd: parseInt(r.AgeEnd) || 150,
|
||||
AgeEnd: parseInt(r.AgeEnd) || 54750, // 150 years in days
|
||||
RefTxt: r.RefTxt || '',
|
||||
Flag: r.Flag || null
|
||||
}));
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import TestFormModal from './test-modal/TestFormModal.svelte';
|
||||
import TestTypePickerModal from './test-modal/modals/TestTypePickerModal.svelte';
|
||||
import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
@ -16,9 +17,11 @@
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let selectedTestId = $state(null);
|
||||
let selectedTestType = $state('TEST');
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
let deleting = $state(false);
|
||||
let testTypePickerOpen = $state(false);
|
||||
|
||||
const testTypeConfig = {
|
||||
TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' },
|
||||
@ -86,6 +89,11 @@
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
testTypePickerOpen = true;
|
||||
}
|
||||
|
||||
function handleTestTypeSelect(type) {
|
||||
selectedTestType = type;
|
||||
modalMode = 'create';
|
||||
selectedTestId = null;
|
||||
modalOpen = true;
|
||||
@ -222,10 +230,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TestTypePickerModal
|
||||
bind:open={testTypePickerOpen}
|
||||
onselect={handleTestTypeSelect}
|
||||
/>
|
||||
|
||||
<TestFormModal
|
||||
bind:open={modalOpen}
|
||||
mode={modalMode}
|
||||
testId={selectedTestId}
|
||||
initialTestType={selectedTestType}
|
||||
{disciplines}
|
||||
{departments}
|
||||
{tests}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { Info, Settings, Calculator, Users, Link } from 'lucide-svelte';
|
||||
import { Info, Settings, Calculator, Users, Link, Hash, Type } from 'lucide-svelte';
|
||||
import { fetchTest, createTest, updateTest, validateTestCode, validateTestName } from '$lib/api/tests.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -9,8 +9,10 @@
|
||||
import CalcDetailsTab from './tabs/CalcDetailsTab.svelte';
|
||||
import GroupMembersTab from './tabs/GroupMembersTab.svelte';
|
||||
import MappingsTab from './tabs/MappingsTab.svelte';
|
||||
import RefNumTab from './tabs/RefNumTab.svelte';
|
||||
import RefTxtTab from './tabs/RefTxtTab.svelte';
|
||||
|
||||
let { open = $bindable(false), mode = 'create', testId = null, disciplines = [], departments = [], tests = [], onsave = null } = $props();
|
||||
let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props();
|
||||
|
||||
let currentTab = $state('basic');
|
||||
let loading = $state(false);
|
||||
@ -30,16 +32,29 @@
|
||||
{ id: 'tech', label: 'Tech Details', component: Settings },
|
||||
{ id: 'calc', label: 'Calculations', component: Calculator },
|
||||
{ id: 'group', label: 'Group Members', component: Users },
|
||||
{ id: 'mappings', label: 'Mappings', component: Link }
|
||||
{ id: 'mappings', label: 'Mappings', component: Link },
|
||||
{ id: 'refnum', label: 'Num Refs', component: Hash },
|
||||
{ id: 'reftxt', label: 'Txt Refs', component: Type }
|
||||
];
|
||||
|
||||
const visibleTabs = $derived.by(() => {
|
||||
const type = formData.TestType;
|
||||
const resultType = formData.details?.ResultType;
|
||||
const refType = formData.details?.RefType;
|
||||
|
||||
return tabConfig.filter(tab => {
|
||||
if (tab.id === 'basic' || tab.id === 'mappings') return true;
|
||||
if (tab.id === 'tech') return ['TEST', 'PARAM', 'CALC'].includes(type);
|
||||
if (tab.id === 'calc') return type === 'CALC';
|
||||
if (tab.id === 'group') return type === 'GROUP';
|
||||
if (tab.id === 'refnum') {
|
||||
// Show for TEST/PARAM with numeric result types and RANGE/THOLD ref types
|
||||
return ['TEST', 'PARAM'].includes(type) && ['NMRIC', 'RANGE'].includes(resultType) && ['RANGE', 'THOLD'].includes(refType);
|
||||
}
|
||||
if (tab.id === 'reftxt') {
|
||||
// Show for TEST/PARAM with TEXT result type
|
||||
return ['TEST', 'PARAM'].includes(type) && resultType === 'TEXT' && refType === 'TEXT';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
@ -84,7 +99,7 @@
|
||||
TestSiteID: null,
|
||||
TestSiteCode: '',
|
||||
TestSiteName: '',
|
||||
TestType: 'TEST',
|
||||
TestType: initialTestType,
|
||||
Description: '',
|
||||
SiteID: 1,
|
||||
SeqScr: 0,
|
||||
@ -220,7 +235,7 @@
|
||||
if (!codeResult.valid) errors.TestSiteCode = codeResult.error;
|
||||
|
||||
const nameResult = validateTestName(formData.TestSiteName);
|
||||
if (!nameResult.valid) errors.TestSiteName = nameResult.valid;
|
||||
if (!nameResult.valid) errors.TestSiteName = nameResult.error;
|
||||
|
||||
if (!formData.TestType) {
|
||||
errors.TestType = 'Test type is required';
|
||||
@ -288,7 +303,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col" style="height: 60vh; max-height: 500px;">
|
||||
<div class="flex flex-col" style="height: 75vh; max-height: 650px;">
|
||||
<!-- Top Tabs -->
|
||||
<div class="border-b border-base-200 bg-base-50">
|
||||
<div class="flex overflow-x-auto">
|
||||
@ -320,6 +335,7 @@
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
onTypeChange={handleTypeChange}
|
||||
{mode}
|
||||
/>
|
||||
{:else if currentTab === 'tech'}
|
||||
<TechDetailsTab
|
||||
@ -327,6 +343,7 @@
|
||||
{disciplines}
|
||||
{departments}
|
||||
bind:isDirty
|
||||
onSwitchTab={handleTabChange}
|
||||
/>
|
||||
{:else if currentTab === 'calc'}
|
||||
<CalcDetailsTab
|
||||
@ -346,6 +363,16 @@
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
/>
|
||||
{:else if currentTab === 'refnum'}
|
||||
<RefNumTab
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
/>
|
||||
{:else if currentTab === 'reftxt'}
|
||||
<RefTxtTab
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import { Microscope, Variable, Calculator, Box, Layers } from 'lucide-svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
let { open = $bindable(false), onselect = null } = $props();
|
||||
|
||||
const testTypes = [
|
||||
{ value: 'TEST', label: 'Test', description: 'Single Test', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' },
|
||||
{ value: 'PARAM', label: 'Parameter', description: 'Test Parameter', icon: Variable, color: '#3399FF', bgColor: '#F0F8FF' },
|
||||
{ value: 'CALC', label: 'Calculated', description: 'Formula-based Test', icon: Calculator, color: '#9933CC', bgColor: '#F5E6FF' },
|
||||
{ value: 'GROUP', label: 'Panel', description: 'Test Group/Panel', icon: Box, color: '#00AA44', bgColor: '#E6F9EE' },
|
||||
{ value: 'TITLE', label: 'Header', description: 'Section Header', icon: Layers, color: '#666666', bgColor: '#F5F5F5' }
|
||||
];
|
||||
|
||||
function handleSelect(type) {
|
||||
if (onselect) {
|
||||
onselect(type.value);
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open size="md" title="Select Test Type">
|
||||
<div class="py-4">
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
Choose the type of test you want to create. This determines the available options and configuration.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
{#each testTypes as type}
|
||||
{@const IconComponent = type.icon}
|
||||
<button
|
||||
class="flex items-center gap-4 p-4 rounded-lg border-2 transition-all duration-200 text-left hover:shadow-md"
|
||||
style="background-color: {type.bgColor}; border-color: {type.color};"
|
||||
onclick={() => handleSelect(type)}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-12 h-12 rounded-lg"
|
||||
style="background-color: {type.color};"
|
||||
>
|
||||
<IconComponent class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-base" style="color: {type.color};">
|
||||
{type.label}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{type.description}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={handleClose} type="button">Cancel</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@ -2,7 +2,9 @@
|
||||
import { validateTestCode, validateTestName } from '$lib/api/tests.js';
|
||||
import { AlertCircle } from 'lucide-svelte';
|
||||
|
||||
let { formData = $bindable(), isDirty = $bindable(false), onTypeChange = null } = $props();
|
||||
let { formData = $bindable(), isDirty = $bindable(false), mode = 'create' } = $props();
|
||||
|
||||
const isEditMode = $derived(mode === 'edit');
|
||||
|
||||
let validationErrors = $state({
|
||||
TestSiteCode: '',
|
||||
@ -11,11 +13,11 @@
|
||||
});
|
||||
|
||||
const testTypes = [
|
||||
{ value: 'TEST', label: 'Test - Single Test' },
|
||||
{ value: 'PARAM', label: 'Parameter - Test Parameter' },
|
||||
{ value: 'CALC', label: 'Calculated - Formula-based' },
|
||||
{ value: 'GROUP', label: 'Panel - Test Group' },
|
||||
{ value: 'TITLE', label: 'Header - Section Header' }
|
||||
{ value: 'TEST', label: 'Test', description: 'Single Test' },
|
||||
{ value: 'PARAM', label: 'Parameter', description: 'Test Parameter' },
|
||||
{ value: 'CALC', label: 'Calculated', description: 'Formula-based' },
|
||||
{ value: 'GROUP', label: 'Panel', description: 'Test Group' },
|
||||
{ value: 'TITLE', label: 'Header', description: 'Section Header' }
|
||||
];
|
||||
|
||||
function validateField(field) {
|
||||
@ -77,18 +79,42 @@
|
||||
handleFieldChange();
|
||||
validateField('TestSiteName');
|
||||
}
|
||||
|
||||
function handleTestTypeChange(event) {
|
||||
const newType = event.target.value;
|
||||
if (onTypeChange) {
|
||||
onTypeChange(newType);
|
||||
}
|
||||
handleFieldChange();
|
||||
validateField('TestType');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Test Type Header -->
|
||||
<div class="bg-base-100 border border-base-200 rounded-lg p-4">
|
||||
{#if isEditMode}
|
||||
{@const selectedType = testTypes.find(t => t.value === formData.TestType)}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-lg font-bold text-primary">{selectedType?.label?.[0] || '?'}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base">{selectedType?.label || 'Unknown'}</span>
|
||||
<span class="text-xs text-gray-500">({selectedType?.description || 'Unknown'})</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">Test type cannot be changed after creation</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@const selectedType = testTypes.find(t => t.value === formData.TestType)}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-lg font-bold text-primary">{selectedType?.label?.[0] || '?'}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base">{selectedType?.label || 'Unknown'}</span>
|
||||
<span class="text-xs text-gray-500">({selectedType?.description || 'Unknown'})</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{formData.TestType}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Test Identity -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Test Identity</h3>
|
||||
@ -142,49 +168,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classification -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Classification</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Test Type -->
|
||||
<div class="space-y-1">
|
||||
<label for="testType" class="block text-sm font-medium text-gray-700">
|
||||
Test Type <span class="text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="testType"
|
||||
class="select select-sm select-bordered w-full"
|
||||
class:select-error={validationErrors.TestType}
|
||||
bind:value={formData.TestType}
|
||||
onchange={handleTestTypeChange}
|
||||
>
|
||||
{#each testTypes as type (type.value)}
|
||||
<option value={type.value}>{type.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if validationErrors.TestType}
|
||||
<span class="text-xs text-error flex items-center gap-1">
|
||||
<AlertCircle class="w-3 h-3" />
|
||||
{validationErrors.TestType}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-1">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<input
|
||||
id="description"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.Description}
|
||||
placeholder="Optional description..."
|
||||
maxlength="500"
|
||||
oninput={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<!-- Description - moved under Test Identity -->
|
||||
<div class="mt-4 space-y-1">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<input
|
||||
id="description"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.Description}
|
||||
placeholder="Optional description..."
|
||||
maxlength="500"
|
||||
oninput={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,39 +1,24 @@
|
||||
<script>
|
||||
import { Plus, Trash2, Hash, Calculator, Edit2 } from 'lucide-svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let selectedRangeIndex = $state(null);
|
||||
let validationError = $state('');
|
||||
|
||||
let editingRange = $state({
|
||||
NumRefType: 'REF',
|
||||
// Simple inline form state - only Low/High visible, rest collapsed
|
||||
let simpleRefNum = $state({
|
||||
Low: '',
|
||||
High: '',
|
||||
RangeType: 'RANGE',
|
||||
Sex: '0',
|
||||
AgeStart: 0,
|
||||
AgeEnd: 150,
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years',
|
||||
LowSign: 'GE',
|
||||
Low: null,
|
||||
HighSign: 'LE',
|
||||
High: null,
|
||||
Flag: 'N',
|
||||
Interpretation: ''
|
||||
HighSign: 'LE'
|
||||
});
|
||||
|
||||
const refTypes = [
|
||||
{ value: 'REF', label: 'Reference', desc: 'Normal reference range' },
|
||||
{ value: 'CRTC', label: 'Critical', desc: 'Critical value range' },
|
||||
{ value: 'VAL', label: 'Validation', desc: 'Validation check range' },
|
||||
{ value: 'RERUN', label: 'Rerun', desc: 'Auto-rerun condition' }
|
||||
];
|
||||
|
||||
const rangeTypes = [
|
||||
{ value: 'RANGE', label: 'Range', desc: 'Low to High range' },
|
||||
{ value: 'THOLD', label: 'Threshold', desc: 'Single threshold value' }
|
||||
];
|
||||
let validationErrors = $state({
|
||||
simple: ''
|
||||
});
|
||||
|
||||
const sexOptions = [
|
||||
{ value: '0', label: 'All' },
|
||||
@ -49,133 +34,114 @@
|
||||
{ value: 'GE', label: '≥' }
|
||||
];
|
||||
|
||||
const flagOptions = [
|
||||
{ value: 'N', label: 'N - Normal' },
|
||||
{ value: 'H', label: 'H - High' },
|
||||
{ value: 'L', label: 'L - Low' },
|
||||
{ value: 'A', label: 'A - Abnormal' },
|
||||
{ value: 'C', label: 'C - Critical' }
|
||||
const ageUnits = [
|
||||
{ value: 'days', label: 'Days' },
|
||||
{ value: 'weeks', label: 'Weeks' },
|
||||
{ value: 'months', label: 'Months' },
|
||||
{ value: 'years', label: 'Years' }
|
||||
];
|
||||
|
||||
function handleFieldChange() {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the current reference range
|
||||
* @returns {{ valid: boolean, error?: string }}
|
||||
*/
|
||||
function validateRange() {
|
||||
// Age validation per spec: AgeStart < AgeEnd
|
||||
if (parseInt(editingRange.AgeStart) >= parseInt(editingRange.AgeEnd)) {
|
||||
return { valid: false, error: 'Age start must be less than age end' };
|
||||
// Convert age to days for storage
|
||||
function convertAgeToDays(value, unit) {
|
||||
if (!value && value !== 0) return null;
|
||||
const num = parseInt(value);
|
||||
switch (unit) {
|
||||
case 'days': return num;
|
||||
case 'weeks': return num * 7;
|
||||
case 'months': return num * 30;
|
||||
case 'years': return num * 365;
|
||||
default: return num;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert days to display unit
|
||||
function convertDaysToUnit(days, unit) {
|
||||
if (days === null || days === undefined) return '';
|
||||
switch (unit) {
|
||||
case 'days': return days;
|
||||
case 'weeks': return Math.floor(days / 7);
|
||||
case 'months': return Math.floor(days / 30);
|
||||
case 'years': return Math.floor(days / 365);
|
||||
default: return days;
|
||||
}
|
||||
}
|
||||
|
||||
function resetSimpleRefNum() {
|
||||
simpleRefNum = {
|
||||
Low: '',
|
||||
High: '',
|
||||
RangeType: formData.details?.RefType === 'THOLD' ? 'THOLD' : 'RANGE',
|
||||
Sex: '0',
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years',
|
||||
LowSign: 'GE',
|
||||
HighSign: 'LE'
|
||||
};
|
||||
validationErrors.simple = '';
|
||||
}
|
||||
|
||||
function validateSimpleRefNum() {
|
||||
if (!simpleRefNum.Low && !simpleRefNum.High) {
|
||||
return { valid: false, error: 'Please enter at least one value' };
|
||||
}
|
||||
|
||||
// Age range validation per spec: 0-150
|
||||
if (parseInt(editingRange.AgeStart) < 0 || parseInt(editingRange.AgeStart) > 150) {
|
||||
return { valid: false, error: 'Age start must be between 0 and 150' };
|
||||
}
|
||||
if (parseInt(editingRange.AgeEnd) < 0 || parseInt(editingRange.AgeEnd) > 150) {
|
||||
return { valid: false, error: 'Age end must be between 0 and 150' };
|
||||
}
|
||||
|
||||
// Value validation per spec: If both Low and High present, Low < High
|
||||
const low = editingRange.Low !== null && editingRange.Low !== '' ? parseFloat(editingRange.Low) : null;
|
||||
const high = editingRange.High !== null && editingRange.High !== '' ? parseFloat(editingRange.High) : null;
|
||||
const low = simpleRefNum.Low !== '' ? parseFloat(simpleRefNum.Low) : null;
|
||||
const high = simpleRefNum.High !== '' ? parseFloat(simpleRefNum.High) : null;
|
||||
|
||||
if (low !== null && high !== null && low >= high) {
|
||||
return { valid: false, error: 'Low value must be less than high value' };
|
||||
}
|
||||
|
||||
// Sign appropriateness validation per spec
|
||||
if (low !== null) {
|
||||
const validLowSigns = ['EQ', 'GE', 'GT'];
|
||||
if (!validLowSigns.includes(editingRange.LowSign)) {
|
||||
return { valid: false, error: 'Low sign should be =, ≥, or > for low bounds' };
|
||||
// Validate age range if provided
|
||||
if (simpleRefNum.AgeStart !== '' || simpleRefNum.AgeEnd !== '') {
|
||||
const ageStart = simpleRefNum.AgeStart !== '' ? parseInt(simpleRefNum.AgeStart) : null;
|
||||
const ageEnd = simpleRefNum.AgeEnd !== '' ? parseInt(simpleRefNum.AgeEnd) : null;
|
||||
|
||||
if (ageStart !== null && ageEnd !== null && ageStart >= ageEnd) {
|
||||
return { valid: false, error: 'Age start must be less than age end' };
|
||||
}
|
||||
}
|
||||
|
||||
if (high !== null) {
|
||||
const validHighSigns = ['EQ', 'LE', 'LT'];
|
||||
if (!validHighSigns.includes(editingRange.HighSign)) {
|
||||
return { valid: false, error: 'High sign should be =, ≤, or < for high bounds' };
|
||||
}
|
||||
}
|
||||
|
||||
// For THOLD type, validate that at least one bound is set
|
||||
if (editingRange.RangeType === 'THOLD' && low === null && high === null) {
|
||||
return { valid: false, error: 'Threshold requires at least one bound' };
|
||||
}
|
||||
|
||||
// Flag validation per spec: single character
|
||||
if (editingRange.Flag && editingRange.Flag.length !== 1) {
|
||||
return { valid: false, error: 'Flag must be a single character' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function openAddRange() {
|
||||
modalMode = 'create';
|
||||
selectedRangeIndex = null;
|
||||
validationError = '';
|
||||
editingRange = {
|
||||
NumRefType: 'REF',
|
||||
RangeType: 'RANGE',
|
||||
Sex: '0',
|
||||
AgeStart: 0,
|
||||
AgeEnd: 150,
|
||||
LowSign: 'GE',
|
||||
Low: null,
|
||||
HighSign: 'LE',
|
||||
High: null,
|
||||
Flag: 'N',
|
||||
Interpretation: ''
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
function addSimpleRefNum() {
|
||||
const validation = validateSimpleRefNum();
|
||||
if (!validation.valid) {
|
||||
validationErrors.simple = validation.error;
|
||||
return;
|
||||
}
|
||||
|
||||
function openEditRange(index) {
|
||||
modalMode = 'edit';
|
||||
selectedRangeIndex = index;
|
||||
validationError = '';
|
||||
editingRange = { ...formData.refnum[index] };
|
||||
modalOpen = true;
|
||||
const newRef = {
|
||||
NumRefType: 'REF',
|
||||
RangeType: simpleRefNum.RangeType,
|
||||
Sex: simpleRefNum.Sex,
|
||||
AgeStart: convertAgeToDays(simpleRefNum.AgeStart, simpleRefNum.AgeUnit) || 0,
|
||||
AgeEnd: convertAgeToDays(simpleRefNum.AgeEnd, simpleRefNum.AgeUnit) || 54750,
|
||||
Low: simpleRefNum.Low !== '' ? parseFloat(simpleRefNum.Low) : null,
|
||||
High: simpleRefNum.High !== '' ? parseFloat(simpleRefNum.High) : null,
|
||||
LowSign: simpleRefNum.LowSign,
|
||||
HighSign: simpleRefNum.HighSign,
|
||||
Flag: null,
|
||||
Interpretation: null
|
||||
};
|
||||
|
||||
formData.refnum = [...(formData.refnum || []), newRef];
|
||||
resetSimpleRefNum();
|
||||
handleFieldChange();
|
||||
}
|
||||
|
||||
function removeRange(index) {
|
||||
if (!confirm('Are you sure you want to delete this reference range?')) {
|
||||
return;
|
||||
}
|
||||
const newRanges = formData.refnum?.filter((_, i) => i !== index) || [];
|
||||
formData.refnum = newRanges;
|
||||
handleFieldChange();
|
||||
}
|
||||
|
||||
function saveRange() {
|
||||
const validation = validateRange();
|
||||
if (!validation.valid) {
|
||||
validationError = validation.error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalMode === 'create') {
|
||||
formData.refnum = [...(formData.refnum || []), { ...editingRange }];
|
||||
} else {
|
||||
const newRanges = formData.refnum?.map((r, i) =>
|
||||
i === selectedRangeIndex ? { ...editingRange } : r
|
||||
) || [];
|
||||
formData.refnum = newRanges;
|
||||
}
|
||||
modalOpen = false;
|
||||
validationError = '';
|
||||
handleFieldChange();
|
||||
}
|
||||
|
||||
function getRefTypeLabel(type) {
|
||||
return refTypes.find(t => t.value === type)?.label || type;
|
||||
}
|
||||
|
||||
function getSexLabel(sex) {
|
||||
return sexOptions.find(s => s.value === sex)?.label || sex;
|
||||
}
|
||||
@ -184,12 +150,18 @@
|
||||
return signOptions.find(s => s.value === sign)?.label || sign;
|
||||
}
|
||||
|
||||
function getAgeDisplay(ageDays) {
|
||||
if (ageDays === null || ageDays === undefined) return '';
|
||||
if (ageDays < 30) return `${ageDays}d`;
|
||||
if (ageDays < 365) return `${Math.floor(ageDays / 30)}mo`;
|
||||
return `${Math.floor(ageDays / 365)}y`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all reference ranges
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validateAll() {
|
||||
// Check for duplicate ranges (same type, sex, age range)
|
||||
const seen = new Set();
|
||||
for (const range of formData.refnum || []) {
|
||||
const key = `${range.NumRefType}-${range.Sex}-${range.AgeStart}-${range.AgeEnd}`;
|
||||
@ -212,208 +184,313 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Ranges ({formData.refnum?.length || 0})</h3>
|
||||
</div>
|
||||
|
||||
{#if !formData.refnum || formData.refnum.length === 0}
|
||||
<div class="text-center py-8 bg-base-200 rounded-lg">
|
||||
<Calculator class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||
<p class="text-sm text-gray-500">No numeric ranges defined</p>
|
||||
<p class="text-xs text-gray-400">Add reference ranges for this test</p>
|
||||
<!-- Simple Inline Form -->
|
||||
<div class="bg-base-100 border border-base-300 rounded-lg p-4 space-y-4">
|
||||
{#if formData.refnum?.length === 0}
|
||||
<!-- First entry - full form with optional collapsed fields -->
|
||||
<div class="space-y-4">
|
||||
{#if validationErrors.simple}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<span>{validationErrors.simple}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Primary: Just Low and High values -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{#if simpleRefNum.RangeType === 'THOLD'}
|
||||
<!-- THOLD: show signs with values -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Low Value</label>
|
||||
<div class="flex gap-2">
|
||||
<select class="select select-sm select-bordered w-16" bind:value={simpleRefNum.LowSign}>
|
||||
{#each signOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.Low}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">High Value</label>
|
||||
<div class="flex gap-2">
|
||||
<select class="select select-sm select-bordered w-16" bind:value={simpleRefNum.HighSign}>
|
||||
{#each signOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.High}
|
||||
placeholder="High"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- RANGE: just values -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Low Value</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.Low}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">High Value</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.High}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Optional: Range Type, Sex and Age (collapsed by default) -->
|
||||
<div class="border-t pt-3">
|
||||
<details>
|
||||
<summary class="text-sm text-gray-600 cursor-pointer hover:text-gray-800">
|
||||
Advanced Options (Range Type, Sex, Age)
|
||||
</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Range Type</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.RangeType}>
|
||||
<option value="RANGE">Range</option>
|
||||
<option value="THOLD">Threshold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Sex</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.Sex}>
|
||||
{#each sexOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Age Unit</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.AgeUnit}>
|
||||
{#each ageUnits as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">From</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.AgeStart}
|
||||
min="0"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{simpleRefNum.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">To</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.AgeEnd}
|
||||
min="0"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{simpleRefNum.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick Presets -->
|
||||
<div class="pt-1">
|
||||
<span class="text-xs text-gray-500 block mb-1">Quick presets:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefNum.AgeStart = ''; simpleRefNum.AgeEnd = ''; }}>All ages</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefNum.AgeStart = 0; simpleRefNum.AgeEnd = 18; simpleRefNum.AgeUnit = 'years'; }}>0-18 years</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefNum.AgeStart = 18; simpleRefNum.AgeEnd = 150; simpleRefNum.AgeUnit = 'years'; }}>18+ years</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button class="btn btn-sm btn-primary" onclick={addSimpleRefNum}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Add Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto border border-base-200 rounded-lg">
|
||||
<table class="table table-sm table-compact">
|
||||
<thead>
|
||||
<tr class="bg-base-200">
|
||||
<th class="w-20">Type</th>
|
||||
<th class="w-20">Range</th>
|
||||
<th class="w-16">Sex</th>
|
||||
<th class="w-24">Age</th>
|
||||
<th>Low Bound</th>
|
||||
<th>High Bound</th>
|
||||
<th class="w-16">Flag</th>
|
||||
<th class="w-24 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each formData.refnum as range, idx (idx)}
|
||||
<tr class="hover:bg-base-100">
|
||||
<td>
|
||||
<span class="badge badge-xs badge-ghost">{getRefTypeLabel(range.NumRefType)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-xs badge-outline">{range.RangeType}</span>
|
||||
</td>
|
||||
<td>{getSexLabel(range.Sex)}</td>
|
||||
<td class="text-sm">{range.AgeStart}-{range.AgeEnd}</td>
|
||||
<td class="font-mono text-sm">
|
||||
{#if range.Low !== null && range.Low !== ''}
|
||||
{getSignLabel(range.LowSign)} {range.Low}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="font-mono text-sm">
|
||||
{#if range.High !== null && range.High !== ''}
|
||||
{getSignLabel(range.HighSign)} {range.High}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-xs badge-secondary">{range.Flag || '-'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex justify-center gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => openEditRange(idx)}
|
||||
title="Edit Range"
|
||||
>
|
||||
<Edit2 class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => removeRange(idx)}
|
||||
title="Remove Range"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
<!-- Show existing ranges as list -->
|
||||
<div class="space-y-2">
|
||||
{#each formData.refnum as ref, idx (idx)}
|
||||
<div class="bg-base-100 border border-base-300 rounded-lg p-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="font-mono bg-base-200 px-2 py-1 rounded">
|
||||
{#if ref.RangeType === 'THOLD' && ref.LowSign}
|
||||
{getSignLabel(ref.LowSign)}
|
||||
{/if}
|
||||
{ref.Low !== null && ref.Low !== '' ? ref.Low : '—'}
|
||||
-
|
||||
{#if ref.RangeType === 'THOLD' && ref.HighSign}
|
||||
{getSignLabel(ref.HighSign)}
|
||||
{/if}
|
||||
{ref.High !== null && ref.High !== '' ? ref.High : '—'}
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
{getSexLabel(ref.Sex)}
|
||||
{#if ref.AgeStart > 0 || ref.AgeEnd < 54750}
|
||||
· {getAgeDisplay(ref.AgeStart)}-{getAgeDisplay(ref.AgeEnd)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => removeRange(idx)} title="Remove">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add another simple form -->
|
||||
<div class="border-t pt-3">
|
||||
{#if validationErrors.simple}
|
||||
<div class="alert alert-error alert-sm mb-3">
|
||||
<span>{validationErrors.simple}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-4 gap-2 items-end">
|
||||
{#if simpleRefNum.RangeType === 'THOLD'}
|
||||
<!-- THOLD: show signs with values -->
|
||||
<div class="col-span-2 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Low</label>
|
||||
<div class="flex gap-1">
|
||||
<select class="select select-sm select-bordered w-12" bind:value={simpleRefNum.LowSign}>
|
||||
{#each signOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.Low}
|
||||
placeholder="Low"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">High</label>
|
||||
<div class="flex gap-1">
|
||||
<select class="select select-sm select-bordered w-12" bind:value={simpleRefNum.HighSign}>
|
||||
{#each signOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.High}
|
||||
placeholder="High"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- RANGE: just values -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Low</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.Low}
|
||||
placeholder="Low"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">High</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.High}
|
||||
placeholder="High"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Sex</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.Sex}>
|
||||
{#each sexOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" onclick={addSimpleRefNum}>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Range Type and Age -->
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-800">
|
||||
Advanced (Range Type, Age)
|
||||
</summary>
|
||||
<div class="mt-2 grid grid-cols-4 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Range Type</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.RangeType}>
|
||||
<option value="RANGE">Range</option>
|
||||
<option value="THOLD">Threshold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Age Unit</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.AgeUnit}>
|
||||
{#each ageUnits as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.AgeStart}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.AgeEnd}
|
||||
placeholder="150"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button class="btn btn-sm btn-primary" onclick={openAddRange}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Numeric Range' : 'Edit Numeric Range'} size="md">
|
||||
<div class="space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{#if validationError}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{validationError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">
|
||||
Reference Type
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<select class="select select-sm select-bordered" bind:value={editingRange.NumRefType}>
|
||||
{#each refTypes as rt (rt.value)}
|
||||
<option value={rt.value} title={rt.desc}>{rt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">
|
||||
Range Type
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<select class="select select-sm select-bordered" bind:value={editingRange.RangeType}>
|
||||
{#each rangeTypes as rt (rt.value)}
|
||||
<option value={rt.value} title={rt.desc}>{rt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">
|
||||
Sex
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<select class="select select-sm select-bordered" bind:value={editingRange.Sex}>
|
||||
{#each sexOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">
|
||||
Age Start
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeStart} min="0" max="150" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">
|
||||
Age End
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeEnd} min="0" max="150" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="text-sm font-medium mb-3">Low Bound</h4>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<select class="select select-sm select-bordered" bind:value={editingRange.LowSign}>
|
||||
{#each signOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input type="number" step="0.01" class="input input-sm input-bordered col-span-2" bind:value={editingRange.Low} placeholder="Low value" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="text-sm font-medium mb-3">High Bound</h4>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<select class="select select-sm select-bordered" bind:value={editingRange.HighSign}>
|
||||
{#each signOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input type="number" step="0.01" class="input input-sm input-bordered col-span-2" bind:value={editingRange.High} placeholder="High value" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">
|
||||
Flag
|
||||
<span class="label-text-alt text-xs text-gray-500">(H, L, A, N, C)</span>
|
||||
</label>
|
||||
<select class="select select-sm select-bordered" bind:value={editingRange.Flag}>
|
||||
{#each flagOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Interpretation</label>
|
||||
<input type="text" class="input input-sm input-bordered" bind:value={editingRange.Interpretation} placeholder="Optional interpretation" maxlength="255" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={() => { modalOpen = false; validationError = ''; }}>Cancel</button>
|
||||
<button class="btn btn-primary" onclick={saveRange}>Save</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@ -12,8 +12,8 @@
|
||||
Sex: '0',
|
||||
AgeStart: 0,
|
||||
AgeEnd: 150,
|
||||
RefTxt: '',
|
||||
Flag: 'N'
|
||||
AgeUnit: 'years',
|
||||
RefTxt: ''
|
||||
});
|
||||
|
||||
const refTypes = [
|
||||
@ -28,26 +28,42 @@
|
||||
{ value: '2', label: 'Male' }
|
||||
];
|
||||
|
||||
const flagOptions = $derived.by(() => {
|
||||
const type = editingRange.TxtRefType;
|
||||
if (type === 'Normal') return [{ value: 'N', label: 'N - Normal' }];
|
||||
if (type === 'Abnormal') return [{ value: 'A', label: 'A - Abnormal' }];
|
||||
if (type === 'Critical') return [{ value: 'C', label: 'C - Critical' }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const autoFlag = $derived.by(() => {
|
||||
const type = editingRange.TxtRefType;
|
||||
if (type === 'Normal') return 'N';
|
||||
if (type === 'Abnormal') return 'A';
|
||||
if (type === 'Critical') return 'C';
|
||||
return editingRange.Flag;
|
||||
});
|
||||
const ageUnits = [
|
||||
{ value: 'days', label: 'Days' },
|
||||
{ value: 'weeks', label: 'Weeks' },
|
||||
{ value: 'months', label: 'Months' },
|
||||
{ value: 'years', label: 'Years' }
|
||||
];
|
||||
|
||||
function handleFieldChange() {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
// Convert age to days for storage
|
||||
function convertAgeToDays(value, unit) {
|
||||
if (!value && value !== 0) return null;
|
||||
const num = parseInt(value);
|
||||
switch (unit) {
|
||||
case 'days': return num;
|
||||
case 'weeks': return num * 7;
|
||||
case 'months': return num * 30;
|
||||
case 'years': return num * 365;
|
||||
default: return num;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert days to display unit
|
||||
function convertDaysToUnit(days, unit) {
|
||||
if (days === null || days === undefined) return 0;
|
||||
switch (unit) {
|
||||
case 'days': return days;
|
||||
case 'weeks': return Math.floor(days / 7);
|
||||
case 'months': return Math.floor(days / 30);
|
||||
case 'years': return Math.floor(days / 365);
|
||||
default: return days;
|
||||
}
|
||||
}
|
||||
|
||||
function openAddRange() {
|
||||
modalMode = 'create';
|
||||
selectedRangeIndex = null;
|
||||
@ -56,8 +72,8 @@
|
||||
Sex: '0',
|
||||
AgeStart: 0,
|
||||
AgeEnd: 150,
|
||||
RefTxt: '',
|
||||
Flag: 'N'
|
||||
AgeUnit: 'years',
|
||||
RefTxt: ''
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
@ -65,7 +81,13 @@
|
||||
function openEditRange(index) {
|
||||
modalMode = 'edit';
|
||||
selectedRangeIndex = index;
|
||||
editingRange = { ...formData.reftxt[index] };
|
||||
const ref = formData.reftxt[index];
|
||||
editingRange = {
|
||||
...ref,
|
||||
AgeUnit: 'years',
|
||||
AgeStart: convertDaysToUnit(ref.AgeStart, 'years'),
|
||||
AgeEnd: convertDaysToUnit(ref.AgeEnd, 'years')
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
@ -76,11 +98,17 @@
|
||||
}
|
||||
|
||||
function saveRange() {
|
||||
const dataToSave = {
|
||||
...editingRange,
|
||||
AgeStart: convertAgeToDays(editingRange.AgeStart, editingRange.AgeUnit),
|
||||
AgeEnd: convertAgeToDays(editingRange.AgeEnd, editingRange.AgeUnit)
|
||||
};
|
||||
|
||||
if (modalMode === 'create') {
|
||||
formData.reftxt = [...(formData.reftxt || []), { ...editingRange, Flag: autoFlag }];
|
||||
formData.reftxt = [...(formData.reftxt || []), dataToSave];
|
||||
} else {
|
||||
const newRanges = formData.reftxt?.map((r, i) =>
|
||||
i === selectedRangeIndex ? { ...editingRange, Flag: autoFlag } : r
|
||||
i === selectedRangeIndex ? dataToSave : r
|
||||
) || [];
|
||||
formData.reftxt = newRanges;
|
||||
}
|
||||
@ -95,6 +123,13 @@
|
||||
function getSexLabel(sex) {
|
||||
return sexOptions.find(s => s.value === sex)?.label || sex;
|
||||
}
|
||||
|
||||
function getAgeDisplay(ageDays) {
|
||||
if (ageDays === null || ageDays === undefined) return '';
|
||||
if (ageDays < 30) return `${ageDays}d`;
|
||||
if (ageDays < 365) return `${Math.floor(ageDays / 30)}mo`;
|
||||
return `${Math.floor(ageDays / 365)}y`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@ -125,7 +160,6 @@
|
||||
<th class="w-16">Sex</th>
|
||||
<th class="w-24">Age</th>
|
||||
<th>Reference Text</th>
|
||||
<th class="w-16">Flag</th>
|
||||
<th class="w-24 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -141,11 +175,8 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>{getSexLabel(range.Sex)}</td>
|
||||
<td class="text-sm">{range.AgeStart}-{range.AgeEnd}</td>
|
||||
<td class="text-sm">{getAgeDisplay(range.AgeStart)}-{getAgeDisplay(range.AgeEnd)}</td>
|
||||
<td class="font-mono text-sm">{range.RefTxt || '-'}</td>
|
||||
<td>
|
||||
<span class="badge badge-xs badge-secondary">{range.Flag}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex justify-center gap-1">
|
||||
<button
|
||||
@ -153,7 +184,7 @@
|
||||
onclick={() => openEditRange(idx)}
|
||||
title="Edit Range"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@ -180,7 +211,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Text Range' : 'Edit Text Range'} size="md">
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Text Range' : 'Edit Text Range'} size="lg">
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Reference Type</label>
|
||||
@ -200,14 +231,49 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Age Start</label>
|
||||
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeStart} min="0" max="150" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Age End</label>
|
||||
<input type="number" class="input input-sm input-bordered" bind:value={editingRange.AgeEnd} min="0" max="150" />
|
||||
<!-- Age Range Section -->
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="text-sm font-medium mb-3">Age Range</h4>
|
||||
<div class="bg-base-200 p-4 rounded-lg space-y-3">
|
||||
<!-- Age Unit Selector - Applied to both start and end -->
|
||||
<div class="form-control">
|
||||
<label class="label text-sm font-medium">Age Unit</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={editingRange.AgeUnit}>
|
||||
{#each ageUnits as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500 mt-1">Both age values will use this unit</span>
|
||||
</div>
|
||||
|
||||
<!-- Age Start/End with Unit Display -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">From</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" class="input input-sm input-bordered flex-1" bind:value={editingRange.AgeStart} min="0" placeholder="0" />
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{editingRange.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">To</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" class="input input-sm input-bordered flex-1" bind:value={editingRange.AgeEnd} min="0" placeholder="150" />
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{editingRange.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Presets -->
|
||||
<div class="pt-2">
|
||||
<span class="text-xs text-gray-500 block mb-2">Quick presets:</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { editingRange.AgeStart = 0; editingRange.AgeEnd = 150; editingRange.AgeUnit = 'years'; }}>All ages</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { editingRange.AgeStart = 0; editingRange.AgeEnd = 18; editingRange.AgeUnit = 'years'; }}>Pediatric (0-18y)</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { editingRange.AgeStart = 18; editingRange.AgeEnd = 150; editingRange.AgeUnit = 'years'; }}>Adult (18y+)</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { editingRange.AgeStart = 0; editingRange.AgeEnd = 30; editingRange.AgeUnit = 'days'; }}>Neonatal (0-30d)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -224,14 +290,6 @@
|
||||
maxlength="255"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Flag</label>
|
||||
<input type="text" class="input input-sm input-bordered" value={autoFlag} readonly />
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-xs text-gray-500">Flag is automatically set based on reference type</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
|
||||
@ -1,13 +1,46 @@
|
||||
<script>
|
||||
import { getResultTypeOptions, getRefTypeOptions, validateTypeCombination } from '$lib/api/tests.js';
|
||||
import { AlertCircle } from 'lucide-svelte';
|
||||
import { AlertCircle, Hash, Type, Plus, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
|
||||
let { formData = $bindable(), disciplines = [], departments = [], isDirty = $bindable(false) } = $props();
|
||||
let { formData = $bindable(), disciplines = [], departments = [], isDirty = $bindable(false), onSwitchTab = null } = $props();
|
||||
|
||||
// Simple inline state for single range entry
|
||||
let simpleRefNum = $state({
|
||||
Low: '',
|
||||
High: '',
|
||||
Sex: '0',
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years'
|
||||
});
|
||||
|
||||
let simpleRefTxt = $state({
|
||||
RefTxt: '',
|
||||
Sex: '0',
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years'
|
||||
});
|
||||
|
||||
let validationErrors = $state({
|
||||
typeCombination: ''
|
||||
typeCombination: '',
|
||||
simpleRefNum: '',
|
||||
simpleRefTxt: ''
|
||||
});
|
||||
|
||||
const sexOptions = [
|
||||
{ value: '0', label: 'All' },
|
||||
{ value: '1', label: 'Female' },
|
||||
{ value: '2', label: 'Male' }
|
||||
];
|
||||
|
||||
const ageUnits = [
|
||||
{ value: 'days', label: 'Days' },
|
||||
{ value: 'weeks', label: 'Weeks' },
|
||||
{ value: 'months', label: 'Months' },
|
||||
{ value: 'years', label: 'Years' }
|
||||
];
|
||||
|
||||
const disciplineOptions = $derived(disciplines.map(d => ({ value: d.DisciplineID, label: d.DisciplineName })));
|
||||
const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName })));
|
||||
|
||||
@ -23,6 +56,25 @@
|
||||
return result;
|
||||
});
|
||||
|
||||
// Computed: Check if numeric reference ranges should be shown
|
||||
const showRefNumSection = $derived.by(() => {
|
||||
const resultType = formData.details?.ResultType;
|
||||
const refType = formData.details?.RefType;
|
||||
return ['NMRIC', 'RANGE'].includes(resultType) && ['RANGE', 'THOLD'].includes(refType);
|
||||
});
|
||||
|
||||
// Computed: Check if text reference ranges should be shown
|
||||
const showRefTxtSection = $derived.by(() => {
|
||||
const resultType = formData.details?.ResultType;
|
||||
const refType = formData.details?.RefType;
|
||||
return resultType === 'TEXT' && refType === 'TEXT';
|
||||
});
|
||||
|
||||
// Computed: Check if we should show sex/age (only if multiple ranges exist or user chooses to)
|
||||
const showAdvancedRefFields = $derived.by(() => {
|
||||
return (formData.refnum?.length > 1) || (formData.reftxt?.length > 1);
|
||||
});
|
||||
|
||||
function handleFieldChange() {
|
||||
isDirty = true;
|
||||
validationErrors.typeCombination = '';
|
||||
@ -44,9 +96,182 @@
|
||||
formData.details.VSet = '';
|
||||
}
|
||||
|
||||
// Clear references when type changes
|
||||
formData.refnum = [];
|
||||
formData.reftxt = [];
|
||||
resetSimpleRefNum();
|
||||
resetSimpleRefTxt();
|
||||
|
||||
handleFieldChange();
|
||||
}
|
||||
|
||||
function resetSimpleRefNum() {
|
||||
simpleRefNum = {
|
||||
Low: '',
|
||||
High: '',
|
||||
Sex: '0',
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years'
|
||||
};
|
||||
validationErrors.simpleRefNum = '';
|
||||
}
|
||||
|
||||
function resetSimpleRefTxt() {
|
||||
simpleRefTxt = {
|
||||
RefTxt: '',
|
||||
Sex: '0',
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years'
|
||||
};
|
||||
validationErrors.simpleRefTxt = '';
|
||||
}
|
||||
|
||||
// Convert age to days for storage
|
||||
function convertAgeToDays(value, unit) {
|
||||
if (!value && value !== 0) return null;
|
||||
const num = parseInt(value);
|
||||
switch (unit) {
|
||||
case 'days': return num;
|
||||
case 'weeks': return num * 7;
|
||||
case 'months': return num * 30;
|
||||
case 'years': return num * 365;
|
||||
default: return num;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert days to display unit
|
||||
function convertDaysToUnit(days, unit) {
|
||||
if (days === null || days === undefined) return '';
|
||||
switch (unit) {
|
||||
case 'days': return days;
|
||||
case 'weeks': return Math.floor(days / 7);
|
||||
case 'months': return Math.floor(days / 30);
|
||||
case 'years': return Math.floor(days / 365);
|
||||
default: return days;
|
||||
}
|
||||
}
|
||||
|
||||
function validateSimpleRefNum() {
|
||||
if (!simpleRefNum.Low && !simpleRefNum.High) {
|
||||
return { valid: false, error: 'Please enter at least one value' };
|
||||
}
|
||||
|
||||
const low = simpleRefNum.Low !== '' ? parseFloat(simpleRefNum.Low) : null;
|
||||
const high = simpleRefNum.High !== '' ? parseFloat(simpleRefNum.High) : null;
|
||||
|
||||
if (low !== null && high !== null && low >= high) {
|
||||
return { valid: false, error: 'Low value must be less than high value' };
|
||||
}
|
||||
|
||||
// Validate age range if provided
|
||||
if (simpleRefNum.AgeStart !== '' || simpleRefNum.AgeEnd !== '') {
|
||||
const ageStart = simpleRefNum.AgeStart !== '' ? parseInt(simpleRefNum.AgeStart) : null;
|
||||
const ageEnd = simpleRefNum.AgeEnd !== '' ? parseInt(simpleRefNum.AgeEnd) : null;
|
||||
|
||||
if (ageStart !== null && ageEnd !== null && ageStart >= ageEnd) {
|
||||
return { valid: false, error: 'Age start must be less than age end' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function addSimpleRefNum() {
|
||||
const validation = validateSimpleRefNum();
|
||||
if (!validation.valid) {
|
||||
validationErrors.simpleRefNum = validation.error;
|
||||
return;
|
||||
}
|
||||
|
||||
const newRef = {
|
||||
NumRefType: 'REF',
|
||||
RangeType: 'RANGE',
|
||||
Sex: simpleRefNum.Sex,
|
||||
AgeStart: convertAgeToDays(simpleRefNum.AgeStart, simpleRefNum.AgeUnit) || 0,
|
||||
AgeEnd: convertAgeToDays(simpleRefNum.AgeEnd, simpleRefNum.AgeUnit) || 54750, // 150 years in days
|
||||
Low: simpleRefNum.Low !== '' ? parseFloat(simpleRefNum.Low) : null,
|
||||
High: simpleRefNum.High !== '' ? parseFloat(simpleRefNum.High) : null,
|
||||
Flag: null,
|
||||
Interpretation: null
|
||||
};
|
||||
|
||||
formData.refnum = [...(formData.refnum || []), newRef];
|
||||
resetSimpleRefNum();
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
function validateSimpleRefTxt() {
|
||||
if (!simpleRefTxt.RefTxt?.trim()) {
|
||||
return { valid: false, error: 'Reference text is required' };
|
||||
}
|
||||
|
||||
// Validate age range if provided
|
||||
if (simpleRefTxt.AgeStart !== '' || simpleRefTxt.AgeEnd !== '') {
|
||||
const ageStart = simpleRefTxt.AgeStart !== '' ? parseInt(simpleRefTxt.AgeStart) : null;
|
||||
const ageEnd = simpleRefTxt.AgeEnd !== '' ? parseInt(simpleRefTxt.AgeEnd) : null;
|
||||
|
||||
if (ageStart !== null && ageEnd !== null && ageStart >= ageEnd) {
|
||||
return { valid: false, error: 'Age start must be less than age end' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function addSimpleRefTxt() {
|
||||
const validation = validateSimpleRefTxt();
|
||||
if (!validation.valid) {
|
||||
validationErrors.simpleRefTxt = validation.error;
|
||||
return;
|
||||
}
|
||||
|
||||
const newRef = {
|
||||
TxtRefType: 'Normal',
|
||||
Sex: simpleRefTxt.Sex,
|
||||
AgeStart: convertAgeToDays(simpleRefTxt.AgeStart, simpleRefTxt.AgeUnit) || 0,
|
||||
AgeEnd: convertDaysToUnit(simpleRefTxt.AgeEnd, simpleRefTxt.AgeUnit) || 54750,
|
||||
RefTxt: simpleRefTxt.RefTxt.trim(),
|
||||
Flag: 'N'
|
||||
};
|
||||
|
||||
formData.reftxt = [...(formData.reftxt || []), newRef];
|
||||
resetSimpleRefTxt();
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
function removeRefNum(index) {
|
||||
const newRanges = formData.refnum?.filter((_, i) => i !== index) || [];
|
||||
formData.refnum = newRanges;
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
function removeRefTxt(index) {
|
||||
const newRanges = formData.reftxt?.filter((_, i) => i !== index) || [];
|
||||
formData.reftxt = newRanges;
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
function switchToRefNumTab() {
|
||||
if (onSwitchTab) onSwitchTab('refnum');
|
||||
}
|
||||
|
||||
function switchToRefTxtTab() {
|
||||
if (onSwitchTab) onSwitchTab('reftxt');
|
||||
}
|
||||
|
||||
function getSexLabel(sex) {
|
||||
return sexOptions.find(s => s.value === sex)?.label || sex;
|
||||
}
|
||||
|
||||
function getAgeDisplay(ageDays) {
|
||||
if (ageDays === null || ageDays === undefined) return '';
|
||||
if (ageDays < 30) return `${ageDays}d`;
|
||||
if (ageDays < 365) return `${Math.floor(ageDays / 30)}mo`;
|
||||
return `${Math.floor(ageDays / 365)}y`;
|
||||
}
|
||||
|
||||
export function validateAll() {
|
||||
validationErrors.typeCombination = '';
|
||||
|
||||
@ -303,4 +528,364 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline Numeric Reference Ranges - Simple Form -->
|
||||
{#if showRefNumSection}
|
||||
<div class="border-t pt-5">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Hash class="w-4 h-4" />
|
||||
Reference Range
|
||||
{#if formData.refnum?.length > 0}
|
||||
<span class="badge badge-sm badge-primary">{formData.refnum.length}</span>
|
||||
{/if}
|
||||
</h3>
|
||||
{#if formData.refnum?.length > 0}
|
||||
<button class="btn btn-ghost btn-xs text-primary" onclick={switchToRefNumTab}>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
Full Manager
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if formData.refnum?.length === 0}
|
||||
<!-- Simple inline form for first entry -->
|
||||
<div class="bg-base-100 border border-base-300 rounded-lg p-4 space-y-4">
|
||||
{#if validationErrors.simpleRefNum}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<span>{validationErrors.simpleRefNum}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Primary: Just Low and High values -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Low Value</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.Low}
|
||||
placeholder="e.g., 10.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">High Value</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.High}
|
||||
placeholder="e.g., 50.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Sex and Age (collapsed by default) -->
|
||||
<div class="border-t pt-3">
|
||||
<details>
|
||||
<summary class="text-sm text-gray-600 cursor-pointer hover:text-gray-800">
|
||||
Add Sex/Age Specific Ranges (Optional)
|
||||
</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Sex</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.Sex}>
|
||||
{#each sexOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Age Unit</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.AgeUnit}>
|
||||
{#each ageUnits as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">From</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.AgeStart}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{simpleRefNum.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">To</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefNum.AgeEnd}
|
||||
placeholder="150"
|
||||
min="0"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{simpleRefNum.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick Presets -->
|
||||
<div class="pt-1">
|
||||
<span class="text-xs text-gray-500 block mb-1">Quick presets:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefNum.AgeStart = ''; simpleRefNum.AgeEnd = ''; }}>All ages</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefNum.AgeStart = 0; simpleRefNum.AgeEnd = 18; simpleRefNum.AgeUnit = 'years'; }}>0-18 years</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefNum.AgeStart = 18; simpleRefNum.AgeEnd = 150; simpleRefNum.AgeUnit = 'years'; }}>18+ years</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button class="btn btn-sm btn-primary" onclick={addSimpleRefNum}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Add Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Show existing ranges as list -->
|
||||
<div class="space-y-2">
|
||||
{#each formData.refnum as ref, idx (idx)}
|
||||
<div class="bg-base-100 border border-base-300 rounded-lg p-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="font-mono bg-base-200 px-2 py-1 rounded">
|
||||
{ref.Low !== null ? ref.Low : '—'} - {ref.High !== null ? ref.High : '—'}
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
{getSexLabel(ref.Sex)}
|
||||
{#if ref.AgeStart > 0 || ref.AgeEnd < 54750}
|
||||
· {getAgeDisplay(ref.AgeStart)}-{getAgeDisplay(ref.AgeEnd)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => removeRefNum(idx)} title="Remove">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add another simple form -->
|
||||
<div class="border-t pt-3">
|
||||
{#if validationErrors.simpleRefNum}
|
||||
<div class="alert alert-error alert-sm mb-3">
|
||||
<span>{validationErrors.simpleRefNum}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-4 gap-2 items-end">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Low</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.Low}
|
||||
placeholder="Low"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">High</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefNum.High}
|
||||
placeholder="High"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Sex</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.Sex}>
|
||||
{#each sexOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" onclick={addSimpleRefNum}>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Inline Text Reference Ranges - Simple Form -->
|
||||
{#if showRefTxtSection}
|
||||
<div class="border-t pt-5">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Type class="w-4 h-4" />
|
||||
Text Reference
|
||||
{#if formData.reftxt?.length > 0}
|
||||
<span class="badge badge-sm badge-primary">{formData.reftxt.length}</span>
|
||||
{/if}
|
||||
</h3>
|
||||
{#if formData.reftxt?.length > 0}
|
||||
<button class="btn btn-ghost btn-xs text-primary" onclick={switchToRefTxtTab}>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
Full Manager
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if formData.reftxt?.length === 0}
|
||||
<!-- Simple inline form for first entry -->
|
||||
<div class="bg-base-100 border border-base-300 rounded-lg p-4 space-y-4">
|
||||
{#if validationErrors.simpleRefTxt}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<span>{validationErrors.simpleRefTxt}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Primary: Just the text value -->
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Reference Text</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefTxt.RefTxt}
|
||||
placeholder="e.g., Clear, Positive, Negative"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Sex and Age (collapsed by default) -->
|
||||
<div class="border-t pt-3">
|
||||
<details>
|
||||
<summary class="text-sm text-gray-600 cursor-pointer hover:text-gray-800">
|
||||
Add Sex/Age Specific References (Optional)
|
||||
</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Sex</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefTxt.Sex}>
|
||||
{#each sexOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Age Unit</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefTxt.AgeUnit}>
|
||||
{#each ageUnits as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">From</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefTxt.AgeStart}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{simpleRefTxt.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700">To</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={simpleRefTxt.AgeEnd}
|
||||
placeholder="150"
|
||||
min="0"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 whitespace-nowrap">{simpleRefTxt.AgeUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick Presets -->
|
||||
<div class="pt-1">
|
||||
<span class="text-xs text-gray-500 block mb-1">Quick presets:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefTxt.AgeStart = ''; simpleRefTxt.AgeEnd = ''; }}>All ages</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefTxt.AgeStart = 0; simpleRefTxt.AgeEnd = 18; simpleRefTxt.AgeUnit = 'years'; }}>0-18 years</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick={() => { simpleRefTxt.AgeStart = 18; simpleRefTxt.AgeEnd = 150; simpleRefTxt.AgeUnit = 'years'; }}>18+ years</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button class="btn btn-sm btn-primary" onclick={addSimpleRefTxt}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Add Reference
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Show existing ranges as list -->
|
||||
<div class="space-y-2">
|
||||
{#each formData.reftxt as ref, idx (idx)}
|
||||
<div class="bg-base-100 border border-base-300 rounded-lg p-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="font-mono bg-base-200 px-2 py-1 rounded">{ref.RefTxt}</span>
|
||||
<span class="text-gray-600">
|
||||
{getSexLabel(ref.Sex)}
|
||||
{#if ref.AgeStart > 0 || ref.AgeEnd < 54750}
|
||||
· {getAgeDisplay(ref.AgeStart)}-{getAgeDisplay(ref.AgeEnd)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => removeRefTxt(idx)} title="Remove">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add another simple form -->
|
||||
<div class="border-t pt-3">
|
||||
{#if validationErrors.simpleRefTxt}
|
||||
<div class="alert alert-error alert-sm mb-3">
|
||||
<span>{validationErrors.simpleRefTxt}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-4 gap-2 items-end">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs text-gray-600 mb-1">Reference Text</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={simpleRefTxt.RefTxt}
|
||||
placeholder="Text value"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">Sex</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefTxt.Sex}>
|
||||
{#each sexOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" onclick={addSimpleRefTxt}>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user