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
302 lines
11 KiB
Svelte
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>
|