refactor: improve patient form validation and order management
This commit is contained in:
parent
afd8028a21
commit
94af37dae5
@ -45,7 +45,13 @@ export async function apiClient(endpoint, options = {}) {
|
|||||||
// Handle other errors
|
// Handle other errors
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
|
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
|
// Parse JSON response
|
||||||
|
|||||||
@ -23,11 +23,8 @@ function createValueSetsStore() {
|
|||||||
* @returns {Promise<Array>} - Array of value set items
|
* @returns {Promise<Array>} - Array of value set items
|
||||||
*/
|
*/
|
||||||
async load(key) {
|
async load(key) {
|
||||||
console.log('valuesets.load() called for key:', key);
|
|
||||||
|
|
||||||
// If there's already an in-flight request, return its promise
|
// If there's already an in-flight request, return its promise
|
||||||
if (inflightRequests.has(key)) {
|
if (inflightRequests.has(key)) {
|
||||||
console.log('valuesets: returning existing in-flight request for:', key);
|
|
||||||
return inflightRequests.get(key);
|
return inflightRequests.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,14 +33,11 @@ function createValueSetsStore() {
|
|||||||
subscribe((state) => { cacheState = state; })();
|
subscribe((state) => { cacheState = state; })();
|
||||||
|
|
||||||
if (cacheState?.[key]?.loaded) {
|
if (cacheState?.[key]?.loaded) {
|
||||||
console.log('valuesets: returning cached items for:', key);
|
|
||||||
return cacheState[key].items;
|
return cacheState[key].items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the fetch promise
|
// Create the fetch promise
|
||||||
const fetchPromise = (async () => {
|
const fetchPromise = (async () => {
|
||||||
console.log('valuesets: starting fetch for:', key);
|
|
||||||
|
|
||||||
// Mark as loading
|
// Mark as loading
|
||||||
update((cache) => ({
|
update((cache) => ({
|
||||||
...cache,
|
...cache,
|
||||||
@ -51,14 +45,11 @@ function createValueSetsStore() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('valuesets: calling API for key:', key);
|
|
||||||
const response = await fetchValueSetByKey(key);
|
const response = await fetchValueSetByKey(key);
|
||||||
console.log('valuesets: API response for', key, ':', response);
|
|
||||||
|
|
||||||
if (response.status === 'success' && response.data) {
|
if (response.status === 'success' && response.data) {
|
||||||
// Handle both response.data being an array or having an Items property
|
// Handle both response.data being an array or having an Items property
|
||||||
let items = Array.isArray(response.data) ? response.data : (response.data.Items || []);
|
let items = Array.isArray(response.data) ? response.data : (response.data.Items || []);
|
||||||
console.log('valuesets: items for', key, ':', items);
|
|
||||||
|
|
||||||
// Sort by Sequence if available
|
// Sort by Sequence if available
|
||||||
items.sort((a, b) => (a.Sequence || 0) - (b.Sequence || 0));
|
items.sort((a, b) => (a.Sequence || 0) - (b.Sequence || 0));
|
||||||
@ -68,7 +59,6 @@ function createValueSetsStore() {
|
|||||||
[key]: { items, loaded: true, loading: false, error: null },
|
[key]: { items, loaded: true, loading: false, error: null },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('valuesets: returning items for', key);
|
|
||||||
return items;
|
return items;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || 'Failed to load value set');
|
throw new Error(response.message || 'Failed to load value set');
|
||||||
|
|||||||
@ -4,12 +4,14 @@
|
|||||||
import { ORDER_STATUS, ORDER_PRIORITY } from '$lib/api/orders.js';
|
import { ORDER_STATUS, ORDER_PRIORITY } from '$lib/api/orders.js';
|
||||||
import { fetchPatients } from '$lib/api/patients.js';
|
import { fetchPatients } from '$lib/api/patients.js';
|
||||||
import { fetchTests } from '$lib/api/tests.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 { 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';
|
import { User, FlaskConical, Building2, Hash, FileText, AlertCircle, Plus, X, Search, Beaker } from 'lucide-svelte';
|
||||||
|
|
||||||
/** @type {{
|
/** @type {{
|
||||||
* open: boolean,
|
* open: boolean,
|
||||||
* order: Object | null,
|
* order: Object | null,
|
||||||
|
* patient: Object | null,
|
||||||
* loading: boolean,
|
* loading: boolean,
|
||||||
* onSave: () => void,
|
* onSave: () => void,
|
||||||
* onCancel: () => void
|
* onCancel: () => void
|
||||||
@ -17,6 +19,7 @@
|
|||||||
let {
|
let {
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
order = null,
|
order = null,
|
||||||
|
patient = null,
|
||||||
loading = false,
|
loading = false,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel
|
onCancel
|
||||||
@ -41,13 +44,18 @@
|
|||||||
let patientSearchResults = $state([]);
|
let patientSearchResults = $state([]);
|
||||||
let showPatientSearch = $state(false);
|
let showPatientSearch = $state(false);
|
||||||
let selectedPatient = $state(null);
|
let selectedPatient = $state(null);
|
||||||
|
let patientVisits = $state([]);
|
||||||
|
let visitsLoading = $state(false);
|
||||||
let testSearchQuery = $state('');
|
let testSearchQuery = $state('');
|
||||||
let testSearchResults = $state([]);
|
let testSearchResults = $state([]);
|
||||||
let showTestSearch = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (open && !isInitialized) {
|
||||||
|
isInitialized = true;
|
||||||
|
|
||||||
if (order) {
|
if (order) {
|
||||||
// Edit mode - populate form
|
// Edit mode - populate form
|
||||||
formData = {
|
formData = {
|
||||||
@ -79,7 +87,16 @@
|
|||||||
Comment: '',
|
Comment: '',
|
||||||
Tests: []
|
Tests: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If patient prop is provided, auto-select it
|
||||||
|
if (patient) {
|
||||||
|
selectedPatient = patient;
|
||||||
|
formData.InternalPID = patient.InternalPID;
|
||||||
|
loadPatientVisits(patient.InternalPID);
|
||||||
|
} else {
|
||||||
selectedPatient = null;
|
selectedPatient = null;
|
||||||
|
}
|
||||||
|
|
||||||
patientSearchQuery = '';
|
patientSearchQuery = '';
|
||||||
patientSearchResults = [];
|
patientSearchResults = [];
|
||||||
}
|
}
|
||||||
@ -88,6 +105,9 @@
|
|||||||
testSearchQuery = '';
|
testSearchQuery = '';
|
||||||
testSearchResults = [];
|
testSearchResults = [];
|
||||||
showTestSearch = false;
|
showTestSearch = false;
|
||||||
|
} else if (!open) {
|
||||||
|
// Reset initialization flag when modal closes
|
||||||
|
isInitialized = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,9 +133,29 @@
|
|||||||
function selectPatient(patient) {
|
function selectPatient(patient) {
|
||||||
selectedPatient = patient;
|
selectedPatient = patient;
|
||||||
formData.InternalPID = patient.InternalPID;
|
formData.InternalPID = patient.InternalPID;
|
||||||
|
formData.PatVisitID = ''; // Reset visit when patient changes
|
||||||
showPatientSearch = false;
|
showPatientSearch = false;
|
||||||
patientSearchQuery = '';
|
patientSearchQuery = '';
|
||||||
patientSearchResults = [];
|
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() {
|
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">ID: {selectedPatient.PatientID}</p>
|
||||||
<p class="text-xs text-gray-500">Internal PID: {selectedPatient.InternalPID}</p>
|
<p class="text-xs text-gray-500">Internal PID: {selectedPatient.InternalPID}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if !order}
|
{#if !order && !patient}
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs btn-square"
|
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" />
|
<X class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@ -342,18 +389,27 @@
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label py-0.5" for="orderVisitId">
|
<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>
|
</label>
|
||||||
<div class="input input-sm input-bordered flex items-center gap-2 focus-within:input-primary">
|
<select
|
||||||
<Hash class="w-3.5 h-3.5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
id="orderVisitId"
|
id="orderVisitId"
|
||||||
type="number"
|
class="select select-sm select-bordered w-full focus:select-primary"
|
||||||
class="grow bg-transparent outline-none"
|
|
||||||
bind:value={formData.PatVisitID}
|
bind:value={formData.PatVisitID}
|
||||||
placeholder="Optional"
|
disabled={!selectedPatient || visitsLoading || patientVisits.length === 0}
|
||||||
/>
|
>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
|
|||||||
@ -1,22 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchPatients, fetchPatient } from '$lib/api/patients.js';
|
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 { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||||
import PatientSearchBar from './PatientSearchBar.svelte';
|
import PatientSearchBar from './PatientSearchBar.svelte';
|
||||||
import PatientList from './PatientList.svelte';
|
import PatientList from './PatientList.svelte';
|
||||||
import OrderList from './OrderList.svelte';
|
import OrderList from './OrderList.svelte';
|
||||||
import PatientFormModal from './PatientFormModal.svelte';
|
import PatientFormModal from './PatientFormModal.svelte';
|
||||||
|
import OrderFormModal from '../orders/OrderFormModal.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import { Plus, Edit2, Trash2 } from 'lucide-svelte';
|
import { Plus, Edit2, Trash2 } from 'lucide-svelte';
|
||||||
|
|
||||||
// Search state
|
// Search state (only PatientID and Name are supported by API)
|
||||||
let searchFilters = $state({
|
let searchFilters = $state({
|
||||||
patientId: '',
|
patientId: '',
|
||||||
patientName: '',
|
patientName: ''
|
||||||
visitNumber: '',
|
|
||||||
orderNumber: '',
|
|
||||||
orderDateFrom: '',
|
|
||||||
orderDateTo: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// List state
|
// List state
|
||||||
@ -44,6 +42,14 @@
|
|||||||
patient: null
|
patient: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Order form state
|
||||||
|
let orderForm = $state({
|
||||||
|
open: false,
|
||||||
|
order: null,
|
||||||
|
patient: null,
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
|
||||||
// Load patients on mount (empty on init)
|
// Load patients on mount (empty on init)
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Don't auto-load - wait for search
|
// Don't auto-load - wait for search
|
||||||
@ -62,27 +68,16 @@
|
|||||||
perPage
|
perPage
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add filters
|
// Add filters (API expects these exact parameter names)
|
||||||
if (searchFilters.patientId.trim()) {
|
if (searchFilters.patientId.trim()) {
|
||||||
params.PatientID = searchFilters.patientId.trim();
|
params.PatientID = searchFilters.patientId.trim();
|
||||||
}
|
}
|
||||||
if (searchFilters.patientName.trim()) {
|
if (searchFilters.patientName.trim()) {
|
||||||
params.Name = 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);
|
const response = await fetchPatients(params);
|
||||||
|
|
||||||
patients = Array.isArray(response.data) ? response.data : [];
|
patients = Array.isArray(response.data) ? response.data : [];
|
||||||
|
|
||||||
if (response.pagination) {
|
if (response.pagination) {
|
||||||
@ -100,11 +95,7 @@
|
|||||||
function handleClear() {
|
function handleClear() {
|
||||||
searchFilters = {
|
searchFilters = {
|
||||||
patientId: '',
|
patientId: '',
|
||||||
patientName: '',
|
patientName: ''
|
||||||
visitNumber: '',
|
|
||||||
orderNumber: '',
|
|
||||||
orderDateFrom: '',
|
|
||||||
orderDateTo: ''
|
|
||||||
};
|
};
|
||||||
patients = [];
|
patients = [];
|
||||||
selectedPatient = null;
|
selectedPatient = null;
|
||||||
@ -130,40 +121,8 @@
|
|||||||
|
|
||||||
ordersLoading = true;
|
ordersLoading = true;
|
||||||
try {
|
try {
|
||||||
// TODO: Replace with actual orders API endpoint
|
const response = await fetchOrders({ InternalPID: patient.InternalPID });
|
||||||
// const response = await fetchOrdersByPatient(patient.InternalPID);
|
orders = Array.isArray(response.data) ? response.data : [];
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err.message || 'Failed to load orders');
|
toastError(err.message || 'Failed to load orders');
|
||||||
orders = [];
|
orders = [];
|
||||||
@ -177,12 +136,6 @@
|
|||||||
loadOrders(patient);
|
loadOrders(patient);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectPatient(patient) {
|
|
||||||
// Just select the patient, don't auto-load orders
|
|
||||||
selectedPatient = patient;
|
|
||||||
orders = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patient CRUD
|
// Patient CRUD
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
patientForm = { open: true, patient: null, loading: false };
|
patientForm = { open: true, patient: null, loading: false };
|
||||||
@ -231,8 +184,32 @@
|
|||||||
|
|
||||||
// Order actions
|
// Order actions
|
||||||
function openCreateOrder() {
|
function openCreateOrder() {
|
||||||
// TODO: Implement order creation modal
|
if (!selectedPatient) {
|
||||||
toastSuccess('Create order - implement modal');
|
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) {
|
function handleViewOrder(order) {
|
||||||
@ -269,10 +246,6 @@
|
|||||||
<PatientSearchBar
|
<PatientSearchBar
|
||||||
bind:patientId={searchFilters.patientId}
|
bind:patientId={searchFilters.patientId}
|
||||||
bind:patientName={searchFilters.patientName}
|
bind:patientName={searchFilters.patientName}
|
||||||
bind:visitNumber={searchFilters.visitNumber}
|
|
||||||
bind:orderNumber={searchFilters.orderNumber}
|
|
||||||
bind:orderDateFrom={searchFilters.orderDateFrom}
|
|
||||||
bind:orderDateTo={searchFilters.orderDateTo}
|
|
||||||
{loading}
|
{loading}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
@ -297,8 +270,7 @@
|
|||||||
{totalItems}
|
{totalItems}
|
||||||
{perPage}
|
{perPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onSelectPatient={handleSelectPatient}
|
onSelectPatient={handleShowOrders}
|
||||||
onShowOrders={handleShowOrders}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -331,6 +303,16 @@
|
|||||||
loading={patientForm.loading}
|
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 -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { FileText, Printer, Eye, Clock, CheckCircle, AlertCircle } from 'lucide-svelte';
|
import { Printer, AlertCircle } from 'lucide-svelte';
|
||||||
import { formatDateTime, formatDate, getOrderStatusClass } from '$lib/utils/patients.js';
|
import { formatDate } from '$lib/utils/patients.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Order
|
* @typedef {Object} Order
|
||||||
@ -23,84 +23,27 @@
|
|||||||
/** @type {{ order: Order, onView: () => void, onPrintBarcode: () => void }} */
|
/** @type {{ order: Order, onView: () => void, onPrintBarcode: () => void }} */
|
||||||
let { order, onView, onPrintBarcode } = $props();
|
let { order, onView, onPrintBarcode } = $props();
|
||||||
|
|
||||||
let statusClass = $derived(getOrderStatusClass(order?.Status));
|
|
||||||
let isUrgent = $derived(order?.Priority?.toLowerCase() === 'urgent' || order?.Priority?.toLowerCase() === 'stat');
|
let isUrgent = $derived(order?.Priority?.toLowerCase() === 'urgent' || order?.Priority?.toLowerCase() === 'stat');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-sm border border-base-200 hover:shadow-md transition-shadow"
|
<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}>
|
class:border-error={isUrgent}
|
||||||
<div class="card-body p-3 compact-y">
|
onclick={onView}>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<!-- Order Header -->
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
{#if isUrgent}
|
{#if isUrgent}
|
||||||
<AlertCircle class="w-4 h-4 text-error" />
|
<AlertCircle class="w-3 h-3 text-error flex-shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-semibold text-sm truncate">
|
<span class="font-medium truncate w-24">{order.OrderNumber || order.OrderID || '-'}</span>
|
||||||
{order.TestName || order.TestCode || 'Unknown Test'}
|
<span class="text-gray-600 text-xs w-24">{formatDate(order.OrderDate)}</span>
|
||||||
</span>
|
<span class="text-gray-600 text-xs truncate flex-1">{order.OrderedBy || '-'}</span>
|
||||||
<span class="badge badge-sm {statusClass}">
|
|
||||||
{order.Status || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
<!-- 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">
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-xs btn-ghost"
|
||||||
title="View Order"
|
|
||||||
onclick={onView}
|
|
||||||
>
|
|
||||||
<Eye class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-ghost"
|
|
||||||
title="Print Barcode"
|
title="Print Barcode"
|
||||||
onclick={onPrintBarcode}
|
onclick={(e) => { e.stopPropagation(); onPrintBarcode(); }}
|
||||||
>
|
>
|
||||||
<Printer class="w-4 h-4" />
|
<Printer class="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -96,7 +96,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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)}
|
{#each orders as order (order.OrderID || order.OrderNumber)}
|
||||||
<OrderCard
|
<OrderCard
|
||||||
{order}
|
{order}
|
||||||
@ -113,7 +120,7 @@
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<FileText class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
<FileText class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||||
<p class="text-lg">Select a patient</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -231,7 +231,12 @@
|
|||||||
open = false;
|
open = false;
|
||||||
onSave?.();
|
onSave?.();
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@ -268,6 +273,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form class="space-y-3" onsubmit={(e) => e.preventDefault()}>
|
<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 -->
|
<!-- DaisyUI Tabs -->
|
||||||
<div class="tabs tabs-bordered">
|
<div class="tabs tabs-bordered">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -27,8 +27,7 @@
|
|||||||
* totalItems: number,
|
* totalItems: number,
|
||||||
* perPage: number,
|
* perPage: number,
|
||||||
* onPageChange: (page: number) => void,
|
* onPageChange: (page: number) => void,
|
||||||
* onSelectPatient: (patient: Patient) => void,
|
* onSelectPatient: (patient: Patient) => void
|
||||||
* onShowOrders: (patient: Patient) => void
|
|
||||||
* }} */
|
* }} */
|
||||||
let {
|
let {
|
||||||
patients = [],
|
patients = [],
|
||||||
@ -39,16 +38,14 @@
|
|||||||
totalItems = 0,
|
totalItems = 0,
|
||||||
perPage = 20,
|
perPage = 20,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onSelectPatient,
|
onSelectPatient
|
||||||
onShowOrders
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const columns = [
|
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: 'FullName', label: 'Name', class: 'min-w-32' },
|
||||||
{ key: 'SexLabel', label: 'Sex', class: 'w-16' },
|
{ key: 'SexLabel', label: 'Sex', class: 'w-12' },
|
||||||
{ key: 'BirthdateFormatted', label: 'Birthdate', class: 'w-28' },
|
{ key: 'BirthdateFormatted', label: 'DOB', class: 'w-24' },
|
||||||
{ key: 'actions', label: 'Orders', class: 'w-24 text-center' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let displayPatients = $derived(
|
let displayPatients = $derived(
|
||||||
@ -60,10 +57,6 @@
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleShowOrders(patient, event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
onShowOrders(patient);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col h-full overflow-hidden">
|
<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}
|
onRowClick={onSelectPatient}
|
||||||
>
|
>
|
||||||
{#snippet cell({ column, row })}
|
{#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
|
<span
|
||||||
class="truncate block"
|
class="truncate block"
|
||||||
class:font-semibold={selectedPatient?.InternalPID === row.InternalPID}
|
class:font-semibold={selectedPatient?.InternalPID === row.InternalPID}
|
||||||
@ -106,7 +88,6 @@
|
|||||||
>
|
>
|
||||||
{row[column.key]}
|
{row[column.key]}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,24 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Search, X, User, FileUser, ClipboardList, FileText, Calendar } from 'lucide-svelte';
|
import { Search, X, User, FileUser } from 'lucide-svelte';
|
||||||
|
|
||||||
/** @type {{
|
/** @type {{
|
||||||
* patientId: string,
|
* patientId: string,
|
||||||
* patientName: string,
|
* patientName: string,
|
||||||
* visitNumber: string,
|
|
||||||
* orderNumber: string,
|
|
||||||
* orderDateFrom: string,
|
|
||||||
* orderDateTo: string,
|
|
||||||
* loading: boolean,
|
* loading: boolean,
|
||||||
* onSearch: () => void,
|
* onSearch: () => void,
|
||||||
* onClear: () => void
|
* onClear: () => void
|
||||||
* }} */
|
* }} */
|
||||||
let {
|
let {
|
||||||
patientId = '',
|
patientId = $bindable(''),
|
||||||
patientName = '',
|
patientName = $bindable(''),
|
||||||
visitNumber = '',
|
|
||||||
orderNumber = '',
|
|
||||||
orderDateFrom = '',
|
|
||||||
orderDateTo = '',
|
|
||||||
loading = false,
|
loading = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
onClear
|
onClear
|
||||||
@ -31,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasFilters() {
|
function hasFilters() {
|
||||||
return patientId || patientName || visitNumber || orderNumber || orderDateFrom || orderDateTo;
|
return patientId || patientName;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -76,94 +68,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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()}
|
{#if hasFilters()}
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm flex-1"
|
class="btn btn-ghost btn-sm"
|
||||||
title="Clear filters"
|
title="Clear filters"
|
||||||
onclick={onClear}
|
onclick={onClear}
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
<span class="hidden sm:inline ml-1">Clear</span>
|
<span class="hidden sm:inline ml-1">Clear</span>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-sm flex-[2]"
|
class="btn btn-primary btn-sm flex-1"
|
||||||
onclick={onSearch}
|
onclick={onSearch}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@ -176,5 +94,4 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user