- Add orders API module with CRUD operations (src/lib/api/orders.js) - Create orders page with search, list, form, and detail modals - Add patient visits ADT form modal for admission/discharge/transfer - Update test management: add Requestable field to BasicInfoTab - Add API documentation for orders and patient visits endpoints - Update visits page to integrate with orders functionality Features: - Order creation with auto-grouped specimens by container type - Order status tracking (ORD, SCH, ANA, VER, REV, REP) - Priority levels (Routine, Stat, Urgent) - Order detail view with specimens and tests - ADT (Admission/Discharge/Transfer) management for visits
465 lines
15 KiB
Svelte
465 lines
15 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
import { ORDER_STATUS, ORDER_PRIORITY } from '$lib/api/orders.js';
|
|
import { fetchPatients } from '$lib/api/patients.js';
|
|
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
|
import { User, FlaskConical, Building2, Hash, FileText, AlertCircle, Plus, X, Search, Beaker } from 'lucide-svelte';
|
|
|
|
/** @type {{
|
|
* open: boolean,
|
|
* order: Object | null,
|
|
* loading: boolean,
|
|
* onSave: () => void,
|
|
* onCancel: () => void
|
|
* }} */
|
|
let {
|
|
open = $bindable(false),
|
|
order = null,
|
|
loading = false,
|
|
onSave,
|
|
onCancel
|
|
} = $props();
|
|
|
|
// Form state
|
|
let formData = $state({
|
|
OrderID: '',
|
|
InternalPID: '',
|
|
PatVisitID: '',
|
|
SiteID: 1,
|
|
PlacerID: '',
|
|
Priority: 'R',
|
|
ReqApp: '',
|
|
Comment: '',
|
|
Tests: []
|
|
});
|
|
|
|
let formLoading = $state(false);
|
|
let formError = $state('');
|
|
let patientSearchQuery = $state('');
|
|
let patientSearchResults = $state([]);
|
|
let showPatientSearch = $state(false);
|
|
let selectedPatient = $state(null);
|
|
let testInput = $state({ TestSiteID: '' });
|
|
|
|
// Reset form when modal opens
|
|
$effect(() => {
|
|
if (open) {
|
|
if (order) {
|
|
// Edit mode - populate form
|
|
formData = {
|
|
OrderID: order.OrderID || '',
|
|
InternalPID: order.InternalPID || '',
|
|
PatVisitID: order.PatVisitID || '',
|
|
SiteID: order.SiteID || 1,
|
|
PlacerID: order.PlacerID || '',
|
|
Priority: order.Priority || 'R',
|
|
ReqApp: order.ReqApp || '',
|
|
Comment: order.Comment || '',
|
|
Tests: order.Tests ? [...order.Tests] : []
|
|
};
|
|
selectedPatient = {
|
|
InternalPID: order.InternalPID,
|
|
PatientID: order.PatientID,
|
|
FullName: order.PatientName
|
|
};
|
|
} else {
|
|
// Create mode - reset form
|
|
formData = {
|
|
OrderID: '',
|
|
InternalPID: '',
|
|
PatVisitID: '',
|
|
SiteID: 1,
|
|
PlacerID: '',
|
|
Priority: 'R',
|
|
ReqApp: '',
|
|
Comment: '',
|
|
Tests: []
|
|
};
|
|
selectedPatient = null;
|
|
patientSearchQuery = '';
|
|
patientSearchResults = [];
|
|
}
|
|
formError = '';
|
|
showPatientSearch = false;
|
|
}
|
|
});
|
|
|
|
async function searchPatients() {
|
|
if (!patientSearchQuery.trim()) return;
|
|
|
|
formLoading = true;
|
|
try {
|
|
const response = await fetchPatients({
|
|
Name: patientSearchQuery.trim(),
|
|
perPage: 10
|
|
});
|
|
patientSearchResults = response.data || [];
|
|
showPatientSearch = true;
|
|
} catch (err) {
|
|
toastError('Failed to search patients');
|
|
patientSearchResults = [];
|
|
} finally {
|
|
formLoading = false;
|
|
}
|
|
}
|
|
|
|
function selectPatient(patient) {
|
|
selectedPatient = patient;
|
|
formData.InternalPID = patient.InternalPID;
|
|
showPatientSearch = false;
|
|
patientSearchQuery = '';
|
|
patientSearchResults = [];
|
|
}
|
|
|
|
function addTest() {
|
|
const testSiteId = parseInt(testInput.TestSiteID);
|
|
if (!testSiteId || isNaN(testSiteId)) {
|
|
formError = 'Please enter a valid Test Site ID';
|
|
return;
|
|
}
|
|
|
|
// Check for duplicates
|
|
if (formData.Tests.some(t => t.TestSiteID === testSiteId)) {
|
|
formError = 'Test already added';
|
|
return;
|
|
}
|
|
|
|
formData.Tests = [...formData.Tests, { TestSiteID: testSiteId }];
|
|
testInput.TestSiteID = '';
|
|
formError = '';
|
|
}
|
|
|
|
function removeTest(index) {
|
|
formData.Tests = formData.Tests.filter((_, i) => i !== index);
|
|
}
|
|
|
|
function validateForm() {
|
|
formError = '';
|
|
|
|
if (!formData.InternalPID) {
|
|
formError = 'Please select a patient';
|
|
return false;
|
|
}
|
|
|
|
if (formData.Tests.length === 0) {
|
|
formError = 'Please add at least one test';
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
if (!validateForm()) return;
|
|
|
|
formLoading = true;
|
|
try {
|
|
// Prepare data for API
|
|
const submitData = {
|
|
...formData,
|
|
InternalPID: parseInt(formData.InternalPID),
|
|
SiteID: parseInt(formData.SiteID) || 1
|
|
};
|
|
|
|
if (formData.PatVisitID) {
|
|
submitData.PatVisitID = parseInt(formData.PatVisitID);
|
|
}
|
|
|
|
onSave(submitData);
|
|
} catch (err) {
|
|
formError = err.message || 'Failed to save order';
|
|
} finally {
|
|
formLoading = false;
|
|
}
|
|
}
|
|
|
|
function handleClose() {
|
|
onCancel();
|
|
}
|
|
|
|
const priorityOptions = Object.values(ORDER_PRIORITY);
|
|
</script>
|
|
|
|
<Modal
|
|
bind:open
|
|
title={order ? 'Edit Order' : 'Create Order'}
|
|
size="xl"
|
|
onClose={handleClose}
|
|
>
|
|
<div class="space-y-4">
|
|
<!-- Error Alert -->
|
|
{#if formError}
|
|
<div class="alert alert-error alert-sm">
|
|
<AlertCircle class="w-4 h-4" />
|
|
<span class="text-sm">{formError}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Two Column Layout -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
<!-- LEFT COLUMN: Order Information -->
|
|
<div class="space-y-4">
|
|
|
|
<!-- Patient Card -->
|
|
<div class="bg-base-200 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2 text-gray-700">
|
|
<User class="w-4 h-4" />
|
|
Patient <span class="text-error">*</span>
|
|
</h4>
|
|
|
|
{#if selectedPatient}
|
|
<div class="bg-base-100 rounded-lg p-3 border border-base-300">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="font-semibold text-sm">{selectedPatient.FullName || selectedPatient.PatientID}</p>
|
|
<p class="text-xs text-gray-500">ID: {selectedPatient.PatientID}</p>
|
|
<p class="text-xs text-gray-500">Internal PID: {selectedPatient.InternalPID}</p>
|
|
</div>
|
|
{#if !order}
|
|
<button
|
|
class="btn btn-ghost btn-xs btn-square"
|
|
onclick={() => { selectedPatient = null; formData.InternalPID = ''; }}
|
|
>
|
|
<X class="w-4 h-4" />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-2">
|
|
<div class="flex gap-2">
|
|
<div class="input input-sm input-bordered flex items-center gap-2 flex-1 focus-within:input-primary">
|
|
<Search class="w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
class="grow bg-transparent outline-none"
|
|
placeholder="Search patient by name..."
|
|
bind:value={patientSearchQuery}
|
|
onkeydown={(e) => e.key === 'Enter' && searchPatients()}
|
|
/>
|
|
</div>
|
|
<button
|
|
class="btn btn-primary btn-sm"
|
|
onclick={searchPatients}
|
|
disabled={formLoading || !patientSearchQuery.trim()}
|
|
>
|
|
{#if formLoading}
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
{:else}
|
|
<Search class="w-4 h-4" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if showPatientSearch && patientSearchResults.length > 0}
|
|
<div class="border border-base-300 rounded-lg max-h-40 overflow-auto bg-base-100">
|
|
{#each patientSearchResults as patient (patient.InternalPID)}
|
|
<button
|
|
class="w-full text-left p-2 hover:bg-base-200 border-b border-base-200 last:border-b-0"
|
|
onclick={() => selectPatient(patient)}
|
|
>
|
|
<p class="text-sm font-medium">{patient.FullName || patient.PatientID}</p>
|
|
<p class="text-xs text-gray-500">ID: {patient.PatientID}</p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{:else if showPatientSearch}
|
|
<p class="text-sm text-gray-500">No patients found</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Order Details Card -->
|
|
<div class="bg-base-200 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2 text-gray-700">
|
|
<FileText class="w-4 h-4" />
|
|
Order Details
|
|
</h4>
|
|
|
|
<div class="space-y-3">
|
|
<!-- Priority & Site ID -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="form-control">
|
|
<label class="label py-0.5" for="orderPriority">
|
|
<span class="label-text text-xs text-gray-500">Priority</span>
|
|
</label>
|
|
<select
|
|
id="orderPriority"
|
|
class="select select-sm select-bordered w-full focus:select-primary"
|
|
bind:value={formData.Priority}
|
|
>
|
|
{#each priorityOptions as priority (priority.code)}
|
|
<option value={priority.code}>{priority.label}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label py-0.5" for="orderSiteId">
|
|
<span class="label-text text-xs text-gray-500">Site ID</span>
|
|
</label>
|
|
<input
|
|
id="orderSiteId"
|
|
type="number"
|
|
class="input input-sm input-bordered w-full focus:input-primary"
|
|
bind:value={formData.SiteID}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Visit ID & Placer ID -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="form-control">
|
|
<label class="label py-0.5" for="orderVisitId">
|
|
<span class="label-text text-xs text-gray-500">Visit ID</span>
|
|
</label>
|
|
<div class="input input-sm input-bordered flex items-center gap-2 focus-within:input-primary">
|
|
<Hash class="w-3.5 h-3.5 text-gray-400" />
|
|
<input
|
|
id="orderVisitId"
|
|
type="number"
|
|
class="grow bg-transparent outline-none"
|
|
bind:value={formData.PatVisitID}
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label py-0.5" for="orderPlacerId">
|
|
<span class="label-text text-xs text-gray-500">Placer ID</span>
|
|
</label>
|
|
<input
|
|
id="orderPlacerId"
|
|
type="text"
|
|
class="input input-sm input-bordered w-full focus:input-primary"
|
|
bind:value={formData.PlacerID}
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Requesting App -->
|
|
<div class="form-control">
|
|
<label class="label py-0.5" for="orderReqApp">
|
|
<span class="label-text text-xs text-gray-500">Requesting Application</span>
|
|
</label>
|
|
<div class="input input-sm input-bordered flex items-center gap-2 focus-within:input-primary">
|
|
<Building2 class="w-3.5 h-3.5 text-gray-400" />
|
|
<input
|
|
id="orderReqApp"
|
|
type="text"
|
|
class="grow bg-transparent outline-none"
|
|
bind:value={formData.ReqApp}
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comment -->
|
|
<div class="form-control">
|
|
<label class="label py-0.5" for="orderComment">
|
|
<span class="label-text text-xs text-gray-500">Comment</span>
|
|
</label>
|
|
<textarea
|
|
id="orderComment"
|
|
class="textarea textarea-bordered textarea-sm w-full focus:textarea-primary"
|
|
bind:value={formData.Comment}
|
|
rows="2"
|
|
placeholder="Optional comments..."
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT COLUMN: Tests -->
|
|
<div class="bg-base-200 rounded-lg p-4 flex flex-col">
|
|
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2 text-gray-700">
|
|
<Beaker class="w-4 h-4" />
|
|
Tests <span class="text-error">*</span>
|
|
{#if formData.Tests.length > 0}
|
|
<span class="badge badge-sm badge-primary ml-auto">{formData.Tests.length}</span>
|
|
{/if}
|
|
</h4>
|
|
|
|
<!-- Add Test Input -->
|
|
<div class="flex gap-2 mb-3">
|
|
<div class="input input-sm input-bordered flex items-center gap-2 flex-1 bg-base-100 focus-within:input-primary">
|
|
<FlaskConical class="w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="number"
|
|
class="grow bg-transparent outline-none"
|
|
placeholder="Enter Test Site ID"
|
|
bind:value={testInput.TestSiteID}
|
|
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTest())}
|
|
/>
|
|
</div>
|
|
<button
|
|
class="btn btn-primary btn-sm"
|
|
onclick={addTest}
|
|
>
|
|
<Plus class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tests List -->
|
|
<div class="flex-1 min-h-[200px] max-h-[400px] overflow-auto">
|
|
{#if formData.Tests.length > 0}
|
|
<div class="space-y-2">
|
|
{#each formData.Tests as test, index (test.TestSiteID)}
|
|
<div class="flex items-center justify-between p-3 bg-base-100 rounded-lg border border-base-300">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
|
<span class="text-xs font-semibold text-primary">{index + 1}</span>
|
|
</div>
|
|
<div>
|
|
<p class="font-medium text-sm">Test Site ID</p>
|
|
<p class="text-lg font-bold text-primary">{test.TestSiteID}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="btn btn-ghost btn-sm btn-square text-error hover:bg-error/10"
|
|
onclick={() => removeTest(index)}
|
|
>
|
|
<X class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col items-center justify-center h-full text-gray-400 py-8">
|
|
<Beaker class="w-12 h-12 mb-2 opacity-50" />
|
|
<p class="text-sm">No tests added yet</p>
|
|
<p class="text-xs mt-1">Enter a Test Site ID and click Add</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#snippet footer()}
|
|
<button
|
|
class="btn btn-ghost btn-sm"
|
|
onclick={handleClose}
|
|
disabled={formLoading}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
class="btn btn-primary btn-sm"
|
|
onclick={handleSubmit}
|
|
disabled={formLoading || !selectedPatient}
|
|
>
|
|
{#if formLoading}
|
|
<span class="loading loading-spinner loading-sm mr-1"></span>
|
|
{/if}
|
|
{order ? 'Update Order' : 'Create Order'}
|
|
</button>
|
|
{/snippet}
|
|
</Modal>
|