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:
mahdahar 2026-02-12 16:28:24 +07:00
parent 1e032e5278
commit 382b05d98e
3 changed files with 514 additions and 886 deletions

View File

@ -17,7 +17,6 @@
Briefcase,
Hash,
Globe,
Calendar,
ChevronDown
} from 'lucide-svelte';
import { auth } from '$lib/stores/auth.js';
@ -252,15 +251,6 @@
<span>Patients</span>
</a>
</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>
<a
href="/orders"

View File

@ -8,12 +8,18 @@
deletePatient,
} from '$lib/api/patients.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 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, ChevronLeft, ChevronRight, User, Calendar } from 'lucide-svelte';
import { Plus, Edit2, Trash2, Search, ChevronLeft, ChevronRight, User, Calendar, Stethoscope, MapPin, Clock } from 'lucide-svelte';
// State
let loading = $state(false);
@ -28,6 +34,13 @@
let deleteItem = $state(null);
let visitsModalOpen = $state(false);
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 formErrors = $state({});
let previousProvince = $state('');
@ -74,7 +87,22 @@
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)
const prefixOptions = [
@ -85,6 +113,37 @@
{ 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
const provinceOptions = $derived(
provinces.map((p) => ({ value: p.value, label: p.label }))
@ -348,12 +407,149 @@
}
async function openVisitsModal(patient) {
currentPatientForVisits = patient;
visitsModalOpen = true;
await loadVisitsForPatient(patient.InternalPID);
}
async function loadVisitsForPatient(patientId) {
try {
const response = await fetchVisitsByPatient(patient.InternalPID);
const response = await fetchVisitsByPatient(patientId);
patientVisits = Array.isArray(response.data) ? response.data : [];
} catch (err) {
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>
<!-- 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">
<!-- 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}
<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}
<div class="overflow-y-auto max-h-96">
<table class="table table-zebra table-hover">
<table class="table table-zebra w-full">
<thead>
<tr class="bg-base-200">
<th>Visit ID</th>
<th>Created</th>
<th>Episode ID</th>
<th>Status</th>
<th>Action</th>
<tr class="bg-emerald-50">
<th class="text-emerald-800">Visit ID</th>
<th class="text-emerald-800">Episode</th>
<th class="text-emerald-800">Class</th>
<th class="text-emerald-800">Status</th>
<th class="text-emerald-800">Created</th>
<th class="text-emerald-800 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each patientVisits as visit}
<tr>
<td>{visit.PVID}</td>
<td>{visit.CreateDate ? new Date(visit.CreateDate).toLocaleDateString() : '-'}</td>
{@const status = getADTStatus(visit.PatVisitADT?.ADTCode)}
<tr class="hover:bg-emerald-50/50 transition-colors">
<td class="font-mono text-sm">{visit.PVID}</td>
<td>{visit.EpisodeID || '-'}</td>
<td>
{#if visit.PatVisitADT?.ADTCode === 'A01'}Admit{/if}
{#if visit.PatVisitADT?.ADTCode === 'A02'}Transfer{/if}
{#if visit.PatVisitADT?.ADTCode === 'A03'}Discharge{/if}
{#if visit.PatVisitADT?.ADTCode === 'A04'}Register{/if}
{#if visit.PatVisitADT?.ADTCode === 'A08'}Update{/if}
{#if !visit.PatVisitADT?.ADTCode}-{/if}
<span class="badge badge-sm badge-outline">
{getPatientClassLabel(visit.PV1?.PatientClass)}
</span>
</td>
<td>
<button class="btn btn-sm btn-primary" onclick={() => { visitsModalOpen = false; window.location.href = '/visits'; }}>
Manage
<span class="badge badge-sm {status.color} gap-1">
<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
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>
</tr>
{/each}
@ -1049,3 +1297,244 @@
</div>
{/snippet}
</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>

View File

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