feat(orders): implement complete orders management module with specimens and tests

- Add orders API module with CRUD operations (src/lib/api/orders.js)
- Create orders page with search, list, form, and detail modals
- Add patient visits ADT form modal for admission/discharge/transfer
- Update test management: add Requestable field to BasicInfoTab
- Add API documentation for orders and patient visits endpoints
- Update visits page to integrate with orders functionality

Features:
- Order creation with auto-grouped specimens by container type
- Order status tracking (ORD, SCH, ANA, VER, REV, REP)
- Priority levels (Routine, Stat, Urgent)
- Order detail view with specimens and tests
- ADT (Admission/Discharge/Transfer) management for visits
This commit is contained in:
mahdahar 2026-03-03 13:51:07 +07:00
parent 77ae55ca98
commit 807cfc8e7a
15 changed files with 2685 additions and 20 deletions

265
docs/orders.yaml Normal file
View File

@ -0,0 +1,265 @@
/api/ordertest:
get:
tags: [Orders]
summary: List orders
security:
- bearerAuth: []
parameters:
- name: page
in: query
schema:
type: integer
- name: perPage
in: query
schema:
type: integer
- name: InternalPID
in: query
schema:
type: integer
description: Filter by internal patient ID
- name: OrderStatus
in: query
schema:
type: string
enum: [ORD, SCH, ANA, VER, REV, REP]
description: |
ORD: Ordered
SCH: Scheduled
ANA: Analysis
VER: Verified
REV: Reviewed
REP: Reported
- name: include
in: query
schema:
type: string
enum: [details]
description: Include specimens and tests in response
responses:
'200':
description: List of orders
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/orders.yaml#/OrderTest'
post:
tags: [Orders]
summary: Create order with specimens and tests
description: Creates an order with associated specimens and patres records. Tests are grouped by container type to minimize specimen creation.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- InternalPID
- Tests
properties:
OrderID:
type: string
description: Optional custom order ID (auto-generated if not provided)
InternalPID:
type: integer
description: Patient internal ID
PatVisitID:
type: integer
description: Visit ID
SiteID:
type: integer
default: 1
PlacerID:
type: string
Priority:
type: string
enum: [R, S, U]
default: R
description: |
R: Routine
S: Stat
U: Urgent
ReqApp:
type: string
description: Requesting application
Comment:
type: string
Tests:
type: array
items:
type: object
required:
- TestSiteID
properties:
TestSiteID:
type: integer
description: Test definition site ID
TestID:
type: integer
description: Alias for TestSiteID
responses:
'201':
description: Order created successfully with specimens and tests
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'
'400':
description: Validation error
'500':
description: Server error
patch:
tags: [Orders]
summary: Update order
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- OrderID
properties:
OrderID:
type: string
Priority:
type: string
enum: [R, S, U]
OrderStatus:
type: string
enum: [ORD, SCH, ANA, VER, REV, REP]
OrderingProvider:
type: string
DepartmentID:
type: integer
WorkstationID:
type: integer
responses:
'200':
description: Order updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'
delete:
tags: [Orders]
summary: Delete order
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- OrderID
properties:
OrderID:
type: string
responses:
'200':
description: Order deleted
/api/ordertest/status:
post:
tags: [Orders]
summary: Update order status
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- OrderID
- OrderStatus
properties:
OrderID:
type: string
OrderStatus:
type: string
enum: [ORD, SCH, ANA, VER, REV, REP]
description: |
ORD: Ordered
SCH: Scheduled
ANA: Analysis
VER: Verified
REV: Reviewed
REP: Reported
responses:
'200':
description: Order status updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'
/api/ordertest/{id}:
get:
tags: [Orders]
summary: Get order by ID
description: Returns order details with associated specimens and tests
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
description: Order ID (e.g., 0025030300001)
responses:
'200':
description: Order details with specimens and tests
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'

519
docs/patient-visits.yaml Normal file
View File

@ -0,0 +1,519 @@
/api/patvisit:
get:
tags: [Patient Visits]
summary: List patient visits
security:
- bearerAuth: []
parameters:
- name: InternalPID
in: query
schema:
type: integer
description: Filter by internal patient ID (exact match)
- name: PVID
in: query
schema:
type: string
description: Filter by visit ID (partial match)
- name: PatientID
in: query
schema:
type: string
description: Filter by patient ID (partial match)
- name: PatientName
in: query
schema:
type: string
description: Search by patient name (searches in both first and last name)
- name: CreateDateFrom
in: query
schema:
type: string
format: date-time
description: Filter visits created on or after this date
- name: CreateDateTo
in: query
schema:
type: string
format: date-time
description: Filter visits created on or before this date
- name: page
in: query
schema:
type: integer
- name: perPage
in: query
schema:
type: integer
responses:
'200':
description: List of patient visits
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
total:
type: integer
description: Total number of records
page:
type: integer
description: Current page number
per_page:
type: integer
description: Number of records per page
post:
tags: [Patient Visits]
summary: Create patient visit
description: |
Creates a new patient visit. PVID is auto-generated with 'DV' prefix if not provided.
Can optionally include PatDiag (diagnosis) and PatVisitADT (ADT information).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- InternalPID
properties:
PVID:
type: string
description: Visit ID (auto-generated with DV prefix if not provided)
InternalPID:
type: integer
description: Patient ID (required)
EpisodeID:
type: string
description: Episode identifier
SiteID:
type: integer
description: Site reference
PatDiag:
type: object
description: Optional diagnosis information
properties:
DiagCode:
type: string
Diagnosis:
type: string
PatVisitADT:
type: object
description: Optional ADT information
properties:
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
LocationID:
type: integer
AttDoc:
type: integer
RefDoc:
type: integer
AdmDoc:
type: integer
CnsDoc:
type: integer
responses:
'201':
description: Visit created successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: object
properties:
PVID:
type: string
InternalPVID:
type: integer
patch:
tags: [Patient Visits]
summary: Update patient visit
description: |
Updates an existing patient visit. InternalPVID is required.
Can update main visit data, PatDiag, and add new PatVisitADT records.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- InternalPVID
properties:
InternalPVID:
type: integer
description: Visit ID (required)
PVID:
type: string
InternalPID:
type: integer
EpisodeID:
type: string
SiteID:
type: integer
PatDiag:
type: object
description: Diagnosis information (will update if exists)
properties:
DiagCode:
type: string
Diagnosis:
type: string
PatVisitADT:
type: array
description: Array of ADT records to add (new records only)
items:
type: object
properties:
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
LocationID:
type: integer
AttDoc:
type: integer
RefDoc:
type: integer
AdmDoc:
type: integer
CnsDoc:
type: integer
sequence:
type: integer
description: Used for ordering multiple ADT records
responses:
'200':
description: Visit updated successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: object
properties:
PVID:
type: string
InternalPVID:
type: integer
delete:
tags: [Patient Visits]
summary: Delete patient visit
security:
- bearerAuth: []
responses:
'200':
description: Visit deleted successfully
/api/patvisit/{id}:
get:
tags: [Patient Visits]
summary: Get visit by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
description: PVID (visit identifier like DV00001)
responses:
'200':
description: Visit details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
/api/patvisit/patient/{patientId}:
get:
tags: [Patient Visits]
summary: Get visits by patient ID
security:
- bearerAuth: []
parameters:
- name: patientId
in: path
required: true
schema:
type: integer
description: Internal Patient ID (InternalPID)
responses:
'200':
description: Patient visits list
content:
application/json:
schema:
type: object
properties:
status:
type: string
data:
type: array
items:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
/api/patvisitadt:
post:
tags: [Patient Visits]
summary: Create ADT record
description: Create a new Admission/Discharge/Transfer record
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/patient-visit.yaml#/PatVisitADT'
responses:
'201':
description: ADT record created successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
patch:
tags: [Patient Visits]
summary: Update ADT record
description: Update an existing ADT record
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/patient-visit.yaml#/PatVisitADT'
responses:
'200':
description: ADT record updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
delete:
tags: [Patient Visits]
summary: Delete ADT visit (soft delete)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- PVADTID
properties:
PVADTID:
type: integer
description: ADT record ID to delete
responses:
'200':
description: ADT visit deleted successfully
/api/patvisitadt/visit/{visitId}:
get:
tags: [Patient Visits]
summary: Get ADT history by visit ID
description: Retrieve the complete Admission/Discharge/Transfer history for a visit, including all locations and doctors
security:
- bearerAuth: []
parameters:
- name: visitId
in: path
required: true
schema:
type: integer
description: Internal Visit ID (InternalPVID)
responses:
'200':
description: ADT history retrieved successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
example: ADT history retrieved
data:
type: array
items:
type: object
properties:
PVADTID:
type: integer
InternalPVID:
type: integer
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
LocationID:
type: integer
LocationName:
type: string
AttDoc:
type: integer
AttDocFirstName:
type: string
AttDocLastName:
type: string
RefDoc:
type: integer
RefDocFirstName:
type: string
RefDocLastName:
type: string
AdmDoc:
type: integer
AdmDocFirstName:
type: string
AdmDocLastName:
type: string
CnsDoc:
type: integer
CnsDocFirstName:
type: string
CnsDocLastName:
type: string
CreateDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
delete:
tags: [Patient Visits]
summary: Delete ADT visit (soft delete)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- PVADTID
properties:
PVADTID:
type: integer
description: ADT record ID to delete
responses:
'200':
description: ADT visit deleted successfully
/api/patvisitadt/{id}:
get:
tags: [Patient Visits]
summary: Get ADT record by ID
description: Retrieve a single ADT record by its ID, including location and doctor details
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: ADT record ID (PVADTID)
responses:
'200':
description: ADT record retrieved successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
example: ADT record retrieved
data:
type: object
properties:
PVADTID:
type: integer
InternalPVID:
type: integer
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
LocationID:
type: integer
LocationName:
type: string
AttDoc:
type: integer
AttDocFirstName:
type: string
AttDocLastName:
type: string
RefDoc:
type: integer
RefDocFirstName:
type: string
RefDocLastName:
type: string
AdmDoc:
type: integer
AdmDocFirstName:
type: string
AdmDocLastName:
type: string
CnsDoc:
type: integer
CnsDocFirstName:
type: string
CnsDocLastName:
type: string
CreateDate:
type: string
format: date-time
EndDate:
type: string
format: date-time

117
src/lib/api/orders.js Normal file
View File

@ -0,0 +1,117 @@
import { get, post, patch, del } from './client.js';
/**
* Fetch orders list with optional filters and pagination
* @param {Object} params - Query parameters
* @param {number} [params.page=1] - Page number
* @param {number} [params.perPage=20] - Items per page
* @param {number} [params.InternalPID] - Filter by internal patient ID
* @param {string} [params.OrderStatus] - Filter by order status (ORD, SCH, ANA, VER, REV, REP)
* @param {string} [params.include] - Include details (set to 'details' to include specimens and tests)
* @returns {Promise<Object>} API response with orders data and pagination
*/
export async function fetchOrders(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/ordertest?${query}` : '/api/ordertest');
}
/**
* Fetch a single order by ID with details
* @param {string} id - Order ID (e.g., 0025030300001)
* @returns {Promise<Object>} API response with order details including specimens and tests
*/
export async function fetchOrderById(id) {
return get(`/api/ordertest/${id}`);
}
/**
* Create a new order with specimens and tests
* @param {Object} data - Order data
* @param {number} data.InternalPID - Patient internal ID (required)
* @param {Array<Object>} data.Tests - Array of test objects with TestSiteID (required)
* @param {string} [data.OrderID] - Optional custom order ID (auto-generated if not provided)
* @param {number} [data.PatVisitID] - Visit ID
* @param {number} [data.SiteID=1] - Site ID
* @param {string} [data.PlacerID] - Placer ID
* @param {string} [data.Priority='R'] - Priority (R: Routine, S: Stat, U: Urgent)
* @param {string} [data.ReqApp] - Requesting application
* @param {string} [data.Comment] - Order comment
* @returns {Promise<Object>} API response with created order data
*/
export async function createOrder(data) {
return post('/api/ordertest', data);
}
/**
* Update an existing order
* @param {Object} data - Order data
* @param {string} data.OrderID - Order ID (required)
* @param {string} [data.Priority] - Priority (R, S, U)
* @param {string} [data.OrderStatus] - Order status (ORD, SCH, ANA, VER, REV, REP)
* @param {string} [data.OrderingProvider] - Ordering provider
* @param {number} [data.DepartmentID] - Department ID
* @param {number} [data.WorkstationID] - Workstation ID
* @returns {Promise<Object>} API response with updated order data
*/
export async function updateOrder(data) {
return patch('/api/ordertest', data);
}
/**
* Delete an order
* @param {string} orderId - Order ID to delete
* @returns {Promise<Object>} API response
*/
export async function deleteOrder(orderId) {
return del('/api/ordertest', { body: JSON.stringify({ OrderID: orderId }) });
}
/**
* Update order status
* @param {Object} data - Status update data
* @param {string} data.OrderID - Order ID (required)
* @param {string} data.OrderStatus - New status (ORD, SCH, ANA, VER, REV, REP)
* @returns {Promise<Object>} API response with updated order data
*/
export async function updateOrderStatus(data) {
return post('/api/ordertest/status', data);
}
/**
* Order status mapping for display
*/
export const ORDER_STATUS = {
ORD: { code: 'ORD', label: 'Ordered', color: 'badge-neutral', description: 'Order has been placed' },
SCH: { code: 'SCH', label: 'Scheduled', color: 'badge-info', description: 'Order is scheduled' },
ANA: { code: 'ANA', label: 'Analysis', color: 'badge-warning', description: 'Analysis in progress' },
VER: { code: 'VER', label: 'Verified', color: 'badge-success', description: 'Results verified' },
REV: { code: 'REV', label: 'Reviewed', color: 'badge-primary', description: 'Results reviewed' },
REP: { code: 'REP', label: 'Reported', color: 'badge-secondary', description: 'Report generated' }
};
/**
* Order priority mapping for display
*/
export const ORDER_PRIORITY = {
R: { code: 'R', label: 'Routine', color: 'badge-neutral' },
S: { code: 'S', label: 'Stat', color: 'badge-error' },
U: { code: 'U', label: 'Urgent', color: 'badge-warning' }
};
/**
* Get status display info
* @param {string} statusCode - Status code
* @returns {Object} Status display info
*/
export function getStatusInfo(statusCode) {
return ORDER_STATUS[statusCode] || { code: statusCode, label: statusCode, color: 'badge-ghost' };
}
/**
* Get priority display info
* @param {string} priorityCode - Priority code
* @returns {Object} Priority display info
*/
export function getPriorityInfo(priorityCode) {
return ORDER_PRIORITY[priorityCode] || { code: priorityCode, label: priorityCode, color: 'badge-ghost' };
}

View File

@ -52,6 +52,7 @@ function buildPayload(formData, isUpdate = false) {
VisibleScr: formData.VisibleScr ? 1 : 0,
VisibleRpt: formData.VisibleRpt ? 1 : 0,
CountStat: formData.CountStat ? 1 : 0,
Requestable: formData.Requestable ? 1 : 0,
// StartDate is auto-set by backend (created_at)
details: {},
refnum: [],

View File

@ -113,6 +113,7 @@
VisibleScr: true,
VisibleRpt: true,
CountStat: true,
Requestable: true,
details: {
DisciplineID: null,
DepartmentID: null,
@ -168,6 +169,7 @@
VisibleScr: test.VisibleScr === '1' || test.VisibleScr === 1 || test.VisibleScr === true,
VisibleRpt: test.VisibleRpt === '1' || test.VisibleRpt === 1 || test.VisibleRpt === true,
CountStat: test.CountStat === '1' || test.CountStat === 1 || test.CountStat === true,
Requestable: test.Requestable === '1' || test.Requestable === 1 || test.Requestable === true,
details: {
DisciplineID: test.DisciplineID || null,
DepartmentID: test.DepartmentID || null,

View File

@ -187,7 +187,7 @@
<!-- Display Settings -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Display Settings</h3>
<div class="grid grid-cols-5 gap-4">
<div class="grid grid-cols-6 gap-4">
<!-- Screen Sequence -->
<div class="space-y-1">
<label for="seqScr" class="block text-sm font-medium text-gray-700">Screen Seq</label>
@ -255,6 +255,20 @@
<span class="text-sm">Count</span>
</label>
</div>
<!-- Requestable -->
<div class="space-y-1">
<span class="block text-sm font-medium text-gray-700">Request</span>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
bind:checked={formData.Requestable}
onchange={handleFieldChange}
/>
<span class="text-sm">Requestable</span>
</label>
</div>
</div>
</div>
</div>

View File

@ -20,7 +20,7 @@
AgeEnd: '',
AgeUnit: 'years',
SpcType: '',
Display: 0,
Display: 1,
Flag: '',
Interpretation: '',
Notes: ''
@ -96,7 +96,7 @@
AgeEnd: '',
AgeUnit: 'years',
SpcType: '',
Display: 0,
Display: 1,
Flag: '',
Interpretation: '',
Notes: ''

View File

@ -0,0 +1,341 @@
<script>
import { onMount } from 'svelte';
import { Plus, Trash2, RefreshCw, FileText } from 'lucide-svelte';
import {
fetchOrders,
fetchOrderById,
createOrder,
updateOrder,
deleteOrder,
updateOrderStatus
} from '$lib/api/orders.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import OrderSearchBar from './OrderSearchBar.svelte';
import OrderList from './OrderList.svelte';
import OrderFormModal from './OrderFormModal.svelte';
import OrderDetailModal from './OrderDetailModal.svelte';
import Modal from '$lib/components/Modal.svelte';
// Search state
let searchFilters = $state({
orderId: '',
patientId: '',
orderStatus: '',
dateFrom: '',
dateTo: ''
});
// List state
let loading = $state(false);
let orders = $state([]);
let currentPage = $state(1);
let perPage = $state(20);
let totalItems = $state(0);
let totalPages = $state(1);
// Modal states
let orderForm = $state({
open: false,
order: null,
loading: false
});
let orderDetail = $state({
open: false,
order: null,
loading: false
});
let deleteModal = $state({
open: false,
order: null
});
let statusModal = $state({
open: false,
order: null,
newStatus: ''
});
// Load orders on mount
onMount(() => {
loadOrders();
});
async function loadOrders() {
loading = true;
try {
const params = {
page: currentPage,
perPage,
include: 'details'
};
// Add filters
if (searchFilters.orderId.trim()) {
params.OrderID = searchFilters.orderId.trim();
}
if (searchFilters.patientId.trim()) {
params.InternalPID = searchFilters.patientId.trim();
}
if (searchFilters.orderStatus) {
params.OrderStatus = searchFilters.orderStatus;
}
const response = await fetchOrders(params);
orders = Array.isArray(response.data) ? response.data : [];
if (response.pagination) {
totalItems = response.pagination.total || 0;
totalPages = Math.ceil(totalItems / perPage) || 1;
} else {
totalItems = orders.length;
totalPages = 1;
}
} catch (err) {
toastError(err.message || 'Failed to load orders');
orders = [];
} finally {
loading = false;
}
}
async function handleSearch() {
currentPage = 1;
await loadOrders();
}
function handleClear() {
searchFilters = {
orderId: '',
patientId: '',
orderStatus: '',
dateFrom: '',
dateTo: ''
};
currentPage = 1;
loadOrders();
}
function handlePageChange(newPage) {
if (newPage >= 1 && newPage <= totalPages) {
currentPage = newPage;
loadOrders();
}
}
// CRUD Operations
function openCreateModal() {
orderForm = { open: true, order: null, loading: false };
}
async function openEditModal(order) {
orderForm = { open: true, order: null, loading: true };
try {
const response = await fetchOrderById(order.OrderID);
orderForm = {
...orderForm,
order: response.data || order,
loading: false
};
} catch (err) {
toastError(err.message || 'Failed to load order details');
orderForm = {
...orderForm,
order: order,
loading: false
};
}
}
async function openViewModal(order) {
orderDetail = { open: true, order: null, loading: true };
try {
const response = await fetchOrderById(order.OrderID);
orderDetail = {
...orderDetail,
order: response.data || order,
loading: false
};
} catch (err) {
toastError(err.message || 'Failed to load order details');
orderDetail = {
...orderDetail,
order: order,
loading: false
};
}
}
async function handleSaveOrder(formData) {
orderForm.loading = true;
try {
if (orderForm.order) {
// Update existing order
await updateOrder({
OrderID: orderForm.order.OrderID,
...formData
});
toastSuccess('Order updated successfully');
} else {
// Create new order
await createOrder(formData);
toastSuccess('Order created successfully');
}
orderForm = { open: false, order: null, loading: false };
await loadOrders();
} catch (err) {
toastError(err.message || 'Failed to save order');
orderForm.loading = false;
}
}
function confirmDelete(order) {
deleteModal = { open: true, order };
}
async function handleDelete() {
if (!deleteModal.order?.OrderID) return;
try {
await deleteOrder(deleteModal.order.OrderID);
toastSuccess('Order deleted successfully');
deleteModal = { open: false, order: null };
await loadOrders();
} catch (err) {
toastError(err.message || 'Failed to delete order');
}
}
// Status management
function openStatusModal(order) {
statusModal = {
open: true,
order,
newStatus: order.OrderStatus
};
}
async function handleStatusUpdate() {
if (!statusModal.order?.OrderID || !statusModal.newStatus) return;
try {
await updateOrderStatus({
OrderID: statusModal.order.OrderID,
OrderStatus: statusModal.newStatus
});
toastSuccess('Order status updated successfully');
statusModal = { open: false, order: null, newStatus: '' };
await loadOrders();
} catch (err) {
toastError(err.message || 'Failed to update order status');
}
}
function handleRefresh() {
loadOrders();
}
function handlePrintBarcode(order) {
// TODO: Implement barcode printing
toastSuccess(`Print barcode for order: ${order.OrderID}`);
}
</script>
<div class="h-[calc(100vh-4rem)] flex flex-col p-4 gap-3">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-800">Orders Management</h1>
<p class="text-sm text-gray-600">Manage laboratory orders and track status</p>
</div>
<div class="flex gap-2">
<button
class="btn btn-ghost btn-sm"
onclick={handleRefresh}
disabled={loading}
>
<RefreshCw class="w-4 h-4 mr-1" />
Refresh
</button>
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-1" />
New Order
</button>
</div>
</div>
<!-- Search Bar -->
<OrderSearchBar
bind:orderId={searchFilters.orderId}
bind:patientId={searchFilters.patientId}
bind:orderStatus={searchFilters.orderStatus}
bind:dateFrom={searchFilters.dateFrom}
bind:dateTo={searchFilters.dateTo}
{loading}
onSearch={handleSearch}
onClear={handleClear}
/>
<!-- Order List -->
<div class="flex-1 min-h-0">
<OrderList
{orders}
{loading}
{currentPage}
{totalPages}
{totalItems}
{perPage}
onPageChange={handlePageChange}
onView={openViewModal}
onEdit={openEditModal}
onDelete={confirmDelete}
/>
</div>
</div>
<!-- Order Form Modal -->
<OrderFormModal
bind:open={orderForm.open}
order={orderForm.order}
loading={orderForm.loading}
onSave={handleSaveOrder}
onCancel={() => orderForm = { open: false, order: null, loading: false }}
/>
<!-- Order Detail Modal -->
<OrderDetailModal
bind:open={orderDetail.open}
order={orderDetail.order}
loading={orderDetail.loading}
onClose={() => orderDetail = { open: false, order: null, loading: false }}
/>
<!-- Delete Confirmation Modal -->
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
<div class="py-2">
<p>
Delete order
<strong>{deleteModal.order?.OrderID}</strong>?
</p>
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button
class="btn btn-ghost btn-sm"
onclick={() => deleteModal.open = false}
>
Cancel
</button>
<button
class="btn btn-error btn-sm"
onclick={handleDelete}
>
<Trash2 class="w-4 h-4 mr-1" />
Delete
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,262 @@
<script>
import Modal from '$lib/components/Modal.svelte';
import { getStatusInfo, getPriorityInfo, ORDER_STATUS } from '$lib/api/orders.js';
import { User, FlaskConical, Calendar, FileText, Hash, Building2, UserCircle, Beaker, ClipboardList } from 'lucide-svelte';
/** @type {{
* open: boolean,
* order: Object | null,
* loading: boolean,
* onClose: () => void
* }} */
let {
open = $bindable(false),
order = null,
loading = false,
onClose
} = $props();
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatShortDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
$effect(() => {
if (!open) {
// Reset when closed
}
});
</script>
<Modal
bind:open
title="Order Details"
size="lg"
onClose={onClose}
>
{#if loading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if order}
<div class="space-y-4">
<!-- Order Header -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
<div>
<div class="flex items-center gap-2 mb-1">
<ClipboardList class="w-5 h-5 text-gray-500" />
<span class="text-lg font-bold">{order.OrderID}</span>
</div>
<p class="text-sm text-gray-600">
Created: {formatDate(order.OrderDate)}
</p>
</div>
<div class="flex flex-col items-end gap-1">
{#if order.OrderStatus}
{@const status = getStatusInfo(order.OrderStatus)}
<span class="badge badge-md {status.color}">
{status.label}
</span>
{/if}
{#if order.Priority}
{@const priority = getPriorityInfo(order.Priority)}
<span class="badge badge-sm {priority.color}">
{priority.label} Priority
</span>
{:else}
{@const priority = getPriorityInfo('R')}
<span class="badge badge-sm {priority.color}">
{priority.label} Priority
</span>
{/if}
</div>
</div>
<!-- Patient Info -->
{#if order.PatientID || order.InternalPID}
<div class="border border-base-300 rounded-lg p-4">
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
<User class="w-4 h-4" />
Patient Information
</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-gray-500">Patient ID:</span>
<span class="ml-1 font-medium">{order.PatientID || '-'}</span>
</div>
<div>
<span class="text-gray-500">Internal PID:</span>
<span class="ml-1 font-medium">{order.InternalPID}</span>
</div>
{#if order.PatientName}
<div class="col-span-2">
<span class="text-gray-500">Name:</span>
<span class="ml-1 font-medium">{order.PatientName}</span>
</div>
{/if}
</div>
</div>
{/if}
<!-- Order Details -->
<div class="border border-base-300 rounded-lg p-4">
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
<FileText class="w-4 h-4" />
Order Information
</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
{#if order.PlacerID}
<div>
<span class="text-gray-500">Placer ID:</span>
<span class="ml-1 font-medium">{order.PlacerID}</span>
</div>
{/if}
{#if order.PatVisitID}
<div>
<span class="text-gray-500">Visit ID:</span>
<span class="ml-1 font-medium">{order.PatVisitID}</span>
</div>
{/if}
{#if order.SiteID}
<div>
<span class="text-gray-500">Site ID:</span>
<span class="ml-1 font-medium">{order.SiteID}</span>
</div>
{/if}
{#if order.ReqApp}
<div>
<span class="text-gray-500">Requesting App:</span>
<span class="ml-1 font-medium">{order.ReqApp}</span>
</div>
{/if}
{#if order.OrderingProvider}
<div>
<span class="text-gray-500">Ordering Provider:</span>
<span class="ml-1 font-medium">{order.OrderingProvider}</span>
</div>
{/if}
</div>
{#if order.Comment}
<div class="mt-3 pt-3 border-t border-base-200">
<span class="text-gray-500 text-sm">Comment:</span>
<p class="mt-1 text-sm">{order.Comment}</p>
</div>
{/if}
</div>
<!-- Tests -->
{#if order.Tests && order.Tests.length > 0}
<div class="border border-base-300 rounded-lg p-4">
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
<FlaskConical class="w-4 h-4" />
Tests ({order.Tests.length})
</h4>
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr class="bg-base-200">
<th class="text-xs">Test Site ID</th>
<th class="text-xs">Test ID</th>
<th class="text-xs">Status</th>
</tr>
</thead>
<tbody>
{#each order.Tests as test, index (test.TestSiteID || index)}
{@const testStatus = getStatusInfo(test.Status || order.OrderStatus)}
<tr>
<td class="text-sm">{test.TestSiteID}</td>
<td class="text-sm">{test.TestID || '-'}</td>
<td class="text-sm">
<span class="badge badge-sm {testStatus.color}">
{testStatus.label}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Specimens -->
{#if order.Specimens && order.Specimens.length > 0}
<div class="border border-base-300 rounded-lg p-4">
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
<Beaker class="w-4 h-4" />
Specimens ({order.Specimens.length})
</h4>
<div class="space-y-2">
{#each order.Specimens as specimen, index (specimen.SpecimenID || index)}
<div class="flex items-center gap-3 p-2 bg-base-100 rounded border border-base-200">
<Beaker class="w-4 h-4 text-gray-400" />
<div class="flex-1">
<p class="text-sm font-medium">{specimen.SpecimenID || 'Specimen'}</p>
{#if specimen.ContainerType}
<p class="text-xs text-gray-500">Container: {specimen.ContainerType}</p>
{/if}
</div>
{#if specimen.CollectionDate}
<span class="text-xs text-gray-500">
{formatShortDate(specimen.CollectionDate)}
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Status History (if available) -->
{#if order.StatusHistory && order.StatusHistory.length > 0}
<div class="border border-base-300 rounded-lg p-4">
<h4 class="font-semibold text-sm mb-3">Status History</h4>
<div class="space-y-2">
{#each order.StatusHistory as history, index (history.Date || index)}
{@const histStatus = getStatusInfo(history.Status)}
<div class="flex items-center gap-3 text-sm">
<span class="text-gray-500 w-24">{formatDate(history.Date)}</span>
<span class="badge badge-sm {histStatus.color}">{histStatus.label}</span>
{#if history.By}
<span class="text-gray-600">by {history.By}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<div class="text-center py-8 text-gray-500">
<FileText class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No order data available</p>
</div>
{/if}
{#snippet footer()}
<button
class="btn btn-ghost btn-sm"
onclick={onClose}
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,464 @@
<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>

View File

@ -0,0 +1,177 @@
<script>
import { Edit2, Trash2, Eye, ChevronLeft, ChevronRight, FileText } from 'lucide-svelte';
import DataTable from '$lib/components/DataTable.svelte';
import { getStatusInfo, getPriorityInfo } from '$lib/api/orders.js';
/**
* @typedef {Object} Order
* @property {string} OrderID
* @property {string} [OrderNumber]
* @property {number} InternalPID
* @property {string} [PatientID]
* @property {string} [PatientName]
* @property {string} OrderStatus
* @property {string} [Priority]
* @property {string} [OrderDate]
* @property {string} [OrderingProvider]
* @property {Array} [Specimens]
* @property {Array} [Tests]
*/
/** @type {{
* orders: Order[],
* loading: boolean,
* currentPage: number,
* totalPages: number,
* totalItems: number,
* perPage: number,
* onPageChange: (page: number) => void,
* onView: (order: Order) => void,
* onEdit: (order: Order) => void,
* onDelete: (order: Order) => void
* }} */
let {
orders = [],
loading = false,
currentPage = 1,
totalPages = 1,
totalItems = 0,
perPage = 20,
onPageChange,
onView,
onEdit,
onDelete
} = $props();
const columns = [
{ key: 'OrderID', label: 'Order ID', class: 'w-32' },
{ key: 'PatientID', label: 'Patient ID', class: 'w-28' },
{ key: 'PatientName', label: 'Patient Name', class: 'w-40' },
{ key: 'OrderStatus', label: 'Status', class: 'w-28' },
{ key: 'Priority', label: 'Priority', class: 'w-24' },
{ key: 'OrderDate', label: 'Order Date', class: 'w-32' },
{ key: 'OrderingProvider', label: 'Provider', class: 'w-32' },
{ key: 'actions', label: 'Actions', class: 'w-28 text-center' }
];
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function handlePreviousPage() {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
}
function handleNextPage() {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
}
</script>
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col h-full overflow-hidden">
<!-- Table Container -->
<div class="flex-1 overflow-auto">
<DataTable
{columns}
data={orders}
{loading}
emptyMessage="No orders found. Use the search filters above to find orders."
striped={true}
hover={true}
>
{#snippet cell({ column, row, value })}
{#if column.key === 'OrderStatus'}
{@const status = getStatusInfo(value)}
<span class="badge badge-sm {status.color}">
{status.label}
</span>
{:else if column.key === 'Priority'}
{@const priority = getPriorityInfo(value || 'R')}
<span class="badge badge-sm {priority.color}">
{priority.label}
</span>
{:else if column.key === 'OrderDate'}
{formatDate(value)}
{:else if column.key === 'PatientName'}
<span class="truncate block max-w-[150px]" title={value}>
{value || '-'}
</span>
{:else if column.key === 'OrderingProvider'}
<span class="truncate block max-w-[120px]" title={value}>
{value || '-'}
</span>
{:else if column.key === 'actions'}
<div class="flex items-center justify-center gap-1">
<button
class="btn btn-ghost btn-xs btn-square"
title="View details"
onclick={() => onView(row)}
>
<Eye class="w-3.5 h-3.5" />
</button>
<button
class="btn btn-ghost btn-xs btn-square"
title="Edit order"
onclick={() => onEdit(row)}
>
<Edit2 class="w-3.5 h-3.5" />
</button>
<button
class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10"
title="Delete order"
onclick={() => onDelete(row)}
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
{:else}
<span class="truncate block max-w-[150px]" title={value}>
{value || '-'}
</span>
{/if}
{/snippet}
</DataTable>
</div>
<!-- Pagination -->
{#if totalItems > 0}
<div class="border-t border-base-200 p-3 flex items-center justify-between bg-base-100">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold">{(currentPage - 1) * perPage + 1}</span> -
<span class="font-semibold">{Math.min(currentPage * perPage, totalItems)}</span> of
<span class="font-semibold">{totalItems}</span> orders
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-sm btn-ghost btn-square"
onclick={handlePreviousPage}
disabled={currentPage <= 1 || loading}
>
<ChevronLeft class="w-4 h-4" />
</button>
<span class="text-sm text-gray-600 min-w-[80px] text-center">
Page {currentPage} of {totalPages}
</span>
<button
class="btn btn-sm btn-ghost btn-square"
onclick={handleNextPage}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,166 @@
<script>
import { Search, X, ClipboardList, User, Filter, Calendar } from 'lucide-svelte';
import { ORDER_STATUS } from '$lib/api/orders.js';
/** @type {{
* orderId: string,
* patientId: string,
* orderStatus: string,
* dateFrom: string,
* dateTo: string,
* loading: boolean,
* onSearch: () => void,
* onClear: () => void
* }} */
let {
orderId = '',
patientId = '',
orderStatus = '',
dateFrom = '',
dateTo = '',
loading = false,
onSearch,
onClear
} = $props();
function handleKeydown(e) {
if (e.key === 'Enter') {
onSearch();
}
}
function hasFilters() {
return orderId || patientId || orderStatus || dateFrom || dateTo;
}
const statusOptions = Object.values(ORDER_STATUS);
</script>
<div class="bg-base-100 rounded-xl shadow-lg border border-base-300/50 p-3">
<div class="flex flex-col gap-3">
<!-- Row 1: Order ID, Patient ID, Status -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<!-- Order ID -->
<div class="form-control">
<label class="label py-0 mb-1" for="searchOrderId">
<span class="label-text text-xs font-medium text-gray-600">Order ID</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="searchOrderId"
type="text"
class="grow bg-transparent outline-none"
placeholder="e.g., 0025030300001"
bind:value={orderId}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Patient ID -->
<div class="form-control">
<label class="label py-0 mb-1" for="searchPatientId">
<span class="label-text text-xs font-medium text-gray-600">Patient ID</span>
</label>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<User class="w-4 h-4 text-gray-400" />
<input
id="searchPatientId"
type="text"
class="grow bg-transparent outline-none"
placeholder="Internal Patient ID"
bind:value={patientId}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Order Status -->
<div class="form-control">
<label class="label py-0 mb-1" for="searchOrderStatus">
<span class="label-text text-xs font-medium text-gray-600">Status</span>
</label>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Filter class="w-4 h-4 text-gray-400" />
<select
id="searchOrderStatus"
class="grow bg-transparent outline-none text-sm"
bind:value={orderStatus}
onchange={onSearch}
>
<option value="">All Statuses</option>
{#each statusOptions as status (status.code)}
<option value={status.code}>{status.label}</option>
{/each}
</select>
</label>
</div>
</div>
<!-- Row 2: Date Range and Actions -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Date From -->
<div class="form-control">
<label class="label py-0 mb-1" for="searchDateFrom">
<span class="label-text text-xs font-medium text-gray-600">Date 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="searchDateFrom"
type="date"
class="grow bg-transparent outline-none text-xs"
bind:value={dateFrom}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Date To -->
<div class="form-control">
<label class="label py-0 mb-1" for="searchDateTo">
<span class="label-text text-xs font-medium text-gray-600">Date 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="searchDateTo"
type="date"
class="grow bg-transparent outline-none text-xs"
bind:value={dateTo}
onkeydown={handleKeydown}
/>
</label>
</div>
<!-- Action Buttons -->
<div class="form-control col-span-2 flex flex-row items-end gap-2">
{#if hasFilters()}
<button
class="btn btn-ghost btn-sm flex-1"
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]"
onclick={onSearch}
disabled={loading}
>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Search class="w-4 h-4 mr-1" />
Search
{/if}
</button>
</div>
</div>
</div>
</div>

View File

@ -6,6 +6,7 @@
import VisitList from './VisitList.svelte';
import VisitFormModal from './VisitFormModal.svelte';
import VisitADTHistoryModal from './VisitADTHistoryModal.svelte';
import ADTFormModal from './ADTFormModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import { Plus, LayoutGrid, List } from 'lucide-svelte';
@ -52,6 +53,12 @@
visit: null
});
let adtForm = $state({
open: false,
visit: null,
adt: null
});
onMount(() => {
// Don't auto-load - wait for search
visits = [];
@ -201,6 +208,15 @@
toastError(err.message || 'Failed to delete visit');
}
}
// ADT Form
function openADTForm(visit) {
adtForm = { open: true, visit, adt: null };
}
function handleADTSaved() {
handleSearch();
}
</script>
<div class="h-[calc(100vh-4rem)] flex flex-col p-4 gap-3">
@ -259,6 +275,7 @@
onDeleteVisit={confirmDelete}
onViewHistory={openADTHistory}
onDischarge={openDischargeModal}
onAddADT={openADTForm}
/>
</div>
</div>
@ -336,3 +353,11 @@
</button>
{/snippet}
</Modal>
<!-- ADT Form Modal -->
<ADTFormModal
bind:open={adtForm.open}
visit={adtForm.visit}
adt={adtForm.adt}
onSave={handleADTSaved}
/>

View File

@ -0,0 +1,288 @@
<script>
import { createADT, updateADT } from '$lib/api/visits.js';
import { fetchLocations } from '$lib/api/locations.js';
import { fetchContacts } from '$lib/api/contacts.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { Activity, MapPin, User, Calendar } from 'lucide-svelte';
/** @type {{ open: boolean, visit: any | null, adt: any | null, onSave: () => void }} */
let { open = $bindable(false), visit = null, adt = null, onSave } = $props();
let loading = $state(false);
let saving = $state(false);
let locations = $state([]);
let contacts = $state([]);
let formErrors = $state({});
let formData = $state({
PVADTID: null,
InternalPVID: '',
ADTCode: '',
LocationID: '',
LocCode: '',
AttDoc: '',
AdmDoc: '',
RefDoc: '',
CnsDoc: '',
CreateDate: '',
EndDate: '',
});
const isEdit = $derived(!!adt?.PVADTID);
const adtTypeOptions = [
{ value: 'A01', label: 'A01 - Admit' },
{ value: 'A02', label: 'A02 - Transfer' },
{ value: 'A03', label: 'A03 - Discharge' },
{ value: 'A04', label: 'A04 - Registration' },
{ value: 'A08', label: 'A08 - Update' },
];
$effect(() => {
if (open) {
loadLocations();
loadContacts();
if (adt) {
formData = {
PVADTID: adt.PVADTID || null,
InternalPVID: adt.InternalPVID || visit?.InternalPVID || '',
ADTCode: adt.ADTCode || '',
LocationID: adt.LocationID || '',
LocCode: adt.LocCode || '',
AttDoc: adt.AttDoc || '',
AdmDoc: adt.AdmDoc || '',
RefDoc: adt.RefDoc || '',
CnsDoc: adt.CnsDoc || '',
CreateDate: adt.CreateDate || new Date().toISOString().slice(0, 16),
EndDate: adt.EndDate || '',
};
} else {
formData = {
PVADTID: null,
InternalPVID: visit?.InternalPVID || '',
ADTCode: '',
LocationID: '',
LocCode: '',
AttDoc: '',
AdmDoc: '',
RefDoc: '',
CnsDoc: '',
CreateDate: new Date().toISOString().slice(0, 16),
EndDate: '',
};
}
}
});
async function loadLocations() {
try {
const response = await fetchLocations();
locations = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load locations:', err);
}
}
async function loadContacts() {
try {
const response = await fetchContacts();
contacts = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load contacts:', err);
}
}
const locationOptions = $derived(
locations.map((l) => ({
value: l.LocationID || l.LocCode || '',
label: l.LocFull || l.LocCode || 'Unknown',
}))
);
const doctorOptions = $derived(
contacts.map((c) => ({
value: c.ContactID || c.ContactCode || '',
label: [c.NamePrefix, c.NameFirst, c.NameMiddle, c.NameLast].filter(Boolean).join(' ') || c.ContactCode || 'Unknown',
}))
);
function validateForm() {
const errors = {};
if (!formData.ADTCode?.trim()) {
errors.ADTCode = 'ADT type is required';
}
if (!formData.CreateDate) {
errors.CreateDate = 'Date is required';
}
formErrors = errors;
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
saving = true;
try {
const payload = { ...formData };
// Remove empty fields
Object.keys(payload).forEach((key) => {
if (payload[key] === '' || payload[key] === null) {
delete payload[key];
}
});
if (isEdit) {
await updateADT(payload);
toastSuccess('ADT record updated successfully');
} else {
await createADT(payload);
toastSuccess('ADT record created successfully');
}
open = false;
onSave?.();
} catch (err) {
toastError(err.message || 'Failed to save ADT record');
} finally {
saving = false;
}
}
</script>
<Modal bind:open title={isEdit ? 'Edit ADT Record' : 'New ADT Record'} size="lg">
{#if loading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else}
<form class="space-y-4" onsubmit={(e) => e.preventDefault()}>
<!-- Visit Info Display -->
{#if visit}
<div class="p-4 bg-base-200 rounded-lg">
<div class="flex items-center gap-2 text-sm">
<Activity class="w-4 h-4 text-gray-500" />
<span class="font-medium">Visit:</span>
<span>{visit.PVID}</span>
{#if visit.PatientID}
<span class="text-gray-500">| Patient: {visit.PatientID}</span>
{/if}
</div>
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectDropdown
label="ADT Type"
name="adtCode"
bind:value={formData.ADTCode}
options={adtTypeOptions}
placeholder="Select ADT type..."
required={true}
error={formErrors.ADTCode}
/>
<div class="form-control">
<label class="label" for="createDate">
<span class="label-text font-medium">Date</span>
<span class="label-text-alt text-error">*</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="createDate"
type="datetime-local"
class="grow bg-transparent outline-none"
class:input-error={formErrors.CreateDate}
bind:value={formData.CreateDate}
/>
</label>
{#if formErrors.CreateDate}
<span class="text-error text-sm mt-1">{formErrors.CreateDate}</span>
{/if}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectDropdown
label="Location"
name="location"
bind:value={formData.LocationID}
options={locationOptions}
placeholder="Select location..."
/>
<div class="form-control">
<label class="label" for="endDate">
<span class="label-text font-medium">End Date</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="endDate"
type="datetime-local"
class="grow bg-transparent outline-none"
bind:value={formData.EndDate}
/>
</label>
</div>
</div>
<div class="border-t border-base-200 pt-4 mt-4">
<h4 class="font-medium text-gray-700 mb-4 flex items-center gap-2">
<User class="w-4 h-4" />
Doctors
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectDropdown
label="Attending Doctor"
name="attDoc"
bind:value={formData.AttDoc}
options={doctorOptions}
placeholder="Select attending doctor..."
/>
<SelectDropdown
label="Admitting Doctor"
name="admDoc"
bind:value={formData.AdmDoc}
options={doctorOptions}
placeholder="Select admitting doctor..."
/>
<SelectDropdown
label="Referring Doctor"
name="refDoc"
bind:value={formData.RefDoc}
options={doctorOptions}
placeholder="Select referring doctor..."
/>
<SelectDropdown
label="Consulting Doctor"
name="cnsDoc"
bind:value={formData.CnsDoc}
options={doctorOptions}
placeholder="Select consulting doctor..."
/>
</div>
</div>
</form>
{/if}
{#snippet footer()}
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-sm" onclick={() => (open = false)} type="button">
Cancel
</button>
<button class="btn btn-primary btn-sm" onclick={handleSubmit} disabled={saving} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
{/snippet}
</Modal>

View File

@ -1,5 +1,5 @@
<script>
import { ChevronLeft, ChevronRight, Calendar, Edit2, History } from 'lucide-svelte';
import { ChevronLeft, ChevronRight, Calendar, Edit2, History, Plus } from 'lucide-svelte';
import VisitCard from './VisitCard.svelte';
import DataTable from '$lib/components/DataTable.svelte';
import { formatPatientName } from '$lib/utils/patients.js';
@ -9,15 +9,20 @@
* @property {string} InternalPVID
* @property {string} [PVID]
* @property {string} [PatientID]
* @property {string} [PatientName]
* @property {string} [NameFirst]
* @property {string} [NameLast]
* @property {string} [EpisodeID]
* @property {string} [LastVisitADT]
* @property {string} [LocCode]
* @property {string} [LocFull]
* @property {string} [PVCreateDate]
* @property {string} [ADTCode]
* @property {string} [LocCode]
* @property {string} [EndDate]
* @property {string} [ArchivedDate]
*/
/** @type {{
* visits: Visit[],
/** @type {{
* visits: Visit[],
* loading: boolean,
* viewMode: 'table' | 'cards',
* currentPage: number,
@ -28,10 +33,11 @@
* onEditVisit: (visit: Visit) => void,
* onDeleteVisit: (visit: Visit) => void,
* onViewHistory: (visit: Visit) => void,
* onDischarge: (visit: Visit) => void
* onDischarge: (visit: Visit) => void,
* onAddADT?: (visit: Visit) => void
* }} */
let {
visits = [],
let {
visits = [],
loading = false,
viewMode = 'table',
currentPage = 1,
@ -42,18 +48,19 @@
onEditVisit,
onDeleteVisit,
onViewHistory,
onDischarge
onDischarge,
onAddADT
} = $props();
const columns = [
{ key: 'PVID', label: 'Visit ID', class: 'font-medium w-24' },
{ key: 'PatientID', label: 'Patient ID', class: 'w-24' },
{ key: 'PatientName', label: 'Patient Name', class: 'min-w-32' },
{ key: 'PVCreateDate', label: 'Date', class: 'w-28' },
{ key: 'ADTCode', label: 'Type', class: 'w-20' },
{ key: 'PVID', label: 'PVID', class: 'font-medium w-24' },
{ key: 'PatientID', label: 'PatID', class: 'w-24' },
{ key: 'PatientName', label: 'Patient Name', class: 'min-w-36' },
{ key: 'EpisodeID', label: 'Episode ID', class: 'w-28' },
{ key: 'LastVisitADT', label: 'Last Visit ADT', class: 'w-32' },
{ key: 'LocCode', label: 'Location', class: 'w-24' },
{ key: 'Status', label: 'Status', class: 'w-20 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-28 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-36 text-center' },
];
function getStatusBadge(visit) {
@ -62,6 +69,12 @@
}
return '<span class="badge badge-sm badge-ghost">Closed</span>';
}
function getPatientName(visit) {
const first = visit.NameFirst || '';
const last = visit.NameLast || '';
return [first, last].filter(Boolean).join(' ') || visit.PatientName || '-';
}
</script>
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col h-full overflow-hidden">
@ -88,22 +101,33 @@
{#snippet cell({ column, row })}
{#if column.key === 'Status'}
{@html getStatusBadge(row)}
{:else if column.key === 'PatientName'}
<span class="truncate block">{getPatientName(row)}</span>
{:else if column.key === 'actions'}
<div class="flex justify-center gap-1">
<button
<button
class="btn btn-xs btn-ghost"
title="Edit"
onclick={() => onEditVisit(row)}
>
<Edit2 class="w-3 h-3" />
</button>
<button
<button
class="btn btn-xs btn-ghost"
title="History"
onclick={() => onViewHistory(row)}
>
<History class="w-3 h-3" />
</button>
{#if onAddADT}
<button
class="btn btn-xs btn-ghost btn-primary"
title="Add ADT"
onclick={() => onAddADT(row)}
>
<Plus class="w-3 h-3" />
</button>
{/if}
</div>
{:else}
<span class="truncate block">{row[column.key] || '-'}</span>