feat(tests): update test module API endpoints and search functionality

- Change API endpoints from /api/tests to /api/test (singular form)
- Refactor search: split single search field into separate code and name inputs
- Add new ThresholdTab.svelte component for threshold management
- Update TestFormModal to include threshold configuration
- Refactor RefNumTab, RefTxtTab, and TechDetailsTab for improved UX
- Update tests page to use separate TestSiteCode and TestSiteName search params
- Improve test data table with better pagination and search handling
This commit is contained in:
mahdahar 2026-03-02 07:02:25 +07:00
parent b693f279e8
commit 96f3b14fd4
7 changed files with 1180 additions and 1183 deletions

View File

@ -20,7 +20,7 @@ import { get, post, patch } from './client.js';
*/ */
export async function fetchTests(params = {}) { export async function fetchTests(params = {}) {
const query = new URLSearchParams(params).toString(); const query = new URLSearchParams(params).toString();
return get(query ? `/api/tests?${query}` : '/api/tests'); return get(query ? `/api/test?${query}` : '/api/test');
} }
/** /**
@ -29,7 +29,7 @@ export async function fetchTests(params = {}) {
* @returns {Promise<TestDetailResponse>} API response with test detail * @returns {Promise<TestDetailResponse>} API response with test detail
*/ */
export async function fetchTest(id) { export async function fetchTest(id) {
return get(`/api/tests/${id}`); return get(`/api/test/${id}`);
} }
/** /**
@ -157,7 +157,7 @@ function buildPayload(formData, isUpdate = false) {
*/ */
export async function createTest(formData) { export async function createTest(formData) {
const payload = buildPayload(formData, false); const payload = buildPayload(formData, false);
return post('/api/tests', payload); return post('/api/test', payload);
} }
/** /**
@ -167,7 +167,7 @@ export async function createTest(formData) {
*/ */
export async function updateTest(formData) { export async function updateTest(formData) {
const payload = buildPayload(formData, true); const payload = buildPayload(formData, true);
return patch('/api/tests', payload); return patch('/api/test', payload);
} }
/** /**
@ -176,7 +176,7 @@ export async function updateTest(formData) {
* @returns {Promise<DeleteTestResponse>} API response * @returns {Promise<DeleteTestResponse>} API response
*/ */
export async function deleteTest(id) { export async function deleteTest(id) {
return patch('/api/tests', { return patch('/api/test', {
TestSiteID: id, TestSiteID: id,
IsActive: '0', IsActive: '0',
}); });

View File

@ -7,15 +7,15 @@
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import TestFormModal from './test-modal/TestFormModal.svelte'; import TestFormModal from './test-modal/TestFormModal.svelte';
import TestTypePickerModal from './test-modal/modals/TestTypePickerModal.svelte'; import TestTypePickerModal from './test-modal/modals/TestTypePickerModal.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users, ChevronLeft, ChevronRight } from 'lucide-svelte'; import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users, ChevronLeft, ChevronRight, Hash, Type } from 'lucide-svelte';
// Pagination and search state // Pagination and search state
let loading = $state(false); let loading = $state(false);
let tests = $state([]); let tests = $state([]);
let disciplines = $state([]); let disciplines = $state([]);
let departments = $state([]); let departments = $state([]);
let searchQuery = $state(''); let searchCode = $state('');
let searchType = $state('all'); // 'all', 'code', 'name' let searchName = $state('');
let currentPage = $state(1); let currentPage = $state(1);
let perPage = $state(25); let perPage = $state(25);
let totalItems = $state(0); let totalItems = $state(0);
@ -56,15 +56,13 @@
function getSearchParams() { function getSearchParams() {
const params = { page: currentPage, perPage }; const params = { page: currentPage, perPage };
const query = searchQuery.trim(); const testCode = searchCode.trim();
if (query) { const testName = searchName.trim();
if (searchType === 'code') { if (testCode) {
params.testCode = query; params.TestSiteCode = testCode;
} else if (searchType === 'name') {
params.testName = query;
} else {
params.search = query;
} }
if (testName) {
params.TestSiteName = testName;
} }
return params; return params;
} }
@ -80,22 +78,25 @@
} }
onMount(async () => { onMount(async () => {
await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]); await Promise.all([loadDisciplines(), loadDepartments()]);
}); });
function handleSearchInput() {
handleSearch();
}
function handleSearchTypeChange(newType) { function handleSearchTypeChange(newType) {
searchType = newType; searchType = newType;
handleSearch();
} }
function clearSearch() { function clearSearch() {
searchQuery = ''; searchCode = '';
searchName = '';
currentPage = 1; currentPage = 1;
loadTests(); tests = [];
totalItems = 0;
totalPages = 0;
hasMore = false;
}
function hasSearchQuery() {
return searchCode.trim() || searchName.trim();
} }
async function loadTests(reset = false) { async function loadTests(reset = false) {
@ -241,45 +242,55 @@
</div> </div>
<div class="mb-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center"> <div class="mb-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div class="flex gap-2"> <div class="flex-1 max-w-2xl flex gap-2">
<button <label class="input input-sm input-bordered w-1/2 flex items-center gap-2">
class="btn btn-sm {searchType === 'all' ? 'btn-primary' : 'btn-ghost'}" <Hash class="w-4 h-4 text-gray-400" />
onclick={() => handleSearchTypeChange('all')}
>
All
</button>
<button
class="btn btn-sm {searchType === 'code' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => handleSearchTypeChange('code')}
>
Code
</button>
<button
class="btn btn-sm {searchType === 'name' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => handleSearchTypeChange('name')}
>
Name
</button>
</div>
<div class="flex-1 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input <input
type="text" type="text"
class="grow" class="grow"
placeholder={searchType === 'code' ? 'Search by code...' : searchType === 'name' ? 'Search by name...' : 'Search by code or name...'} placeholder="Test code..."
bind:value={searchQuery} bind:value={searchCode}
oninput={handleSearchInput} onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/> />
{#if searchQuery} {#if searchCode}
<button <button
class="btn btn-ghost btn-xs btn-circle" class="btn btn-ghost btn-xs btn-circle"
onclick={clearSearch} onclick={() => searchCode = ''}
> >
× ×
</button> </button>
{/if} {/if}
</label> </label>
<label class="input input-sm input-bordered w-1/2 flex items-center gap-2">
<Type class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Test name..."
bind:value={searchName}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
{#if searchName}
<button
class="btn btn-ghost btn-xs btn-circle"
onclick={() => searchName = ''}
>
×
</button>
{/if}
</label>
<button
class="btn btn-primary btn-sm"
onclick={handleSearch}
disabled={loading}
>
{#if loading}
<Loader2 class="w-4 h-4 animate-spin mr-2" />
{:else}
<Search class="w-4 h-4 mr-2" />
{/if}
Search
</button>
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
{totalItems} total {totalItems} total
@ -298,26 +309,20 @@
<Microscope class="w-12 h-12 text-gray-400" /> <Microscope class="w-12 h-12 text-gray-400" />
</div> </div>
<h3 class="text-base font-semibold text-gray-700 mb-1"> <h3 class="text-base font-semibold text-gray-700 mb-1">
{searchQuery ? 'No tests found' : 'No tests yet'} {hasSearchQuery() ? 'No tests found' : 'Search for tests'}
</h3> </h3>
<p class="text-xs text-gray-500 text-center max-w-sm mb-4"> <p class="text-xs text-gray-500 text-center max-w-sm mb-4">
{searchQuery {hasSearchQuery()
? `No tests matching "${searchQuery}". Try a different search term.` ? `No tests matching your search. Try a different search term.`
: 'Get started by adding your first laboratory test.'} : 'Enter a search term above and click Search to find laboratory tests.'}
</p> </p>
{#if !searchQuery}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add First Test
</button>
{/if}
</div> </div>
{:else} {:else}
<DataTable <DataTable
{columns} {columns}
data={filteredTests} data={filteredTests}
loading={loading} loading={loading}
emptyMessage={searchQuery ? 'No tests found matching your search' : 'No tests yet'} emptyMessage={hasSearchQuery() ? 'No tests found matching your search' : 'No tests yet'}
hover={true} hover={true}
bordered={false} bordered={false}
> >

View File

@ -1,6 +1,6 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Info, Settings, Calculator, Users, Link, Hash, Type } from 'lucide-svelte'; import { Info, Settings, Calculator, Users, Link, Hash, Type, AlertTriangle } from 'lucide-svelte';
import { fetchTest, createTest, updateTest, validateTestCode, validateTestName } from '$lib/api/tests.js'; import { fetchTest, createTest, updateTest, validateTestCode, validateTestName } from '$lib/api/tests.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js'; import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
@ -11,6 +11,7 @@
import MappingsTab from './tabs/MappingsTab.svelte'; import MappingsTab from './tabs/MappingsTab.svelte';
import RefNumTab from './tabs/RefNumTab.svelte'; import RefNumTab from './tabs/RefNumTab.svelte';
import RefTxtTab from './tabs/RefTxtTab.svelte'; import RefTxtTab from './tabs/RefTxtTab.svelte';
import ThresholdTab from './tabs/ThresholdTab.svelte';
let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props(); let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props();
@ -34,6 +35,7 @@
{ id: 'group', label: 'Group Members', component: Users }, { 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: 'refnum', label: 'Num Refs', component: Hash },
{ id: 'threshold', label: 'Thresholds', component: AlertTriangle },
{ id: 'reftxt', label: 'Txt Refs', component: Type } { id: 'reftxt', label: 'Txt Refs', component: Type }
]; ];
@ -48,8 +50,12 @@
if (tab.id === 'calc') return type === 'CALC'; if (tab.id === 'calc') return type === 'CALC';
if (tab.id === 'group') return type === 'GROUP'; if (tab.id === 'group') return type === 'GROUP';
if (tab.id === 'refnum') { if (tab.id === 'refnum') {
// Show for TEST/PARAM with numeric result types and RANGE/THOLD ref types // Show for TEST/PARAM with numeric result types and RANGE ref type
return ['TEST', 'PARAM'].includes(type) && ['NMRIC', 'RANGE'].includes(resultType) && ['RANGE', 'THOLD'].includes(refType); return ['TEST', 'PARAM'].includes(type) && ['NMRIC', 'RANGE'].includes(resultType) && refType === 'RANGE';
}
if (tab.id === 'threshold') {
// Show for TEST/PARAM with numeric result types and THOLD ref type
return ['TEST', 'PARAM'].includes(type) && ['NMRIC', 'RANGE'].includes(resultType) && refType === 'THOLD';
} }
if (tab.id === 'reftxt') { if (tab.id === 'reftxt') {
// Show for TEST/PARAM with TEXT result type // Show for TEST/PARAM with TEXT result type
@ -386,6 +392,11 @@
bind:formData bind:formData
bind:isDirty bind:isDirty
/> />
{:else if currentTab === 'threshold'}
<ThresholdTab
bind:formData
bind:isDirty
/>
{:else if currentTab === 'reftxt'} {:else if currentTab === 'reftxt'}
<RefTxtTab <RefTxtTab
bind:formData bind:formData

View File

@ -1,24 +1,37 @@
<script> <script>
import { Plus, Trash2, Hash, Calculator, Edit2 } from 'lucide-svelte'; import { Plus, Trash2, Hash, Edit2, X, ChevronDown, ChevronUp } from 'lucide-svelte';
import { valueSets } from '$lib/stores/valuesets.js';
import { onMount } from 'svelte';
let { formData = $bindable(), isDirty = $bindable(false) } = $props(); let { formData = $bindable(), isDirty = $bindable(false) } = $props();
// Simple inline form state - only Low/High visible, rest collapsed let isEditing = $state(false);
let editingIndex = $state(null);
let validationError = $state('');
let showAdvanced = $state(false);
let specimenTypes = $state([]);
let simpleRefNum = $state({ let simpleRefNum = $state({
NumRefType: 'REF',
Low: '', Low: '',
High: '', High: '',
RangeType: 'RANGE',
Sex: '0', Sex: '0',
AgeStart: '', AgeStart: '',
AgeEnd: '', AgeEnd: '',
AgeUnit: 'years', AgeUnit: 'years',
LowSign: 'GE', SpcType: '',
HighSign: 'LE' Display: 0,
Flag: '',
Interpretation: '',
Notes: ''
}); });
let validationErrors = $state({ const numRefTypeOptions = [
simple: '' { value: 'REF', label: 'Reference' },
}); { value: 'CRTC', label: 'Critical' },
{ value: 'VAL', label: 'Validation' },
{ value: 'RERUN', label: 'Rerun' }
];
const sexOptions = [ const sexOptions = [
{ value: '0', label: 'All' }, { value: '0', label: 'All' },
@ -26,14 +39,6 @@
{ value: '2', label: 'Male' } { value: '2', label: 'Male' }
]; ];
const signOptions = [
{ value: 'EQ', label: '=' },
{ value: 'LT', label: '<' },
{ value: 'LE', label: '≤' },
{ value: 'GT', label: '>' },
{ value: 'GE', label: '≥' }
];
const ageUnits = [ const ageUnits = [
{ value: 'days', label: 'Days' }, { value: 'days', label: 'Days' },
{ value: 'weeks', label: 'Weeks' }, { value: 'weeks', label: 'Weeks' },
@ -41,11 +46,23 @@
{ value: 'years', label: 'Years' } { value: 'years', label: 'Years' }
]; ];
onMount(async () => {
try {
const items = await valueSets.load('specimen_type');
specimenTypes = items.map(item => ({
value: item.value || '',
label: item.label || ''
}));
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function handleFieldChange() { function handleFieldChange() {
isDirty = true; isDirty = true;
validationError = '';
} }
// Convert age to days for storage
function convertAgeToDays(value, unit) { function convertAgeToDays(value, unit) {
if (!value && value !== 0) return null; if (!value && value !== 0) return null;
const num = parseInt(value); const num = parseInt(value);
@ -58,7 +75,6 @@
} }
} }
// Convert days to display unit
function convertDaysToUnit(days, unit) { function convertDaysToUnit(days, unit) {
if (days === null || days === undefined) return ''; if (days === null || days === undefined) return '';
switch (unit) { switch (unit) {
@ -70,19 +86,25 @@
} }
} }
function resetSimpleRefNum() { function resetForm() {
simpleRefNum = { simpleRefNum = {
NumRefType: 'REF',
Low: '', Low: '',
High: '', High: '',
RangeType: formData.details?.RefType === 'THOLD' ? 'THOLD' : 'RANGE',
Sex: '0', Sex: '0',
AgeStart: '', AgeStart: '',
AgeEnd: '', AgeEnd: '',
AgeUnit: 'years', AgeUnit: 'years',
LowSign: 'GE', SpcType: '',
HighSign: 'LE' Display: 0,
Flag: '',
Interpretation: '',
Notes: ''
}; };
validationErrors.simple = ''; validationError = '';
isEditing = false;
editingIndex = null;
showAdvanced = false;
} }
function validateSimpleRefNum() { function validateSimpleRefNum() {
@ -97,7 +119,6 @@
return { valid: false, error: 'Low value must be less than high value' }; return { valid: false, error: 'Low value must be less than high value' };
} }
// Validate age range if provided
if (simpleRefNum.AgeStart !== '' || simpleRefNum.AgeEnd !== '') { if (simpleRefNum.AgeStart !== '' || simpleRefNum.AgeEnd !== '') {
const ageStart = simpleRefNum.AgeStart !== '' ? parseInt(simpleRefNum.AgeStart) : null; const ageStart = simpleRefNum.AgeStart !== '' ? parseInt(simpleRefNum.AgeStart) : null;
const ageEnd = simpleRefNum.AgeEnd !== '' ? parseInt(simpleRefNum.AgeEnd) : null; const ageEnd = simpleRefNum.AgeEnd !== '' ? parseInt(simpleRefNum.AgeEnd) : null;
@ -110,35 +131,71 @@
return { valid: true }; return { valid: true };
} }
function addSimpleRefNum() { function saveRange() {
const validation = validateSimpleRefNum(); const validation = validateSimpleRefNum();
if (!validation.valid) { if (!validation.valid) {
validationErrors.simple = validation.error; validationError = validation.error;
return; return;
} }
const newRef = { const newRef = {
NumRefType: 'REF', NumRefType: simpleRefNum.NumRefType,
RangeType: simpleRefNum.RangeType, RangeType: 'RANGE',
Sex: simpleRefNum.Sex, Sex: simpleRefNum.Sex,
AgeStart: convertAgeToDays(simpleRefNum.AgeStart, simpleRefNum.AgeUnit) || 0, AgeStart: convertAgeToDays(simpleRefNum.AgeStart, simpleRefNum.AgeUnit) || 0,
AgeEnd: convertAgeToDays(simpleRefNum.AgeEnd, simpleRefNum.AgeUnit) || 54750, AgeEnd: convertAgeToDays(simpleRefNum.AgeEnd, simpleRefNum.AgeUnit) || 54750,
Low: simpleRefNum.Low !== '' ? parseFloat(simpleRefNum.Low) : null, Low: simpleRefNum.Low !== '' ? parseFloat(simpleRefNum.Low) : null,
High: simpleRefNum.High !== '' ? parseFloat(simpleRefNum.High) : null, High: simpleRefNum.High !== '' ? parseFloat(simpleRefNum.High) : null,
LowSign: simpleRefNum.LowSign, LowSign: null,
HighSign: simpleRefNum.HighSign, HighSign: null,
Flag: null, SpcType: simpleRefNum.SpcType || null,
Interpretation: null Display: simpleRefNum.Display,
Flag: simpleRefNum.Flag || null,
Interpretation: simpleRefNum.Interpretation || null,
Notes: simpleRefNum.Notes || null
}; };
if (isEditing && editingIndex !== null) {
const updated = formData.refnum?.map((r, i) => i === editingIndex ? newRef : r) || [];
formData.refnum = updated;
} else {
formData.refnum = [...(formData.refnum || []), newRef]; formData.refnum = [...(formData.refnum || []), newRef];
resetSimpleRefNum(); }
resetForm();
handleFieldChange(); handleFieldChange();
} }
function editRange(index) {
const ref = formData.refnum[index];
simpleRefNum = {
NumRefType: ref.NumRefType || 'REF',
Low: ref.Low !== null ? String(ref.Low) : '',
High: ref.High !== null ? String(ref.High) : '',
Sex: ref.Sex || '0',
AgeStart: convertDaysToUnit(ref.AgeStart, 'years'),
AgeEnd: convertDaysToUnit(ref.AgeEnd, 'years'),
AgeUnit: 'years',
SpcType: ref.SpcType || '',
Display: ref.Display === 1 || ref.Display === '1' || ref.Display === true ? 1 : 0,
Flag: ref.Flag || '',
Interpretation: ref.Interpretation || '',
Notes: ref.Notes || ''
};
isEditing = true;
editingIndex = index;
validationError = '';
showAdvanced = true;
}
function removeRange(index) { function removeRange(index) {
const newRanges = formData.refnum?.filter((_, i) => i !== index) || []; const newRanges = formData.refnum?.filter((_, i) => i !== index) || [];
formData.refnum = newRanges; formData.refnum = newRanges;
if (isEditing && editingIndex === index) {
resetForm();
} else if (isEditing && editingIndex > index) {
editingIndex--;
}
handleFieldChange(); handleFieldChange();
} }
@ -146,8 +203,8 @@
return sexOptions.find(s => s.value === sex)?.label || sex; return sexOptions.find(s => s.value === sex)?.label || sex;
} }
function getSignLabel(sign) { function getNumRefTypeLabel(numRefType) {
return signOptions.find(s => s.value === sign)?.label || sign; return numRefTypeOptions.find(opt => opt.value === numRefType)?.label || numRefType;
} }
function getAgeDisplay(ageDays) { function getAgeDisplay(ageDays) {
@ -157,10 +214,19 @@
return `${Math.floor(ageDays / 365)}y`; return `${Math.floor(ageDays / 365)}y`;
} }
/** function getAgeUnitSuffix() {
* Validate all reference ranges const unit = simpleRefNum.AgeUnit;
* @returns {boolean} if (unit === 'days') return 'D';
*/ if (unit === 'weeks') return 'W';
if (unit === 'months') return 'M';
if (unit === 'years') return 'Y';
return 'Y';
}
function getDisplayLabel(value) {
return value === 1 || value === '1' || value === true ? 'Y' : 'N';
}
export function validateAll() { export function validateAll() {
const seen = new Set(); const seen = new Set();
for (const range of formData.refnum || []) { for (const range of formData.refnum || []) {
@ -174,350 +240,259 @@
} }
</script> </script>
<div class="space-y-6"> <div class="space-y-5">
<h2 class="text-lg font-semibold text-gray-800">Numeric Reference Ranges</h2> <h2 class="text-lg font-semibold text-gray-800">Numeric Reference Ranges</h2>
<div class="alert alert-info text-sm"> <!-- Form Section -->
<Hash class="w-4 h-4" /> <div class="bg-base-100 border border-base-300 rounded-lg p-3">
<div> {#if validationError}
<strong>Numeric Ranges:</strong> Define normal, critical, and validation ranges for numeric test results. <div class="alert alert-error alert-sm mb-2">
</div> <span>{validationError}</span>
</div>
<!-- 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> </div>
{/if} {/if}
<!-- Primary: Just Low and High values --> <!-- Main Fields -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-[0.9fr_0.8fr_0.9fr_0.9fr_0.6fr_1fr_1fr_auto] gap-2 items-end">
{#if simpleRefNum.RangeType === 'THOLD'} <!-- NumRefType -->
<!-- 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 class="w-full">
<summary class="text-sm text-gray-600 cursor-pointer hover:text-gray-800">
Advanced Options
</summary>
<div class="mt-2 flex flex-wrap items-end gap-2 justify-between">
<div class="flex flex-wrap items-end gap-2">
<div class="flex flex-col"> <div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Type</label> <label class="text-xs text-gray-600 mb-0.5">Type</label>
<select class="select select-xs select-bordered w-20" bind:value={simpleRefNum.RangeType}> <select class="select select-sm select-bordered w-20" bind:value={simpleRefNum.NumRefType}>
<option value="RANGE">Range</option> {#each numRefTypeOptions as opt (opt.value)}
<option value="THOLD">Thold</option> <option value={opt.value}>{opt.label}</option>
{/each}
</select> </select>
</div> </div>
<!-- Sex -->
<div class="flex flex-col"> <div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Sex</label> <label class="text-xs text-gray-600 mb-0.5">Sex</label>
<select class="select select-xs select-bordered w-16" bind:value={simpleRefNum.Sex}>
{#each sexOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Age Unit</label>
<select class="select select-xs select-bordered w-16" bind:value={simpleRefNum.AgeUnit}>
{#each ageUnits as opt (opt.value)}
<option value={opt.value}>{opt.label.substring(0,1)}</option>
{/each}
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">From</label>
<input
type="number"
class="input input-xs input-bordered w-16"
bind:value={simpleRefNum.AgeStart}
min="0"
/>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">To</label>
<input
type="number"
class="input input-xs input-bordered w-16"
bind:value={simpleRefNum.AgeEnd}
min="0"
/>
</div>
</div>
<!-- Low/High signs for THOLD -->
{#if simpleRefNum.RangeType === 'THOLD'}
<div class="flex flex-wrap items-end gap-2">
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Low Sign</label>
<select class="select select-xs select-bordered w-14" bind:value={simpleRefNum.LowSign}>
{#each signOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">High Sign</label>
<select class="select select-xs select-bordered w-14" bind:value={simpleRefNum.HighSign}>
{#each signOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
{/if}
</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">
{#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>
</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}> <select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.Sex}>
{#each sexOptions as opt (opt.value)} {#each sexOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option> <option value={opt.value}>{opt.label}</option>
{/each} {/each}
</select> </select>
</div> </div>
<button class="btn btn-sm btn-primary" onclick={addSimpleRefNum}>
<!-- Age From with Suffix -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">From</label>
<label class="input input-sm input-bordered flex items-center gap-1 w-full">
<input
type="number"
class="grow bg-transparent outline-none"
bind:value={simpleRefNum.AgeStart}
min="0"
placeholder="0"
/>
<span class="text-xs text-gray-500">{getAgeUnitSuffix()}</span>
</label>
</div>
<!-- Age To with Suffix -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">To</label>
<label class="input input-sm input-bordered flex items-center gap-1 w-full">
<input
type="number"
class="grow bg-transparent outline-none"
bind:value={simpleRefNum.AgeEnd}
min="0"
placeholder="150"
/>
<span class="text-xs text-gray-500">{getAgeUnitSuffix()}</span>
</label>
</div>
<!-- Age Unit -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">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>
<!-- Low Value -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Low</label>
<input
type="number"
step="0.01"
class="input input-sm input-bordered w-full"
bind:value={simpleRefNum.Low}
/>
</div>
<!-- High Value -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">High</label>
<input
type="number"
step="0.01"
class="input input-sm input-bordered w-full"
bind:value={simpleRefNum.High}
/>
</div>
<!-- Action Buttons -->
<div class="flex items-end justify-center">
<div class="flex gap-1">
{#if isEditing}
<button class="btn btn-sm btn-ghost" onclick={resetForm}>
<X class="w-4 h-4" />
</button>
{/if}
<button class="btn btn-sm btn-primary" onclick={saveRange}>
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
</button> </button>
</div> </div>
</div>
</div>
<!-- Optional: Range Type and Age --> <!-- Toggle Advanced -->
<details class="mt-2 w-full"> <div class="mt-2 pt-2 border-t border-base-200">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-800"> <button
Advanced class="btn btn-xs btn-ghost text-gray-500 flex items-center gap-1"
</summary> onclick={() => showAdvanced = !showAdvanced}
<div class="mt-2 flex flex-wrap items-end gap-2 justify-between"> >
<div class="flex flex-wrap items-end gap-2"> {#if showAdvanced}
<div class="flex flex-col"> <ChevronUp class="w-3 h-3" />
<label class="text-xs text-gray-600 mb-0.5">Type</label> <span>Hide Advanced</span>
<select class="select select-xs select-bordered w-20" bind:value={simpleRefNum.RangeType}> {:else}
<option value="RANGE">Range</option> <ChevronDown class="w-3 h-3" />
<option value="THOLD">Thold</option> <span>Show Advanced</span>
</select> {/if}
</button>
</div> </div>
<!-- Advanced Fields (Hidden by default) -->
{#if showAdvanced}
<div class="mt-3 pt-3 border-t border-base-200 space-y-2">
<!-- Row 1: Specimen, Flag, Display, Interpretation -->
<div class="grid grid-cols-4 gap-2">
<!-- SpcType -->
<div class="flex flex-col"> <div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Age Unit</label> <label class="text-xs text-gray-600 mb-0.5">Specimen</label>
<select class="select select-xs select-bordered w-16" bind:value={simpleRefNum.AgeUnit}> <select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.SpcType}>
{#each ageUnits as opt (opt.value)} <option value="">Select...</option>
<option value={opt.value}>{opt.label.substring(0,1)}</option> {#each specimenTypes as opt (opt.value)}
{/each}
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">From</label>
<input
type="number"
class="input input-xs input-bordered w-16"
bind:value={simpleRefNum.AgeStart}
placeholder="0"
min="0"
/>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">To</label>
<input
type="number"
class="input input-xs input-bordered w-16"
bind:value={simpleRefNum.AgeEnd}
placeholder="150"
min="0"
/>
</div>
</div>
<!-- Low/High signs for THOLD -->
{#if simpleRefNum.RangeType === 'THOLD'}
<div class="flex flex-wrap items-end gap-2">
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Low Sign</label>
<select class="select select-xs select-bordered w-14" bind:value={simpleRefNum.LowSign}>
{#each signOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option> <option value={opt.value}>{opt.label}</option>
{/each} {/each}
</select> </select>
</div> </div>
<!-- Flag -->
<div class="flex flex-col"> <div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">High Sign</label> <label class="text-xs text-gray-600 mb-0.5">Flag</label>
<select class="select select-xs select-bordered w-14" bind:value={simpleRefNum.HighSign}> <input
{#each signOptions as opt (opt.value)} type="text"
<option value={opt.value}>{opt.label}</option> class="input input-sm input-bordered w-full"
{/each} bind:value={simpleRefNum.Flag}
placeholder="H, L, N"
maxlength="10"
/>
</div>
<!-- Display (Y/N Select) -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Display</label>
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.Display}>
<option value={1}>Y</option>
<option value={0}>N</option>
</select> </select>
</div> </div>
<!-- Interpretation -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Interpretation</label>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={simpleRefNum.Interpretation}
placeholder="Interpretation..."
/>
</div> </div>
{/if}
</div> </div>
</details>
<!-- Row 2: Notes -->
<div>
<label class="text-xs text-gray-600 mb-0.5">Notes</label>
<textarea
class="textarea textarea-sm textarea-bordered w-full"
bind:value={simpleRefNum.Notes}
placeholder="Additional notes..."
rows="2"
></textarea>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<!-- List Section - Bottom -->
<div class="space-y-3">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">
Current Ranges ({formData.refnum?.length || 0})
</h3>
{#if !formData.refnum || formData.refnum.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg">
<Hash 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 using the form above</p>
</div>
{:else}
<div class="overflow-x-auto border border-base-200 rounded-lg">
<table class="table table-sm table-compact">
<thead>
<tr class="bg-base-200">
<th class="w-16">Type</th>
<th class="w-24">Range</th>
<th class="w-16">Sex</th>
<th class="w-24">Age</th>
<th class="w-20">Specimen</th>
<th class="w-16">Flag</th>
<th class="w-12">Disp</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each formData.refnum as ref, idx (idx)}
<tr class="hover:bg-base-100">
<td class="text-xs">{getNumRefTypeLabel(ref.NumRefType)}</td>
<td class="font-mono text-sm">
{ref.Low !== null && ref.Low !== '' ? ref.Low : '—'}
-
{ref.High !== null && ref.High !== '' ? ref.High : '—'}
</td>
<td>{getSexLabel(ref.Sex)}</td>
<td class="text-sm">{getAgeDisplay(ref.AgeStart)}-{getAgeDisplay(ref.AgeEnd)}</td>
<td class="text-sm">{ref.SpcType || '—'}</td>
<td class="text-sm">{ref.Flag || '—'}</td>
<td class="text-sm">{getDisplayLabel(ref.Display)}</td>
<td>
<div class="flex justify-center gap-1">
<button
class="btn btn-ghost btn-xs"
onclick={() => editRange(idx)}
title="Edit Range"
>
<Edit2 class="w-3 h-3" />
</button>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => removeRange(idx)}
title="Remove Range"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div> </div>

View File

@ -1,17 +1,17 @@
<script> <script>
import { Plus, Trash2, Type } from 'lucide-svelte'; import { Plus, Trash2, Type, Edit2, X } from 'lucide-svelte';
import Modal from '$lib/components/Modal.svelte';
let { formData = $bindable(), isDirty = $bindable(false) } = $props(); let { formData = $bindable(), isDirty = $bindable(false) } = $props();
let modalOpen = $state(false); let isEditing = $state(false);
let modalMode = $state('create'); let editingIndex = $state(null);
let selectedRangeIndex = $state(null); let validationError = $state('');
let editingRange = $state({
let simpleRefTxt = $state({
TxtRefType: 'Normal', TxtRefType: 'Normal',
Sex: '0', Sex: '0',
AgeStart: 0, AgeStart: '',
AgeEnd: 150, AgeEnd: '',
AgeUnit: 'years', AgeUnit: 'years',
RefTxt: '' RefTxt: ''
}); });
@ -37,9 +37,9 @@
function handleFieldChange() { function handleFieldChange() {
isDirty = true; isDirty = true;
validationError = '';
} }
// Convert age to days for storage
function convertAgeToDays(value, unit) { function convertAgeToDays(value, unit) {
if (!value && value !== 0) return null; if (!value && value !== 0) return null;
const num = parseInt(value); const num = parseInt(value);
@ -52,9 +52,8 @@
} }
} }
// Convert days to display unit
function convertDaysToUnit(days, unit) { function convertDaysToUnit(days, unit) {
if (days === null || days === undefined) return 0; if (days === null || days === undefined) return '';
switch (unit) { switch (unit) {
case 'days': return days; case 'days': return days;
case 'weeks': return Math.floor(days / 7); case 'weeks': return Math.floor(days / 7);
@ -64,55 +63,87 @@
} }
} }
function openAddRange() { function resetForm() {
modalMode = 'create'; simpleRefTxt = {
selectedRangeIndex = null;
editingRange = {
TxtRefType: 'Normal', TxtRefType: 'Normal',
Sex: '0', Sex: '0',
AgeStart: 0, AgeStart: '',
AgeEnd: 150, AgeEnd: '',
AgeUnit: 'years', AgeUnit: 'years',
RefTxt: '' RefTxt: ''
}; };
modalOpen = true; validationError = '';
isEditing = false;
editingIndex = null;
} }
function openEditRange(index) { function validateSimpleRefTxt() {
modalMode = 'edit'; if (!simpleRefTxt.RefTxt?.trim()) {
selectedRangeIndex = index; return { valid: false, error: 'Reference text is required' };
const ref = formData.reftxt[index]; }
editingRange = {
...ref, if (simpleRefTxt.AgeStart !== '' || simpleRefTxt.AgeEnd !== '') {
AgeUnit: 'years', const ageStart = simpleRefTxt.AgeStart !== '' ? parseInt(simpleRefTxt.AgeStart) : null;
AgeStart: convertDaysToUnit(ref.AgeStart, 'years'), const ageEnd = simpleRefTxt.AgeEnd !== '' ? parseInt(simpleRefTxt.AgeEnd) : null;
AgeEnd: convertDaysToUnit(ref.AgeEnd, 'years')
if (ageStart !== null && ageEnd !== null && ageStart >= ageEnd) {
return { valid: false, error: 'Age start must be less than age end' };
}
}
return { valid: true };
}
function saveRange() {
const validation = validateSimpleRefTxt();
if (!validation.valid) {
validationError = validation.error;
return;
}
const newRef = {
TxtRefType: simpleRefTxt.TxtRefType,
Sex: simpleRefTxt.Sex,
AgeStart: convertAgeToDays(simpleRefTxt.AgeStart, simpleRefTxt.AgeUnit) || 0,
AgeEnd: convertAgeToDays(simpleRefTxt.AgeEnd, simpleRefTxt.AgeUnit) || 54750,
RefTxt: simpleRefTxt.RefTxt.trim(),
Flag: simpleRefTxt.TxtRefType === 'Normal' ? 'N' : (simpleRefTxt.TxtRefType === 'Abnormal' ? 'A' : 'C')
}; };
modalOpen = true;
if (isEditing && editingIndex !== null) {
const updated = formData.reftxt?.map((r, i) => i === editingIndex ? newRef : r) || [];
formData.reftxt = updated;
} else {
formData.reftxt = [...(formData.reftxt || []), newRef];
}
resetForm();
handleFieldChange();
}
function editRange(index) {
const ref = formData.reftxt[index];
simpleRefTxt = {
TxtRefType: ref.TxtRefType || 'Normal',
Sex: ref.Sex || '0',
AgeStart: convertDaysToUnit(ref.AgeStart, 'years'),
AgeEnd: convertDaysToUnit(ref.AgeEnd, 'years'),
AgeUnit: 'years',
RefTxt: ref.RefTxt || ''
};
isEditing = true;
editingIndex = index;
validationError = '';
} }
function removeRange(index) { function removeRange(index) {
const newRanges = formData.reftxt?.filter((_, i) => i !== index) || []; const newRanges = formData.reftxt?.filter((_, i) => i !== index) || [];
formData.reftxt = newRanges; formData.reftxt = newRanges;
handleFieldChange(); if (isEditing && editingIndex === index) {
resetForm();
} else if (isEditing && editingIndex > index) {
editingIndex--;
} }
function saveRange() {
const dataToSave = {
...editingRange,
AgeStart: convertAgeToDays(editingRange.AgeStart, editingRange.AgeUnit),
AgeEnd: convertAgeToDays(editingRange.AgeEnd, editingRange.AgeUnit)
};
if (modalMode === 'create') {
formData.reftxt = [...(formData.reftxt || []), dataToSave];
} else {
const newRanges = formData.reftxt?.map((r, i) =>
i === selectedRangeIndex ? dataToSave : r
) || [];
formData.reftxt = newRanges;
}
modalOpen = false;
handleFieldChange(); handleFieldChange();
} }
@ -132,7 +163,7 @@
} }
</script> </script>
<div class="space-y-6"> <div class="space-y-5">
<h2 class="text-lg font-semibold text-gray-800">Text Reference Ranges</h2> <h2 class="text-lg font-semibold text-gray-800">Text Reference Ranges</h2>
<div class="alert alert-info text-sm"> <div class="alert alert-info text-sm">
@ -142,14 +173,131 @@
</div> </div>
</div> </div>
<div class="space-y-4"> <!-- Form Section - Top -->
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Ranges ({formData.reftxt?.length || 0})</h3> <div class="bg-base-100 border border-base-300 rounded-lg p-4 space-y-4">
<h3 class="text-sm font-semibold text-gray-700">
{isEditing ? 'Edit Reference' : 'Add New Reference'}
</h3>
{#if validationError}
<div class="alert alert-error alert-sm">
<span>{validationError}</span>
</div>
{/if}
<!-- Primary: Reference Text -->
<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>
<!-- Reference Type -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700">Type</label>
<select class="select select-sm select-bordered w-full" bind:value={simpleRefTxt.TxtRefType}>
{#each refTypes 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">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>
<!-- Advanced Options - Age -->
<div class="border-t pt-3">
<details class="w-full">
<summary class="text-sm text-gray-600 cursor-pointer hover:text-gray-800">
Age Range (Optional)
</summary>
<div class="mt-2 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">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>
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Age Unit</label>
<select class="select select-xs select-bordered w-24" bind:value={simpleRefTxt.AgeUnit}>
{#each ageUnits as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</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>
<!-- Action Buttons -->
<div class="flex justify-end gap-2 pt-2">
{#if isEditing}
<button class="btn btn-sm btn-ghost" onclick={resetForm}>
<X class="w-4 h-4 mr-1" />
Cancel
</button>
{/if}
<button class="btn btn-sm btn-primary" onclick={saveRange}>
<Plus class="w-4 h-4 mr-1" />
{isEditing ? 'Update' : 'Add Reference'}
</button>
</div>
</div>
<!-- List Section - Bottom -->
<div class="space-y-3">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">
Current References ({formData.reftxt?.length || 0})
</h3>
{#if !formData.reftxt || formData.reftxt.length === 0} {#if !formData.reftxt || formData.reftxt.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg"> <div class="text-center py-8 bg-base-200 rounded-lg">
<Type class="w-12 h-12 mx-auto text-gray-400 mb-2" /> <Type class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500">No text ranges defined</p> <p class="text-sm text-gray-500">No text references defined</p>
<p class="text-xs text-gray-400">Add reference ranges for this test</p> <p class="text-xs text-gray-400">Add reference text using the form above</p>
</div> </div>
{:else} {:else}
<div class="overflow-x-auto border border-base-200 rounded-lg"> <div class="overflow-x-auto border border-base-200 rounded-lg">
@ -164,32 +312,32 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each formData.reftxt as range, idx (idx)} {#each formData.reftxt as ref, idx (idx)}
<tr class="hover:bg-base-100"> <tr class="hover:bg-base-100">
<td> <td>
<span class="badge badge-xs <span class="badge badge-xs
{range.TxtRefType === 'Normal' ? 'badge-success' : {ref.TxtRefType === 'Normal' ? 'badge-success' :
range.TxtRefType === 'Abnormal' ? 'badge-warning' : ref.TxtRefType === 'Abnormal' ? 'badge-warning' :
'badge-error'}"> 'badge-error'}">
{getRefTypeLabel(range.TxtRefType)} {getRefTypeLabel(ref.TxtRefType)}
</span> </span>
</td> </td>
<td>{getSexLabel(range.Sex)}</td> <td>{getSexLabel(ref.Sex)}</td>
<td class="text-sm">{getAgeDisplay(range.AgeStart)}-{getAgeDisplay(range.AgeEnd)}</td> <td class="text-sm">{getAgeDisplay(ref.AgeStart)}-{getAgeDisplay(ref.AgeEnd)}</td>
<td class="font-mono text-sm">{range.RefTxt || '-'}</td> <td class="font-mono text-sm">{ref.RefTxt || '-'}</td>
<td> <td>
<div class="flex justify-center gap-1"> <div class="flex justify-center gap-1">
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
onclick={() => openEditRange(idx)} onclick={() => editRange(idx)}
title="Edit Range" title="Edit Reference"
> >
<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> <Edit2 class="w-3 h-3" />
</button> </button>
<button <button
class="btn btn-ghost btn-xs text-error" class="btn btn-ghost btn-xs text-error"
onclick={() => removeRange(idx)} onclick={() => removeRange(idx)}
title="Remove Range" title="Remove Reference"
> >
<Trash2 class="w-3 h-3" /> <Trash2 class="w-3 h-3" />
</button> </button>
@ -202,98 +350,4 @@
</div> </div>
{/if} {/if}
</div> </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>
</div>
<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>
<select class="select select-sm select-bordered" bind:value={editingRange.TxtRefType}>
{#each refTypes as rt (rt.value)}
<option value={rt.value}>{rt.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label text-sm">Sex</label>
<select class="select select-sm select-bordered" bind:value={editingRange.Sex}>
{#each sexOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- 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>
<div class="form-control">
<label class="label text-sm">
Reference Text
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
type="text"
class="input input-sm input-bordered"
bind:value={editingRange.RefTxt}
placeholder="e.g., Clear, Cloudy, Bloody"
maxlength="255"
/>
</div>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => modalOpen = false}>Cancel</button>
<button class="btn btn-primary" onclick={saveRange}>Save</button>
{/snippet}
</Modal>

View File

@ -1,46 +1,13 @@
<script> <script>
import { getResultTypeOptions, getRefTypeOptions, validateTypeCombination } from '$lib/api/tests.js'; import { getResultTypeOptions, getRefTypeOptions, validateTypeCombination } from '$lib/api/tests.js';
import { AlertCircle, Hash, Type, Plus, ExternalLink, Trash2 } from 'lucide-svelte'; import { AlertCircle } from 'lucide-svelte';
let { formData = $bindable(), disciplines = [], departments = [], isDirty = $bindable(false), onSwitchTab = null } = $props(); let { formData = $bindable(), disciplines = [], departments = [], isDirty = $bindable(false) } = $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({ 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 disciplineOptions = $derived(disciplines.map(d => ({ value: d.DisciplineID, label: d.DisciplineName })));
const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName }))); const departmentOptions = $derived(departments.map(d => ({ value: d.DepartmentID, label: d.DepartmentName })));
@ -56,25 +23,6 @@
return result; 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() { function handleFieldChange() {
isDirty = true; isDirty = true;
validationErrors.typeCombination = ''; validationErrors.typeCombination = '';
@ -96,182 +44,9 @@
formData.details.VSet = ''; formData.details.VSet = '';
} }
// Clear references when type changes
formData.refnum = [];
formData.reftxt = [];
resetSimpleRefNum();
resetSimpleRefTxt();
handleFieldChange(); 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() { export function validateAll() {
validationErrors.typeCombination = ''; validationErrors.typeCombination = '';
@ -535,363 +310,4 @@
</div> </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> </div>

View File

@ -0,0 +1,536 @@
<script>
import { Plus, Trash2, Hash, Edit2, X, ChevronDown, ChevronUp } from 'lucide-svelte';
import { valueSets } from '$lib/stores/valuesets.js';
import { onMount } from 'svelte';
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
let isEditing = $state(false);
let editingIndex = $state(null);
let validationError = $state('');
let showAdvanced = $state(false);
let specimenTypes = $state([]);
let thresholdData = $state({
NumRefType: 'REF',
Low: '',
High: '',
Sex: '0',
AgeStart: '',
AgeEnd: '',
AgeUnit: 'years',
LowSign: 'GE',
HighSign: 'LE',
SpcType: '',
Display: 0,
Flag: '',
Interpretation: '',
Notes: ''
});
const numRefTypeOptions = [
{ value: 'REF', label: 'Reference' },
{ value: 'CRTC', label: 'Critical' },
{ value: 'VAL', label: 'Validation' },
{ value: 'RERUN', label: 'Rerun' }
];
const sexOptions = [
{ value: '0', label: 'All' },
{ value: '1', label: 'Female' },
{ value: '2', label: 'Male' }
];
const signOptions = [
{ value: 'EQ', label: '=' },
{ value: 'LT', label: '<' },
{ value: 'LE', label: '≤' },
{ value: 'GT', label: '>' },
{ value: 'GE', label: '≥' }
];
const ageUnits = [
{ value: 'days', label: 'Days' },
{ value: 'weeks', label: 'Weeks' },
{ value: 'months', label: 'Months' },
{ value: 'years', label: 'Years' }
];
onMount(async () => {
try {
const items = await valueSets.load('specimen_type');
specimenTypes = items.map(item => ({
value: item.value || '',
label: item.label || ''
}));
} catch (err) {
console.error('Failed to load specimen types:', err);
}
});
function handleFieldChange() {
isDirty = true;
validationError = '';
}
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;
}
}
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 resetForm() {
thresholdData = {
NumRefType: 'REF',
Low: '',
High: '',
Sex: '0',
AgeStart: '',
AgeEnd: '',
AgeUnit: 'years',
LowSign: 'GE',
HighSign: 'LE',
SpcType: '',
Display: 0,
Flag: '',
Interpretation: '',
Notes: ''
};
validationError = '';
isEditing = false;
editingIndex = null;
showAdvanced = false;
}
function validateThreshold() {
if (!thresholdData.Low && !thresholdData.High) {
return { valid: false, error: 'Please enter at least one threshold value' };
}
const low = thresholdData.Low !== '' ? parseFloat(thresholdData.Low) : null;
const high = thresholdData.High !== '' ? parseFloat(thresholdData.High) : null;
if (low !== null && high !== null && low >= high) {
return { valid: false, error: 'Low threshold must be less than high threshold' };
}
if (thresholdData.AgeStart !== '' || thresholdData.AgeEnd !== '') {
const ageStart = thresholdData.AgeStart !== '' ? parseInt(thresholdData.AgeStart) : null;
const ageEnd = thresholdData.AgeEnd !== '' ? parseInt(thresholdData.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 saveThreshold() {
const validation = validateThreshold();
if (!validation.valid) {
validationError = validation.error;
return;
}
const newRef = {
NumRefType: thresholdData.NumRefType,
RangeType: 'THOLD',
Sex: thresholdData.Sex,
AgeStart: convertAgeToDays(thresholdData.AgeStart, thresholdData.AgeUnit) || 0,
AgeEnd: convertAgeToDays(thresholdData.AgeEnd, thresholdData.AgeUnit) || 54750,
Low: thresholdData.Low !== '' ? parseFloat(thresholdData.Low) : null,
High: thresholdData.High !== '' ? parseFloat(thresholdData.High) : null,
LowSign: thresholdData.LowSign,
HighSign: thresholdData.HighSign,
SpcType: thresholdData.SpcType || null,
Display: thresholdData.Display,
Flag: thresholdData.Flag || null,
Interpretation: thresholdData.Interpretation || null,
Notes: thresholdData.Notes || null
};
if (isEditing && editingIndex !== null) {
const updated = formData.refnum?.map((r, i) => i === editingIndex ? newRef : r) || [];
formData.refnum = updated;
} else {
formData.refnum = [...(formData.refnum || []), newRef];
}
resetForm();
handleFieldChange();
}
function editThreshold(index) {
const ref = formData.refnum[index];
thresholdData = {
NumRefType: ref.NumRefType || 'REF',
Low: ref.Low !== null ? String(ref.Low) : '',
High: ref.High !== null ? String(ref.High) : '',
Sex: ref.Sex || '0',
AgeStart: convertDaysToUnit(ref.AgeStart, 'years'),
AgeEnd: convertDaysToUnit(ref.AgeEnd, 'years'),
AgeUnit: 'years',
LowSign: ref.LowSign || 'GE',
HighSign: ref.HighSign || 'LE',
SpcType: ref.SpcType || '',
Display: ref.Display === 1 || ref.Display === '1' || ref.Display === true ? 1 : 0,
Flag: ref.Flag || '',
Interpretation: ref.Interpretation || '',
Notes: ref.Notes || ''
};
isEditing = true;
editingIndex = index;
validationError = '';
showAdvanced = true;
}
function removeThreshold(index) {
const newThresholds = formData.refnum?.filter((_, i) => i !== index) || [];
formData.refnum = newThresholds;
if (isEditing && editingIndex === index) {
resetForm();
} else if (isEditing && editingIndex > index) {
editingIndex--;
}
handleFieldChange();
}
function getSexLabel(sex) {
return sexOptions.find(s => s.value === sex)?.label || sex;
}
function getNumRefTypeLabel(numRefType) {
return numRefTypeOptions.find(opt => opt.value === numRefType)?.label || numRefType;
}
function getSignLabel(sign) {
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`;
}
function getAgeUnitSuffix() {
const unit = thresholdData.AgeUnit;
if (unit === 'days') return 'D';
if (unit === 'weeks') return 'W';
if (unit === 'months') return 'M';
if (unit === 'years') return 'Y';
return 'Y';
}
function getDisplayLabel(value) {
return value === 1 || value === '1' || value === true ? 'Y' : 'N';
}
export function validateAll() {
const seen = new Set();
for (const range of formData.refnum || []) {
const key = `${range.NumRefType}-${range.Sex}-${range.AgeStart}-${range.AgeEnd}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
}
return true;
}
</script>
<div class="space-y-5">
<h2 class="text-lg font-semibold text-gray-800">Threshold References</h2>
<!-- Form Section -->
<div class="bg-base-100 border border-base-300 rounded-lg p-3">
{#if validationError}
<div class="alert alert-error alert-sm mb-2">
<span>{validationError}</span>
</div>
{/if}
<!-- Main Fields -->
<div class="grid grid-cols-[0.9fr_0.8fr_0.9fr_0.9fr_0.6fr_0.9fr_0.9fr_auto] gap-2 items-end">
<!-- NumRefType -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Type</label>
<select class="select select-sm select-bordered w-20" bind:value={thresholdData.NumRefType}>
{#each numRefTypeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Sex -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Sex</label>
<select class="select select-sm select-bordered w-full" bind:value={thresholdData.Sex}>
{#each sexOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Age From with Suffix -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">From</label>
<label class="input input-sm input-bordered flex items-center gap-1 w-full">
<input
type="number"
class="grow bg-transparent outline-none"
bind:value={thresholdData.AgeStart}
min="0"
placeholder="0"
/>
<span class="text-xs text-gray-500">{getAgeUnitSuffix()}</span>
</label>
</div>
<!-- Age To with Suffix -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">To</label>
<label class="input input-sm input-bordered flex items-center gap-1 w-full">
<input
type="number"
class="grow bg-transparent outline-none"
bind:value={thresholdData.AgeEnd}
min="0"
placeholder="150"
/>
<span class="text-xs text-gray-500">{getAgeUnitSuffix()}</span>
</label>
</div>
<!-- Age Unit -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Unit</label>
<select class="select select-sm select-bordered w-20" bind:value={thresholdData.AgeUnit}>
{#each ageUnits as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Low Value with Sign -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Low</label>
<div class="flex gap-1">
<select class="select select-sm select-bordered w-12" bind:value={thresholdData.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={thresholdData.Low}
/>
</div>
</div>
<!-- High Value with Sign -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">High</label>
<div class="flex gap-1">
<select class="select select-sm select-bordered w-12" bind:value={thresholdData.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={thresholdData.High}
/>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-end justify-center">
<div class="flex gap-1">
{#if isEditing}
<button class="btn btn-sm btn-ghost" onclick={resetForm}>
<X class="w-4 h-4" />
</button>
{/if}
<button class="btn btn-sm btn-primary" onclick={saveThreshold}>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Toggle Advanced -->
<div class="mt-2 pt-2 border-t border-base-200">
<button
class="btn btn-xs btn-ghost text-gray-500 flex items-center gap-1"
onclick={() => showAdvanced = !showAdvanced}
>
{#if showAdvanced}
<ChevronUp class="w-3 h-3" />
<span>Hide Advanced</span>
{:else}
<ChevronDown class="w-3 h-3" />
<span>Show Advanced</span>
{/if}
</button>
</div>
<!-- Advanced Fields (Hidden by default) -->
{#if showAdvanced}
<div class="mt-3 pt-3 border-t border-base-200 space-y-2">
<!-- Row 1: Specimen, Flag, Display, Interpretation -->
<div class="grid grid-cols-4 gap-2">
<!-- SpcType -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Specimen</label>
<select class="select select-sm select-bordered w-full" bind:value={thresholdData.SpcType}>
<option value="">Select...</option>
{#each specimenTypes as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Flag -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Flag</label>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={thresholdData.Flag}
placeholder="H, L, N"
maxlength="10"
/>
</div>
<!-- Display (Y/N Select) -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Display</label>
<select class="select select-sm select-bordered w-full" bind:value={thresholdData.Display}>
<option value={1}>Y</option>
<option value={0}>N</option>
</select>
</div>
<!-- Interpretation -->
<div class="flex flex-col">
<label class="text-xs text-gray-600 mb-0.5">Interpretation</label>
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={thresholdData.Interpretation}
placeholder="Interpretation..."
/>
</div>
</div>
<!-- Row 2: Notes -->
<div>
<label class="text-xs text-gray-600 mb-0.5">Notes</label>
<textarea
class="textarea textarea-sm textarea-bordered w-full"
bind:value={thresholdData.Notes}
placeholder="Additional notes..."
rows="2"
></textarea>
</div>
</div>
{/if}
</div>
<!-- List Section - Bottom -->
<div class="space-y-3">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">
Current Thresholds ({formData.refnum?.length || 0})
</h3>
{#if !formData.refnum || formData.refnum.length === 0}
<div class="text-center py-8 bg-base-200 rounded-lg">
<Hash class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500">No threshold references defined</p>
<p class="text-xs text-gray-400">Add threshold references using the form above</p>
</div>
{:else}
<div class="overflow-x-auto border border-base-200 rounded-lg">
<table class="table table-sm table-compact">
<thead>
<tr class="bg-base-200">
<th class="w-16">Type</th>
<th class="w-24">Threshold</th>
<th class="w-16">Sex</th>
<th class="w-24">Age</th>
<th class="w-20">Specimen</th>
<th class="w-16">Flag</th>
<th class="w-12">Disp</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each formData.refnum as ref, idx (idx)}
<tr class="hover:bg-base-100">
<td class="text-xs">{getNumRefTypeLabel(ref.NumRefType)}</td>
<td class="font-mono text-sm">
{#if ref.LowSign}
{getSignLabel(ref.LowSign)}
{/if}
{ref.Low !== null && ref.Low !== '' ? ref.Low : '—'}
-
{#if ref.HighSign}
{getSignLabel(ref.HighSign)}
{/if}
{ref.High !== null && ref.High !== '' ? ref.High : '—'}
</td>
<td>{getSexLabel(ref.Sex)}</td>
<td class="text-sm">{getAgeDisplay(ref.AgeStart)}-{getAgeDisplay(ref.AgeEnd)}</td>
<td class="text-sm">{ref.SpcType || '—'}</td>
<td class="text-sm">{ref.Flag || '—'}</td>
<td class="text-sm">{getDisplayLabel(ref.Display)}</td>
<td>
<div class="flex justify-center gap-1">
<button
class="btn btn-ghost btn-xs"
onclick={() => editThreshold(idx)}
title="Edit Threshold"
>
<Edit2 class="w-3 h-3" />
</button>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => removeThreshold(idx)}
title="Remove Threshold"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>