diff --git a/android_opd_queue_tv/app/src/main/res/drawable/ic_launcher_background.xml b/android_opd_queue_tv/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d66a50e --- /dev/null +++ b/android_opd_queue_tv/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android_opd_queue_tv/app/src/main/res/drawable/ic_launcher_foreground.xml b/android_opd_queue_tv/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..fe6816e --- /dev/null +++ b/android_opd_queue_tv/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android_opd_queue_tv/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android_opd_queue_tv/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android_opd_queue_tv/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android_opd_queue_tv/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android_opd_queue_tv/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android_opd_queue_tv/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android_opd_queue_tv/app/src/main/res/values/colors.xml b/android_opd_queue_tv/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ca1931b --- /dev/null +++ b/android_opd_queue_tv/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/android_opd_queue_tv/build.gradle b/android_opd_queue_tv/build.gradle index df4514e..e596520 100644 --- a/android_opd_queue_tv/build.gradle +++ b/android_opd_queue_tv/build.gradle @@ -4,17 +4,10 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.2.2' + classpath 'com.android.tools.build:gradle:8.13.2' } } -allprojects { - repositories { - google() - mavenCentral() - } -} - -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory } diff --git a/android_opd_queue_tv/settings.gradle b/android_opd_queue_tv/settings.gradle index e391733..419f328 100644 --- a/android_opd_queue_tv/settings.gradle +++ b/android_opd_queue_tv/settings.gradle @@ -5,6 +5,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { diff --git a/app/Config/Routes.php b/app/Config/Routes.php index f16ef5a..e0e9884 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1592,6 +1592,10 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct $routes->post('nursing/opd/vitals/save/(:num)', 'NursingApi::saveOpdVitals/$1'); $routes->post('nursing/opd/scan/save/(:num)', 'NursingApi::saveOpdScan/$1'); $routes->post('nursing/ipd/scan/save/(:num)', 'NursingApi::saveIpdScan/$1'); + $routes->get('nursing/patient/search', 'NursingApi::searchPatient'); + $routes->post('nursing/patient/wellness/save', 'NursingApi::savePatientWellness'); + $routes->get('nursing/patient/wellness/history/(:num)', 'NursingApi::getPatientWellnessHistory/$1'); + $routes->post('nursing/patient/document/save', 'NursingApi::savePatientDocument'); // Doctor PWA APIs $routes->get('doctor/list', 'DoctorApi::doctors'); diff --git a/app/Controllers/Api/v1/NursingApi.php b/app/Controllers/Api/v1/NursingApi.php index 30aa45f..cb39241 100644 --- a/app/Controllers/Api/v1/NursingApi.php +++ b/app/Controllers/Api/v1/NursingApi.php @@ -78,7 +78,7 @@ class NursingApi extends BaseController FROM bed_master b LEFT JOIN ward_master w ON b.ward_id = w.id LEFT JOIN bed_category_master c ON b.bed_category_id = c.id - LEFT JOIN ipd_master i ON (b.current_ipd_id = i.id AND i.ipd_status = 0) + LEFT JOIN ipd_master i ON ((b.current_ipd_id = i.id OR (b.bed_number = i.bed_no AND i.bed_no != '')) AND i.ipd_status = 0) LEFT JOIN patient_master p ON i.p_id = p.id LEFT JOIN ( select i.ipd_id, group_concat(distinct concat_ws(' ', 'Dr.', d.p_fname, d.p_mname, d.p_lname)) as doc_name @@ -224,7 +224,15 @@ class NursingApi extends BaseController */ public function saveEntry(int $ipdId) { - $post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []); + $post = $this->request->getPost(); + if (empty($post)) { + try { + $post = $this->request->getJSON(true) ?? []; + } catch (\Throwable $e) { + $raw = $this->request->getBody(); + $post = is_string($raw) ? (json_decode($raw, true) ?? []) : []; + } + } $entryType = (string) ($post['entry_type'] ?? ''); if (! in_array($entryType, ['vitals', 'fluid', 'treatment', 'admission'], true)) { return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid nursing entry type']); @@ -288,13 +296,13 @@ class NursingApi extends BaseController $whereClause = "WHERE 1=1"; if ($dateMode === 'today') { - $whereClause .= " AND DATE(o.apointment_date) = CURDATE()"; + $whereClause .= " AND (DATE(o.apointment_date) = CURDATE() OR (o.apointment_date IS NULL AND DATE(o.opd_book_date) = CURDATE()))"; } if ($docId > 0) { $whereClause .= " AND o.doc_id = " . $docId; } - $sql = "SELECT o.opd_id, o.opd_code, o.opd_no, o.p_id, o.doc_id, o.apointment_date, o.opd_status, coalesce(o.opd_fee_type, 'Cash') as opd_type, + $sql = "SELECT o.opd_id, o.opd_code, o.opd_no, o.p_id, o.doc_id, o.apointment_date, o.opd_book_date, o.opd_status, coalesce(o.opd_fee_type, 'Cash') as opd_type, p.p_fname as fname, p.p_rname as rname, p.p_code as uhid, p.mphone1, p.gender, p.age, p.age_in_month, p.estimate_dob, p.dob, concat('Dr. ', d.p_fname, ' ', coalesce(d.p_lname, '')) as doctor_name, pr.temp, pr.pulse, pr.bp, pr.diastolic, pr.spo2, pr.weight, pr.height, pr.rr_min @@ -308,8 +316,9 @@ class NursingApi extends BaseController $query = $db->query($sql); $records = $query->getResultArray(); - $todayTotalCount = (int) $db->query("SELECT count(*) as c FROM opd_master WHERE DATE(apointment_date) = CURDATE()")->getRow()->c; - $allTotalCount = (int) $db->query("SELECT count(*) as c FROM opd_master")->getRow()->c; + $docWhere = $docId > 0 ? " AND doc_id = " . $docId : ""; + $todayTotalCount = (int) $db->query("SELECT count(*) as c FROM opd_master WHERE (DATE(apointment_date) = CURDATE() OR (apointment_date IS NULL AND DATE(opd_book_date) = CURDATE()))" . $docWhere)->getRow()->c; + $allTotalCount = (int) $db->query("SELECT count(*) as c FROM opd_master WHERE 1=1" . $docWhere)->getRow()->c; $counts = [ 'all' => 0, 'waiting' => 0, 'visited' => 0, 'booked' => 0, 'cancelled' => 0, @@ -358,6 +367,10 @@ class NursingApi extends BaseController $ageDisplay = get_age_1($r['dob'] ?? null, $r['age'] ?? '', $r['age_in_month'] ?? '', $r['estimate_dob'] ?? ''); } elseif (! empty($r['age'])) { $ageDisplay = $r['age'] . ' Year'; + } elseif (! empty($r['dob']) && $r['dob'] !== '0000-00-00') { + try { + $ageDisplay = date_diff(date_create($r['dob']), date_create('today'))->y . ' Year'; + } catch (\Throwable $e) {} } $r['status_key'] = $statusKey; @@ -621,5 +634,361 @@ class NursingApi extends BaseController 'ai_alert_flag' => 0, ]); } + + /** + * Search patient by Barcode, QR Code payload, UHID, ABHA ID/Address, Mobile, or Name + * GET api/v1/nursing/patient/search?q=... + */ + public function searchPatient() + { + $db = db_connect(); + $q = trim((string) $this->request->getGet('q')); + if ($q === '') { + return $this->response->setJSON(['status' => 1, 'data' => []]); + } + + // Check if $q is a JSON payload from ABHA QR or Hospital QR + $abhaHidn = null; + $abhaAddress = null; + $abhaMobile = null; + $abhaName = null; + + if (str_starts_with($q, '{') && str_ends_with($q, '}')) { + $jsonParsed = json_decode($q, true); + if (is_array($jsonParsed)) { + $abhaHidn = $jsonParsed['hidn'] ?? $jsonParsed['abha_number'] ?? $jsonParsed['hid_number'] ?? null; + $abhaAddress = $jsonParsed['hid'] ?? $jsonParsed['abha_address'] ?? null; + $abhaMobile = $jsonParsed['mobile'] ?? $jsonParsed['phone'] ?? null; + $abhaName = $jsonParsed['name'] ?? null; + $uhidFromJson = $jsonParsed['uhid'] ?? $jsonParsed['p_code'] ?? null; + if ($uhidFromJson) { + $q = $uhidFromJson; + } + } + } + + // Clean query (e.g. remove hyphens if looks like ABHA number) + $cleanAbha = str_replace('-', '', $q); + + $builder = $db->table('patient_master p'); + $builder->select('p.id, p.p_code, p.p_fname, p.p_lname, p.title, p.gender, p.dob, p.age, p.mphone1, p.mphone2, p.email1, p.city, p.district, p.state, p.abha_id, p.abha_address, p.abha_verified_status, p.abha_kyc_verified, p.abha_mobile_verified, p.abha_profile_photo_base64'); + + $builder->groupStart(); + $builder->where('p.p_code', $q); + $builder->orWhere('p.old_uhid', $q); + $builder->orWhere('p.mphone1', $q); + $builder->orWhere('p.mphone2', $q); + $builder->orWhere('p.abha_id', $q); + $builder->orWhere('p.abha_id', $cleanAbha); + $builder->orWhere('p.abha_address', $q); + + if ($abhaHidn) { + $builder->orWhere('p.abha_id', $abhaHidn); + $builder->orWhere('p.abha_id', str_replace('-', '', $abhaHidn)); + } + if ($abhaAddress) { + $builder->orWhere('p.abha_address', $abhaAddress); + } + if ($abhaMobile) { + $builder->orWhere('p.mphone1', $abhaMobile); + $builder->orWhere('p.mphone2', $abhaMobile); + } + + if (strlen($q) >= 2) { + $builder->orLike('p.p_fname', $q); + $builder->orLike('p.p_lname', $q); + $builder->orLike("CONCAT(p.p_fname, ' ', p.p_lname)", $q); + $builder->orLike('p.mphone1', $q); + $builder->orLike('p.p_code', $q); + } + $builder->groupEnd(); + + $patients = $builder->limit(15)->get()->getResultArray(); + + // Enrich with current IPD bed admission and today's OPD appointment status + $today = date('Y-m-d'); + foreach ($patients as &$pat) { + $pId = (int) $pat['id']; + $pat['fullName'] = trim(($pat['title'] ? $pat['title'] . ' ' : '') . $pat['p_fname'] . ($pat['p_lname'] && $pat['p_lname'] !== '0' ? ' ' . $pat['p_lname'] : '')); + + // Check IPD admission + $ipdRow = $db->table('ipd_master i') + ->select('i.id as ipd_id, i.ipd_code, i.bed_no, i.register_date, i.r_doc_name, b.bed_number, w.ward_name') + ->join('bed_master b', 'b.id = i.bed_no OR b.bed_number = i.bed_no', 'left') + ->join('ward_master w', 'w.id = b.ward_id', 'left') + ->where('i.p_id', $pId) + ->groupStart() + ->where('i.discharge_date IS NULL') + ->orWhere('i.discharge_date', '0000-00-00') + ->groupEnd() + ->orderBy('i.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $pat['ipd_admission'] = $ipdRow ?: null; + + // Check Today OPD appointment + $opdRow = $db->table('opd_master o') + ->select('o.opd_id, o.opd_code, o.opd_no as token_no, o.doc_id, o.doc_name as doctor_name, o.apointment_date, o.opd_book_date, pr.temp, pr.pulse, pr.bp, pr.diastolic, pr.spo2, pr.weight, pr.height, pr.rr_min') + ->join('opd_prescription pr', 'pr.opd_id = o.opd_id', 'left') + ->where('o.p_id', $pId) + ->groupStart() + ->where('DATE(o.apointment_date)', $today) + ->orGroupStart() + ->where('o.apointment_date IS NULL') + ->where('DATE(o.opd_book_date)', $today) + ->groupEnd() + ->groupEnd() + ->orderBy('o.opd_id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $pat['opd_today'] = $opdRow ?: null; + } + + return $this->response->setJSON([ + 'status' => 1, + 'data' => $patients, + 'count' => count($patients), + ]); + } + + /** + * Save ABDM M2 Wellness Vitals for any registered patient + * POST api/v1/nursing/patient/wellness/save + */ + public function savePatientWellness() + { + $db = db_connect(); + $post = $this->request->getPost(); + if (empty($post)) { + try { + $post = $this->request->getJSON(true) ?? []; + } catch (\Throwable $e) { + $raw = $this->request->getBody(); + $post = is_string($raw) ? (json_decode($raw, true) ?? []) : []; + } + } + + $patientId = (int) ($post['patient_id'] ?? 0); + if ($patientId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Patient ID is required']); + } + + $patient = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray(); + if (! $patient) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Patient record not found']); + } + + $recordedAtInput = (string) ($post['recorded_at'] ?? ''); + $recordedAt = $recordedAtInput !== '' + ? str_replace('T', ' ', $recordedAtInput) . (strlen($recordedAtInput) === 16 ? ':00' : '') + : date('Y-m-d H:i:s'); + + $tempF = isset($post['temperature_f']) && $post['temperature_f'] !== '' ? (float) $post['temperature_f'] : null; + $tempC = $tempF !== null ? (($tempF - 32) * 5 / 9) : null; + $heightCm = isset($post['height_cm']) && $post['height_cm'] !== '' ? (float) $post['height_cm'] : null; + $weightKg = isset($post['weight_kg']) && $post['weight_kg'] !== '' ? (float) $post['weight_kg'] : null; + $bmi = null; + if ($heightCm && $heightCm > 0 && $weightKg && $weightKg > 0) { + $heightM = $heightCm / 100; + $bmi = round($weightKg / ($heightM * $heightM), 2); + } + + $wellnessData = [ + 'patient_id' => $patientId, + 'uhid' => $patient['p_code'] ?? '', + 'abha_id' => $patient['abha_id'] ?? null, + 'abha_address' => $patient['abha_address'] ?? null, + 'recorded_at' => $recordedAt, + 'temperature_f' => $tempF, + 'temperature_c' => $tempC !== null ? round($tempC, 2) : null, + 'pulse_rate' => isset($post['pulse_rate']) && $post['pulse_rate'] !== '' ? (int) $post['pulse_rate'] : null, + 'resp_rate' => isset($post['resp_rate']) && $post['resp_rate'] !== '' ? (int) $post['resp_rate'] : null, + 'bp_systolic' => isset($post['bp_systolic']) && $post['bp_systolic'] !== '' ? (int) $post['bp_systolic'] : null, + 'bp_diastolic' => isset($post['bp_diastolic']) && $post['bp_diastolic'] !== '' ? (int) $post['bp_diastolic'] : null, + 'spo2' => isset($post['spo2']) && $post['spo2'] !== '' ? (int) $post['spo2'] : null, + 'weight_kg' => $weightKg, + 'height_cm' => $heightCm, + 'bmi' => $bmi, + 'general_advice' => (string) ($post['general_advice'] ?? ''), + 'diet_lifestyle_note' => (string) ($post['diet_lifestyle_note'] ?? ''), + 'recorded_by' => (string) ($post['recorded_by'] ?? 'Staff Nurse'), + 'recorded_by_id' => isset($post['recorded_by_id']) ? (int) $post['recorded_by_id'] : null, + 'abdm_status' => 'ready', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $db->table('patient_wellness_records')->insert($wellnessData); + $wellnessId = $db->insertID(); + + // Also register in health_records for ABDM M2 / M3 discovery & push + if ($db->tableExists('health_records')) { + $careContextRef = 'WELLNESS-' . $wellnessId . '-' . date('Ymd'); + $db->table('health_records')->insert([ + 'patient_id' => $patientId, + 'abha_id' => $patient['abha_id'] ?? null, + 'hi_type' => 'WellnessRecord', + 'entity_type' => 'wellness', + 'entity_id' => (string) $wellnessId, + 'push_status' => 'pending', + 'care_context_reference' => $careContextRef, + 'record_data' => json_encode($wellnessData), + 'created_by_name' => (string) ($post['recorded_by'] ?? 'Staff Nurse'), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'ABDM M2 Wellness & Vitals recorded successfully', + 'wellness_id' => $wellnessId, + 'bmi' => $bmi, + ]); + } + + /** + * Get patient wellness & vitals history + * GET api/v1/nursing/patient/wellness/history/(:num) + */ + public function getPatientWellnessHistory(int $patientId) + { + $db = db_connect(); + $records = $db->table('patient_wellness_records') + ->where('patient_id', $patientId) + ->orderBy('recorded_at', 'DESC') + ->limit(30) + ->get() + ->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'patient_id' => $patientId, + 'records' => $records, + ]); + } + + /** + * Save ABDM M2 Health Document from scan/camera for any registered patient + * POST api/v1/nursing/patient/document/save + */ + public function savePatientDocument() + { + $db = db_connect(); + $post = $this->request->getPost(); + if (empty($post)) { + try { + $post = $this->request->getJSON(true) ?? []; + } catch (\Throwable $e) { + $raw = $this->request->getBody(); + $post = is_string($raw) ? (json_decode($raw, true) ?? []) : []; + } + } + + $patientId = (int) ($post['patient_id'] ?? 0); + if ($patientId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Patient ID required']); + } + + $patient = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray(); + if (! $patient) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Patient record not found']); + } + + $imageBase64 = $post['image_base64'] ?? null; + if (empty($imageBase64)) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Scanned document image required']); + } + + $docCategory = trim((string) ($post['document_type'] ?? 'Health Document')); + $nurseName = trim((string) ($post['nurse_name'] ?? 'Staff Nurse')); + $nurseId = (int) ($post['nurse_id'] ?? 0); + $docDate = trim((string) ($post['document_date'] ?? date('Y-m-d'))); + $remarks = trim((string) ($post['remarks'] ?? '')); + + $imgData = $imageBase64; + $ext = 'jpg'; + if (preg_match('/^data:image\/(\w+);base64,/', $imageBase64, $type)) { + $imgData = substr($imageBase64, strpos($imageBase64, ',') + 1); + $ext = strtolower($type[1]); + if ($ext === 'jpeg') $ext = 'jpg'; + } + $imgData = str_replace(' ', '+', $imgData); + $binary = base64_decode($imgData); + + if ($binary === false || strlen($binary) === 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Failed to process document image']); + } + + $uploadDir = FCPATH . 'uploads/health_documents/'; + if (! is_dir($uploadDir)) { + @mkdir($uploadDir, 0777, true); + } + $filename = 'abdm_m2_doc_p' . $patientId . '_' . time() . '_' . rand(100, 999) . '.' . $ext; + file_put_contents($uploadDir . $filename, $binary); + $imageUrl = '/uploads/health_documents/' . $filename; + + // Map to ABDM HI Type + $hiType = 'HealthDocumentRecord'; + $catLower = strtolower($docCategory); + if (str_contains($catLower, 'presc')) { + $hiType = 'PrescriptionRecord'; + } elseif (str_contains($catLower, 'diag') || str_contains($catLower, 'lab') || str_contains($catLower, 'report')) { + $hiType = 'DiagnosticReport'; + } elseif (str_contains($catLower, 'discharg')) { + $hiType = 'DischargeSummary'; + } elseif (str_contains($catLower, 'immun')) { + $hiType = 'ImmunizationRecord'; + } + + $healthRecordId = null; + if ($db->tableExists('health_records')) { + $careContextRef = 'DOC-' . $patientId . '-' . time(); + $db->table('health_records')->insert([ + 'patient_id' => $patientId, + 'abha_id' => $patient['abha_id'] ?? null, + 'hi_type' => $hiType, + 'entity_type' => 'document', + 'entity_id' => (string) time(), + 'attachment_path' => $imageUrl, + 'push_status' => 'pending', + 'care_context_reference' => $careContextRef, + 'record_data' => json_encode([ + 'category' => $docCategory, + 'document_date' => $docDate, + 'remarks' => $remarks, + 'uploaded_by' => $nurseName, + 'image_url' => $imageUrl, + ]), + 'created_by_name' => $nurseName, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $healthRecordId = $db->insertID(); + } + + // Register in file_upload_data for HMS Scan Doc List popup + $this->registerFileUploadData([ + 'filename' => $filename, + 'public_path' => $imageUrl, + 'p_id' => $patientId, + 'upload_by' => $nurseName, + 'doc_type' => $docCategory, + 'file_size_kb' => round(strlen($binary) / 1024, 2), + ]); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'ABDM M2 Health Document uploaded successfully', + 'image_url' => $imageUrl, + 'health_record_id' => $healthRecordId, + 'hi_type' => $hiType, + ]); + } } + diff --git a/public/App/Nursing/index.html b/public/App/Nursing/index.html index a6cebf5..ad94edc 100644 --- a/public/App/Nursing/index.html +++ b/public/App/Nursing/index.html @@ -27,6 +27,9 @@ + + + @@ -369,6 +475,36 @@ const [scanImageBase64, setScanImageBase64] = useState(''); const [scanImagePreview, setScanImagePreview] = useState(''); + // ABDM M2 & Patient Search / Barcode Scanner State + const [showPatientSearchModal, setShowPatientSearchModal] = useState(false); + const [searchModalTab, setSearchModalTab] = useState('scan'); // 'scan' or 'type' + const [patientSearchQuery, setPatientSearchQuery] = useState(''); + const [patientSearchResults, setPatientSearchResults] = useState([]); + const [searchingPatient, setSearchingPatient] = useState(false); + const [scannerActive, setScannerActive] = useState(false); + const [scannerError, setScannerError] = useState(''); + + // ABDM M2 Wellness Vitals Modal State + const [activeWellnessPatient, setActiveWellnessPatient] = useState(null); + const [wellnessForm, setWellnessForm] = useState({ + temperature_f: '', pulse_rate: '', resp_rate: '', + bp_systolic: '', bp_diastolic: '', spo2: '', weight_kg: '', height_cm: '', + general_advice: '', diet_lifestyle_note: '' + }); + + // ABDM M2 Health Document Modal State + const [activeDocPatient, setActiveDocPatient] = useState(null); + const [patientDocForm, setPatientDocForm] = useState({ + document_type: 'Health Document', + document_date: new Date().toISOString().slice(0, 10), + remarks: '' + }); + + // Vitals History Modal State + const [wellnessHistoryPatient, setWellnessHistoryPatient] = useState(null); + const [wellnessHistoryList, setWellnessHistoryList] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + // Auth State const [authenticatedNurse, setAuthenticatedNurse] = useState(null); const [authNurseId, setAuthNurseId] = useState(0); @@ -393,6 +529,8 @@ treatment_text: '', general_note: '' }); + const [showAllDoctorOrders, setShowAllDoctorOrders] = useState(false); + const fetchBeds = async () => { try { setLoading(true); @@ -467,8 +605,6 @@ const parsed = JSON.parse(savedAuth); if (parsed && parsed.nurse && parsed.expires_at > Date.now()) { setAuthenticatedNurse(parsed.nurse); - fetchBeds(); - fetchOpdAppointments(opdDateMode, selectedDoctorId, vitalsFilter); } else { localStorage.removeItem(DEVICE_AUTH_KEY); } @@ -477,6 +613,7 @@ fetchBeds(); fetchNurses(); fetchDoctors(); + fetchOpdAppointments('today', '0', 'all'); }, []); const handleVerifyPin = async () => { @@ -511,7 +648,7 @@ localStorage.setItem(DEVICE_AUTH_KEY, JSON.stringify(deviceAuthObj)); setPinInput(''); fetchBeds(); - fetchOpdAppointments(opdDateMode); + fetchOpdAppointments(opdDateMode, selectedDoctorId, vitalsFilter); } else if (json.status === 2) { setIsSettingNewPin(true); } else { @@ -598,6 +735,21 @@ const json = await res.json(); if (json.status === 1) { alert(json.message || 'Saved successfully!'); + if (entryType === 'vitals') { + setVitalsForm({ + temperature_f: '', pulse_rate: '', resp_rate: '', + bp_systolic: '', bp_diastolic: '', spo2: '', weight_kg: '', general_note: '' + }); + } else if (entryType === 'fluid') { + setFluidForm({ + fluid_direction: 'intake', fluid_route: 'IV (Intravenous)', + fluid_amount_ml: '', general_note: '' + }); + } else if (entryType === 'treatment') { + setTreatmentForm({ + treatment_text: '', general_note: '' + }); + } openWorkspace(activeBed); } else { alert(json.message || 'Failed to save entry'); @@ -759,6 +911,255 @@ } }; + // ----------------------------------------------------------------- + // ABDM M2 PATIENT SEARCH & BARCODE / QR SCANNER HANDLERS + // ----------------------------------------------------------------- + let scannerInstance = null; + + const startBarcodeQrScanner = () => { + if (typeof Html5Qrcode === 'undefined') { + setScannerError('Scanner library is loading. Please try in a moment.'); + return; + } + setScannerError(''); + setScannerActive(true); + + setTimeout(() => { + const readerElem = document.getElementById('patient-qr-reader'); + if (!readerElem) return; + + try { + if (scannerInstance) { + scannerInstance.stop().catch(() => {}).finally(() => { + initHtml5QrCode(readerElem); + }); + } else { + initHtml5QrCode(readerElem); + } + } catch (e) { + setScannerError('Could not access camera: ' + (e.message || e)); + setScannerActive(false); + } + }, 300); + }; + + const initHtml5QrCode = (readerElem) => { + try { + const html5QrCode = new Html5Qrcode("patient-qr-reader"); + scannerInstance = html5QrCode; + const config = { + fps: 12, + qrbox: { width: 280, height: 180 }, + aspectRatio: 1.333334, + formatsToSupport: [ + Html5QrcodeSupportedFormats.QR_CODE, + Html5QrcodeSupportedFormats.CODE_128, + Html5QrcodeSupportedFormats.CODE_39, + Html5QrcodeSupportedFormats.EAN_13, + Html5QrcodeSupportedFormats.EAN_8, + Html5QrcodeSupportedFormats.DATA_MATRIX + ] + }; + + html5QrCode.start( + { facingMode: "environment" }, + config, + (decodedText) => { + // On successful scan + stopBarcodeQrScanner(); + setSearchModalTab('type'); + setPatientSearchQuery(decodedText); + executePatientSearch(decodedText); + }, + (errorMessage) => { + // ignore frame errors + } + ).catch(err => { + setScannerError('Camera permission required or camera busy: ' + (err.message || err)); + setScannerActive(false); + }); + } catch (err) { + setScannerError('Failed to initialize camera scanner: ' + (err.message || err)); + setScannerActive(false); + } + }; + + const stopBarcodeQrScanner = () => { + if (scannerInstance) { + try { + scannerInstance.stop().then(() => { + scannerInstance.clear(); + scannerInstance = null; + setScannerActive(false); + }).catch(() => { + scannerInstance = null; + setScannerActive(false); + }); + } catch (e) { + scannerInstance = null; + setScannerActive(false); + } + } else { + setScannerActive(false); + } + }; + + const openPatientSearch = (defaultTab = 'scan') => { + setShowPatientSearchModal(true); + setSearchModalTab(defaultTab); + setPatientSearchResults([]); + setPatientSearchQuery(''); + if (defaultTab === 'scan') { + startBarcodeQrScanner(); + } else { + stopBarcodeQrScanner(); + } + }; + + const closePatientSearch = () => { + stopBarcodeQrScanner(); + setShowPatientSearchModal(false); + setPatientSearchResults([]); + setPatientSearchQuery(''); + }; + + const executePatientSearch = async (queryText) => { + const q = queryText !== undefined ? queryText : patientSearchQuery; + if (!q || q.trim() === '') { + setPatientSearchResults([]); + return; + } + setSearchingPatient(true); + try { + const res = await fetch(`/api/v1/nursing/patient/search?q=${encodeURIComponent(q.trim())}`); + const json = await res.json(); + if (json.status === 1) { + setPatientSearchResults(json.data || []); + } else { + setPatientSearchResults([]); + } + } catch (err) { + console.error('Patient search error', err); + } finally { + setSearchingPatient(false); + } + }; + + // ABDM M2 Wellness Actions + const openWellnessModal = (patient) => { + setActiveWellnessPatient(patient); + const opdVitals = patient.opd_today || {}; + setWellnessForm({ + temperature_f: opdVitals.temp || '', + pulse_rate: opdVitals.pulse || '', + resp_rate: opdVitals.rr_min || '', + bp_systolic: opdVitals.bp || '', + bp_diastolic: opdVitals.diastolic || '', + spo2: opdVitals.spo2 || '', + weight_kg: opdVitals.weight || '', + height_cm: opdVitals.height || '', + general_advice: '', + diet_lifestyle_note: '' + }); + setShowPatientSearchModal(false); + stopBarcodeQrScanner(); + }; + + const handleSaveWellness = async () => { + if (!activeWellnessPatient) return; + try { + const payload = { + patient_id: activeWellnessPatient.id, + recorded_by: authenticatedNurse ? authenticatedNurse.name : 'Staff Nurse', + recorded_by_id: authenticatedNurse ? authenticatedNurse.id : 0, + ...wellnessForm + }; + const res = await fetch('/api/v1/nursing/patient/wellness/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const json = await res.json(); + if (json.status === 1) { + alert('✅ ' + (json.message || 'ABDM M2 Wellness Vitals saved successfully!') + (json.bmi ? ` (BMI: ${json.bmi})` : '')); + setActiveWellnessPatient(null); + setWellnessForm({ + temperature_f: '', pulse_rate: '', resp_rate: '', + bp_systolic: '', bp_diastolic: '', spo2: '', weight_kg: '', height_cm: '', + general_advice: '', diet_lifestyle_note: '' + }); + } else { + alert(json.message || 'Failed to save wellness vitals'); + } + } catch (err) { + alert('Error saving wellness vitals'); + } + }; + + // ABDM M2 Health Document Actions + const openDocModal = (patient) => { + setActiveDocPatient(patient); + setScanImageBase64(''); + setScanImagePreview(''); + setPatientDocForm({ + document_type: 'Health Document', + document_date: new Date().toISOString().slice(0, 10), + remarks: '' + }); + setShowPatientSearchModal(false); + stopBarcodeQrScanner(); + }; + + const handleSavePatientDocument = async () => { + if (!activeDocPatient) return; + if (!scanImageBase64) { + alert('Please capture or upload a document photo first.'); + return; + } + try { + const payload = { + patient_id: activeDocPatient.id, + nurse_name: authenticatedNurse ? authenticatedNurse.name : 'Staff Nurse', + nurse_id: authenticatedNurse ? authenticatedNurse.id : 0, + image_base64: scanImageBase64, + ...patientDocForm + }; + const res = await fetch('/api/v1/nursing/patient/document/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const json = await res.json(); + if (json.status === 1) { + alert('✅ ' + (json.message || 'ABDM M2 Health Document uploaded successfully!')); + setActiveDocPatient(null); + setScanImageBase64(''); + setScanImagePreview(''); + } else { + alert(json.message || 'Failed to upload health document'); + } + } catch (err) { + alert('Error uploading health document'); + } + }; + + // Vitals History Actions + const openWellnessHistory = async (patient) => { + setWellnessHistoryPatient(patient); + setLoadingHistory(true); + try { + const res = await fetch(`/api/v1/nursing/patient/wellness/history/${patient.id}`); + const json = await res.json(); + if (json.status === 1) { + setWellnessHistoryList(json.records || []); + } + } catch (err) { + console.error('Failed to load history', err); + } finally { + setLoadingHistory(false); + } + }; + const getInitials = (nameStr) => { if (!nameStr) return 'N'; const parts = nameStr.trim().split(' '); @@ -788,15 +1189,35 @@ ); }; - // Filtered lists + const displayTempF = (tempC) => { + if (tempC === null || tempC === undefined || tempC === '') return '-'; + const val = (parseFloat(tempC) * 9 / 5) + 32; + return val.toFixed(1) + '°F'; + }; + + const formatDateTime = (dtStr) => { + if (!dtStr) return '-'; + try { + const d = new Date(dtStr.replace(/-/g, '/')); + if (isNaN(d.getTime())) return dtStr; + return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }); + } catch (e) { + return dtStr; + } + }; + + // Filtered lists: Only Admitted IPD Patients with Bed (Occupied Beds) const filteredBeds = beds.filter(bed => { + const isOccupied = bed.status === 'occupied' || (bed.patient_name && bed.patient_name.trim() !== '') || Boolean(bed.ipd_id); const matchesWard = selectedWard === 'all' || bed.ward_name === selectedWard; const q = searchQuery.toLowerCase(); const matchesSearch = searchQuery === '' || (bed.bed_number || '').toLowerCase().includes(q) || (bed.patient_name || '').toLowerCase().includes(q) || - (bed.uhid || '').toLowerCase().includes(q); - return matchesWard && matchesSearch; + (bed.uhid || '').toLowerCase().includes(q) || + (bed.ipd_code || '').toLowerCase().includes(q) || + (bed.doctor_name || '').toLowerCase().includes(q); + return isOccupied && matchesWard && matchesSearch; }); const filteredOpdAppointments = opdAppointments.filter(app => { @@ -932,7 +1353,7 @@ - - @@ -987,42 +1408,48 @@
- {filteredBeds.map(bed => ( -
-
openWorkspace(bed)} - > -
- Bed {bed.bed_number} - - {bed.status} - -
- {bed.ward_name || 'General Ward'} - - {bed.patient_name ? ( -
-
{bed.patient_name}
- UHID: {bed.uhid || 'N/A'} • IPD: {bed.ipd_code || 'N/A'} - - {bed.doctor_name || 'Dr. Assigned'} + {filteredBeds.length === 0 ? ( +
+ +
No Admitted Patients Found
+ There are currently no admitted IPD patients in the selected ward. +
+ ) : ( + filteredBeds.map(bed => ( +
+
openWorkspace(bed)} + > +
+ Bed {bed.bed_number} + + {bed.status}
- ) : ( -
Available for Admission
- )} + {bed.ward_name || 'General Ward'} + + {bed.patient_name ? ( +
+
{bed.patient_name}
+ UHID: {bed.uhid || 'N/A'} • IPD: {bed.ipd_code || 'N/A'} + + {bed.doctor_name || 'Dr. Assigned'} + +
+ ) : ( +
Available for Admission
+ )} - {bed.status === 'occupied' && (
-
- )} +
-
- ))} + )) + )}
) : ( @@ -1219,64 +1646,448 @@
{/* Form Tabs for Bedside Care */} -