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:
parent
b693f279e8
commit
96f3b14fd4
@ -20,7 +20,7 @@ import { get, post, patch } from './client.js';
|
||||
*/
|
||||
export async function fetchTests(params = {}) {
|
||||
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
|
||||
*/
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
*/
|
||||
export async function deleteTest(id) {
|
||||
return patch('/api/tests', {
|
||||
return patch('/api/test', {
|
||||
TestSiteID: id,
|
||||
IsActive: '0',
|
||||
});
|
||||
|
||||
@ -7,15 +7,15 @@
|
||||
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, 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
|
||||
let loading = $state(false);
|
||||
let tests = $state([]);
|
||||
let disciplines = $state([]);
|
||||
let departments = $state([]);
|
||||
let searchQuery = $state('');
|
||||
let searchType = $state('all'); // 'all', 'code', 'name'
|
||||
let searchCode = $state('');
|
||||
let searchName = $state('');
|
||||
let currentPage = $state(1);
|
||||
let perPage = $state(25);
|
||||
let totalItems = $state(0);
|
||||
@ -56,15 +56,13 @@
|
||||
|
||||
function getSearchParams() {
|
||||
const params = { page: currentPage, perPage };
|
||||
const query = searchQuery.trim();
|
||||
if (query) {
|
||||
if (searchType === 'code') {
|
||||
params.testCode = query;
|
||||
} else if (searchType === 'name') {
|
||||
params.testName = query;
|
||||
} else {
|
||||
params.search = query;
|
||||
const testCode = searchCode.trim();
|
||||
const testName = searchName.trim();
|
||||
if (testCode) {
|
||||
params.TestSiteCode = testCode;
|
||||
}
|
||||
if (testName) {
|
||||
params.TestSiteName = testName;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
@ -80,22 +78,25 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]);
|
||||
await Promise.all([loadDisciplines(), loadDepartments()]);
|
||||
});
|
||||
|
||||
function handleSearchInput() {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleSearchTypeChange(newType) {
|
||||
searchType = newType;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery = '';
|
||||
searchCode = '';
|
||||
searchName = '';
|
||||
currentPage = 1;
|
||||
loadTests();
|
||||
tests = [];
|
||||
totalItems = 0;
|
||||
totalPages = 0;
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
function hasSearchQuery() {
|
||||
return searchCode.trim() || searchName.trim();
|
||||
}
|
||||
|
||||
async function loadTests(reset = false) {
|
||||
@ -241,45 +242,55 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm {searchType === 'all' ? 'btn-primary' : 'btn-ghost'}"
|
||||
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" />
|
||||
<div class="flex-1 max-w-2xl flex gap-2">
|
||||
<label class="input input-sm input-bordered w-1/2 flex items-center gap-2">
|
||||
<Hash class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder={searchType === 'code' ? 'Search by code...' : searchType === 'name' ? 'Search by name...' : 'Search by code or name...'}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearchInput}
|
||||
placeholder="Test code..."
|
||||
bind:value={searchCode}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
{#if searchCode}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
onclick={clearSearch}
|
||||
onclick={() => searchCode = ''}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</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 class="text-sm text-gray-600">
|
||||
{totalItems} total
|
||||
@ -298,26 +309,20 @@
|
||||
<Microscope class="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-xs text-gray-500 text-center max-w-sm mb-4">
|
||||
{searchQuery
|
||||
? `No tests matching "${searchQuery}". Try a different search term.`
|
||||
: 'Get started by adding your first laboratory test.'}
|
||||
{hasSearchQuery()
|
||||
? `No tests matching your search. Try a different search term.`
|
||||
: 'Enter a search term above and click Search to find laboratory tests.'}
|
||||
</p>
|
||||
{#if !searchQuery}
|
||||
<button class="btn btn-primary" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add First Test
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable
|
||||
{columns}
|
||||
data={filteredTests}
|
||||
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}
|
||||
bordered={false}
|
||||
>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script>
|
||||
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 { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -11,6 +11,7 @@
|
||||
import MappingsTab from './tabs/MappingsTab.svelte';
|
||||
import RefNumTab from './tabs/RefNumTab.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();
|
||||
|
||||
@ -34,6 +35,7 @@
|
||||
{ id: 'group', label: 'Group Members', component: Users },
|
||||
{ id: 'mappings', label: 'Mappings', component: Link },
|
||||
{ id: 'refnum', label: 'Num Refs', component: Hash },
|
||||
{ id: 'threshold', label: 'Thresholds', component: AlertTriangle },
|
||||
{ id: 'reftxt', label: 'Txt Refs', component: Type }
|
||||
];
|
||||
|
||||
@ -48,8 +50,12 @@
|
||||
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);
|
||||
// Show for TEST/PARAM with numeric result types and RANGE ref type
|
||||
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') {
|
||||
// Show for TEST/PARAM with TEXT result type
|
||||
@ -386,6 +392,11 @@
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
/>
|
||||
{:else if currentTab === 'threshold'}
|
||||
<ThresholdTab
|
||||
bind:formData
|
||||
bind:isDirty
|
||||
/>
|
||||
{:else if currentTab === 'reftxt'}
|
||||
<RefTxtTab
|
||||
bind:formData
|
||||
|
||||
@ -1,24 +1,37 @@
|
||||
<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();
|
||||
|
||||
// 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({
|
||||
NumRefType: 'REF',
|
||||
Low: '',
|
||||
High: '',
|
||||
RangeType: 'RANGE',
|
||||
Sex: '0',
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years',
|
||||
LowSign: 'GE',
|
||||
HighSign: 'LE'
|
||||
SpcType: '',
|
||||
Display: 0,
|
||||
Flag: '',
|
||||
Interpretation: '',
|
||||
Notes: ''
|
||||
});
|
||||
|
||||
let validationErrors = $state({
|
||||
simple: ''
|
||||
});
|
||||
const numRefTypeOptions = [
|
||||
{ value: 'REF', label: 'Reference' },
|
||||
{ value: 'CRTC', label: 'Critical' },
|
||||
{ value: 'VAL', label: 'Validation' },
|
||||
{ value: 'RERUN', label: 'Rerun' }
|
||||
];
|
||||
|
||||
const sexOptions = [
|
||||
{ value: '0', label: 'All' },
|
||||
@ -26,14 +39,6 @@
|
||||
{ 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' },
|
||||
@ -41,11 +46,23 @@
|
||||
{ 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 = '';
|
||||
}
|
||||
|
||||
// Convert age to days for storage
|
||||
function convertAgeToDays(value, unit) {
|
||||
if (!value && value !== 0) return null;
|
||||
const num = parseInt(value);
|
||||
@ -58,7 +75,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Convert days to display unit
|
||||
function convertDaysToUnit(days, unit) {
|
||||
if (days === null || days === undefined) return '';
|
||||
switch (unit) {
|
||||
@ -70,19 +86,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetSimpleRefNum() {
|
||||
function resetForm() {
|
||||
simpleRefNum = {
|
||||
NumRefType: 'REF',
|
||||
Low: '',
|
||||
High: '',
|
||||
RangeType: formData.details?.RefType === 'THOLD' ? 'THOLD' : 'RANGE',
|
||||
Sex: '0',
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years',
|
||||
LowSign: 'GE',
|
||||
HighSign: 'LE'
|
||||
SpcType: '',
|
||||
Display: 0,
|
||||
Flag: '',
|
||||
Interpretation: '',
|
||||
Notes: ''
|
||||
};
|
||||
validationErrors.simple = '';
|
||||
validationError = '';
|
||||
isEditing = false;
|
||||
editingIndex = null;
|
||||
showAdvanced = false;
|
||||
}
|
||||
|
||||
function validateSimpleRefNum() {
|
||||
@ -97,7 +119,6 @@
|
||||
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;
|
||||
@ -110,35 +131,71 @@
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function addSimpleRefNum() {
|
||||
function saveRange() {
|
||||
const validation = validateSimpleRefNum();
|
||||
if (!validation.valid) {
|
||||
validationErrors.simple = validation.error;
|
||||
validationError = validation.error;
|
||||
return;
|
||||
}
|
||||
|
||||
const newRef = {
|
||||
NumRefType: 'REF',
|
||||
RangeType: simpleRefNum.RangeType,
|
||||
NumRefType: simpleRefNum.NumRefType,
|
||||
RangeType: 'RANGE',
|
||||
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
|
||||
LowSign: null,
|
||||
HighSign: null,
|
||||
SpcType: simpleRefNum.SpcType || 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];
|
||||
resetSimpleRefNum();
|
||||
}
|
||||
|
||||
resetForm();
|
||||
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) {
|
||||
const newRanges = formData.refnum?.filter((_, i) => i !== index) || [];
|
||||
formData.refnum = newRanges;
|
||||
if (isEditing && editingIndex === index) {
|
||||
resetForm();
|
||||
} else if (isEditing && editingIndex > index) {
|
||||
editingIndex--;
|
||||
}
|
||||
handleFieldChange();
|
||||
}
|
||||
|
||||
@ -146,8 +203,8 @@
|
||||
return sexOptions.find(s => s.value === sex)?.label || sex;
|
||||
}
|
||||
|
||||
function getSignLabel(sign) {
|
||||
return signOptions.find(s => s.value === sign)?.label || sign;
|
||||
function getNumRefTypeLabel(numRefType) {
|
||||
return numRefTypeOptions.find(opt => opt.value === numRefType)?.label || numRefType;
|
||||
}
|
||||
|
||||
function getAgeDisplay(ageDays) {
|
||||
@ -157,10 +214,19 @@
|
||||
return `${Math.floor(ageDays / 365)}y`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all reference ranges
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function getAgeUnitSuffix() {
|
||||
const unit = simpleRefNum.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 || []) {
|
||||
@ -174,350 +240,259 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Numeric Reference Ranges</h2>
|
||||
|
||||
<div class="alert alert-info text-sm">
|
||||
<Hash class="w-4 h-4" />
|
||||
<div>
|
||||
<strong>Numeric Ranges:</strong> Define normal, critical, and validation ranges for numeric test results.
|
||||
</div>
|
||||
</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>
|
||||
<!-- 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}
|
||||
|
||||
<!-- 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 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">
|
||||
<!-- Main Fields -->
|
||||
<div class="grid grid-cols-[0.9fr_0.8fr_0.9fr_0.9fr_0.6fr_1fr_1fr_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-xs select-bordered w-20" bind:value={simpleRefNum.RangeType}>
|
||||
<option value="RANGE">Range</option>
|
||||
<option value="THOLD">Thold</option>
|
||||
<select class="select select-sm select-bordered w-20" bind:value={simpleRefNum.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-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}>
|
||||
{#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}>
|
||||
|
||||
<!-- 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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Range Type and Age -->
|
||||
<details class="mt-2 w-full">
|
||||
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-800">
|
||||
Advanced
|
||||
</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">
|
||||
<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}>
|
||||
<option value="RANGE">Range</option>
|
||||
<option value="THOLD">Thold</option>
|
||||
</select>
|
||||
<!-- 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">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}
|
||||
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)}
|
||||
<label class="text-xs text-gray-600 mb-0.5">Specimen</label>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={simpleRefNum.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">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}
|
||||
<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={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>
|
||||
</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>
|
||||
{/if}
|
||||
</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>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
<script>
|
||||
import { Plus, Trash2, Type } from 'lucide-svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Plus, Trash2, Type, Edit2, X } from 'lucide-svelte';
|
||||
|
||||
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let selectedRangeIndex = $state(null);
|
||||
let editingRange = $state({
|
||||
let isEditing = $state(false);
|
||||
let editingIndex = $state(null);
|
||||
let validationError = $state('');
|
||||
|
||||
let simpleRefTxt = $state({
|
||||
TxtRefType: 'Normal',
|
||||
Sex: '0',
|
||||
AgeStart: 0,
|
||||
AgeEnd: 150,
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years',
|
||||
RefTxt: ''
|
||||
});
|
||||
@ -37,9 +37,9 @@
|
||||
|
||||
function handleFieldChange() {
|
||||
isDirty = true;
|
||||
validationError = '';
|
||||
}
|
||||
|
||||
// Convert age to days for storage
|
||||
function convertAgeToDays(value, unit) {
|
||||
if (!value && value !== 0) return null;
|
||||
const num = parseInt(value);
|
||||
@ -52,9 +52,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Convert days to display unit
|
||||
function convertDaysToUnit(days, unit) {
|
||||
if (days === null || days === undefined) return 0;
|
||||
if (days === null || days === undefined) return '';
|
||||
switch (unit) {
|
||||
case 'days': return days;
|
||||
case 'weeks': return Math.floor(days / 7);
|
||||
@ -64,55 +63,87 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openAddRange() {
|
||||
modalMode = 'create';
|
||||
selectedRangeIndex = null;
|
||||
editingRange = {
|
||||
function resetForm() {
|
||||
simpleRefTxt = {
|
||||
TxtRefType: 'Normal',
|
||||
Sex: '0',
|
||||
AgeStart: 0,
|
||||
AgeEnd: 150,
|
||||
AgeStart: '',
|
||||
AgeEnd: '',
|
||||
AgeUnit: 'years',
|
||||
RefTxt: ''
|
||||
};
|
||||
modalOpen = true;
|
||||
validationError = '';
|
||||
isEditing = false;
|
||||
editingIndex = null;
|
||||
}
|
||||
|
||||
function openEditRange(index) {
|
||||
modalMode = 'edit';
|
||||
selectedRangeIndex = index;
|
||||
const ref = formData.reftxt[index];
|
||||
editingRange = {
|
||||
...ref,
|
||||
AgeUnit: 'years',
|
||||
AgeStart: convertDaysToUnit(ref.AgeStart, 'years'),
|
||||
AgeEnd: convertDaysToUnit(ref.AgeEnd, 'years')
|
||||
function validateSimpleRefTxt() {
|
||||
if (!simpleRefTxt.RefTxt?.trim()) {
|
||||
return { valid: false, error: 'Reference text is required' };
|
||||
}
|
||||
|
||||
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 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) {
|
||||
const newRanges = formData.reftxt?.filter((_, i) => i !== index) || [];
|
||||
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();
|
||||
}
|
||||
|
||||
@ -132,7 +163,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Text Reference Ranges</h2>
|
||||
|
||||
<div class="alert alert-info text-sm">
|
||||
@ -142,14 +173,131 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Ranges ({formData.reftxt?.length || 0})</h3>
|
||||
<!-- Form Section - Top -->
|
||||
<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}
|
||||
<div class="text-center py-8 bg-base-200 rounded-lg">
|
||||
<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-xs text-gray-400">Add reference ranges for this test</p>
|
||||
<p class="text-sm text-gray-500">No text references defined</p>
|
||||
<p class="text-xs text-gray-400">Add reference text using the form above</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto border border-base-200 rounded-lg">
|
||||
@ -164,32 +312,32 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each formData.reftxt as range, idx (idx)}
|
||||
{#each formData.reftxt as ref, idx (idx)}
|
||||
<tr class="hover:bg-base-100">
|
||||
<td>
|
||||
<span class="badge badge-xs
|
||||
{range.TxtRefType === 'Normal' ? 'badge-success' :
|
||||
range.TxtRefType === 'Abnormal' ? 'badge-warning' :
|
||||
{ref.TxtRefType === 'Normal' ? 'badge-success' :
|
||||
ref.TxtRefType === 'Abnormal' ? 'badge-warning' :
|
||||
'badge-error'}">
|
||||
{getRefTypeLabel(range.TxtRefType)}
|
||||
{getRefTypeLabel(ref.TxtRefType)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{getSexLabel(range.Sex)}</td>
|
||||
<td class="text-sm">{getAgeDisplay(range.AgeStart)}-{getAgeDisplay(range.AgeEnd)}</td>
|
||||
<td class="font-mono text-sm">{range.RefTxt || '-'}</td>
|
||||
<td>{getSexLabel(ref.Sex)}</td>
|
||||
<td class="text-sm">{getAgeDisplay(ref.AgeStart)}-{getAgeDisplay(ref.AgeEnd)}</td>
|
||||
<td class="font-mono text-sm">{ref.RefTxt || '-'}</td>
|
||||
<td>
|
||||
<div class="flex justify-center gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => openEditRange(idx)}
|
||||
title="Edit Range"
|
||||
onclick={() => editRange(idx)}
|
||||
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
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => removeRange(idx)}
|
||||
title="Remove Range"
|
||||
title="Remove Reference"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
@ -202,98 +350,4 @@
|
||||
</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 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>
|
||||
|
||||
@ -1,46 +1,13 @@
|
||||
<script>
|
||||
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();
|
||||
|
||||
// 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 { formData = $bindable(), disciplines = [], departments = [], isDirty = $bindable(false) } = $props();
|
||||
|
||||
let validationErrors = $state({
|
||||
typeCombination: '',
|
||||
simpleRefNum: '',
|
||||
simpleRefTxt: ''
|
||||
typeCombination: ''
|
||||
});
|
||||
|
||||
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 })));
|
||||
|
||||
@ -56,25 +23,6 @@
|
||||
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 = '';
|
||||
@ -96,182 +44,9 @@
|
||||
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 = '';
|
||||
|
||||
@ -535,363 +310,4 @@
|
||||
</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>
|
||||
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user