From d289bb5be1d4012dd5762cd790a271f0c49f9848 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Wed, 12 Aug 2026 13:38:25 +0530 Subject: [PATCH] feat: add OT workflow and allow zero payments --- app/Config/AuthGroups.php | 19 + app/Config/IpdOtForms.php | 62 ++ app/Config/Routes.php | 11 + app/Controllers/IpdOt.php | 597 ++++++++++++++++++ app/Controllers/Payment.php | 4 +- ...8-10-000008_CreateIpdPreopExaminations.php | 143 +++++ .../2026-08-10-000009_CreateIpdOtWorkflow.php | 179 ++++++ app/Models/IpdOtCaseModel.php | 23 + app/Models/IpdPreopExaminationModel.php | 36 ++ app/Views/ipd_nursing/workspace.php | 20 + app/Views/ipd_ot/case_form.php | 28 + app/Views/ipd_ot/ophthalmology_preop.php | 107 ++++ app/Views/ipd_ot/panel.php | 120 ++++ app/Views/ipd_ot/postop_form.php | 13 + app/Views/ipd_ot/queue.php | 31 + app/Views/ipd_ot/request_form.php | 22 + app/Views/ipd_ot/workflow_script.php | 3 + app/Views/partials/sidebar.php | 14 +- app/Views/payment/payment_edit.php | 2 +- tests/unit/IpdOtConfigTest.php | 27 + 20 files changed, 1457 insertions(+), 4 deletions(-) create mode 100644 app/Config/IpdOtForms.php create mode 100644 app/Controllers/IpdOt.php create mode 100644 app/Database/Migrations/2026-08-10-000008_CreateIpdPreopExaminations.php create mode 100644 app/Database/Migrations/2026-08-10-000009_CreateIpdOtWorkflow.php create mode 100644 app/Models/IpdOtCaseModel.php create mode 100644 app/Models/IpdPreopExaminationModel.php create mode 100644 app/Views/ipd_ot/case_form.php create mode 100644 app/Views/ipd_ot/ophthalmology_preop.php create mode 100644 app/Views/ipd_ot/panel.php create mode 100644 app/Views/ipd_ot/postop_form.php create mode 100644 app/Views/ipd_ot/queue.php create mode 100644 app/Views/ipd_ot/request_form.php create mode 100644 app/Views/ipd_ot/workflow_script.php create mode 100644 tests/unit/IpdOtConfigTest.php diff --git a/app/Config/AuthGroups.php b/app/Config/AuthGroups.php index 9e6deb8..56b6e97 100644 --- a/app/Config/AuthGroups.php +++ b/app/Config/AuthGroups.php @@ -221,6 +221,12 @@ class AuthGroups extends ShieldAuthGroups 'ipd_discharge.view' => 'Can search, preview, and print IPD discharge summaries', 'ipd_discharge.manage' => 'Can create and update IPD discharge summaries and personal section templates', 'ipd_discharge.master.manage' => 'Can manage shared discharge surgery, course, dietary, and ICD masters', + 'ipd_ot.view' => 'Can view IPD Surgery/OT and specialty pre-operative examinations', + 'ipd_ot.examination.manage' => 'Can create, complete, and revise specialty pre-operative examinations', + 'ipd_ot.request.manage' => 'Can create and update OT surgery requests', + 'ipd_ot.schedule.manage' => 'Can schedule and reschedule OT surgery cases', + 'ipd_ot.status.manage' => 'Can record patient calls, safety checks, and OT status changes', + 'ipd_ot.postop.manage' => 'Can record signed post-operative assessments and handover', ]; /** @@ -256,6 +262,7 @@ class AuthGroups extends ShieldAuthGroups 'abdm.*', 'ipd_nursing.*', 'ipd_discharge.*', + 'ipd_ot.*', ], 'admin' => [ 'admin.access', @@ -284,6 +291,7 @@ class AuthGroups extends ShieldAuthGroups 'abdm.*', 'ipd_nursing.*', 'ipd_discharge.*', + 'ipd_ot.*', ], 'developer' => [ 'admin.access', @@ -311,6 +319,7 @@ class AuthGroups extends ShieldAuthGroups 'abdm.*', 'ipd_nursing.*', 'ipd_discharge.*', + 'ipd_ot.*', ], 'doctor' => [ 'opd.doctor-panel.access', @@ -322,6 +331,12 @@ class AuthGroups extends ShieldAuthGroups 'ipd_nursing.view', 'ipd_discharge.view', 'ipd_discharge.manage', + 'ipd_ot.view', + 'ipd_ot.examination.manage', + 'ipd_ot.request.manage', + 'ipd_ot.schedule.manage', + 'ipd_ot.status.manage', + 'ipd_ot.postop.manage', ], 'nurse' => [ 'ipd_nursing.view', @@ -329,6 +344,10 @@ class AuthGroups extends ShieldAuthGroups 'ipd_nursing.bed.transfer', 'ipd_nursing.charge.manage', 'ipd_discharge.view', + 'ipd_ot.view', + 'ipd_ot.request.manage', + 'ipd_ot.status.manage', + 'ipd_ot.postop.manage', ], 'pharmacy_admin' => [ 'pharmacy.*', diff --git a/app/Config/IpdOtForms.php b/app/Config/IpdOtForms.php new file mode 100644 index 0000000..a4c707f --- /dev/null +++ b/app/Config/IpdOtForms.php @@ -0,0 +1,62 @@ + [ + 'title' => 'Pre-operative Ophthalmology Examination', + 'schema_version' => 1, + 'department_aliases' => [ + 'ophthalmology', + 'ophthalmic', + 'ophthalmic surgery', + 'eye', + 'eye department', + 'eye surgery', + 'department of eye', + 'department of ophthalmology', + 'ophthalmology department', + 'ophthalmology eye', + ], + 'columns' => [ + 'od' => 'Right Eye (OD)', + 'os' => 'Left Eye (OS)', + ], + 'rows' => [ + 'visual_acuity' => 'Visual Acuity', + 'refraction_findings' => 'Refraction Findings', + 'slit_lamp_examination' => 'Slit Lamp Examination', + 'fundus_examination' => 'Fundus Examination', + 'intraocular_pressure' => 'Intraocular Pressure', + ], + ], + ]; + + public function formsForDepartment(string $departmentName): array + { + $normalizedDepartment = $this->normalizeDepartmentName($departmentName); + $matches = []; + foreach ($this->examinationForms as $formKey => $form) { + $aliases = array_map( + fn ($alias): string => $this->normalizeDepartmentName((string) $alias), + $form['department_aliases'] ?? [] + ); + if ($normalizedDepartment !== '' && in_array($normalizedDepartment, $aliases, true)) { + $matches[(string) $formKey] = $form; + } + } + + return $matches; + } + + private function normalizeDepartmentName(string $name): string + { + $name = strtolower(trim($name)); + $name = (string) preg_replace('/[^a-z0-9]+/', ' ', $name); + return trim((string) preg_replace('/\s+/', ' ', $name)); + } +} \ No newline at end of file diff --git a/app/Config/Routes.php b/app/Config/Routes.php index d507627..b291a80 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -448,6 +448,17 @@ $routes->post('setup/db-tools/prepare-filesystem', 'Setup::prepareFilesystem'); $routes->post('setup/db-tools/ensure-admin', 'Setup::ensureAdminLogin'); $routes->post('setup/db-tools/complete', 'Setup::complete'); +$routes->get('ipd/ot/(:num)', 'IpdOt::panel/$1', ['filter' => 'permission:ipd_ot.view,ipd_ot.examination.manage']); +$routes->get('ipd/ot/(:num)/examination/(:segment)', 'IpdOt::examination/$1/$2', ['filter' => 'permission:ipd_ot.view,ipd_ot.examination.manage']); +$routes->post('ipd/ot/(:num)/examination/(:segment)', 'IpdOt::saveExamination/$1/$2', ['filter' => 'permission:ipd_ot.examination.manage']); +$routes->get('ipd/ot/(:num)/request', 'IpdOt::requestForm/$1', ['filter' => 'permission:ipd_ot.request.manage']); +$routes->post('ipd/ot/(:num)/requests', 'IpdOt::createCase/$1', ['filter' => 'permission:ipd_ot.request.manage']); +$routes->get('ot/queue', 'IpdOt::queue', ['filter' => 'permission:ipd_ot.view,ipd_ot.schedule.manage,ipd_ot.status.manage,ipd_ot.postop.manage']); +$routes->get('ot/cases/(:num)', 'IpdOt::caseForm/$1', ['filter' => 'permission:ipd_ot.view,ipd_ot.schedule.manage,ipd_ot.status.manage,ipd_ot.postop.manage']); +$routes->post('ot/cases/(:num)', 'IpdOt::updateCase/$1', ['filter' => 'permission:ipd_ot.schedule.manage,ipd_ot.status.manage']); +$routes->get('ot/cases/(:num)/postop', 'IpdOt::postopForm/$1', ['filter' => 'permission:ipd_ot.view,ipd_ot.postop.manage']); +$routes->post('ot/cases/(:num)/postop', 'IpdOt::savePostop/$1', ['filter' => 'permission:ipd_ot.postop.manage']); + $routes->group('billing', function($routes) { $routes->get('ipd', 'Billing\\Ipd::index'); diff --git a/app/Controllers/IpdOt.php b/app/Controllers/IpdOt.php new file mode 100644 index 0000000..c80f5b8 --- /dev/null +++ b/app/Controllers/IpdOt.php @@ -0,0 +1,597 @@ +ipdBillingModel = new IpdBillingModel(); + $this->examinationModel = new IpdPreopExaminationModel(); + $this->caseModel = new IpdOtCaseModel(); + $this->otConfig = config('IpdOtForms'); + helper(['form']); + } + + public function panel(int $ipdId) + { + if ($response = $this->requireAnyPermission(['ipd_ot.view', 'ipd_ot.examination.manage'])) { + return $response; + } + + $context = $this->getAdmissionContext($ipdId); + if ($context === []) { + return $this->response->setStatusCode(404)->setBody('IPD admission not found.'); + } + + $forms = $this->otConfig->formsForDepartment((string) $context['department_name']); + foreach ($forms as $formKey => &$form) { + $form['key'] = $formKey; + $form['record'] = $this->examinationModel->findForAdmission($ipdId, $formKey); + } + unset($form); + + $cases = $this->caseModel->where('ipd_id', $ipdId)->orderBy('id', 'DESC')->findAll(); + + return view('ipd_ot/panel', [ + 'ipd_id' => $ipdId, + 'department_name' => $context['department_name'], + 'forms' => $forms, + 'can_manage' => $this->userCan('ipd_ot.examination.manage'), + 'cases' => $cases, + 'can_request' => $this->userCan('ipd_ot.request.manage'), + 'can_status' => $this->userCan('ipd_ot.status.manage'), + ]); + } + + public function queue() + { + if ($response = $this->requireAnyPermission(['ipd_ot.view', 'ipd_ot.schedule.manage', 'ipd_ot.status.manage', 'ipd_ot.postop.manage'])) { + return $response; + } + $date = trim((string) ($this->request->getGet('date') ?? date('Y-m-d'))); + $departmentId = max(0, (int) ($this->request->getGet('department_id') ?? 0)); + $status = trim((string) ($this->request->getGet('status') ?? '')); + $builder = $this->db->table('ipd_ot_cases c') + ->select('c.*, i.ipd_code, p.p_code, p.p_fname, p.mphone1') + ->join('ipd_master i', 'i.id = c.ipd_id') + ->join('patient_master p', 'p.id = c.patient_id', 'left'); + if ($date !== '') { + $builder->where('COALESCE(DATE(c.scheduled_start_at), c.requested_date)', $date, false); + } + if ($departmentId > 0) { + $builder->where('c.department_id', $departmentId); + } + if ($status !== '') { + $builder->where('c.status', $status); + } + return view('ipd_ot/queue', [ + 'rows' => $builder->orderBy('COALESCE(c.scheduled_start_at, CONCAT(c.requested_date, " ", COALESCE(c.requested_time, "00:00:00")))', 'ASC', false)->get()->getResultArray(), + 'departments' => $this->db->table('hc_department')->select('iId, vName')->where('iId >', 0)->orderBy('vName')->get()->getResultArray(), + 'filter_date' => $date, + 'filter_department_id' => $departmentId, + 'filter_status' => $status, + 'can_schedule' => $this->userCan('ipd_ot.schedule.manage'), + 'can_status' => $this->userCan('ipd_ot.status.manage'), + 'can_postop' => $this->userCan('ipd_ot.postop.manage'), + ]); + } + + public function requestForm(int $ipdId) + { + if ($response = $this->requireAnyPermission(['ipd_ot.request.manage'])) { + return $response; + } + $context = $this->getAdmissionContext($ipdId); + if ($context === []) { + return $this->response->setStatusCode(404)->setBody('IPD admission not found.'); + } + return view('ipd_ot/request_form', ['ipd_id' => $ipdId, 'department_name' => $context['department_name'], 'doctors' => $this->ipdBillingModel->getIpdDoctorList()]); + } + + public function createCase(int $ipdId) + { + if ($response = $this->requireAnyPermission(['ipd_ot.request.manage'])) { + return $response; + } + $context = $this->getAdmissionContext($ipdId); + if ($context === []) { + return $this->jsonResult(false, 'IPD admission not found.', [], 404); + } + $procedureName = trim((string) ($this->request->getPost('procedure_name') ?? '')); + $requestedDate = trim((string) ($this->request->getPost('requested_date') ?? '')); + if ($procedureName === '' || ! $this->validDate($requestedDate)) { + return $this->jsonResult(false, 'Procedure and valid requested date are required.', [], 422); + } + $surgeonId = max(0, (int) ($this->request->getPost('surgeon_id') ?? 0)); + $userId = (int) (auth()->user()->id ?? 0); + $data = [ + 'request_no' => 'OT' . date('ymdHis') . str_pad((string) $ipdId, 4, '0', STR_PAD_LEFT), + 'ipd_id' => $ipdId, 'patient_id' => (int) $context['patient_id'], + 'department_id' => (int) $context['department_id'], 'department_name_snapshot' => (string) $context['department_name'], + 'procedure_name' => mb_substr($procedureName, 0, 255), + 'procedure_side' => $this->allowedValue((string) $this->request->getPost('procedure_side'), ['not_applicable', 'left', 'right', 'bilateral'], 'not_applicable'), + 'priority' => $this->allowedValue((string) $this->request->getPost('priority'), ['routine', 'urgent', 'emergency'], 'routine'), + 'requested_date' => $requestedDate, + 'requested_time' => $this->validTime((string) $this->request->getPost('requested_time')) ? (string) $this->request->getPost('requested_time') : null, + 'requested_notes' => mb_substr(trim((string) $this->request->getPost('requested_notes')), 0, 5000), + 'surgeon_id' => $surgeonId, 'surgeon_name_snapshot' => $this->doctorName($surgeonId), + 'status' => 'requested', 'call_status' => 'not_called', 'created_by' => $userId, 'updated_by' => $userId, + ]; + $this->db->transBegin(); + $this->db->query('SELECT id FROM ipd_master WHERE id = ? FOR UPDATE', [$ipdId]); + if ($this->caseModel->where('ipd_id', $ipdId)->whereNotIn('status', ['completed', 'cancelled'])->countAllResults() > 0) { + $this->db->transRollback(); + return $this->jsonResult(false, 'This admission already has an open surgery request.', [], 422); + } + if ($this->caseModel->insert($data) === false) { + $this->db->transRollback(); + return $this->jsonResult(false, 'Unable to create surgery request.', [], 500); + } + $caseId = (int) $this->caseModel->getInsertID(); + if (! $this->appendEvent($caseId, 'requested', '', 'requested', null, null, '', (string) $data['requested_notes'])) { + $this->db->transRollback(); + return $this->jsonResult(false, 'Unable to record surgery request history.', [], 500); + } + $this->db->transCommit(); + return $this->jsonResult(true, 'Surgery request created.', ['id' => $caseId]); + } + + public function caseForm(int $caseId) + { + if ($response = $this->requireAnyPermission(['ipd_ot.view', 'ipd_ot.schedule.manage', 'ipd_ot.status.manage', 'ipd_ot.postop.manage'])) { + return $response; + } + $case = $this->caseModel->find($caseId); + if (! is_array($case)) { + return $this->response->setStatusCode(404)->setBody('OT case not found.'); + } + $status = (string) $case['status']; + return view('ipd_ot/case_form', [ + 'case' => $case, + 'doctors' => $this->ipdBillingModel->getIpdDoctorList(), + 'can_schedule' => $this->userCan('ipd_ot.schedule.manage') && in_array($status, ['requested', 'scheduled', 'called'], true), + 'can_status' => $this->userCan('ipd_ot.status.manage') && in_array($status, ['requested', 'scheduled', 'called', 'in_progress'], true), + 'can_postop' => $this->userCan('ipd_ot.postop.manage') && in_array($status, ['in_progress', 'completed'], true), + ]); + } + + public function updateCase(int $caseId) + { + if ($response = $this->requireAnyPermission(['ipd_ot.schedule.manage', 'ipd_ot.status.manage'])) { + return $response; + } + $case = $this->caseModel->find($caseId); + if (! is_array($case)) { + return $this->jsonResult(false, 'OT case not found.', [], 404); + } + $action = trim((string) ($this->request->getPost('action') ?? '')); + $reason = trim((string) ($this->request->getPost('reason') ?? '')); + $userId = (int) (auth()->user()->id ?? 0); + $now = date('Y-m-d H:i:s'); + $updates = ['updated_by' => $userId]; + $fromStatus = (string) $case['status']; + $toStatus = $fromStatus; + $oldSchedule = $case['scheduled_start_at'] ?? null; + $newSchedule = $oldSchedule; + + if (in_array($action, ['schedule', 'reschedule'], true)) { + if (! $this->userCan('ipd_ot.schedule.manage')) { + return $this->jsonResult(false, 'Scheduling permission is required.', [], 403); + } + $scheduledAt = str_replace('T', ' ', trim((string) $this->request->getPost('scheduled_start_at'))); + $canInitialSchedule = $action === 'schedule' && $fromStatus === 'requested' && empty($oldSchedule); + $canReschedule = $action === 'reschedule' && in_array($fromStatus, ['scheduled', 'called'], true) && ! empty($oldSchedule); + if (! $canInitialSchedule && ! $canReschedule) { + return $this->jsonResult(false, 'This OT case cannot be scheduled from its current status.', [], 422); + } + if (! $this->validDateTime($scheduledAt) || ($action === 'reschedule' && $reason === '')) { + return $this->jsonResult(false, $action === 'reschedule' ? 'Valid date/time and reschedule reason are required.' : 'Valid surgery date and time are required.', [], 422); + } + $surgeonId = max(0, (int) ($this->request->getPost('surgeon_id') ?? $case['surgeon_id'])); + $anesthetistId = max(0, (int) ($this->request->getPost('anesthetist_id') ?? $case['anesthetist_id'])); + $updates += ['scheduled_start_at' => $scheduledAt, 'surgeon_id' => $surgeonId, 'surgeon_name_snapshot' => $this->doctorName($surgeonId), 'anesthetist_id' => $anesthetistId, 'anesthetist_name_snapshot' => $this->doctorName($anesthetistId), 'status' => 'scheduled', 'call_status' => 'not_called', 'called_at' => null, 'called_by' => 0, 'confirmed_at' => null, 'confirmed_by' => 0]; + $toStatus = 'scheduled'; + $newSchedule = $scheduledAt; + } elseif ($action === 'call') { + if (! $this->userCan('ipd_ot.status.manage')) { + return $this->jsonResult(false, 'Status permission is required.', [], 403); + } + $callStatus = $this->allowedValue((string) $this->request->getPost('call_status'), ['called', 'confirmed', 'not_reachable', 'declined'], 'called'); + if ($callStatus === 'declined' && $reason === '') { + return $this->jsonResult(false, 'Reason is required when the patient declines.', [], 422); + } + $updates += ['call_status' => $callStatus, 'called_at' => $now, 'called_by' => $userId]; + if ($callStatus === 'confirmed') { + $updates += ['confirmed_at' => $now, 'confirmed_by' => $userId]; + } + } elseif ($action === 'status') { + if (! $this->userCan('ipd_ot.status.manage')) { + return $this->jsonResult(false, 'Status permission is required.', [], 403); + } + $nextStatus = $this->allowedValue((string) $this->request->getPost('status'), ['requested', 'scheduled', 'called', 'in_progress', 'completed', 'cancelled'], ''); + $allowedTransitions = ['requested' => ['cancelled'], 'scheduled' => ['called', 'in_progress', 'cancelled'], 'called' => ['scheduled', 'in_progress', 'cancelled'], 'in_progress' => ['completed']]; + if (! in_array($nextStatus, $allowedTransitions[$fromStatus] ?? [], true)) { + return $this->jsonResult(false, 'Invalid OT status transition.', [], 422); + } + if ($nextStatus === 'cancelled' && $reason === '') { + return $this->jsonResult(false, 'Cancellation reason is required.', [], 422); + } + $safetyFields = ['consent_verified', 'site_side_verified', 'allergy_verified', 'npo_verified', 'investigations_verified', 'anesthesia_clearance', 'who_sign_in', 'who_time_out', 'who_sign_out']; + foreach ($safetyFields as $field) { + $updates[$field] = (int) ($this->request->getPost($field) ?? 0) === 1 ? 1 : 0; + } + $updates['blood_availability'] = $this->allowedValue((string) ($this->request->getPost('blood_availability') ?? $case['blood_availability']), ['not_required', 'arranged', 'available'], 'not_required'); + if ($nextStatus === 'in_progress') { + foreach (['consent_verified', 'site_side_verified', 'allergy_verified', 'npo_verified', 'investigations_verified', 'anesthesia_clearance', 'who_sign_in'] as $required) { + if ((int) $updates[$required] !== 1) { + return $this->jsonResult(false, 'Complete all pre-operative safety checks and WHO sign-in first.', [], 422); + } + } + $updates['actual_start_at'] = $now; + $updates['who_time_out'] = 0; + $updates['who_sign_out'] = 0; + } + if ($nextStatus === 'completed') { + if ((int) $updates['who_time_out'] !== 1 || (int) $updates['who_sign_out'] !== 1) { + return $this->jsonResult(false, 'WHO time-out and sign-out are required before completion.', [], 422); + } + $updates['actual_end_at'] = $now; + } + $updates['status'] = $nextStatus; + $toStatus = $nextStatus; + } else { + return $this->jsonResult(false, 'Unsupported OT action.', [], 422); + } + $this->db->transBegin(); + $caseUpdated = $this->db->table('ipd_ot_cases') + ->where('id', $caseId) + ->where('status', $fromStatus) + ->where('lock_version', (int) ($case['lock_version'] ?? 0)) + ->update($updates + ['lock_version' => (int) ($case['lock_version'] ?? 0) + 1, 'updated_at' => $now]); + if (! $caseUpdated) { + $this->db->transRollback(); + return $this->jsonResult(false, 'Unable to update OT case and history.', [], 500); + } + if ($this->db->affectedRows() === 0) { + $currentCase = $this->caseModel->select('status, lock_version')->find($caseId); + if ((string) ($currentCase['status'] ?? '') !== $fromStatus || (int) ($currentCase['lock_version'] ?? -1) !== (int) ($case['lock_version'] ?? 0)) { + $this->db->transRollback(); + return $this->jsonResult(false, 'The OT case changed while you were editing it. Reload and try again.', [], 409); + } + } + if (! $this->appendEvent($caseId, $action, $fromStatus, $toStatus, $oldSchedule, $newSchedule, $reason, trim((string) $this->request->getPost('notes')))) { + $this->db->transRollback(); + return $this->jsonResult(false, 'Unable to update OT case history.', [], 500); + } + $this->db->transCommit(); + return $this->jsonResult(true, 'OT case updated.', ['id' => $caseId]); + } + + public function postopForm(int $caseId) + { + if ($response = $this->requireAnyPermission(['ipd_ot.view', 'ipd_ot.postop.manage'])) { + return $response; + } + $case = $this->caseModel->find($caseId); + if (! is_array($case)) { + return $this->response->setStatusCode(404)->setBody('OT case not found.'); + } + return view('ipd_ot/postop_form', ['case' => $case, 'assessments' => $this->db->table('ipd_ot_postop_assessments')->where('case_id', $caseId)->orderBy('observed_at', 'DESC')->get()->getResultArray(), 'can_manage' => $this->userCan('ipd_ot.postop.manage')]); + } + + public function savePostop(int $caseId) + { + if ($response = $this->requireAnyPermission(['ipd_ot.postop.manage'])) { + return $response; + } + $case = $this->caseModel->find($caseId); + if (! is_array($case) || ! in_array((string) $case['status'], ['in_progress', 'completed'], true)) { + return $this->jsonResult(false, 'Post-operative assessment is available after surgery starts.', [], 422); + } + $observedAt = str_replace('T', ' ', trim((string) $this->request->getPost('observed_at'))); + if (! $this->validDateTime($observedAt)) { + return $this->jsonResult(false, 'Valid observation date and time are required.', [], 422); + } + $now = date('Y-m-d H:i:s'); + $consciousness = trim((string) $this->request->getPost('consciousness')); + $airwayBreathing = trim((string) $this->request->getPost('airway_breathing')); + $bp = trim((string) $this->request->getPost('bp')); + $pulse = trim((string) $this->request->getPost('pulse')); + $spo2 = trim((string) $this->request->getPost('spo2')); + $disposition = trim((string) $this->request->getPost('disposition')); + $handoverNotes = trim((string) $this->request->getPost('handover_notes')); + if ($consciousness === '' || $airwayBreathing === '' || $bp === '' || $pulse === '' || $spo2 === '' || $disposition === '' || $handoverNotes === '') { + return $this->jsonResult(false, 'Consciousness, airway/breathing, BP, pulse, SpO2, disposition, and handover notes are required.', [], 422); + } + if ($observedAt > $now || (! empty($case['actual_start_at']) && $observedAt < (string) $case['actual_start_at'])) { + return $this->jsonResult(false, 'Observation time must be between surgery start and the current time.', [], 422); + } + $this->db->transBegin(); + $assessmentSaved = $this->db->table('ipd_ot_postop_assessments')->insert([ + 'case_id' => $caseId, 'observed_at' => $observedAt, + 'consciousness' => mb_substr($consciousness, 0, 80), 'airway_breathing' => mb_substr($airwayBreathing, 0, 255), + 'bp' => mb_substr($bp, 0, 30), 'pulse' => mb_substr($pulse, 0, 20), 'spo2' => mb_substr($spo2, 0, 20), 'temperature' => mb_substr(trim((string) $this->request->getPost('temperature')), 0, 20), + 'pain_score' => max(0, min(10, (int) $this->request->getPost('pain_score'))), 'bleeding_wound_drain' => mb_substr(trim((string) $this->request->getPost('bleeding_wound_drain')), 0, 5000), 'nausea_vomiting' => mb_substr(trim((string) $this->request->getPost('nausea_vomiting')), 0, 100), + 'complications' => mb_substr(trim((string) $this->request->getPost('complications')), 0, 5000), 'interventions' => mb_substr(trim((string) $this->request->getPost('interventions')), 0, 5000), 'disposition' => mb_substr($disposition, 0, 100), 'handover_notes' => mb_substr($handoverNotes, 0, 5000), + 'recorded_by' => (int) (auth()->user()->id ?? 0), 'signed_at' => $now, 'created_at' => $now, + ]); + if (! $assessmentSaved + || ! $this->appendEvent($caseId, 'postop_assessment', (string) $case['status'], (string) $case['status'], $case['scheduled_start_at'] ?? null, $case['scheduled_start_at'] ?? null, '', 'Post-operative assessment signed.')) { + $this->db->transRollback(); + return $this->jsonResult(false, 'Unable to sign post-operative assessment and history.', [], 500); + } + $this->db->transCommit(); + return $this->jsonResult(true, 'Post-operative assessment signed.'); + } + + public function examination(int $ipdId, string $formKey) + { + if ($response = $this->requireAnyPermission(['ipd_ot.view', 'ipd_ot.examination.manage'])) { + return $response; + } + + $resolved = $this->resolveRequestedForm($ipdId, $formKey); + if (isset($resolved['error'])) { + return $this->response->setStatusCode((int) $resolved['status'])->setBody((string) $resolved['error']); + } + + $record = $this->examinationModel->findForAdmission($ipdId, $formKey); + $values = json_decode((string) ($record['payload_json'] ?? ''), true); + if (! is_array($values)) { + $values = []; + } + + return view('ipd_ot/ophthalmology_preop', [ + 'ipd_id' => $ipdId, + 'form_key' => $formKey, + 'form' => $resolved['form'], + 'department_name' => $resolved['context']['department_name'], + 'record' => $record, + 'values' => $values, + 'can_manage' => $this->userCan('ipd_ot.examination.manage'), + ]); + } + + public function saveExamination(int $ipdId, string $formKey) + { + if ($response = $this->requireAnyPermission(['ipd_ot.examination.manage'])) { + return $response; + } + + $resolved = $this->resolveRequestedForm($ipdId, $formKey); + if (isset($resolved['error'])) { + return $this->jsonResult(false, (string) $resolved['error'], [], (int) $resolved['status']); + } + + $form = $resolved['form']; + $context = $resolved['context']; + $postedValues = $this->request->getPost('values'); + $postedValues = is_array($postedValues) ? $postedValues : []; + $values = $this->sanitizeValues($postedValues, $form); + $status = strtolower(trim((string) ($this->request->getPost('status') ?? 'draft'))); + $status = $status === 'completed' ? 'completed' : 'draft'; + $editReason = trim((string) ($this->request->getPost('edit_reason') ?? '')); + $existing = $this->examinationModel->findForAdmission($ipdId, $formKey); + + if ($status === 'completed' && ! $this->hasAnyFinding($values)) { + return $this->jsonResult(false, 'Enter at least one examination finding before completing the form.', [], 422); + } + + $payloadJson = (string) json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + $hasChanged = $existing === [] + || (string) ($existing['payload_json'] ?? '') !== $payloadJson + || (string) ($existing['status'] ?? 'draft') !== $status; + + if (! $hasChanged) { + return $this->jsonResult(true, 'No changes to save.', ['id' => (int) ($existing['id'] ?? 0)]); + } + if ((string) ($existing['status'] ?? '') === 'completed' && $editReason === '') { + return $this->jsonResult(false, 'Edit reason is required when changing a completed examination.', [], 422); + } + + $userId = (int) (auth()->user()->id ?? 0); + $now = date('Y-m-d H:i:s'); + $saveData = [ + 'ipd_id' => $ipdId, + 'patient_id' => (int) $context['patient_id'], + 'department_id' => (int) $context['department_id'], + 'department_name_snapshot' => (string) $context['department_name'], + 'form_key' => $formKey, + 'schema_version' => (int) ($form['schema_version'] ?? 1), + 'episode_no' => 1, + 'payload_json' => $payloadJson, + 'status' => $status, + 'examined_by' => $status === 'completed' ? $userId : (int) ($existing['examined_by'] ?? 0), + 'examined_at' => $status === 'completed' ? $now : ($existing['examined_at'] ?? null), + 'updated_by' => $userId, + ]; + + $this->db->transStart(); + if ($existing !== []) { + $revisionNo = (int) ($this->db->table('ipd_preop_examination_updates') + ->selectMax('revision_no', 'revision_no') + ->where('examination_id', (int) $existing['id']) + ->get() + ->getRowArray()['revision_no'] ?? 0) + 1; + $this->db->table('ipd_preop_examination_updates')->insert([ + 'examination_id' => (int) $existing['id'], + 'revision_no' => $revisionNo, + 'payload_json' => (string) ($existing['payload_json'] ?? ''), + 'status' => (string) ($existing['status'] ?? 'draft'), + 'examined_by' => (int) ($existing['examined_by'] ?? 0), + 'examined_at' => $existing['examined_at'] ?? null, + 'edit_reason' => $editReason, + 'updated_by' => $userId, + 'updated_at' => $now, + ]); + $this->examinationModel->update((int) $existing['id'], $saveData); + $recordId = (int) $existing['id']; + } else { + $saveData['created_by'] = $userId; + $this->examinationModel->insert($saveData); + $recordId = (int) $this->examinationModel->getInsertID(); + } + $this->db->transComplete(); + + if (! $this->db->transStatus()) { + return $this->jsonResult(false, 'Unable to save the examination.', [], 500); + } + + try { + (new ClinicalAuditTrail())->logChangedFields( + 'ipd_preop_examination', + $recordId, + ['payload' => json_decode((string) ($existing['payload_json'] ?? ''), true), 'status' => $existing['status'] ?? null], + ['payload' => $values, 'status' => $status], + $userId + ); + } catch (\Throwable $exception) { + log_message('error', 'Unable to write pre-operative examination audit: ' . $exception->getMessage()); + } + + return $this->jsonResult(true, 'Pre-operative examination saved.', ['id' => $recordId]); + } + + private function resolveRequestedForm(int $ipdId, string $formKey): array + { + $context = $this->getAdmissionContext($ipdId); + if ($context === []) { + return ['error' => 'IPD admission not found.', 'status' => 404]; + } + + $forms = $this->otConfig->formsForDepartment((string) $context['department_name']); + if (! isset($forms[$formKey])) { + return ['error' => 'This examination is not configured for the admission department.', 'status' => 403]; + } + + return ['context' => $context, 'form' => $forms[$formKey]]; + } + + private function appendEvent(int $caseId, string $eventType, string $fromStatus, string $toStatus, $oldSchedule, $newSchedule, string $reason, string $notes): bool + { + return $this->db->table('ipd_ot_case_events')->insert(['case_id' => $caseId, 'event_type' => $eventType, 'from_status' => $fromStatus, 'to_status' => $toStatus, 'old_scheduled_at' => $oldSchedule ?: null, 'new_scheduled_at' => $newSchedule ?: null, 'reason' => mb_substr($reason, 0, 255), 'notes' => mb_substr($notes, 0, 5000), 'actor_id' => (int) (auth()->user()->id ?? 0), 'created_at' => date('Y-m-d H:i:s')]); + } + + private function doctorName(int $doctorId): string + { + if ($doctorId <= 0) { + return ''; + } + $row = $this->db->table('doctor_master')->select("TRIM(CONCAT_WS(' ', p_fname, p_mname, p_lname)) AS name", false)->where('id', $doctorId)->get(1)->getRowArray(); + return trim((string) ($row['name'] ?? '')); + } + + private function allowedValue(string $value, array $allowed, string $default): string + { + $value = strtolower(trim($value)); + return in_array($value, $allowed, true) ? $value : $default; + } + + private function validDate(string $value): bool + { + $date = \DateTime::createFromFormat('Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value; + } + + private function validTime(string $value): bool + { + return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/', trim($value)) === 1; + } + + private function validDateTime(string $value): bool + { + $value = trim($value); + foreach (['Y-m-d H:i', 'Y-m-d H:i:s'] as $format) { + $date = \DateTime::createFromFormat($format, $value); + if ($date !== false && $date->format($format) === $value) { + return true; + } + } + return false; + } + + private function getAdmissionContext(int $ipdId): array + { + $row = $this->db->table('ipd_master i') + ->select('i.id, i.p_id, i.dept_id, d.vName AS department_name') + ->join('hc_department d', 'd.iId = i.dept_id', 'left') + ->where('i.id', $ipdId) + ->get(1) + ->getRowArray(); + + if ($row === null) { + return []; + } + + return [ + 'patient_id' => (int) ($row['p_id'] ?? 0), + 'department_id' => (int) ($row['dept_id'] ?? 0), + 'department_name' => trim((string) ($row['department_name'] ?? '')), + ]; + } + + private function sanitizeValues(array $postedValues, array $form): array + { + $values = []; + foreach (($form['rows'] ?? []) as $rowKey => $unusedLabel) { + foreach (($form['columns'] ?? []) as $columnKey => $unusedColumnLabel) { + $value = trim((string) ($postedValues[$rowKey][$columnKey] ?? '')); + $values[(string) $rowKey][(string) $columnKey] = mb_substr($value, 0, 5000); + } + } + return $values; + } + + private function hasAnyFinding(array $values): bool + { + foreach ($values as $rowValues) { + foreach ($rowValues as $value) { + if (trim((string) $value) !== '') { + return true; + } + } + } + return false; + } + + private function requireAnyPermission(array $permissions) + { + foreach ($permissions as $permission) { + if ($this->userCan($permission)) { + return null; + } + } + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + private function userCan(string $permission): bool + { + if (! function_exists('auth')) { + return false; + } + $user = auth()->user(); + return $user !== null && method_exists($user, 'can') && $user->can($permission); + } + + private function jsonResult(bool $success, string $message, array $extra = [], int $statusCode = 200) + { + return $this->response->setStatusCode($statusCode)->setJSON(array_merge([ + 'update' => $success ? 1 : 0, + 'error_text' => $message, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ], $extra)); + } +} \ No newline at end of file diff --git a/app/Controllers/Payment.php b/app/Controllers/Payment.php index 02fcd8a..e2e3b5a 100644 --- a/app/Controllers/Payment.php +++ b/app/Controllers/Payment.php @@ -98,8 +98,8 @@ class Payment extends BaseController { $paymentId = (int) $this->request->getPost('pay_id'); $rawAmount = trim((string) $this->request->getPost('change_value')); - if (! is_numeric($rawAmount) || (float) $rawAmount <= 0) { - return $this->error('Enter a valid amount greater than zero.', 422); + if (! is_numeric($rawAmount) || (float) $rawAmount < 0) { + return $this->error('Enter a valid amount of zero or greater.', 422); } $payment = $this->paymentModel->findPayment($paymentId); diff --git a/app/Database/Migrations/2026-08-10-000008_CreateIpdPreopExaminations.php b/app/Database/Migrations/2026-08-10-000008_CreateIpdPreopExaminations.php new file mode 100644 index 0000000..0d040dc --- /dev/null +++ b/app/Database/Migrations/2026-08-10-000008_CreateIpdPreopExaminations.php @@ -0,0 +1,143 @@ +tableExists('ipd_preop_examinations')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'ipd_id' => ['type' => 'INT', 'unsigned' => true], + 'patient_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'department_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'department_name_snapshot' => ['type' => 'VARCHAR', 'constraint' => 150, 'default' => ''], + 'form_key' => ['type' => 'VARCHAR', 'constraint' => 100], + 'schema_version' => ['type' => 'INT', 'unsigned' => true, 'default' => 1], + 'episode_no' => ['type' => 'INT', 'unsigned' => true, 'default' => 1], + 'payload_json' => ['type' => 'LONGTEXT', 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'draft'], + 'examined_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'examined_at' => ['type' => 'DATETIME', 'null' => true], + 'created_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'updated_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['ipd_id', 'form_key', 'episode_no'], 'uq_ipd_preop_episode'); + $this->forge->addKey(['department_id', 'status'], false, false, 'idx_ipd_preop_department_status'); + $this->forge->createTable('ipd_preop_examinations', true, ['ENGINE' => 'InnoDB']); + } + + if (! $this->tableExists('ipd_preop_examination_updates')) { + $this->forge->addField([ + 'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true], + 'examination_id' => ['type' => 'INT', 'unsigned' => true], + 'revision_no' => ['type' => 'INT', 'unsigned' => true], + 'payload_json' => ['type' => 'LONGTEXT', 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'draft'], + 'examined_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'examined_at' => ['type' => 'DATETIME', 'null' => true], + 'edit_reason' => ['type' => 'VARCHAR', 'constraint' => 255, 'default' => ''], + 'updated_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'updated_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['examination_id', 'revision_no'], 'uq_ipd_preop_revision'); + $this->forge->createTable('ipd_preop_examination_updates', true, ['ENGINE' => 'InnoDB']); + } + + $this->grantPermissions(); + } + + public function down() + { + if ($this->tableExists('auth_permissions_users')) { + $this->db->table('auth_permissions_users')->whereIn('permission', self::PERMISSIONS)->delete(); + } + if ($this->tableExists('auth_role_permissions')) { + $this->db->table('auth_role_permissions')->whereIn('permission', self::PERMISSIONS)->delete(); + } + if ($this->tableExists('auth_roles')) { + (new RoleRegistry($this->db))->publish(); + } + + $this->forge->dropTable('ipd_preop_examination_updates', true); + $this->forge->dropTable('ipd_preop_examinations', true); + } + + private function grantPermissions(): void + { + if (! $this->tableExists('auth_roles') || ! $this->tableExists('auth_role_permissions')) { + return; + } + + $now = date('Y-m-d H:i:s'); + $roleGrants = [ + 'superadmin' => self::PERMISSIONS, + 'admin' => self::PERMISSIONS, + 'developer' => self::PERMISSIONS, + 'doctor' => self::PERMISSIONS, + 'nurse' => ['ipd_ot.view'], + ]; + + foreach ($roleGrants as $alias => $permissions) { + $role = $this->db->table('auth_roles')->select('id')->where('alias', $alias)->get(1)->getRowArray(); + if ($role === null) { + continue; + } + foreach ($permissions as $permission) { + $exists = $this->db->table('auth_role_permissions') + ->where('role_id', (int) $role['id']) + ->where('permission', $permission) + ->countAllResults(); + if ($exists === 0) { + $this->db->table('auth_role_permissions')->insert([ + 'role_id' => (int) $role['id'], + 'permission' => $permission, + 'created_at' => $now, + ]); + } + } + } + + if ($this->tableExists('auth_permissions_users')) { + $managerIds = array_column( + $this->db->table('auth_permissions_users')->select('user_id')->where('permission', 'users.manage-admins')->get()->getResultArray(), + 'user_id' + ); + foreach ($managerIds as $userId) { + foreach (self::PERMISSIONS as $permission) { + $exists = $this->db->table('auth_permissions_users') + ->where('user_id', (int) $userId) + ->where('permission', $permission) + ->countAllResults(); + if ($exists === 0) { + $this->db->table('auth_permissions_users')->insert([ + 'user_id' => (int) $userId, + 'permission' => $permission, + 'created_at' => $now, + ]); + } + } + } + } + + (new RoleRegistry($this->db))->publish(); + } + + private function tableExists(string $table): bool + { + return $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray() !== null; + } +} \ No newline at end of file diff --git a/app/Database/Migrations/2026-08-10-000009_CreateIpdOtWorkflow.php b/app/Database/Migrations/2026-08-10-000009_CreateIpdOtWorkflow.php new file mode 100644 index 0000000..52dc1d0 --- /dev/null +++ b/app/Database/Migrations/2026-08-10-000009_CreateIpdOtWorkflow.php @@ -0,0 +1,179 @@ +tableExists('ipd_ot_cases')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'request_no' => ['type' => 'VARCHAR', 'constraint' => 30], + 'ipd_id' => ['type' => 'INT', 'unsigned' => true], + 'patient_id' => ['type' => 'INT', 'unsigned' => true], + 'department_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'department_name_snapshot' => ['type' => 'VARCHAR', 'constraint' => 150, 'default' => ''], + 'procedure_name' => ['type' => 'VARCHAR', 'constraint' => 255], + 'procedure_side' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => 'not_applicable'], + 'priority' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'routine'], + 'requested_date' => ['type' => 'DATE'], + 'requested_time' => ['type' => 'TIME', 'null' => true], + 'requested_notes' => ['type' => 'TEXT', 'null' => true], + 'scheduled_start_at' => ['type' => 'DATETIME', 'null' => true], + 'surgeon_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'surgeon_name_snapshot' => ['type' => 'VARCHAR', 'constraint' => 180, 'default' => ''], + 'anesthetist_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'anesthetist_name_snapshot' => ['type' => 'VARCHAR', 'constraint' => 180, 'default' => ''], + 'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => 'requested'], + 'call_status' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => 'not_called'], + 'called_at' => ['type' => 'DATETIME', 'null' => true], + 'called_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'confirmed_at' => ['type' => 'DATETIME', 'null' => true], + 'confirmed_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'consent_verified' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'site_side_verified' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'allergy_verified' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'npo_verified' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'investigations_verified' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'anesthesia_clearance' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'blood_availability' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => 'not_required'], + 'who_sign_in' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'who_time_out' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'who_sign_out' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'actual_start_at' => ['type' => 'DATETIME', 'null' => true], + 'actual_end_at' => ['type' => 'DATETIME', 'null' => true], + 'lock_version' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'created_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'updated_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('request_no', 'uq_ipd_ot_request_no'); + $this->forge->addKey(['scheduled_start_at', 'department_id', 'status'], false, false, 'idx_ipd_ot_queue'); + $this->forge->addKey(['ipd_id', 'status'], false, false, 'idx_ipd_ot_admission'); + $this->forge->createTable('ipd_ot_cases', true, ['ENGINE' => 'InnoDB']); + } + + if (! $this->tableExists('ipd_ot_case_events')) { + $this->forge->addField([ + 'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true], + 'case_id' => ['type' => 'INT', 'unsigned' => true], + 'event_type' => ['type' => 'VARCHAR', 'constraint' => 40], + 'from_status' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => ''], + 'to_status' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => ''], + 'old_scheduled_at' => ['type' => 'DATETIME', 'null' => true], + 'new_scheduled_at' => ['type' => 'DATETIME', 'null' => true], + 'reason' => ['type' => 'VARCHAR', 'constraint' => 255, 'default' => ''], + 'notes' => ['type' => 'TEXT', 'null' => true], + 'actor_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'created_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['case_id', 'created_at'], false, false, 'idx_ipd_ot_case_events'); + $this->forge->createTable('ipd_ot_case_events', true, ['ENGINE' => 'InnoDB']); + } + + if (! $this->tableExists('ipd_ot_postop_assessments')) { + $this->forge->addField([ + 'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true], + 'case_id' => ['type' => 'INT', 'unsigned' => true], + 'supersedes_id' => ['type' => 'BIGINT', 'unsigned' => true, 'default' => 0], + 'observed_at' => ['type' => 'DATETIME'], + 'consciousness' => ['type' => 'VARCHAR', 'constraint' => 80, 'default' => ''], + 'airway_breathing' => ['type' => 'VARCHAR', 'constraint' => 255, 'default' => ''], + 'bp' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => ''], + 'pulse' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => ''], + 'spo2' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => ''], + 'temperature' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => ''], + 'pain_score' => ['type' => 'TINYINT', 'unsigned' => true, 'default' => 0], + 'bleeding_wound_drain' => ['type' => 'TEXT', 'null' => true], + 'nausea_vomiting' => ['type' => 'VARCHAR', 'constraint' => 100, 'default' => ''], + 'complications' => ['type' => 'TEXT', 'null' => true], + 'interventions' => ['type' => 'TEXT', 'null' => true], + 'disposition' => ['type' => 'VARCHAR', 'constraint' => 100, 'default' => ''], + 'handover_notes' => ['type' => 'TEXT', 'null' => true], + 'edit_reason' => ['type' => 'VARCHAR', 'constraint' => 255, 'default' => ''], + 'recorded_by' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'signed_at' => ['type' => 'DATETIME'], + 'created_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['case_id', 'observed_at'], false, false, 'idx_ipd_ot_postop_case'); + $this->forge->createTable('ipd_ot_postop_assessments', true, ['ENGINE' => 'InnoDB']); + } + + $this->grantPermissions(); + } + + public function down() + { + if ($this->tableExists('auth_permissions_users')) { + $this->db->table('auth_permissions_users')->whereIn('permission', self::PERMISSIONS)->delete(); + } + if ($this->tableExists('auth_role_permissions')) { + $this->db->table('auth_role_permissions')->whereIn('permission', self::PERMISSIONS)->delete(); + } + if ($this->tableExists('auth_roles')) { + (new RoleRegistry($this->db))->publish(); + } + $this->forge->dropTable('ipd_ot_postop_assessments', true); + $this->forge->dropTable('ipd_ot_case_events', true); + $this->forge->dropTable('ipd_ot_cases', true); + } + + private function grantPermissions(): void + { + if (! $this->tableExists('auth_roles') || ! $this->tableExists('auth_role_permissions')) { + return; + } + $now = date('Y-m-d H:i:s'); + $roleGrants = [ + 'superadmin' => self::PERMISSIONS, + 'admin' => self::PERMISSIONS, + 'developer' => self::PERMISSIONS, + 'doctor' => self::PERMISSIONS, + 'nurse' => ['ipd_ot.request.manage', 'ipd_ot.status.manage', 'ipd_ot.postop.manage'], + ]; + foreach ($roleGrants as $alias => $permissions) { + $role = $this->db->table('auth_roles')->select('id')->where('alias', $alias)->get(1)->getRowArray(); + if ($role === null) { + continue; + } + foreach ($permissions as $permission) { + $exists = $this->db->table('auth_role_permissions')->where('role_id', (int) $role['id'])->where('permission', $permission)->countAllResults(); + if ($exists === 0) { + $this->db->table('auth_role_permissions')->insert(['role_id' => (int) $role['id'], 'permission' => $permission, 'created_at' => $now]); + } + } + } + if ($this->tableExists('auth_permissions_users')) { + $managerIds = array_column($this->db->table('auth_permissions_users')->select('user_id')->where('permission', 'users.manage-admins')->get()->getResultArray(), 'user_id'); + foreach ($managerIds as $userId) { + foreach (self::PERMISSIONS as $permission) { + $exists = $this->db->table('auth_permissions_users')->where('user_id', (int) $userId)->where('permission', $permission)->countAllResults(); + if ($exists === 0) { + $this->db->table('auth_permissions_users')->insert(['user_id' => (int) $userId, 'permission' => $permission, 'created_at' => $now]); + } + } + } + } + (new RoleRegistry($this->db))->publish(); + } + + private function tableExists(string $table): bool + { + return $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray() !== null; + } +} \ No newline at end of file diff --git a/app/Models/IpdOtCaseModel.php b/app/Models/IpdOtCaseModel.php new file mode 100644 index 0000000..d556c53 --- /dev/null +++ b/app/Models/IpdOtCaseModel.php @@ -0,0 +1,23 @@ +where('ipd_id', $ipdId) + ->where('form_key', $formKey) + ->where('episode_no', $episodeNo) + ->first() ?? []; + } +} \ No newline at end of file diff --git a/app/Views/ipd_nursing/workspace.php b/app/Views/ipd_nursing/workspace.php index 6cc5996..688cded 100644 --- a/app/Views/ipd_nursing/workspace.php +++ b/app/Views/ipd_nursing/workspace.php @@ -13,6 +13,10 @@ $dischargeSummaryUrl = site_url('Ipd_discharge/preview_discharge_report/' . $ipd $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); +$otPanelUrl = site_url('ipd/ot/' . $ipdId); +$authUser = function_exists('auth') ? auth()->user() : null; +$canViewIpdOt = $authUser && method_exists($authUser, 'can') + && ($authUser->can('ipd_ot.view') || $authUser->can('ipd_ot.examination.manage')); $today = date('Y-m-d'); $csrfName = csrf_token(); $csrfHash = csrf_hash(); @@ -193,6 +197,11 @@ ksort($nurseNames); + + + @@ -519,6 +528,12 @@ ksort($nurseNames); + +
+
Loading...
+
+ +
OCR/AI extraction is assistive only. Nurse review is mandatory before save (NABH documentation requirement). @@ -1334,6 +1349,11 @@ ksort($nurseNames); if (targetPane) { targetPane.classList.remove('d-none'); } + + var contentUrl = button.getAttribute('data-url'); + if (contentUrl && targetId === 'nursing_tab_ot') { + load_form_div(contentUrl, 'tab_ot_content'); + } }); }); diff --git a/app/Views/ipd_ot/case_form.php b/app/Views/ipd_ot/case_form.php new file mode 100644 index 0000000..80565e6 --- /dev/null +++ b/app/Views/ipd_ot/case_form.php @@ -0,0 +1,28 @@ + $case */ +/** @var array $doctors */ +/** @var bool $can_schedule */ +/** @var bool $can_status */ +/** @var bool $can_postop */ +$caseId = (int) ($case['id'] ?? 0); +$status = (string) ($case['status'] ?? 'requested'); +?> +
-
+

| Status: | Call:

+ +
Schedule / Reschedule
+
+
+
+
+ + +
Patient Call Confirmation
+
Safety Checklist & Status
+'Consent','site_side_verified'=>'Patient/Site/Side','allergy_verified'=>'Allergy','npo_verified'=>'NPO/Fasting','investigations_verified'=>'Investigations','anesthesia_clearance'=>'Anesthesia Clearance','who_sign_in'=>'WHO Sign-in','who_time_out'=>'WHO Time-out','who_sign_out'=>'WHO Sign-out'] as $field=>$label) : ?>
+
+
+ +
+ + \ No newline at end of file diff --git a/app/Views/ipd_ot/ophthalmology_preop.php b/app/Views/ipd_ot/ophthalmology_preop.php new file mode 100644 index 0000000..51a8b0a --- /dev/null +++ b/app/Views/ipd_ot/ophthalmology_preop.php @@ -0,0 +1,107 @@ + $form */ +/** @var string $department_name */ +/** @var array $record */ +/** @var array> $values */ +/** @var bool $can_manage */ +$isCompleted = (string) ($record['status'] ?? '') === 'completed'; +?> +
+ +
+
+
+
+
+ + Completed + +
+ +
+
+ + + + + + + + + + + $rowLabel) : ?> + + + $columnLabel) : ?> + + + + + +
Parameter
+ +
+
+ + + +
+ + +
+ +
+ + +
+ +
+ + \ No newline at end of file diff --git a/app/Views/ipd_ot/panel.php b/app/Views/ipd_ot/panel.php new file mode 100644 index 0000000..dafdd7a --- /dev/null +++ b/app/Views/ipd_ot/panel.php @@ -0,0 +1,120 @@ +> $forms */ +/** @var bool $can_manage */ +/** @var array> $cases */ +/** @var bool $can_request */ +/** @var bool $can_status */ +?> +
+
+
Surgery / OT
+
Department:
+
+ + + +
+ + +
+ + + + + + + + + + + + + + +
RequestProcedureRequested/ScheduledStatusCallAction
+
+ + + +
No specialty pre-operative examination is configured for this department.
+ +
+ + + + + + + + + + + + + + + + + + +
Pre-operative ExaminationStatusAction
+ + + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/app/Views/ipd_ot/postop_form.php b/app/Views/ipd_ot/postop_form.php new file mode 100644 index 0000000..379310d --- /dev/null +++ b/app/Views/ipd_ot/postop_form.php @@ -0,0 +1,13 @@ + $case */ +/** @var array> $assessments */ +/** @var bool $can_manage */ +$caseId=(int)($case['id']??0); +?> +
Post-operative Assessment & Handover

+
+
+
+
+
Signed Assessments
ObservedVitalsPainDisposition/Handover
BP , Pulse , SpO2
+ \ No newline at end of file diff --git a/app/Views/ipd_ot/queue.php b/app/Views/ipd_ot/queue.php new file mode 100644 index 0000000..f3e246d --- /dev/null +++ b/app/Views/ipd_ot/queue.php @@ -0,0 +1,31 @@ +> $rows */ +/** @var array> $departments */ +/** @var string $filter_date */ +/** @var int $filter_department_id */ +/** @var string $filter_status */ +?> +
OT Surgery Queue
+
+
+
+
+
+
+
+ + + + + + + + +
Date/TimePatientDepartmentProcedureSurgeonStatusCallAction
No surgery cases for selected filters.

/
+
+ + \ No newline at end of file diff --git a/app/Views/ipd_ot/request_form.php b/app/Views/ipd_ot/request_form.php new file mode 100644 index 0000000..a752a85 --- /dev/null +++ b/app/Views/ipd_ot/request_form.php @@ -0,0 +1,22 @@ + $doctors */ +?> +
+ +
New Surgery Request
+

Department:

+
+
+
+
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/app/Views/ipd_ot/workflow_script.php b/app/Views/ipd_ot/workflow_script.php new file mode 100644 index 0000000..668e530 --- /dev/null +++ b/app/Views/ipd_ot/workflow_script.php @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index 689603c..9cf6246 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -29,9 +29,13 @@ $canIpdNursing = $user && method_exists($user, 'can') $canIpdDischarge = $user && method_exists($user, 'can') ? ($user->can('ipd_discharge.view') || $user->can('ipd_discharge.manage') || $user->can('ipd_discharge.*')) : false; +$canIpdOt = $user && method_exists($user, 'can') + ? ($user->can('ipd_ot.view') || $user->can('ipd_ot.request.manage') || $user->can('ipd_ot.schedule.manage') || $user->can('ipd_ot.status.manage') || $user->can('ipd_ot.postop.manage') || $user->can('ipd_ot.*')) + : false; if ($user && method_exists($user, 'inGroup') && $user->inGroup('superadmin', 'admin', 'developer')) { $canIpdNursing = true; $canIpdDischarge = true; + $canIpdOt = true; } $canBilling = false; @@ -329,7 +333,7 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) { - + + + +