diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 5e8630a..82428ad 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1562,9 +1562,11 @@ $routes->get('Document_Patient/health_document_fhir_preview/(:num)', 'DoctorDocu * -------------------------------------------------------------------- */ -// Dedicated PWA Web App Endpoint (Serves React PWA without touching standard CI4 web views) +// Dedicated PWA Web App Endpoints (Serves React PWAs without touching standard CI4 web views) $routes->get('app/nursing', 'Api\v1\NursingApi::pwaIndex'); $routes->get('app/nursing/(:any)', 'Api\v1\NursingApi::pwaIndex'); +$routes->get('app/doctor', 'Api\v1\DoctorApi::pwaIndex'); +$routes->get('app/doctor/(:any)', 'Api\v1\DoctorApi::pwaIndex'); // Dedicated RESTful JSON API v1 Group for React PWAs & Mobile Apps $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static function ($routes) { @@ -1575,5 +1577,14 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct $routes->post('nursing/auth/set-pin', 'NursingApi::setPin'); $routes->get('nursing/workspace/(:num)', 'NursingApi::workspace/$1'); $routes->post('nursing/entry/save/(:num)', 'NursingApi::saveEntry/$1'); + + // Doctor PWA APIs + $routes->get('doctor/list', 'DoctorApi::doctors'); + $routes->post('doctor/auth/verify-pin', 'DoctorApi::verifyPin'); + $routes->post('doctor/auth/set-pin', 'DoctorApi::setPin'); + $routes->get('doctor/opd/list/(:num)', 'DoctorApi::opdList/$1'); + $routes->get('doctor/ipd/list/(:num)', 'DoctorApi::ipdList/$1'); + $routes->get('doctor/ipd/workspace/(:num)', 'DoctorApi::patientWorkspace/$1'); + $routes->post('doctor/ipd/treatment/save/(:num)', 'DoctorApi::saveTreatmentNote/$1'); }); diff --git a/app/Controllers/Api/v1/DoctorApi.php b/app/Controllers/Api/v1/DoctorApi.php new file mode 100644 index 0000000..64bee39 --- /dev/null +++ b/app/Controllers/Api/v1/DoctorApi.php @@ -0,0 +1,262 @@ +doctorModel = new DoctorModel(); + $this->ipdNursingEntryModel = new IpdNursingEntryModel(); + $this->opdModel = new OpdModel(); + } + + /** + * Serves the React PWA app entry point at /app/doctor + */ + public function pwaIndex() + { + $pwaPath = FCPATH . 'App/Doctor/index.html'; + if (file_exists($pwaPath)) { + return $this->response->setBody(file_get_contents($pwaPath)); + } + + return $this->response->setJSON([ + 'app_name' => 'Doctor EMR & Bedside Care PWA Gateway', + 'status' => 'ready', + 'api_base_url' => base_url('api/v1/doctor/'), + 'message' => 'React PWA build target directory /App/Doctor is active. API endpoints are ready.', + 'endpoints' => [ + 'doctors' => base_url('api/v1/doctor/list'), + 'verify_pin' => base_url('api/v1/doctor/auth/verify-pin'), + 'set_pin' => base_url('api/v1/doctor/auth/set-pin'), + 'opd_list' => base_url('api/v1/doctor/opd/list/{docId}'), + 'ipd_list' => base_url('api/v1/doctor/ipd/list/{docId}'), + 'patient_workspace' => base_url('api/v1/doctor/ipd/workspace/{ipdId}'), + 'save_treatment' => base_url('api/v1/doctor/ipd/treatment/save/{ipdId}'), + ] + ]); + } + + /** + * GET api/v1/doctor/list + */ + public function doctors() + { + $doctors = $this->doctorModel->getDoctors(); + $list = []; + foreach ($doctors as $d) { + $list[] = [ + 'id' => (int) $d->id, + 'name' => trim(($d->p_title ?? '') . ' ' . ($d->p_fname ?? '')), + 'email' => $d->email1 ?? '', + 'phone' => $d->mphone1 ?? '', + 'doc_sign' => $d->doc_sign ?? '', + 'has_pin' => ! empty($d->app_pin), + ]; + } + + return $this->response->setJSON([ + 'status' => 1, + 'data' => $list, + ]); + } + + /** + * POST api/v1/doctor/auth/verify-pin + */ + public function verifyPin() + { + $post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []); + $docId = (int) ($post['doctor_id'] ?? 0); + $pin = trim((string) ($post['pin'] ?? '')); + + if ($docId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Doctor ID is required']); + } + + $doctorRows = $this->doctorModel->getDoctorById($docId); + $doctorRow = $doctorRows[0] ?? null; + + if (! $doctorRow) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Doctor profile not found']); + } + + $hashedPin = (string) ($doctorRow->app_pin ?? ''); + + if ($hashedPin === '') { + return $this->response->setJSON([ + 'status' => 2, + 'message' => 'PIN not set yet. Please set a 4-6 digit PIN.', + 'doctor' => [ + 'id' => (int) $doctorRow->id, + 'name' => trim(($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '')), + ], + ]); + } + + $isValid = password_verify($pin, $hashedPin) || ($pin === $hashedPin); + + if (! $isValid) { + return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Incorrect Security PIN']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Authentication successful', + 'doctor' => [ + 'id' => (int) $doctorRow->id, + 'name' => trim(($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '')), + ], + ]); + } + + /** + * POST api/v1/doctor/auth/set-pin + */ + public function setPin() + { + $post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []); + $docId = (int) ($post['doctor_id'] ?? 0); + $newPin = trim((string) ($post['new_pin'] ?? '')); + $oldPin = trim((string) ($post['old_pin'] ?? '')); + + if ($docId <= 0 || strlen($newPin) < 4) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Valid Doctor ID and 4-6 digit PIN are required']); + } + + $doctorRows = $this->doctorModel->getDoctorById($docId); + $doctorRow = $doctorRows[0] ?? null; + + if (! $doctorRow) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Doctor profile not found']); + } + + $hashedPin = (string) ($doctorRow->app_pin ?? ''); + if ($hashedPin !== '' && $oldPin !== '') { + $isValid = password_verify($oldPin, $hashedPin) || ($oldPin === $hashedPin); + if (! $isValid) { + return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Current PIN is incorrect']); + } + } + + $this->doctorModel->updateDoctor([ + 'app_pin' => password_hash($newPin, PASSWORD_DEFAULT), + ], $docId); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Security PIN updated successfully', + ]); + } + + /** + * GET api/v1/doctor/opd/list/(:num) + */ + public function opdList(int $docId) + { + $db = db_connect(); + $builder = $db->table('opd_master opd') + ->select('opd.*, p.fname, p.mname, p.lname, p.uhid, p.mphone1, p.gender, p.age') + ->join('patient_master p', 'p.id = opd.patient_id', 'left') + ->orderBy('opd.id', 'DESC') + ->limit(50); + + if ($docId > 0 && $db->fieldExists('doctor_id', 'opd_master')) { + $builder->where('opd.doctor_id', $docId); + } + + $records = $builder->get()->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'doctor_id' => $docId, + 'appointments' => $records, + ]); + } + + /** + * GET api/v1/doctor/ipd/list/(:num) + */ + public function ipdList(int $docId) + { + $db = db_connect(); + $builder = $db->table('ipd_master ipd') + ->select('ipd.*, p.fname, p.mname, p.lname, p.uhid, p.mphone1, p.gender, p.age, b.bed_code, b.bed_number, w.ward_name') + ->join('patient_master p', 'p.id = ipd.patient_id', 'left') + ->join('bed_master b', 'b.id = ipd.bed_id', 'left') + ->join('ward_master w', 'w.id = b.ward_id', 'left') + ->where('ipd.status', 'admitted') + ->orderBy('ipd.id', 'DESC'); + + if ($docId > 0 && $db->fieldExists('doctor_id', 'ipd_master')) { + $builder->where('ipd.doctor_id', $docId); + } + + $patients = $builder->get()->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'doctor_id' => $docId, + 'patients' => $patients, + ]); + } + + /** + * GET api/v1/doctor/ipd/workspace/(:num) + */ + public function patientWorkspace(int $ipdId) + { + $entries = $this->ipdNursingEntryModel->getByIpd($ipdId); + + return $this->response->setJSON([ + 'status' => 1, + 'ipd_id' => $ipdId, + 'entries' => $entries, + ]); + } + + /** + * POST api/v1/doctor/ipd/treatment/save/(:num) + */ + public function saveTreatmentNote(int $ipdId) + { + $post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []); + $treatmentText = trim((string) ($post['treatment_text'] ?? '')); + + if ($treatmentText === '') { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Doctor treatment note / order is required']); + } + + $docId = (int) ($post['doctor_id'] ?? 0); + $doctorName = trim((string) ($post['doctor_name'] ?? 'Doctor')); + + $data = [ + 'ipd_id' => $ipdId, + 'entry_type' => 'treatment', + 'recorded_at' => date('Y-m-d H:i:s'), + 'treatment_text' => '[Doctor Note] ' . $treatmentText, + 'general_note' => (string) ($post['general_note'] ?? ''), + 'recorded_by' => 'Dr. ' . $doctorName, + 'recorded_by_id' => $docId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $this->ipdNursingEntryModel->insert($data); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Doctor clinical note / treatment saved successfully', + ]); + } +} diff --git a/app/Controllers/Setting/Doctor.php b/app/Controllers/Setting/Doctor.php index f10d9b7..5e6f4b8 100644 --- a/app/Controllers/Setting/Doctor.php +++ b/app/Controllers/Setting/Doctor.php @@ -128,6 +128,11 @@ class Doctor extends BaseController $data['healthplix_doctor_identifier'] = $healthplixDoctorIdentifier; } + $appPin = trim((string) ($this->request->getPost('app_pin') ?? '')); + if ($appPin !== '') { + $data['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT); + } + $insertId = $doctorModel->insert($data); if ($insertId <= 0) { @@ -246,6 +251,11 @@ class Doctor extends BaseController $data['healthplix_doctor_identifier'] = $healthplixDoctorIdentifier; } + $appPin = trim((string) ($this->request->getPost('app_pin') ?? '')); + if ($appPin !== '') { + $data['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT); + } + $updated = $doctorModel->updateDoctor($data, $docId); return $this->response->setJSON([ diff --git a/app/Models/DoctorModel.php b/app/Models/DoctorModel.php index 4a31fcb..e2f7275 100644 --- a/app/Models/DoctorModel.php +++ b/app/Models/DoctorModel.php @@ -11,6 +11,18 @@ class DoctorModel public function __construct() { $this->db = db_connect(); + $this->ensureDoctorMasterTable(); + } + + public function ensureDoctorMasterTable(): bool + { + if ($this->db->tableExists('doctor_master')) { + $fields = $this->db->getFieldNames('doctor_master') ?? []; + if (! in_array('app_pin', $fields, true)) { + $this->db->query("ALTER TABLE doctor_master ADD COLUMN app_pin VARCHAR(255) NULL"); + } + } + return true; } public function getDoctors(): array diff --git a/app/Views/Setting/Doctor/Doctor_Search_V.php b/app/Views/Setting/Doctor/Doctor_Search_V.php index 2fb1826..99d292d 100644 --- a/app/Views/Setting/Doctor/Doctor_Search_V.php +++ b/app/Views/Setting/Doctor/Doctor_Search_V.php @@ -35,6 +35,9 @@ mphone1 ?? '') ?> email1 ?? '') ?> + @@ -49,3 +52,57 @@ + + + + + diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php index a37a8ea..bfa0310 100644 --- a/app/Views/partials/header.php +++ b/app/Views/partials/header.php @@ -302,17 +302,17 @@
-
+
Nursing Care
Active PWA
-
- +
+
Doctor App
- Coming Soon + Active PWA
@@ -326,7 +326,7 @@
- + @@ -360,15 +360,18 @@ + + + + + + +
+ + + + diff --git a/public/App/Doctor/manifest.json b/public/App/Doctor/manifest.json new file mode 100644 index 0000000..efdc545 --- /dev/null +++ b/public/App/Doctor/manifest.json @@ -0,0 +1,16 @@ +{ + "short_name": "Doctor EMR", + "name": "E-Atria Doctor EMR & Bedside Care PWA", + "icons": [ + { + "src": "https://cdn-icons-png.flaticon.com/512/3774/3774299.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": "/app/doctor", + "background_color": "#0f172a", + "theme_color": "#0d6efd", + "display": "standalone", + "orientation": "portrait" +} diff --git a/public/App/Doctor/sw.js b/public/App/Doctor/sw.js new file mode 100644 index 0000000..8c1e1bd --- /dev/null +++ b/public/App/Doctor/sw.js @@ -0,0 +1,21 @@ +const CACHE_NAME = 'doctor-pwa-v1'; +const ASSETS_TO_CACHE = [ + '/app/doctor', + 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css', + 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS_TO_CACHE); + }) + ); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + event.respondWith( + fetch(event.request).catch(() => caches.match(event.request)) + ); +});