mahdahar 96f3b14fd4 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
2026-03-02 07:02:25 +07:00

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>