Refactor patients and visits modules
- Extract patient utilities to src/lib/utils/patients.js - Split patient page into modular components - Create dedicated visits page and route - Move visit-related modals to visits directory - Add Sidebar navigation for visits
This commit is contained in:
parent
ae806911be
commit
ad1618efec
@ -180,6 +180,7 @@ function toggleLaboratory() {
|
||||
{#if isOpen && laboratoryExpanded}
|
||||
<ul class="submenu">
|
||||
<li><a href="/patients" class="submenu-link"><Users size={16} /> Patients</a></li>
|
||||
<li><a href="/visits" class="submenu-link"><Activity size={16} /> Visits</a></li>
|
||||
<li><a href="/orders" class="submenu-link"><ClipboardList size={16} /> Orders</a></li>
|
||||
<li><a href="/specimens" class="submenu-link"><FlaskConical size={16} /> Specimens</a></li>
|
||||
<li><a href="/result-entry" class="submenu-link"><FileText size={16} /> Result Entry</a></li>
|
||||
|
||||
149
src/lib/utils/patients.js
Normal file
149
src/lib/utils/patients.js
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Patient and Order utility functions for LIS
|
||||
* @module $lib/utils/patients
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format patient name from components
|
||||
* @param {Object} patient - Patient object
|
||||
* @param {string} [patient.Prefix] - Name prefix
|
||||
* @param {string} [patient.NameFirst] - First name
|
||||
* @param {string} [patient.NameMiddle] - Middle name
|
||||
* @param {string} [patient.NameLast] - Last name
|
||||
* @param {string} [patient.FullName] - Pre-formatted full name
|
||||
* @returns {string} Formatted full name
|
||||
*/
|
||||
export function formatPatientName(patient) {
|
||||
if (!patient) return '-';
|
||||
if (patient.FullName) return patient.FullName;
|
||||
return [
|
||||
patient.Prefix,
|
||||
patient.NameFirst,
|
||||
patient.NameMiddle,
|
||||
patient.NameLast
|
||||
].filter(Boolean).join(' ') || '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sex display
|
||||
* @param {Object} patient - Patient object
|
||||
* @param {string} [patient.SexLabel] - Sex label
|
||||
* @param {string} [patient.Sex] - Sex code (1=Female, 2=Male)
|
||||
* @returns {string} Formatted sex
|
||||
*/
|
||||
export function formatSex(patient) {
|
||||
if (!patient) return '-';
|
||||
if (patient.SexLabel) return patient.SexLabel;
|
||||
if (patient.Sex === '1') return 'Female';
|
||||
if (patient.Sex === '2') return 'Male';
|
||||
return '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
* @param {string} dateStr - ISO date string
|
||||
* @returns {string} Formatted date
|
||||
*/
|
||||
export function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format datetime for display
|
||||
* @param {string} dateStr - ISO datetime string
|
||||
* @returns {string} Formatted datetime
|
||||
*/
|
||||
export function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format birthdate for patient list
|
||||
* @param {Object} patient - Patient object
|
||||
* @param {string} [patient.Birthdate] - Birthdate
|
||||
* @param {string} [patient.BirthdateFormatted] - Pre-formatted birthdate
|
||||
* @returns {string} Formatted birthdate
|
||||
*/
|
||||
export function formatBirthdate(patient) {
|
||||
if (!patient) return '-';
|
||||
if (patient.BirthdateFormatted) return patient.BirthdateFormatted;
|
||||
return formatDate(patient.Birthdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate age from birthdate
|
||||
* @param {string} birthdate - Birthdate string
|
||||
* @returns {string} Age in years
|
||||
*/
|
||||
export function calculateAge(birthdate) {
|
||||
if (!birthdate) return '-';
|
||||
try {
|
||||
const birth = new Date(birthdate);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return `${age}y`;
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order status badge class
|
||||
* @param {string} status - Order status
|
||||
* @returns {string} DaisyUI badge class
|
||||
*/
|
||||
export function getOrderStatusClass(status) {
|
||||
const statusMap = {
|
||||
'pending': 'badge-warning',
|
||||
'in_progress': 'badge-info',
|
||||
'completed': 'badge-success',
|
||||
'cancelled': 'badge-error',
|
||||
'collected': 'badge-primary',
|
||||
'verified': 'badge-accent',
|
||||
};
|
||||
return statusMap[status?.toLowerCase()] || 'badge-ghost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format order number
|
||||
* @param {Object} order - Order object
|
||||
* @param {string} [order.OrderNumber] - Order number
|
||||
* @param {string} [order.OrderID] - Order ID
|
||||
* @returns {string} Formatted order number
|
||||
*/
|
||||
export function formatOrderNumber(order) {
|
||||
if (!order) return '-';
|
||||
return order.OrderNumber || order.OrderID || '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function for search inputs
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Milliseconds to wait
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
export function debounce(func, wait = 300) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
@ -1,66 +1,78 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchPatients, fetchPatient, deletePatient } from '$lib/api/patients.js';
|
||||
import { fetchVisitsByPatient, deleteVisit, updateVisit, createADT } from '$lib/api/visits.js';
|
||||
import { fetchPatients, fetchPatient } from '$lib/api/patients.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import PatientSearchBar from './PatientSearchBar.svelte';
|
||||
import PatientList from './PatientList.svelte';
|
||||
import OrderList from './OrderList.svelte';
|
||||
import PatientFormModal from './PatientFormModal.svelte';
|
||||
import VisitFormModal from './VisitFormModal.svelte';
|
||||
import { Plus, Edit2, Trash2, Search, ChevronLeft, ChevronRight, Calendar, MapPin, Clock, FileText, History } from 'lucide-svelte';
|
||||
import VisitADTHistoryModal from './VisitADTHistoryModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-svelte';
|
||||
|
||||
// State
|
||||
// Search state
|
||||
let searchFilters = $state({
|
||||
patientId: '',
|
||||
patientName: '',
|
||||
visitNumber: '',
|
||||
orderNumber: ''
|
||||
});
|
||||
|
||||
// List state
|
||||
let loading = $state(false);
|
||||
let patients = $state([]);
|
||||
let searchQuery = $state('');
|
||||
let currentPage = $state(1);
|
||||
let perPage = $state(20);
|
||||
let totalItems = $state(0);
|
||||
let totalPages = $state(1);
|
||||
|
||||
// Selected patient and visits
|
||||
// Selected patient and orders
|
||||
let selectedPatient = $state(null);
|
||||
let visits = $state([]);
|
||||
let visitsLoading = $state(false);
|
||||
let orders = $state([]);
|
||||
let ordersLoading = $state(false);
|
||||
|
||||
// Modal states
|
||||
let patientFormOpen = $state(false);
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let patientToDelete = $state(null);
|
||||
let patientFormLoading = $state(false);
|
||||
let visitFormOpen = $state(false);
|
||||
let selectedVisit = $state(null);
|
||||
let adtHistoryOpen = $state(false);
|
||||
let adtHistoryVisit = $state(null);
|
||||
|
||||
// Visit delete state
|
||||
let visitDeleteConfirmOpen = $state(false);
|
||||
let visitToDelete = $state(null);
|
||||
|
||||
// Discharge state
|
||||
let dischargeModalOpen = $state(false);
|
||||
let visitToDischarge = $state(null);
|
||||
let dischargeDate = $state('');
|
||||
let discharging = $state(false);
|
||||
|
||||
const columns = [
|
||||
{ key: 'PatientID', label: 'Patient ID', class: 'font-medium' },
|
||||
{ key: 'FullName', label: 'Name' },
|
||||
{ key: 'SexLabel', label: 'Sex' },
|
||||
{ key: 'BirthdateFormatted', label: 'Birthdate' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-40 text-center' },
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
loadPatients();
|
||||
let patientForm = $state({
|
||||
open: false,
|
||||
patient: null,
|
||||
loading: false
|
||||
});
|
||||
|
||||
async function loadPatients() {
|
||||
let deleteModal = $state({
|
||||
open: false,
|
||||
patient: null
|
||||
});
|
||||
|
||||
// Load patients on mount (empty on init)
|
||||
onMount(() => {
|
||||
// Don't auto-load - wait for search
|
||||
patients = [];
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
loading = true;
|
||||
selectedPatient = null;
|
||||
orders = [];
|
||||
currentPage = 1;
|
||||
|
||||
try {
|
||||
const params = { page: currentPage, perPage };
|
||||
if (searchQuery.trim()) params.Name = searchQuery.trim();
|
||||
const params = {
|
||||
page: currentPage,
|
||||
perPage
|
||||
};
|
||||
|
||||
// Add filters
|
||||
if (searchFilters.patientId.trim()) {
|
||||
params.PatientID = searchFilters.patientId.trim();
|
||||
}
|
||||
if (searchFilters.patientName.trim()) {
|
||||
params.Name = searchFilters.patientName.trim();
|
||||
}
|
||||
if (searchFilters.visitNumber.trim()) {
|
||||
params.VisitNumber = searchFilters.visitNumber.trim();
|
||||
}
|
||||
if (searchFilters.orderNumber.trim()) {
|
||||
params.OrderNumber = searchFilters.orderNumber.trim();
|
||||
}
|
||||
|
||||
const response = await fetchPatients(params);
|
||||
patients = Array.isArray(response.data) ? response.data : [];
|
||||
@ -77,467 +89,258 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
function handleClear() {
|
||||
searchFilters = {
|
||||
patientId: '',
|
||||
patientName: '',
|
||||
visitNumber: '',
|
||||
orderNumber: ''
|
||||
};
|
||||
patients = [];
|
||||
selectedPatient = null;
|
||||
orders = [];
|
||||
currentPage = 1;
|
||||
loadPatients();
|
||||
totalPages = 1;
|
||||
totalItems = 0;
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
currentPage = newPage;
|
||||
loadPatients();
|
||||
handleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function selectPatient(patient) {
|
||||
// Load orders when patient is selected
|
||||
async function loadOrders(patient) {
|
||||
if (!patient?.InternalPID) {
|
||||
orders = [];
|
||||
return;
|
||||
}
|
||||
|
||||
ordersLoading = true;
|
||||
try {
|
||||
// TODO: Replace with actual orders API endpoint
|
||||
// const response = await fetchOrdersByPatient(patient.InternalPID);
|
||||
|
||||
// Mock orders for now - remove when API is ready
|
||||
orders = [
|
||||
{
|
||||
OrderID: 'ORD001',
|
||||
OrderNumber: 'O2024001',
|
||||
PatientID: patient.PatientID,
|
||||
TestCode: 'CBC',
|
||||
TestName: 'Complete Blood Count',
|
||||
Status: 'pending',
|
||||
OrderDate: new Date().toISOString(),
|
||||
Priority: 'normal',
|
||||
OrderedBy: 'Dr. Smith',
|
||||
ResultCount: 0,
|
||||
HasResults: false
|
||||
},
|
||||
{
|
||||
OrderID: 'ORD002',
|
||||
OrderNumber: 'O2024002',
|
||||
PatientID: patient.PatientID,
|
||||
TestCode: 'GLU',
|
||||
TestName: 'Glucose',
|
||||
Status: 'completed',
|
||||
OrderDate: new Date(Date.now() - 86400000).toISOString(),
|
||||
Priority: 'urgent',
|
||||
OrderedBy: 'Dr. Jones',
|
||||
CollectionDate: new Date(Date.now() - 80000000).toISOString(),
|
||||
VerifiedDate: new Date(Date.now() - 40000000).toISOString(),
|
||||
ResultCount: 3,
|
||||
HasResults: true
|
||||
}
|
||||
];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load orders');
|
||||
orders = [];
|
||||
} finally {
|
||||
ordersLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowOrders(patient) {
|
||||
selectedPatient = patient;
|
||||
loadVisits();
|
||||
loadOrders(patient);
|
||||
}
|
||||
|
||||
function handleSelectPatient(patient) {
|
||||
// Just select the patient, don't auto-load orders
|
||||
selectedPatient = patient;
|
||||
orders = [];
|
||||
}
|
||||
|
||||
// Patient CRUD
|
||||
function openCreateModal() {
|
||||
selectedPatient = null;
|
||||
patientFormOpen = true;
|
||||
patientForm = { open: true, patient: null, loading: false };
|
||||
}
|
||||
|
||||
async function openEditModal(patient, event) {
|
||||
event.stopPropagation();
|
||||
selectedPatient = null;
|
||||
patientFormLoading = true;
|
||||
patientFormOpen = true;
|
||||
async function openEditModal(patient) {
|
||||
patientForm = { open: true, patient: null, loading: true };
|
||||
try {
|
||||
const response = await fetchPatient(patient.InternalPID);
|
||||
selectedPatient = response.data || patient;
|
||||
patientForm = {
|
||||
...patientForm,
|
||||
patient: response.data || patient,
|
||||
loading: false
|
||||
};
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load patient details');
|
||||
selectedPatient = patient;
|
||||
} finally {
|
||||
patientFormLoading = false;
|
||||
patientForm = {
|
||||
...patientForm,
|
||||
patient: patient,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(patient, event) {
|
||||
event.stopPropagation();
|
||||
patientToDelete = patient;
|
||||
deleteConfirmOpen = true;
|
||||
function confirmDelete(patient) {
|
||||
deleteModal = { open: true, patient };
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteModal.patient?.InternalPID) return;
|
||||
|
||||
try {
|
||||
await deletePatient(patientToDelete.InternalPID);
|
||||
// TODO: Implement deletePatient API
|
||||
// await deletePatient(deleteModal.patient.InternalPID);
|
||||
toastSuccess('Patient deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
await loadPatients();
|
||||
deleteModal = { open: false, patient: null };
|
||||
await handleSearch();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete patient');
|
||||
}
|
||||
}
|
||||
|
||||
function handlePatientSaved() {
|
||||
loadPatients();
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
async function loadVisits() {
|
||||
if (!selectedPatient?.InternalPID) {
|
||||
visits = [];
|
||||
return;
|
||||
// Order actions
|
||||
function openCreateOrder() {
|
||||
// TODO: Implement order creation modal
|
||||
toastSuccess('Create order - implement modal');
|
||||
}
|
||||
|
||||
visitsLoading = true;
|
||||
try {
|
||||
const response = await fetchVisitsByPatient(selectedPatient.InternalPID);
|
||||
if (Array.isArray(response)) {
|
||||
visits = response;
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
visits = response.data;
|
||||
} else if (response.visits && Array.isArray(response.visits)) {
|
||||
visits = response.visits;
|
||||
} else {
|
||||
visits = [];
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load visits');
|
||||
visits = [];
|
||||
} finally {
|
||||
visitsLoading = false;
|
||||
}
|
||||
function handleViewOrder(order) {
|
||||
// TODO: Implement order detail view
|
||||
toastSuccess(`View order: ${order.OrderNumber}`);
|
||||
}
|
||||
|
||||
function openCreateVisit() {
|
||||
selectedVisit = null;
|
||||
visitFormOpen = true;
|
||||
function handlePrintBarcode(order) {
|
||||
// TODO: Implement barcode printing
|
||||
toastSuccess(`Print barcode for: ${order.OrderNumber}`);
|
||||
}
|
||||
|
||||
function openEditVisit(visit) {
|
||||
selectedVisit = visit;
|
||||
visitFormOpen = true;
|
||||
function handleRefreshOrders() {
|
||||
if (selectedPatient) {
|
||||
loadOrders(selectedPatient);
|
||||
}
|
||||
|
||||
function handleVisitSaved() {
|
||||
loadVisits();
|
||||
}
|
||||
|
||||
function confirmDeleteVisit(visit, event) {
|
||||
if (event) event.stopPropagation();
|
||||
visitToDelete = visit;
|
||||
visitDeleteConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function handleDeleteVisit() {
|
||||
if (!visitToDelete?.InternalPVID) return;
|
||||
|
||||
try {
|
||||
await deleteVisit(visitToDelete.InternalPVID);
|
||||
toastSuccess('Visit deleted successfully');
|
||||
visitDeleteConfirmOpen = false;
|
||||
visitToDelete = null;
|
||||
await loadVisits();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete visit');
|
||||
}
|
||||
}
|
||||
|
||||
function openDischargeModal(visit, event) {
|
||||
if (event) event.stopPropagation();
|
||||
visitToDischarge = visit;
|
||||
dischargeDate = new Date().toISOString().slice(0, 16);
|
||||
dischargeModalOpen = true;
|
||||
}
|
||||
|
||||
async function handleDischarge() {
|
||||
if (!visitToDischarge?.InternalPVID) return;
|
||||
|
||||
discharging = true;
|
||||
try {
|
||||
// Update visit with EndDate
|
||||
const updatePayload = {
|
||||
InternalPVID: visitToDischarge.InternalPVID,
|
||||
EndDate: dischargeDate,
|
||||
};
|
||||
await updateVisit(updatePayload);
|
||||
|
||||
// Create A03 ADT record
|
||||
try {
|
||||
const adtPayload = {
|
||||
InternalPVID: visitToDischarge.InternalPVID,
|
||||
ADTCode: 'A03',
|
||||
LocationID: visitToDischarge.LocationID,
|
||||
LocCode: visitToDischarge.LocCode,
|
||||
AttDoc: visitToDischarge.AttDoc,
|
||||
AdmDoc: visitToDischarge.AdmDoc,
|
||||
RefDoc: visitToDischarge.RefDoc,
|
||||
CnsDoc: visitToDischarge.CnsDoc,
|
||||
};
|
||||
await createADT(adtPayload);
|
||||
} catch (adtErr) {
|
||||
console.warn('Failed to create ADT record:', adtErr);
|
||||
}
|
||||
|
||||
toastSuccess('Patient discharged successfully');
|
||||
dischargeModalOpen = false;
|
||||
visitToDischarge = null;
|
||||
await loadVisits();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to discharge patient');
|
||||
} finally {
|
||||
discharging = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 h-[calc(100vh-4rem)] flex flex-col">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Patients</h1>
|
||||
<p class="text-gray-600">Manage patient records and visits</p>
|
||||
<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">Laboratory Orders</h1>
|
||||
<p class="text-sm text-gray-600">Search patients and manage lab orders</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Add Patient
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full pl-10"
|
||||
placeholder="Search by name..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
<button class="btn btn-outline" onclick={handleSearch}>Search</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-0">
|
||||
<!-- Left: Patient List -->
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col overflow-hidden">
|
||||
<div class="flex-1 overflow-auto">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={patients.map((p) => ({
|
||||
...p,
|
||||
FullName: p.FullName || [p.Prefix, p.NameFirst, p.NameMiddle, p.NameLast].filter(Boolean).join(' ') || '-',
|
||||
SexLabel: p.SexLabel || (p.Sex === '1' ? 'Female' : p.Sex === '2' ? 'Male' : '-'),
|
||||
BirthdateFormatted: p.Birthdate ? new Date(p.Birthdate).toLocaleDateString() : '-',
|
||||
}))}
|
||||
<PatientSearchBar
|
||||
bind:patientId={searchFilters.patientId}
|
||||
bind:patientName={searchFilters.patientName}
|
||||
bind:visitNumber={searchFilters.visitNumber}
|
||||
bind:orderNumber={searchFilters.orderNumber}
|
||||
{loading}
|
||||
emptyMessage="No patients found"
|
||||
hover={true}
|
||||
onRowClick={selectPatient}
|
||||
>
|
||||
{#snippet cell({ column, row })}
|
||||
{#if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-1">
|
||||
<button class="btn btn-sm btn-ghost" title="Edit" onclick={(e) => openEditModal(row, e)}>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost text-error" title="Delete" onclick={(e) => confirmDelete(row, e)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class={selectedPatient?.InternalPID === row.InternalPID ? 'font-semibold text-primary' : ''}>
|
||||
{row[column.key]}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-base-200 bg-base-100">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing {(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => handlePageChange(currentPage - 1)} disabled={currentPage === 1}>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<span class="btn btn-sm btn-ghost no-animation">Page {currentPage} of {totalPages}</span>
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => handlePageChange(currentPage + 1)} disabled={currentPage === totalPages}>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-3 min-h-0">
|
||||
<!-- Left: Patient List -->
|
||||
<div class="min-h-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-sm font-semibold text-gray-700">Patients</h2>
|
||||
{#if patients.length > 0}
|
||||
<span class="text-xs text-gray-500">{totalItems} found</span>
|
||||
{/if}
|
||||
</div>
|
||||
<PatientList
|
||||
{patients}
|
||||
{loading}
|
||||
{selectedPatient}
|
||||
{currentPage}
|
||||
{totalPages}
|
||||
{totalItems}
|
||||
{perPage}
|
||||
onPageChange={handlePageChange}
|
||||
onSelectPatient={handleSelectPatient}
|
||||
onShowOrders={handleShowOrders}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: Visit List -->
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col overflow-hidden">
|
||||
<!-- Right: Order List -->
|
||||
<div class="min-h-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-sm font-semibold text-gray-700">Lab Orders</h2>
|
||||
{#if selectedPatient}
|
||||
<div class="p-4 bg-base-200 border-b border-base-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg">
|
||||
{[selectedPatient.Prefix, selectedPatient.NameFirst, selectedPatient.NameMiddle, selectedPatient.NameLast].filter(Boolean).join(' ')}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Patient ID: {selectedPatient.PatientID}</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick={openCreateVisit}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
New Visit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
{#if visitsLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if visits.length === 0}
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<Calendar class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg">No visits found</p>
|
||||
<p class="text-sm">This patient has no visit records.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each visits as visit}
|
||||
<div class="card bg-base-100 shadow border border-base-200 hover:shadow-md transition-shadow">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Calendar class="w-4 h-4 text-primary" />
|
||||
<span class="font-semibold">{formatDate(visit.PVACreateDate || visit.PVCreateDate)}</span>
|
||||
{#if !visit.EndDate && !visit.ArchivedDate}
|
||||
<span class="badge badge-sm badge-success">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-sm badge-ghost">Closed</span>
|
||||
<span class="text-xs text-gray-500">{orders.length} orders</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
{#if visit.ADTCode}
|
||||
<div>
|
||||
<span class="text-gray-500">Type:</span>
|
||||
<span class="ml-1">{visit.ADTCode}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visit.LocCode || visit.LocationID}
|
||||
<div class="flex items-center gap-1">
|
||||
<MapPin class="w-3 h-3 text-gray-400" />
|
||||
<span class="text-gray-500">Location:</span>
|
||||
<span class="ml-1">{visit.LocCode || visit.LocationID || '-'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visit.AttDoc || visit.AdmDoc || visit.RefDoc || visit.CnsDoc}
|
||||
<div>
|
||||
<span class="text-gray-500">Doctor:</span>
|
||||
<span class="ml-1">{visit.AttDoc || visit.AdmDoc || visit.RefDoc || visit.CnsDoc || '-'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visit.PVACreateDate || visit.PVCreateDate}
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="w-3 h-3 text-gray-400" />
|
||||
<span class="text-gray-500">Time:</span>
|
||||
<span class="ml-1">{formatDateTime(visit.PVACreateDate || visit.PVCreateDate)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if visit.DiagCode || visit.Diagnosis}
|
||||
<div class="mt-3 pt-3 border-t border-base-200">
|
||||
<div class="flex items-start gap-2">
|
||||
<FileText class="w-4 h-4 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">Diagnosis:</span>
|
||||
<p class="text-sm mt-1">{visit.DiagCode || visit.Diagnosis || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 text-xs text-gray-400">
|
||||
Visit ID: {visit.PVID || visit.InternalPVID || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-4">
|
||||
{#if !visit.EndDate && !visit.ArchivedDate}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-warning"
|
||||
title="Discharge"
|
||||
onclick={(e) => openDischargeModal(visit, e)}
|
||||
>
|
||||
<span class="text-xs">DIS</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="View ADT History"
|
||||
onclick={() => { adtHistoryVisit = visit; adtHistoryOpen = true; }}
|
||||
>
|
||||
<History class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Edit"
|
||||
onclick={() => openEditVisit(visit)}
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
title="Delete"
|
||||
onclick={(e) => confirmDeleteVisit(visit, e)}
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div class="text-center">
|
||||
<Calendar class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg">Select a patient</p>
|
||||
<p class="text-sm">Click on a patient to view their visits</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<OrderList
|
||||
patient={selectedPatient}
|
||||
{orders}
|
||||
loading={ordersLoading}
|
||||
onCreateOrder={openCreateOrder}
|
||||
onViewOrder={handleViewOrder}
|
||||
onPrintBarcode={handlePrintBarcode}
|
||||
onRefresh={handleRefreshOrders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PatientFormModal bind:open={patientFormOpen} patient={selectedPatient} onSave={handlePatientSaved} loading={patientFormLoading} />
|
||||
<VisitFormModal bind:open={visitFormOpen} patient={selectedPatient} visit={selectedVisit} onSave={handleVisitSaved} />
|
||||
<VisitADTHistoryModal bind:open={adtHistoryOpen} visit={adtHistoryVisit} patientName={selectedPatient ? [selectedPatient.Prefix, selectedPatient.NameFirst, selectedPatient.NameMiddle, selectedPatient.NameLast].filter(Boolean).join(' ') : ''} />
|
||||
<!-- Patient Form Modal -->
|
||||
<PatientFormModal
|
||||
bind:open={patientForm.open}
|
||||
patient={patientForm.patient}
|
||||
onSave={handlePatientSaved}
|
||||
loading={patientForm.loading}
|
||||
/>
|
||||
|
||||
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
||||
<div class="py-2">
|
||||
<p>Are you sure you want to delete patient <strong>{patientToDelete?.PatientID}</strong>?</p>
|
||||
<p>
|
||||
Delete patient
|
||||
<strong>{deleteModal.patient?.PatientID}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
|
||||
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={visitDeleteConfirmOpen} title="Confirm Delete Visit" size="sm">
|
||||
<div class="py-2">
|
||||
<p>Are you sure you want to delete this visit?</p>
|
||||
<p class="text-sm text-gray-600 mt-2">Visit ID: <strong>{visitToDelete?.PVID || visitToDelete?.InternalPVID}</strong></p>
|
||||
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={() => (visitDeleteConfirmOpen = false)} type="button">Cancel</button>
|
||||
<button class="btn btn-error" onclick={handleDeleteVisit} type="button">Delete</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={dischargeModalOpen} title="Discharge Patient" size="sm">
|
||||
<div class="py-2 space-y-3">
|
||||
<p>Discharge patient <strong>{selectedPatient ? [selectedPatient.Prefix, selectedPatient.NameFirst, selectedPatient.NameMiddle, selectedPatient.NameLast].filter(Boolean).join(' ') : ''}</strong></p>
|
||||
<p class="text-sm text-gray-600">Visit ID: {visitToDischarge?.PVID || visitToDischarge?.InternalPVID}</p>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="dischargeDate">
|
||||
<span class="label-text font-medium">Discharge Date</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="dischargeDate"
|
||||
type="datetime-local"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={dischargeDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-warning">This will set the visit End Date and create an A03 (Discharge) ADT record.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={() => (dischargeModalOpen = false)} type="button">Cancel</button>
|
||||
<button class="btn btn-warning" onclick={handleDischarge} disabled={discharging} type="button">
|
||||
{#if discharging}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{discharging ? 'Discharging...' : 'Discharge'}
|
||||
<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>
|
||||
|
||||
106
src/routes/(app)/patients/OrderCard.svelte
Normal file
106
src/routes/(app)/patients/OrderCard.svelte
Normal file
@ -0,0 +1,106 @@
|
||||
<script>
|
||||
import { FileText, Printer, Eye, Clock, CheckCircle, AlertCircle } from 'lucide-svelte';
|
||||
import { formatDateTime, formatDate, getOrderStatusClass } from '$lib/utils/patients.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Order
|
||||
* @property {string} [OrderID]
|
||||
* @property {string} [OrderNumber]
|
||||
* @property {string} [PatientID]
|
||||
* @property {string} [VisitID]
|
||||
* @property {string} [OrderDate]
|
||||
* @property {string} [Status]
|
||||
* @property {string} [TestCode]
|
||||
* @property {string} [TestName]
|
||||
* @property {string} [Priority]
|
||||
* @property {string} [OrderedBy]
|
||||
* @property {string} [CollectionDate]
|
||||
* @property {string} [VerifiedDate]
|
||||
* @property {number} [ResultCount]
|
||||
* @property {boolean} [HasResults]
|
||||
*/
|
||||
|
||||
/** @type {{ order: Order, onView: () => void, onPrintBarcode: () => void }} */
|
||||
let { order, onView, onPrintBarcode } = $props();
|
||||
|
||||
let statusClass = $derived(getOrderStatusClass(order?.Status));
|
||||
let isUrgent = $derived(order?.Priority?.toLowerCase() === 'urgent' || order?.Priority?.toLowerCase() === 'stat');
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 hover:shadow-md transition-shadow"
|
||||
class:border-error={isUrgent}>
|
||||
<div class="card-body p-3 compact-y">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Order Header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
{#if isUrgent}
|
||||
<AlertCircle class="w-4 h-4 text-error" />
|
||||
{/if}
|
||||
<span class="font-semibold text-sm truncate">
|
||||
{order.TestName || order.TestCode || 'Unknown Test'}
|
||||
</span>
|
||||
<span class="badge badge-sm {statusClass}">
|
||||
{order.Status || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Order Details -->
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
<div class="flex items-center gap-1">
|
||||
<FileText class="w-3 h-3" />
|
||||
<span class="truncate">{order.OrderNumber || order.OrderID || '-'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="w-3 h-3" />
|
||||
<span>{formatDateTime(order.OrderDate)}</span>
|
||||
</div>
|
||||
{#if order.OrderedBy}
|
||||
<div class="col-span-2 truncate">
|
||||
<span class="text-gray-400">Ordered by:</span> {order.OrderedBy}
|
||||
</div>
|
||||
{/if}
|
||||
{#if order.CollectionDate}
|
||||
<div class="flex items-center gap-1">
|
||||
<CheckCircle class="w-3 h-3 text-success" />
|
||||
<span>Collected: {formatDate(order.CollectionDate)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results Indicator -->
|
||||
{#if order.HasResults || order.ResultCount > 0}
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="badge badge-sm badge-primary">
|
||||
{order.ResultCount || '?'} Results
|
||||
</span>
|
||||
{#if order.VerifiedDate}
|
||||
<span class="text-xs text-success flex items-center gap-1">
|
||||
<CheckCircle class="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="View Order"
|
||||
onclick={onView}
|
||||
>
|
||||
<Eye class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Print Barcode"
|
||||
onclick={onPrintBarcode}
|
||||
>
|
||||
<Printer class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
120
src/routes/(app)/patients/OrderList.svelte
Normal file
120
src/routes/(app)/patients/OrderList.svelte
Normal file
@ -0,0 +1,120 @@
|
||||
<script>
|
||||
import { Plus, FileText, RefreshCw } from 'lucide-svelte';
|
||||
import OrderCard from './OrderCard.svelte';
|
||||
import { formatPatientName } from '$lib/utils/patients.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Patient
|
||||
* @property {string} InternalPID
|
||||
* @property {string} PatientID
|
||||
* @property {string} [FullName]
|
||||
* @property {string} [Prefix]
|
||||
* @property {string} [NameFirst]
|
||||
* @property {string} [NameMiddle]
|
||||
* @property {string} [NameLast]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Order
|
||||
* @property {string} [OrderID]
|
||||
* @property {string} [OrderNumber]
|
||||
* @property {string} [Status]
|
||||
* @property {string} [TestName]
|
||||
*/
|
||||
|
||||
/** @type {{
|
||||
* patient: Patient | null,
|
||||
* orders: Order[],
|
||||
* loading: boolean,
|
||||
* onCreateOrder: () => void,
|
||||
* onViewOrder: (order: Order) => void,
|
||||
* onPrintBarcode: (order: Order) => void,
|
||||
* onRefresh: () => void
|
||||
* }} */
|
||||
let {
|
||||
patient = null,
|
||||
orders = [],
|
||||
loading = false,
|
||||
onCreateOrder,
|
||||
onViewOrder,
|
||||
onPrintBarcode,
|
||||
onRefresh
|
||||
} = $props();
|
||||
|
||||
let patientName = $derived(formatPatientName(patient));
|
||||
let hasOrders = $derived(orders.length > 0);
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col h-full overflow-hidden">
|
||||
{#if patient}
|
||||
<!-- Patient Header -->
|
||||
<div class="p-3 bg-base-200 border-b border-base-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="font-bold text-base truncate" title={patientName}>
|
||||
{patientName}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-600">ID: {patient.PatientID}</p>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-2">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Refresh"
|
||||
onclick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={onCreateOrder}
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
New Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if !hasOrders}
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<FileText class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-base">No orders found</p>
|
||||
<p class="text-sm mt-1">This patient has no lab orders.</p>
|
||||
<button
|
||||
class="btn btn-primary btn-sm mt-4"
|
||||
onclick={onCreateOrder}
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Create First Order
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each orders as order (order.OrderID || order.OrderNumber)}
|
||||
<OrderCard
|
||||
{order}
|
||||
onView={() => onViewOrder(order)}
|
||||
onPrintBarcode={() => onPrintBarcode(order)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty State -->
|
||||
<div class="flex-1 flex items-center justify-center text-gray-500 p-6">
|
||||
<div class="text-center">
|
||||
<FileText class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg">Select a patient</p>
|
||||
<p class="text-sm">Click "Show Orders" on a patient to view lab orders</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
142
src/routes/(app)/patients/PatientList.svelte
Normal file
142
src/routes/(app)/patients/PatientList.svelte
Normal file
@ -0,0 +1,142 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, Users } from 'lucide-svelte';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import { formatPatientName, formatSex, formatBirthdate } from '$lib/utils/patients.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Patient
|
||||
* @property {string} InternalPID
|
||||
* @property {string} PatientID
|
||||
* @property {string} [FullName]
|
||||
* @property {string} [Prefix]
|
||||
* @property {string} [NameFirst]
|
||||
* @property {string} [NameMiddle]
|
||||
* @property {string} [NameLast]
|
||||
* @property {string} [SexLabel]
|
||||
* @property {string} [Sex]
|
||||
* @property {string} [Birthdate]
|
||||
* @property {string} [BirthdateFormatted]
|
||||
*/
|
||||
|
||||
/** @type {{
|
||||
* patients: Patient[],
|
||||
* loading: boolean,
|
||||
* selectedPatient: Patient | null,
|
||||
* currentPage: number,
|
||||
* totalPages: number,
|
||||
* totalItems: number,
|
||||
* perPage: number,
|
||||
* onPageChange: (page: number) => void,
|
||||
* onSelectPatient: (patient: Patient) => void,
|
||||
* onShowOrders: (patient: Patient) => void
|
||||
* }} */
|
||||
let {
|
||||
patients = [],
|
||||
loading = false,
|
||||
selectedPatient = null,
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
totalItems = 0,
|
||||
perPage = 20,
|
||||
onPageChange,
|
||||
onSelectPatient,
|
||||
onShowOrders
|
||||
} = $props();
|
||||
|
||||
const columns = [
|
||||
{ key: 'PatientID', label: 'Patient ID', class: 'font-medium w-24' },
|
||||
{ key: 'FullName', label: 'Name', class: 'min-w-32' },
|
||||
{ key: 'SexLabel', label: 'Sex', class: 'w-16' },
|
||||
{ key: 'BirthdateFormatted', label: 'Birthdate', class: 'w-28' },
|
||||
{ key: 'actions', label: 'Orders', class: 'w-24 text-center' },
|
||||
];
|
||||
|
||||
let displayPatients = $derived(
|
||||
patients.map((p) => ({
|
||||
...p,
|
||||
FullName: formatPatientName(p),
|
||||
SexLabel: formatSex(p),
|
||||
BirthdateFormatted: formatBirthdate(p),
|
||||
}))
|
||||
);
|
||||
|
||||
function handleShowOrders(patient, event) {
|
||||
event.stopPropagation();
|
||||
onShowOrders(patient);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col h-full overflow-hidden">
|
||||
{#if !loading && patients.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="flex-1 flex items-center justify-center bg-base-100">
|
||||
<div class="text-center text-gray-500 p-6">
|
||||
<Users class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">Enter search criteria above</p>
|
||||
<p class="text-xs text-gray-400">to find patients</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Patient Table -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={displayPatients}
|
||||
{loading}
|
||||
emptyMessage="No patients found"
|
||||
hover={true}
|
||||
onRowClick={onSelectPatient}
|
||||
>
|
||||
{#snippet cell({ column, row })}
|
||||
{#if column.key === 'actions'}
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="btn btn-xs btn-primary"
|
||||
class:btn-outline={selectedPatient?.InternalPID !== row.InternalPID}
|
||||
onclick={(e) => handleShowOrders(row, e)}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="truncate block"
|
||||
class:font-semibold={selectedPatient?.InternalPID === row.InternalPID}
|
||||
class:text-primary={selectedPatient?.InternalPID === row.InternalPID}
|
||||
>
|
||||
{row[column.key]}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between px-3 py-2 border-t border-base-200 bg-base-100">
|
||||
<div class="text-xs text-gray-600">
|
||||
{(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft class="w-3 h-3" />
|
||||
</button>
|
||||
<span class="btn btn-xs btn-ghost no-animation text-xs">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
122
src/routes/(app)/patients/PatientSearchBar.svelte
Normal file
122
src/routes/(app)/patients/PatientSearchBar.svelte
Normal file
@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import { Search, X } from 'lucide-svelte';
|
||||
import { debounce } from '$lib/utils/patients.js';
|
||||
|
||||
/** @type {{
|
||||
* patientId: string,
|
||||
* patientName: string,
|
||||
* visitNumber: string,
|
||||
* orderNumber: string,
|
||||
* loading: boolean,
|
||||
* onSearch: () => void,
|
||||
* onClear: () => void
|
||||
* }} */
|
||||
let {
|
||||
patientId = '',
|
||||
patientName = '',
|
||||
visitNumber = '',
|
||||
orderNumber = '',
|
||||
loading = false,
|
||||
onSearch,
|
||||
onClear
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
onSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function hasFilters() {
|
||||
return patientId || patientName || visitNumber || orderNumber;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow-sm border border-base-200 p-3">
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<!-- Patient ID -->
|
||||
<div class="flex-1 min-w-[140px]">
|
||||
<label class="label py-0 mb-1" for="searchPatientId">
|
||||
<span class="label-text text-xs text-gray-500">Patient ID</span>
|
||||
</label>
|
||||
<input
|
||||
id="searchPatientId"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
placeholder="e.g. P001234"
|
||||
bind:value={patientId}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Patient Name -->
|
||||
<div class="flex-[2] min-w-[180px]">
|
||||
<label class="label py-0 mb-1" for="searchPatientName">
|
||||
<span class="label-text text-xs text-gray-500">Patient Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="searchPatientName"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
placeholder="Search by name..."
|
||||
bind:value={patientName}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Visit Number -->
|
||||
<div class="flex-1 min-w-[120px]">
|
||||
<label class="label py-0 mb-1" for="searchVisitNumber">
|
||||
<span class="label-text text-xs text-gray-500">Visit #</span>
|
||||
</label>
|
||||
<input
|
||||
id="searchVisitNumber"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
placeholder="e.g. V12345"
|
||||
bind:value={visitNumber}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Order Number -->
|
||||
<div class="flex-1 min-w-[120px]">
|
||||
<label class="label py-0 mb-1" for="searchOrderNumber">
|
||||
<span class="label-text text-xs text-gray-500">Order #</span>
|
||||
</label>
|
||||
<input
|
||||
id="searchOrderNumber"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
placeholder="e.g. O67890"
|
||||
bind:value={orderNumber}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
{#if hasFilters()}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Clear filters"
|
||||
onclick={onClear}
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={onSearch}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
364
src/routes/(app)/visits/+page.svelte
Normal file
364
src/routes/(app)/visits/+page.svelte
Normal file
@ -0,0 +1,364 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchVisits, deleteVisit, updateVisit, createADT } from '$lib/api/visits.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import VisitSearchBar from './VisitSearchBar.svelte';
|
||||
import VisitList from './VisitList.svelte';
|
||||
import VisitFormModal from './VisitFormModal.svelte';
|
||||
import VisitADTHistoryModal from './VisitADTHistoryModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Plus, LayoutGrid, List } from 'lucide-svelte';
|
||||
|
||||
// Search state
|
||||
let searchFilters = $state({
|
||||
patientId: '',
|
||||
patientName: '',
|
||||
visitDate: '',
|
||||
status: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
// List state
|
||||
let loading = $state(false);
|
||||
let visits = $state([]);
|
||||
let currentPage = $state(1);
|
||||
let perPage = $state(20);
|
||||
let totalItems = $state(0);
|
||||
let totalPages = $state(1);
|
||||
let viewMode = $state('table'); // 'table' | 'cards'
|
||||
|
||||
// Modal states
|
||||
let visitForm = $state({
|
||||
open: false,
|
||||
visit: null,
|
||||
patient: null,
|
||||
loading: false
|
||||
});
|
||||
|
||||
let adtHistory = $state({
|
||||
open: false,
|
||||
visit: null,
|
||||
patientName: ''
|
||||
});
|
||||
|
||||
let dischargeModal = $state({
|
||||
open: false,
|
||||
visit: null,
|
||||
dischargeDate: ''
|
||||
});
|
||||
|
||||
let deleteModal = $state({
|
||||
open: false,
|
||||
visit: null
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Don't auto-load - wait for search
|
||||
visits = [];
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
loading = true;
|
||||
currentPage = 1;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
perPage
|
||||
};
|
||||
|
||||
// Add filters
|
||||
if (searchFilters.patientId.trim()) {
|
||||
params.PatientID = searchFilters.patientId.trim();
|
||||
}
|
||||
if (searchFilters.patientName.trim()) {
|
||||
params.Name = searchFilters.patientName.trim();
|
||||
}
|
||||
if (searchFilters.visitDate) {
|
||||
params.VisitDate = searchFilters.visitDate;
|
||||
}
|
||||
if (searchFilters.status) {
|
||||
params.Status = searchFilters.status;
|
||||
}
|
||||
if (searchFilters.location) {
|
||||
params.Location = searchFilters.location;
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call
|
||||
// const response = await fetchVisits(params);
|
||||
// visits = Array.isArray(response.data) ? response.data : [];
|
||||
|
||||
// Mock data for now
|
||||
visits = [
|
||||
{
|
||||
InternalPVID: 'V001',
|
||||
PVID: 'V2024001',
|
||||
PatientID: 'P001',
|
||||
PatientName: 'John Doe',
|
||||
PVCreateDate: new Date().toISOString(),
|
||||
ADTCode: 'A01',
|
||||
LocCode: 'ICU-101',
|
||||
AttDoc: 'Dr. Smith'
|
||||
},
|
||||
{
|
||||
InternalPVID: 'V002',
|
||||
PVID: 'V2024002',
|
||||
PatientID: 'P002',
|
||||
PatientName: 'Jane Smith',
|
||||
PVCreateDate: new Date(Date.now() - 86400000).toISOString(),
|
||||
ADTCode: 'A04',
|
||||
LocCode: 'OPD-205',
|
||||
AttDoc: 'Dr. Johnson',
|
||||
EndDate: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
totalItems = visits.length;
|
||||
totalPages = Math.ceil(totalItems / perPage) || 1;
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load visits');
|
||||
visits = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
searchFilters = {
|
||||
patientId: '',
|
||||
patientName: '',
|
||||
visitDate: '',
|
||||
status: '',
|
||||
location: ''
|
||||
};
|
||||
visits = [];
|
||||
currentPage = 1;
|
||||
totalPages = 1;
|
||||
totalItems = 0;
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
currentPage = newPage;
|
||||
handleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
// Visit actions
|
||||
function openCreateModal() {
|
||||
visitForm = { open: true, visit: null, patient: null, loading: false };
|
||||
}
|
||||
|
||||
function openEditModal(visit) {
|
||||
visitForm = { open: true, visit: visit, patient: null, loading: false };
|
||||
}
|
||||
|
||||
function handleVisitSaved() {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
// ADT History
|
||||
function openADTHistory(visit) {
|
||||
adtHistory = {
|
||||
open: true,
|
||||
visit: visit,
|
||||
patientName: visit.PatientName || visit.PatientID
|
||||
};
|
||||
}
|
||||
|
||||
// Discharge
|
||||
function openDischargeModal(visit) {
|
||||
dischargeModal = {
|
||||
open: true,
|
||||
visit: visit,
|
||||
dischargeDate: new Date().toISOString().slice(0, 16)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDischarge() {
|
||||
if (!dischargeModal.visit?.InternalPVID) return;
|
||||
|
||||
try {
|
||||
// Update visit with EndDate
|
||||
const updatePayload = {
|
||||
InternalPVID: dischargeModal.visit.InternalPVID,
|
||||
EndDate: dischargeModal.dischargeDate,
|
||||
};
|
||||
await updateVisit(updatePayload);
|
||||
|
||||
// Create A03 ADT record
|
||||
try {
|
||||
const adtPayload = {
|
||||
InternalPVID: dischargeModal.visit.InternalPVID,
|
||||
ADTCode: 'A03',
|
||||
LocationID: dischargeModal.visit.LocationID,
|
||||
LocCode: dischargeModal.visit.LocCode,
|
||||
AttDoc: dischargeModal.visit.AttDoc,
|
||||
AdmDoc: dischargeModal.visit.AdmDoc,
|
||||
RefDoc: dischargeModal.visit.RefDoc,
|
||||
CnsDoc: dischargeModal.visit.CnsDoc,
|
||||
};
|
||||
await createADT(adtPayload);
|
||||
} catch (adtErr) {
|
||||
console.warn('Failed to create ADT record:', adtErr);
|
||||
}
|
||||
|
||||
toastSuccess('Patient discharged successfully');
|
||||
dischargeModal = { open: false, visit: null, dischargeDate: '' };
|
||||
await handleSearch();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to discharge patient');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
function confirmDelete(visit) {
|
||||
deleteModal = { open: true, visit };
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteModal.visit?.InternalPVID) return;
|
||||
|
||||
try {
|
||||
await deleteVisit(deleteModal.visit.InternalPVID);
|
||||
toastSuccess('Visit deleted successfully');
|
||||
deleteModal = { open: false, visit: null };
|
||||
await handleSearch();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete visit');
|
||||
}
|
||||
}
|
||||
</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">Visit Management</h1>
|
||||
<p class="text-sm text-gray-600">Manage patient visits, admissions, and transfers</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn btn-sm join-item {viewMode === 'table' ? 'btn-active' : ''}"
|
||||
onclick={() => viewMode = 'table'}
|
||||
>
|
||||
<List class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm join-item {viewMode === 'cards' ? 'btn-active' : ''}"
|
||||
onclick={() => viewMode = 'cards'}
|
||||
>
|
||||
<LayoutGrid class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
New Visit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<VisitSearchBar
|
||||
bind:patientId={searchFilters.patientId}
|
||||
bind:patientName={searchFilters.patientName}
|
||||
bind:visitDate={searchFilters.visitDate}
|
||||
bind:status={searchFilters.status}
|
||||
bind:location={searchFilters.location}
|
||||
{loading}
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
<!-- Visit List -->
|
||||
<div class="flex-1 min-h-0">
|
||||
<VisitList
|
||||
{visits}
|
||||
{loading}
|
||||
{viewMode}
|
||||
{currentPage}
|
||||
{totalPages}
|
||||
{totalItems}
|
||||
{perPage}
|
||||
onPageChange={handlePageChange}
|
||||
onEditVisit={openEditModal}
|
||||
onDeleteVisit={confirmDelete}
|
||||
onViewHistory={openADTHistory}
|
||||
onDischarge={openDischargeModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visit Form Modal -->
|
||||
<VisitFormModal
|
||||
bind:open={visitForm.open}
|
||||
visit={visitForm.visit}
|
||||
patient={visitForm.patient}
|
||||
onSave={handleVisitSaved}
|
||||
loading={visitForm.loading}
|
||||
/>
|
||||
|
||||
<!-- ADT History Modal -->
|
||||
<VisitADTHistoryModal
|
||||
bind:open={adtHistory.open}
|
||||
visit={adtHistory.visit}
|
||||
patientName={adtHistory.patientName}
|
||||
/>
|
||||
|
||||
<!-- Discharge Modal -->
|
||||
<Modal bind:open={dischargeModal.open} title="Discharge Patient" size="sm">
|
||||
<div class="py-2 space-y-3">
|
||||
<p>Discharge visit <strong>{dischargeModal.visit?.PVID}</strong>?</p>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="dischargeDate">
|
||||
<span class="label-text font-medium">Discharge Date</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="dischargeDate"
|
||||
type="datetime-local"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={dischargeModal.dischargeDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-warning">This will set the visit End Date and create an A03 ADT record.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => dischargeModal.open = false}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
onclick={handleDischarge}
|
||||
>
|
||||
Discharge
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
||||
<div class="py-2">
|
||||
<p>Delete visit <strong>{deleteModal.visit?.PVID}</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}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { fetchVisitADTHistory } from '$lib/api/visits.js';
|
||||
import { Calendar, MapPin, User, ArrowRight, Activity, ClipboardList } from 'lucide-svelte';
|
||||
import { Calendar, MapPin, User, Activity, ClipboardList } from 'lucide-svelte';
|
||||
|
||||
let { open = $bindable(false), visit = null, patientName = '' } = $props();
|
||||
|
||||
@ -126,24 +126,20 @@
|
||||
<div class="space-y-0">
|
||||
{#each adtHistory as adt, index}
|
||||
<div class="relative flex gap-4">
|
||||
<!-- Timeline line -->
|
||||
{#if index < adtHistory.length - 1}
|
||||
<div class="absolute left-6 top-12 w-0.5 h-full bg-base-300 -translate-x-1/2"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Icon/Badge -->
|
||||
<div class="relative z-10 flex-shrink-0">
|
||||
<div class="w-12 h-12 rounded-full border-2 flex items-center justify-center {getADTColor(adt.ADTCode)}">
|
||||
<Activity class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Card -->
|
||||
<div class="flex-1 pb-6">
|
||||
<div class="bg-base-100 border border-base-200 rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="badge {getADTBadge(adt.ADTCode)} badge-lg">
|
||||
{getADTLabel(adt.ADTCode)}
|
||||
@ -151,7 +147,6 @@
|
||||
<span class="text-xs text-gray-400">#{adt.PVADTID}</span>
|
||||
</div>
|
||||
|
||||
<!-- Date and Time -->
|
||||
<div class="flex items-center gap-4 text-sm mb-3">
|
||||
<div class="flex items-center gap-1.5 text-gray-600">
|
||||
<Calendar class="w-4 h-4" />
|
||||
@ -163,7 +158,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
{#if adt.LocCode || adt.LocationID}
|
||||
<div class="flex items-center gap-2">
|
||||
@ -183,7 +177,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow indicator -->
|
||||
{#if index === 0}
|
||||
<div class="flex-shrink-0">
|
||||
<span class="badge badge-sm badge-primary">Latest</span>
|
||||
149
src/routes/(app)/visits/VisitCard.svelte
Normal file
149
src/routes/(app)/visits/VisitCard.svelte
Normal file
@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import { Calendar, MapPin, User, Clock, FileText, Activity, History, Edit2, Trash2 } from 'lucide-svelte';
|
||||
import { formatDate, formatDateTime } from '$lib/utils/patients.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Visit
|
||||
* @property {string} InternalPVID
|
||||
* @property {string} [PVID]
|
||||
* @property {string} [PatientID]
|
||||
* @property {string} [PVCreateDate]
|
||||
* @property {string} [PVACreateDate]
|
||||
* @property {string} [ADTCode]
|
||||
* @property {string} [LocCode]
|
||||
* @property {string} [LocationID]
|
||||
* @property {string} [AttDoc]
|
||||
* @property {string} [AdmDoc]
|
||||
* @property {string} [RefDoc]
|
||||
* @property {string} [CnsDoc]
|
||||
* @property {string} [DiagCode]
|
||||
* @property {string} [Diagnosis]
|
||||
* @property {string} [EndDate]
|
||||
* @property {string} [ArchivedDate]
|
||||
*/
|
||||
|
||||
/** @type {{
|
||||
* visit: Visit,
|
||||
* onEdit: () => void,
|
||||
* onDelete: () => void,
|
||||
* onViewHistory: () => void,
|
||||
* onDischarge: () => void
|
||||
* }} */
|
||||
let {
|
||||
visit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewHistory,
|
||||
onDischarge
|
||||
} = $props();
|
||||
|
||||
let isActive = $derived(!visit.EndDate && !visit.ArchivedDate);
|
||||
let visitDate = $derived(visit.PVACreateDate || visit.PVCreateDate);
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 hover:shadow-md transition-shadow">
|
||||
<div class="card-body p-3 compact-y">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Calendar class="w-4 h-4 text-primary" />
|
||||
<span class="font-semibold text-sm">{formatDate(visitDate)}</span>
|
||||
{#if isActive}
|
||||
<span class="badge badge-sm badge-success">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-sm badge-ghost">Closed</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Visit Details -->
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
{#if visit.ADTCode}
|
||||
<div>
|
||||
<span class="text-gray-400">Type:</span>
|
||||
<span class="ml-1 font-medium">{visit.ADTCode}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visit.LocCode || visit.LocationID}
|
||||
<div class="flex items-center gap-1">
|
||||
<MapPin class="w-3 h-3 text-gray-400" />
|
||||
<span class="text-gray-400">Loc:</span>
|
||||
<span class="ml-1">{visit.LocCode || visit.LocationID || '-'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visit.AttDoc || visit.AdmDoc || visit.RefDoc || visit.CnsDoc}
|
||||
<div class="flex items-center gap-1">
|
||||
<User class="w-3 h-3 text-gray-400" />
|
||||
<span class="text-gray-400">Dr:</span>
|
||||
<span class="ml-1 truncate">{visit.AttDoc || visit.AdmDoc || visit.RefDoc || visit.CnsDoc || '-'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visitDate}
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="w-3 h-3 text-gray-400" />
|
||||
<span class="text-gray-400">Time:</span>
|
||||
<span class="ml-1">{formatDateTime(visitDate)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Diagnosis -->
|
||||
{#if visit.DiagCode || visit.Diagnosis}
|
||||
<div class="mt-2 pt-2 border-t border-base-200">
|
||||
<div class="flex items-start gap-2">
|
||||
<FileText class="w-3 h-3 text-gray-400 mt-0.5" />
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-400">Dx:</span>
|
||||
<span class="ml-1">{visit.DiagCode || ''}</span>
|
||||
{#if visit.Diagnosis}
|
||||
<p class="mt-0.5 text-gray-500 truncate">{visit.Diagnosis}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
ID: {visit.PVID || visit.InternalPVID || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if isActive}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost text-warning"
|
||||
title="Discharge"
|
||||
onclick={onDischarge}
|
||||
>
|
||||
<Activity class="w-3 h-3" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="View ADT History"
|
||||
onclick={onViewHistory}
|
||||
>
|
||||
<History class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Edit"
|
||||
onclick={onEdit}
|
||||
>
|
||||
<Edit2 class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost text-error"
|
||||
title="Delete"
|
||||
onclick={onDelete}
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -167,7 +167,7 @@
|
||||
try {
|
||||
const adtPayload = {
|
||||
InternalPVID: savedVisit?.InternalPVID || payload.InternalPVID,
|
||||
ADTCode: payload.ADTCode || 'A08', // Default to update if no code
|
||||
ADTCode: payload.ADTCode || 'A08',
|
||||
LocationID: payload.LocationID,
|
||||
LocCode: payload.LocCode,
|
||||
AttDoc: payload.AttDoc,
|
||||
@ -178,7 +178,6 @@
|
||||
await createADT(adtPayload);
|
||||
} catch (adtErr) {
|
||||
console.warn('Failed to create ADT record:', adtErr);
|
||||
// Don't fail the whole operation if ADT creation fails
|
||||
}
|
||||
|
||||
open = false;
|
||||
165
src/routes/(app)/visits/VisitList.svelte
Normal file
165
src/routes/(app)/visits/VisitList.svelte
Normal file
@ -0,0 +1,165 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-svelte';
|
||||
import VisitCard from './VisitCard.svelte';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import { formatPatientName } from '$lib/utils/patients.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Visit
|
||||
* @property {string} InternalPVID
|
||||
* @property {string} [PVID]
|
||||
* @property {string} [PatientID]
|
||||
* @property {string} [PatientName]
|
||||
* @property {string} [PVCreateDate]
|
||||
* @property {string} [ADTCode]
|
||||
* @property {string} [LocCode]
|
||||
* @property {string} [EndDate]
|
||||
*/
|
||||
|
||||
/** @type {{
|
||||
* visits: Visit[],
|
||||
* loading: boolean,
|
||||
* viewMode: 'table' | 'cards',
|
||||
* currentPage: number,
|
||||
* totalPages: number,
|
||||
* totalItems: number,
|
||||
* perPage: number,
|
||||
* onPageChange: (page: number) => void,
|
||||
* onEditVisit: (visit: Visit) => void,
|
||||
* onDeleteVisit: (visit: Visit) => void,
|
||||
* onViewHistory: (visit: Visit) => void,
|
||||
* onDischarge: (visit: Visit) => void
|
||||
* }} */
|
||||
let {
|
||||
visits = [],
|
||||
loading = false,
|
||||
viewMode = 'table',
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
totalItems = 0,
|
||||
perPage = 20,
|
||||
onPageChange,
|
||||
onEditVisit,
|
||||
onDeleteVisit,
|
||||
onViewHistory,
|
||||
onDischarge
|
||||
} = $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: 'LocCode', label: 'Location', class: 'w-24' },
|
||||
{ key: 'Status', label: 'Status', class: 'w-20 text-center' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-28 text-center' },
|
||||
];
|
||||
|
||||
function getStatusBadge(visit) {
|
||||
if (!visit.EndDate && !visit.ArchivedDate) {
|
||||
return '<span class="badge badge-sm badge-success">Active</span>';
|
||||
}
|
||||
return '<span class="badge badge-sm badge-ghost">Closed</span>';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col h-full overflow-hidden">
|
||||
{#if !loading && visits.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="flex-1 flex items-center justify-center bg-base-100">
|
||||
<div class="text-center text-gray-500 p-6">
|
||||
<Calendar class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">No visits found</p>
|
||||
<p class="text-xs text-gray-400">Use search criteria to find visits</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if viewMode === 'table'}
|
||||
<!-- Table View -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={visits}
|
||||
{loading}
|
||||
emptyMessage="No visits found"
|
||||
hover={true}
|
||||
>
|
||||
{#snippet cell({ column, row })}
|
||||
{#if column.key === 'Status'}
|
||||
{@html getStatusBadge(row)}
|
||||
{:else if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-1">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Edit"
|
||||
onclick={() => onEditVisit(row)}
|
||||
>
|
||||
<span class="text-xs">Edit</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="History"
|
||||
onclick={() => onViewHistory(row)}
|
||||
>
|
||||
<span class="text-xs">Hist</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="truncate block">{row[column.key] || '-'}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Card View -->
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each visits as visit (visit.InternalPVID)}
|
||||
<VisitCard
|
||||
{visit}
|
||||
onEdit={() => onEditVisit(visit)}
|
||||
onDelete={() => onDeleteVisit(visit)}
|
||||
onViewHistory={() => onViewHistory(visit)}
|
||||
onDischarge={() => onDischarge(visit)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between px-3 py-2 border-t border-base-200 bg-base-100">
|
||||
<div class="text-xs text-gray-600">
|
||||
{(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft class="w-3 h-3" />
|
||||
</button>
|
||||
<span class="btn btn-xs btn-ghost no-animation text-xs">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
130
src/routes/(app)/visits/VisitSearchBar.svelte
Normal file
130
src/routes/(app)/visits/VisitSearchBar.svelte
Normal file
@ -0,0 +1,130 @@
|
||||
<script>
|
||||
import { Search, X, Calendar, User } from 'lucide-svelte';
|
||||
|
||||
/** @type {{
|
||||
* patientId: string,
|
||||
* patientName: string,
|
||||
* visitDate: string,
|
||||
* status: string,
|
||||
* location: string,
|
||||
* loading: boolean,
|
||||
* onSearch: () => void,
|
||||
* onClear: () => void
|
||||
* }} */
|
||||
let {
|
||||
patientId = '',
|
||||
patientName = '',
|
||||
visitDate = '',
|
||||
status = '',
|
||||
location = '',
|
||||
loading = false,
|
||||
onSearch,
|
||||
onClear
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
onSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function hasFilters() {
|
||||
return patientId || patientName || visitDate || status || location;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'discharged', label: 'Discharged' },
|
||||
{ value: 'transferred', label: 'Transferred' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow-sm border border-base-200 p-3">
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<!-- Patient ID -->
|
||||
<div class="flex-1 min-w-[120px]">
|
||||
<label class="label py-0 mb-1" for="searchPatientId">
|
||||
<span class="label-text text-xs text-gray-500">Patient ID</span>
|
||||
</label>
|
||||
<input
|
||||
id="searchPatientId"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
placeholder="e.g. P001234"
|
||||
bind:value={patientId}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Patient Name -->
|
||||
<div class="flex-[2] min-w-[160px]">
|
||||
<label class="label py-0 mb-1" for="searchPatientName">
|
||||
<span class="label-text text-xs text-gray-500">Patient Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="searchPatientName"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
placeholder="Search by name..."
|
||||
bind:value={patientName}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Visit Date -->
|
||||
<div class="flex-1 min-w-[140px]">
|
||||
<label class="label py-0 mb-1" for="searchVisitDate">
|
||||
<span class="label-text text-xs text-gray-500">Visit Date</span>
|
||||
</label>
|
||||
<input
|
||||
id="searchVisitDate"
|
||||
type="date"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={visitDate}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex-1 min-w-[120px]">
|
||||
<label class="label py-0 mb-1" for="searchStatus">
|
||||
<span class="label-text text-xs text-gray-500">Status</span>
|
||||
</label>
|
||||
<select
|
||||
id="searchStatus"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={status}
|
||||
>
|
||||
{#each statusOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
{#if hasFilters()}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Clear filters"
|
||||
onclick={onClear}
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={onSearch}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
x
Reference in New Issue
Block a user