clqms-fe1/src/routes/(app)/orders/OrderFormModal.svelte

465 lines
15 KiB
Svelte
Raw Normal View History

<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>