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
|
||||
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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
>
|
||||
@ -176,5 +94,4 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user