Complete overhaul of the valueset system to use human-readable names
instead of numeric IDs for improved maintainability and API consistency.
- PatientController: Renamed 'Gender' field to 'Sex' in validation rules
- ValuesetController: Changed API endpoints from ID-based (/:num) to name-based (/:any)
- TestsController: Refactored to use ValueSet library instead of direct valueset queries
- Added ValueSet library (app/Libraries/ValueSet.php) with static lookup methods:
- getOptions() - returns dropdown format [{value, label}]
- getLabel(, ) - returns label for a value
- transformLabels(, ) - batch transform records
- get() and getRaw() for Lookups compatibility
- Added ValueSetApiController for public valueset API endpoints
- Added ValueSet refresh endpoint (POST /api/valueset/refresh)
- Added DemoOrderController for testing order creation without auth
- 2026-01-12-000001: Convert valueset references from VID to VValue
- 2026-01-12-000002: Rename patient.Gender column to Sex
- OrderTestController: Now uses OrderTestModel with proper model pattern
- TestsController: Uses ValueSet library for all lookup operations
- ValueSetController: Simplified to use name-based lookups
- Updated all organization (account/site/workstation) dialogs and index views
- Updated specimen container dialogs and index views
- Updated tests_index.php with ValueSet integration
- Updated patient dialog form and index views
- Removed .factory/config.json and CLAUDE.md (replaced by AGENTS.md)
- Consolidated lookups in Lookups.php (removed inline valueset constants)
- Updated all test files to match new field names
- 32 modified files, 17 new files, 2 deleted files
- Net: +661 insertions, -1443 deletions (significant cleanup)
447 lines
14 KiB
PHP
447 lines
14 KiB
PHP
<?= $this->extend("v2/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 group hover:shadow-xl transition-all">
|
|
<div class="p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Total Patients</p>
|
|
<p class="text-3xl font-bold" style="color: rgb(var(--color-text));" x-text="stats.total">0</p>
|
|
</div>
|
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform" style="background: rgba(var(--color-primary), 0.15);">
|
|
<i class="fa-solid fa-users text-2xl" style="color: rgb(var(--color-primary));"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New Today -->
|
|
<div class="card group hover:shadow-xl transition-all">
|
|
<div class="p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">New Today</p>
|
|
<p class="text-3xl font-bold text-emerald-500" x-text="stats.newToday">0</p>
|
|
</div>
|
|
<div class="w-14 h-14 rounded-2xl bg-emerald-500/15 flex items-center justify-center group-hover:scale-110 transition-transform">
|
|
<i class="fa-solid fa-user-plus text-emerald-500 text-2xl"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pending -->
|
|
<div class="card group hover:shadow-xl transition-all">
|
|
<div class="p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Pending Visits</p>
|
|
<p class="text-3xl font-bold text-amber-500" x-text="stats.pending">0</p>
|
|
</div>
|
|
<div class="w-14 h-14 rounded-2xl bg-amber-500/15 flex items-center justify-center group-hover:scale-110 transition-transform">
|
|
<i class="fa-solid fa-clock text-amber-500 text-2xl"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search & Actions Bar -->
|
|
<div class="card mb-6">
|
|
<div class="p-4">
|
|
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
|
<!-- Search -->
|
|
<div class="flex w-full sm:w-auto gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name, ID, phone..."
|
|
class="input flex-1 sm:w-80"
|
|
x-model="keyword"
|
|
@keyup.enter="fetchList()"
|
|
/>
|
|
<button class="btn btn-primary" @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 overflow-hidden">
|
|
<!-- Loading State -->
|
|
<div x-show="loading" class="p-12 text-center" x-cloak>
|
|
<div class="spinner spinner-lg mx-auto mb-4"></div>
|
|
<p style="color: rgb(var(--color-text-muted));">Loading patients...</p>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="overflow-x-auto" x-show="!loading">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Patient ID</th>
|
|
<th>Name</th>
|
|
<th>Sex</th>
|
|
<th>Birth Date</th>
|
|
<th>Phone</th>
|
|
<th class="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-3" style="color: rgb(var(--color-text-muted));">
|
|
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
|
|
<p class="text-lg">No patients found</p>
|
|
<button class="btn btn-primary btn-sm 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="cursor-pointer hover:bg-opacity-50" @click="viewPatient(patient.InternalPID)">
|
|
<td>
|
|
<span class="badge badge-ghost font-mono text-xs" x-text="patient.PatientID || '-'"></span>
|
|
</td>
|
|
<td>
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold bg-gradient-to-br from-blue-600 to-blue-900">
|
|
<span class="text-sm" x-text="(patient.NameFirst || '?')[0].toUpperCase()"></span>
|
|
</div>
|
|
<div>
|
|
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="(patient.NameFirst || '') + ' ' + (patient.NameLast || '')"></div>
|
|
<div class="text-xs" style="color: rgb(var(--color-text-muted));" x-text="patient.EmailAddress1 || ''"></div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span
|
|
class="badge badge-sm"
|
|
:class="patient.Sex === 'M' ? 'badge-info' : patient.Sex === 'F' ? 'badge-secondary' : 'badge-ghost'"
|
|
x-text="patient.SexText || patient.Sex || '-'"
|
|
></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-sky-500"></i>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(patient)" title="Delete">
|
|
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));" x-show="list && list.length > 0">
|
|
<span class="text-sm" style="color: rgb(var(--color-text-muted));" x-text="'Showing ' + list.length + ' patients'"></span>
|
|
<div class="flex gap-1">
|
|
<button class="btn btn-ghost btn-sm">«</button>
|
|
<button class="btn btn-primary btn-sm">1</button>
|
|
<button class="btn btn-ghost btn-sm">2</button>
|
|
<button class="btn btn-ghost btn-sm">3</button>
|
|
<button class="btn btn-ghost btn-sm">»</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Include Form Dialog -->
|
|
<?= $this->include('v2/patients/dialog_form') ?>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
<div
|
|
x-show="showDeleteModal"
|
|
x-cloak
|
|
class="modal-overlay"
|
|
@click.self="showDeleteModal = false"
|
|
>
|
|
<div class="modal-content p-6 max-w-md">
|
|
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
|
|
<i class="fa-solid fa-exclamation-triangle"></i>
|
|
Confirm Delete
|
|
</h3>
|
|
<p class="mb-6" style="color: rgb(var(--color-text));">
|
|
Are you sure you want to delete patient <strong x-text="deleteTarget?.PatientID"></strong>?
|
|
This action cannot be undone.
|
|
</p>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
|
|
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deletePatient()" :disabled="deleting">
|
|
<span x-show="deleting" class="spinner spinner-sm"></span>
|
|
<span x-show="!deleting">Delete</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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: "",
|
|
Sex: "M",
|
|
Birthdate: "",
|
|
MobilePhone: "",
|
|
EmailAddress1: "",
|
|
Street_1: "",
|
|
City: "",
|
|
Province: "",
|
|
ZIP: ""
|
|
},
|
|
|
|
// Delete Modal
|
|
showDeleteModal: false,
|
|
deleteTarget: null,
|
|
deleting: false,
|
|
|
|
// Lookup Options
|
|
sexOptions: [],
|
|
|
|
// Lifecycle
|
|
async init() {
|
|
await this.fetchList();
|
|
await this.fetchSexOptions();
|
|
},
|
|
|
|
// Fetch sex options from valueset
|
|
async fetchSexOptions() {
|
|
try {
|
|
const res = await fetch(`${BASEURL}api/valueset/gender`, {
|
|
credentials: 'include'
|
|
});
|
|
if (!res.ok) throw new Error("HTTP error");
|
|
const data = await res.json();
|
|
this.sexOptions = data.data || [];
|
|
} catch (err) {
|
|
console.error('Failed to fetch sex options:', err);
|
|
this.sexOptions = [
|
|
{ key: 'M', value: 'Male' },
|
|
{ key: 'F', value: 'Female' }
|
|
];
|
|
}
|
|
},
|
|
|
|
// 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}`, {
|
|
credentials: 'include'
|
|
});
|
|
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: "",
|
|
Sex: "M",
|
|
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}`, {
|
|
credentials: 'include'
|
|
});
|
|
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),
|
|
credentials: 'include'
|
|
});
|
|
} else {
|
|
res = await fetch(`${BASEURL}api/patient`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this.form),
|
|
credentials: 'include'
|
|
});
|
|
}
|
|
|
|
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 }),
|
|
credentials: 'include'
|
|
});
|
|
|
|
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() ?>
|