clqms-be/app/Views/v2/patients.php
2025-12-23 06:29:01 +07:00

692 lines
28 KiB
PHP

<?= $this->extend('layouts/v2') ?>
<?= $this->section('content') ?>
<div x-data="patientManager()">
<!-- Page Header with Actions -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div>
<h2 class="text-2xl font-bold">Patients</h2>
<p class="text-base-content/60">Manage patient records</p>
</div>
<button @click="openCreateModal()" class="btn btn-primary gap-2">
<i data-lucide="plus" class="w-4 h-4"></i>
Add Patient
</button>
</div>
<!-- Search & Filter Card -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<div class="flex flex-col lg:flex-row gap-4">
<!-- Search by Name -->
<div class="form-control flex-1">
<label class="label">
<span class="label-text">Patient Name</span>
</label>
<input
type="text"
placeholder="Search by name..."
class="input input-bordered w-full"
x-model="filters.Name"
@keyup.enter="searchPatients"
>
</div>
<!-- Search by ID -->
<div class="form-control w-full lg:w-48">
<label class="label">
<span class="label-text">Patient ID</span>
</label>
<input
type="text"
placeholder="MRN..."
class="input input-bordered w-full"
x-model="filters.PatientID"
@keyup.enter="searchPatients"
>
</div>
<!-- Search by Birthdate -->
<div class="form-control w-full lg:w-48">
<label class="label">
<span class="label-text">Birthdate</span>
</label>
<input
type="date"
class="input input-bordered w-full"
x-model="filters.Birthdate"
>
</div>
<!-- Search Button -->
<div class="form-control w-full lg:w-auto">
<label class="label lg:invisible">
<span class="label-text">&nbsp;</span>
</label>
<div class="flex gap-2">
<button
class="btn btn-primary flex-1 lg:flex-none gap-2"
@click="searchPatients"
:disabled="isLoading"
>
<i data-lucide="search" class="w-4 h-4"></i>
Search
</button>
<button
class="btn btn-ghost"
@click="clearFilters"
:disabled="isLoading"
>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Results Table -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<!-- Loading State -->
<template x-if="isLoading">
<div class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
</template>
<!-- Error State -->
<template x-if="error && !isLoading">
<div class="alert alert-error">
<i data-lucide="alert-circle" class="w-5 h-5"></i>
<span x-text="error"></span>
</div>
</template>
<!-- Empty State -->
<template x-if="!isLoading && !error && patients.length === 0">
<div class="text-center py-12">
<i data-lucide="users" class="w-16 h-16 mx-auto text-base-content/20 mb-4"></i>
<h3 class="text-lg font-semibold">No patients found</h3>
<p class="text-base-content/60" x-text="hasSearched ? 'Try adjusting your search criteria' : 'Click Search to load patients'"></p>
</div>
</template>
<!-- Data Table -->
<template x-if="!isLoading && !error && patients.length > 0">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Patient ID</th>
<th>Name</th>
<th>Gender</th>
<th>Birthdate</th>
<th>Mobile</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="patient in patients" :key="patient.InternalPID">
<tr>
<td>
<span class="font-mono text-sm" x-text="patient.PatientID || '-'"></span>
</td>
<td>
<div class="font-medium" x-text="patient.FullName || '-'"></div>
<div class="text-xs text-base-content/50" x-text="patient.Email || ''"></div>
</td>
<td>
<span class="badge badge-ghost" x-text="patient.Gender || '-'"></span>
</td>
<td x-text="formatDate(patient.Birthdate)"></td>
<td x-text="patient.MobilePhone || '-'"></td>
<td>
<div class="flex gap-1">
<button
@click="openEditModal(patient)"
class="btn btn-ghost btn-sm btn-square"
title="Edit"
>
<i data-lucide="pencil" class="w-4 h-4"></i>
</button>
<button
class="btn btn-ghost btn-sm btn-square text-error"
title="Delete"
@click="confirmDelete(patient)"
>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Results Count -->
<div class="text-sm text-base-content/60 mt-4">
Showing <span x-text="patients.length" class="font-medium"></span> patients
</div>
</div>
</template>
</div>
</div>
<!-- Form Modal -->
<template x-teleport="body">
<dialog id="patientModal" class="modal" :class="{ 'modal-open': showFormModal }">
<div class="modal-box w-11/12 max-w-5xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeFormModal()">✕</button>
</form>
<h3 class="font-bold text-lg mb-6" x-text="isEdit ? 'Edit Patient' : 'New Patient'"></h3>
<!-- Error Alert in Modal -->
<template x-if="formError">
<div class="alert alert-error mb-6">
<i data-lucide="alert-circle" class="w-5 h-5"></i>
<span x-text="formError"></span>
</div>
</template>
<form @submit.prevent="submitForm">
<!-- Personal Information -->
<div class="mb-6">
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
<i data-lucide="user" class="w-4 h-4"></i> Personal Information
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Patient ID (MRN)</span></label>
<input type="text" class="input input-bordered" x-model="form.PatientID" placeholder="Auto-generated">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Prefix</span></label>
<select class="select select-bordered" x-model="form.Prefix">
<option value="">Select...</option>
<option value="Mr.">Mr.</option>
<option value="Mrs.">Mrs.</option>
<option value="Ms.">Ms.</option>
<option value="Dr.">Dr.</option>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">First Name *</span></label>
<input type="text" class="input input-bordered" x-model="form.NameFirst" required>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Middle Name</span></label>
<input type="text" class="input input-bordered" x-model="form.NameMiddle">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Last Name</span></label>
<input type="text" class="input input-bordered" x-model="form.NameLast">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Suffix</span></label>
<input type="text" class="input input-bordered" x-model="form.Suffix">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Gender *</span></label>
<select class="select select-bordered" x-model="form.Gender" required>
<option value="">Select...</option>
<template x-for="g in options.gender" :key="g.VID">
<option :value="g.VID" x-text="g.VDesc"></option>
</template>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Birthdate *</span></label>
<input type="date" class="input input-bordered" x-model="form.Birthdate" required>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Place of Birth</span></label>
<input type="text" class="input input-bordered" x-model="form.PlaceOfBirth">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Marital Status</span></label>
<select class="select select-bordered" x-model="form.MaritalStatus">
<option value="">Select...</option>
<template x-for="m in options.marital" :key="m.VID">
<option :value="m.VID" x-text="m.VDesc"></option>
</template>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Religion</span></label>
<select class="select select-bordered" x-model="form.Religion">
<option value="">Select...</option>
<template x-for="r in options.religion" :key="r.VID">
<option :value="r.VID" x-text="r.VDesc"></option>
</template>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Ethnic</span></label>
<select class="select select-bordered" x-model="form.Ethnic">
<option value="">Select...</option>
<template x-for="e in options.ethnic" :key="e.VID">
<option :value="e.VID" x-text="e.VDesc"></option>
</template>
</select>
</div>
</div>
</div>
<!-- Contact Information -->
<div class="mb-6">
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
<i data-lucide="phone" class="w-4 h-4"></i> Contact Information
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Mobile Phone</span></label>
<input type="tel" class="input input-bordered" x-model="form.MobilePhone">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Phone</span></label>
<input type="tel" class="input input-bordered" x-model="form.Phone">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Email Address</span></label>
<input type="email" class="input input-bordered" x-model="form.EmailAddress1">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Alternate Email</span></label>
<input type="email" class="input input-bordered" x-model="form.EmailAddress2">
</div>
</div>
</div>
<!-- Address -->
<div class="mb-6">
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
<i data-lucide="map-pin" class="w-4 h-4"></i> Address
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">Street Address</span></label>
<input type="text" class="input input-bordered" x-model="form.Street_1">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">Street Address 2</span></label>
<input type="text" class="input input-bordered" x-model="form.Street_2">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Province</span></label>
<select class="select select-bordered" x-model="form.Province" @change="loadCities">
<option value="">Select Province...</option>
<template x-for="p in options.provinces" :key="p.AreaGeoID">
<option :value="p.AreaGeoID" x-text="p.AreaName"></option>
</template>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">City</span></label>
<select class="select select-bordered" x-model="form.City" :disabled="!form.Province">
<option value="">Select City...</option>
<template x-for="c in options.cities" :key="c.AreaGeoID">
<option :value="c.AreaGeoID" x-text="c.AreaName"></option>
</template>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">ZIP Code</span></label>
<input type="text" class="input input-bordered" x-model="form.ZIP">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Country</span></label>
<select class="select select-bordered" x-model="form.Country">
<option value="">Select...</option>
<template x-for="c in options.country" :key="c.VID">
<option :value="c.VID" x-text="c.VDesc"></option>
</template>
</select>
</div>
</div>
</div>
<!-- Identifier -->
<div class="mb-6">
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
<i data-lucide="id-card" class="w-4 h-4"></i> Identifier
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">ID Type</span></label>
<select class="select select-bordered" x-model="form.PatIdt.IdentifierType">
<option value="">Select...</option>
<option value="KTP">KTP</option>
<option value="SIM">SIM</option>
<option value="Passport">Passport</option>
<option value="BPJS">BPJS</option>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">ID Number</span></label>
<input type="text" class="input input-bordered" x-model="form.PatIdt.Identifier">
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeFormModal()">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
<span x-show="isSubmitting" class="loading loading-spinner loading-sm"></span>
<span x-text="isSubmitting ? 'Saving...' : 'Save Patient'"></span>
</button>
</div>
</form>
</div>
</dialog>
</template>
<!-- Delete Confirmation Modal -->
<template x-teleport="body">
<dialog id="deleteModal" class="modal" :class="{ 'modal-open': showDeleteModal }">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete Patient</h3>
<p class="py-4">
Are you sure you want to delete patient
<span class="font-semibold" x-text="deleteTarget?.FullName"></span>?
This action cannot be undone.
</p>
<div class="modal-action">
<button class="btn" @click="showDeleteModal = false">Cancel</button>
<button
class="btn btn-error"
@click="deletePatient"
:disabled="isDeleting"
>
<span x-show="isDeleting" class="loading loading-spinner loading-sm"></span>
Delete
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="showDeleteModal = false">close</button>
</form>
</dialog>
</template>
</div>
<?= $this->endSection() ?>
<?= $this->section('script') ?>
<script type="module">
import Alpine, { Utils } from '<?= base_url('/assets/js/app.js'); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data('patientManager', () => ({
// List State
patients: [],
isLoading: false,
error: null,
hasSearched: false,
filters: {
Name: '',
PatientID: '',
Birthdate: ''
},
// Delete State
showDeleteModal: false,
deleteTarget: null,
isDeleting: false,
// Form/Modal State
showFormModal: false,
isEdit: false,
isSubmitting: false,
formError: null,
// Options Cache
options: {
gender: [],
marital: [],
religion: [],
ethnic: [],
country: [],
provinces: [],
cities: []
},
// Default Form Data
defaultForm: {
InternalPID: null,
PatientID: '',
Prefix: '',
NameFirst: '',
NameMiddle: '',
NameLast: '',
Suffix: '',
Gender: '',
Birthdate: '',
PlaceOfBirth: '',
MaritalStatus: '',
Religion: '',
Ethnic: '',
Country: '',
Race: '',
MobilePhone: '',
Phone: '',
EmailAddress1: '',
EmailAddress2: '',
Street_1: '',
Street_2: '',
Street_3: '',
Province: '',
City: '',
ZIP: '',
PatIdt: {
IdentifierType: '',
Identifier: ''
}
},
form: {}, // Initialized in init
init() {
this.form = JSON.parse(JSON.stringify(this.defaultForm));
this.loadOptions();
this.searchPatients(); // Initial load
},
// --- List Actions ---
async searchPatients() {
this.isLoading = true;
this.error = null;
this.hasSearched = true;
try {
const params = new URLSearchParams();
if (this.filters.Name) params.append('Name', this.filters.Name);
if (this.filters.PatientID) params.append('PatientID', this.filters.PatientID);
if (this.filters.Birthdate) params.append('Birthdate', this.filters.Birthdate);
const data = await Utils.api('<?= site_url('api/patient') ?>?' + params.toString());
this.patients = data.data || [];
// Re-initialize icons for new rows
setTimeout(() => {
if(window.lucide) window.lucide.createIcons();
}, 50);
} catch (e) {
this.error = e.message;
} finally {
this.isLoading = false;
}
},
clearFilters() {
this.filters = { Name: '', PatientID: '', Birthdate: '' };
this.searchPatients();
},
formatDate(dateStr) {
return Utils.formatDate(dateStr);
},
// --- Delete Actions ---
confirmDelete(patient) {
this.deleteTarget = patient;
this.showDeleteModal = true;
},
async deletePatient() {
if (!this.deleteTarget) return;
this.isDeleting = true;
try {
await Utils.api('<?= site_url('api/patient') ?>', {
method: 'DELETE',
body: JSON.stringify({ InternalPID: this.deleteTarget.InternalPID })
});
Alpine.store('toast').success('Patient deleted successfully');
this.showDeleteModal = false;
this.searchPatients();
} catch (e) {
Alpine.store('toast').error(e.message || 'Failed to delete patient');
} finally {
this.isDeleting = false;
}
},
// --- Form/Modal Actions ---
async loadOptions() {
try {
// Check if already loaded
if(this.options.gender.length > 0) return;
const [gender, marital, religion, ethnic, country, provinces] = await Promise.all([
Utils.api('<?= site_url('api/valueset/valuesetdef/1') ?>'),
Utils.api('<?= site_url('api/valueset/valuesetdef/2') ?>'),
Utils.api('<?= site_url('api/religion') ?>'),
Utils.api('<?= site_url('api/ethnic') ?>'),
Utils.api('<?= site_url('api/country') ?>'),
Utils.api('<?= site_url('api/areageo/provinces') ?>')
]);
this.options.gender = gender.data || [];
this.options.marital = marital.data || [];
this.options.religion = religion.data || [];
this.options.ethnic = ethnic.data || [];
this.options.country = country.data || [];
this.options.provinces = provinces.data || [];
} catch (e) {
console.error('Failed to load options', e);
Alpine.store('toast').error('Failed to load form options');
}
},
async loadCities() {
if (!this.form.Province) {
this.options.cities = [];
return;
}
try {
const data = await Utils.api('<?= site_url('api/areageo/cities') ?>?province=' + this.form.Province);
this.options.cities = data.data || [];
} catch (e) {
console.error(e);
}
},
openCreateModal() {
this.isEdit = false;
this.formError = null;
this.form = JSON.parse(JSON.stringify(this.defaultForm));
this.showFormModal = true;
},
async openEditModal(patient) {
this.isEdit = true;
this.formError = null;
// Fetch full details if needed, or use row data if sufficient.
// Row data might allow for quicker edit if all fields are present, but it's safer to fetch.
// Assuming row data is partial:
try {
const fullPatient = await Utils.api('<?= site_url('api/patient/') ?>' + patient.InternalPID);
this.mapPatientToForm(fullPatient.data || patient);
this.showFormModal = true;
} catch(e) {
Alpine.store('toast').error('Failed to load patient details');
}
},
mapPatientToForm(patient) {
// deep copy default first
const f = JSON.parse(JSON.stringify(this.defaultForm));
Object.keys(f).forEach(key => {
if (key === 'PatIdt') {
if (patient.PatIdt) f.PatIdt = patient.PatIdt;
} else if (patient[key] !== undefined && patient[key] !== null) {
if (patient[key + 'VID']) {
f[key] = patient[key + 'VID'];
} else if (key === 'Province' && patient.ProvinceID) {
f.Province = patient.ProvinceID;
} else if (key === 'City' && patient.CityID) {
f.City = patient.CityID;
} else {
f[key] = patient[key];
}
}
});
this.form = f;
if (this.form.Province) {
this.loadCities();
}
},
closeFormModal() {
this.showFormModal = false;
},
async submitForm() {
this.isSubmitting = true;
this.formError = null;
try {
const url = '<?= site_url('api/patient') ?>';
const method = this.isEdit ? 'PATCH' : 'POST';
// Clean up PatIdt if empty
const payload = { ...this.form };
if (!payload.PatIdt.IdentifierType || !payload.PatIdt.Identifier) {
delete payload.PatIdt;
}
await Utils.api(url, {
method: method,
body: JSON.stringify(payload)
});
Alpine.store('toast').success(this.isEdit ? 'Patient updated successfully' : 'Patient created successfully');
this.closeFormModal();
this.searchPatients();
} catch (e) {
this.formError = e.message;
Alpine.store('toast').error('Failed to save patient');
} finally {
this.isSubmitting = false;
}
}
}));
});
Alpine.start();
</script>
<?= $this->endSection() ?>