2026-02-20 13:51:54 +07:00
|
|
|
<script>
|
|
|
|
|
import { Plus, Trash2, Type } from 'lucide-svelte';
|
|
|
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
|
|
|
|
|
|
|
|
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
|
|
|
|
|
|
|
|
|
|
let modalOpen = $state(false);
|
|
|
|
|
let modalMode = $state('create');
|
|
|
|
|
let selectedRangeIndex = $state(null);
|
|
|
|
|
let editingRange = $state({
|
|
|
|
|
TxtRefType: 'Normal',
|
|
|
|
|
Sex: '0',
|
|
|
|
|
AgeStart: 0,
|
|
|
|
|
AgeEnd: 150,
|
2026-02-20 16:49:34 +07:00
|
|
|
AgeUnit: 'years',
|
|
|
|
|
RefTxt: ''
|
2026-02-20 13:51:54 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const refTypes = [
|
|
|
|
|
{ value: 'Normal', label: 'Normal' },
|
|
|
|
|
{ value: 'Abnormal', label: 'Abnormal' },
|
|
|
|
|
{ value: 'Critical', label: 'Critical' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const sexOptions = [
|
|
|
|
|
{ value: '0', label: 'All' },
|
|
|
|
|
{ value: '1', label: 'Female' },
|
|
|
|
|
{ value: '2', label: 'Male' }
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-20 16:49:34 +07:00
|
|
|
const ageUnits = [
|
|
|
|
|
{ value: 'days', label: 'Days' },
|
|
|
|
|
{ value: 'weeks', label: 'Weeks' },
|
|
|
|
|
{ value: 'months', label: 'Months' },
|
|
|
|
|
{ value: 'years', label: 'Years' }
|
|
|
|
|
];
|
2026-02-20 13:51:54 +07:00
|
|
|
|
|
|
|
|
function handleFieldChange() {
|
|
|
|
|
isDirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:49:34 +07:00
|
|
|
// Convert age to days for storage
|
|
|
|
|
function convertAgeToDays(value, unit) {
|
|
|
|
|
if (!value && value !== 0) return null;
|
|
|
|
|
const num = parseInt(value);
|
|
|
|
|
switch (unit) {
|
|
|
|
|
case 'days': return num;
|
|
|
|
|
case 'weeks': return num * 7;
|
|
|
|
|
case 'months': return num * 30;
|
|
|
|
|
case 'years': return num * 365;
|
|
|
|
|
default: return num;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert days to display unit
|
|
|
|
|
function convertDaysToUnit(days, unit) {
|
|
|
|
|
if (days === null || days === undefined) return 0;
|
|
|
|
|
switch (unit) {
|
|
|
|
|
case 'days': return days;
|
|
|
|
|
case 'weeks': return Math.floor(days / 7);
|
|
|
|
|
case 'months': return Math.floor(days / 30);
|
|
|
|
|
case 'years': return Math.floor(days / 365);
|
|
|
|
|
default: return days;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:51:54 +07:00
|
|
|
function openAddRange() {
|
|
|
|
|
modalMode = 'create';
|
|
|
|
|
selectedRangeIndex = null;
|
|
|
|
|
editingRange = {
|
|
|
|
|
TxtRefType: 'Normal',
|
|
|
|
|
Sex: '0',
|
|
|
|
|
AgeStart: 0,
|
|
|
|
|
AgeEnd: 150,
|
2026-02-20 16:49:34 +07:00
|
|
|
AgeUnit: 'years',
|
|
|
|
|
RefTxt: ''
|
2026-02-20 13:51:54 +07:00
|
|
|
};
|
|
|
|
|
modalOpen = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openEditRange(index) {
|
|
|
|
|
modalMode = 'edit';
|
|
|
|
|
selectedRangeIndex = index;
|
2026-02-20 16:49:34 +07:00
|
|
|
const ref = formData.reftxt[index];
|
|
|
|
|
editingRange = {
|
|
|
|
|
...ref,
|
|
|
|
|
AgeUnit: 'years',
|
|
|
|
|
AgeStart: convertDaysToUnit(ref.AgeStart, 'years'),
|
|
|
|
|
AgeEnd: convertDaysToUnit(ref.AgeEnd, 'years')
|
|
|
|
|
};
|
2026-02-20 13:51:54 +07:00
|
|
|
modalOpen = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeRange(index) {
|
|
|
|
|
const newRanges = formData.reftxt?.filter((_, i) => i !== index) || [];
|
|
|
|
|
formData.reftxt = newRanges;
|
|
|
|
|
handleFieldChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveRange() {
|
2026-02-20 16:49:34 +07:00
|
|
|
const dataToSave = {
|
|
|
|
|
...editingRange,
|
|
|
|
|
AgeStart: convertAgeToDays(editingRange.AgeStart, editingRange.AgeUnit),
|
|
|
|
|
AgeEnd: convertAgeToDays(editingRange.AgeEnd, editingRange.AgeUnit)
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-20 13:51:54 +07:00
|
|
|
if (modalMode === 'create') {
|
2026-02-20 16:49:34 +07:00
|
|
|
formData.reftxt = [...(formData.reftxt || []), dataToSave];
|
2026-02-20 13:51:54 +07:00
|
|
|
} else {
|
|
|
|
|
const newRanges = formData.reftxt?.map((r, i) =>
|
2026-02-20 16:49:34 +07:00
|
|
|
i === selectedRangeIndex ? dataToSave : r
|
2026-02-20 13:51:54 +07:00
|
|
|
) || [];
|
|
|
|
|
formData.reftxt = newRanges;
|
|
|
|
|
}
|
|
|
|
|
modalOpen = false;
|
|
|
|
|
handleFieldChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRefTypeLabel(type) {
|
|
|
|
|
return refTypes.find(t => t.value === type)?.label || type;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSexLabel(sex) {
|
|
|
|
|
return sexOptions.find(s => s.value === sex)?.label || sex;
|
|
|
|
|
}
|
2026-02-20 16:49:34 +07:00
|
|
|
|
|
|
|
|
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`;
|
|
|
|
|
}
|
2026-02-20 13:51:54 +07:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-6">
|
|
|
|
|
<h2 class="text-lg font-semibold text-gray-800">Text Reference Ranges</h2>
|
|
|
|
|
|
|
|
|
|
<div class="alert alert-info text-sm">
|
|
|
|
|
<Type class="w-4 h-4" />
|
|
|
|
|
<div>
|
|
|
|
|
<strong>Text Ranges:</strong> Define expected text values for tests with text-based results.
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
{#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>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="overflow-x-auto border border-base-200 rounded-lg">
|
|
|
|
|
<table class="table table-sm table-compact">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr class="bg-base-200">
|
|
|
|
|
<th class="w-20">Type</th>
|
|
|
|
|
<th class="w-16">Sex</th>
|
|
|
|
|
<th class="w-24">Age</th>
|
|
|
|
|
<th>Reference Text</th>
|
|
|
|
|
<th class="w-24 text-center">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{#each formData.reftxt as range, idx (idx)}
|
|
|
|
|
<tr class="hover:bg-base-100">
|
|
|
|
|
<td>
|
|
|
|
|
<span class="badge badge-xs
|
|
|
|
|
{range.TxtRefType === 'Normal' ? 'badge-success' :
|
|
|
|
|
range.TxtRefType === 'Abnormal' ? 'badge-warning' :
|
|
|
|
|
'badge-error'}">
|
|
|
|
|
{getRefTypeLabel(range.TxtRefType)}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{getSexLabel(range.Sex)}</td>
|
2026-02-20 16:49:34 +07:00
|
|
|
<td class="text-sm">{getAgeDisplay(range.AgeStart)}-{getAgeDisplay(range.AgeEnd)}</td>
|
2026-02-20 13:51:54 +07:00
|
|
|
<td class="font-mono text-sm">{range.RefTxt || '-'}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="flex justify-center gap-1">
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-ghost btn-xs"
|
|
|
|
|
onclick={() => openEditRange(idx)}
|
|
|
|
|
title="Edit Range"
|
|
|
|
|
>
|
2026-02-20 16:49:34 +07:00
|
|
|
<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>
|
2026-02-20 13:51:54 +07:00
|
|
|
</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 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>
|
|
|
|
|
|
2026-02-20 16:49:34 +07:00
|
|
|
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Text Range' : 'Edit Text Range'} size="lg">
|
2026-02-20 13:51:54 +07:00
|
|
|
<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>
|
|
|
|
|
|
2026-02-20 16:49:34 +07:00
|
|
|
<!-- 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>
|
2026-02-20 13:51:54 +07:00
|
|
|
</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>
|