feat: Integrate visits management into patients page with full CRUD operations
- Add visit creation, editing, and deletion directly from patient details - Add patient class selection and ADT status display - Remove standalone visits page and sidebar navigation - Update visit form with Episode ID, location, doctors, and dates
This commit is contained in:
parent
1e032e5278
commit
382b05d98e
@ -17,7 +17,6 @@
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
Hash,
|
Hash,
|
||||||
Globe,
|
Globe,
|
||||||
Calendar,
|
|
||||||
ChevronDown
|
ChevronDown
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
@ -252,15 +251,6 @@
|
|||||||
<span>Patients</span>
|
<span>Patients</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/visits"
|
|
||||||
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
|
|
||||||
>
|
|
||||||
<Calendar class="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span>Visits</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/orders"
|
href="/orders"
|
||||||
|
|||||||
@ -8,12 +8,18 @@
|
|||||||
deletePatient,
|
deletePatient,
|
||||||
} from '$lib/api/patients.js';
|
} from '$lib/api/patients.js';
|
||||||
import { fetchProvinces, fetchCities } from '$lib/api/geography.js';
|
import { fetchProvinces, fetchCities } from '$lib/api/geography.js';
|
||||||
import { fetchVisitsByPatient } from '$lib/api/visits.js';
|
import {
|
||||||
|
fetchVisitsByPatient,
|
||||||
|
fetchVisit,
|
||||||
|
createVisit,
|
||||||
|
updateVisit,
|
||||||
|
deleteVisit,
|
||||||
|
} from '$lib/api/visits.js';
|
||||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||||
import DataTable from '$lib/components/DataTable.svelte';
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
||||||
import { Plus, Edit2, Trash2, Search, ChevronLeft, ChevronRight, User, Calendar } from 'lucide-svelte';
|
import { Plus, Edit2, Trash2, Search, ChevronLeft, ChevronRight, User, Calendar, Stethoscope, MapPin, Clock } from 'lucide-svelte';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@ -28,6 +34,13 @@
|
|||||||
let deleteItem = $state(null);
|
let deleteItem = $state(null);
|
||||||
let visitsModalOpen = $state(false);
|
let visitsModalOpen = $state(false);
|
||||||
let patientVisits = $state([]);
|
let patientVisits = $state([]);
|
||||||
|
let currentPatientForVisits = $state(null);
|
||||||
|
let visitModalOpen = $state(false);
|
||||||
|
let visitModalMode = $state('create');
|
||||||
|
let visitFormLoading = $state(false);
|
||||||
|
let visitFormErrors = $state({});
|
||||||
|
let visitDeleteConfirmOpen = $state(false);
|
||||||
|
let visitToDelete = $state(null);
|
||||||
let currentStep = $state(1);
|
let currentStep = $state(1);
|
||||||
let formErrors = $state({});
|
let formErrors = $state({});
|
||||||
let previousProvince = $state('');
|
let previousProvince = $state('');
|
||||||
@ -74,7 +87,22 @@
|
|||||||
PatCom: '',
|
PatCom: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Visit form data
|
||||||
|
let visitFormData = $state({
|
||||||
|
InternalPVID: null,
|
||||||
|
PatientID: '',
|
||||||
|
EpisodeID: '',
|
||||||
|
InternalPID: null,
|
||||||
|
PV1: {
|
||||||
|
PatientClass: '',
|
||||||
|
AssignedPatientLocation: '',
|
||||||
|
AttendingDoctor: '',
|
||||||
|
ReferringDoctor: '',
|
||||||
|
AdmittingDoctor: '',
|
||||||
|
AdmitDateTime: '',
|
||||||
|
DischargeDateTime: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Dropdown options (prefix - valueset not available yet)
|
// Dropdown options (prefix - valueset not available yet)
|
||||||
const prefixOptions = [
|
const prefixOptions = [
|
||||||
@ -85,6 +113,37 @@
|
|||||||
{ value: 'Prof', label: 'Prof' },
|
{ value: 'Prof', label: 'Prof' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Patient class options
|
||||||
|
const patientClassOptions = [
|
||||||
|
{ value: 'E', label: 'Emergency' },
|
||||||
|
{ value: 'I', label: 'Inpatient' },
|
||||||
|
{ value: 'O', label: 'Outpatient' },
|
||||||
|
{ value: 'P', label: 'Preadmit' },
|
||||||
|
{ value: 'R', label: 'Recurring Patient' },
|
||||||
|
{ value: 'B', label: 'Obstetrics' },
|
||||||
|
{ value: 'C', label: 'Commercial Account' },
|
||||||
|
{ value: 'N', label: 'Not Applicable' },
|
||||||
|
{ value: 'U', label: 'Unknown' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ADT Status options with colors
|
||||||
|
const adtStatusOptions = [
|
||||||
|
{ value: 'A01', label: 'Admitted', color: 'badge-error', icon: '🏥' },
|
||||||
|
{ value: 'A02', label: 'Transferred', color: 'badge-warning', icon: '🔄' },
|
||||||
|
{ value: 'A03', label: 'Discharged', color: 'badge-success', icon: '✅' },
|
||||||
|
{ value: 'A04', label: 'Registered', color: 'badge-info', icon: '📝' },
|
||||||
|
{ value: 'A08', label: 'Updated', color: 'badge-ghost', icon: '✏️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getADTStatus(code) {
|
||||||
|
return adtStatusOptions.find((s) => s.value === code) || { label: 'Unknown', color: 'badge-ghost', icon: '❓' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPatientClassLabel(code) {
|
||||||
|
const option = patientClassOptions.find((o) => o.value === code);
|
||||||
|
return option ? option.label : code || '-';
|
||||||
|
}
|
||||||
|
|
||||||
// Derived values
|
// Derived values
|
||||||
const provinceOptions = $derived(
|
const provinceOptions = $derived(
|
||||||
provinces.map((p) => ({ value: p.value, label: p.label }))
|
provinces.map((p) => ({ value: p.value, label: p.label }))
|
||||||
@ -348,12 +407,149 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openVisitsModal(patient) {
|
async function openVisitsModal(patient) {
|
||||||
|
currentPatientForVisits = patient;
|
||||||
visitsModalOpen = true;
|
visitsModalOpen = true;
|
||||||
|
await loadVisitsForPatient(patient.InternalPID);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVisitsForPatient(patientId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetchVisitsByPatient(patient.InternalPID);
|
const response = await fetchVisitsByPatient(patientId);
|
||||||
patientVisits = Array.isArray(response.data) ? response.data : [];
|
patientVisits = Array.isArray(response.data) ? response.data : [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err.message || 'Failed to load patient visits');
|
toastError(err.message || 'Failed to load patient visits');
|
||||||
|
patientVisits = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateVisitModal() {
|
||||||
|
visitModalMode = 'create';
|
||||||
|
visitFormErrors = {};
|
||||||
|
visitFormData = {
|
||||||
|
InternalPVID: null,
|
||||||
|
PatientID: currentPatientForVisits?.PatientID || '',
|
||||||
|
EpisodeID: '',
|
||||||
|
InternalPID: currentPatientForVisits?.InternalPID || null,
|
||||||
|
PV1: {
|
||||||
|
PatientClass: '',
|
||||||
|
AssignedPatientLocation: '',
|
||||||
|
AttendingDoctor: '',
|
||||||
|
ReferringDoctor: '',
|
||||||
|
AdmittingDoctor: '',
|
||||||
|
AdmitDateTime: '',
|
||||||
|
DischargeDateTime: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
visitModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditVisitModal(visit) {
|
||||||
|
visitModalMode = 'edit';
|
||||||
|
visitFormErrors = {};
|
||||||
|
visitFormLoading = true;
|
||||||
|
visitModalOpen = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchVisit(visit.PVID);
|
||||||
|
const visitData = response.data || response;
|
||||||
|
|
||||||
|
visitFormData = {
|
||||||
|
InternalPVID: visitData.InternalPVID,
|
||||||
|
PatientID: visitData.PatientID || currentPatientForVisits?.PatientID || '',
|
||||||
|
EpisodeID: visitData.EpisodeID || '',
|
||||||
|
InternalPID: visitData.InternalPID || currentPatientForVisits?.InternalPID || null,
|
||||||
|
PV1: {
|
||||||
|
PatientClass: visitData.PV1?.PatientClass || '',
|
||||||
|
AssignedPatientLocation: visitData.PV1?.AssignedPatientLocation || '',
|
||||||
|
AttendingDoctor: visitData.PV1?.AttendingDoctor || '',
|
||||||
|
ReferringDoctor: visitData.PV1?.ReferringDoctor || '',
|
||||||
|
AdmittingDoctor: visitData.PV1?.AdmittingDoctor || '',
|
||||||
|
AdmitDateTime: visitData.PV1?.AdmitDateTime
|
||||||
|
? new Date(visitData.PV1.AdmitDateTime).toISOString().slice(0, 16)
|
||||||
|
: '',
|
||||||
|
DischargeDateTime: visitData.PV1?.DischargeDateTime
|
||||||
|
? new Date(visitData.PV1.DischargeDateTime).toISOString().slice(0, 16)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err.message || 'Failed to load visit details');
|
||||||
|
visitModalOpen = false;
|
||||||
|
} finally {
|
||||||
|
visitFormLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVisitForm() {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
if (!visitFormData.EpisodeID?.trim()) {
|
||||||
|
errors.EpisodeID = 'Episode ID is required';
|
||||||
|
}
|
||||||
|
if (!visitFormData.PV1?.PatientClass) {
|
||||||
|
errors.PatientClass = 'Patient class is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
visitFormErrors = errors;
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveVisit() {
|
||||||
|
if (!validateVisitForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...visitFormData,
|
||||||
|
PV1: {
|
||||||
|
...visitFormData.PV1,
|
||||||
|
AdmitDateTime: visitFormData.PV1.AdmitDateTime
|
||||||
|
? new Date(visitFormData.PV1.AdmitDateTime).toISOString()
|
||||||
|
: undefined,
|
||||||
|
DischargeDateTime: visitFormData.PV1.DischargeDateTime
|
||||||
|
? new Date(visitFormData.PV1.DischargeDateTime).toISOString()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove empty optional fields from PV1
|
||||||
|
Object.keys(payload.PV1).forEach((key) => {
|
||||||
|
if (payload.PV1[key] === '' || payload.PV1[key] === null) {
|
||||||
|
delete payload.PV1[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visitModalMode === 'create') {
|
||||||
|
await createVisit(payload);
|
||||||
|
toastSuccess('🎉 Visit created successfully!');
|
||||||
|
} else {
|
||||||
|
await updateVisit(payload);
|
||||||
|
toastSuccess('✨ Visit updated successfully!');
|
||||||
|
}
|
||||||
|
visitModalOpen = false;
|
||||||
|
await loadVisitsForPatient(currentPatientForVisits.InternalPID);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err.message || 'Failed to save visit');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteVisit(visit) {
|
||||||
|
visitToDelete = visit;
|
||||||
|
visitDeleteConfirmOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteVisit() {
|
||||||
|
try {
|
||||||
|
await deleteVisit(visitToDelete.InternalPVID);
|
||||||
|
toastSuccess('🗑️ Visit deleted successfully');
|
||||||
|
visitDeleteConfirmOpen = false;
|
||||||
|
await loadVisitsForPatient(currentPatientForVisits.InternalPID);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err.message || 'Failed to delete visit');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1000,40 +1196,92 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Patient Visits Modal -->
|
<!-- Patient Visits Modal -->
|
||||||
<Modal bind:open={visitsModalOpen} title="Patient Visits" size="lg" closable={true}>
|
<Modal
|
||||||
|
bind:open={visitsModalOpen}
|
||||||
|
title={currentPatientForVisits ? `Visits for ${[currentPatientForVisits.Prefix, currentPatientForVisits.NameFirst, currentPatientForVisits.NameLast].filter(Boolean).join(' ')}` : 'Patient Visits'}
|
||||||
|
size="lg"
|
||||||
|
closable={true}
|
||||||
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<!-- Fun Header with Add Button -->
|
||||||
|
<div class="flex justify-between items-center pb-4 border-b border-base-200">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Stethoscope class="w-5 h-5 text-emerald-600" />
|
||||||
|
<span class="text-sm text-gray-600">{patientVisits.length} visit(s) found</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary gap-2 hover:scale-105 transition-transform"
|
||||||
|
onclick={openCreateVisitModal}
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 animate-bounce" />
|
||||||
|
Add Visit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if patientVisits.length === 0}
|
{#if patientVisits.length === 0}
|
||||||
<div class="text-center py-8 text-gray-500">No visits found for this patient</div>
|
<!-- Fun Empty State -->
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-6xl mb-4">🏥</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-700 mb-2">No visits yet!</h3>
|
||||||
|
<p class="text-gray-500 mb-6">This patient hasn't had any visits recorded.</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary gap-2 hover:scale-105 transition-transform"
|
||||||
|
onclick={openCreateVisitModal}
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Create First Visit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-y-auto max-h-96">
|
<div class="overflow-y-auto max-h-96">
|
||||||
<table class="table table-zebra table-hover">
|
<table class="table table-zebra w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-base-200">
|
<tr class="bg-emerald-50">
|
||||||
<th>Visit ID</th>
|
<th class="text-emerald-800">Visit ID</th>
|
||||||
<th>Created</th>
|
<th class="text-emerald-800">Episode</th>
|
||||||
<th>Episode ID</th>
|
<th class="text-emerald-800">Class</th>
|
||||||
<th>Status</th>
|
<th class="text-emerald-800">Status</th>
|
||||||
<th>Action</th>
|
<th class="text-emerald-800">Created</th>
|
||||||
|
<th class="text-emerald-800 text-center">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each patientVisits as visit}
|
{#each patientVisits as visit}
|
||||||
<tr>
|
{@const status = getADTStatus(visit.PatVisitADT?.ADTCode)}
|
||||||
<td>{visit.PVID}</td>
|
<tr class="hover:bg-emerald-50/50 transition-colors">
|
||||||
<td>{visit.CreateDate ? new Date(visit.CreateDate).toLocaleDateString() : '-'}</td>
|
<td class="font-mono text-sm">{visit.PVID}</td>
|
||||||
<td>{visit.EpisodeID || '-'}</td>
|
<td>{visit.EpisodeID || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{#if visit.PatVisitADT?.ADTCode === 'A01'}Admit{/if}
|
<span class="badge badge-sm badge-outline">
|
||||||
{#if visit.PatVisitADT?.ADTCode === 'A02'}Transfer{/if}
|
{getPatientClassLabel(visit.PV1?.PatientClass)}
|
||||||
{#if visit.PatVisitADT?.ADTCode === 'A03'}Discharge{/if}
|
</span>
|
||||||
{#if visit.PatVisitADT?.ADTCode === 'A04'}Register{/if}
|
|
||||||
{#if visit.PatVisitADT?.ADTCode === 'A08'}Update{/if}
|
|
||||||
{#if !visit.PatVisitADT?.ADTCode}-{/if}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-primary" onclick={() => { visitsModalOpen = false; window.location.href = '/visits'; }}>
|
<span class="badge badge-sm {status.color} gap-1">
|
||||||
Manage
|
<span>{status.icon}</span>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-gray-600">
|
||||||
|
{visit.CreateDate ? new Date(visit.CreateDate).toLocaleDateString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex justify-center gap-1">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs hover:bg-emerald-100 hover:text-emerald-700"
|
||||||
|
title="Edit Visit"
|
||||||
|
onclick={() => openEditVisitModal(visit)}
|
||||||
|
>
|
||||||
|
<Edit2 class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs hover:bg-red-100 hover:text-red-600"
|
||||||
|
title="Delete Visit"
|
||||||
|
onclick={() => confirmDeleteVisit(visit)}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@ -1049,3 +1297,244 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Visit Form Modal -->
|
||||||
|
<Modal
|
||||||
|
bind:open={visitModalOpen}
|
||||||
|
title={visitModalMode === 'create' ? '🏥 Create New Visit' : '✏️ Edit Visit'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{#if visitFormLoading}
|
||||||
|
<div class="flex flex-col items-center justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-emerald-600"></span>
|
||||||
|
<p class="text-gray-500 mt-4">Loading visit data...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form class="space-y-5" onsubmit={(e) => e.preventDefault()}>
|
||||||
|
<!-- Locked Patient Display -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="patient">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
Patient
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered w-full bg-base-200 flex items-center gap-2 opacity-75">
|
||||||
|
<span class="text-xl">🔒</span>
|
||||||
|
<span class="font-medium">
|
||||||
|
{currentPatientForVisits
|
||||||
|
? [currentPatientForVisits.Prefix, currentPatientForVisits.NameFirst, currentPatientForVisits.NameLast].filter(Boolean).join(' ')
|
||||||
|
: 'Unknown Patient'}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 ml-auto">
|
||||||
|
ID: {currentPatientForVisits?.PatientID || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Patient is locked for this visit</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Episode ID & Patient Class -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="episodeId">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<Calendar class="w-4 h-4" />
|
||||||
|
Episode ID
|
||||||
|
<span class="text-error">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="episodeId"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
class:input-error={visitFormErrors.EpisodeID}
|
||||||
|
bind:value={visitFormData.EpisodeID}
|
||||||
|
placeholder="Enter episode ID"
|
||||||
|
/>
|
||||||
|
{#if visitFormErrors.EpisodeID}
|
||||||
|
<span class="text-error text-sm mt-1">{visitFormErrors.EpisodeID}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="patientClass">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<Stethoscope class="w-4 h-4" />
|
||||||
|
Patient Class
|
||||||
|
<span class="text-error">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="patientClass"
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
class:select-error={visitFormErrors.PatientClass}
|
||||||
|
bind:value={visitFormData.PV1.PatientClass}
|
||||||
|
>
|
||||||
|
<option value="">Select class...</option>
|
||||||
|
{#each patientClassOptions as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if visitFormErrors.PatientClass}
|
||||||
|
<span class="text-error text-sm mt-1">{visitFormErrors.PatientClass}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="location">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<MapPin class="w-4 h-4" />
|
||||||
|
Assigned Location
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="location"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={visitFormData.PV1.AssignedPatientLocation}
|
||||||
|
placeholder="e.g., Ward A, Room 101, ER Bay 3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Doctors -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="attendingDoctor">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
Attending Doctor
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="attendingDoctor"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={visitFormData.PV1.AttendingDoctor}
|
||||||
|
placeholder="Dr. Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="referringDoctor">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
Referring Doctor
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="referringDoctor"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={visitFormData.PV1.ReferringDoctor}
|
||||||
|
placeholder="Dr. Johnson"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admitting Doctor -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="admittingDoctor">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
Admitting Doctor
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admittingDoctor"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={visitFormData.PV1.AdmittingDoctor}
|
||||||
|
placeholder="Dr. Williams"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dates -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="admitDate">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<Clock class="w-4 h-4" />
|
||||||
|
Admit Date & Time
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admitDate"
|
||||||
|
type="datetime-local"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={visitFormData.PV1.AdmitDateTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="dischargeDate">
|
||||||
|
<span class="label-text font-medium flex items-center gap-2">
|
||||||
|
<Clock class="w-4 h-4" />
|
||||||
|
Discharge Date & Time
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dischargeDate"
|
||||||
|
type="datetime-local"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
bind:value={visitFormData.PV1.DischargeDateTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button class="btn btn-ghost" onclick={() => (visitModalOpen = false)} type="button">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary gap-2 hover:scale-105 transition-transform"
|
||||||
|
onclick={handleSaveVisit}
|
||||||
|
disabled={saving}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if saving}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Saving...
|
||||||
|
{:else}
|
||||||
|
<span>{visitModalMode === 'create' ? '🎉' : '✨'}</span>
|
||||||
|
{visitModalMode === 'create' ? 'Create Visit' : 'Update Visit'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Visit Delete Confirmation Modal -->
|
||||||
|
<Modal bind:open={visitDeleteConfirmOpen} title="🗑️ Delete Visit?" size="sm">
|
||||||
|
<div class="py-2">
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
Are you sure you want to delete visit
|
||||||
|
<strong class="text-base-content">#{visitToDelete?.PVID}</strong>?
|
||||||
|
</p>
|
||||||
|
{#if visitToDelete?.EpisodeID}
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Episode: {visitToDelete.EpisodeID}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-sm text-error mt-3">⚠️ 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 gap-2"
|
||||||
|
onclick={handleDeleteVisit}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|||||||
@ -1,851 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import {
|
|
||||||
fetchVisits,
|
|
||||||
fetchVisit,
|
|
||||||
createVisit,
|
|
||||||
updateVisit,
|
|
||||||
deleteVisit,
|
|
||||||
createADT,
|
|
||||||
updateADT,
|
|
||||||
fetchVisitsByPatient,
|
|
||||||
} from '$lib/api/visits.js';
|
|
||||||
import { fetchPatients } from '$lib/api/patients.js';
|
|
||||||
import { fetchLocations } from '$lib/api/locations.js';
|
|
||||||
import { fetchContacts } from '$lib/api/contacts.js';
|
|
||||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
|
||||||
import DataTable from '$lib/components/DataTable.svelte';
|
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
|
||||||
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
|
||||||
import { Plus, Edit2, Trash2, Search, LogIn, ArrowRight, LogOut, Calendar, CheckCircle2 } from 'lucide-svelte';
|
|
||||||
|
|
||||||
let loading = $state(false);
|
|
||||||
let saving = $state(false);
|
|
||||||
let visits = $state([]);
|
|
||||||
let modalOpen = $state(false);
|
|
||||||
let adtModalOpen = $state(false);
|
|
||||||
let patientModalOpen = $state(false);
|
|
||||||
let deleteConfirmOpen = $state(false);
|
|
||||||
let modalMode = $state('create');
|
|
||||||
let currentTab = $state(1);
|
|
||||||
let deleteItem = $state(null);
|
|
||||||
let selectedVisit = $state(null);
|
|
||||||
let formErrors = $state({});
|
|
||||||
|
|
||||||
let patients = $state([]);
|
|
||||||
let locations = $state([]);
|
|
||||||
let contacts = $state([]);
|
|
||||||
|
|
||||||
let searchQuery = $state('');
|
|
||||||
let visitIdQuery = $state('');
|
|
||||||
let dateFrom = $state('');
|
|
||||||
let dateTo = $state('');
|
|
||||||
|
|
||||||
let formData = $state({
|
|
||||||
InternalPVID: null,
|
|
||||||
PVID: '',
|
|
||||||
InternalPID: '',
|
|
||||||
EpisodeID: '',
|
|
||||||
SiteID: '',
|
|
||||||
PatDiag: {
|
|
||||||
DiagCode: '',
|
|
||||||
Diagnosis: '',
|
|
||||||
},
|
|
||||||
PatVisitADT: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
let adtFormData = $state({
|
|
||||||
InternalPVID: null,
|
|
||||||
ADTCode: '',
|
|
||||||
LocationID: '',
|
|
||||||
AttDoc: '',
|
|
||||||
RefDoc: '',
|
|
||||||
AdmDoc: '',
|
|
||||||
CnsDoc: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
let patientSearchQuery = $state('');
|
|
||||||
let filteredPatients = $state([]);
|
|
||||||
|
|
||||||
const adtCodeOptions = [
|
|
||||||
{ value: 'A01', label: 'Admit' },
|
|
||||||
{ value: 'A02', label: 'Transfer' },
|
|
||||||
{ value: 'A03', label: 'Discharge' },
|
|
||||||
{ value: 'A04', label: 'Register' },
|
|
||||||
{ value: 'A08', label: 'Update' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const locationOptions = $derived(
|
|
||||||
locations.map((l) => ({ value: l.LocationID, label: l.LocFull || l.LocCode }))
|
|
||||||
);
|
|
||||||
|
|
||||||
const contactOptions = $derived(
|
|
||||||
contacts.map((c) => ({
|
|
||||||
value: c.ContactID,
|
|
||||||
label: [c.NameFirst, c.NameLast].filter(Boolean).join(' ') || c.NameFirst || '-',
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'PVID', label: 'Visit ID', class: 'font-medium' },
|
|
||||||
{ key: 'PatientName', label: 'Patient' },
|
|
||||||
{ key: 'EpisodeID', label: 'Episode ID' },
|
|
||||||
{ key: 'CreateDateFormatted', label: 'Created' },
|
|
||||||
{ key: 'StatusLabel', label: 'Status' },
|
|
||||||
{ key: 'actions', label: 'Actions', class: 'w-48 text-center', render: (row) => {
|
|
||||||
return `
|
|
||||||
<div class="flex items-center justify-center gap-1">
|
|
||||||
<button class="btn btn-ghost btn-xs text-blue-600" title="Admit" onclick="window.openVisitADT('${row.PVID}', 'A01')">
|
|
||||||
<LogIn class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-xs text-orange-600" title="Transfer" onclick="window.openVisitADT('${row.PVID}', 'A02')">
|
|
||||||
<ArrowRight class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-xs text-red-600" title="Discharge" onclick="window.openVisitADT('${row.PVID}', 'A03')">
|
|
||||||
<LogOut class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-xs" title="Edit" onclick="window.openVisitEdit('${row.PVID}')">
|
|
||||||
<Edit2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-xs text-red-500" title="Delete" onclick="window.openVisitDelete('${row.PVID}', '${row.InternalPVID}')">
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}},
|
|
||||||
];
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await Promise.all([loadVisits(), loadLocations(), loadContacts()]);
|
|
||||||
window.openVisitADT = (pvid, adtCode) => {
|
|
||||||
const visit = visits.find(v => v.PVID === pvid);
|
|
||||||
if (visit) openADTModal(visit, adtCode);
|
|
||||||
};
|
|
||||||
window.openVisitEdit = (pvid) => {
|
|
||||||
const visit = visits.find(v => v.PVID === pvid);
|
|
||||||
if (visit) openEditModal(visit);
|
|
||||||
};
|
|
||||||
window.openVisitDelete = (pvid, internalPVID) => {
|
|
||||||
const visit = visits.find(v => v.PVID === pvid);
|
|
||||||
if (visit) {
|
|
||||||
deleteItem = visit;
|
|
||||||
deleteConfirmOpen = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadVisits() {
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const params = {};
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
params.PatientName = searchQuery.trim();
|
|
||||||
}
|
|
||||||
if (visitIdQuery.trim()) {
|
|
||||||
params.PVID = visitIdQuery.trim();
|
|
||||||
}
|
|
||||||
if (dateFrom) {
|
|
||||||
params.DateFrom = dateFrom;
|
|
||||||
}
|
|
||||||
if (dateTo) {
|
|
||||||
params.DateTo = dateTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetchVisits(params);
|
|
||||||
visits = Array.isArray(response.data) ? response.data : [];
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err.message || 'Failed to load visits');
|
|
||||||
visits = [];
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLocations() {
|
|
||||||
try {
|
|
||||||
const response = await fetchLocations();
|
|
||||||
locations = Array.isArray(response.data) ? response.data : [];
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load locations:', err);
|
|
||||||
locations = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContacts() {
|
|
||||||
try {
|
|
||||||
const response = await fetchContacts();
|
|
||||||
contacts = Array.isArray(response.data) ? response.data : [];
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load contacts:', err);
|
|
||||||
contacts = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPatients() {
|
|
||||||
try {
|
|
||||||
const params = {};
|
|
||||||
if (patientSearchQuery.trim()) {
|
|
||||||
params.Name = patientSearchQuery.trim();
|
|
||||||
}
|
|
||||||
const response = await fetchPatients(params);
|
|
||||||
patients = Array.isArray(response.data) ? response.data : [];
|
|
||||||
filteredPatients = patients;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load patients:', err);
|
|
||||||
patients = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateModal() {
|
|
||||||
modalMode = 'create';
|
|
||||||
currentTab = 1;
|
|
||||||
formErrors = {};
|
|
||||||
formData = {
|
|
||||||
InternalPVID: null,
|
|
||||||
PVID: '',
|
|
||||||
InternalPID: '',
|
|
||||||
EpisodeID: '',
|
|
||||||
SiteID: '',
|
|
||||||
PatDiag: {
|
|
||||||
DiagCode: '',
|
|
||||||
Diagnosis: '',
|
|
||||||
},
|
|
||||||
PatVisitADT: null,
|
|
||||||
};
|
|
||||||
modalOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditModal(row) {
|
|
||||||
modalMode = 'edit';
|
|
||||||
currentTab = 1;
|
|
||||||
formErrors = {};
|
|
||||||
try {
|
|
||||||
const response = await fetchVisit(row.PVID);
|
|
||||||
const visit = response.data || response;
|
|
||||||
|
|
||||||
formData = {
|
|
||||||
InternalPVID: visit.InternalPVID,
|
|
||||||
PVID: visit.PVID || '',
|
|
||||||
InternalPID: visit.InternalPID || '',
|
|
||||||
EpisodeID: visit.EpisodeID || '',
|
|
||||||
SiteID: visit.SiteID || '',
|
|
||||||
PatDiag: visit.PatDiag || { DiagCode: '', Diagnosis: '' },
|
|
||||||
PatVisitADT: visit.PatVisitADT || null,
|
|
||||||
};
|
|
||||||
modalOpen = true;
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err.message || 'Failed to load visit details');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openPatientVisitsModal(patient) {
|
|
||||||
patientModalOpen = true;
|
|
||||||
try {
|
|
||||||
const response = await fetchVisitsByPatient(patient.InternalPID);
|
|
||||||
const patientVisits = Array.isArray(response.data) ? response.data : [];
|
|
||||||
filteredPatients = [
|
|
||||||
{
|
|
||||||
...patient,
|
|
||||||
visits: patientVisits.map((v) => ({
|
|
||||||
...v,
|
|
||||||
CreateDateFormatted: v.CreateDate ? new Date(v.CreateDate).toLocaleDateString() : '-',
|
|
||||||
StatusLabel: getVisitStatusLabel(v),
|
|
||||||
PatientName: [patient.Prefix, patient.NameFirst, patient.NameMiddle, patient.NameLast, patient.Suffix]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' '),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err.message || 'Failed to load patient visits');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openADTModal(visit, preselectedADTCode = '') {
|
|
||||||
selectedVisit = visit;
|
|
||||||
adtFormData = {
|
|
||||||
InternalPVID: visit.InternalPVID,
|
|
||||||
ADTCode: preselectedADTCode,
|
|
||||||
LocationID: '',
|
|
||||||
AttDoc: '',
|
|
||||||
RefDoc: '',
|
|
||||||
AdmDoc: '',
|
|
||||||
CnsDoc: '',
|
|
||||||
};
|
|
||||||
adtModalOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving = true;
|
|
||||||
formErrors = {};
|
|
||||||
try {
|
|
||||||
const dataToSubmit = { ...formData };
|
|
||||||
if (!dataToSubmit.PatVisitADT || Object.keys(dataToSubmit.PatVisitADT).length === 0) {
|
|
||||||
delete dataToSubmit.PatVisitADT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalMode === 'create') {
|
|
||||||
await createVisit(dataToSubmit);
|
|
||||||
toastSuccess('Visit created successfully');
|
|
||||||
} else {
|
|
||||||
await updateVisit(dataToSubmit);
|
|
||||||
toastSuccess('Visit updated successfully');
|
|
||||||
}
|
|
||||||
modalOpen = false;
|
|
||||||
await loadVisits();
|
|
||||||
} catch (err) {
|
|
||||||
formErrors = err.errors || { general: err.message || 'Failed to save visit' };
|
|
||||||
toastError(formErrors.general || 'Failed to save visit');
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleADTSubmit() {
|
|
||||||
saving = true;
|
|
||||||
formErrors = {};
|
|
||||||
try {
|
|
||||||
await createADT(adtFormData);
|
|
||||||
toastSuccess(`ADT action completed: ${adtCodeOptions.find((o) => o.value === adtFormData.ADTCode)?.label}`);
|
|
||||||
adtModalOpen = false;
|
|
||||||
await loadVisits();
|
|
||||||
} catch (err) {
|
|
||||||
formErrors = err.errors || { general: err.message || 'Failed to complete ADT action' };
|
|
||||||
toastError(formErrors.general || 'Failed to complete ADT action');
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
try {
|
|
||||||
await deleteVisit(deleteItem.InternalPVID);
|
|
||||||
toastSuccess('Visit deleted successfully');
|
|
||||||
deleteConfirmOpen = false;
|
|
||||||
deleteItem = null;
|
|
||||||
await loadVisits();
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err.message || 'Failed to delete visit');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVisitStatusLabel(visit) {
|
|
||||||
if (visit.PatVisitADT && visit.PatVisitADT.ADTCode) {
|
|
||||||
const adtCode = visit.PatVisitADT.ADTCode;
|
|
||||||
return adtCodeOptions.find((o) => o.value === adtCode)?.label || adtCode;
|
|
||||||
}
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (patientSearchQuery.trim()) {
|
|
||||||
const query = patientSearchQuery.toLowerCase();
|
|
||||||
filteredPatients = patients.filter((p) => {
|
|
||||||
const name = [p.NameFirst, p.NameLast, p.PatientID].join(' ').toLowerCase();
|
|
||||||
return name.includes(query);
|
|
||||||
});
|
|
||||||
} else if (patients.length > 0) {
|
|
||||||
filteredPatients = patients;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-800">Patient Visits</h1>
|
|
||||||
<p class="text-gray-500 mt-1">Manage patient encounters and ADT workflow</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary gap-2" onclick={openCreateModal}>
|
|
||||||
<Plus class="w-5 h-5" />
|
|
||||||
New Visit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-md">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex flex-col md:flex-row gap-4 mb-6">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Search</span>
|
|
||||||
</label>
|
|
||||||
<div class="join">
|
|
||||||
<span class="btn btn-disabled join-item"><Search class="w-4 h-4" /></span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Patient name..."
|
|
||||||
class="input input-bordered join-item flex-1"
|
|
||||||
bind:value={searchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Visit ID</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g., DV00001"
|
|
||||||
class="input input-bordered"
|
|
||||||
bind:value={visitIdQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">From</span>
|
|
||||||
</label>
|
|
||||||
<input type="date" class="input input-bordered" bind:value={dateFrom} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">To</span>
|
|
||||||
</label>
|
|
||||||
<input type="date" class="input input-bordered" bind:value={dateTo} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button class="btn btn-primary" onclick={loadVisits}>Filter</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={visits.map((v) => {
|
|
||||||
return {
|
|
||||||
...v,
|
|
||||||
CreateDateFormatted: v.CreateDate ? new Date(v.CreateDate).toLocaleDateString() : '-',
|
|
||||||
StatusLabel: getVisitStatusLabel(v),
|
|
||||||
PatientName: v.PatientName || '-',
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
loading={loading}
|
|
||||||
striped={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Visit Form Modal -->
|
|
||||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'New Visit' : 'Edit Visit'} size="lg">
|
|
||||||
<form class="space-y-5" onsubmit={(e) => e.preventDefault()}>
|
|
||||||
{#if formErrors.general}
|
|
||||||
<div class="alert alert-error text-sm">
|
|
||||||
<span>{formErrors.general}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button class="btn btn-sm {currentTab === 1 ? 'btn-primary' : ''}" onclick={() => currentTab = 1}>Basic Info</button>
|
|
||||||
<button class="btn btn-sm {currentTab === 2 ? 'btn-primary' : ''}" onclick={() => currentTab = 2}>Diagnosis</button>
|
|
||||||
<button class="btn btn-sm {currentTab === 3 ? 'btn-primary' : ''}" onclick={() => currentTab = 3}>Initial ADT</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if currentTab === 1}
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Patient *</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered flex-1"
|
|
||||||
bind:value={formData.InternalPID}
|
|
||||||
placeholder="Enter Internal Patient ID"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button type="button" class="btn btn-outline gap-2" onclick={() => { patientSearchQuery = ''; loadPatients(); patientModalOpen = true; }}>
|
|
||||||
<Search class="w-4 h-4" />
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if formErrors.InternalPID}
|
|
||||||
<label class="label text-error text-xs">{formErrors.InternalPID}</label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Episode ID</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
bind:value={formData.EpisodeID}
|
|
||||||
placeholder="Enter episode ID"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Visit ID</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
bind:value={formData.PVID}
|
|
||||||
placeholder="Auto-generated (DV prefix)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Site</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="site"
|
|
||||||
bind:value={formData.SiteID}
|
|
||||||
options={locationOptions}
|
|
||||||
placeholder="Select site..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if currentTab === 2}
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Diagnosis Code</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
bind:value={formData.PatDiag.DiagCode}
|
|
||||||
placeholder="e.g., I10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Diagnosis</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
bind:value={formData.PatDiag.Diagnosis}
|
|
||||||
placeholder="Enter diagnosis description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if currentTab === 3}
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Initial ADT Action</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="adtCode"
|
|
||||||
value={formData.PatVisitADT?.ADTCode || ''}
|
|
||||||
oninput={(e) => {
|
|
||||||
if (!formData.PatVisitADT) formData.PatVisitADT = {};
|
|
||||||
formData.PatVisitADT.ADTCode = e.target.value;
|
|
||||||
}}
|
|
||||||
options={adtCodeOptions}
|
|
||||||
placeholder="Select ADT action (optional)..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if (formData.PatVisitADT?.ADTCode === 'A01' || formData.PatVisitADT?.ADTCode === 'A02')}
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Location *</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="location"
|
|
||||||
value={formData.PatVisitADT?.LocationID || ''}
|
|
||||||
oninput={(e) => {
|
|
||||||
if (!formData.PatVisitADT) formData.PatVisitADT = {};
|
|
||||||
formData.PatVisitADT.LocationID = e.target.value;
|
|
||||||
}}
|
|
||||||
options={locationOptions}
|
|
||||||
placeholder="Select location..."
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Attending Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="attDoc"
|
|
||||||
value={formData.PatVisitADT?.AttDoc || ''}
|
|
||||||
oninput={(e) => {
|
|
||||||
if (!formData.PatVisitADT) formData.PatVisitADT = {};
|
|
||||||
formData.PatVisitADT.AttDoc = e.target.value;
|
|
||||||
}}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Referring Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="refDoc"
|
|
||||||
value={formData.PatVisitADT?.RefDoc || ''}
|
|
||||||
oninput={(e) => {
|
|
||||||
if (!formData.PatVisitADT) formData.PatVisitADT = {};
|
|
||||||
formData.PatVisitADT.RefDoc = e.target.value;
|
|
||||||
}}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Admitting Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="admDoc"
|
|
||||||
value={formData.PatVisitADT?.AdmDoc || ''}
|
|
||||||
oninput={(e) => {
|
|
||||||
if (!formData.PatVisitADT) formData.PatVisitADT = {};
|
|
||||||
formData.PatVisitADT.AdmDoc = e.target.value;
|
|
||||||
}}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Consulting Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="cnsDoc"
|
|
||||||
value={formData.PatVisitADT?.CnsDoc || ''}
|
|
||||||
oninput={(e) => {
|
|
||||||
if (!formData.PatVisitADT) formData.PatVisitADT = {};
|
|
||||||
formData.PatVisitADT.CnsDoc = e.target.value;
|
|
||||||
}}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#snippet footer()}
|
|
||||||
<div class="flex justify-between w-full">
|
|
||||||
<button class="btn btn-ghost" onclick={() => modalOpen = false}>Cancel</button>
|
|
||||||
<button class="btn btn-primary gap-2" disabled={saving} onclick={handleSave}>
|
|
||||||
{saving ? '<span class="loading loading-spinner loading-sm"></span>' : ''}
|
|
||||||
{modalMode === 'create' ? 'Create Visit' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- ADT Action Modal -->
|
|
||||||
<Modal bind:open={adtModalOpen} title="ADT Action" size="md">
|
|
||||||
<form class="space-y-5" onsubmit={(e) => e.preventDefault()}>
|
|
||||||
{#if formErrors.general}
|
|
||||||
<div class="alert alert-error text-sm">
|
|
||||||
<span>{formErrors.general}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">ADT Code *</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="adtCode"
|
|
||||||
bind:value={adtFormData.ADTCode}
|
|
||||||
options={adtCodeOptions}
|
|
||||||
placeholder="Select ADT action..."
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if adtFormData.ADTCode === 'A01' || adtFormData.ADTCode === 'A02'}
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Location *</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="location"
|
|
||||||
bind:value={adtFormData.LocationID}
|
|
||||||
options={locationOptions}
|
|
||||||
placeholder="Select location..."
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Attending Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="attDoc"
|
|
||||||
bind:value={adtFormData.AttDoc}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Referring Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="refDoc"
|
|
||||||
bind:value={adtFormData.RefDoc}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Admitting Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="admDoc"
|
|
||||||
bind:value={adtFormData.AdmDoc}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Consulting Physician</span>
|
|
||||||
</label>
|
|
||||||
<SelectDropdown
|
|
||||||
name="cnsDoc"
|
|
||||||
bind:value={adtFormData.CnsDoc}
|
|
||||||
options={contactOptions}
|
|
||||||
placeholder="Select physician..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#snippet footer()}
|
|
||||||
<div class="flex justify-between w-full">
|
|
||||||
<button class="btn btn-ghost" onclick={() => adtModalOpen = false}>Cancel</button>
|
|
||||||
<button class="btn btn-primary gap-2" disabled={saving} onclick={handleADTSubmit}>
|
|
||||||
{saving ? '<span class="loading loading-spinner loading-sm"></span>' : ''}
|
|
||||||
Complete Action
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- Patient Selection Modal -->
|
|
||||||
<Modal bind:open={patientModalOpen} title="Select Patient" size="lg" closable={true}>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<div class="join">
|
|
||||||
<span class="btn btn-disabled join-item"><Search class="w-4 h-4" /></span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by name or patient ID..."
|
|
||||||
class="input input-bordered join-item flex-1"
|
|
||||||
bind:value={patientSearchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filteredPatients.length === 0}
|
|
||||||
<div class="text-center py-8 text-gray-500">No patients found</div>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-y-auto max-h-96">
|
|
||||||
<table class="table table-zebra table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-base-200">
|
|
||||||
<th>Patient ID</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Birthdate</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each filteredPatients as patient}
|
|
||||||
{#if patient.visits}
|
|
||||||
<tr class="bg-base-100 font-semibold">
|
|
||||||
<td colspan="4" class="text-emerald-600">
|
|
||||||
{patient.PatientID} - {[patient.Prefix, patient.NameFirst, patient.NameLast].filter(Boolean).join(' ')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{#each patient.visits as visit}
|
|
||||||
<tr>
|
|
||||||
<td class="pl-8">{visit.PVID}</td>
|
|
||||||
<td class="pl-8">{visit.StatusLabel}</td>
|
|
||||||
<td class="pl-8">{visit.CreateDateFormatted}</td>
|
|
||||||
<td class="pl-8">
|
|
||||||
<button class="btn btn-ghost btn-xs" onclick={() => { patientModalOpen = false; openEditModal(visit); }}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<tr>
|
|
||||||
<td>{patient.PatientID}</td>
|
|
||||||
<td>
|
|
||||||
{[patient.Prefix, patient.NameFirst, patient.NameMiddle, patient.NameLast, patient.Suffix]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
</td>
|
|
||||||
<td>{patient.Birthdate ? new Date(patient.Birthdate).toLocaleDateString() : '-'}</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn btn-primary btn-xs gap-1" onclick={() => { formData.InternalPID = patient.InternalPID; patientModalOpen = false; }}>
|
|
||||||
<CheckCircle2 class="w-3 h-3" />
|
|
||||||
Select
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#snippet footer()}
|
|
||||||
<div class="flex justify-end w-full">
|
|
||||||
<button class="btn btn-ghost" onclick={() => patientModalOpen = false}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm" closable={true}>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-gray-700">
|
|
||||||
Are you sure you want to delete visit <span class="font-bold">{deleteItem?.PVID}</span>?
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500">This action cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#snippet footer()}
|
|
||||||
<div class="flex justify-between w-full">
|
|
||||||
<button class="btn btn-ghost" onclick={() => deleteConfirmOpen = false}>Cancel</button>
|
|
||||||
<button class="btn btn-error gap-2" onclick={handleDelete}>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Modal>
|
|
||||||
Loading…
x
Reference in New Issue
Block a user