clqms-be/app/Views/patients/patients_index.php
mahdahar cb4181dbff refactor: restructure application architecture and consolidate controllers
- Consolidate page controllers into unified PagesController
- Remove deprecated V2 pages, layouts, and controllers (AuthPage, DashboardPage, V2Page)
- Add Edge resource with migration and model (EdgeResModel)
- Implement new main_layout.php for consistent page structure
- Reorganize patient views into dedicated module with dialog form
- Update routing configuration in Routes.php
- Enhance AuthFilter for improved authentication handling
- Clean up unused V2 assets (CSS, JS) and legacy images
- Update README.md with latest project information

This refactoring improves code organization, removes technical debt, and
establishes a cleaner foundation for future development.
2025-12-29 16:57:46 +07:00

415 lines
13 KiB
PHP

<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="patients()" x-init="init()">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Total Patients -->
<div class="card bg-base-100 shadow-sm border border-base-content/10">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/60">Total Patients</p>
<p class="text-2xl font-bold" x-text="stats.total">0</p>
</div>
<div class="w-12 h-12 bg-primary/20 rounded-full flex items-center justify-center">
<i class="fa-solid fa-users text-primary text-xl"></i>
</div>
</div>
</div>
</div>
<!-- New Today -->
<div class="card bg-base-100 shadow-sm border border-base-content/10">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/60">New Today</p>
<p class="text-2xl font-bold text-success" x-text="stats.newToday">0</p>
</div>
<div class="w-12 h-12 bg-success/20 rounded-full flex items-center justify-center">
<i class="fa-solid fa-user-plus text-success text-xl"></i>
</div>
</div>
</div>
</div>
<!-- Pending -->
<div class="card bg-base-100 shadow-sm border border-base-content/10">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/60">Pending Visits</p>
<p class="text-2xl font-bold text-warning" x-text="stats.pending">0</p>
</div>
<div class="w-12 h-12 bg-warning/20 rounded-full flex items-center justify-center">
<i class="fa-solid fa-clock text-warning text-xl"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card bg-base-100 shadow-sm border border-base-content/10 mb-6">
<div class="card-body p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<!-- Search -->
<div class="join w-full sm:w-auto">
<input
type="text"
placeholder="Search by name, ID, phone..."
class="input input-bordered join-item w-full sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary join-item" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<!-- Actions -->
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i> New Patient
</button>
</div>
</div>
</div>
<!-- Patient List Table -->
<div class="card bg-base-100 shadow-sm border border-base-content/10 overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-8 text-center" x-cloak>
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-2 text-base-content/60">Loading patients...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table table-zebra">
<thead class="bg-base-200">
<tr>
<th class="font-semibold">Patient ID</th>
<th class="font-semibold">Name</th>
<th class="font-semibold">Gender</th>
<th class="font-semibold">Birth Date</th>
<th class="font-semibold">Phone</th>
<th class="font-semibold text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-2 text-base-content/40">
<i class="fa-solid fa-inbox text-4xl"></i>
<p>No patients found</p>
<button class="btn btn-sm btn-primary mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Patient
</button>
</div>
</td>
</tr>
</template>
<!-- Patient Rows -->
<template x-for="patient in list" :key="patient.InternalPID">
<tr class="hover:bg-base-200/50 cursor-pointer" @click="viewPatient(patient.InternalPID)">
<td>
<span class="badge badge-ghost font-mono" x-text="patient.PatientID || '-'"></span>
</td>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary/20 text-primary rounded-full w-10">
<span x-text="(patient.NameFirst || '?')[0].toUpperCase()"></span>
</div>
</div>
<div>
<div class="font-medium" x-text="(patient.NameFirst || '') + ' ' + (patient.NameLast || '')"></div>
<div class="text-xs text-base-content/50" x-text="patient.EmailAddress1 || ''"></div>
</div>
</div>
</td>
<td>
<span
class="badge badge-sm"
:class="patient.Gender == 1 ? 'badge-info' : 'badge-secondary'"
x-text="patient.Gender == 1 ? 'Male' : patient.Gender == 2 ? 'Female' : '-'"
></span>
</td>
<td x-text="formatDate(patient.Birthdate)"></td>
<td x-text="patient.MobilePhone || patient.Phone || '-'"></td>
<td class="text-center" @click.stop>
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editPatient(patient.InternalPID)" title="Edit">
<i class="fa-solid fa-pen text-info"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(patient)" title="Delete">
<i class="fa-solid fa-trash text-error"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination (placeholder) -->
<div class="p-4 border-t border-base-content/10 flex items-center justify-between" x-show="list && list.length > 0">
<span class="text-sm text-base-content/60" x-text="'Showing ' + list.length + ' patients'"></span>
<div class="join">
<button class="join-item btn btn-sm">«</button>
<button class="join-item btn btn-sm btn-active">1</button>
<button class="join-item btn btn-sm">2</button>
<button class="join-item btn btn-sm">3</button>
<button class="join-item btn btn-sm">»</button>
</div>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('patients/dialog_form') ?>
<!-- Delete Confirmation Dialog -->
<dialog id="delete_modal" class="modal" :class="showDeleteModal && 'modal-open'">
<div class="modal-box">
<h3 class="font-bold text-lg text-error">
<i class="fa-solid fa-exclamation-triangle mr-2"></i> Confirm Delete
</h3>
<p class="py-4">
Are you sure you want to delete patient <strong x-text="deleteTarget?.PatientID"></strong>?
This action cannot be undone.
</p>
<div class="modal-action">
<button class="btn btn-ghost" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-error" @click="deletePatient()" :disabled="deleting">
<span x-show="deleting" class="loading loading-spinner loading-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
<div class="modal-backdrop bg-black/50" @click="showDeleteModal = false"></div>
</dialog>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function patients() {
return {
// State
loading: false,
list: [],
keyword: "",
// Stats
stats: {
total: 0,
newToday: 0,
pending: 0
},
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
InternalPID: null,
PatientID: "",
NameFirst: "",
NameMiddle: "",
NameLast: "",
Gender: 1,
Birthdate: "",
MobilePhone: "",
EmailAddress1: "",
Street_1: "",
City: "",
Province: "",
ZIP: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
},
// Fetch patient list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/patient?${params}`);
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
this.stats.total = this.list.length;
// Calculate new today (simplified - you may want server-side)
const today = new Date().toISOString().split('T')[0];
this.stats.newToday = this.list.filter(p => p.CreateDate && p.CreateDate.startsWith(today)).length;
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Format date
formatDate(dateStr) {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return dateStr;
}
},
// View patient details
viewPatient(id) {
// Could navigate to detail page or open drawer
console.log('View patient:', id);
},
// Show form for new patient
showForm() {
this.isEditing = false;
this.form = {
InternalPID: null,
PatientID: "",
NameFirst: "",
NameMiddle: "",
NameLast: "",
Gender: 1,
Birthdate: "",
MobilePhone: "",
EmailAddress1: "",
Street_1: "",
City: "",
Province: "",
ZIP: ""
};
this.errors = {};
this.showModal = true;
},
// Edit patient
async editPatient(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/patient/${id}`);
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load patient');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.NameFirst?.trim()) e.NameFirst = "First name is required";
if (!this.form.NameLast?.trim()) e.NameLast = "Last name is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save patient
async save() {
if (!this.validate()) return;
this.saving = true;
try {
let res;
if (this.isEditing && this.form.InternalPID) {
res = await fetch(`${BASEURL}api/patient`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
} else {
res = await fetch(`${BASEURL}api/patient`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
}
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save patient");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(patient) {
this.deleteTarget = patient;
this.showDeleteModal = true;
},
// Delete patient
async deletePatient() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/patient`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InternalPID: this.deleteTarget.InternalPID })
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete patient");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>