- 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
353 lines
12 KiB
Svelte
353 lines
12 KiB
Svelte
<script>
|
|
import { Plus, Trash2, Type, Edit2, X } from 'lucide-svelte';
|
|
|
|
let { formData = $bindable(), isDirty = $bindable(false) } = $props();
|
|
|
|
let isEditing = $state(false);
|
|
let editingIndex = $state(null);
|
|
let validationError = $state('');
|
|
|
|
let simpleRefTxt = $state({
|
|
TxtRefType: 'Normal',
|
|
Sex: '0',
|
|
AgeStart: '',
|
|
AgeEnd: '',
|
|
AgeUnit: 'years',
|
|
RefTxt: ''
|
|
});
|
|
|
|
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' }
|
|
];
|
|
|
|
const ageUnits = [
|
|
{ value: 'days', label: 'Days' },
|
|
{ value: 'weeks', label: 'Weeks' },
|
|
{ value: 'months', label: 'Months' },
|
|
{ value: 'years', label: 'Years' }
|
|
];
|
|
|
|
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() {
|
|
simpleRefTxt = {
|
|
TxtRefType: 'Normal',
|
|
Sex: '0',
|
|
AgeStart: '',
|
|
AgeEnd: '',
|
|
AgeUnit: 'years',
|
|
RefTxt: ''
|
|
};
|
|
validationError = '';
|
|
isEditing = false;
|
|
editingIndex = null;
|
|
}
|
|
|
|
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')
|
|
};
|
|
|
|
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;
|
|
if (isEditing && editingIndex === index) {
|
|
resetForm();
|
|
} else if (isEditing && editingIndex > index) {
|
|
editingIndex--;
|
|
}
|
|
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;
|
|
}
|
|
|
|
function getAgeDisplay(ageDays) {
|
|
if (ageDays === null || ageDays === undefined) return '';
|
|
if (ageDays < 30) return `${ageDays}d`;
|
|
if (ageDays < 365) return `${Math.floor(ageDays / 30)}mo`;
|
|
return `${Math.floor(ageDays / 365)}y`;
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-5">
|
|
<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>
|
|
|
|
<!-- 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 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">
|
|
<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 ref, idx (idx)}
|
|
<tr class="hover:bg-base-100">
|
|
<td>
|
|
<span class="badge badge-xs
|
|
{ref.TxtRefType === 'Normal' ? 'badge-success' :
|
|
ref.TxtRefType === 'Abnormal' ? 'badge-warning' :
|
|
'badge-error'}">
|
|
{getRefTypeLabel(ref.TxtRefType)}
|
|
</span>
|
|
</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={() => editRange(idx)}
|
|
title="Edit Reference"
|
|
>
|
|
<Edit2 class="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
class="btn btn-ghost btn-xs text-error"
|
|
onclick={() => removeRange(idx)}
|
|
title="Remove Reference"
|
|
>
|
|
<Trash2 class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div> |