refactor: improve patient form validation and order management

This commit is contained in:
mahdahar 2026-03-05 16:05:42 +07:00
parent afd8028a21
commit 94af37dae5
9 changed files with 216 additions and 319 deletions

View File

@ -45,7 +45,13 @@ export async function apiClient(endpoint, options = {}) {
// Handle other errors
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || `HTTP error! status: ${response.status}`);
// Create error object with full response data for field-specific errors
const err = new Error(error.message || `HTTP error! status: ${response.status}`);
err.status = response.status;
if (error.messages) {
err.messages = error.messages;
}
throw err;
}
// Parse JSON response

View File

@ -23,11 +23,8 @@ function createValueSetsStore() {
* @returns {Promise<Array>} - Array of value set items
*/
async load(key) {
console.log('valuesets.load() called for key:', key);
// If there's already an in-flight request, return its promise
if (inflightRequests.has(key)) {
console.log('valuesets: returning existing in-flight request for:', key);
return inflightRequests.get(key);
}
@ -36,14 +33,11 @@ function createValueSetsStore() {
subscribe((state) => { cacheState = state; })();
if (cacheState?.[key]?.loaded) {
console.log('valuesets: returning cached items for:', key);
return cacheState[key].items;
}
// Create the fetch promise
const fetchPromise = (async () => {
console.log('valuesets: starting fetch for:', key);
// Mark as loading
update((cache) => ({
...cache,
@ -51,14 +45,11 @@ function createValueSetsStore() {
}));
try {
console.log('valuesets: calling API for key:', key);
const response = await fetchValueSetByKey(key);
console.log('valuesets: API response for', key, ':', response);
if (response.status === 'success' && response.data) {
// Handle both response.data being an array or having an Items property
let items = Array.isArray(response.data) ? response.data : (response.data.Items || []);
console.log('valuesets: items for', key, ':', items);
// Sort by Sequence if available
items.sort((a, b) => (a.Sequence || 0) - (b.Sequence || 0));
@ -68,7 +59,6 @@ function createValueSetsStore() {
[key]: { items, loaded: true, loading: false, error: null },
}));
console.log('valuesets: returning items for', key);
return items;
} else {
throw new Error(response.message || 'Failed to load value set');

View File

@ -4,12 +4,14 @@
import { ORDER_STATUS, ORDER_PRIORITY } from '$lib/api/orders.js';
import { fetchPatients } from '$lib/api/patients.js';
import { fetchTests } from '$lib/api/tests.js';
import { fetchVisitsByPatient } from '$lib/api/visits.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,
* patient: Object | null,
* loading: boolean,
* onSave: () => void,
* onCancel: () => void
@ -17,6 +19,7 @@
let {
open = $bindable(false),
order = null,
patient = null,
loading = false,
onSave,
onCancel
@ -41,13 +44,18 @@
let patientSearchResults = $state([]);
let showPatientSearch = $state(false);
let selectedPatient = $state(null);
let patientVisits = $state([]);
let visitsLoading = $state(false);
let testSearchQuery = $state('');
let testSearchResults = $state([]);
let showTestSearch = $state(false);
let isInitialized = $state(false);
// Reset form when modal opens
// Reset form when modal opens (only when transitioning from closed to open)
$effect(() => {
if (open) {
if (open && !isInitialized) {
isInitialized = true;
if (order) {
// Edit mode - populate form
formData = {
@ -79,7 +87,16 @@
Comment: '',
Tests: []
};
// If patient prop is provided, auto-select it
if (patient) {
selectedPatient = patient;
formData.InternalPID = patient.InternalPID;
loadPatientVisits(patient.InternalPID);
} else {
selectedPatient = null;
}
patientSearchQuery = '';
patientSearchResults = [];
}
@ -88,6 +105,9 @@
testSearchQuery = '';
testSearchResults = [];
showTestSearch = false;
} else if (!open) {
// Reset initialization flag when modal closes
isInitialized = false;
}
});
@ -113,9 +133,29 @@
function selectPatient(patient) {
selectedPatient = patient;
formData.InternalPID = patient.InternalPID;
formData.PatVisitID = ''; // Reset visit when patient changes
showPatientSearch = false;
patientSearchQuery = '';
patientSearchResults = [];
loadPatientVisits(patient.InternalPID);
}
async function loadPatientVisits(internalPID) {
if (!internalPID) {
patientVisits = [];
return;
}
visitsLoading = true;
try {
const response = await fetchVisitsByPatient(internalPID);
patientVisits = response.data || [];
} catch (err) {
console.error('Failed to load patient visits:', err);
patientVisits = [];
} finally {
visitsLoading = false;
}
}
async function searchTests() {
@ -245,15 +285,22 @@
<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}
{#if !order && !patient}
<button
class="btn btn-ghost btn-xs btn-square"
onclick={() => { selectedPatient = null; formData.InternalPID = ''; }}
onclick={() => { selectedPatient = null; formData.InternalPID = ''; formData.PatVisitID = ''; patientVisits = []; }}
>
<X class="w-4 h-4" />
</button>
{/if}
</div>
{#if patientVisits.length > 0}
<div class="mt-2 pt-2 border-t border-base-200">
<p class="text-xs text-gray-500">
{patientVisits.length} visit{patientVisits.length === 1 ? '' : 's'} available
</p>
</div>
{/if}
</div>
{:else}
<div class="space-y-2">
@ -342,18 +389,27 @@
<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>
<span class="label-text text-xs text-gray-500">Visit</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
<select
id="orderVisitId"
type="number"
class="grow bg-transparent outline-none"
class="select select-sm select-bordered w-full focus:select-primary"
bind:value={formData.PatVisitID}
placeholder="Optional"
/>
</div>
disabled={!selectedPatient || visitsLoading || patientVisits.length === 0}
>
<option value="">Select visit...</option>
{#if visitsLoading}
<option value="" disabled>Loading visits...</option>
{:else if patientVisits.length === 0}
<option value="" disabled>No visits found</option>
{:else}
{#each patientVisits as visit (visit.InternalPVID)}
<option value={visit.InternalPVID}>
{visit.PVID} - {visit.PVACreateDate ? new Date(visit.PVACreateDate).toLocaleDateString() : 'N/A'}{visit.ADTCode ? ` (${visit.ADTCode})` : ''}
</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">

View File

@ -1,22 +1,20 @@
<script>
import { onMount } from 'svelte';
import { fetchPatients, fetchPatient } from '$lib/api/patients.js';
import { fetchOrders, createOrder } from '$lib/api/orders.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import PatientSearchBar from './PatientSearchBar.svelte';
import PatientList from './PatientList.svelte';
import OrderList from './OrderList.svelte';
import PatientFormModal from './PatientFormModal.svelte';
import OrderFormModal from '../orders/OrderFormModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import { Plus, Edit2, Trash2 } from 'lucide-svelte';
// Search state
// Search state (only PatientID and Name are supported by API)
let searchFilters = $state({
patientId: '',
patientName: '',
visitNumber: '',
orderNumber: '',
orderDateFrom: '',
orderDateTo: ''
patientName: ''
});
// List state
@ -44,6 +42,14 @@
patient: null
});
// Order form state
let orderForm = $state({
open: false,
order: null,
patient: null,
loading: false
});
// Load patients on mount (empty on init)
onMount(() => {
// Don't auto-load - wait for search
@ -62,27 +68,16 @@
perPage
};
// Add filters
// Add filters (API expects these exact parameter names)
if (searchFilters.patientId.trim()) {
params.PatientID = searchFilters.patientId.trim();
}
if (searchFilters.patientName.trim()) {
params.Name = searchFilters.patientName.trim();
}
if (searchFilters.visitNumber.trim()) {
params.VisitNumber = searchFilters.visitNumber.trim();
}
if (searchFilters.orderNumber.trim()) {
params.OrderNumber = searchFilters.orderNumber.trim();
}
if (searchFilters.orderDateFrom) {
params.OrderDateFrom = searchFilters.orderDateFrom;
}
if (searchFilters.orderDateTo) {
params.OrderDateTo = searchFilters.orderDateTo;
}
const response = await fetchPatients(params);
patients = Array.isArray(response.data) ? response.data : [];
if (response.pagination) {
@ -100,11 +95,7 @@
function handleClear() {
searchFilters = {
patientId: '',
patientName: '',
visitNumber: '',
orderNumber: '',
orderDateFrom: '',
orderDateTo: ''
patientName: ''
};
patients = [];
selectedPatient = null;
@ -130,40 +121,8 @@
ordersLoading = true;
try {
// TODO: Replace with actual orders API endpoint
// const response = await fetchOrdersByPatient(patient.InternalPID);
// Mock orders for now - remove when API is ready
orders = [
{
OrderID: 'ORD001',
OrderNumber: 'O2024001',
PatientID: patient.PatientID,
TestCode: 'CBC',
TestName: 'Complete Blood Count',
Status: 'pending',
OrderDate: new Date().toISOString(),
Priority: 'normal',
OrderedBy: 'Dr. Smith',
ResultCount: 0,
HasResults: false
},
{
OrderID: 'ORD002',
OrderNumber: 'O2024002',
PatientID: patient.PatientID,
TestCode: 'GLU',
TestName: 'Glucose',
Status: 'completed',
OrderDate: new Date(Date.now() - 86400000).toISOString(),
Priority: 'urgent',
OrderedBy: 'Dr. Jones',
CollectionDate: new Date(Date.now() - 80000000).toISOString(),
VerifiedDate: new Date(Date.now() - 40000000).toISOString(),
ResultCount: 3,
HasResults: true
}
];
const response = await fetchOrders({ InternalPID: patient.InternalPID });
orders = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load orders');
orders = [];
@ -177,12 +136,6 @@
loadOrders(patient);
}
function handleSelectPatient(patient) {
// Just select the patient, don't auto-load orders
selectedPatient = patient;
orders = [];
}
// Patient CRUD
function openCreateModal() {
patientForm = { open: true, patient: null, loading: false };
@ -231,8 +184,32 @@
// Order actions
function openCreateOrder() {
// TODO: Implement order creation modal
toastSuccess('Create order - implement modal');
if (!selectedPatient) {
toastError('Please select a patient first');
return;
}
orderForm = { open: true, order: null, patient: selectedPatient, loading: false };
}
async function handleOrderSaved(formData) {
orderForm.loading = true;
try {
await createOrder(formData);
toastSuccess('Order created successfully');
orderForm.open = false;
// Refresh orders list
if (selectedPatient) {
await loadOrders(selectedPatient);
}
} catch (err) {
toastError(err.message || 'Failed to create order');
} finally {
orderForm.loading = false;
}
}
function handleOrderCancel() {
orderForm.open = false;
}
function handleViewOrder(order) {
@ -269,10 +246,6 @@
<PatientSearchBar
bind:patientId={searchFilters.patientId}
bind:patientName={searchFilters.patientName}
bind:visitNumber={searchFilters.visitNumber}
bind:orderNumber={searchFilters.orderNumber}
bind:orderDateFrom={searchFilters.orderDateFrom}
bind:orderDateTo={searchFilters.orderDateTo}
{loading}
onSearch={handleSearch}
onClear={handleClear}
@ -297,8 +270,7 @@
{totalItems}
{perPage}
onPageChange={handlePageChange}
onSelectPatient={handleSelectPatient}
onShowOrders={handleShowOrders}
onSelectPatient={handleShowOrders}
/>
</div>
@ -331,6 +303,16 @@
loading={patientForm.loading}
/>
<!-- Order Form Modal -->
<OrderFormModal
bind:open={orderForm.open}
order={orderForm.order}
patient={orderForm.patient}
onSave={handleOrderSaved}
onCancel={handleOrderCancel}
loading={orderForm.loading}
/>
<!-- Delete Confirmation Modal -->
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
<div class="py-2">

View File

@ -1,6 +1,6 @@
<script>
import { FileText, Printer, Eye, Clock, CheckCircle, AlertCircle } from 'lucide-svelte';
import { formatDateTime, formatDate, getOrderStatusClass } from '$lib/utils/patients.js';
import { Printer, AlertCircle } from 'lucide-svelte';
import { formatDate } from '$lib/utils/patients.js';
/**
* @typedef {Object} Order
@ -23,84 +23,27 @@
/** @type {{ order: Order, onView: () => void, onPrintBarcode: () => void }} */
let { order, onView, onPrintBarcode } = $props();
let statusClass = $derived(getOrderStatusClass(order?.Status));
let isUrgent = $derived(order?.Priority?.toLowerCase() === 'urgent' || order?.Priority?.toLowerCase() === 'stat');
</script>
<div class="card bg-base-100 shadow-sm border border-base-200 hover:shadow-md transition-shadow"
class:border-error={isUrgent}>
<div class="card-body p-3 compact-y">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<!-- Order Header -->
<div class="flex items-center gap-2 mb-1">
<div class="flex items-center justify-between py-1.5 px-2 text-sm border-b border-base-200 hover:bg-base-200 cursor-pointer"
class:border-error={isUrgent}
onclick={onView}>
<div class="flex items-center gap-3 min-w-0 flex-1">
{#if isUrgent}
<AlertCircle class="w-4 h-4 text-error" />
<AlertCircle class="w-3 h-3 text-error flex-shrink-0" />
{/if}
<span class="font-semibold text-sm truncate">
{order.TestName || order.TestCode || 'Unknown Test'}
</span>
<span class="badge badge-sm {statusClass}">
{order.Status || 'Unknown'}
</span>
<span class="font-medium truncate w-24">{order.OrderNumber || order.OrderID || '-'}</span>
<span class="text-gray-600 text-xs w-24">{formatDate(order.OrderDate)}</span>
<span class="text-gray-600 text-xs truncate flex-1">{order.OrderedBy || '-'}</span>
</div>
<!-- Order Details -->
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
<div class="flex items-center gap-1">
<FileText class="w-3 h-3" />
<span class="truncate">{order.OrderNumber || order.OrderID || '-'}</span>
</div>
<div class="flex items-center gap-1">
<Clock class="w-3 h-3" />
<span>{formatDateTime(order.OrderDate)}</span>
</div>
{#if order.OrderedBy}
<div class="col-span-2 truncate">
<span class="text-gray-400">Ordered by:</span> {order.OrderedBy}
</div>
{/if}
{#if order.CollectionDate}
<div class="flex items-center gap-1">
<CheckCircle class="w-3 h-3 text-success" />
<span>Collected: {formatDate(order.CollectionDate)}</span>
</div>
{/if}
</div>
<!-- Results Indicator -->
{#if order.HasResults || order.ResultCount > 0}
<div class="mt-2 flex items-center gap-2">
<span class="badge badge-sm badge-primary">
{order.ResultCount || '?'} Results
</span>
{#if order.VerifiedDate}
<span class="text-xs text-success flex items-center gap-1">
<CheckCircle class="w-3 h-3" />
Verified
</span>
{/if}
</div>
{/if}
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-1">
<div class="flex gap-1">
<button
class="btn btn-sm btn-ghost"
title="View Order"
onclick={onView}
>
<Eye class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost"
class="btn btn-xs btn-ghost"
title="Print Barcode"
onclick={onPrintBarcode}
onclick={(e) => { e.stopPropagation(); onPrintBarcode(); }}
>
<Printer class="w-4 h-4" />
<Printer class="w-3 h-3" />
</button>
</div>
</div>
</div>
</div>

View File

@ -96,7 +96,14 @@
</button>
</div>
{:else}
<div class="space-y-2">
<!-- Compact Orders Table Header -->
<div class="flex items-center py-1 px-2 text-xs font-medium text-gray-500 border-b border-base-300 bg-base-100">
<span class="w-24">Order #</span>
<span class="w-24">Date</span>
<span class="flex-1">Doctor</span>
<span class="w-8"></span>
</div>
<div class="divide-y divide-base-200">
{#each orders as order (order.OrderID || order.OrderNumber)}
<OrderCard
{order}
@ -113,7 +120,7 @@
<div class="text-center">
<FileText class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg">Select a patient</p>
<p class="text-sm">Click "Show Orders" on a patient to view lab orders</p>
<p class="text-sm">Click a patient to view lab orders</p>
</div>
</div>
{/if}

View File

@ -231,7 +231,12 @@
open = false;
onSave?.();
} catch (err) {
toastError(err.message || 'Failed to save patient');
// Show API validation errors on modal
if (err.messages && typeof err.messages === 'object') {
formErrors = err.messages;
} else {
formErrors = { general: err.message || 'Failed to save patient' };
}
} finally {
saving = false;
}
@ -268,6 +273,16 @@
</div>
{:else}
<form class="space-y-3" onsubmit={(e) => e.preventDefault()}>
<!-- Error Alert -->
{#if Object.keys(formErrors).length > 0}
<div class="alert alert-error alert-sm">
<div class="flex flex-col">
{#each Object.entries(formErrors) as [field, msg]}
<span class="text-sm">{field}: {msg}</span>
{/each}
</div>
</div>
{/if}
<!-- DaisyUI Tabs -->
<div class="tabs tabs-bordered">
<button

View File

@ -27,8 +27,7 @@
* totalItems: number,
* perPage: number,
* onPageChange: (page: number) => void,
* onSelectPatient: (patient: Patient) => void,
* onShowOrders: (patient: Patient) => void
* onSelectPatient: (patient: Patient) => void
* }} */
let {
patients = [],
@ -39,16 +38,14 @@
totalItems = 0,
perPage = 20,
onPageChange,
onSelectPatient,
onShowOrders
onSelectPatient
} = $props();
const columns = [
{ key: 'PatientID', label: 'Patient ID', class: 'font-medium w-24' },
{ key: 'PatientID', label: 'ID', class: 'font-medium w-20' },
{ key: 'FullName', label: 'Name', class: 'min-w-32' },
{ key: 'SexLabel', label: 'Sex', class: 'w-16' },
{ key: 'BirthdateFormatted', label: 'Birthdate', class: 'w-28' },
{ key: 'actions', label: 'Orders', class: 'w-24 text-center' },
{ key: 'SexLabel', label: 'Sex', class: 'w-12' },
{ key: 'BirthdateFormatted', label: 'DOB', class: 'w-24' },
];
let displayPatients = $derived(
@ -60,10 +57,6 @@
}))
);
function handleShowOrders(patient, event) {
event.stopPropagation();
onShowOrders(patient);
}
</script>
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col h-full overflow-hidden">
@ -88,17 +81,6 @@
onRowClick={onSelectPatient}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center">
<button
class="btn btn-xs btn-primary"
class:btn-outline={selectedPatient?.InternalPID !== row.InternalPID}
onclick={(e) => handleShowOrders(row, e)}
>
Show
</button>
</div>
{:else}
<span
class="truncate block"
class:font-semibold={selectedPatient?.InternalPID === row.InternalPID}
@ -106,7 +88,6 @@
>
{row[column.key]}
</span>
{/if}
{/snippet}
</DataTable>
</div>

View File

@ -1,24 +1,16 @@
<script>
import { Search, X, User, FileUser, ClipboardList, FileText, Calendar } from 'lucide-svelte';
import { Search, X, User, FileUser } from 'lucide-svelte';
/** @type {{
* patientId: string,
* patientName: string,
* visitNumber: string,
* orderNumber: string,
* orderDateFrom: string,
* orderDateTo: string,
* loading: boolean,
* onSearch: () => void,
* onClear: () => void
* }} */
let {
patientId = '',
patientName = '',
visitNumber = '',
orderNumber = '',
orderDateFrom = '',
orderDateTo = '',
patientId = $bindable(''),
patientName = $bindable(''),
loading = false,
onSearch,
onClear
@ -31,7 +23,7 @@
}
function hasFilters() {
return patientId || patientName || visitNumber || orderNumber || orderDateFrom || orderDateTo;
return patientId || patientName;
}
</script>
@ -76,94 +68,20 @@
</div>
</div>
<!-- Row 2: Visit #, Order #, Date From, Date To -->
<div class="grid grid-cols-2 md:grid-cols-6 gap-3">
<!-- Visit Number -->
<div class="form-control col-span-1">
<label class="label py-0 mb-1" for="searchVisitNumber">
<span class="label-text text-xs font-medium text-gray-600">Visit #</span>
</label>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<ClipboardList class="w-4 h-4 text-gray-400" />
<input
id="searchVisitNumber"
type="text"
class="grow bg-transparent outline-none"
placeholder="V12345"
bind:value={visitNumber}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Order Number -->
<div class="form-control col-span-1">
<label class="label py-0 mb-1" for="searchOrderNumber">
<span class="label-text text-xs font-medium text-gray-600">Order #</span>
</label>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<FileText class="w-4 h-4 text-gray-400" />
<input
id="searchOrderNumber"
type="text"
class="grow bg-transparent outline-none"
placeholder="O67890"
bind:value={orderNumber}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Date From -->
<div class="form-control col-span-1">
<label class="label py-0 mb-1" for="searchOrderDateFrom">
<span class="label-text text-xs font-medium text-gray-600">Order From</span>
</label>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Calendar class="w-4 h-4 text-gray-400" />
<input
id="searchOrderDateFrom"
type="date"
class="grow bg-transparent outline-none text-xs"
bind:value={orderDateFrom}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Date To -->
<div class="form-control col-span-1">
<label class="label py-0 mb-1" for="searchOrderDateTo">
<span class="label-text text-xs font-medium text-gray-600">Order To</span>
</label>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Calendar class="w-4 h-4 text-gray-400" />
<input
id="searchOrderDateTo"
type="date"
class="grow bg-transparent outline-none text-xs"
bind:value={orderDateTo}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Action Buttons -->
<div class="form-control col-span-2 flex flex-row items-end gap-2">
<div class="flex flex-row items-end gap-2 mt-2">
{#if hasFilters()}
<button
class="btn btn-ghost btn-sm flex-1"
class="btn btn-ghost btn-sm"
title="Clear filters"
onclick={onClear}
>
<X class="w-4 h-4" />
<span class="hidden sm:inline ml-1">Clear</span>
</button>
{:else}
<div class="flex-1"></div>
{/if}
<button
class="btn btn-primary btn-sm flex-[2]"
class="btn btn-primary btn-sm flex-1"
onclick={onSearch}
disabled={loading}
>
@ -177,4 +95,3 @@
</div>
</div>
</div>
</div>