Add patient immunization module

This commit is contained in:
DevSoftTech Server 2026-07-19 00:41:12 +05:30
parent 629590d9ab
commit 82fd31d105
14 changed files with 2468 additions and 0 deletions

View File

@ -683,6 +683,15 @@ $routes->post('AbdmGateway/share_diagnosis_report_bundle', 'AbdmGateway::shareDi
$routes->post('AbdmGateway/shareDiagnosisReportBundle', 'AbdmGateway::shareDiagnosisReportBundle', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/diagnosis_report_fhir_preview', 'AbdmGateway::diagnosisReportFhirPreview', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/ipd_discharge_fhir_preview', 'AbdmGateway::ipdDischargeFhirPreview', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/immunization_fhir_preview', 'AbdmGateway::immunizationFhirPreview', ['filter' => $abdmPermFilter]);
$routes->post('AbdmGateway/share_immunization_bundle', 'AbdmGateway::shareImmunizationBundle', ['filter' => $abdmPermFilter]);
$routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/update_schedule/(:num)', 'Immunization::updateSchedule/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization/schedule', 'Immunization::schedule', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization/patient/(:num)', 'Immunization::patient/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/generate_patient_schedule/(:num)', 'Immunization::generatePatientSchedule/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/complete/(:num)', 'Immunization::complete/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('AbdmGateway/push_health_record', 'AbdmGateway::pushHealthRecord', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/health_records_list', 'AbdmGateway::healthRecordsList', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/bridge_record_status/(:num)', 'AbdmGateway::bridgeRecordStatus/$1', ['filter' => $abdmPermFilter]);
@ -767,6 +776,7 @@ $routes->get('Opd_prescription/section_template_list', 'Opd_prescription::sectio
$routes->get('Opd_prescription/section_past_data', 'Opd_prescription::section_past_data');
$routes->get('Opd_prescription/template_workspace', 'Opd_prescription::template_workspace', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']);
$routes->get('doctor_work/document_workspace', 'DoctorDocument::workspace', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']);
$routes->get('doctor_work/patient_search', 'DoctorDocument::patient_search', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']);
// Legacy-compatible doctor document templates and issue flow.
$routes->get('Doc_Admin/doc_list', 'DoctorDocument::doc_list', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']);

View File

@ -2020,6 +2020,174 @@ class AbdmGateway extends BaseController
]);
}
public function immunizationFhirPreview()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(403)->setJSON(['status' => 'error', 'message' => 'AJAX only']);
}
$patientId = (int) ($this->request->getGet('patient_id') ?? $this->request->getPost('patient_id') ?? 0);
$recordId = (int) ($this->request->getGet('record_id') ?? $this->request->getPost('record_id') ?? 0);
$abhaId = trim((string) ($this->request->getGet('abha_id') ?? $this->request->getPost('abha_id') ?? ''));
if ($patientId <= 0) {
return $this->response->setStatusCode(400)->setJSON([
'status' => 'error',
'message' => 'patient_id is required.',
]);
}
$payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId);
if ($payload === null) {
return $this->response->setStatusCode(404)->setJSON([
'status' => 'error',
'message' => 'Unable to prepare ImmunizationRecord FHIR payload.',
]);
}
return $this->response->setJSON([
'status' => 'ok',
'patient_id' => $patientId,
'record_id' => $recordId > 0 ? $recordId : null,
'abha_id' => $abhaId,
'care_context_reference' => (string) ($payload['care_context_reference'] ?? ''),
'bundle' => (array) ($payload['bundle'] ?? []),
]);
}
public function shareImmunizationBundle()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$patientId = (int) $this->request->getPost('patient_id');
$recordId = (int) $this->request->getPost('record_id');
$abhaId = trim((string) $this->request->getPost('abha_id'));
$abhaAddressPost = trim((string) $this->request->getPost('abha_address'));
$consentHandle = trim((string) $this->request->getPost('consent_handle'));
$pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1;
if ($abhaId === '' && $abhaAddressPost !== '') {
$abhaId = $abhaAddressPost;
}
if ($patientId <= 0) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']);
}
$payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId);
if ($payload === null) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Unable to prepare ImmunizationRecord FHIR payload']);
}
$bundle = (array) ($payload['bundle'] ?? []);
$bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ccRef = (string) ($payload['care_context_reference'] ?? ('IMM-' . $patientId . '-' . date('Y-m-d')));
$visitDate = (string) ($payload['visit_date'] ?? date('Y-m-d'));
$patientName = (string) ($payload['patient_name'] ?? ('PATIENT-' . $patientId));
$consent = $abhaId !== '' ? $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle) : null;
$effectiveConsent = (string) ($consent['consent_handle'] ?? $consentHandle);
$healthRecordId = $this->storeHealthRecord([
'patient_id' => $patientId,
'abha_id' => $abhaId,
'hi_type' => 'ImmunizationRecord',
'entity_type' => 'immunization',
'entity_id' => $recordId > 0 ? (string) $recordId : (string) $patientId,
'fhir_bundle' => $bundleJson,
'care_context_reference' => $ccRef,
'consent_handle' => $effectiveConsent,
]);
if ($abhaId === '') {
if ($healthRecordId > 0 && $this->db->tableExists('health_records')) {
$this->db->table('health_records')->where('id', $healthRecordId)->update([
'push_status' => 'local_only',
'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
]);
}
return $this->response->setJSON([
'ok' => 1,
'status' => 'local_stored',
'health_record_id' => $healthRecordId,
'care_context_reference' => $ccRef,
'message' => 'ABHA not available. Immunization record stored locally for later discovery.',
]);
}
if (! $pushToGateway) {
if ($healthRecordId > 0 && $this->db->tableExists('health_records')) {
$this->db->table('health_records')->where('id', $healthRecordId)->update([
'push_status' => 'local_discovery_ready',
'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
]);
}
return $this->response->setJSON([
'ok' => 1,
'status' => 'local_discovery_ready',
'health_record_id' => $healthRecordId,
'care_context_reference' => $ccRef,
'consent_handle' => $effectiveConsent,
'mode' => 'm2_hms_source',
'message' => 'Immunization record registered in HMS for ABDM discovery/fetch callbacks.',
]);
}
$queueId = null;
$bridgeRecordId = 0;
$connectorError = null;
try {
$result = $this->connector->pushRecord([
'patient_id' => (string) $patientId,
'patient_name' => $patientName,
'abha_id' => preg_match('/^\d{14}$/', $abhaId) === 1 ? $abhaId : '',
'abha_address' => str_contains($abhaId, '@') ? $abhaId : '',
'hi_type' => 'ImmunizationRecord',
'record_type' => 'ImmunizationRecord',
'visit_date' => $visitDate,
'care_context_reference' => $ccRef,
'care_context_display' => 'Immunization Record - ' . $visitDate,
'notes' => 'Immunization Record - ' . $visitDate,
'queue_id' => $ccRef,
'record_data' => $bundle,
]);
$queueId = (string) ($result['queue_id'] ?? '');
$bridgeRecordId = (int) ($result['id'] ?? 0);
} catch (\Throwable $e) {
$connectorError = $e->getMessage();
}
if ($healthRecordId > 0) {
$this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError, $bridgeRecordId);
}
$this->getAuditService()->log([
'action' => 'push_record',
'entity_type' => 'immunization',
'entity_id' => $recordId > 0 ? (string) $recordId : (string) $patientId,
'abha_id' => $abhaId,
'patient_id' => $patientId,
'request' => ['patient_id' => $patientId, 'record_id' => $recordId, 'hi_type' => 'ImmunizationRecord'],
'response' => ['queue_id' => $queueId],
'outcome' => $connectorError === null ? 'success' : 'failure',
'error_message' => (string) ($connectorError ?? ''),
]);
return $this->response->setJSON([
'ok' => $connectorError === null ? 1 : 0,
'queue_id' => $queueId,
'bridge_record_id' => $bridgeRecordId > 0 ? $bridgeRecordId : null,
'health_record_id' => $healthRecordId,
'consent_handle' => $effectiveConsent,
'care_context_reference' => $ccRef,
'status' => $connectorError === null ? 'queued' : 'failed',
'error' => $connectorError,
]);
}
public function ipdDischargeFhirPreview()
{
if (! $this->request->isAJAX()) {
@ -2372,6 +2540,120 @@ class AbdmGateway extends BaseController
];
}
/**
* @return array<string,mixed>|null
*/
private function buildImmunizationGatewayPayload(int $patientId, int $recordId = 0, string $preferredAbhaId = ''): ?array
{
if ($patientId <= 0 || ! $this->db->tableExists('patient_master') || ! $this->db->tableExists('immunization_records')) {
return null;
}
$patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? [];
if (empty($patientRow)) {
return null;
}
$recordsBuilder = $this->db->table('immunization_records r')
->select('r.*, s.series_name, s.series_doses, s.age_label, v.target_disease_code, v.target_disease_name')
->join('immunization_schedule_master s', 's.id = r.schedule_id', 'left')
->join('immunization_vaccine_master v', 'v.id = r.vaccine_master_id', 'left')
->where('r.patient_id', $patientId)
->orderBy('COALESCE(r.given_date, r.due_date)', 'ASC', false)
->orderBy('r.id', 'ASC');
if ($recordId > 0) {
$recordsBuilder->where('r.id', $recordId);
}
$records = $recordsBuilder->get()->getResultArray();
if (empty($records)) {
return null;
}
$patientName = trim(trim((string) ($patientRow['p_fname'] ?? '')) . ' ' . trim((string) ($patientRow['p_lname'] ?? '')));
$rawAbha = trim($preferredAbhaId);
if ($rawAbha === '') {
foreach (['abha_address', 'abha_id', 'abha_no', 'abha'] as $field) {
$candidate = trim((string) ($patientRow[$field] ?? ''));
if ($candidate !== '') {
$rawAbha = $candidate;
break;
}
}
}
$hospital = $this->getHospitalProfileForFhir();
$practitioner = null;
$performerId = 0;
foreach ($records as $record) {
$performerId = (int) ($record['performer_id'] ?? 0);
if ($performerId > 0) {
break;
}
}
if ($performerId > 0 && $this->db->tableExists('doctor_master')) {
$doctorRow = $this->db->table('doctor_master')->where('id', $performerId)->get(1)->getRowArray() ?? [];
if (! empty($doctorRow)) {
$doctorName = trim(trim((string) ($doctorRow['p_fname'] ?? '')) . ' ' . trim((string) ($doctorRow['p_lname'] ?? '')));
$doctorRegNo = '';
foreach (['doctor_reg_no', 'registration_no', 'reg_no'] as $field) {
$candidate = trim((string) ($doctorRow[$field] ?? ''));
if ($candidate !== '') {
$doctorRegNo = $candidate;
break;
}
}
$practitioner = ['id' => (string) $performerId, 'name' => $doctorName, 'registration_number' => $doctorRegNo];
}
}
$latestDate = '';
foreach ($records as $record) {
$candidate = trim((string) ($record['given_date'] ?? $record['due_date'] ?? ''));
if ($candidate !== '' && ($latestDate === '' || strtotime($candidate) > strtotime($latestDate))) {
$latestDate = $candidate;
}
}
$visitDate = $latestDate !== '' ? date('Y-m-d', strtotime($latestDate)) : date('Y-m-d');
$ccRef = $recordId > 0 ? ('IMM-' . $recordId . '-' . $visitDate) : ('IMM-' . $patientId . '-' . $visitDate);
$birthDate = '';
foreach (['dob', 'birth_date', 'date_of_birth', 'p_dob'] as $field) {
$candidate = trim((string) ($patientRow[$field] ?? ''));
if ($candidate !== '' && strtotime($candidate) !== false) {
$birthDate = date('Y-m-d', strtotime($candidate));
break;
}
}
$patient = [
'id' => (string) $patientId,
'name' => $patientName !== '' ? $patientName : ('PATIENT-' . $patientId),
'gender' => (string) ($patientRow['gender'] ?? $patientRow['xgender'] ?? ''),
'birthDate' => $birthDate,
'abhaAddress' => $rawAbha,
'phone' => (string) ($patientRow['mphone1'] ?? ''),
];
$fhir = new FhirR4Builder();
$bundle = $fhir->buildImmunizationRecordBundle($patient, $records, [
'practitioner' => $practitioner,
'organization' => ['name' => (string) ($hospital['name'] ?? ''), 'hfr_id' => (string) ($hospital['hfr_id'] ?? '')],
'encounter' => ['id' => $recordId > 0 ? ('IMM-' . $recordId) : ('IMM-' . $patientId), 'status' => 'finished', 'period_start' => $latestDate],
'care_context_reference' => $ccRef,
]);
return [
'bundle' => $bundle,
'care_context_reference' => $ccRef,
'care_context_display' => 'Immunization Record - ' . $visitDate,
'patient_name' => $patient['name'],
'visit_date' => $visitDate,
'records' => $records,
];
}
/**
* @param array<int,string> $columns
* @return array<int,array<string,mixed>>
@ -3979,6 +4261,7 @@ class AbdmGateway extends BaseController
'IPD' => 'HealthDocumentRecord',
'LAB' => 'DiagnosticReportRecord',
'DISCHARGE' => 'DischargeSummaryRecord',
'IMMUNIZATION' => 'ImmunizationRecord',
'MLC' => 'HealthDocumentRecord',
default => 'HealthDocumentRecord',
};
@ -4179,6 +4462,9 @@ class AbdmGateway extends BaseController
if (str_contains($v, 'discharge')) {
return 'DISCHARGE';
}
if (str_contains($v, 'immunization')) {
return 'IMMUNIZATION';
}
if ($entityType === 'ipd') {
return 'IPD';
}

View File

@ -0,0 +1,340 @@
<?php
namespace App\Controllers;
use App\Libraries\ImmunizationScheduleService;
class Immunization extends BaseController
{
public function index()
{
return view('immunization/index', [
'page_title' => 'Immunization Record',
'patient_id' => (int) ($this->request->getGet('patient_id') ?? 0),
]);
}
public function schedule()
{
if (! $this->db->tableExists('immunization_schedule_master') || ! $this->db->tableExists('immunization_vaccine_master')) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => 'Immunization tables not found. Run migrations first.',
]);
}
$rows = $this->db->table('immunization_schedule_master s')
->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.target_disease_name, v.route_name, v.site_name')
->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'left')
->where('s.is_active', 1)
->orderBy('s.sort_order', 'ASC')
->get()
->getResultArray();
return $this->response->setJSON([
'ok' => 1,
'count' => count($rows),
'items' => $rows,
]);
}
public function scheduleMaster()
{
return view('immunization/schedule_master', [
'page_title' => 'UIP Schedule Master',
'items' => $this->loadScheduleRows(true),
]);
}
public function updateSchedule(int $scheduleId = 0)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
if ($scheduleId <= 0 || ! $this->db->tableExists('immunization_schedule_master') || ! $this->db->tableExists('immunization_vaccine_master')) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid schedule record']);
}
$schedule = $this->db->table('immunization_schedule_master')->where('id', $scheduleId)->get(1)->getRowArray();
if (empty($schedule)) {
return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Schedule not found']);
}
$now = date('Y-m-d H:i:s');
$ageLabel = $this->nullablePost('age_label') ?? (string) ($schedule['age_label'] ?? '');
$doseNumber = $this->nullablePost('dose_number') ?? (string) ($schedule['dose_number'] ?? '');
$notes = $this->nullablePost('notes');
$scheduleUpdate = [
'age_label' => $ageLabel,
'age_value' => max(0, (int) ($this->request->getPost('age_value') ?? ($schedule['age_value'] ?? 0))),
'age_unit' => $this->nullablePost('age_unit') ?? (string) ($schedule['age_unit'] ?? 'days'),
'age_offset_days' => max(0, (int) ($this->request->getPost('age_offset_days') ?? ($schedule['age_offset_days'] ?? 0))),
'dose_number' => $doseNumber,
'series_doses' => $this->nullablePost('series_doses'),
'notes' => $notes,
'sort_order' => max(0, (int) ($this->request->getPost('sort_order') ?? ($schedule['sort_order'] ?? 0))),
'is_active' => (int) ($this->request->getPost('is_active') ?? 0) === 1 ? 1 : 0,
'updated_at' => $now,
];
$vaccineUpdate = [
'vaccine_name' => $this->nullablePost('vaccine_name') ?? '',
'target_disease_name' => $this->nullablePost('target_disease_name'),
'route_name' => $this->nullablePost('route_name'),
'site_name' => $this->nullablePost('site_name'),
'updated_at' => $now,
];
if ($vaccineUpdate['vaccine_name'] === '') {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Vaccine name is required']);
}
$this->db->table('immunization_schedule_master')->where('id', $scheduleId)->update($scheduleUpdate);
$vaccineId = (int) ($schedule['vaccine_master_id'] ?? 0);
if ($vaccineId > 0) {
$this->db->table('immunization_vaccine_master')->where('id', $vaccineId)->update($vaccineUpdate);
}
if ($this->db->tableExists('immunization_records')) {
$this->db->table('immunization_records')
->where('schedule_id', $scheduleId)
->where('status !=', 'completed')
->update([
'vaccine_name' => $vaccineUpdate['vaccine_name'],
'dose_number' => $doseNumber,
'route_name' => $vaccineUpdate['route_name'],
'site_name' => $vaccineUpdate['site_name'],
'notes' => $notes,
'updated_at' => $now,
]);
}
return $this->response->setJSON([
'ok' => 1,
'item' => $this->loadScheduleRow($scheduleId),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function patient(int $patientId = 0)
{
if ($patientId <= 0) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid patient_id']);
}
foreach (['patient_master', 'immunization_records'] as $table) {
if (! $this->db->tableExists($table)) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $table . ' table not found']);
}
}
$patient = $this->loadPatientHeader($patientId);
if (empty($patient)) {
return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Patient not found']);
}
$rows = $this->db->table('immunization_records r')
->select('r.*, s.age_label, s.age_offset_days, s.series_name, s.series_doses, v.target_disease_name')
->join('immunization_schedule_master s', 's.id = r.schedule_id', 'left')
->join('immunization_vaccine_master v', 'v.id = r.vaccine_master_id', 'left')
->where('r.patient_id', $patientId)
->orderBy('COALESCE(r.due_date, r.given_date)', 'ASC', false)
->orderBy('r.id', 'ASC')
->get()
->getResultArray();
return $this->response->setJSON([
'ok' => 1,
'patient' => $patient,
'summary' => $this->buildSummary($rows),
'count' => count($rows),
'items' => $rows,
]);
}
public function generatePatientSchedule(int $patientId = 0)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$result = (new ImmunizationScheduleService($this->db))->generateUipDueRecordsForPatient($patientId, [
'generation_mode' => (string) ($this->request->getPost('generation_mode') ?? 'full'),
]);
$httpCode = (int) ($result['ok'] ?? 0) === 1 ? 200 : 400;
return $this->response->setStatusCode($httpCode)->setJSON($result + [
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function complete(int $recordId = 0)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
if ($recordId <= 0 || ! $this->db->tableExists('immunization_records')) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid immunization record']);
}
$record = $this->db->table('immunization_records')->where('id', $recordId)->get(1)->getRowArray();
if (empty($record)) {
return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Immunization record not found']);
}
$givenDate = $this->normalizeDateTime((string) ($this->request->getPost('given_date') ?? ''));
if ($givenDate === null) {
$givenDate = date('Y-m-d H:i:s');
}
$expiryDate = $this->normalizeDate((string) ($this->request->getPost('expiry_date') ?? ''));
$user = function_exists('auth') ? auth()->user() : null;
$userId = (int) ($user->id ?? 0);
$update = [
'given_date' => $givenDate,
'status' => 'completed',
'lot_number' => $this->nullablePost('lot_number'),
'expiry_date' => $expiryDate,
'manufacturer' => $this->nullablePost('manufacturer'),
'performer_id' => (int) ($this->request->getPost('performer_id') ?? 0) ?: null,
'location_name' => $this->nullablePost('location_name'),
'route_code' => $this->nullablePost('route_code') ?? (string) ($record['route_code'] ?? ''),
'route_name' => $this->nullablePost('route_name') ?? (string) ($record['route_name'] ?? ''),
'site_code' => $this->nullablePost('site_code') ?? (string) ($record['site_code'] ?? ''),
'site_name' => $this->nullablePost('site_name') ?? (string) ($record['site_name'] ?? ''),
'reaction_notes' => $this->nullablePost('reaction_notes'),
'notes' => $this->nullablePost('notes') ?? (string) ($record['notes'] ?? ''),
'updated_by' => $userId > 0 ? $userId : null,
'updated_at' => date('Y-m-d H:i:s'),
];
$this->db->table('immunization_records')->where('id', $recordId)->update($update);
return $this->response->setJSON([
'ok' => 1,
'record_id' => $recordId,
'status' => 'completed',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
/**
* @return array<string,mixed>
*/
private function loadPatientHeader(int $patientId): array
{
$fields = $this->db->getFieldNames('patient_master') ?? [];
$select = ['id'];
foreach (['p_code', 'p_fname', 'p_lname', 'gender', 'dob', 'age', 'age_in_month', 'mphone1', 'abha_id', 'abha_no', 'abha_address', 'abha'] as $field) {
if (in_array($field, $fields, true)) {
$select[] = $field;
}
}
return $this->db->table('patient_master')
->select(implode(',', array_unique($select)))
->where('id', $patientId)
->get(1)
->getRowArray() ?? [];
}
/**
* @return array<int,array<string,mixed>>
*/
private function loadScheduleRows(bool $includeInactive = false): array
{
if (! $this->db->tableExists('immunization_schedule_master') || ! $this->db->tableExists('immunization_vaccine_master')) {
return [];
}
$builder = $this->db->table('immunization_schedule_master s')
->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.target_disease_name, v.route_name, v.site_name')
->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'left')
->orderBy('s.sort_order', 'ASC')
->orderBy('s.id', 'ASC');
if (! $includeInactive) {
$builder->where('s.is_active', 1);
}
return $builder->get()->getResultArray();
}
/**
* @return array<string,mixed>
*/
private function loadScheduleRow(int $scheduleId): array
{
if ($scheduleId <= 0) {
return [];
}
return $this->db->table('immunization_schedule_master s')
->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.target_disease_name, v.route_name, v.site_name')
->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'left')
->where('s.id', $scheduleId)
->get(1)
->getRowArray() ?? [];
}
/**
* @param array<int,array<string,mixed>> $rows
* @return array<string,int>
*/
private function buildSummary(array $rows): array
{
$summary = [
'total' => count($rows),
'due' => 0,
'overdue' => 0,
'completed' => 0,
'missed' => 0,
'postponed' => 0,
];
$today = date('Y-m-d');
foreach ($rows as $row) {
$status = strtolower(trim((string) ($row['status'] ?? 'due')));
if (isset($summary[$status])) {
$summary[$status]++;
}
$dueDate = trim((string) ($row['due_date'] ?? ''));
if ($status === 'due' && $dueDate !== '' && $dueDate < $today) {
$summary['overdue']++;
}
}
return $summary;
}
private function nullablePost(string $key): ?string
{
$value = trim((string) ($this->request->getPost($key) ?? ''));
return $value !== '' ? $value : null;
}
private function normalizeDateTime(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function normalizeDate(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d', $timestamp);
}
}

View File

@ -0,0 +1,497 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateImmunizationModuleTables extends Migration
{
public function up(): void
{
$this->createVaccineMasterTable();
$this->createScheduleMasterTable();
$this->createRecordsTable();
$this->seedUipSchedule();
}
public function down(): void
{
$this->forge->dropTable('immunization_records', true);
$this->forge->dropTable('immunization_schedule_master', true);
$this->forge->dropTable('immunization_vaccine_master', true);
}
private function createVaccineMasterTable(): void
{
if ($this->tableExists('immunization_vaccine_master')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'vaccine_name' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => false,
],
'vaccine_code' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'vaccine_code_system' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
],
'vaccine_display' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'target_disease_code' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'target_disease_name' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'route_code' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'route_name' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'site_code' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'site_name' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'is_active' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 1,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('vaccine_name');
$this->forge->addKey('vaccine_code');
$this->forge->addKey('is_active');
$this->forge->createTable('immunization_vaccine_master', true);
}
private function createScheduleMasterTable(): void
{
if ($this->tableExists('immunization_schedule_master')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'schedule_name' => [
'type' => 'VARCHAR',
'constraint' => 160,
'default' => 'UIP',
],
'age_label' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => false,
],
'age_value' => [
'type' => 'INT',
'constraint' => 11,
'default' => 0,
],
'age_unit' => [
'type' => 'VARCHAR',
'constraint' => 20,
'default' => 'days',
],
'age_offset_days' => [
'type' => 'INT',
'constraint' => 11,
'default' => 0,
],
'vaccine_master_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => false,
],
'dose_number' => [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
],
'series_name' => [
'type' => 'VARCHAR',
'constraint' => 160,
'default' => 'Indian Universal Immunization Programme',
],
'series_doses' => [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
],
'notes' => [
'type' => 'TEXT',
'null' => true,
],
'sort_order' => [
'type' => 'INT',
'constraint' => 11,
'default' => 0,
],
'is_uip' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 1,
],
'is_active' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 1,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('vaccine_master_id');
$this->forge->addKey('age_offset_days');
$this->forge->addKey('is_uip');
$this->forge->addKey('is_active');
$this->forge->createTable('immunization_schedule_master', true);
}
private function createRecordsTable(): void
{
if ($this->tableExists('immunization_records')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'patient_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => false,
],
'opd_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'ipd_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'schedule_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'vaccine_master_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'vaccine_name' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => false,
],
'vaccine_code' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'vaccine_code_system' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
],
'dose_number' => [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
],
'due_date' => [
'type' => 'DATE',
'null' => true,
],
'given_date' => [
'type' => 'DATETIME',
'null' => true,
],
'status' => [
'type' => 'VARCHAR',
'constraint' => 30,
'default' => 'due',
],
'lot_number' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'expiry_date' => [
'type' => 'DATE',
'null' => true,
],
'manufacturer' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'performer_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'location_name' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'route_code' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'route_name' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'site_code' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'site_name' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'reaction_notes' => [
'type' => 'TEXT',
'null' => true,
],
'notes' => [
'type' => 'TEXT',
'null' => true,
],
'abdm_care_context_reference' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'abdm_health_record_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'abdm_push_status' => [
'type' => 'VARCHAR',
'constraint' => 40,
'null' => true,
],
'created_by' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'updated_by' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('patient_id');
$this->forge->addKey('schedule_id');
$this->forge->addKey('vaccine_master_id');
$this->forge->addKey('due_date');
$this->forge->addKey('given_date');
$this->forge->addKey('status');
$this->forge->addKey('abdm_care_context_reference');
$this->forge->createTable('immunization_records', true);
}
private function seedUipSchedule(): void
{
if (! $this->tableExists('immunization_vaccine_master') || ! $this->tableExists('immunization_schedule_master')) {
return;
}
$existing = $this->db->table('immunization_schedule_master')->where('is_uip', 1)->countAllResults();
if ($existing > 0) {
return;
}
$now = date('Y-m-d H:i:s');
$codeSystem = 'https://hms.local/immunization/uip';
$vaccines = [
'UIP-BCG' => ['BCG', 'Tuberculosis', 'Intradermal'],
'UIP-OPV' => ['Oral Polio Vaccine', 'Poliomyelitis', 'Oral'],
'UIP-HEPB' => ['Hepatitis B Vaccine', 'Hepatitis B', 'Intramuscular'],
'UIP-PENTA' => ['Pentavalent Vaccine', 'Diphtheria, Pertussis, Tetanus, Hepatitis B and Hib', 'Intramuscular'],
'UIP-ROTA' => ['Rotavirus Vaccine', 'Rotavirus gastroenteritis', 'Oral'],
'UIP-FIPV' => ['Fractional IPV', 'Poliomyelitis', 'Intradermal'],
'UIP-PCV' => ['Pneumococcal Conjugate Vaccine', 'Pneumococcal disease', 'Intramuscular'],
'UIP-MR' => ['Measles Rubella Vaccine', 'Measles and rubella', 'Subcutaneous'],
'UIP-JE' => ['Japanese Encephalitis Vaccine', 'Japanese encephalitis', 'Subcutaneous'],
'UIP-DPT' => ['DPT Vaccine', 'Diphtheria, Pertussis and Tetanus', 'Intramuscular'],
'UIP-TD' => ['Td Vaccine', 'Tetanus and diphtheria', 'Intramuscular'],
];
$vaccineRows = [];
foreach ($vaccines as $code => $vaccine) {
$vaccineRows[] = [
'vaccine_name' => $vaccine[0],
'vaccine_code' => $code,
'vaccine_code_system' => $codeSystem,
'vaccine_display' => $vaccine[0],
'target_disease_name' => $vaccine[1],
'route_name' => $vaccine[2],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->db->table('immunization_vaccine_master')->insertBatch($vaccineRows);
$vaccineIds = [];
$rows = $this->db->table('immunization_vaccine_master')->select('id,vaccine_code')->whereIn('vaccine_code', array_keys($vaccines))->get()->getResultArray();
foreach ($rows as $row) {
$vaccineIds[(string) $row['vaccine_code']] = (int) $row['id'];
}
$schedule = [
['At Birth', 0, 'days', 0, 'UIP-BCG', '1', '1', 'Preferably at birth or as early as possible.'],
['At Birth', 0, 'days', 0, 'UIP-OPV', '0', '4', 'OPV birth dose.'],
['At Birth', 0, 'days', 0, 'UIP-HEPB', '0', '4', 'Within 24 hours of birth where possible.'],
['6 Weeks', 6, 'weeks', 42, 'UIP-OPV', '1', '4', ''],
['6 Weeks', 6, 'weeks', 42, 'UIP-PENTA', '1', '3', ''],
['6 Weeks', 6, 'weeks', 42, 'UIP-ROTA', '1', '3', ''],
['6 Weeks', 6, 'weeks', 42, 'UIP-FIPV', '1', '2', ''],
['6 Weeks', 6, 'weeks', 42, 'UIP-PCV', '1', '3', 'Where applicable as per local programme.'],
['10 Weeks', 10, 'weeks', 70, 'UIP-OPV', '2', '4', ''],
['10 Weeks', 10, 'weeks', 70, 'UIP-PENTA', '2', '3', ''],
['10 Weeks', 10, 'weeks', 70, 'UIP-ROTA', '2', '3', ''],
['14 Weeks', 14, 'weeks', 98, 'UIP-OPV', '3', '4', ''],
['14 Weeks', 14, 'weeks', 98, 'UIP-PENTA', '3', '3', ''],
['14 Weeks', 14, 'weeks', 98, 'UIP-ROTA', '3', '3', ''],
['14 Weeks', 14, 'weeks', 98, 'UIP-FIPV', '2', '2', ''],
['14 Weeks', 14, 'weeks', 98, 'UIP-PCV', '2', '3', 'Where applicable as per local programme.'],
['9-12 Months', 9, 'months', 274, 'UIP-MR', '1', '2', ''],
['9-12 Months', 9, 'months', 274, 'UIP-JE', '1', '2', 'In JE endemic districts.'],
['9-12 Months', 9, 'months', 274, 'UIP-PCV', 'Booster', '3', 'Where applicable as per local programme.'],
['16-24 Months', 16, 'months', 487, 'UIP-DPT', 'Booster 1', '3', ''],
['16-24 Months', 16, 'months', 487, 'UIP-OPV', 'Booster', '4', ''],
['16-24 Months', 16, 'months', 487, 'UIP-MR', '2', '2', ''],
['16-24 Months', 16, 'months', 487, 'UIP-JE', '2', '2', 'In JE endemic districts.'],
['5-6 Years', 5, 'years', 1826, 'UIP-DPT', 'Booster 2', '3', ''],
['10 Years', 10, 'years', 3652, 'UIP-TD', '1', '2', ''],
['16 Years', 16, 'years', 5844, 'UIP-TD', '2', '2', ''],
];
$scheduleRows = [];
foreach ($schedule as $index => $item) {
$vaccineId = $vaccineIds[$item[4]] ?? 0;
if ($vaccineId <= 0) {
continue;
}
$scheduleRows[] = [
'schedule_name' => 'UIP',
'age_label' => $item[0],
'age_value' => $item[1],
'age_unit' => $item[2],
'age_offset_days' => $item[3],
'vaccine_master_id' => $vaccineId,
'dose_number' => $item[5],
'series_name' => 'Indian Universal Immunization Programme',
'series_doses' => $item[6],
'notes' => $item[7] !== '' ? $item[7] : null,
'sort_order' => $index + 1,
'is_uip' => 1,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
];
}
if (! empty($scheduleRows)) {
$this->db->table('immunization_schedule_master')->insertBatch($scheduleRows);
}
}
private function tableExists(string $table): bool
{
$rows = $this->db->query('SHOW TABLES LIKE ?', [$table])->getResultArray();
return ! empty($rows);
}
}

View File

@ -1389,6 +1389,340 @@ class FhirR4Builder
];
}
/**
* Build an ABDM ImmunizationRecord FHIR DocumentBundle.
*
* @param array<string,mixed> $patient
* @param array<int,array<string,mixed>> $immunizations
* @param array<string,mixed> $context practitioner, organization, encounter, care_context_reference
*
* @return array<string,mixed>
*/
public function buildImmunizationRecordBundle(array $patient, array $immunizations, array $context = []): array
{
$issuedAt = $this->isoTimestamp();
$bundleUuid = $this->generateUuid();
$compositionUuid = $this->generateUuid();
$patientUuid = $this->generateUuid();
$patientRef = 'urn:uuid:' . $patientUuid;
$practitioner = is_array($context['practitioner'] ?? null) ? (array) $context['practitioner'] : [];
$organization = is_array($context['organization'] ?? null) ? (array) $context['organization'] : [];
$encounter = is_array($context['encounter'] ?? null) ? (array) $context['encounter'] : [];
$hasPractitioner = trim((string) ($practitioner['id'] ?? '')) !== '' || trim((string) ($practitioner['name'] ?? '')) !== '';
$hasOrganization = trim((string) ($organization['id'] ?? $organization['hfr_id'] ?? '')) !== '' || trim((string) ($organization['name'] ?? '')) !== '';
$hasEncounter = trim((string) ($encounter['id'] ?? '')) !== '';
$practitionerUuid = $hasPractitioner ? $this->generateUuid() : '';
$organizationUuid = $hasOrganization ? $this->generateUuid() : '';
$encounterUuid = $hasEncounter ? $this->generateUuid() : '';
$practitionerRef = $hasPractitioner ? ('urn:uuid:' . $practitionerUuid) : '';
$organizationRef = $hasOrganization ? ('urn:uuid:' . $organizationUuid) : '';
$encounterRef = $hasEncounter ? ('urn:uuid:' . $encounterUuid) : '';
$resourceEntries = [];
$patientResource = $this->buildPatientResource($patient);
$patientResource['id'] = $patientUuid;
$patientResource['meta'] = ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient']];
$resourceEntries[] = ['fullUrl' => $patientRef, 'resource' => $patientResource];
if ($hasPractitioner) {
$practitionerResource = [
'resourceType' => 'Practitioner',
'id' => $practitionerUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']],
'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]],
];
$registrationNumber = trim((string) ($practitioner['registration_number'] ?? ''));
if ($registrationNumber !== '') {
$practitionerResource['identifier'] = [[
'type' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
'code' => 'MD',
'display' => 'Medical License number',
]]],
'system' => 'https://doctor.ndhm.gov.in',
'value' => $registrationNumber,
]];
}
$resourceEntries[] = ['fullUrl' => $practitionerRef, 'resource' => $practitionerResource];
}
if ($hasOrganization) {
$hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? ''));
$organizationResource = [
'resourceType' => 'Organization',
'id' => $organizationUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']],
'name' => trim((string) ($organization['name'] ?? '')),
];
if ($hfrId !== '') {
$organizationResource['identifier'] = [[
'type' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
'code' => 'PRN',
'display' => 'Provider number',
]]],
'system' => 'https://facility.ndhm.gov.in',
'value' => $hfrId,
]];
}
$resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $organizationResource];
}
if ($hasEncounter) {
$encounterResource = [
'resourceType' => 'Encounter',
'id' => $encounterUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter']],
'identifier' => [['system' => 'https://ndhm.in', 'value' => (string) ($encounter['id'] ?? $encounterUuid)]],
'status' => (string) ($encounter['status'] ?? 'finished'),
'class' => [
'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
'code' => (string) ($encounter['class_code'] ?? 'AMB'),
'display' => (string) ($encounter['class_display'] ?? 'Ambulatory'),
],
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
];
$periodStart = $this->normalizeIsoDateTime((string) ($encounter['period_start'] ?? ''), '');
if ($periodStart !== '') {
$encounterResource['period'] = ['start' => $periodStart];
}
if ($practitionerRef !== '') {
$encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]];
}
if ($organizationRef !== '') {
$encounterResource['serviceProvider'] = ['reference' => $organizationRef];
}
$resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterResource];
}
$immunizationRefs = [];
foreach ($immunizations as $immunization) {
$vaccineName = trim((string) ($immunization['vaccine_name'] ?? $immunization['vaccine_display'] ?? ''));
if ($vaccineName === '') {
continue;
}
$immunizationUuid = $this->generateUuid();
$immunizationRef = 'urn:uuid:' . $immunizationUuid;
$status = $this->normalizeImmunizationStatus((string) ($immunization['status'] ?? 'completed'));
$occurrence = $this->normalizeIsoDateTime(
(string) ($immunization['given_date'] ?? $immunization['occurrenceDateTime'] ?? $immunization['due_date'] ?? ''),
$issuedAt
);
$resource = [
'resourceType' => 'Immunization',
'id' => $immunizationUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Immunization']],
'identifier' => [[
'system' => 'https://ndhm.in/immunization',
'value' => (string) ($immunization['id'] ?? $immunizationUuid),
]],
'status' => $status,
'vaccineCode' => $this->buildSimpleCodeableConcept(
(string) ($immunization['vaccine_code_system'] ?? ''),
(string) ($immunization['vaccine_code'] ?? ''),
$vaccineName,
$vaccineName
),
'patient' => ['reference' => $patientRef, 'display' => 'Patient'],
'occurrenceDateTime' => $occurrence,
'recorded' => $issuedAt,
'primarySource' => true,
];
if ($encounterRef !== '') {
$resource['encounter'] = ['reference' => $encounterRef];
}
if ($organizationRef !== '') {
$resource['location'] = ['reference' => $organizationRef, 'display' => trim((string) ($organization['name'] ?? ''))];
}
$manufacturer = trim((string) ($immunization['manufacturer'] ?? ''));
if ($manufacturer !== '') {
$resource['manufacturer'] = ['display' => $manufacturer];
}
$lotNumber = trim((string) ($immunization['lot_number'] ?? ''));
if ($lotNumber !== '') {
$resource['lotNumber'] = $lotNumber;
}
$expiryDate = trim((string) ($immunization['expiry_date'] ?? $immunization['expirationDate'] ?? ''));
if ($expiryDate !== '' && strtotime($expiryDate) !== false) {
$resource['expirationDate'] = date('Y-m-d', strtotime($expiryDate));
}
$site = $this->buildSimpleCodeableConcept('http://snomed.info/sct', (string) ($immunization['site_code'] ?? ''), (string) ($immunization['site_name'] ?? ''), (string) ($immunization['site_name'] ?? ''));
if (! empty($site['text']) || ! empty($site['coding'])) {
$resource['site'] = $site;
}
$route = $this->buildSimpleCodeableConcept('http://snomed.info/sct', (string) ($immunization['route_code'] ?? ''), (string) ($immunization['route_name'] ?? ''), (string) ($immunization['route_name'] ?? ''));
if (! empty($route['text']) || ! empty($route['coding'])) {
$resource['route'] = $route;
}
if ($practitionerRef !== '') {
$resource['performer'] = [[
'function' => ['text' => 'Administering provider'],
'actor' => ['reference' => $practitionerRef, 'display' => trim((string) ($practitioner['name'] ?? ''))],
]];
} elseif ($organizationRef !== '') {
$resource['performer'] = [[
'function' => ['text' => 'Administering organization'],
'actor' => ['reference' => $organizationRef, 'display' => trim((string) ($organization['name'] ?? ''))],
]];
}
$protocol = [];
$series = trim((string) ($immunization['series_name'] ?? ''));
if ($series !== '') {
$protocol['series'] = $series;
}
$dose = $this->buildDoseNumberElement((string) ($immunization['dose_number'] ?? ''));
if (! empty($dose)) {
$protocol += $dose;
}
$seriesDoses = $this->buildDoseNumberElement((string) ($immunization['series_doses'] ?? ''), 'seriesDoses');
if (! empty($seriesDoses)) {
$protocol += $seriesDoses;
}
$targetDisease = $this->buildSimpleCodeableConcept('http://snomed.info/sct', (string) ($immunization['target_disease_code'] ?? ''), (string) ($immunization['target_disease_name'] ?? ''), (string) ($immunization['target_disease_name'] ?? ''));
if (! empty($targetDisease['text']) || ! empty($targetDisease['coding'])) {
$protocol['targetDisease'] = [$targetDisease];
}
if ($organizationRef !== '') {
$protocol['authority'] = ['reference' => $organizationRef];
}
if (! empty($protocol)) {
$resource['protocolApplied'] = [$protocol];
}
$note = trim((string) ($immunization['notes'] ?? ''));
if ($note !== '') {
$resource['note'] = [['text' => $note]];
}
$immunizationRefs[] = ['reference' => $immunizationRef, 'display' => $vaccineName];
$resourceEntries[] = ['fullUrl' => $immunizationRef, 'resource' => $resource];
}
$composition = [
'resourceType' => 'Composition',
'id' => $compositionUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord']],
'language' => 'en-IN',
'text' => [
'status' => 'generated',
'div' => '<div xmlns="http://www.w3.org/1999/xhtml">Immunization Record</div>',
],
'identifier' => [
'system' => 'https://ndhm.in/phr',
'value' => $compositionUuid,
],
'status' => 'final',
'type' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '41000179103',
'display' => 'Immunization record',
]],
'text' => 'Immunization record',
],
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'date' => $issuedAt,
'title' => 'Immunization Record',
'section' => [[
'title' => 'Immunization record',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '41000179103',
'display' => 'Immunization record',
]]],
'entry' => $immunizationRefs,
]],
];
if ($practitionerRef !== '') {
$composition['author'] = [['reference' => $practitionerRef]];
} elseif ($organizationRef !== '') {
$composition['author'] = [['reference' => $organizationRef]];
}
$careContextReference = trim((string) ($context['care_context_reference'] ?? ('IMM-' . (string) ($patient['id'] ?? 'unknown') . '-' . date('YmdHis'))));
$hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? ''));
return [
'resourceType' => 'Bundle',
'id' => $bundleUuid,
'meta' => [
'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle'],
'security' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/v3-Confidentiality',
'code' => 'V',
'display' => 'very restricted',
]],
],
'identifier' => [
'system' => $hfrId !== '' ? ('https://' . $hfrId . '.hfr.abdm.gov.in') : 'urn:ietf:rfc:3986',
'value' => $careContextReference,
],
'type' => 'document',
'timestamp' => $issuedAt,
'entry' => array_merge([['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]], $resourceEntries),
];
}
private function normalizeImmunizationStatus(string $status): string
{
$status = strtolower(trim($status));
return match ($status) {
'completed', 'done', 'given' => 'completed',
'entered-in-error', 'entered_in_error', 'error' => 'entered-in-error',
default => 'not-done',
};
}
/**
* @return array<string,mixed>
*/
private function buildSimpleCodeableConcept(string $system, string $code, string $display, string $text): array
{
$concept = [];
$text = trim($text);
if ($text !== '') {
$concept['text'] = $text;
}
$system = trim($system);
$code = trim($code);
$display = trim($display);
if ($system !== '' && $code !== '' && $display !== '') {
$concept['coding'] = [[
'system' => $system,
'code' => $code,
'display' => $display,
]];
}
return $concept;
}
/**
* @return array<string,mixed>
*/
private function buildDoseNumberElement(string $value, string $fieldPrefix = 'doseNumber'): array
{
$value = trim($value);
if ($value === '') {
return [];
}
if (preg_match('/^[1-9]\d*$/', $value) === 1) {
return [$fieldPrefix . 'PositiveInt' => (int) $value];
}
return [$fieldPrefix . 'String' => $value];
}
/** Generate a cryptographically random UUID v4. */
private function generateUuid(): string
{

View File

@ -0,0 +1,153 @@
<?php
namespace App\Libraries;
use CodeIgniter\Database\BaseConnection;
use Config\Database;
use DateTimeImmutable;
use DateTimeZone;
class ImmunizationScheduleService
{
private BaseConnection $db;
public function __construct(?BaseConnection $db = null)
{
$this->db = $db ?? Database::connect();
}
/**
* @return array{ok:int,created:int,skipped:int,error?:string,patient_id:int}
*/
public function generateUipDueRecordsForPatient(int $patientId, array $options = []): array
{
if ($patientId <= 0) {
return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => 'Invalid patient_id'];
}
foreach (['patient_master', 'immunization_schedule_master', 'immunization_vaccine_master', 'immunization_records'] as $table) {
if (! $this->db->tableExists($table)) {
return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => $table . ' table not found'];
}
}
$patient = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray();
if (empty($patient)) {
return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => 'Patient not found'];
}
$dob = $this->resolvePatientDob($patient);
if ($dob === null) {
return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => 'Patient DOB is required'];
}
$today = new DateTimeImmutable('today', new DateTimeZone('Asia/Kolkata'));
$diff = $dob->diff($today);
$ageDays = $diff->invert === 0 ? (int) $diff->days : 0;
$generationMode = strtolower(trim((string) ($options['generation_mode'] ?? 'full')));
if (! in_array($generationMode, ['eligible', 'full'], true)) {
$generationMode = 'full';
}
$scheduleRows = $this->db->table('immunization_schedule_master s')
->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.route_code, v.route_name, v.site_code, v.site_name')
->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'inner')
->where('s.is_uip', 1)
->where('s.is_active', 1)
->where('v.is_active', 1)
->orderBy('s.sort_order', 'ASC')
->get()
->getResultArray();
if (empty($scheduleRows)) {
return ['ok' => 1, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId];
}
$existingRows = $this->db->table('immunization_records')
->select('schedule_id')
->where('patient_id', $patientId)
->where('schedule_id IS NOT NULL', null, false)
->get()
->getResultArray();
$existingScheduleIds = [];
foreach ($existingRows as $row) {
$existingScheduleIds[(int) ($row['schedule_id'] ?? 0)] = true;
}
$now = date('Y-m-d H:i:s');
$insertRows = [];
$skipped = 0;
$futureSkipped = 0;
foreach ($scheduleRows as $schedule) {
$scheduleId = (int) ($schedule['id'] ?? 0);
if ($scheduleId <= 0 || isset($existingScheduleIds[$scheduleId])) {
$skipped++;
continue;
}
$offsetDays = max(0, (int) ($schedule['age_offset_days'] ?? 0));
if ($generationMode === 'eligible' && $offsetDays > $ageDays) {
$futureSkipped++;
continue;
}
$dueDate = $dob->modify('+' . $offsetDays . ' days')->format('Y-m-d');
$insertRows[] = [
'patient_id' => $patientId,
'schedule_id' => $scheduleId,
'vaccine_master_id' => (int) ($schedule['vaccine_master_id'] ?? 0),
'vaccine_name' => (string) ($schedule['vaccine_name'] ?? ''),
'vaccine_code' => (string) ($schedule['vaccine_code'] ?? ''),
'vaccine_code_system' => (string) ($schedule['vaccine_code_system'] ?? ''),
'dose_number' => (string) ($schedule['dose_number'] ?? ''),
'due_date' => $dueDate,
'status' => 'due',
'route_code' => (string) ($schedule['route_code'] ?? ''),
'route_name' => (string) ($schedule['route_name'] ?? ''),
'site_code' => (string) ($schedule['site_code'] ?? ''),
'site_name' => (string) ($schedule['site_name'] ?? ''),
'notes' => (string) ($schedule['notes'] ?? ''),
'created_at' => $now,
'updated_at' => $now,
];
}
if (! empty($insertRows)) {
$this->db->table('immunization_records')->insertBatch($insertRows);
}
return [
'ok' => 1,
'created' => count($insertRows),
'skipped' => $skipped,
'future_skipped' => $futureSkipped,
'patient_id' => $patientId,
'generation_mode' => $generationMode,
'patient_age_days' => $ageDays,
'patient_gender' => (string) ($patient['gender'] ?? $patient['xgender'] ?? ''),
];
}
/**
* @param array<string,mixed> $patient
*/
private function resolvePatientDob(array $patient): ?DateTimeImmutable
{
foreach (['dob', 'birth_date', 'date_of_birth', 'p_dob'] as $field) {
$raw = trim((string) ($patient[$field] ?? ''));
if ($raw === '' || $raw === '0000-00-00') {
continue;
}
$timestamp = strtotime($raw);
if ($timestamp === false) {
continue;
}
return (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Kolkata'));
}
return null;
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ImmunizationRecordModel extends Model
{
protected $table = 'immunization_records';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'patient_id',
'opd_id',
'ipd_id',
'schedule_id',
'vaccine_master_id',
'vaccine_name',
'vaccine_code',
'vaccine_code_system',
'dose_number',
'due_date',
'given_date',
'status',
'lot_number',
'expiry_date',
'manufacturer',
'performer_id',
'location_name',
'route_code',
'route_name',
'site_code',
'site_name',
'reaction_notes',
'notes',
'abdm_care_context_reference',
'abdm_health_record_id',
'abdm_push_status',
'created_by',
'updated_by',
'created_at',
'updated_at',
];
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ImmunizationScheduleModel extends Model
{
protected $table = 'immunization_schedule_master';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'schedule_name',
'age_label',
'age_value',
'age_unit',
'age_offset_days',
'vaccine_master_id',
'dose_number',
'series_name',
'series_doses',
'notes',
'sort_order',
'is_uip',
'is_active',
'created_at',
'updated_at',
];
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ImmunizationVaccineModel extends Model
{
protected $table = 'immunization_vaccine_master';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'vaccine_name',
'vaccine_code',
'vaccine_code_system',
'vaccine_display',
'target_disease_code',
'target_disease_name',
'route_code',
'route_name',
'site_code',
'site_name',
'is_active',
'created_at',
'updated_at',
];
}

View File

@ -71,6 +71,7 @@
'back_url' => $historyBackUrl,
'back_title' => 'Doctor Patient Search',
]);
$immunizationUrl = base_url('Immunization') . '?' . http_build_query(['patient_id' => $patientId]);
$patientRawName = trim((string) ($patient['p_fname'] ?? '') . ' { ' . (string) ($patient['p_rname'] ?? '') . ' }');
$patientAge = function_exists('get_age_1')
? trim((string) get_age_1($patient['dob'] ?? null, $patient['age'] ?? '', $patient['age_in_month'] ?? '', $patient['estimate_dob'] ?? '', $patient['last_visit'] ?? null))
@ -96,6 +97,9 @@
<a href="javascript:load_form('<?= esc($documentUrl, 'js') ?>','Patient Documents');" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-text"></i> Issue Document
</a>
<a href="javascript:load_form('<?= esc($immunizationUrl, 'js') ?>','Immunization Record');" class="btn btn-success btn-sm">
<i class="bi bi-shield-check"></i> Immunization
</a>
</div>
</td>
</tr>

View File

@ -31,6 +31,7 @@
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-success" onclick="openPatientDoc()">Open Patient Documents</button>
<button type="button" class="btn btn-outline-success" onclick="openPatientImmunization()">Open Immunization</button>
<button type="button" class="btn btn-outline-secondary" onclick="load_form('<?= base_url('doctor_work/patient_search') ?>','Doctor Patient Search')">Search Patient with History</button>
<button type="button" class="btn btn-outline-primary" onclick="load_form('<?= base_url('Report/document_list') ?>', 'Document Issue Report')">Document Issue Report</button>
</div>
@ -68,4 +69,32 @@ function openPatientDoc() {
alert('Unable to resolve patient. Try again.');
});
}
function openPatientImmunization() {
var patientKey = (document.getElementById('doc_workspace_patient_id').value || '').trim();
if (!patientKey) {
alert('Enter patient id');
return;
}
var resolveUrl = '<?= base_url('Document_Patient/open_by_key') ?>?patient_key=' + encodeURIComponent(patientKey);
fetch(resolveUrl, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function(response) {
return response.json();
})
.then(function(data) {
if (data && Number(data.status) === 1 && Number(data.patient_id) > 0) {
load_form('<?= base_url('Immunization') ?>?patient_id=' + Number(data.patient_id), 'Immunization Record');
return;
}
alert((data && data.message) ? data.message : 'Patient not found');
})
.catch(function() {
alert('Unable to resolve patient. Try again.');
});
}
</script>

View File

@ -0,0 +1,465 @@
<?php
$csrfName = csrf_token();
$csrfHash = csrf_hash();
$initialPatientId = (int) ($patient_id ?? 0);
?>
<div class="container-fluid py-3 immunization-ui">
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h5 class="mb-0">Immunization Record</h5>
<div class="small text-muted">Patient immunization schedule and vaccination completion</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnLoadSchedule">UIP Schedule Master</button>
</div>
</div>
<div class="card-body">
<input type="hidden" id="csrfName" value="<?= esc($csrfName) ?>">
<input type="hidden" id="csrfHash" value="<?= esc($csrfHash) ?>">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label mb-1">UHID / Patient ID</label>
<input type="text" class="form-control" id="patientId" value="<?= $initialPatientId > 0 ? esc((string) $initialPatientId) : '' ?>" placeholder="e.g. P26071000012">
</div>
<div class="col-md-3">
<button type="button" class="btn btn-primary w-100" id="btnLoadPatient">Load Patient</button>
</div>
<div class="col-md-7">
<div class="small text-muted pb-2">Enter UHID like P26071000012 or internal patient id.</div>
</div>
</div>
<div class="mt-3" id="statusBox"></div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-3">
<div class="card shadow-sm h-100">
<div class="card-header py-2 fw-semibold">Patient</div>
<div class="card-body">
<div class="small text-muted mb-1">Selected patient</div>
<div class="fs-6 fw-semibold" id="patientName">-</div>
<div class="small mt-2" id="patientMeta">Enter a patient ID and load.</div>
<hr>
<div class="mb-2">
<label class="form-label mb-1">Generate Basis</label>
<select class="form-select form-select-sm" id="generationMode" disabled>
<option value="eligible" selected>Age/Gender eligible doses only</option>
<option value="full">Full UIP schedule</option>
</select>
<div class="small text-muted mt-1" id="generationHint">Load patient first.</div>
</div>
<button type="button" class="btn btn-sm btn-outline-success w-100 mb-3" id="btnGenerate" disabled>Generate UIP</button>
<div class="row g-2 text-center" id="summaryGrid">
<div class="col-6"><div class="summary-tile"><span id="sumTotal">0</span><small>Total</small></div></div>
<div class="col-6"><div class="summary-tile"><span id="sumDue">0</span><small>Due</small></div></div>
<div class="col-6"><div class="summary-tile"><span id="sumOverdue">0</span><small>Overdue</small></div></div>
<div class="col-6"><div class="summary-tile"><span id="sumCompleted">0</span><small>Completed</small></div></div>
</div>
</div>
</div>
</div>
<div class="col-lg-9">
<div class="card shadow-sm">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<div class="fw-semibold">Vaccination Timeline</div>
<div class="small text-muted" id="recordCount">No records loaded</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0" id="recordTable">
<thead class="table-light">
<tr>
<th>Age</th>
<th>Vaccine</th>
<th>Dose</th>
<th>Due</th>
<th>Given</th>
<th>Status</th>
<th>Lot</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<tr><td colspan="8" class="text-center text-muted py-4">No patient loaded.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mt-3 d-none" id="scheduleCard">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<div class="fw-semibold">UIP Master Schedule</div>
<div class="small text-muted" id="scheduleCount"></div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0" id="scheduleTable">
<thead class="table-light">
<tr>
<th>Age</th>
<th>Vaccine</th>
<th>Dose</th>
<th>Route</th>
<th>Target Disease</th>
<th>Notes</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="completeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h5 class="modal-title">Complete Vaccination</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="completeRecordId">
<div class="alert alert-light border mb-3" id="completeTitle"></div>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label mb-1">Given On</label>
<input type="datetime-local" class="form-control" id="givenDate">
</div>
<div class="col-md-4">
<label class="form-label mb-1">Lot Number</label>
<input type="text" class="form-control" id="lotNumber">
</div>
<div class="col-md-4">
<label class="form-label mb-1">Expiry On</label>
<input type="date" class="form-control" id="expiryDate">
</div>
<div class="col-md-4">
<label class="form-label mb-1">Manufacturer</label>
<input type="text" class="form-control" id="manufacturer">
</div>
<div class="col-md-4">
<label class="form-label mb-1">Location</label>
<input type="text" class="form-control" id="locationName">
</div>
<div class="col-md-4">
<label class="form-label mb-1">Performer ID</label>
<input type="number" min="1" step="1" class="form-control" id="performerId">
</div>
<div class="col-md-6">
<label class="form-label mb-1">Reaction Notes</label>
<textarea class="form-control" rows="2" id="reactionNotes"></textarea>
</div>
<div class="col-md-6">
<label class="form-label mb-1">Notes</label>
<textarea class="form-control" rows="2" id="notes"></textarea>
</div>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btnSaveComplete">Save Completed</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="jsonModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h5 class="modal-title" id="jsonModalTitle">FHIR Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre class="json-preview mb-0" id="jsonPreview">{}</pre>
</div>
</div>
</div>
</div>
</div>
<style>
.immunization-ui .summary-tile {
border: 1px solid #e3e7ee;
border-radius: 6px;
padding: 10px 8px;
background: #f8fafc;
}
.immunization-ui .summary-tile span {
display: block;
font-weight: 700;
font-size: 1.15rem;
line-height: 1.1;
}
.immunization-ui .summary-tile small {
color: #6c757d;
}
.immunization-ui .json-preview {
min-height: 360px;
max-height: 70vh;
white-space: pre-wrap;
word-break: break-word;
background: #0f172a;
color: #dbeafe;
border-radius: 6px;
padding: 14px;
font-size: 12px;
}
</style>
<script>
(function () {
var currentPatientId = 0;
var records = [];
function esc(value) {
return $('<div>').text(value == null ? '' : String(value)).html();
}
function csrfPayload() {
var name = $('#csrfName').val();
var hash = $('#csrfHash').val();
var payload = {};
payload[name] = hash;
return payload;
}
function updateCsrf(response) {
if (response && response.csrfName && response.csrfHash) {
$('#csrfName').val(response.csrfName);
$('#csrfHash').val(response.csrfHash);
}
}
function ajaxError(xhr) {
var json = xhr.responseJSON || {};
return json.error_text || json.message || json.error || xhr.statusText || 'Request failed';
}
function showStatus(type, message) {
var cls = type === 'success' ? 'alert-success' : (type === 'warning' ? 'alert-warning' : 'alert-danger');
$('#statusBox').html('<div class="alert ' + cls + ' py-2 mb-0">' + esc(message) + '</div>');
}
function selectedPatientKey() {
return $.trim($('#patientId').val() || '');
}
function resolvePatientKey(callback) {
var key = selectedPatientKey();
if (!key) {
showStatus('warning', 'Enter UHID / Patient ID.');
return;
}
$.ajax({
url: '<?= site_url('Document_Patient/open_by_key') ?>',
method: 'GET',
dataType: 'json',
data: {patient_key: key},
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).done(function (data) {
var resolvedId = parseInt(data.patient_id || 0, 10);
if (parseInt(data.status || 0, 10) === 1 && resolvedId > 0) {
currentPatientId = resolvedId;
callback(resolvedId);
return;
}
showStatus('warning', data.message || 'Patient not found');
}).fail(function (xhr) {
showStatus('warning', ajaxError(xhr));
});
}
function badge(status, dueDate) {
var raw = (status || 'due').toLowerCase();
var label = raw;
var cls = 'bg-secondary';
var today = new window.Date().toISOString().slice(0, 10);
if (raw === 'completed') {
cls = 'bg-success';
} else if (raw === 'due' && dueDate && dueDate < today) {
cls = 'bg-danger';
label = 'overdue';
} else if (raw === 'due') {
cls = 'bg-warning text-dark';
}
return '<span class="badge ' + cls + '">' + esc(label) + '</span>';
}
function renderPatient(data) {
var patient = data.patient || {};
var name = $.trim([(patient.p_fname || ''), (patient.p_lname || '')].join(' ')) || ('Patient #' + currentPatientId);
$('#patientName').text(name);
$('#patientMeta').html([
patient.p_code ? 'UHID: ' + esc(patient.p_code) : '',
patient.gender ? 'Gender: ' + esc(patient.gender) : '',
patient.age ? 'Age: ' + esc(patient.age) : '',
patient.age_in_month ? 'Age in months: ' + esc(patient.age_in_month) : '',
patient.dob ? 'DOB: ' + esc(patient.dob) : '',
patient.mphone1 ? 'Mobile: ' + esc(patient.mphone1) : ''
].filter(Boolean).join('<br>') || 'Patient loaded.');
var summary = data.summary || {};
$('#sumTotal').text(summary.total || 0);
$('#sumDue').text(summary.due || 0);
$('#sumOverdue').text(summary.overdue || 0);
$('#sumCompleted').text(summary.completed || 0);
records = data.items || [];
$('#recordCount').text(records.length + ' record(s)');
$('#btnGenerate, #generationMode').prop('disabled', false);
$('#generationHint').text('Default creates only doses due for the patient age. Current child UIP master has no gender-specific dose rule.');
var rows = [];
records.forEach(function (record) {
var canComplete = (record.status || '').toLowerCase() !== 'completed';
rows.push('<tr>' +
'<td>' + esc(record.age_label || '-') + '</td>' +
'<td><div class="fw-semibold">' + esc(record.vaccine_name || '-') + '</div><div class="small text-muted">' + esc(record.target_disease_name || '') + '</div></td>' +
'<td>' + esc(record.dose_number || '-') + '</td>' +
'<td>' + esc(record.due_date || '-') + '</td>' +
'<td>' + esc(record.given_date || '-') + '</td>' +
'<td>' + badge(record.status, record.due_date) + '</td>' +
'<td>' + esc(record.lot_number || '-') + '</td>' +
'<td class="text-end"><button type="button" class="btn btn-sm btn-outline-primary btnComplete" data-id="' + esc(record.id) + '" ' + (canComplete ? '' : 'disabled') + '>Complete</button></td>' +
'</tr>');
});
$('#recordTable tbody').html(rows.length ? rows.join('') : '<tr><td colspan="8" class="text-center text-muted py-4">No immunization records yet. Click Generate UIP.</td></tr>');
}
function loadPatient() {
resolvePatientKey(function (patientId) {
currentPatientId = patientId;
$.ajax({
url: '<?= site_url('Immunization/patient') ?>/' + patientId,
method: 'GET',
dataType: 'json',
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).done(function (data) {
if (parseInt(data.ok || 0, 10) !== 1) {
showStatus('warning', data.error_text || 'Patient load failed');
return;
}
renderPatient(data);
showStatus('success', 'Patient immunization timeline loaded.');
}).fail(function (xhr) {
showStatus('warning', ajaxError(xhr));
});
});
}
function generateSchedule() {
resolvePatientKey(function (patientId) {
currentPatientId = patientId;
var payload = csrfPayload();
payload.generation_mode = $('#generationMode').val() || 'eligible';
$.ajax({
url: '<?= site_url('Immunization/generate_patient_schedule') ?>/' + patientId,
method: 'POST',
dataType: 'json',
data: payload,
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).done(function (data) {
updateCsrf(data);
if (parseInt(data.ok || 0, 10) !== 1) {
showStatus('warning', data.error || data.error_text || 'Schedule generation failed');
return;
}
var futureSkipped = parseInt(data.future_skipped || 0, 10);
var detail = 'UIP generated: ' + (data.created || 0) + ' created, ' + (data.skipped || 0) + ' already existed';
if (futureSkipped > 0) {
detail += ', ' + futureSkipped + ' future dose(s) skipped by age';
}
showStatus('success', detail + '.');
loadPatient();
}).fail(function (xhr) {
updateCsrf(xhr.responseJSON || {});
showStatus('warning', ajaxError(xhr));
});
});
}
function loadSchedule() {
load_form('<?= site_url('Immunization/schedule_master') ?>', 'UIP Schedule Master');
}
function openComplete(recordId) {
var record = records.find(function (item) { return parseInt(item.id, 10) === parseInt(recordId, 10); });
if (!record) {
return;
}
$('#completeRecordId').val(record.id);
$('#completeTitle').text((record.vaccine_name || 'Vaccine') + ' | Due: ' + (record.due_date || '-') + ' | Dose: ' + (record.dose_number || '-'));
$('#givenDate').val(new window.Date().toISOString().slice(0, 16));
$('#lotNumber').val(record.lot_number || '');
$('#expiryDate').val(record.expiry_date || '');
$('#manufacturer').val(record.manufacturer || '');
$('#locationName').val(record.location_name || '');
$('#performerId').val(record.performer_id || '');
$('#reactionNotes').val(record.reaction_notes || '');
$('#notes').val(record.notes || '');
bootstrap.Modal.getOrCreateInstance(document.getElementById('completeModal')).show();
}
function saveComplete() {
var recordId = parseInt($('#completeRecordId').val(), 10) || 0;
if (recordId <= 0) {
return;
}
var payload = csrfPayload();
payload.given_date = $('#givenDate').val();
payload.lot_number = $('#lotNumber').val();
payload.expiry_date = $('#expiryDate').val();
payload.manufacturer = $('#manufacturer').val();
payload.location_name = $('#locationName').val();
payload.performer_id = $('#performerId').val();
payload.reaction_notes = $('#reactionNotes').val();
payload.notes = $('#notes').val();
$.ajax({
url: '<?= site_url('Immunization/complete') ?>/' + recordId,
method: 'POST',
dataType: 'json',
data: payload,
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).done(function (data) {
updateCsrf(data);
if (parseInt(data.ok || 0, 10) !== 1) {
showStatus('warning', data.error_text || 'Save failed');
return;
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('completeModal')).hide();
showStatus('success', 'Vaccination marked completed.');
loadPatient();
}).fail(function (xhr) {
updateCsrf(xhr.responseJSON || {});
showStatus('warning', ajaxError(xhr));
});
}
$('#btnLoadPatient').on('click', loadPatient);
$('#btnGenerate').on('click', generateSchedule);
$('#btnLoadSchedule').on('click', loadSchedule);
$('#btnSaveComplete').on('click', saveComplete);
$('#recordTable').on('click', '.btnComplete', function () {
openComplete($(this).data('id'));
});
$('#patientId').on('keydown', function (event) {
if (event.key === 'Enter') {
loadPatient();
}
});
if (selectedPatientKey() !== '') {
loadPatient();
}
})();
</script>

View File

@ -0,0 +1,241 @@
<?php
$csrfName = csrf_token();
$csrfHash = csrf_hash();
$items = isset($items) && is_array($items) ? $items : [];
?>
<div class="container-fluid py-3 immunization-master-ui">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h5 class="mb-0">UIP Schedule Master</h5>
<div class="small text-muted">Edit vaccine, age, route, dose and notes used for new patient schedules.</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="load_form('<?= site_url('Immunization') ?>','Immunization Record')">Back to Immunization</button>
</div>
<div class="card-body p-0">
<input type="hidden" id="uipCsrfName" value="<?= esc($csrfName) ?>">
<input type="hidden" id="uipCsrfHash" value="<?= esc($csrfHash) ?>">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0" id="uipScheduleMasterTable">
<thead class="table-light">
<tr>
<th>Sort</th>
<th>Age</th>
<th>Vaccine</th>
<th>Dose</th>
<th>Offset Days</th>
<th>Route</th>
<th>Target Disease</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<?php if ($items === []) : ?>
<tr><td colspan="9" class="text-center text-muted py-4">No UIP schedule found.</td></tr>
<?php else : ?>
<?php foreach ($items as $item) : ?>
<tr data-id="<?= (int) ($item['id'] ?? 0) ?>">
<td><?= (int) ($item['sort_order'] ?? 0) ?></td>
<td><?= esc((string) ($item['age_label'] ?? '')) ?></td>
<td>
<div class="fw-semibold"><?= esc((string) ($item['vaccine_name'] ?? '')) ?></div>
<div class="small text-muted"><?= esc((string) ($item['vaccine_code'] ?? '')) ?></div>
</td>
<td><?= esc((string) ($item['dose_number'] ?? '')) ?></td>
<td><?= (int) ($item['age_offset_days'] ?? 0) ?></td>
<td><?= esc((string) ($item['route_name'] ?? '')) ?></td>
<td><?= esc((string) ($item['target_disease_name'] ?? '')) ?></td>
<td><?= ((int) ($item['is_active'] ?? 0) === 1) ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>' ?></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-primary btnEditSchedule" data-item='<?= esc(json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'attr') ?>'>Edit</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="uipEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h5 class="modal-title">Edit UIP Schedule</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="scheduleId">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label mb-1">Age Label</label>
<input type="text" class="form-control" id="ageLabel">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Age Value</label>
<input type="number" min="0" step="1" class="form-control" id="ageValue">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Age Unit</label>
<select class="form-select" id="ageUnit">
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="years">years</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label mb-1">Offset Days</label>
<input type="number" min="0" step="1" class="form-control" id="ageOffsetDays">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Sort</label>
<input type="number" min="0" step="1" class="form-control" id="sortOrder">
</div>
<div class="col-md-5">
<label class="form-label mb-1">Vaccine</label>
<input type="text" class="form-control" id="vaccineName">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Dose</label>
<input type="text" class="form-control" id="doseNumber">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Series Doses</label>
<input type="text" class="form-control" id="seriesDoses">
</div>
<div class="col-md-3">
<label class="form-label mb-1">Route</label>
<input type="text" class="form-control" id="routeName">
</div>
<div class="col-md-6">
<label class="form-label mb-1">Target Disease</label>
<input type="text" class="form-control" id="targetDiseaseName">
</div>
<div class="col-md-3">
<label class="form-label mb-1">Site</label>
<input type="text" class="form-control" id="siteName">
</div>
<div class="col-md-3">
<label class="form-label mb-1">Status</label>
<select class="form-select" id="isActive">
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div class="col-12">
<label class="form-label mb-1">Notes</label>
<textarea class="form-control" rows="2" id="scheduleNotes"></textarea>
</div>
</div>
<div class="mt-3" id="uipMasterStatus"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btnSaveSchedule">Save</button>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
function esc(value) {
return $('<div>').text(value == null ? '' : String(value)).html();
}
function csrfPayload() {
var payload = {};
payload[$('#uipCsrfName').val()] = $('#uipCsrfHash').val();
return payload;
}
function updateCsrf(response) {
if (response && response.csrfName && response.csrfHash) {
$('#uipCsrfName').val(response.csrfName);
$('#uipCsrfHash').val(response.csrfHash);
}
}
function showStatus(type, message) {
var cls = type === 'success' ? 'alert-success' : 'alert-warning';
$('#uipMasterStatus').html('<div class="alert ' + cls + ' py-2 mb-0">' + esc(message) + '</div>');
}
function fillModal(item) {
$('#scheduleId').val(item.id || '');
$('#ageLabel').val(item.age_label || '');
$('#ageValue').val(item.age_value || 0);
$('#ageUnit').val(item.age_unit || 'days');
$('#ageOffsetDays').val(item.age_offset_days || 0);
$('#sortOrder').val(item.sort_order || 0);
$('#vaccineName').val(item.vaccine_name || '');
$('#doseNumber').val(item.dose_number || '');
$('#seriesDoses').val(item.series_doses || '');
$('#routeName').val(item.route_name || '');
$('#targetDiseaseName').val(item.target_disease_name || '');
$('#siteName').val(item.site_name || '');
$('#isActive').val(String(item.is_active || 0));
$('#scheduleNotes').val(item.notes || '');
$('#uipMasterStatus').empty();
bootstrap.Modal.getOrCreateInstance(document.getElementById('uipEditModal')).show();
}
function saveSchedule() {
var scheduleId = parseInt($('#scheduleId').val(), 10) || 0;
if (scheduleId <= 0) {
return;
}
var payload = csrfPayload();
payload.age_label = $('#ageLabel').val();
payload.age_value = $('#ageValue').val();
payload.age_unit = $('#ageUnit').val();
payload.age_offset_days = $('#ageOffsetDays').val();
payload.sort_order = $('#sortOrder').val();
payload.vaccine_name = $('#vaccineName').val();
payload.dose_number = $('#doseNumber').val();
payload.series_doses = $('#seriesDoses').val();
payload.route_name = $('#routeName').val();
payload.target_disease_name = $('#targetDiseaseName').val();
payload.site_name = $('#siteName').val();
payload.is_active = $('#isActive').val();
payload.notes = $('#scheduleNotes').val();
$.ajax({
url: '<?= site_url('Immunization/update_schedule') ?>/' + scheduleId,
method: 'POST',
dataType: 'json',
data: payload,
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).done(function (data) {
updateCsrf(data);
if (parseInt(data.ok || 0, 10) !== 1) {
showStatus('warning', data.error_text || 'Save failed');
return;
}
showStatus('success', 'Schedule updated. Reloading master...');
window.setTimeout(function () {
load_form('<?= site_url('Immunization/schedule_master') ?>', 'UIP Schedule Master');
}, 450);
}).fail(function (xhr) {
updateCsrf(xhr.responseJSON || {});
var json = xhr.responseJSON || {};
showStatus('warning', json.error_text || json.message || xhr.statusText || 'Save failed');
});
}
$('#uipScheduleMasterTable').on('click', '.btnEditSchedule', function () {
var raw = $(this).attr('data-item') || '{}';
try {
fillModal(JSON.parse(raw));
} catch (error) {
showStatus('warning', 'Unable to open schedule record.');
}
});
$('#btnSaveSchedule').on('click', saveSchedule);
})();
</script>

View File

@ -379,6 +379,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) {
<span>Document Issue </span>
</a>
</li>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('Immunization') ?>','Immunization Record')">
<i class="bi bi-shield-check"></i>
<span>Immunization</span>
</a>
</li>
<?php } ?>
<?php if ($canPharmacy) { ?>