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:
mahdahar 2026-02-25 07:12:24 +07:00
parent ae806911be
commit ad1618efec
13 changed files with 1699 additions and 456 deletions

View File

@ -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
View 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);
};
}

View File

@ -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>
<!-- Right: Visit List -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col overflow-hidden">
{#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>
{/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}
</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(' ') : ''} />
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p>Are you sure you want to delete patient <strong>{patientToDelete?.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}
<PatientList
{patients}
{loading}
{selectedPatient}
{currentPage}
{totalPages}
{totalItems}
{perPage}
onPageChange={handlePageChange}
onSelectPatient={handleSelectPatient}
onShowOrders={handleShowOrders}
/>
</div>
<p class="text-sm text-warning">This will set the visit End Date and create an A03 (Discharge) ADT record.</p>
<!-- 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}
<span class="text-xs text-gray-500">{orders.length} orders</span>
{/if}
</div>
<OrderList
patient={selectedPatient}
{orders}
loading={ordersLoading}
onCreateOrder={openCreateOrder}
onViewOrder={handleViewOrder}
onPrintBarcode={handlePrintBarcode}
onRefresh={handleRefreshOrders}
/>
</div>
</div>
</div>
<!-- Patient Form Modal -->
<PatientFormModal
bind:open={patientForm.open}
patient={patientForm.patient}
onSave={handlePatientSaved}
loading={patientForm.loading}
/>
<!-- Delete Confirmation Modal -->
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
<div class="py-2">
<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={() => (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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View File

@ -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;

View 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>

View 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>