feat: Add Visits Management module and integrate with patients
- Add Visits API client and page routes - Add Calendar icon and Visits menu item to Sidebar - Add patient visits view modal in patients page - Update AGENTS.md to use pnpm commands
This commit is contained in:
parent
4641668f78
commit
1e032e5278
@ -8,16 +8,16 @@ SvelteKit frontend for Clinical Laboratory Quality Management System (CLQMS). Us
|
||||
|
||||
```bash
|
||||
# Development server
|
||||
npm run dev
|
||||
pnpm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
pnpm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
pnpm run preview
|
||||
|
||||
# Sync SvelteKit (runs automatically on install)
|
||||
npm run prepare
|
||||
pnpm run prepare
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
34
src/lib/api/visits.js
Normal file
34
src/lib/api/visits.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { get, post, patch, del } from './client.js';
|
||||
|
||||
export async function fetchVisits(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/patvisit?${query}` : '/api/patvisit');
|
||||
}
|
||||
|
||||
export async function fetchVisit(id) {
|
||||
return get(`/api/patvisit/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function fetchVisitsByPatient(patientId) {
|
||||
return get(`/api/patvisit/patient/${encodeURIComponent(patientId)}`);
|
||||
}
|
||||
|
||||
export async function createVisit(data) {
|
||||
return post('/api/patvisit', data);
|
||||
}
|
||||
|
||||
export async function updateVisit(data) {
|
||||
return patch('/api/patvisit', data);
|
||||
}
|
||||
|
||||
export async function deleteVisit(id) {
|
||||
return del('/api/patvisit', { body: JSON.stringify({ InternalPVID: id }) });
|
||||
}
|
||||
|
||||
export async function createADT(data) {
|
||||
return post('/api/patvisitadt', data);
|
||||
}
|
||||
|
||||
export async function updateADT(data) {
|
||||
return patch('/api/patvisitadt', data);
|
||||
}
|
||||
@ -17,6 +17,7 @@
|
||||
Briefcase,
|
||||
Hash,
|
||||
Globe,
|
||||
Calendar,
|
||||
ChevronDown
|
||||
} from 'lucide-svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
@ -251,6 +252,15 @@
|
||||
<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"
|
||||
|
||||
@ -8,11 +8,12 @@
|
||||
deletePatient,
|
||||
} from '$lib/api/patients.js';
|
||||
import { fetchProvinces, fetchCities } from '$lib/api/geography.js';
|
||||
import { fetchVisitsByPatient } 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 } from 'lucide-svelte';
|
||||
import { Plus, Edit2, Trash2, Search, ChevronLeft, ChevronRight, User, Calendar } from 'lucide-svelte';
|
||||
|
||||
// State
|
||||
let loading = $state(false);
|
||||
@ -25,6 +26,8 @@
|
||||
let saving = $state(false);
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
let visitsModalOpen = $state(false);
|
||||
let patientVisits = $state([]);
|
||||
let currentStep = $state(1);
|
||||
let formErrors = $state({});
|
||||
let previousProvince = $state('');
|
||||
@ -344,6 +347,16 @@
|
||||
deleteConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function openVisitsModal(patient) {
|
||||
visitsModalOpen = true;
|
||||
try {
|
||||
const response = await fetchVisitsByPatient(patient.InternalPID);
|
||||
patientVisits = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load patient visits');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deletePatient(deleteItem.InternalPID);
|
||||
@ -417,10 +430,13 @@
|
||||
{#snippet cell({ column, row, value })}
|
||||
{#if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-2">
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}>
|
||||
<button class="btn btn-sm btn-ghost" title="View Visits" onclick={() => openVisitsModal(row)}>
|
||||
<Calendar class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" title="Edit" onclick={() => openEditModal(row)}>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
|
||||
<button class="btn btn-sm btn-ghost text-error" title="Delete" onclick={() => confirmDelete(row)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -982,3 +998,54 @@
|
||||
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- Patient Visits Modal -->
|
||||
<Modal bind:open={visitsModalOpen} title="Patient Visits" size="lg" closable={true}>
|
||||
<div class="space-y-4">
|
||||
{#if patientVisits.length === 0}
|
||||
<div class="text-center py-8 text-gray-500">No visits found for this patient</div>
|
||||
{:else}
|
||||
<div class="overflow-y-auto max-h-96">
|
||||
<table class="table table-zebra table-hover">
|
||||
<thead>
|
||||
<tr class="bg-base-200">
|
||||
<th>Visit ID</th>
|
||||
<th>Created</th>
|
||||
<th>Episode ID</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each patientVisits as visit}
|
||||
<tr>
|
||||
<td>{visit.PVID}</td>
|
||||
<td>{visit.CreateDate ? new Date(visit.CreateDate).toLocaleDateString() : '-'}</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}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick={() => { visitsModalOpen = false; window.location.href = '/visits'; }}>
|
||||
Manage
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex justify-end w-full">
|
||||
<button class="btn btn-ghost" onclick={() => visitsModalOpen = false}>Close</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
851
src/routes/(app)/visits/+page.svelte
Normal file
851
src/routes/(app)/visits/+page.svelte
Normal file
@ -0,0 +1,851 @@
|
||||
<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