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:
parent
77ae55ca98
commit
807cfc8e7a
265
docs/orders.yaml
Normal file
265
docs/orders.yaml
Normal 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
519
docs/patient-visits.yaml
Normal 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
117
src/lib/api/orders.js
Normal 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' };
|
||||
}
|
||||
@ -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: [],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: ''
|
||||
|
||||
341
src/routes/(app)/orders/+page.svelte
Normal file
341
src/routes/(app)/orders/+page.svelte
Normal 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>
|
||||
262
src/routes/(app)/orders/OrderDetailModal.svelte
Normal file
262
src/routes/(app)/orders/OrderDetailModal.svelte
Normal 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>
|
||||
464
src/routes/(app)/orders/OrderFormModal.svelte
Normal file
464
src/routes/(app)/orders/OrderFormModal.svelte
Normal 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>
|
||||
177
src/routes/(app)/orders/OrderList.svelte
Normal file
177
src/routes/(app)/orders/OrderList.svelte
Normal 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>
|
||||
166
src/routes/(app)/orders/OrderSearchBar.svelte
Normal file
166
src/routes/(app)/orders/OrderSearchBar.svelte
Normal 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>
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
288
src/routes/(app)/visits/ADTFormModal.svelte
Normal file
288
src/routes/(app)/visits/ADTFormModal.svelte
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user