clqms-fe1/src/routes/(app)/results/ResultEntryModal.svelte
mahdahar afd8028a21 feat(reports,results): add complete reports and results management modules
Add new Reports module:
- Create reports page with listing and viewer functionality
- Add ReportViewerModal for viewing generated reports
- Implement reports API client with endpoints

Add new Results module:
- Create results page for lab result entry and management
- Add ResultEntryModal for entering test results
- Implement results API client with validation support

API and Store Updates:
- Update auth.js API client with improved error handling
- Enhance client.js with new request utilities
- Update auth store for better session management

UI/UX Improvements:
- Update dashboard page layout and styling
- Enhance OrderFormModal with better test selection
- Improve login page styling and validation
- Update main app layout with new navigation items

Documentation:
- Add bundled API documentation (api-docs.bundled.yaml)
- Remove outdated component organization docs
- Delete deprecated YAML specification files

Cleanup:
- Remove cookies.txt from tracking
- Delete COMPONENT_ORGANIZATION.md
- Consolidate documentation files
2026-03-04 16:48:03 +07:00

302 lines
11 KiB
Svelte

<script>
import { FlaskConical, AlertTriangle, CheckCircle2, X, Save, User, FileText } from 'lucide-svelte';
import { updateResult } from '$lib/api/results.js';
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte';
let {
order = $bindable(),
open = $bindable(false),
onSaved
} = $props();
// Form state - array of result entries
let results = $state([]);
let formLoading = $state(false);
let saveProgress = $state({ current: 0, total: 0 });
// Initialize results when order changes
$effect(() => {
if (order && open) {
// Map tests to editable result entries
results = (order.Tests || []).map(test => ({
ResultID: test.ResultID,
TestSiteID: test.TestSiteID,
TestSiteCode: test.TestSiteCode,
TestSiteName: test.TestSiteName,
Result: test.Result || '',
SampleType: test.SampleType || '',
WorkstationID: test.WorkstationID || '',
EquipmentID: test.EquipmentID || '',
Unit1: test.Unit1,
Low: test.Low,
High: test.High,
LowSign: test.LowSign,
HighSign: test.HighSign,
RefDisplay: test.RefDisplay,
RefNumID: test.RefNumID,
flag: calculateFlag(test.Result, test.Low, test.High),
saved: false,
error: null
}));
saveProgress = { current: 0, total: 0 };
}
});
function calculateFlag(value, low, high) {
if (!value || value === '') return null;
const numValue = parseFloat(value);
if (isNaN(numValue)) return null;
if (low !== null && numValue < low) return 'L';
if (high !== null && numValue > high) return 'H';
return null;
}
function updateResultFlag(index) {
const entry = results[index];
results[index].flag = calculateFlag(entry.Result, entry.Low, entry.High);
}
function getFlagColor(flag) {
if (flag === 'H') return 'text-error';
if (flag === 'L') return 'text-warning';
return 'text-success';
}
function getFlagBg(flag) {
if (flag === 'H') return 'bg-error/10';
if (flag === 'L') return 'bg-warning/10';
return 'bg-success/10';
}
function getInputBg(flag) {
if (flag === 'H') return 'bg-error/5 border-error/30';
if (flag === 'L') return 'bg-warning/5 border-warning/30';
return '';
}
async function handleSaveAll() {
// Filter to only entries with values that haven't been saved yet
const entriesToSave = results.filter(r => r.Result && r.Result.trim() !== '' && !r.saved);
if (entriesToSave.length === 0) {
toastError('No results to save');
return;
}
formLoading = true;
saveProgress = { current: 0, total: entriesToSave.length };
try {
// Save each result sequentially
for (let i = 0; i < entriesToSave.length; i++) {
const entry = entriesToSave[i];
saveProgress.current = i + 1;
const data = {
Result: entry.Result,
SampleType: entry.SampleType || null,
WorkstationID: entry.WorkstationID ? parseInt(entry.WorkstationID) : null,
EquipmentID: entry.EquipmentID ? parseInt(entry.EquipmentID) : null,
RefNumID: entry.RefNumID || null
};
const response = await updateResult(entry.ResultID, data);
if (response.status === 'success') {
// Mark as saved
const resultIndex = results.findIndex(r => r.ResultID === entry.ResultID);
if (resultIndex !== -1) {
results[resultIndex].saved = true;
results[resultIndex].error = null;
}
} else {
// Mark with error
const resultIndex = results.findIndex(r => r.ResultID === entry.ResultID);
if (resultIndex !== -1) {
results[resultIndex].error = response.message || 'Failed to save';
}
}
}
// Check if all were saved successfully
const failedCount = results.filter(r => r.error).length;
if (failedCount === 0) {
toastSuccess(`Saved ${entriesToSave.length} result(s) successfully`);
onSaved?.();
} else {
toastError(`${failedCount} result(s) failed to save`);
}
} catch (err) {
toastError(err.message || 'Failed to save results');
} finally {
formLoading = false;
saveProgress = { current: 0, total: 0 };
}
}
function handleClose() {
open = false;
}
function handleKeyDown(event, index) {
// Navigate with Enter/Arrow keys
if (event.key === 'Enter') {
event.preventDefault();
const nextIndex = index + 1;
if (nextIndex < results.length) {
const nextInput = document.getElementById(`result-input-${nextIndex}`);
nextInput?.focus();
nextInput?.select();
}
}
}
const pendingCount = $derived(results.filter(r => !r.Result || r.Result === '').length);
const savedCount = $derived(results.filter(r => r.saved).length);
</script>
<Modal bind:open={open} title="Enter Results - Order {order?.OrderID}" size="xl">
{#snippet children()}
<div class="space-y-4">
<!-- Order Info Header -->
<div class="bg-base-200/50 rounded-lg p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<User class="w-4 h-4 text-primary" />
<span class="text-sm font-medium">{order?.PatientName || 'Unknown'}</span>
<span class="text-xs text-base-content/50">(ID: {order?.InternalPID})</span>
</div>
<div class="flex items-center gap-2">
<FileText class="w-4 h-4 text-primary" />
<span class="text-xs text-base-content/70">{order?.Tests?.length || 0} tests</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
{#if pendingCount > 0}
<span class="badge badge-warning badge-sm">{pendingCount} pending</span>
{/if}
{#if savedCount > 0}
<span class="badge badge-success badge-sm">{savedCount} saved</span>
{/if}
</div>
</div>
</div>
<!-- Results Table -->
<div class="overflow-x-auto max-h-[400px] overflow-y-auto">
<table class="table table-sm">
<thead class="sticky top-0 bg-base-100 z-10">
<tr class="bg-base-200">
<th class="text-xs font-semibold w-16">Code</th>
<th class="text-xs font-semibold">Test Name</th>
<th class="text-xs font-semibold w-32">Result</th>
<th class="text-xs font-semibold w-16">Flag</th>
<th class="text-xs font-semibold w-24">Reference</th>
<th class="text-xs font-semibold w-20">Unit</th>
</tr>
</thead>
<tbody>
{#each results as result, index (result.ResultID)}
<tr class="hover:bg-base-200/30 {result.saved ? 'opacity-60' : ''}">
<td class="text-xs font-mono">{result.TestSiteCode}</td>
<td class="text-xs">
{result.TestSiteName}
{#if result.error}
<div class="text-error text-xs">{result.error}</div>
{/if}
</td>
<td class="p-1">
<label class="input input-sm input-bordered flex items-center gap-1 w-full {getInputBg(result.flag)}">
{#if result.flag === 'H'}
<AlertTriangle class="w-3 h-3 text-error flex-shrink-0" />
{:else if result.flag === 'L'}
<AlertTriangle class="w-3 h-3 text-warning flex-shrink-0" />
{:else if result.Result}
<CheckCircle2 class="w-3 h-3 text-success flex-shrink-0" />
{/if}
<input
id="result-input-{index}"
type="text"
class="grow bg-transparent outline-none text-sm font-mono"
placeholder="..."
bind:value={result.Result}
oninput={() => updateResultFlag(index)}
onkeydown={(e) => handleKeyDown(e, index)}
disabled={result.saved || formLoading}
/>
{#if result.Unit1}
<span class="text-xs text-base-content/50 flex-shrink-0">{result.Unit1}</span>
{/if}
</label>
</td>
<td class="text-xs text-center">
{#if result.flag}
<span class="badge {result.flag === 'H' ? 'badge-error' : 'badge-warning'} badge-sm">
{result.flag}
</span>
{:else}
<span class="text-base-content/30">-</span>
{/if}
</td>
<td class="text-xs text-base-content/60">{result.RefDisplay || '-'}</td>
<td class="text-xs">{result.Unit1 || '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Legend -->
<div class="flex gap-4 text-xs text-base-content/60 pt-2 border-t">
<div class="flex items-center gap-1">
<span class="badge badge-error badge-sm">H</span>
<span>High</span>
</div>
<div class="flex items-center gap-1">
<span class="badge badge-warning badge-sm">L</span>
<span>Low</span>
</div>
<div class="flex items-center gap-1">
<CheckCircle2 class="w-3 h-3 text-success" />
<span>Normal</span>
</div>
<div class="flex-1 text-right">
<span class="text-base-content/40">Press Enter to move to next field</span>
</div>
</div>
<!-- Progress -->
{#if formLoading && saveProgress.total > 0}
<div class="flex items-center gap-2 text-sm">
<span class="loading loading-spinner loading-xs"></span>
<span>Saving {saveProgress.current} of {saveProgress.total}...</span>
</div>
{/if}
</div>
{/snippet}
{#snippet footer()}
<button class="btn btn-ghost btn-sm" onclick={handleClose} disabled={formLoading}>
<X class="w-4 h-4" />
Close
</button>
<button
class="btn btn-primary btn-sm"
onclick={handleSaveAll}
disabled={formLoading || pendingCount === results.length}
>
{#if formLoading}
<span class="loading loading-spinner loading-xs"></span>
Saving...
{:else}
<Save class="w-4 h-4" />
Save All Results
{/if}
</button>
{/snippet}
</Modal>