- Consolidate fragmented test modal components into unified TestFormModal.svelte - Reorganize reference range components into organized tabs (RefNumTab, RefTxtTab) - Add new tab components: BasicInfoTab, TechDetailsTab, CalcDetailsTab, MappingsTab, GroupMembersTab - Move test-related type definitions to src/lib/types/test.types.ts for better type organization - Delete old component files: TestModal.svelte and 10+ sub-components - Add backup directory for old component preservation - Update AGENTS.md with coding guidelines and project conventions - Update tests.js API client with improved structure - Add documentation for frontend test management architecture
246 lines
8.7 KiB
Svelte
246 lines
8.7 KiB
Svelte
<script>
|
|
import { PlusCircle, Calculator, X, ChevronDown, ChevronUp, Beaker } from 'lucide-svelte';
|
|
import { refTypeOptions, createNumRef } from '../referenceRange.js';
|
|
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
|
|
import { onMount } from 'svelte';
|
|
|
|
/**
|
|
* @typedef {Object} Props
|
|
* @property {Array} refnum - Numeric reference ranges array
|
|
* @property {(refnum: Array) => void} onupdateRefnum - Update handler
|
|
*/
|
|
|
|
/** @type {Props} */
|
|
let {
|
|
refnum = [],
|
|
onupdateRefnum = () => {}
|
|
} = $props();
|
|
|
|
let showAdvanced = $state({});
|
|
let specimenTypeOptions = $state([]);
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const response = await fetchValueSetByKey('specimen_type');
|
|
specimenTypeOptions = response.data?.items?.map(item => ({
|
|
value: item.itemCode,
|
|
label: item.itemValue
|
|
})) || [];
|
|
} catch (err) {
|
|
console.error('Failed to load specimen types:', err);
|
|
}
|
|
});
|
|
|
|
function addRefRange() {
|
|
const newRef = createNumRef();
|
|
newRef.Sex = '0';
|
|
newRef.Flag = 'N';
|
|
onupdateRefnum([...refnum, newRef]);
|
|
}
|
|
|
|
function removeRefRange(index) {
|
|
onupdateRefnum(refnum.filter((_, i) => i !== index));
|
|
delete showAdvanced[index];
|
|
}
|
|
|
|
function toggleAdvanced(index) {
|
|
showAdvanced[index] = !showAdvanced[index];
|
|
}
|
|
|
|
function hasAdvancedData(ref) {
|
|
return ref.Interpretation || ref.SpcType || ref.Criteria ||
|
|
ref.Sex !== '0' || ref.Flag !== 'N' ||
|
|
(ref.AgeStart !== 0 || ref.AgeEnd !== 120);
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-2">
|
|
<!-- Header -->
|
|
<div class="flex justify-between items-center">
|
|
<div class="flex items-center gap-2">
|
|
<Calculator class="w-5 h-5 text-primary" />
|
|
<h3 class="font-semibold">Numeric Reference Ranges</h3>
|
|
{#if refnum?.length > 0}
|
|
<span class="badge badge-sm badge-ghost">{refnum.length}</span>
|
|
{/if}
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-primary" onclick={addRefRange}>
|
|
<PlusCircle class="w-4 h-4 mr-1" />
|
|
Add Range
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
{#if refnum?.length === 0}
|
|
<div class="text-center py-8 bg-base-200 rounded-lg border-2 border-dashed border-base-300">
|
|
<Calculator class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
|
<p class="text-gray-500">No numeric ranges defined</p>
|
|
<button type="button" class="btn btn-sm btn-outline mt-2" onclick={addRefRange}>
|
|
<PlusCircle class="w-4 h-4 mr-1" />
|
|
Add First Range
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Range List - Table-like rows -->
|
|
{#if refnum?.length > 0}
|
|
<div class="border border-base-300 rounded-lg overflow-hidden">
|
|
<!-- Table Header -->
|
|
<div class="bg-base-200 px-4 py-3 grid grid-cols-12 gap-3 text-xs font-medium text-gray-600 border-b border-base-300">
|
|
<div class="col-span-1">#</div>
|
|
<div class="col-span-3">Type</div>
|
|
<div class="col-span-3">Low</div>
|
|
<div class="col-span-3">High</div>
|
|
<div class="col-span-2"></div>
|
|
</div>
|
|
|
|
<!-- Table Rows -->
|
|
{#each refnum || [] as ref, index (index)}
|
|
<div class="border-b border-base-200 last:border-b-0">
|
|
<!-- Main Row -->
|
|
<div class="px-4 py-3 grid grid-cols-12 gap-3 items-center hover:bg-base-100">
|
|
<!-- Row Number -->
|
|
<div class="col-span-1 flex items-center gap-1">
|
|
<span class="text-xs text-gray-500">{index + 1}</span>
|
|
</div>
|
|
|
|
<!-- Type Dropdown -->
|
|
<div class="col-span-3">
|
|
<select class="select select-sm select-bordered w-full" bind:value={ref.RefType}>
|
|
{#each refTypeOptions as option (option.value)}
|
|
<option value={option.value}>{option.label}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Low Input -->
|
|
<div class="col-span-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
class="input input-sm input-bordered w-full text-right"
|
|
bind:value={ref.Low}
|
|
placeholder="-"
|
|
/>
|
|
</div>
|
|
|
|
<!-- High Input -->
|
|
<div class="col-span-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
class="input input-sm input-bordered w-full text-right"
|
|
bind:value={ref.High}
|
|
placeholder="-"
|
|
/>
|
|
</div>
|
|
|
|
<!-- More & Delete Buttons -->
|
|
<div class="col-span-2 flex items-center justify-end gap-1">
|
|
<button
|
|
type="button"
|
|
class="btn btn-xs btn-ghost btn-square relative"
|
|
onclick={() => toggleAdvanced(index)}
|
|
title={showAdvanced[index] ? 'Hide details' : 'Show details'}
|
|
>
|
|
{#if hasAdvancedData(ref)}
|
|
<span class="badge badge-xs badge-primary absolute -top-1 -right-1 w-2 h-2 p-0 min-w-0"></span>
|
|
{/if}
|
|
{#if showAdvanced[index]}
|
|
<ChevronUp class="w-4 h-4" />
|
|
{:else}
|
|
<ChevronDown class="w-4 h-4" />
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-xs btn-ghost text-error btn-square"
|
|
onclick={() => removeRefRange(index)}
|
|
title="Remove"
|
|
>
|
|
<X class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Section (Expanded) -->
|
|
{#if showAdvanced[index]}
|
|
<div class="px-4 pb-4 pt-2 bg-base-100/50 border-t border-base-200">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<!-- Sex -->
|
|
<div class="form-control">
|
|
<span class="label-text text-xs text-gray-500">Sex</span>
|
|
<select class="select select-sm select-bordered w-full" bind:value={ref.Sex}>
|
|
<option value="0">Any</option>
|
|
<option value="1">Female</option>
|
|
<option value="2">Male</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Age Range -->
|
|
<div class="form-control">
|
|
<span class="label-text text-xs text-gray-500">Age Range</span>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="120"
|
|
class="input input-sm input-bordered w-20 text-right"
|
|
bind:value={ref.AgeStart}
|
|
/>
|
|
<span class="text-gray-400">to</span>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="120"
|
|
class="input input-sm input-bordered w-20 text-right"
|
|
bind:value={ref.AgeEnd}
|
|
/>
|
|
<span class="text-xs text-gray-400">years</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Specimen -->
|
|
<div class="form-control md:col-span-2">
|
|
<span class="label-text text-xs text-gray-500 flex items-center gap-1">
|
|
<Beaker class="w-3 h-3" />
|
|
Specimen Type
|
|
</span>
|
|
<select class="select select-sm select-bordered w-full" bind:value={ref.SpcType}>
|
|
<option value="">Any specimen</option>
|
|
{#each specimenTypeOptions as option (option.value)}
|
|
<option value={option.value}>{option.label}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Interpretation -->
|
|
<div class="form-control md:col-span-2">
|
|
<span class="label-text text-xs text-gray-500">Interpretation</span>
|
|
<input
|
|
type="text"
|
|
class="input input-sm input-bordered w-full"
|
|
bind:value={ref.Interpretation}
|
|
placeholder="e.g., Normal fasting glucose range"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Criteria -->
|
|
<div class="form-control md:col-span-2">
|
|
<span class="label-text text-xs text-gray-500">Criteria</span>
|
|
<input
|
|
type="text"
|
|
class="input input-sm input-bordered w-full"
|
|
bind:value={ref.Criteria}
|
|
placeholder="e.g., Fasting, Morning sample"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|