diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 40a1360..4275dd2 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -223,6 +223,21 @@ $routes->post('Payment_Medical/change_user', 'Payment_Medical::change_user'); service('auth')->routes($routes); +// Legacy compatibility: some IPD charge screens still refresh via old IpdNew URL. +$routes->get('IpdNew/show_ipd_items/(:num)', 'Billing\\Ipd::panelTab/$1/ipd-charges'); +$routes->get('IpdNew/show_ipd_items/(:num)/(:num)', 'Billing\\Ipd::panelTab/$1/ipd-charges'); + +// Legacy compatibility: old discharge summary links should open current discharge tab. +$routes->match(['get', 'post'], 'Ipd_discharge/ipd_select/(:num)', 'Ipd_discharge::ipd_select/$1'); +$routes->match(['get', 'post'], 'Ipd_discharge/ipd_select/(:num)/(:num)', 'Ipd_discharge::ipd_select/$1/$2'); +$routes->get('Ipd_discharge/section_past_data', 'Ipd_discharge::section_past_data'); +$routes->get('Ipd_discharge/section_template_list', 'Ipd_discharge::section_template_list'); +$routes->post('Ipd_discharge/section_template_save', 'Ipd_discharge::section_template_save'); +$routes->match(['get', 'post'], 'Ipd_discharge/preview_discharge_report/(:num)', 'Ipd_discharge::preview_discharge_report/$1'); +$routes->get('Ipd_discharge/show_discharge/(:num)', 'Ipd_discharge::show_discharge/$1'); +$routes->get('Ipd_discharge/show_discharge/(:num)/(:num)', 'Ipd_discharge::show_discharge/$1/$2'); +$routes->get('Ipd_discharge/show_file3/(:num)', 'Ipd_discharge::show_file3/$1'); + $routes->get('setup/db-tools', 'Setup::dbTools'); $routes->post('setup/db-tools/generate', 'Setup::generateMigrations'); $routes->post('setup/db-tools/export-master-schema', 'Setup::exportMasterSchema'); @@ -244,6 +259,10 @@ $routes->group('billing', function($routes) { $routes->post('ipd/charge/add/(:num)', 'Billing\\Ipd::addIpdCharge/$1'); $routes->post('ipd/charge/update/(:num)', 'Billing\\Ipd::updateIpdCharge/$1'); $routes->post('ipd/charge/delete/(:num)', 'Billing\\Ipd::deleteIpdCharge/$1'); + $routes->post('ipd/bedside-charge/add/(:num)', 'Billing\\Ipd::addBedsideCharge/$1'); + $routes->post('ipd/doctor-visit/add/(:num)', 'Billing\\Ipd::addDoctorVisitCharge/$1'); + $routes->post('ipd/nursing-charge/update/(:num)/(:num)', 'Billing\\Ipd::updateNursingCharge/$1/$2'); + $routes->post('ipd/nursing-charge/delete/(:num)/(:num)', 'Billing\\Ipd::deleteNursingCharge/$1/$2'); $routes->post('ipd/charge/package/(:num)', 'Billing\\Ipd::updateIpdChargePackage/$1'); $routes->post('ipd/package/add/(:num)', 'Billing\\Ipd::addIpdPackage/$1'); $routes->post('ipd/package/update/(:num)', 'Billing\\Ipd::updateIpdPackage/$1'); @@ -319,6 +338,21 @@ $routes->group('billing', function($routes) { $routes->post('charges/update-discount', 'Billing\\Charges::updateDiscount'); }); +$routes->get('ipd/patient', 'IpdPatient::index'); +$routes->get('ipd/patient/workspace/(:num)', 'IpdPatient::workspace/$1'); +$routes->post('ipd/patient/nursing/save/(:num)', 'IpdPatient::saveNursingEntry/$1'); +$routes->post('ipd/patient/history/save/(:num)', 'IpdPatient::saveAdmissionHistory/$1'); +$routes->post('ipd/patient/nursing/scan/(:num)', 'IpdPatient::scanNursingPaper/$1'); +$routes->post('ipd/patient/nursing/scan-save/(:num)', 'IpdPatient::saveScannedNursingEntries/$1'); +$routes->get('ipd/patient/nursing/print/(:num)', 'IpdPatient::nursingChartPrint/$1'); + +$routes->get('nursing-bedside-items', 'Setting\\NursingBedsideItems::index'); +$routes->get('nursing-bedside-items/(:num)', 'Setting\\NursingBedsideItems::get/$1'); +$routes->post('nursing-bedside-items/save', 'Setting\\NursingBedsideItems::save'); +$routes->get('nursing-bedside-items/insurance/(:num)', 'Setting\\NursingBedsideItems::insuranceList/$1'); +$routes->post('nursing-bedside-items/insurance/add', 'Setting\\NursingBedsideItems::insuranceAdd'); +$routes->post('nursing-bedside-items/insurance/remove', 'Setting\\NursingBedsideItems::insuranceRemove'); + $routes->get('Opd/addopd/(:num)', 'Opd::addopd/$1'); $routes->get('Opd/addopd/(:num)/(:num)', 'Opd::addopd/$1/$2'); $routes->get('opd/appointment', 'Opd::appointment'); @@ -479,6 +513,9 @@ $routes->post('Lab_Admin/add_test_repo/(:num)/(:num)', 'Setting\Template::add_te $routes->post('Lab_Admin/remove_test_item/(:num)/(:num)', 'Setting\Template::remove_test_item/$1/$2'); $routes->post('Lab_Admin/test_item_search', 'Setting\Template::test_item_search'); +$routes->match(['get', 'post'], 'setting/template/discharge_templates', 'Setting\Template::discharge_templates'); +$routes->get('setting/template/discharge_templates/delete/(:num)', 'Setting\Template::discharge_template_delete/$1'); + $routes->get('Opdcase/addopd/(:num)', 'Opdcase::addopd/$1'); $routes->post('Opdcase/showfee', 'Opdcase::showfee'); $routes->post('Opdcase/confirm_opd', 'Opdcase::confirm_opd'); @@ -496,6 +533,10 @@ $routes->get('Report/diagnosis_report_data/(:segment)/(:segment)', 'Report::diag $routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)', 'Report::diagnosis_report_data/$1/$2/$3'); $routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::diagnosis_report_data/$1/$2/$3/$4'); +$routes->get('Report/nabh_audit_report', 'Report::nabh_audit_report'); +$routes->get('Report/nabh_audit_report_data/(:segment)/(:segment)/(:segment)', 'Report::nabh_audit_report_data/$1/$2/$3'); +$routes->get('Report/nabh_audit_report_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::nabh_audit_report_data/$1/$2/$3/$4'); + // Insurance Credit Reports $routes->get('Report/insurance_credit_main', 'Report::insurance_credit_main'); $routes->get('Report/insurance_opd_report', 'Report::insurance_opd_report'); diff --git a/app/Controllers/IpdPatient.php b/app/Controllers/IpdPatient.php new file mode 100644 index 0000000..c98981a --- /dev/null +++ b/app/Controllers/IpdPatient.php @@ -0,0 +1,825 @@ +ipdBillingModel = new IpdBillingModel(); + $this->ipdNursingEntryModel = new IpdNursingEntryModel(); + $this->nursingBedsideItemModel = new NursingBedsideItemModel(); + $this->nursingScanExtractionService = new NursingScanExtractionService(); + helper(['common', 'form', 'age']); + } + + public function index() + { + $permission = $this->requireAnyPermission([ + 'billing.access', + 'billing.ipd.current-admission', + 'billing.ipd.invoice', + ]); + if ($permission) { + return $permission; + } + + return view('ipd_nursing/patient_list', [ + 'records' => $this->ipdBillingModel->getCurrentAdmissions(), + ]); + } + + public function workspace(int $ipdId) + { + $permission = $this->requireAnyPermission([ + 'billing.access', + 'billing.ipd.current-admission', + 'billing.ipd.invoice', + ]); + if ($permission) { + return $permission; + } + + $panelData = $this->ipdBillingModel->getIpdPanelInfo($ipdId); + if (empty($panelData)) { + return $this->response->setStatusCode(404)->setBody('IPD not found'); + } + + $caseMeta = $this->ipdBillingModel->getIpdCaseMeta($ipdId); + $insCompId = (int) ($caseMeta['insurance_id'] ?? 0); + if ($insCompId <= 0) { + $insCompId = 1; + } + + $docList = $this->ipdBillingModel->getIpdDoctorList(); + $doctorIds = array_map(static fn ($doc) => (int) ($doc->id ?? 0), $docList); + $doctorIds = array_values(array_filter($doctorIds, static fn ($id) => $id > 0)); + $person = $panelData['person_info'] ?? null; + $patientId = max(0, (int) ($person->id ?? 0)); + if ($patientId <= 0) { + $patientId = $this->getPatientIdFromIpd($ipdId); + } + + $historyFields = [ + 'is_smoking' => 'Smoking', + 'is_alcohol' => 'Alcohol', + 'is_tobacoo' => 'Tobacco', + 'is_drug_abuse' => 'Drug abuse', + 'is_hypertesion' => 'Hypertension', + 'is_niddm' => 'Type 2 diabetes mellitus (DM)', + 'is_hbsag' => 'HBsAg', + 'is_hcv' => 'HCV', + 'is_hiv_I_II' => 'HIV I & II', + ]; + $patientHistoryRow = $this->getPatientHistoryFlags($patientId, array_keys($historyFields)); + $opdHistorySnapshot = $this->getLatestOpdHistorySnapshot($patientId); + $isFemalePatient = strtolower((string) ($person->xgender ?? '')) === 'female'; + + return view('ipd_nursing/workspace', [ + 'ipd_info' => $panelData['ipd_info'] ?? null, + 'person_info' => $person, + 'nursing_entries' => $this->ipdNursingEntryModel->getByIpd($ipdId), + 'nursing_charge_history' => $this->ipdBillingModel->getNursingChargeHistory($ipdId), + 'bedside_items_by_category' => $this->nursingBedsideItemModel->getBillableGroupedByCategory($insCompId), + 'doctor_visit_fee_types' => $this->ipdBillingModel->getDoctorVisitFeeTypes(), + 'doctor_visit_fee_map' => $this->ipdBillingModel->getDoctorVisitFeeMap($doctorIds), + 'doc_list' => $docList, + 'history_fields' => $historyFields, + 'patient_history_row' => $patientHistoryRow, + 'opd_history_snapshot' => $opdHistorySnapshot, + 'is_female_patient' => $isFemalePatient, + ]); + } + + public function saveAdmissionHistory(int $ipdId) + { + $permission = $this->requireAnyPermission([ + 'billing.access', + 'billing.ipd.current-admission', + 'billing.ipd.invoice', + ]); + if ($permission) { + return $permission; + } + + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Invalid request']); + } + + $patientId = $this->getPatientIdFromIpd($ipdId); + if ($patientId <= 0) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Patient not found']); + } + + $historyFieldKeys = [ + 'is_smoking', + 'is_alcohol', + 'is_tobacoo', + 'is_drug_abuse', + 'is_hypertesion', + 'is_niddm', + 'is_hbsag', + 'is_hcv', + 'is_hiv_I_II', + ]; + + $saved = false; + if ($this->db->tableExists('patient_master')) { + $patientFields = $this->db->getFieldNames('patient_master') ?? []; + if (in_array('id', $patientFields, true)) { + $historyUpdate = []; + foreach ($historyFieldKeys as $field) { + if (in_array($field, $patientFields, true)) { + $historyUpdate[$field] = (int) ($this->request->getPost($field) ?? 0) === 1 ? 1 : 0; + } + } + + if (! empty($historyUpdate)) { + $saved = (bool) $this->db->table('patient_master') + ->where('id', $patientId) + ->update($historyUpdate) || $saved; + } + } + } + + $opdPayload = [ + 'drug_allergy_status' => trim((string) ($this->request->getPost('drug_allergy_status') ?? '')), + 'drug_allergy_details' => trim((string) ($this->request->getPost('drug_allergy_details') ?? '')), + 'adr_history' => trim((string) ($this->request->getPost('adr_history') ?? '')), + 'current_medications' => trim((string) ($this->request->getPost('current_medications') ?? '')), + 'co_morbidities' => trim((string) ($this->request->getPost('co_morbidities') ?? '')), + 'women_lmp' => trim((string) ($this->request->getPost('women_lmp') ?? '')), + 'women_last_baby' => trim((string) ($this->request->getPost('women_last_baby') ?? '')), + 'women_pregnancy_related' => trim((string) ($this->request->getPost('women_pregnancy_related') ?? '')), + 'women_related_problems' => trim((string) ($this->request->getPost('women_related_problems') ?? '')), + 'hpi_note' => trim((string) ($this->request->getPost('hpi_note') ?? '')), + ]; + + if ($this->db->tableExists('opd_prescription')) { + $rxFields = $this->db->getFieldNames('opd_prescription') ?? []; + if (in_array('p_id', $rxFields, true) && in_array('id', $rxFields, true)) { + $latest = $this->db->table('opd_prescription') + ->select('id,Prescriber_Remarks') + ->where('p_id', $patientId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + if (is_array($latest) && ! empty($latest['id'])) { + $update = []; + foreach (['drug_allergy_status', 'drug_allergy_details', 'adr_history', 'current_medications', 'women_lmp', 'women_last_baby', 'women_pregnancy_related', 'women_related_problems'] as $field) { + if (in_array($field, $rxFields, true)) { + $update[$field] = (string) ($opdPayload[$field] ?? ''); + } + } + + if (in_array('Prescriber_Remarks', $rxFields, true)) { + $remarks = (string) ($latest['Prescriber_Remarks'] ?? ''); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Drug Allergy Status', (string) ($opdPayload['drug_allergy_status'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Drug Allergy Details', (string) ($opdPayload['drug_allergy_details'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'ADR History', (string) ($opdPayload['adr_history'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Current Medications', (string) ($opdPayload['current_medications'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Co-Morbidities', (string) ($opdPayload['co_morbidities'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'HPI Note', (string) ($opdPayload['hpi_note'] ?? '')); + $update['Prescriber_Remarks'] = $remarks; + } + + if (! empty($update)) { + $saved = (bool) $this->db->table('opd_prescription') + ->where('id', (int) $latest['id']) + ->update($update) || $saved; + } + } + } + } + + return $this->response->setJSON([ + 'status' => $saved ? 1 : 0, + 'message' => $saved ? 'Admission history updated' : 'No data updated', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function saveNursingEntry(int $ipdId) + { + $permission = $this->requireAnyPermission([ + 'billing.access', + 'billing.ipd.current-admission', + 'billing.ipd.invoice', + ]); + if ($permission) { + return $permission; + } + + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Invalid request']); + } + + $entryType = (string) ($this->request->getPost('entry_type') ?? ''); + if (! in_array($entryType, ['vitals', 'fluid', 'treatment', 'admission'], true)) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid nursing entry type']); + } + + $recordedAtInput = (string) ($this->request->getPost('recorded_at') ?? ''); + $recordedAt = $recordedAtInput !== '' + ? str_replace('T', ' ', $recordedAtInput) . (strlen($recordedAtInput) === 16 ? ':00' : '') + : date('Y-m-d H:i:s'); + + $user = auth()->user(); + $recordedBy = $user->username ?? $user->email ?? 'User'; + $recordedById = $user->id ?? null; + + $data = [ + 'ipd_id' => $ipdId, + 'entry_type' => $entryType, + 'recorded_at' => $recordedAt, + 'shift_name' => $this->resolveShiftName($recordedAt), + 'temperature_c' => $this->resolveTemperatureCFromPost(), + 'pulse_rate' => $this->request->getPost('pulse_rate') !== null && $this->request->getPost('pulse_rate') !== '' ? (int) $this->request->getPost('pulse_rate') : null, + 'resp_rate' => $this->request->getPost('resp_rate') !== null && $this->request->getPost('resp_rate') !== '' ? (int) $this->request->getPost('resp_rate') : null, + 'bp_systolic' => $this->request->getPost('bp_systolic') !== null && $this->request->getPost('bp_systolic') !== '' ? (int) $this->request->getPost('bp_systolic') : null, + 'bp_diastolic' => $this->request->getPost('bp_diastolic') !== null && $this->request->getPost('bp_diastolic') !== '' ? (int) $this->request->getPost('bp_diastolic') : null, + 'spo2' => $this->request->getPost('spo2') !== null && $this->request->getPost('spo2') !== '' ? (int) $this->request->getPost('spo2') : null, + 'weight_kg' => $this->request->getPost('weight_kg') !== null && $this->request->getPost('weight_kg') !== '' ? (float) $this->request->getPost('weight_kg') : null, + 'fluid_direction' => (string) ($this->request->getPost('fluid_direction') ?? ''), + 'fluid_route' => (string) ($this->request->getPost('fluid_route') ?? ''), + 'fluid_amount_ml' => $this->request->getPost('fluid_amount_ml') !== null && $this->request->getPost('fluid_amount_ml') !== '' ? (int) $this->request->getPost('fluid_amount_ml') : null, + 'treatment_text' => (string) ($this->request->getPost('treatment_text') ?? ''), + 'general_note' => (string) ($this->request->getPost('general_note') ?? ''), + 'recorded_by' => $recordedBy, + 'recorded_by_id' => $recordedById, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($entryType === 'vitals') { + $hasVitals = $data['temperature_c'] !== null + || $data['pulse_rate'] !== null + || $data['resp_rate'] !== null + || $data['bp_systolic'] !== null + || $data['bp_diastolic'] !== null + || $data['spo2'] !== null + || $data['weight_kg'] !== null; + if (! $hasVitals) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Enter at least one vital value']); + } + } + + if ($entryType === 'fluid' && $data['fluid_amount_ml'] === null) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Fluid amount is required']); + } + + if ($entryType === 'treatment' && trim((string) $data['treatment_text']) === '') { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Treatment note is required']); + } + + if ($entryType === 'admission') { + $hasVitals = $data['temperature_c'] !== null + || $data['pulse_rate'] !== null + || $data['resp_rate'] !== null + || $data['bp_systolic'] !== null + || $data['bp_diastolic'] !== null + || $data['spo2'] !== null + || $data['weight_kg'] !== null; + $hasComplaint = trim((string) $data['treatment_text']) !== ''; + + if (! $hasVitals && ! $hasComplaint) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Enter admission complaints or at least one vital value', + ]); + } + } + + $this->ipdNursingEntryModel->insert($data); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Nursing entry saved', + ]); + } + + public function scanNursingPaper(int $ipdId) + { + $permission = $this->requireAnyPermission([ + 'billing.access', + 'billing.ipd.current-admission', + 'billing.ipd.invoice', + ]); + if ($permission) { + return $permission; + } + + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Invalid request']); + } + + $docType = strtolower(trim((string) ($this->request->getPost('document_type') ?? 'auto'))); + if (! in_array($docType, ['auto', 'vitals', 'fluid', 'treatment'], true)) { + $docType = 'auto'; + } + + $manualText = trim((string) ($this->request->getPost('ocr_text') ?? '')); + $file = $this->request->getFile('scan_file'); + $uploadedMeta = null; + + if ($file && $file->isValid() && ! $file->hasMoved()) { + $allowed = ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'txt']; + $ext = strtolower((string) $file->getExtension()); + if (! in_array($ext, $allowed, true)) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Only PDF, image, or text files are allowed', + ]); + } + + $scanRef = 'NSCAN-' . date('YmdHis') . '-' . substr(md5((string) mt_rand()), 0, 6); + $targetDir = WRITEPATH . 'uploads/nursing_scans/' . $ipdId; + if (! is_dir($targetDir)) { + @mkdir($targetDir, 0775, true); + } + $storedName = $scanRef . '.' . $ext; + $file->move($targetDir, $storedName, true); + + $uploadedMeta = [ + 'scan_ref' => $scanRef, + 'path' => rtrim($targetDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $storedName, + 'mime' => (string) $file->getClientMimeType(), + ]; + } + + if ($manualText === '' && $uploadedMeta === null) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Upload a scan file or paste OCR text', + ]); + } + + $parsed = $this->nursingScanExtractionService->extract($docType, $manualText, $uploadedMeta); + $rows = is_array($parsed['rows'] ?? null) ? $parsed['rows'] : []; + + return $this->response->setJSON([ + 'status' => 1, + 'message' => empty($rows) + ? 'Scan processed. No structured rows detected; please review and edit manually.' + : 'Scan processed. Review extracted rows before saving.', + 'scan_ref' => (string) ($parsed['scan_ref'] ?? ''), + 'document_type' => $docType, + 'rows' => $rows, + 'warnings' => $parsed['warnings'] ?? [], + 'nabh_requirements' => [ + 'Each row must have recorded date/time.', + 'Each row must include nursing note or measurable values.', + 'Vitals rows require at least one vital parameter.', + 'Fluid rows require direction and amount in ml.', + 'All AI/OCR rows must be nurse-reviewed before save.', + ], + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function saveScannedNursingEntries(int $ipdId) + { + $permission = $this->requireAnyPermission([ + 'billing.access', + 'billing.ipd.current-admission', + 'billing.ipd.invoice', + ]); + if ($permission) { + return $permission; + } + + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Invalid request']); + } + + $rowsJson = (string) ($this->request->getPost('rows_json') ?? '[]'); + $rows = json_decode($rowsJson, true); + if (! is_array($rows) || empty($rows)) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'No reviewed rows to save', + ]); + } + + $scanRef = trim((string) ($this->request->getPost('scan_ref') ?? '')); + + $user = auth()->user(); + $recordedBy = $user->username ?? $user->email ?? 'User'; + $recordedById = $user->id ?? null; + + $savedCount = 0; + $errors = []; + + foreach ($rows as $idx => $row) { + if (! is_array($row)) { + $errors[] = 'Row ' . ($idx + 1) . ': Invalid payload.'; + continue; + } + + $entryType = strtolower(trim((string) ($row['entry_type'] ?? ''))); + if (! in_array($entryType, ['vitals', 'fluid', 'treatment'], true)) { + $errors[] = 'Row ' . ($idx + 1) . ': Unsupported entry type.'; + continue; + } + + $recordedAt = $this->normalizeScannedRecordedAt((string) ($row['recorded_at'] ?? '')); + if ($recordedAt === '') { + $errors[] = 'Row ' . ($idx + 1) . ': Recorded At is required.'; + continue; + } + + $temperatureF = $this->toNullableFloat($row['temperature_f'] ?? null); + $temperatureC = $temperatureF !== null ? round(($temperatureF - 32) * 5 / 9, 2) : null; + $pulse = $this->toNullableInt($row['pulse_rate'] ?? null); + $resp = $this->toNullableInt($row['resp_rate'] ?? null); + $sbp = $this->toNullableInt($row['bp_systolic'] ?? null); + $dbp = $this->toNullableInt($row['bp_diastolic'] ?? null); + $spo2 = $this->toNullableInt($row['spo2'] ?? null); + $weight = $this->toNullableFloat($row['weight_kg'] ?? null); + $fluidAmount = $this->toNullableInt($row['fluid_amount_ml'] ?? null); + $fluidDirection = strtolower(trim((string) ($row['fluid_direction'] ?? ''))); + $fluidRoute = trim((string) ($row['fluid_route'] ?? '')); + $treatmentText = trim((string) ($row['treatment_text'] ?? '')); + $generalNote = trim((string) ($row['general_note'] ?? '')); + $confidence = $this->toNullableFloat($row['confidence'] ?? null); + $isCorrected = (int) ($row['is_corrected'] ?? 0) === 1; + + $sourceParts = ['Source: Scanned Paper']; + if ($scanRef !== '') { + $sourceParts[] = 'ScanRef: ' . $scanRef; + } + $sourceParts[] = 'ReviewedBy: ' . $recordedBy; + $sourceParts[] = 'ReviewedAt: ' . date('Y-m-d H:i:s'); + if ($confidence !== null) { + $sourceParts[] = 'AIConfidence: ' . max(0, min(100, (int) round($confidence * 100))) . '%'; + } + $sourceParts[] = 'Corrected: ' . ($isCorrected ? 'Yes' : 'No'); + $sourceTag = ' [' . implode(' | ', $sourceParts) . ']'; + $generalNote = trim($generalNote . $sourceTag); + + if ($entryType === 'vitals') { + $hasVitals = $temperatureC !== null || $pulse !== null || $resp !== null || $sbp !== null || $dbp !== null || $spo2 !== null || $weight !== null; + if (! $hasVitals) { + $errors[] = 'Row ' . ($idx + 1) . ': Vitals row requires at least one vital parameter.'; + continue; + } + } + + if ($entryType === 'fluid') { + if (! in_array($fluidDirection, ['intake', 'output'], true)) { + $errors[] = 'Row ' . ($idx + 1) . ': Fluid direction must be intake/output.'; + continue; + } + if ($fluidAmount === null || $fluidAmount <= 0) { + $errors[] = 'Row ' . ($idx + 1) . ': Fluid amount is required.'; + continue; + } + } + + if ($entryType === 'treatment' && $treatmentText === '' && $generalNote === $sourceTag) { + $errors[] = 'Row ' . ($idx + 1) . ': Treatment text or note is required.'; + continue; + } + + $insert = [ + 'ipd_id' => $ipdId, + 'entry_type' => $entryType, + 'recorded_at' => $recordedAt, + 'shift_name' => $this->resolveShiftName($recordedAt), + 'temperature_c' => $temperatureC, + 'pulse_rate' => $pulse, + 'resp_rate' => $resp, + 'bp_systolic' => $sbp, + 'bp_diastolic' => $dbp, + 'spo2' => $spo2, + 'weight_kg' => $weight, + 'fluid_direction' => $entryType === 'fluid' ? $fluidDirection : '', + 'fluid_route' => $entryType === 'fluid' ? $fluidRoute : '', + 'fluid_amount_ml' => $entryType === 'fluid' ? $fluidAmount : null, + 'treatment_text' => $entryType === 'treatment' ? $treatmentText : '', + 'general_note' => $generalNote, + 'recorded_by' => $recordedBy, + 'recorded_by_id' => $recordedById, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $this->ipdNursingEntryModel->insert($insert); + $savedCount++; + } + + return $this->response->setJSON([ + 'status' => $savedCount > 0 ? 1 : 0, + 'message' => $savedCount > 0 + ? ('Saved ' . $savedCount . ' scanned nursing row(s).') + : 'No rows saved. Please fix validation errors.', + 'saved_count' => $savedCount, + 'errors' => $errors, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function nursingChartPrint(int $ipdId) + { + $permission = $this->requireAnyPermission([ + 'billing.access', + 'billing.ipd.current-admission', + 'billing.ipd.invoice', + ]); + if ($permission) { + return $permission; + } + + $panelData = $this->ipdBillingModel->getIpdPanelInfo($ipdId); + if (empty($panelData)) { + return $this->response->setStatusCode(404)->setBody('IPD not found'); + } + + $chartDate = (string) ($this->request->getGet('date') ?? date('Y-m-d')); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $chartDate)) { + $chartDate = date('Y-m-d'); + } + + $nurse = trim((string) ($this->request->getGet('nurse') ?? '')); + $startDateTime = $chartDate . ' 00:00:00'; + $endDateTime = $chartDate . ' 23:59:59'; + + $entries = $this->ipdNursingEntryModel->getByIpdPeriod($ipdId, $startDateTime, $endDateTime, $nurse); + + return view('billing/ipd/nursing_chart_print', [ + 'ipd_info' => $panelData['ipd_info'] ?? null, + 'person_info' => $panelData['person_info'] ?? null, + 'nursing_entries' => $entries, + 'chart_date' => $chartDate, + 'chart_nurse' => $nurse, + ]); + } + + private function requireAnyPermission(array $permissions) + { + if (! function_exists('auth')) { + return null; + } + + $user = auth()->user(); + if (! $user || ! method_exists($user, 'can')) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + foreach ($permissions as $permission) { + if ($user->can($permission)) { + return null; + } + } + + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + private function resolveShiftName(string $recordedAt): string + { + $timePart = substr($recordedAt, 11, 5); + if (! preg_match('/^\d{2}:\d{2}$/', $timePart)) { + return 'Morning'; + } + + [$hour, $minute] = array_map('intval', explode(':', $timePart)); + $minutesFromMidnight = ($hour * 60) + $minute; + + if ($minutesFromMidnight >= 360 && $minutesFromMidnight < 840) { + return 'Morning'; + } + + if ($minutesFromMidnight >= 840 && $minutesFromMidnight < 1320) { + return 'Evening'; + } + + return 'Night'; + } + + private function resolveTemperatureCFromPost(): ?float + { + $temperatureF = $this->request->getPost('temperature_f'); + if ($temperatureF !== null && $temperatureF !== '') { + return round((((float) $temperatureF) - 32) * 5 / 9, 2); + } + + $temperatureC = $this->request->getPost('temperature_c'); + if ($temperatureC !== null && $temperatureC !== '') { + return (float) $temperatureC; + } + + return null; + } + + private function normalizeScannedRecordedAt(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + $candidate = str_replace('T', ' ', $value); + if (preg_match('/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}(:\d{2})?$/', $candidate) === 1) { + return strlen($candidate) === 16 ? $candidate . ':00' : $candidate; + } + + $ts = strtotime($value); + if ($ts === false) { + return ''; + } + + return date('Y-m-d H:i:s', $ts); + } + + private function toNullableInt($value): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (! is_numeric($value)) { + return null; + } + + return (int) $value; + } + + private function toNullableFloat($value): ?float + { + if ($value === null || $value === '') { + return null; + } + + if (! is_numeric($value)) { + return null; + } + + return (float) $value; + } + + private function getPatientIdFromIpd(int $ipdId): int + { + if ($ipdId <= 0 || ! $this->db->tableExists('ipd_master')) { + return 0; + } + + $fields = $this->db->getFieldNames('ipd_master') ?? []; + if (! in_array('id', $fields, true) || ! in_array('p_id', $fields, true)) { + return 0; + } + + $row = $this->db->table('ipd_master') + ->select('p_id') + ->where('id', $ipdId) + ->get(1) + ->getRowArray(); + + return max(0, (int) ($row['p_id'] ?? 0)); + } + + private function getPatientHistoryFlags(int $patientId, array $keys): array + { + $result = []; + foreach ($keys as $key) { + $result[$key] = 0; + } + + if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) { + return $result; + } + + $fields = $this->db->getFieldNames('patient_master') ?? []; + if (! in_array('id', $fields, true)) { + return $result; + } + + $select = ['id']; + foreach ($keys as $key) { + if (in_array($key, $fields, true)) { + $select[] = $key; + } + } + + $row = $this->db->table('patient_master') + ->select(implode(',', $select)) + ->where('id', $patientId) + ->get(1) + ->getRowArray() ?? []; + + foreach ($keys as $key) { + if (isset($row[$key])) { + $result[$key] = (int) $row[$key] === 1 ? 1 : 0; + } + } + + return $result; + } + + private function extractLabeledField(string $remarks, string $label): string + { + if (preg_match('/^\s*' . preg_quote($label, '/') . '\s*:\s*(.+)$/im', $remarks, $m) !== 1) { + return ''; + } + + return trim((string) ($m[1] ?? '')); + } + + private function getLatestOpdHistorySnapshot(int $patientId): array + { + $empty = [ + 'drug_allergy_status' => '', + 'drug_allergy_details' => '', + 'adr_history' => '', + 'current_medications' => '', + 'co_morbidities' => '', + 'women_lmp' => '', + 'women_last_baby' => '', + 'women_pregnancy_related' => '', + 'women_related_problems' => '', + 'hpi_note' => '', + ]; + + if ($patientId <= 0 || ! $this->db->tableExists('opd_prescription')) { + return $empty; + } + + $fields = $this->db->getFieldNames('opd_prescription') ?? []; + if (! in_array('p_id', $fields, true)) { + return $empty; + } + + $row = $this->db->table('opd_prescription') + ->where('p_id', $patientId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray() ?? []; + + if (empty($row)) { + return $empty; + } + + $remarks = trim((string) ($row['Prescriber_Remarks'] ?? '')); + foreach (['drug_allergy_status', 'drug_allergy_details', 'adr_history', 'current_medications', 'women_lmp', 'women_last_baby', 'women_pregnancy_related', 'women_related_problems'] as $field) { + $value = trim((string) ($row[$field] ?? '')); + if ($value !== '') { + $empty[$field] = $value; + } + } + + if ($empty['co_morbidities'] === '') { + $empty['co_morbidities'] = $this->extractLabeledField($remarks, 'Co-Morbidities'); + } + if ($empty['hpi_note'] === '') { + $empty['hpi_note'] = $this->extractLabeledField($remarks, 'HPI Note'); + } + + if ($empty['drug_allergy_status'] === '') { + $empty['drug_allergy_status'] = $this->extractLabeledField($remarks, 'Drug Allergy Status'); + } + if ($empty['drug_allergy_details'] === '') { + $empty['drug_allergy_details'] = $this->extractLabeledField($remarks, 'Drug Allergy Details'); + } + if ($empty['adr_history'] === '') { + $empty['adr_history'] = $this->extractLabeledField($remarks, 'ADR History'); + } + if ($empty['current_medications'] === '') { + $empty['current_medications'] = $this->extractLabeledField($remarks, 'Current Medications'); + } + + return $empty; + } + + private function upsertLabeledLineInRemarks(string $remarks, string $label, string $value): string + { + $remarks = trim($remarks); + $pattern = '/^\s*' . preg_quote($label, '/') . '\s*:\s*.*$/im'; + $remarks = preg_replace($pattern, '', $remarks) ?? $remarks; + + $lines = array_filter(array_map(static function (string $line): string { + return trim($line); + }, preg_split('/\R/', $remarks) ?: []), static function (string $line): bool { + return $line !== ''; + }); + + $value = trim($value); + if ($value !== '') { + $lines[] = $label . ': ' . $value; + } + + return trim(implode(PHP_EOL, $lines)); + } +} diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index cdbc0e0..be4dc12 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -55,6 +55,203 @@ class Ipd_discharge extends BaseController return (string) ($row['content'] ?? ''); } + private function ensureDischargeTemplateTable(): void + { + if ($this->db->tableExists('ipd_discharge_templates')) { + return; + } + + $sql = "CREATE TABLE IF NOT EXISTS ipd_discharge_templates ( + id INT NOT NULL AUTO_INCREMENT, + template_name VARCHAR(120) NOT NULL, + template_html LONGTEXT NOT NULL, + is_default TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"; + + $this->db->query($sql); + } + + private function defaultDischargeTemplateHtml(): string + { + return '

Discharge Summary

' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
Patient: {{PATIENT_NAME}}UHID: {{UHID}}IPD: {{IPD_CODE}}
Age/Gender: {{AGE_GENDER}}Admit Date: {{ADMIT_DATE}}Discharge Date: {{DISCHARGE_DATE}}
' + . '
{{CONTENT}}
'; + } + + private function nabhDischargeTemplateHtml(): string + { + return '

DISCHARGE SUMMARY

' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
Patient Name: {{PATIENT_NAME}}UHID: {{UHID}}IPD No.: {{IPD_CODE}}
Age/Gender: {{AGE_GENDER}}Date of Admission: {{ADMIT_DATE}}Date of Discharge: {{DISCHARGE_DATE}}
Prepared On: {{CURRENT_DATE}}
' + . '
' + . 'NABH guidance note: Ensure diagnosis, procedures, clinical course, condition at discharge, medication with dose/duration, follow-up advice, red-flag signs, and emergency contact are documented.' + . '
' + . '
{{CONTENT}}
' + . '

Counselling & Handover Confirmation

' + . '' + . '' + . '' + . '' + . '' + . '
Medication explained to patient/attendantRemarks:
Follow-up date and department explainedNext Visit: __________________
Red-flag symptoms explainedEmergency Contact: __________________
Diet and activity instructions explained
' + . '' + . '' + . '' + . '' + . '' + . '' + . '
____________________
Consultant Name/Signature
____________________
Medical Officer Signature
____________________
Patient/Attendant Signature & Date
'; + } + + private function ensureDefaultDischargeTemplateSeeded(): void + { + $this->ensureDischargeTemplateTable(); + if (! $this->db->tableExists('ipd_discharge_templates')) { + return; + } + + $table = $this->db->table('ipd_discharge_templates'); + $count = (int) ($table->countAllResults() ?? 0); + if ($count === 0) { + $table->insert([ + 'template_name' => 'Default Discharge Template', + 'template_html' => $this->defaultDischargeTemplateHtml(), + 'is_default' => 1, + 'status' => 1, + ]); + } + + $nabhExists = $this->db->table('ipd_discharge_templates') + ->where('template_name', 'NABH Compliant Discharge Summary') + ->get(1) + ->getRowArray(); + + if (empty($nabhExists)) { + $table->insert([ + 'template_name' => 'NABH Compliant Discharge Summary', + 'template_html' => $this->nabhDischargeTemplateHtml(), + 'is_default' => 0, + 'status' => 1, + ]); + } + } + + private function getDischargeTemplateRows(): array + { + $this->ensureDefaultDischargeTemplateSeeded(); + if (! $this->db->tableExists('ipd_discharge_templates')) { + return []; + } + + return $this->db->table('ipd_discharge_templates') + ->select('id,template_name,template_html,is_default,status') + ->where('status', 1) + ->orderBy('is_default', 'DESC') + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + } + + private function applyDischargeTemplate(string $content, array $panelData, ?int $requestedTemplateId = null): array + { + $templates = $this->getDischargeTemplateRows(); + + $selectedTemplate = null; + if ($requestedTemplateId !== null && $requestedTemplateId > 0) { + foreach ($templates as $row) { + if ((int) ($row['id'] ?? 0) === $requestedTemplateId) { + $selectedTemplate = $row; + break; + } + } + } + + if ($selectedTemplate === null) { + foreach ($templates as $row) { + if ((int) ($row['is_default'] ?? 0) === 1) { + $selectedTemplate = $row; + break; + } + } + } + + if ($selectedTemplate === null && ! empty($templates)) { + $selectedTemplate = $templates[0]; + } + + $templateHtml = (string) ($selectedTemplate['template_html'] ?? '{{CONTENT}}'); + if (trim($templateHtml) === '') { + $templateHtml = '{{CONTENT}}'; + } + + if (strpos($templateHtml, '{{CONTENT}}') === false) { + $templateHtml .= "\n{{CONTENT}}"; + } + + $ipd = $panelData['ipd_info'] ?? null; + $person = $panelData['person_info'] ?? null; + $patientName = trim((string) ($person->p_fname ?? '')); + $patientCode = trim((string) ( + $person->uhid + ?? $person->UHID + ?? $person->patient_code + ?? $person->p_code + ?? $person->reg_no + ?? '' + )); + + $age = get_age_1($person->dob ?? null, $person->age ?? '', $person->age_in_month ?? '', $person->estimate_dob ?? ''); + $ageGender = trim($age . ' / ' . ((string) ($person->xgender ?? ''))); + + $replacements = [ + '{{CONTENT}}' => $content, + '{{PATIENT_NAME}}' => esc($patientName), + '{{UHID}}' => esc($patientCode), + '{{IPD_CODE}}' => esc((string) ($ipd->ipd_code ?? '')), + '{{AGE_GENDER}}' => esc($ageGender), + '{{ADMIT_DATE}}' => esc((string) ($ipd->str_register_date ?? '')), + '{{DISCHARGE_DATE}}' => esc((string) ($ipd->str_discharge_date ?? '')), + '{{CURRENT_DATE}}' => esc(date('d-m-Y')), + ]; + + $rendered = strtr($templateHtml, $replacements); + + return [ + 'rendered_html' => $rendered, + 'templates' => $templates, + 'selected_template_id' => (int) ($selectedTemplate['id'] ?? 0), + 'selected_template_name' => (string) ($selectedTemplate['template_name'] ?? ''), + ]; + } + private function currentUserLabel(): string { if (! function_exists('auth')) { @@ -146,6 +343,209 @@ class Ipd_discharge extends BaseController return true; } + private function getCurrentUserId(): int + { + if (! function_exists('auth')) { + return 0; + } + + $user = auth()->user(); + if (! $user) { + return 0; + } + + return max(0, (int) ($user->id ?? 0)); + } + + private function isNarrativeTemplateSectionAllowed(string $section): bool + { + return in_array($section, ['diagnosis_remark', 'course_remark'], true); + } + + private function narrativeSectionTable(string $section): ?string + { + $map = [ + 'diagnosis_remark' => 'ipd_discharge_diagnosis_remark', + 'course_remark' => 'ipd_discharge_course_remark', + ]; + + return $map[$section] ?? null; + } + + private function ensureDischargeNarrativeTemplateTable(): bool + { + if ($this->db->tableExists('ipd_discharge_narrative_templates')) { + return true; + } + + try { + $sql = "CREATE TABLE IF NOT EXISTS ipd_discharge_narrative_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + doc_id INT NOT NULL DEFAULT 0, + section_key VARCHAR(80) NOT NULL, + template_name VARCHAR(120) NOT NULL, + template_text LONGTEXT NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NULL, + updated_at DATETIME NULL, + UNIQUE KEY uniq_doc_section_name (doc_id, section_key, template_name), + INDEX idx_section_doc (section_key, doc_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"; + $this->db->query($sql); + } catch (\Throwable $e) { + return false; + } + + return $this->db->tableExists('ipd_discharge_narrative_templates'); + } + + private function getPatientIdFromIpd(int $ipdId): int + { + if ($ipdId <= 0 || ! $this->tableHasColumns('ipd_master', ['id', 'p_id'])) { + return 0; + } + + $row = $this->db->table('ipd_master') + ->select('p_id') + ->where('id', $ipdId) + ->get(1) + ->getRowArray(); + + return max(0, (int) ($row['p_id'] ?? 0)); + } + + public function section_past_data() + { + $section = trim((string) $this->request->getGet('section')); + $ipdId = (int) $this->request->getGet('ipd_id'); + + if ($ipdId <= 0 || ! $this->isNarrativeTemplateSectionAllowed($section)) { + return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'Past data not found']); + } + + $table = $this->narrativeSectionTable($section); + if ($table === null || ! $this->tableHasColumns($table, ['ipd_id', 'comp_remark'])) { + return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'Past data column missing']); + } + + $patientId = $this->getPatientIdFromIpd($ipdId); + if ($patientId <= 0) { + return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'Patient not found']); + } + + $row = $this->db->table($table . ' r') + ->select('r.comp_remark as section_text,r.ipd_id') + ->join('ipd_master m', 'm.id = r.ipd_id', 'inner') + ->where('m.p_id', $patientId) + ->where('r.ipd_id !=', $ipdId) + ->where('r.comp_remark IS NOT NULL', null, false) + ->where('r.comp_remark !=', '') + ->orderBy('r.id', 'DESC') + ->get(1) + ->getRowArray(); + + if (empty($row)) { + return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'No past data found']); + } + + return $this->response->setJSON([ + 'update' => 1, + 'past_text' => (string) ($row['section_text'] ?? ''), + 'past_ipd_id' => (int) ($row['ipd_id'] ?? 0), + 'error_text' => 'Past data loaded', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function section_template_save() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $section = trim((string) $this->request->getPost('section')); + $templateName = trim((string) $this->request->getPost('template_name')); + $templateText = trim((string) $this->request->getPost('template_text')); + $templateScope = strtolower(trim((string) $this->request->getPost('template_scope'))); + + if ($templateName === '' || $templateText === '') { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Template name and text are required']); + } + + if (! $this->isNarrativeTemplateSectionAllowed($section)) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Unsupported section']); + } + + if (! $this->ensureDischargeNarrativeTemplateTable()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to access template storage']); + } + + $docId = $templateScope === 'master' ? 0 : $this->getCurrentUserId(); + $table = $this->db->table('ipd_discharge_narrative_templates'); + $existing = $table + ->where('doc_id', $docId) + ->where('section_key', $section) + ->where('template_name', $templateName) + ->where('is_active', 1) + ->get(1) + ->getRowArray(); + + $now = date('Y-m-d H:i:s'); + if (! empty($existing)) { + $table->where('id', (int) ($existing['id'] ?? 0))->update([ + 'template_text' => $templateText, + 'updated_at' => $now, + ]); + } else { + $table->insert([ + 'doc_id' => $docId, + 'section_key' => $section, + 'template_name' => $templateName, + 'template_text' => $templateText, + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + return $this->response->setJSON([ + 'update' => 1, + 'error_text' => $docId === 0 ? 'Master template saved' : 'My template saved', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function section_template_list() + { + $section = trim((string) $this->request->getGet('section')); + if (! $this->isNarrativeTemplateSectionAllowed($section)) { + return $this->response->setJSON(['rows' => []]); + } + + if (! $this->ensureDischargeNarrativeTemplateTable()) { + return $this->response->setJSON(['rows' => []]); + } + + $docId = $this->getCurrentUserId(); + $rows = $this->db->table('ipd_discharge_narrative_templates') + ->select('id,template_name,template_text,doc_id,section_key') + ->where('section_key', $section) + ->where('is_active', 1) + ->groupStart() + ->where('doc_id', $docId) + ->orWhere('doc_id', 0) + ->groupEnd() + ->orderBy('doc_id', 'DESC') + ->orderBy('template_name', 'ASC') + ->limit(100) + ->get() + ->getResultArray(); + + return $this->response->setJSON(['rows' => $rows]); + } + private function byIpdRows(string $table, array $columns = ['*'], string $orderBy = 'id ASC', int $ipdId = 0): array { if ($ipdId <= 0 || ! $this->tableHasColumns($table, ['ipd_id'])) { @@ -223,23 +623,10 @@ class Ipd_discharge extends BaseController ?? '' )); $patientId = (int) ($person->id ?? 0); + $opdHistory = $this->getLatestOpdHistorySnapshot($patientId); $sections = []; - $sections[] = '

IPD Discharge Summary

'; - $sections[] = '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '
IPD: ' . esc((string) ($ipd->ipd_code ?? $ipdId)) . 'UHID: ' . esc($patientCode) . 'Date: ' . esc(date('d-m-Y')) . '
Patient: ' . esc($patientName) . 'Admit Date: ' . esc((string) ($ipd->str_register_date ?? '')) . 'Discharge Date: ' . esc((string) ($ipd->str_discharge_date ?? '')) . '
'; - $complaints = $this->byIpdRows('ipd_discharge_complaint', ['comp_report', 'comp_remark'], 'id ASC', $ipdId); $this->addListSection($sections, 'Presenting Complaints and Reason for Admission', $complaints); $complaintRemark = $this->firstRowByIpd('ipd_discharge_complaint_remark', $ipdId); @@ -248,11 +635,30 @@ class Ipd_discharge extends BaseController $sections[] = '
' . nl2br(esc($complaintRemarkText)) . '
'; } + $complaintMeta = $this->parseComplaintMetaPayload((string) ($complaintRemark['comp_report'] ?? '')); + $painValue = (string) ($complaintMeta['pain_value'] ?? ''); + $painLabel = $this->painScaleLabel($painValue); + if ($painLabel !== '') { + $sections[] = '
Pain Measurement Scale: ' . esc($painLabel) . ' (' . esc($painValue) . ')
'; + } elseif ((string) ($opdHistory['pain_label'] ?? '') !== '') { + $opdPainLabel = (string) ($opdHistory['pain_label'] ?? ''); + $opdPainValue = (string) ($opdHistory['pain_value'] ?? ''); + $sections[] = '
Pain Measurement Scale: ' . esc($opdPainLabel) + . ($opdPainValue !== '' ? ' (' . esc($opdPainValue) . ')' : '') + . '
'; + } + $physicalExamRows = $this->getPhysicalExamRows($ipdId); - $generalRows = array_merge( - $physicalExamRows['general_group_1'] ?? [], - $physicalExamRows['general_group_2'] ?? [] - ); + $generalRowsRaw = $physicalExamRows['general_all'] ?? []; + $generalRows = []; + foreach ($generalRowsRaw as $row) { + $value = trim((string) ($row['value'] ?? '')); + if ($value === '') { + continue; + } + $generalRows[] = $row; + } + if (! empty($generalRows)) { $html = '

General Examination on Admission

' . ''; @@ -266,7 +672,12 @@ class Ipd_discharge extends BaseController $label = (string) ($row['label'] ?? ''); $value = trim((string) ($row['value'] ?? '')); - $html .= ''; + $unit = trim((string) ($row['unit'] ?? '')); + $html .= ''; if ($col % $perRow === $perRow - 1) { $html .= ''; @@ -328,6 +739,28 @@ class Ipd_discharge extends BaseController $sections[] = '

Personal History

' . esc(implode(', ', $personalHistory)) . '
'; } + $allergyLines = []; + if ((string) ($opdHistory['drug_allergy_status'] ?? '') !== '') { + $allergyLines[] = '
Drug Allergy Status: ' . esc((string) ($opdHistory['drug_allergy_status'] ?? '')) . '
'; + } + if ((string) ($opdHistory['drug_allergy_details'] ?? '') !== '') { + $allergyLines[] = '
Drug Allergy Details: ' . esc((string) ($opdHistory['drug_allergy_details'] ?? '')) . '
'; + } + if ((string) ($opdHistory['adr_history'] ?? '') !== '') { + $allergyLines[] = '
ADR History: ' . esc((string) ($opdHistory['adr_history'] ?? '')) . '
'; + } + if ((string) ($opdHistory['current_medications'] ?? '') !== '') { + $allergyLines[] = '
Current Medications: ' . esc((string) ($opdHistory['current_medications'] ?? '')) . '
'; + } + if (! empty($allergyLines)) { + $sections[] = '

Drug Allergy / ADR

' . implode('', $allergyLines); + } + + $coMorbText = trim((string) ($opdHistory['co_morbidities'] ?? '')); + if ($coMorbText !== '') { + $sections[] = '

Co-Morbidities

' . esc($coMorbText) . '
'; + } + $admitDate = $this->normalizeDateValue((string) ($ipd->register_date ?? '')) ?? ''; $dischargeDate = $this->normalizeDateValue((string) ($ipd->discharge_date ?? '')) ?? date('Y-m-d'); $savedClinicalDates = $this->getSavedClinicalLabDates($ipdId); @@ -463,7 +896,21 @@ class Ipd_discharge extends BaseController $sections[] = '
' . nl2br(esc($courseRemarkText)) . '
'; } - $dischargeExamRows = $this->getMappedColRows('ipd_discharge_general_exam_col', 'ipd_discharge_1_b_final', $ipdId, 'Discharge Condition', null); + $nursingTrendSection = $this->buildNursingTrendSection($ipdId); + if ($nursingTrendSection !== '') { + $sections[] = $nursingTrendSection; + } + + $dischargeExamRowsRaw = $this->getMappedColRows('ipd_discharge_general_exam_col', 'ipd_discharge_1_b_final', $ipdId, 'Discharge Condition', null); + $dischargeExamRows = []; + foreach ($dischargeExamRowsRaw as $row) { + $value = trim((string) ($row['value'] ?? '')); + if ($value === '') { + continue; + } + $dischargeExamRows[] = $row; + } + if (! empty($dischargeExamRows)) { $html = '

Examination on Discharge

' . '
' . esc($label) . ': ' . esc($value) . '' . esc($label) . ': ' . esc($value); + if ($unit !== '') { + $html .= ' ' . esc($unit); + } + $html .= '
'; @@ -477,7 +924,12 @@ class Ipd_discharge extends BaseController $label = (string) ($row['label'] ?? ''); $value = trim((string) ($row['value'] ?? '')); - $html .= ''; + $unit = trim((string) ($row['unit'] ?? '')); + $html .= ''; if ($col % $perRow === $perRow - 1) { $html .= ''; @@ -621,6 +1073,267 @@ class Ipd_discharge extends BaseController return trim(implode("\n", $sections)); } + private function buildNursingTrendSection(int $ipdId): string + { + if ($ipdId <= 0 || ! $this->db->tableExists('ipd_nursing_entries')) { + return ''; + } + + foreach (['ipd_id', 'entry_type', 'recorded_at'] as $field) { + if (! $this->db->fieldExists($field, 'ipd_nursing_entries')) { + return ''; + } + } + + $rows = $this->db->table('ipd_nursing_entries') + ->select('entry_type,recorded_at,temperature_c,pulse_rate,resp_rate,bp_systolic,bp_diastolic,spo2,weight_kg,fluid_direction,fluid_amount_ml,treatment_text,general_note') + ->where('ipd_id', $ipdId) + ->orderBy('recorded_at', 'ASC') + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + if (empty($rows)) { + return ''; + } + + $sinceTs = strtotime('-24 hours'); + $since = $sinceTs === false ? date('Y-m-d H:i:s') : date('Y-m-d H:i:s', $sinceTs); + + $vitalsCount = 0; + $fluidIn = 0; + $fluidOut = 0; + $treatments = []; + $scannedCount = 0; + $latestVitalsLine = ''; + + foreach ($rows as $row) { + $recordedAt = trim((string) ($row['recorded_at'] ?? '')); + if ($recordedAt === '' || $recordedAt < $since) { + continue; + } + + $entryType = strtolower(trim((string) ($row['entry_type'] ?? ''))); + $note = trim((string) ($row['general_note'] ?? '')); + if (stripos($note, 'Source: Scanned Paper') !== false) { + $scannedCount++; + } + + if ($entryType === 'vitals' || $entryType === 'admission') { + $vitalsCount++; + $parts = []; + if ($row['temperature_c'] !== null && $row['temperature_c'] !== '') { + $tempF = (((float) $row['temperature_c']) * 9 / 5) + 32; + $parts[] = 'Temp ' . rtrim(rtrim(number_format($tempF, 1, '.', ''), '0'), '.') . ' F'; + } + if ($row['pulse_rate'] !== null && $row['pulse_rate'] !== '') { + $parts[] = 'Pulse ' . (string) $row['pulse_rate']; + } + if ($row['resp_rate'] !== null && $row['resp_rate'] !== '') { + $parts[] = 'Resp ' . (string) $row['resp_rate']; + } + if ($row['bp_systolic'] !== null && $row['bp_systolic'] !== '') { + $parts[] = 'BP ' . (string) $row['bp_systolic'] . '/' . (string) ($row['bp_diastolic'] ?? ''); + } + if ($row['spo2'] !== null && $row['spo2'] !== '') { + $parts[] = 'SpO2 ' . (string) $row['spo2'] . '%'; + } + if ($row['weight_kg'] !== null && $row['weight_kg'] !== '') { + $parts[] = 'Wt ' . (string) $row['weight_kg'] . ' kg'; + } + if (! empty($parts)) { + $latestVitalsLine = '[' . $recordedAt . '] ' . implode(', ', $parts); + } + } + + if ($entryType === 'fluid') { + $amount = (int) ($row['fluid_amount_ml'] ?? 0); + $dir = strtolower(trim((string) ($row['fluid_direction'] ?? ''))); + if ($dir === 'output') { + $fluidOut += max(0, $amount); + } else { + $fluidIn += max(0, $amount); + } + } + + if ($entryType === 'treatment') { + $text = trim((string) ($row['treatment_text'] ?? '')); + if ($text !== '') { + $treatments[] = '[' . $recordedAt . '] ' . $text; + } + } + } + + if ($vitalsCount === 0 && $fluidIn === 0 && $fluidOut === 0 && empty($treatments) && $scannedCount === 0) { + return ''; + } + + $html = '

Nursing Trend Summary (Last 24 Hours)

'; + $html .= ''; + + return $html; + } + + private function hasAnyNonEmptyValue(array $rows, array $fields): bool + { + foreach ($rows as $row) { + if (! is_array($row)) { + continue; + } + foreach ($fields as $field) { + $value = trim((string) ($row[$field] ?? '')); + if ($value !== '') { + return true; + } + } + } + + return false; + } + + private function buildNabhAuditChecklist(int $ipdId, array $panelData): array + { + $person = $panelData['person_info'] ?? null; + $patientId = (int) ($person->id ?? 0); + + $complaintRows = $this->byIpdRows('ipd_discharge_complaint', ['comp_report', 'comp_remark'], 'id ASC', $ipdId); + $complaintRemark = $this->firstRowByIpd('ipd_discharge_complaint_remark', $ipdId); + $diagnosisRows = $this->byIpdRows('ipd_discharge_diagnosis', ['comp_report', 'comp_remark'], 'id ASC', $ipdId); + $diagnosisRemark = $this->firstRowByIpd('ipd_discharge_diagnosis_remark', $ipdId); + $courseRows = $this->byIpdRows('ipd_discharge_course', ['comp_report', 'comp_remark'], 'id ASC', $ipdId); + $courseRemark = $this->firstRowByIpd('ipd_discharge_course_remark', $ipdId); + $surgeryRows = $this->byIpdRows('ipd_discharge_surgery', ['surgery_name', 'surgery_remark'], 'id ASC', $ipdId); + $procedureRows = $this->byIpdRows('ipd_discharge_procedure', ['procedure_name', 'procedure_remark'], 'id ASC', $ipdId); + $conditionRows = $this->getMappedColRows('ipd_discharge_general_exam_col', 'ipd_discharge_1_b_final', $ipdId, 'Discharge Condition', null); + $drugRows = $this->byIpdRows('ipd_discharge_drug', ['drug_name', 'drug_dose', 'drug_day'], 'id ASC', $ipdId); + $legacyMedRows = $this->byIpdRows('ipd_discharge_prescrption_prescribed', ['med_name', 'med_type', 'qty', 'no_of_days', 'remark'], 'id ASC', $ipdId); + $instructionRow = $this->firstRowByIpd('ipd_discharge_instructions', $ipdId); + $opdSnapshot = $this->getLatestOpdHistorySnapshot($patientId); + + $instructionText = trim((string) ($instructionRow['comp_remark'] ?? '')); + $reviewAfter = trim((string) ($instructionRow['review_after'] ?? '')); + $instructionLower = strtolower($instructionText); + + $hasAdmissionReason = $this->hasAnyNonEmptyValue($complaintRows, ['comp_report', 'comp_remark']) + || trim((string) ($complaintRemark['comp_remark'] ?? '')) !== ''; + $hasDiagnosis = $this->hasAnyNonEmptyValue($diagnosisRows, ['comp_report', 'comp_remark']) + || trim((string) ($diagnosisRemark['comp_remark'] ?? '')) !== ''; + $hasCourse = $this->hasAnyNonEmptyValue($courseRows, ['comp_report', 'comp_remark']) + || trim((string) ($courseRemark['comp_remark'] ?? '')) !== ''; + $hasProcedure = $this->hasAnyNonEmptyValue($surgeryRows, ['surgery_name', 'surgery_remark']) + || $this->hasAnyNonEmptyValue($procedureRows, ['procedure_name', 'procedure_remark']); + $hasCondition = $this->hasAnyNonEmptyValue($conditionRows, ['value']); + $hasMedication = $this->hasAnyNonEmptyValue($drugRows, ['drug_name']) + || $this->hasAnyNonEmptyValue($legacyMedRows, ['med_name']); + $hasFollowUp = $reviewAfter !== '' || $instructionText !== ''; + $hasAllergyAdr = trim((string) ($opdSnapshot['drug_allergy_status'] ?? '')) !== '' + || trim((string) ($opdSnapshot['drug_allergy_details'] ?? '')) !== '' + || trim((string) ($opdSnapshot['adr_history'] ?? '')) !== ''; + $hasRedFlags = strpos($instructionLower, 'emergency') !== false + || strpos($instructionLower, 'warning') !== false + || strpos($instructionLower, 'red flag') !== false + || strpos($instructionLower, 'immediately') !== false + || strpos($instructionLower, 'return if') !== false; + + $items = [ + [ + 'key' => 'admission_reason', + 'label' => 'Reason for admission / presenting complaints', + 'ok' => $hasAdmissionReason, + 'critical' => true, + ], + [ + 'key' => 'final_diagnosis', + 'label' => 'Final diagnosis documented', + 'ok' => $hasDiagnosis, + 'critical' => true, + ], + [ + 'key' => 'hospital_course', + 'label' => 'Course / treatment in hospital documented', + 'ok' => $hasCourse, + 'critical' => true, + ], + [ + 'key' => 'procedure_documentation', + 'label' => 'Surgery/procedure documented (if applicable)', + 'ok' => $hasProcedure, + 'critical' => false, + ], + [ + 'key' => 'condition_at_discharge', + 'label' => 'Condition at discharge documented', + 'ok' => $hasCondition, + 'critical' => true, + ], + [ + 'key' => 'discharge_medication', + 'label' => 'Discharge medication documented', + 'ok' => $hasMedication, + 'critical' => true, + ], + [ + 'key' => 'follow_up_plan', + 'label' => 'Follow-up instructions / review plan documented', + 'ok' => $hasFollowUp, + 'critical' => true, + ], + [ + 'key' => 'allergy_adr', + 'label' => 'Drug allergy / ADR history documented', + 'ok' => $hasAllergyAdr, + 'critical' => false, + ], + [ + 'key' => 'red_flags', + 'label' => 'Red-flag / emergency return advice documented', + 'ok' => $hasRedFlags, + 'critical' => false, + ], + ]; + + $criticalMissing = []; + $okCount = 0; + foreach ($items as $item) { + if (! empty($item['ok'])) { + $okCount++; + continue; + } + if (! empty($item['critical'])) { + $criticalMissing[] = (string) ($item['label'] ?? ''); + } + } + + return [ + 'items' => $items, + 'ok_count' => $okCount, + 'total_count' => count($items), + 'critical_missing' => $criticalMissing, + 'critical_missing_count' => count($criticalMissing), + ]; + } + private function firstRowByIpd(string $table, int $ipdId): array { if ($ipdId <= 0 || ! $this->tableHasColumns($table, ['ipd_id'])) { @@ -755,14 +1468,19 @@ class Ipd_discharge extends BaseController { $group1 = []; $group2 = []; + $generalAll = []; $sysRows = []; if ($this->tableHasColumns('ipd_discharge_general_exam_col', ['id', 'col_description'])) { - $generalCols = $this->db->table('ipd_discharge_general_exam_col') + $generalBuilder = $this->db->table('ipd_discharge_general_exam_col') ->select('id,col_description,col_name,col_pre_value,cat_group') - ->orderBy('id', 'ASC') - ->get() - ->getResultArray(); + ->orderBy('id', 'ASC'); + + if ($this->db->fieldExists('col_unit', 'ipd_discharge_general_exam_col')) { + $generalBuilder->select('col_unit'); + } + + $generalCols = $generalBuilder->get()->getResultArray(); $rdataMap = []; if ($this->tableHasColumns('ipd_discharge_1_b', ['ipd_d_id', 'col_id', 'rdata'])) { @@ -785,8 +1503,11 @@ class Ipd_discharge extends BaseController 'id' => $colId, 'label' => (string) ($row['col_description'] ?? $row['col_name'] ?? ('Exam ' . $colId)), 'value' => (string) ($rdataMap[$colId] ?? (string) ($row['col_pre_value'] ?? '')), + 'unit' => trim((string) ($row['col_unit'] ?? '')), ]; + $generalAll[] = $item; + if ((int) ($row['cat_group'] ?? 1) === 2) { $group2[] = $item; } else { @@ -827,13 +1548,121 @@ class Ipd_discharge extends BaseController } } + $nursingAdmission = $this->getNursingAdmissionSnapshot($ipdId); + if (! empty($nursingAdmission)) { + $group1 = $this->applyAdmissionVitalsToGeneralRows($group1, $nursingAdmission); + $group2 = $this->applyAdmissionVitalsToGeneralRows($group2, $nursingAdmission); + $generalAll = $this->applyAdmissionVitalsToGeneralRows($generalAll, $nursingAdmission); + } + return [ 'general_group_1' => $group1, 'general_group_2' => $group2, + 'general_all' => $generalAll, 'systemic' => $sysRows, ]; } + private function getNursingAdmissionSnapshot(int $ipdId): array + { + if ($ipdId <= 0 || ! $this->db->tableExists('ipd_nursing_entries')) { + return []; + } + + $required = ['ipd_id', 'entry_type', 'recorded_at']; + foreach ($required as $field) { + if (! $this->db->fieldExists($field, 'ipd_nursing_entries')) { + return []; + } + } + + $builder = $this->db->table('ipd_nursing_entries') + ->where('ipd_id', $ipdId) + ->whereIn('entry_type', ['admission', 'vitals']) + ->orderBy('entry_type', 'DESC') + ->orderBy('recorded_at', 'ASC') + ->orderBy('id', 'ASC'); + + $row = $builder->get(1)->getRowArray(); + return is_array($row) ? $row : []; + } + + private function celsiusToFahrenheit(?float $value): string + { + if ($value === null) { + return ''; + } + + $f = ($value * 9 / 5) + 32; + return rtrim(rtrim(number_format($f, 1, '.', ''), '0'), '.'); + } + + private function applyAdmissionVitalsToGeneralRows(array $rows, array $snapshot): array + { + if (empty($rows) || empty($snapshot)) { + return $rows; + } + + $pulse = trim((string) ($snapshot['pulse_rate'] ?? '')); + $resp = trim((string) ($snapshot['resp_rate'] ?? '')); + $spo2 = trim((string) ($snapshot['spo2'] ?? '')); + $weight = trim((string) ($snapshot['weight_kg'] ?? '')); + $sbp = trim((string) ($snapshot['bp_systolic'] ?? '')); + $dbp = trim((string) ($snapshot['bp_diastolic'] ?? '')); + $bp = $sbp !== '' || $dbp !== '' ? trim($sbp . ($dbp !== '' ? '/' . $dbp : '')) : ''; + + $tempC = $snapshot['temperature_c'] ?? null; + $temp = ''; + if ($tempC !== null && $tempC !== '') { + $temp = $this->celsiusToFahrenheit((float) $tempC); + } + + foreach ($rows as &$row) { + $current = trim((string) ($row['value'] ?? '')); + if ($current !== '') { + continue; + } + + $label = strtolower(trim((string) ($row['label'] ?? ''))); + if ($label === '') { + continue; + } + + if (strpos($label, 'pulse') !== false && $pulse !== '') { + $row['value'] = $pulse; + continue; + } + if ((strpos($label, 'resp') !== false || strpos($label, 'rr') !== false) && $resp !== '') { + $row['value'] = $resp; + continue; + } + if ((strpos($label, 'spo2') !== false || strpos($label, 'spo 2') !== false) && $spo2 !== '') { + $row['value'] = $spo2; + continue; + } + if ((strpos($label, 'temp') !== false || strpos($label, 'temperature') !== false) && $temp !== '') { + $row['value'] = $temp; + continue; + } + if (strpos($label, 'weight') !== false && $weight !== '') { + $row['value'] = $weight; + continue; + } + if ((strpos($label, 'bp') !== false || strpos($label, 'blood pressure') !== false) && $bp !== '') { + if (strpos($label, 'diastolic') !== false && $dbp !== '') { + $row['value'] = $dbp; + } elseif ((strpos($label, 'systolic') !== false || strpos($label, 'sys') !== false) && $sbp !== '') { + $row['value'] = $sbp; + } else { + $row['value'] = $bp; + } + } + } + unset($row); + + return $rows; + } + private function getMappedColRows( string $masterTable, string $valueTable, @@ -848,6 +1677,10 @@ class Ipd_discharge extends BaseController $builder = $this->db->table($masterTable) ->select('id,col_name,col_description,col_pre_value'); + if ($this->db->fieldExists('col_unit', $masterTable)) { + $builder->select('col_unit'); + } + if ($catGroup !== null && $this->db->fieldExists('cat_group', $masterTable)) { $builder->where('cat_group', $catGroup); } @@ -879,6 +1712,7 @@ class Ipd_discharge extends BaseController 'name' => (string) ($row['col_name'] ?? ''), 'label' => (string) ($row['col_description'] ?? $row['col_name'] ?? ($prefix . ' ' . $colId)), 'value' => (string) ($valueMap[$colId] ?? (string) ($row['col_pre_value'] ?? '')), + 'unit' => trim((string) ($row['col_unit'] ?? '')), ]; } @@ -1363,6 +2197,238 @@ class Ipd_discharge extends BaseController return $ts === false ? null : date('Y-m-d', $ts); } + private function painScaleLabel(string $value): string + { + $map = [ + '0' => 'No Pain', + '1' => 'Mild Pain', + '2' => 'Moderate', + '3' => 'Intense', + '4' => 'Worst Pain Possible', + ]; + + return $map[$value] ?? ''; + } + + private function buildComplaintMetaPayload(array $meta): string + { + $painValue = trim((string) ($meta['pain_value'] ?? '')); + if (! in_array($painValue, ['0', '1', '2', '3', '4'], true)) { + return ''; + } + + return json_encode(['pain_value' => $painValue], JSON_UNESCAPED_UNICODE) ?: ''; + } + + private function parseComplaintMetaPayload(string $raw): array + { + $raw = trim($raw); + if ($raw === '') { + return []; + } + + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + return []; + } + + $painValue = trim((string) ($decoded['pain_value'] ?? '')); + if (! in_array($painValue, ['0', '1', '2', '3', '4'], true)) { + $painValue = ''; + } + + return ['pain_value' => $painValue]; + } + + private function extractNabhFieldsFromRemarks(string $remarks): array + { + $extract = static function (string $pattern, string $source): string { + if (preg_match($pattern, $source, $m) !== 1) { + return ''; + } + + return trim((string) ($m[1] ?? '')); + }; + + return [ + 'drug_allergy_status' => $extract('/^\s*Drug\s*Allergy\s*Status\s*:\s*(.+)$/im', $remarks), + 'drug_allergy_details' => $extract('/^\s*Drug\s*Allergy\s*Details\s*:\s*(.+)$/im', $remarks), + 'adr_history' => $extract('/^\s*ADR\s*History\s*:\s*(.+)$/im', $remarks), + 'current_medications' => $extract('/^\s*Current\s*Medications\s*:\s*(.+)$/im', $remarks), + 'co_morbidities' => $extract('/^\s*Co-Morbidities\s*:\s*(.+)$/im', $remarks), + 'women_lmp' => $extract('/^\s*Women\s*Related\s*LMP\s*:\s*(.+)$/im', $remarks), + 'women_last_baby' => $extract('/^\s*Women\s*Related\s*Last\s*Baby\s*:\s*(.+)$/im', $remarks), + 'women_pregnancy_related' => $extract('/^\s*Women\s*Related\s*Pregnancy\s*Related\s*:\s*(.+)$/im', $remarks), + 'women_related_problems' => $extract('/^\s*Women\s*Related\s*Problems\s*:\s*(.+)$/im', $remarks), + 'hpi_note' => $extract('/^\s*HPI\s*Note\s*:\s*(.+)$/im', $remarks), + ]; + } + + private function hydrateNabhFieldsFromOpdRow(array $row): array + { + $pick = static function (array $candidates) use ($row): string { + foreach ($candidates as $field) { + $value = trim((string) ($row[$field] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + }; + + $remarks = trim((string) ($row['Prescriber_Remarks'] ?? '')); + $parsed = $this->extractNabhFieldsFromRemarks($remarks); + + $result = [ + 'drug_allergy_status' => $pick(['drug_allergy_status', 'allergy_status', 'drug_allergy']), + 'drug_allergy_details' => $pick(['drug_allergy_details', 'allergy_details', 'drug_allergy_note', 'allergy_note']), + 'adr_history' => $pick(['adr_history', 'adverse_drug_reaction', 'adr_details', 'adverse_reaction_history']), + 'current_medications' => $pick(['current_medications', 'current_medication', 'current_medication_history', 'ongoing_medications']), + 'co_morbidities' => (string) ($parsed['co_morbidities'] ?? ''), + 'women_lmp' => $pick(['women_lmp']), + 'women_last_baby' => $pick(['women_last_baby']), + 'women_pregnancy_related' => $pick(['women_pregnancy_related']), + 'women_related_problems' => $pick(['women_related_problems']), + 'hpi_note' => (string) ($parsed['hpi_note'] ?? ''), + 'pain_value' => trim((string) ($row['pain_value'] ?? '')), + ]; + + foreach (['drug_allergy_status', 'drug_allergy_details', 'adr_history', 'current_medications', 'women_lmp', 'women_last_baby', 'women_pregnancy_related', 'women_related_problems'] as $key) { + if ($result[$key] === '' && ! empty($parsed[$key])) { + $result[$key] = (string) $parsed[$key]; + } + } + + if (! in_array($result['pain_value'], ['0', '1', '2', '3', '4'], true)) { + $result['pain_value'] = ''; + } + + return $result; + } + + private function getLatestOpdHistorySnapshot(int $patientId): array + { + $empty = [ + 'drug_allergy_status' => '', + 'drug_allergy_details' => '', + 'adr_history' => '', + 'current_medications' => '', + 'co_morbidities' => '', + 'women_lmp' => '', + 'women_last_baby' => '', + 'women_pregnancy_related' => '', + 'women_related_problems' => '', + 'hpi_note' => '', + 'pain_value' => '', + 'pain_label' => '', + ]; + + if ($patientId <= 0 || ! $this->db->tableExists('opd_prescription')) { + return $empty; + } + + $row = $this->db->table('opd_prescription') + ->where('p_id', $patientId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray() ?? []; + + if (empty($row)) { + return $empty; + } + + $hydrated = $this->hydrateNabhFieldsFromOpdRow($row); + + return [ + 'drug_allergy_status' => (string) ($hydrated['drug_allergy_status'] ?? ''), + 'drug_allergy_details' => (string) ($hydrated['drug_allergy_details'] ?? ''), + 'adr_history' => (string) ($hydrated['adr_history'] ?? ''), + 'current_medications' => (string) ($hydrated['current_medications'] ?? ''), + 'co_morbidities' => (string) ($hydrated['co_morbidities'] ?? ''), + 'women_lmp' => (string) ($hydrated['women_lmp'] ?? ''), + 'women_last_baby' => (string) ($hydrated['women_last_baby'] ?? ''), + 'women_pregnancy_related' => (string) ($hydrated['women_pregnancy_related'] ?? ''), + 'women_related_problems' => (string) ($hydrated['women_related_problems'] ?? ''), + 'hpi_note' => (string) ($hydrated['hpi_note'] ?? ''), + 'pain_value' => (string) ($hydrated['pain_value'] ?? ''), + 'pain_label' => $this->painScaleLabel((string) ($hydrated['pain_value'] ?? '')), + ]; + } + + private function upsertLabeledLineInRemarks(string $remarks, string $label, string $value): string + { + $remarks = trim($remarks); + $pattern = '/^\s*' . preg_quote($label, '/') . '\s*:\s*.*$/im'; + $remarks = preg_replace($pattern, '', $remarks) ?? $remarks; + + $lines = array_filter(array_map(static function (string $line): string { + return trim($line); + }, preg_split('/\R/', $remarks) ?: []), static function (string $line): bool { + return $line !== ''; + }); + + $value = trim($value); + if ($value !== '') { + $lines[] = $label . ': ' . $value; + } + + return trim(implode(PHP_EOL, $lines)); + } + + private function saveNabhHistoryFromDischarge(int $patientId, array $payload): bool + { + if ($patientId <= 0 || ! $this->db->tableExists('opd_prescription')) { + return false; + } + + $fields = $this->db->getFieldNames('opd_prescription') ?? []; + if (! in_array('p_id', $fields, true) || ! in_array('id', $fields, true)) { + return false; + } + + $latest = $this->db->table('opd_prescription') + ->select('id,Prescriber_Remarks') + ->where('p_id', $patientId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + if (! is_array($latest) || empty($latest['id'])) { + return false; + } + + $update = []; + foreach (['drug_allergy_status', 'drug_allergy_details', 'adr_history', 'current_medications', 'women_lmp', 'women_last_baby', 'women_pregnancy_related', 'women_related_problems'] as $field) { + if (in_array($field, $fields, true)) { + $update[$field] = trim((string) ($payload[$field] ?? '')); + } + } + + if (in_array('Prescriber_Remarks', $fields, true)) { + $remarks = (string) ($latest['Prescriber_Remarks'] ?? ''); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Drug Allergy Status', (string) ($payload['drug_allergy_status'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Drug Allergy Details', (string) ($payload['drug_allergy_details'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'ADR History', (string) ($payload['adr_history'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Current Medications', (string) ($payload['current_medications'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Co-Morbidities', (string) ($payload['co_morbidities'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Women Related LMP', (string) ($payload['women_lmp'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Women Related Last Baby', (string) ($payload['women_last_baby'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Women Related Pregnancy Related', (string) ($payload['women_pregnancy_related'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'Women Related Problems', (string) ($payload['women_related_problems'] ?? '')); + $remarks = $this->upsertLabeledLineInRemarks($remarks, 'HPI Note', (string) ($payload['hpi_note'] ?? '')); + $update['Prescriber_Remarks'] = $remarks; + } + + if ($update === []) { + return false; + } + + return (bool) $this->db->table('opd_prescription') + ->where('id', (int) $latest['id']) + ->update($update); + } + public function ipd_select(int $ipdId, int $reCreate = 0) { $permission = $this->requireAnyPermission([ @@ -1397,6 +2463,11 @@ class Ipd_discharge extends BaseController if ($action === 'add_complaint') { $complaintName = trim((string) ($this->request->getPost('new_complaint_name') ?? '')); $complaintRemarkRow = trim((string) ($this->request->getPost('new_complaint_remark') ?? '')); + $complaintRemarkText = trim((string) ($this->request->getPost('complaint_remark') ?? '')); + $painValue = trim((string) ($this->request->getPost('pain_value') ?? '')); + if (! in_array($painValue, ['0', '1', '2', '3', '4'], true)) { + $painValue = ''; + } if ($complaintName !== '' && $this->tableHasColumns('ipd_discharge_complaint', ['ipd_id'])) { $insert = [ @@ -1414,12 +2485,25 @@ class Ipd_discharge extends BaseController $savedAny = (bool) $this->db->table('ipd_discharge_complaint')->insert($insert); $notice = $savedAny ? 'Complaint row added.' : 'Unable to add complaint row.'; $noticeType = $savedAny ? 'success' : 'warning'; + + if ($this->tableHasColumns('ipd_discharge_complaint_remark', ['ipd_id'])) { + $savedAny = $this->upsertByIpd('ipd_discharge_complaint_remark', $ipdId, [ + 'comp_report' => $this->buildComplaintMetaPayload(['pain_value' => $painValue]), + 'comp_remark' => $complaintRemarkText, + 'update_by' => $userLabel, + ]) || $savedAny; + } } else { $notice = 'Enter complaint name before adding.'; $noticeType = 'warning'; } } elseif ($action === 'remove_complaint') { $removeId = (int) ($this->request->getPost('complaint_remove_id') ?? 0); + $complaintRemarkText = trim((string) ($this->request->getPost('complaint_remark') ?? '')); + $painValue = trim((string) ($this->request->getPost('pain_value') ?? '')); + if (! in_array($painValue, ['0', '1', '2', '3', '4'], true)) { + $painValue = ''; + } if ($removeId > 0 && $this->tableHasColumns('ipd_discharge_complaint', ['id', 'ipd_id'])) { $savedAny = (bool) $this->db->table('ipd_discharge_complaint') ->where('id', $removeId) @@ -1427,6 +2511,14 @@ class Ipd_discharge extends BaseController ->delete(); $notice = $savedAny ? 'Complaint row removed.' : 'Unable to remove complaint row.'; $noticeType = $savedAny ? 'success' : 'warning'; + + if ($this->tableHasColumns('ipd_discharge_complaint_remark', ['ipd_id'])) { + $savedAny = $this->upsertByIpd('ipd_discharge_complaint_remark', $ipdId, [ + 'comp_report' => $this->buildComplaintMetaPayload(['pain_value' => $painValue]), + 'comp_remark' => $complaintRemarkText, + 'update_by' => $userLabel, + ]) || $savedAny; + } } } elseif ($action === 'add_surgery') { $name = trim((string) ($this->request->getPost('new_surgery_name') ?? '')); @@ -1515,6 +2607,7 @@ class Ipd_discharge extends BaseController } elseif ($action === 'add_diagnosis') { $name = trim((string) ($this->request->getPost('new_diagnosis_name') ?? '')); $remark = trim((string) ($this->request->getPost('new_diagnosis_remark') ?? '')); + $diagnosisRemarkText = trim((string) ($this->request->getPost('diagnosis_remark') ?? '')); if ($name !== '' && $this->tableHasColumns('ipd_discharge_diagnosis', ['ipd_id', 'comp_report'])) { $insert = [ 'ipd_id' => $ipdId, @@ -1529,6 +2622,14 @@ class Ipd_discharge extends BaseController $savedAny = (bool) $this->db->table('ipd_discharge_diagnosis')->insert($insert); $notice = $savedAny ? 'Diagnosis row added.' : 'Unable to add diagnosis row.'; $noticeType = $savedAny ? 'success' : 'warning'; + + if ($this->tableHasColumns('ipd_discharge_diagnosis_remark', ['ipd_id'])) { + $savedAny = $this->upsertByIpd('ipd_discharge_diagnosis_remark', $ipdId, [ + 'comp_report' => '', + 'comp_remark' => $diagnosisRemarkText, + 'update_by' => $userLabel, + ]) || $savedAny; + } } else { $notice = $name === '' ? 'Enter diagnosis before adding.' @@ -1537,6 +2638,7 @@ class Ipd_discharge extends BaseController } } elseif ($action === 'remove_diagnosis') { $removeId = (int) ($this->request->getPost('diagnosis_remove_id') ?? 0); + $diagnosisRemarkText = trim((string) ($this->request->getPost('diagnosis_remark') ?? '')); if ($removeId > 0 && $this->tableHasColumns('ipd_discharge_diagnosis', ['id', 'ipd_id'])) { $savedAny = (bool) $this->db->table('ipd_discharge_diagnosis') ->where('id', $removeId) @@ -1544,6 +2646,14 @@ class Ipd_discharge extends BaseController ->delete(); $notice = $savedAny ? 'Diagnosis row removed.' : 'Unable to remove diagnosis row.'; $noticeType = $savedAny ? 'success' : 'warning'; + + if ($this->tableHasColumns('ipd_discharge_diagnosis_remark', ['ipd_id'])) { + $savedAny = $this->upsertByIpd('ipd_discharge_diagnosis_remark', $ipdId, [ + 'comp_report' => '', + 'comp_remark' => $diagnosisRemarkText, + 'update_by' => $userLabel, + ]) || $savedAny; + } } else { $notice = 'Select a valid diagnosis row to remove.'; $noticeType = 'warning'; @@ -1551,6 +2661,7 @@ class Ipd_discharge extends BaseController } elseif ($action === 'add_course') { $name = trim((string) ($this->request->getPost('new_course_name') ?? '')); $remark = trim((string) ($this->request->getPost('new_course_remark') ?? '')); + $courseRemarkText = trim((string) ($this->request->getPost('course_remark') ?? '')); if ($name !== '' && $this->tableHasColumns('ipd_discharge_course', ['ipd_id', 'comp_report'])) { $insert = [ 'ipd_id' => $ipdId, @@ -1565,6 +2676,14 @@ class Ipd_discharge extends BaseController $savedAny = (bool) $this->db->table('ipd_discharge_course')->insert($insert); $notice = $savedAny ? 'Course row added.' : 'Unable to add course row.'; $noticeType = $savedAny ? 'success' : 'warning'; + + if ($this->tableHasColumns('ipd_discharge_course_remark', ['ipd_id'])) { + $savedAny = $this->upsertByIpd('ipd_discharge_course_remark', $ipdId, [ + 'comp_report' => '', + 'comp_remark' => $courseRemarkText, + 'update_by' => $userLabel, + ]) || $savedAny; + } } else { $notice = $name === '' ? 'Enter course/treatment text before adding.' @@ -1573,6 +2692,7 @@ class Ipd_discharge extends BaseController } } elseif ($action === 'remove_course') { $removeId = (int) ($this->request->getPost('course_remove_id') ?? 0); + $courseRemarkText = trim((string) ($this->request->getPost('course_remark') ?? '')); if ($removeId > 0 && $this->tableHasColumns('ipd_discharge_course', ['id', 'ipd_id'])) { $savedAny = (bool) $this->db->table('ipd_discharge_course') ->where('id', $removeId) @@ -1580,6 +2700,14 @@ class Ipd_discharge extends BaseController ->delete(); $notice = $savedAny ? 'Course row removed.' : 'Unable to remove course row.'; $noticeType = $savedAny ? 'success' : 'warning'; + + if ($this->tableHasColumns('ipd_discharge_course_remark', ['ipd_id'])) { + $savedAny = $this->upsertByIpd('ipd_discharge_course_remark', $ipdId, [ + 'comp_report' => '', + 'comp_remark' => $courseRemarkText, + 'update_by' => $userLabel, + ]) || $savedAny; + } } else { $notice = 'Select a valid course row to remove.'; $noticeType = 'warning'; @@ -1646,6 +2774,10 @@ class Ipd_discharge extends BaseController } $complaintRemark = trim((string) ($this->request->getPost('complaint_remark') ?? '')); + $painValue = trim((string) ($this->request->getPost('pain_value') ?? '')); + if (! in_array($painValue, ['0', '1', '2', '3', '4'], true)) { + $painValue = ''; + } $diagnosisRemark = trim((string) ($this->request->getPost('diagnosis_remark') ?? '')); $courseRemark = trim((string) ($this->request->getPost('course_remark') ?? '')); $instructionRemark = trim((string) ($this->request->getPost('instruction_remark') ?? '')); @@ -1653,7 +2785,7 @@ class Ipd_discharge extends BaseController if ($complaintRemark !== '' || $this->tableHasColumns('ipd_discharge_complaint_remark', ['ipd_id'])) { $savedAny = $this->upsertByIpd('ipd_discharge_complaint_remark', $ipdId, [ - 'comp_report' => '', + 'comp_report' => $this->buildComplaintMetaPayload(['pain_value' => $painValue]), 'comp_remark' => $complaintRemark, 'update_by' => $userLabel, ]) || $savedAny; @@ -1727,26 +2859,53 @@ class Ipd_discharge extends BaseController ->get() ->getResultArray(); - foreach ($sysMaster as $sys) { - $sid = (int) ($sys['id'] ?? 0); - if ($sid <= 0) { - continue; + $singleSystemicTextRaw = $this->request->getPost('systemic_exam_text'); + if ($singleSystemicTextRaw !== null) { + $singleSystemicText = trim((string) $singleSystemicTextRaw); + $primarySysId = (int) ($sysMaster[0]['id'] ?? 0); + + foreach ($sysMaster as $sys) { + $sid = (int) ($sys['id'] ?? 0); + if ($sid <= 0) { + continue; + } + + $rowText = $sid === $primarySysId ? $singleSystemicText : ''; + $savedAny = $this->upsertByComposite( + 'ipd_discharge_1_a', + 'ipd_d_id', + $ipdId, + 'head_id', + $sid, + [ + 'short_head' => (string) ($sys['sys_exam_name'] ?? ('Systemic Exam ' . $sid)), + 'rdata' => $rowText, + ] + ) || $savedAny; } - $posted = $this->request->getPost('sys_exam_' . $sid); - if ($posted === null) { - continue; + + } else { + foreach ($sysMaster as $sys) { + $sid = (int) ($sys['id'] ?? 0); + if ($sid <= 0) { + continue; + } + $posted = $this->request->getPost('sys_exam_' . $sid); + if ($posted === null) { + continue; + } + $savedAny = $this->upsertByComposite( + 'ipd_discharge_1_a', + 'ipd_d_id', + $ipdId, + 'head_id', + $sid, + [ + 'short_head' => (string) ($sys['sys_exam_name'] ?? ('Systemic Exam ' . $sid)), + 'rdata' => trim((string) $posted), + ] + ) || $savedAny; } - $savedAny = $this->upsertByComposite( - 'ipd_discharge_1_a', - 'ipd_d_id', - $ipdId, - 'head_id', - $sid, - [ - 'short_head' => (string) ($sys['sys_exam_name'] ?? ('Systemic Exam ' . $sid)), - 'rdata' => trim((string) $posted), - ] - ) || $savedAny; } } @@ -1933,6 +3092,21 @@ class Ipd_discharge extends BaseController } } + // Save editable NABH history fields back to latest OPD history row for this patient. + $nabhHistoryPayload = [ + 'drug_allergy_status' => trim((string) ($this->request->getPost('drug_allergy_status') ?? '')), + 'drug_allergy_details' => trim((string) ($this->request->getPost('drug_allergy_details') ?? '')), + 'adr_history' => trim((string) ($this->request->getPost('adr_history') ?? '')), + 'current_medications' => trim((string) ($this->request->getPost('current_medications') ?? '')), + 'co_morbidities' => trim((string) ($this->request->getPost('co_morbidities') ?? '')), + 'women_lmp' => trim((string) ($this->request->getPost('women_lmp') ?? '')), + 'women_last_baby' => trim((string) ($this->request->getPost('women_last_baby') ?? '')), + 'women_pregnancy_related' => trim((string) ($this->request->getPost('women_pregnancy_related') ?? '')), + 'women_related_problems' => trim((string) ($this->request->getPost('women_related_problems') ?? '')), + 'hpi_note' => trim((string) ($this->request->getPost('hpi_note') ?? '')), + ]; + $savedAny = $this->saveNabhHistoryFromDischarge($patientId, $nabhHistoryPayload) || $savedAny; + if ($savedAny) { $notice = 'Discharge form data saved. You can now preview or regenerate summary.'; } else { @@ -1960,6 +3134,7 @@ class Ipd_discharge extends BaseController : []; $complaintRemarkRow = $this->firstRowByIpd('ipd_discharge_complaint_remark', $ipdId); + $complaintMeta = $this->parseComplaintMetaPayload((string) ($complaintRemarkRow['comp_report'] ?? '')); $complaintRows = $this->byIpdRows('ipd_discharge_complaint', ['id', 'comp_report', 'comp_remark'], 'id ASC', $ipdId); $surgeryRows = $this->byIpdRows('ipd_discharge_surgery', ['id', 'surgery_name', 'surgery_date', 'surgery_remark'], 'id ASC', $ipdId); $procedureRows = $this->byIpdRows('ipd_discharge_procedure', ['id', 'procedure_name', 'procedure_date', 'procedure_remark'], 'id ASC', $ipdId); @@ -1983,6 +3158,8 @@ class Ipd_discharge extends BaseController $patientHistoryRow = $patientId > 0 && $this->db->tableExists('patient_master') ? ($this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? []) : []; + $opdHistorySnapshot = $this->getLatestOpdHistorySnapshot($patientId); + $nursingAdmissionSnapshot = $this->getNursingAdmissionSnapshot($ipdId); $physicalExamRows = $this->getPhysicalExamRows($ipdId); $manualInvestRows = $this->getMappedColRows('ipd_discharge_investigation_during_admit', 'ipd_discharge_1_d', $ipdId, 'Manual Exam', 1); $specialInvestRows = $this->getMappedColRows('ipd_discharge_special_investigation', 'ipd_discharge_1_e', $ipdId, 'Special Exam', 1); @@ -1999,6 +3176,28 @@ class Ipd_discharge extends BaseController $labInvestigationList = implode(',', $savedClinicalDates); $nonPathInvestigationList = implode(',', $savedNonPathIds); + $complaintRemarkText = (string) ($complaintRemarkRow['comp_remark'] ?? ''); + if (trim($complaintRemarkText) === '' && ! empty($nursingAdmissionSnapshot)) { + $parts = []; + $nursingComplaint = trim((string) ($nursingAdmissionSnapshot['treatment_text'] ?? '')); + $nursingNote = trim((string) ($nursingAdmissionSnapshot['general_note'] ?? '')); + if ($nursingComplaint !== '') { + $parts[] = $nursingComplaint; + } + if ($nursingNote !== '') { + $parts[] = $nursingNote; + } + if (! empty($parts)) { + $complaintRemarkText = implode(PHP_EOL, $parts); + } + } + if (trim($complaintRemarkText) === '') { + $hpiFallback = trim((string) ($opdHistorySnapshot['hpi_note'] ?? '')); + if ($hpiFallback !== '') { + $complaintRemarkText = $hpiFallback; + } + } + return view('billing/ipd/discharge_create', [ 'ipd_id' => $ipdId, 'ipd_info' => $panelData['ipd_info'] ?? null, @@ -2023,7 +3222,10 @@ class Ipd_discharge extends BaseController 'lab_investigation_list' => $labInvestigationList, 'non_path_investigation_list' => $nonPathInvestigationList, 'discharge_condition_rows' => $dischargeConditionRows, - 'complaint_remark' => (string) ($complaintRemarkRow['comp_remark'] ?? ''), + 'complaint_remark' => $complaintRemarkText, + 'pain_value' => (string) ($complaintMeta['pain_value'] ?? ''), + 'opd_history_snapshot' => $opdHistorySnapshot, + 'nursing_admission_snapshot' => $nursingAdmissionSnapshot, 'diagnosis_remark' => (string) ($diagnosisRemarkRow['comp_remark'] ?? ''), 'course_remark' => (string) ($courseRemarkRow['comp_remark'] ?? ''), 'instruction_remark' => (string) ($instructionRow['comp_remark'] ?? ''), @@ -2057,17 +3259,9 @@ class Ipd_discharge extends BaseController $noticeType = 'success'; $content = $this->getDischargeContent($ipdId); $shouldRegenerate = (int) ($this->request->getGet('regen') ?? 0) === 1; + $requestedTemplateId = (int) ($this->request->getGet('tpl') ?? 0); - if (strtolower($this->request->getMethod()) === 'post') { - $content = (string) ($this->request->getPost('editor_Discharge_Preview') ?? ''); - if ($this->saveDischargeContent($ipdId, $content)) { - $notice = 'Discharge summary saved.'; - $noticeType = 'success'; - } else { - $notice = 'Unable to save discharge summary in ipd_discharge table. Please check schema/permissions.'; - $noticeType = 'danger'; - } - } elseif ($shouldRegenerate || trim(strip_tags($content)) === '') { + if ($shouldRegenerate || trim(strip_tags($content)) === '') { $generated = $this->buildAutoDischargeContent($ipdId, $panelData); if (trim(strip_tags($generated)) !== '') { $content = $generated; @@ -2081,11 +3275,19 @@ class Ipd_discharge extends BaseController } } + $templatePack = $this->applyDischargeTemplate($content, $panelData, $requestedTemplateId > 0 ? $requestedTemplateId : null); + $nabhAudit = $this->buildNabhAuditChecklist($ipdId, $panelData); + return view('billing/ipd/discharge_preview', [ 'ipd_id' => $ipdId, 'ipd_info' => $panelData['ipd_info'] ?? null, 'person_info' => $panelData['person_info'] ?? null, 'content' => $content, + 'rendered_content' => (string) ($templatePack['rendered_html'] ?? $content), + 'template_rows' => $templatePack['templates'] ?? [], + 'selected_template_id' => (int) ($templatePack['selected_template_id'] ?? 0), + 'selected_template_name' => (string) ($templatePack['selected_template_name'] ?? ''), + 'nabh_audit' => $nabhAudit, 'notice' => $notice, 'notice_type' => $noticeType, ]); @@ -2119,11 +3321,16 @@ class Ipd_discharge extends BaseController } } + $requestedTemplateId = (int) ($this->request->getGet('tpl') ?? 0); + $templatePack = $this->applyDischargeTemplate($content, $panelData, $requestedTemplateId > 0 ? $requestedTemplateId : null); + return view('billing/ipd/discharge_print', [ 'ipd_id' => $ipdId, 'ipd_info' => $panelData['ipd_info'] ?? null, 'person_info' => $panelData['person_info'] ?? null, - 'content' => $content, + 'content' => (string) ($templatePack['rendered_html'] ?? $content), + 'selected_template_name' => (string) ($templatePack['selected_template_name'] ?? ''), + 'selected_template_id' => (int) ($templatePack['selected_template_id'] ?? 0), 'print_type' => $printType, 'print_mode' => 'standard', ]); diff --git a/app/Controllers/Setting/NursingBedsideItems.php b/app/Controllers/Setting/NursingBedsideItems.php new file mode 100644 index 0000000..c811c82 --- /dev/null +++ b/app/Controllers/Setting/NursingBedsideItems.php @@ -0,0 +1,167 @@ +model = new NursingBedsideItemModel(); + } + + public function index() + { + return view('Setting/Charges/Nursing_Bedside/item_search_V', [ + 'rows' => $this->model->getMasterList(), + 'insurance_list' => $this->model->getInsuranceList(), + ]); + } + + public function get(int $itemId) + { + return $this->response->setJSON([ + 'ok' => 1, + 'row' => $this->model->find($itemId), + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function save() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'message' => 'Invalid request']); + } + + $itemId = (int) ($this->request->getPost('item_id') ?? 0); + $itemName = trim((string) ($this->request->getPost('item_name') ?? '')); + $itemType = trim((string) ($this->request->getPost('item_type') ?? '')); + + if ($itemName === '' || $itemType === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'message' => 'Item Name and Item Type are required', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + $userId = null; + if (function_exists('auth')) { + $user = auth()->user(); + if ($user) { + $userId = (int) ($user->id ?? 0); + } + } + + $data = [ + 'item_code' => trim((string) ($this->request->getPost('item_code') ?? '')) ?: null, + 'item_name' => $itemName, + 'item_type' => $itemType, + 'category' => trim((string) ($this->request->getPost('category') ?? '')) ?: null, + 'default_rate' => (float) ($this->request->getPost('default_rate') ?? 0), + 'unit' => trim((string) ($this->request->getPost('unit') ?? 'Unit')), + 'description' => trim((string) ($this->request->getPost('description') ?? '')) ?: null, + 'is_active' => (int) ($this->request->getPost('is_active') ?? 0) === 1 ? 1 : 0, + 'is_billable' => (int) ($this->request->getPost('is_billable') ?? 0) === 1 ? 1 : 0, + 'updated_by' => $userId, + ]; + + if ($itemId > 0) { + $saved = $this->model->update($itemId, $data); + $savedItemId = $itemId; + } else { + $data['created_by'] = $userId; + $savedItemId = (int) $this->model->insert($data); + $saved = $savedItemId > 0; + } + + return $this->response->setJSON([ + 'ok' => $saved ? 1 : 0, + 'message' => $saved ? 'Saved successfully' : 'Unable to save', + 'item_id' => $saved ? $savedItemId : 0, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function insuranceList(int $itemId) + { + return $this->response->setJSON([ + 'ok' => 1, + 'html' => view('Setting/Charges/Nursing_Bedside/item_insurance_list', [ + 'rows' => $this->model->getInsuranceRatesForItem($itemId), + ]), + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function insuranceAdd() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'message' => 'Invalid request']); + } + + $itemId = (int) ($this->request->getPost('item_id') ?? 0); + $insuranceId = (int) ($this->request->getPost('insurance_id') ?? 0); + $amount = (float) ($this->request->getPost('amount') ?? 0); + $code = trim((string) ($this->request->getPost('code') ?? '')); + + if ($itemId <= 0 || $insuranceId <= 0) { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'message' => 'Select item and insurance company', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + $saved = $this->model->upsertInsuranceRate($itemId, $insuranceId, $amount, $code); + + return $this->response->setJSON([ + 'ok' => $saved ? 1 : 0, + 'message' => $saved ? 'Insurance rate saved' : 'Unable to save insurance rate', + 'html' => view('Setting/Charges/Nursing_Bedside/item_insurance_list', [ + 'rows' => $this->model->getInsuranceRatesForItem($itemId), + ]), + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function insuranceRemove() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'message' => 'Invalid request']); + } + + $mappingId = (int) ($this->request->getPost('mapping_id') ?? 0); + $itemId = (int) ($this->request->getPost('item_id') ?? 0); + if ($mappingId <= 0 || $itemId <= 0) { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'message' => 'Invalid insurance mapping', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + $removed = $this->model->removeInsuranceRate($mappingId); + + return $this->response->setJSON([ + 'ok' => $removed ? 1 : 0, + 'message' => $removed ? 'Insurance rate removed' : 'Unable to remove insurance rate', + 'html' => view('Setting/Charges/Nursing_Bedside/item_insurance_list', [ + 'rows' => $this->model->getInsuranceRatesForItem($itemId), + ]), + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } +} diff --git a/app/Database/Migrations/2026-03-05-000003_CreateIpdNursingEntriesTable.php b/app/Database/Migrations/2026-03-05-000003_CreateIpdNursingEntriesTable.php new file mode 100644 index 0000000..1b4c3fc --- /dev/null +++ b/app/Database/Migrations/2026-03-05-000003_CreateIpdNursingEntriesTable.php @@ -0,0 +1,131 @@ +db->tableExists('ipd_nursing_entries')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'ipd_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'entry_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => false, + ], + 'recorded_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + 'shift_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + ], + 'temperature_c' => [ + 'type' => 'DECIMAL', + 'constraint' => '5,2', + 'null' => true, + ], + 'pulse_rate' => [ + 'type' => 'SMALLINT', + 'constraint' => 5, + 'null' => true, + ], + 'resp_rate' => [ + 'type' => 'SMALLINT', + 'constraint' => 5, + 'null' => true, + ], + 'bp_systolic' => [ + 'type' => 'SMALLINT', + 'constraint' => 5, + 'null' => true, + ], + 'bp_diastolic' => [ + 'type' => 'SMALLINT', + 'constraint' => 5, + 'null' => true, + ], + 'spo2' => [ + 'type' => 'SMALLINT', + 'constraint' => 5, + 'null' => true, + ], + 'weight_kg' => [ + 'type' => 'DECIMAL', + 'constraint' => '6,2', + 'null' => true, + ], + 'fluid_direction' => [ + 'type' => 'VARCHAR', + 'constraint' => 10, + 'null' => true, + ], + 'fluid_route' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + 'fluid_amount_ml' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'treatment_text' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'general_note' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'recorded_by' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'recorded_by_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('ipd_id'); + $this->forge->addKey('entry_type'); + $this->forge->addKey('recorded_at'); + $this->forge->createTable('ipd_nursing_entries', true); + } + + public function down() + { + $this->forge->dropTable('ipd_nursing_entries', true); + } +} diff --git a/app/Database/Migrations/2026-03-06-000001_CreateNursingBedsideItemsInsuranceTable.php b/app/Database/Migrations/2026-03-06-000001_CreateNursingBedsideItemsInsuranceTable.php new file mode 100644 index 0000000..06475cc --- /dev/null +++ b/app/Database/Migrations/2026-03-06-000001_CreateNursingBedsideItemsInsuranceTable.php @@ -0,0 +1,72 @@ +db->tableExists('nursing_bedside_items_insurance')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'bedside_item_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'hc_insurance_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'amount1' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'null' => false, + 'default' => '0.00', + ], + 'code' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'isdelete' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'null' => false, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('bedside_item_id'); + $this->forge->addKey('hc_insurance_id'); + $this->forge->addKey(['bedside_item_id', 'hc_insurance_id']); + $this->forge->createTable('nursing_bedside_items_insurance', true); + } + + public function down() + { + $this->forge->dropTable('nursing_bedside_items_insurance', true); + } +} diff --git a/app/Libraries/NursingScanExtractionService.php b/app/Libraries/NursingScanExtractionService.php new file mode 100644 index 0000000..f5a5b38 --- /dev/null +++ b/app/Libraries/NursingScanExtractionService.php @@ -0,0 +1,297 @@ +extractTextUsingAzureDocumentIntelligence($uploadedPath, $uploaded['mime'] ?? ''); + if ($azureText !== '') { + $text = $azureText; + } else { + $warnings[] = 'No OCR text extracted. You can paste OCR text manually and rescan.'; + } + } + + $rows = $this->parseRows($docType, $text); + + return [ + 'scan_ref' => $scanRef, + 'source_text' => $text, + 'rows' => $rows, + 'warnings' => $warnings, + ]; + } + + private function parseRows(string $docType, string $text): array + { + $normalized = strtolower(trim($docType)); + if (! in_array($normalized, ['vitals', 'fluid', 'treatment', 'auto'], true)) { + $normalized = 'auto'; + } + + if ($normalized === 'auto') { + $hint = strtolower($text); + if (preg_match('/\b(intake|output|i\/?o|fluid)\b/i', $hint) === 1) { + $normalized = 'fluid'; + } elseif (preg_match('/\b(pulse|bp|spo2|temperature|temp|resp|rr)\b/i', $hint) === 1) { + $normalized = 'vitals'; + } else { + $normalized = 'treatment'; + } + } + + if ($normalized === 'vitals') { + return [$this->parseVitalsRow($text)]; + } + + if ($normalized === 'fluid') { + return [$this->parseFluidRow($text)]; + } + + return [$this->parseTreatmentRow($text)]; + } + + private function parseVitalsRow(string $text): array + { + return [ + 'entry_type' => 'vitals', + 'recorded_at' => $this->extractDateTime($text), + 'temperature_f' => $this->extractNumber($text, '/(?:temp|temperature)\s*[:=-]?\s*(\d{2,3}(?:\.\d+)?)/i'), + 'pulse_rate' => $this->extractNumber($text, '/(?:pulse|pr)\s*[:=-]?\s*(\d{2,3})/i'), + 'resp_rate' => $this->extractNumber($text, '/(?:resp|rr)\s*[:=-]?\s*(\d{1,3})/i'), + 'bp_systolic' => $this->extractBloodPressure($text, 0), + 'bp_diastolic' => $this->extractBloodPressure($text, 1), + 'spo2' => $this->extractNumber($text, '/(?:spo2|spo\s*2)\s*[:=-]?\s*(\d{2,3})/i'), + 'weight_kg' => $this->extractNumber($text, '/(?:weight|wt)\s*[:=-]?\s*(\d{1,3}(?:\.\d+)?)/i'), + 'general_note' => trim($text), + 'confidence' => 0.65, + ]; + } + + private function parseFluidRow(string $text): array + { + $direction = 'intake'; + if (preg_match('/\b(output|urine|drain|vomit|stool)\b/i', $text) === 1) { + $direction = 'output'; + } + + return [ + 'entry_type' => 'fluid', + 'recorded_at' => $this->extractDateTime($text), + 'fluid_direction' => $direction, + 'fluid_route' => $this->extractWord($text, '/(?:route)\s*[:=-]?\s*([a-zA-Z\s\/]{2,40})/i'), + 'fluid_amount_ml' => $this->extractNumber($text, '/(\d{1,5})\s*(?:ml|mL|cc)\b/i'), + 'general_note' => trim($text), + 'confidence' => 0.65, + ]; + } + + private function parseTreatmentRow(string $text): array + { + return [ + 'entry_type' => 'treatment', + 'recorded_at' => $this->extractDateTime($text), + 'treatment_text' => trim($text), + 'general_note' => trim($text), + 'confidence' => 0.6, + ]; + } + + private function extractDateTime(string $text): string + { + if (preg_match('/(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/', $text, $m) === 1) { + return $m[1] . 'T' . $m[2]; + } + + if (preg_match('/(\d{2}[\/\-]\d{2}[\/\-]\d{4})\s+(\d{1,2}:\d{2})/', $text, $m) === 1) { + $date = str_replace('/', '-', $m[1]); + $ts = strtotime($date . ' ' . $m[2]); + if ($ts !== false) { + return date('Y-m-d\TH:i', $ts); + } + } + + return date('Y-m-d\TH:i'); + } + + private function extractNumber(string $text, string $pattern): ?string + { + if (preg_match($pattern, $text, $m) !== 1) { + return null; + } + + return trim((string) ($m[1] ?? '')); + } + + private function extractWord(string $text, string $pattern): string + { + if (preg_match($pattern, $text, $m) !== 1) { + return ''; + } + + return trim((string) ($m[1] ?? '')); + } + + private function extractBloodPressure(string $text, int $index): ?string + { + if (preg_match('/(?:bp|blood\s*pressure)\s*[:=-]?\s*(\d{2,3})\s*[\/\\-]\s*(\d{2,3})/i', $text, $m) !== 1) { + return null; + } + + return $index === 0 ? (string) ($m[1] ?? '') : (string) ($m[2] ?? ''); + } + + private function extractTextUsingAzureDocumentIntelligence(string $path, string $mime): string + { + $endpoint = rtrim((string) env('azure.docintel.endpoint', ''), '/'); + $apiKey = trim((string) env('azure.docintel.api_key', '')); + $model = trim((string) env('azure.docintel.model', 'prebuilt-read')); + $apiVersion = trim((string) env('azure.docintel.api_version', '2024-11-30')); + + if ($endpoint === '' || $apiKey === '') { + return ''; + } + + $content = @file_get_contents($path); + if (! is_string($content) || $content === '') { + return ''; + } + + $url = $endpoint . '/documentintelligence/documentModels/' . rawurlencode($model) . ':analyze?api-version=' . rawurlencode($apiVersion); + + $headers = [ + 'Ocp-Apim-Subscription-Key: ' . $apiKey, + 'Content-Type: ' . ($mime !== '' ? $mime : 'application/octet-stream'), + ]; + + $result = $this->sendCurlRequest($url, 'POST', $headers, $content, true); + if ($result['http'] < 200 || $result['http'] >= 300) { + return ''; + } + + $operation = ''; + foreach ($result['headers'] as $key => $value) { + if (strtolower($key) === 'operation-location') { + $operation = $value; + break; + } + } + + if ($operation === '') { + return ''; + } + + $readText = ''; + for ($i = 0; $i < 10; $i++) { + usleep(700000); + $poll = $this->sendCurlRequest($operation, 'GET', [ + 'Ocp-Apim-Subscription-Key: ' . $apiKey, + ], null, false); + + if ($poll['http'] < 200 || $poll['http'] >= 300) { + continue; + } + + $body = json_decode((string) $poll['body'], true); + if (! is_array($body)) { + continue; + } + + $status = strtolower((string) ($body['status'] ?? '')); + if ($status === 'succeeded') { + $lines = []; + $items = $body['analyzeResult']['content'] ?? ''; + if (is_string($items) && trim($items) !== '') { + $readText = trim($items); + } elseif (isset($body['analyzeResult']['pages']) && is_array($body['analyzeResult']['pages'])) { + foreach ($body['analyzeResult']['pages'] as $page) { + foreach (($page['lines'] ?? []) as $line) { + $txt = trim((string) ($line['content'] ?? '')); + if ($txt !== '') { + $lines[] = $txt; + } + } + } + $readText = trim(implode("\n", $lines)); + } + break; + } + + if ($status === 'failed') { + break; + } + } + + return $readText; + } + + private function sendCurlRequest(string $url, string $method, array $headers, ?string $body, bool $includeHeaders): array + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 45); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_HEADER, $includeHeaders); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $raw = curl_exec($ch); + $http = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + + $responseHeaders = []; + $responseBody = is_string($raw) ? $raw : ''; + if ($includeHeaders && is_string($raw)) { + $headerPart = substr($raw, 0, $headerSize); + $responseBody = substr($raw, $headerSize); + $lines = preg_split('/\r\n|\n|\r/', $headerPart) ?: []; + foreach ($lines as $line) { + $parts = explode(':', $line, 2); + if (count($parts) !== 2) { + continue; + } + $name = trim($parts[0]); + $value = trim($parts[1]); + if ($name !== '') { + $responseHeaders[$name] = $value; + } + } + } + + return [ + 'http' => $http, + 'headers' => $responseHeaders, + 'body' => $responseBody, + ]; + } +} diff --git a/app/Models/IpdNursingEntryModel.php b/app/Models/IpdNursingEntryModel.php new file mode 100644 index 0000000..acb70d0 --- /dev/null +++ b/app/Models/IpdNursingEntryModel.php @@ -0,0 +1,62 @@ +builder() + ->where('ipd_id', $ipdId) + ->orderBy('recorded_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + } + + public function getByIpdPeriod(int $ipdId, string $startDateTime, string $endDateTime, string $nurse = ''): array + { + $builder = $this->builder() + ->where('ipd_id', $ipdId) + ->where('recorded_at >=', $startDateTime) + ->where('recorded_at <=', $endDateTime) + ->orderBy('recorded_at', 'ASC') + ->orderBy('id', 'ASC'); + + if ($nurse !== '') { + $builder->where('recorded_by', $nurse); + } + + return $builder->get()->getResultArray(); + } +} diff --git a/app/Models/NursingBedsideItemModel.php b/app/Models/NursingBedsideItemModel.php new file mode 100644 index 0000000..ad7756d --- /dev/null +++ b/app/Models/NursingBedsideItemModel.php @@ -0,0 +1,199 @@ +db->table('nursing_bedside_items b') + ->select('b.item_id, b.item_code, b.item_name, b.item_type, b.category, b.default_rate, b.unit') + ->where('b.is_active', 1) + ->where('b.is_billable', 1) + ->orderBy('b.category', 'ASC') + ->orderBy('b.item_name', 'ASC'); + + if ($insuranceId > 0 && $this->insuranceTableExists()) { + $builder->select('i.amount1 as insurance_rate, i.code as insurance_code'); + $builder->join( + 'nursing_bedside_items_insurance i', + 'i.bedside_item_id = b.item_id and i.hc_insurance_id = ' . (int) $insuranceId . ' and i.isdelete = 0', + 'left' + ); + } + + $rows = $builder->get()->getResultArray(); + + foreach ($rows as &$row) { + if (isset($row['insurance_rate']) && $row['insurance_rate'] !== null) { + $row['default_rate'] = (float) $row['insurance_rate']; + } + if (! isset($row['insurance_code'])) { + $row['insurance_code'] = null; + } + } + unset($row); + + $grouped = []; + foreach ($rows as $row) { + $category = trim((string) ($row['category'] ?? '')); + if ($category === '') { + $category = 'General'; + } + if (! isset($grouped[$category])) { + $grouped[$category] = []; + } + $grouped[$category][] = $row; + } + + return $grouped; + } + + public function getMasterList(): array + { + return $this->builder() + ->select('*') + ->orderBy('category', 'ASC') + ->orderBy('item_name', 'ASC') + ->get() + ->getResultArray(); + } + + public function getInsuranceList(): array + { + return $this->db->table('hc_insurance') + ->select('id, ins_company_name') + ->where('id >', 1) + ->orderBy('ins_company_name', 'ASC') + ->get() + ->getResultArray(); + } + + public function getInsuranceRatesForItem(int $itemId): array + { + if (! $this->insuranceTableExists()) { + return []; + } + + $latestSubQuery = $this->db->table('nursing_bedside_items_insurance') + ->select('MAX(id) AS id') + ->where('bedside_item_id', $itemId) + ->where('isdelete', 0) + ->groupBy('hc_insurance_id') + ->getCompiledSelect(); + + return $this->db->table('nursing_bedside_items_insurance i') + ->select('i.id, i.bedside_item_id, i.hc_insurance_id, i.amount1, i.code, c.ins_company_name') + ->join('hc_insurance c', 'c.id = i.hc_insurance_id', 'left') + ->where('i.id IN (' . $latestSubQuery . ')', null, false) + ->orderBy('c.ins_company_name', 'ASC') + ->get() + ->getResultArray(); + } + + public function upsertInsuranceRate(int $itemId, int $insuranceId, float $amount, string $code): bool + { + if (! $this->insuranceTableExists()) { + return false; + } + + $existing = $this->db->table('nursing_bedside_items_insurance') + ->where('bedside_item_id', $itemId) + ->where('hc_insurance_id', $insuranceId) + ->where('isdelete', 0) + ->get() + ->getRowArray(); + + if (! empty($existing)) { + return (bool) $this->db->table('nursing_bedside_items_insurance') + ->where('id', (int) $existing['id']) + ->update([ + 'amount1' => $amount, + 'code' => $code, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + return (bool) $this->db->table('nursing_bedside_items_insurance')->insert([ + 'bedside_item_id' => $itemId, + 'hc_insurance_id' => $insuranceId, + 'amount1' => $amount, + 'code' => $code, + 'isdelete' => 0, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + public function removeInsuranceRate(int $mappingId): bool + { + if (! $this->insuranceTableExists()) { + return false; + } + + return (bool) $this->db->table('nursing_bedside_items_insurance') + ->where('id', $mappingId) + ->update([ + 'isdelete' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + public function getBillableItemForInsurance(int $itemId, int $insuranceId): ?array + { + $builder = $this->db->table('nursing_bedside_items b'); + $builder->select([ + 'b.item_id', + 'b.item_code', + 'b.item_name', + 'b.item_type', + 'b.category', + 'b.default_rate', + 'b.unit', + 'b.is_active', + 'b.is_billable', + ]); + if ($insuranceId > 0 && $this->insuranceTableExists()) { + $builder->select('i.amount1 as insurance_rate, i.code as insurance_code'); + $builder->join( + 'nursing_bedside_items_insurance i', + 'i.bedside_item_id = b.item_id and i.hc_insurance_id = ' . (int) $insuranceId . ' and i.isdelete = 0', + 'left' + ); + } else { + $builder->select('NULL as insurance_rate, NULL as insurance_code', false); + } + $builder->where('b.item_id', $itemId); + $builder->where('b.is_active', 1); + $builder->where('b.is_billable', 1); + + $row = $builder->get()->getRowArray(); + + return empty($row) ? null : $row; + } + + private function insuranceTableExists(): bool + { + return $this->db->tableExists('nursing_bedside_items_insurance'); + } +} diff --git a/app/Views/Setting/Charges/Nursing_Bedside/item_insurance_list.php b/app/Views/Setting/Charges/Nursing_Bedside/item_insurance_list.php new file mode 100644 index 0000000..a5b63ef --- /dev/null +++ b/app/Views/Setting/Charges/Nursing_Bedside/item_insurance_list.php @@ -0,0 +1,27 @@ + +
' . esc($label) . ': ' . esc($value) . '' . esc($label) . ': ' . esc($value); + if ($unit !== '') { + $html .= ' ' . esc($unit); + } + $html .= '
+ + + + + + + + + + + + + + + + + + + + + + +
Company NameRateCode
No insurance-specific rate added.
+ +
diff --git a/app/Views/Setting/Charges/Nursing_Bedside/item_search_V.php b/app/Views/Setting/Charges/Nursing_Bedside/item_search_V.php new file mode 100644 index 0000000..ca00b6c --- /dev/null +++ b/app/Views/Setting/Charges/Nursing_Bedside/item_search_V.php @@ -0,0 +1,296 @@ + + +
+
+

Clinical Procedure / Test Master

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeItem NameTypeCategoryRateUnitBillableActive
+ +
+
+
+ + + + diff --git a/app/Views/billing/ipd/discharge_create.php b/app/Views/billing/ipd/discharge_create.php index c6eb96b..57dbf1f 100644 --- a/app/Views/billing/ipd/discharge_create.php +++ b/app/Views/billing/ipd/discharge_create.php @@ -45,6 +45,27 @@ $physicalExamRows = $physical_exam_rows ?? []; $generalExamGroup1 = $physicalExamRows['general_group_1'] ?? []; $generalExamGroup2 = $physicalExamRows['general_group_2'] ?? []; $systemicExamRows = $physicalExamRows['systemic'] ?? []; +$systemicExamText = ''; +if (! empty($systemicExamRows)) { + $systemicParts = []; + foreach ($systemicExamRows as $row) { + $value = trim((string) ($row['value'] ?? '')); + if ($value === '') { + continue; + } + + $name = trim((string) ($row['name'] ?? '')); + if ($name !== '' && stripos($value, $name . ':') !== 0) { + $systemicParts[] = $name . ': ' . $value; + } else { + $systemicParts[] = $value; + } + } + + if (! empty($systemicParts)) { + $systemicExamText = implode(PHP_EOL, $systemicParts); + } +} $manualInvestRows = $manual_invest_rows ?? []; $specialInvestRows = $special_invest_rows ?? []; $clinicalLabRows = $clinical_lab_rows ?? []; @@ -59,6 +80,86 @@ $courseRows = $course_rows ?? []; $drugRows = $drug_rows ?? []; $inhosRemark = (string) ($inhos_remark ?? ''); $otherExamText = (string) ($other_exam_text ?? ''); +$painValue = (string) ($pain_value ?? ''); +$opdHistorySnapshot = $opd_history_snapshot ?? []; +$nursingAdmissionSnapshot = $nursing_admission_snapshot ?? []; +$drugAllergyStatus = trim((string) ($opdHistorySnapshot['drug_allergy_status'] ?? '')); +$drugAllergyDetails = trim((string) ($opdHistorySnapshot['drug_allergy_details'] ?? '')); +$adrHistory = trim((string) ($opdHistorySnapshot['adr_history'] ?? '')); +$currentMedications = trim((string) ($opdHistorySnapshot['current_medications'] ?? '')); +$coMorbiditiesText = trim((string) ($opdHistorySnapshot['co_morbidities'] ?? '')); +$womenLmp = trim((string) ($opdHistorySnapshot['women_lmp'] ?? '')); +$womenLastBaby = trim((string) ($opdHistorySnapshot['women_last_baby'] ?? '')); +$womenPregnancyRelated = trim((string) ($opdHistorySnapshot['women_pregnancy_related'] ?? '')); +$womenRelatedProblems = trim((string) ($opdHistorySnapshot['women_related_problems'] ?? '')); +$hpiNote = trim((string) ($opdHistorySnapshot['hpi_note'] ?? '')); +$nursingHistoryRecordedAt = trim((string) ($nursingAdmissionSnapshot['recorded_at'] ?? '')); +$isFemalePatient = strtolower(trim((string) ($person->xgender ?? ''))) === 'female'; +$hasNursingHistoryPrefill = $nursingHistoryRecordedAt !== '' + && ($hpiNote !== '' + || $drugAllergyStatus !== '' + || $coMorbiditiesText !== '' + || $womenLmp !== '' + || $womenLastBaby !== '' + || $womenPregnancyRelated !== '' + || $womenRelatedProblems !== ''); +if ($coMorbiditiesText === '') { + $coMorbidFallback = []; + if ((int) ($patientHistory['is_niddm'] ?? 0) === 1) { + $coMorbidFallback[] = 'Diabetes mellitus (DM)'; + } + if ((int) ($patientHistory['is_hypertesion'] ?? 0) === 1) { + $coMorbidFallback[] = 'Hypertension'; + } + if (! empty($coMorbidFallback)) { + $coMorbiditiesText = implode(', ', $coMorbidFallback); + } +} + +$coMorbidityOptions = [ + 'dm' => ['label' => 'Diabetes mellitus (DM)', 'keywords' => ['diabetes mellitus', 'diabetes', 'dm']], + 'htn' => ['label' => 'high blood pressure (HTn)', 'keywords' => ['high blood pressure', 'hypertension', 'htn']], + 'cad' => ['label' => 'Coronary artery disease (CAD)', 'keywords' => ['coronary artery disease', 'cad']], + 'copd' => ['label' => 'Chronic Obstructive Pulmonary Disease', 'keywords' => ['chronic obstructive pulmonary disease', 'copd']], + 'cva' => ['label' => 'cerebral vascular accident (CVA)', 'keywords' => ['cerebral vascular accident', 'stroke', 'cva']], +]; +$coMorbiditySelected = array_fill_keys(array_keys($coMorbidityOptions), false); +$coMorbidityOtherText = ''; +if ($coMorbiditiesText !== '') { + $coMorbidityParts = preg_split('/[,;\n]+/', $coMorbiditiesText); + if (is_array($coMorbidityParts)) { + $otherParts = []; + foreach ($coMorbidityParts as $part) { + $cleanPart = trim((string) $part); + if ($cleanPart === '') { + continue; + } + $normalizedPart = strtolower($cleanPart); + $matched = false; + foreach ($coMorbidityOptions as $key => $opt) { + foreach (($opt['keywords'] ?? []) as $kw) { + if ($kw !== '' && strpos($normalizedPart, strtolower((string) $kw)) !== false) { + $coMorbiditySelected[$key] = true; + $matched = true; + break; + } + } + if ($matched) { + break; + } + } + if (! $matched) { + $otherParts[] = $cleanPart; + } + } + $coMorbidityOtherText = implode(', ', $otherParts); + } +} + +$allergyStatusNormalized = strtolower($drugAllergyStatus); +$allergyStatusKnown = in_array($allergyStatusNormalized, ['known'], true); +$allergyStatusUnknown = in_array($allergyStatusNormalized, ['allergies not known', 'not known', 'unknown'], true); +$allergyStatusNoKnown = in_array($allergyStatusNormalized, ['no known drug allergy', 'none', 'no known allergy'], true); $historyFields = [ 'is_smoking' => 'Smoking', @@ -74,10 +175,15 @@ $otherExamText = (string) ($other_exam_text ?? ''); ?> -
+
Name : / Gender : xgender ?? '')) ?> @@ -174,7 +451,8 @@ $otherExamText = (string) ($other_exam_text ?? '');
@@ -209,56 +488,169 @@ $otherExamText = (string) ($other_exam_text ?? '');
+
+
+ From Nursing History & Physical Assessment + + Prefilled from Nursing H&P + + No Nursing H&P prefill found + +
+
+ +
Admission snapshot time:
+ +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
Complaints with Duration and Reason for Admission
-
- + + + + + +
+ +
+ + + +
+ +
+
+ +
+
- + - - - - - - - - - + + + + + + +
Complaint NameRemarksRemark Action
No complaint rows yet.
- -
+ +
- -
-
- - +
+
Pain Measurement Scale
+ +
+ > + > + > + > + >
-
- - +
+ +
+
+
+
Drug Allergy / ADR (NABH)
+
Required: Drug Allergy Status is mandatory. If status is Known, Drug Allergy Details are mandatory.
+
+
+ + + +
+
+ + + +
+
+ + +
+
+ + +
+
+
-
- +
+
+
Co-Morbidities
+
+ $mOpt): ?> + + +
+
+ + +
+ +
- +
+ +
+ +
@@ -301,16 +693,8 @@ $otherExamText = (string) ($other_exam_text ?? '');
Other / Systemic Examinations
- -
No systemic examination master rows found.
- - -
- - -
- - + +
@@ -408,6 +792,43 @@ $otherExamText = (string) ($other_exam_text ?? '');
+
+
Admission / Discharge Information
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
Surgery / Procedure / delivery if any
@@ -465,6 +886,16 @@ $otherExamText = (string) ($other_exam_text ?? '');
Final Diagnosis
+
+ +
+ + +
+ +
+
+ @@ -481,10 +912,25 @@ $otherExamText = (string) ($other_exam_text ?? '');
DiagnosisRemarkAction
-
+
+ +
+ + +
+ +
+ +
@@ -498,6 +944,16 @@ $otherExamText = (string) ($other_exam_text ?? '');
Course / Treatment in the hospital
+
+ +
+ + +
+ +
+
+ @@ -514,10 +970,25 @@ $otherExamText = (string) ($other_exam_text ?? '');
CourseRemarkAction
-
+
+ +
+ + +
+ +
+ +
@@ -563,55 +1034,11 @@ $otherExamText = (string) ($other_exam_text ?? '');
-
-
Admission / Discharge Information
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
+
-
Summary Blocks
+
Discharge Instructions / Advice
-
- - -
-
- - -
@@ -634,8 +1061,64 @@ $otherExamText = (string) ($other_exam_text ?? '');
+ + + + diff --git a/app/Views/ipd_nursing/workspace.php b/app/Views/ipd_nursing/workspace.php new file mode 100644 index 0000000..9c6297f --- /dev/null +++ b/app/Views/ipd_nursing/workspace.php @@ -0,0 +1,1442 @@ +id ?? 0); +$saveUrl = site_url('ipd/patient/nursing/save/' . $ipdId); +$historySaveUrl = site_url('ipd/patient/history/save/' . $ipdId); +$scanExtractUrl = site_url('ipd/patient/nursing/scan/' . $ipdId); +$scanSaveUrl = site_url('ipd/patient/nursing/scan-save/' . $ipdId); +$reloadUrl = site_url('ipd/patient/workspace/' . $ipdId); +$printUrl = site_url('ipd/patient/nursing/print/' . $ipdId); +$dischargeSummaryUrl = site_url('Ipd_discharge/preview_discharge_report/' . $ipdId); +$dischargeSummaryCreateUrl = site_url('Ipd_discharge/ipd_select/' . $ipdId); +$bedsideChargeUrl = site_url('billing/ipd/bedside-charge/add/' . $ipdId); +$doctorVisitChargeUrl = site_url('billing/ipd/doctor-visit/add/' . $ipdId); +$today = date('Y-m-d'); +$csrfName = csrf_token(); +$csrfHash = csrf_hash(); +$bedsideItemsByCategory = $bedside_items_by_category ?? []; +$doctorVisitFeeTypes = $doctor_visit_fee_types ?? []; +$doctorVisitFeeMap = $doctor_visit_fee_map ?? []; +$docList = $doc_list ?? []; +$nursingChargeHistory = $nursing_charge_history ?? []; +$historyFields = $history_fields ?? []; +$patientHistoryRow = $patient_history_row ?? []; +$opdHistorySnapshot = $opd_history_snapshot ?? []; +$isFemalePatient = (bool) ($is_female_patient ?? false); +$coMorbidityOptions = ['Hypertension', 'DM', 'COPD', 'Asthma', 'IHD', 'CKD', 'Hypothyroidism']; +$selectedCoMorbidities = array_filter(array_map('trim', explode(',', (string) ($opdHistorySnapshot['co_morbidities'] ?? ''))), static fn ($v) => $v !== ''); +$otherCoMorbidities = array_values(array_filter($selectedCoMorbidities, static fn ($item) => ! in_array($item, $coMorbidityOptions, true))); +$toFahrenheit = static function ($temperatureC): string { + if ($temperatureC === null || $temperatureC === '') { + return ''; + } + + $value = (((float) $temperatureC) * 9 / 5) + 32; + + return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.'); +}; +$nurseNames = []; +$scannedAuditRows = []; +foreach ($entries as $entryRow) { + $name = trim((string) ($entryRow['recorded_by'] ?? '')); + if ($name === '') { + continue; + } + $nurseNames[$name] = $name; + + $note = trim((string) ($entryRow['general_note'] ?? '')); + if (stripos($note, 'Source: Scanned Paper') !== false) { + $scanRef = ''; + $confidence = ''; + $corrected = ''; + if (preg_match('/ScanRef\s*:\s*([^\]|]+)/i', $note, $m) === 1) { + $scanRef = trim((string) ($m[1] ?? '')); + } + if (preg_match('/AIConfidence\s*:\s*([^\]|]+)/i', $note, $m) === 1) { + $confidence = trim((string) ($m[1] ?? '')); + } + if (preg_match('/Corrected\s*:\s*([^\]|]+)/i', $note, $m) === 1) { + $corrected = trim((string) ($m[1] ?? '')); + } + + $scannedAuditRows[] = [ + 'entry_id' => (int) ($entryRow['id'] ?? 0), + 'recorded_at' => (string) ($entryRow['recorded_at'] ?? ''), + 'entry_type' => (string) ($entryRow['entry_type'] ?? ''), + 'recorded_by' => (string) ($entryRow['recorded_by'] ?? ''), + 'scan_ref' => $scanRef, + 'confidence' => $confidence, + 'corrected' => $corrected, + 'note' => $note, + ]; + } +} +ksort($nurseNames); +?> + + +
+
+
+
Nursing Workspace
+
+ + + +
+
+
+

+ IPD: ipd_code ?? '')) ?> | + Patient: p_fname ?? '')) ?> | + UHID: p_code ?? '')) ?> +

+
+
+ +
+
+
+
    + + + + + + + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ Keep manual admission paper form if needed. This section stores the same H&P data digitally. +
+
+ +
+ +
+ $fieldLabel) : ?> +
+ + > + +
+ +
+
+ +
+ + + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+ + +
+ > + +
+ +
+ Other + +
+
+ +
+ + +
+
+
+
+ + +
+ + +
+ +
+ +
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+ +
+
+ OCR/AI extraction is assistive only. Nurse review is mandatory before save (NABH documentation requirement). +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
Extracted Rows (Review Before Save)
+
+ + + + + + + + + + + +
#TypeRecorded AtValues / NotesConfidence
+
+
+
+ +
+
+
+
+
Bedside Clinical / Nursing Charge
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
Doctor Visit Charge
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+ +
+
Charge Entry History
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDItemTypeDoctorDateTimeQtyRateAmountIn BillCommentAction
No charge history found.
+
+ + +
+
+
+
+
+
+
+ +
+
Nursing Entries History
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + +
TimeTypeShiftDetailsBy
No nursing entries yet.
+ + Temp: °F, Pulse: , Resp: , BP: /, SpO2: , Wt: + + , Route: , Amount: ml + + + +
Note: +
+
+
+ +
+
Scanned Entry Audit Trail
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Entry IDRecorded AtTypeScan RefAI ConfidenceCorrectedReviewed ByAudit Note
No scanned-entry audit rows yet.
+
+
+
+
+ +