feat: add OT workflow and allow zero payments

This commit is contained in:
DevSoftTech Server 2026-08-12 13:38:25 +05:30
parent 670f6d3f1f
commit d289bb5be1
20 changed files with 1457 additions and 4 deletions

View File

@ -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.*',

62
app/Config/IpdOtForms.php Normal file
View File

@ -0,0 +1,62 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class IpdOtForms extends BaseConfig
{
public array $examinationForms = [
'ophthalmology_preop_v1' => [
'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));
}
}

View File

@ -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');

597
app/Controllers/IpdOt.php Normal file
View File

@ -0,0 +1,597 @@
<?php
namespace App\Controllers;
use App\Libraries\ClinicalAuditTrail;
use App\Models\IpdBillingModel;
use App\Models\IpdOtCaseModel;
use App\Models\IpdPreopExaminationModel;
use Config\IpdOtForms;
class IpdOt extends BaseController
{
private IpdBillingModel $ipdBillingModel;
private IpdPreopExaminationModel $examinationModel;
private IpdOtCaseModel $caseModel;
private IpdOtForms $otConfig;
public function __construct()
{
$this->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));
}
}

View File

@ -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);

View File

@ -0,0 +1,143 @@
<?php
namespace App\Database\Migrations;
use App\Libraries\RoleRegistry;
use CodeIgniter\Database\Migration;
class CreateIpdPreopExaminations extends Migration
{
private const PERMISSIONS = [
'ipd_ot.view',
'ipd_ot.examination.manage',
];
public function up()
{
if (! $this->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;
}
}

View File

@ -0,0 +1,179 @@
<?php
namespace App\Database\Migrations;
use App\Libraries\RoleRegistry;
use CodeIgniter\Database\Migration;
class CreateIpdOtWorkflow extends Migration
{
private const PERMISSIONS = [
'ipd_ot.request.manage',
'ipd_ot.schedule.manage',
'ipd_ot.status.manage',
'ipd_ot.postop.manage',
];
public function up()
{
if (! $this->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;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class IpdOtCaseModel extends Model
{
protected $table = 'ipd_ot_cases';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'request_no', 'ipd_id', 'patient_id', 'department_id', 'department_name_snapshot',
'procedure_name', 'procedure_side', 'priority', 'requested_date', 'requested_time',
'requested_notes', 'scheduled_start_at', 'surgeon_id', 'surgeon_name_snapshot',
'anesthetist_id', 'anesthetist_name_snapshot', 'status', 'call_status', 'called_at',
'called_by', 'confirmed_at', 'confirmed_by', 'consent_verified', 'site_side_verified',
'allergy_verified', 'npo_verified', 'investigations_verified', 'anesthesia_clearance',
'blood_availability', 'who_sign_in', 'who_time_out', 'who_sign_out',
'actual_start_at', 'actual_end_at', 'lock_version', 'created_by', 'updated_by',
];
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class IpdPreopExaminationModel extends Model
{
protected $table = 'ipd_preop_examinations';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'ipd_id',
'patient_id',
'department_id',
'department_name_snapshot',
'form_key',
'schema_version',
'episode_no',
'payload_json',
'status',
'examined_by',
'examined_at',
'created_by',
'updated_by',
];
public function findForAdmission(int $ipdId, string $formKey, int $episodeNo = 1): array
{
return $this->where('ipd_id', $ipdId)
->where('form_key', $formKey)
->where('episode_no', $episodeNo)
->first() ?? [];
}
}

View File

@ -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);
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-nursing-tab="nursing_tab_treatment">Treatment / Procedure Note</button>
</li>
<?php if ($canViewIpdOt) : ?>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-nursing-tab="nursing_tab_ot" data-url="<?= esc($otPanelUrl, 'attr') ?>">Surgery / OT</button>
</li>
<?php endif ?>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-nursing-tab="nursing_tab_scan">Scan Paper -> Auto Fill</button>
</li>
@ -519,6 +528,12 @@ ksort($nurseNames);
</form>
</div>
<?php if ($canViewIpdOt) : ?>
<div class="nursing-tab-pane d-none" id="nursing_tab_ot">
<div id="tab_ot_content">Loading...</div>
</div>
<?php endif ?>
<div class="nursing-tab-pane d-none" id="nursing_tab_scan">
<div class="alert alert-warning py-2 mb-3">
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');
}
});
});

View File

@ -0,0 +1,28 @@
<?php
/** @var array<string, mixed> $case */
/** @var array<int, object> $doctors */
/** @var bool $can_schedule */
/** @var bool $can_status */
/** @var bool $can_postop */
$caseId = (int) ($case['id'] ?? 0);
$status = (string) ($case['status'] ?? 'requested');
?>
<h5><?= esc((string) ($case['request_no'] ?? 'OT Case')) ?> - <?= esc((string) ($case['procedure_name'] ?? '')) ?></h5>
<p class="small text-muted mb-3"><?= esc((string) ($case['department_name_snapshot'] ?? '')) ?> | Status: <?= esc(ucwords(str_replace('_', ' ', $status))) ?> | Call: <?= esc(ucwords(str_replace('_', ' ', (string) ($case['call_status'] ?? '')))) ?></p>
<?php if ($can_schedule) : ?>
<form class="ipd-ot-ajax-form border rounded p-3 mb-3" action="<?= site_url('ot/cases/' . $caseId) ?>" method="post" data-refresh="queue"><h6>Schedule / Reschedule</h6><?= csrf_field() ?><input type="hidden" name="action" value="<?= empty($case['scheduled_start_at']) ? 'schedule' : 'reschedule' ?>"><div class="row g-2">
<div class="col-md-4"><label class="form-label">Date &amp; Time</label><input type="datetime-local" name="scheduled_start_at" class="form-control" value="<?= esc(str_replace(' ', 'T', substr((string) ($case['scheduled_start_at'] ?? ''), 0, 16)), 'attr') ?>" required></div>
<div class="col-md-4"><label class="form-label">Surgeon</label><select name="surgeon_id" class="form-select"><option value="0">Select</option><?php foreach ($doctors as $doctor) : ?><option value="<?= (int) ($doctor->id ?? 0) ?>" <?= (int) ($doctor->id ?? 0) === (int) ($case['surgeon_id'] ?? 0) ? 'selected' : '' ?>><?= esc((string) ($doctor->DocSpecName ?? '')) ?></option><?php endforeach ?></select></div>
<div class="col-md-4"><label class="form-label">Anesthetist</label><select name="anesthetist_id" class="form-select"><option value="0">Select</option><?php foreach ($doctors as $doctor) : ?><option value="<?= (int) ($doctor->id ?? 0) ?>" <?= (int) ($doctor->id ?? 0) === (int) ($case['anesthetist_id'] ?? 0) ? 'selected' : '' ?>><?= esc((string) ($doctor->DocSpecName ?? '')) ?></option><?php endforeach ?></select></div>
<?php if (! empty($case['scheduled_start_at'])) : ?><div class="col-md-9"><label class="form-label">Reason for date/time modification</label><input type="text" name="reason" class="form-control" maxlength="255" required></div><?php endif ?><div class="col-md-3 d-flex align-items-end"><button class="btn btn-primary w-100" type="submit"><?= empty($case['scheduled_start_at']) ? 'Schedule' : 'Reschedule' ?></button></div></div><div class="ipd-ot-form-notice mt-2"></div></form>
<?php endif ?>
<?php if ($can_status) : ?>
<form class="ipd-ot-ajax-form border rounded p-3 mb-3" action="<?= site_url('ot/cases/' . $caseId) ?>" method="post" data-refresh="queue"><h6>Patient Call Confirmation</h6><?= csrf_field() ?><input type="hidden" name="action" value="call"><div class="row g-2"><div class="col-md-4"><select name="call_status" class="form-select"><option value="called">Called</option><option value="confirmed">Confirmed</option><option value="not_reachable">Not reachable</option><option value="declined">Declined</option></select></div><div class="col-md-5"><input type="text" name="reason" class="form-control" placeholder="Reason/remarks (required if declined)"></div><div class="col-md-3"><button class="btn btn-outline-primary w-100">Update Call</button></div></div><div class="ipd-ot-form-notice mt-2"></div></form>
<form class="ipd-ot-ajax-form border rounded p-3" action="<?= site_url('ot/cases/' . $caseId) ?>" method="post" data-refresh="queue"><h6>Safety Checklist &amp; Status</h6><?= csrf_field() ?><input type="hidden" name="action" value="status"><div class="row g-2">
<?php foreach (['consent_verified'=>'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) : ?><div class="col-md-4"><label class="form-check"><input class="form-check-input" type="checkbox" name="<?= $field ?>" value="1" <?= ! empty($case[$field]) ? 'checked' : '' ?>><span class="form-check-label"><?= esc($label) ?></span></label></div><?php endforeach ?>
<div class="col-md-4"><label class="form-label">Blood</label><select class="form-select" name="blood_availability"><option value="not_required">Not required</option><option value="arranged" <?= ($case['blood_availability'] ?? '') === 'arranged' ? 'selected' : '' ?>>Arranged</option><option value="available" <?= ($case['blood_availability'] ?? '') === 'available' ? 'selected' : '' ?>>Available</option></select></div>
<div class="col-md-4"><label class="form-label">Next Status</label><select class="form-select" name="status"><?php $statusOptions = ['requested'=>['cancelled'],'scheduled'=>['called','in_progress','cancelled'],'called'=>['scheduled','in_progress','cancelled'],'in_progress'=>['completed']][$status] ?? []; foreach ($statusOptions as $next) : ?><option value="<?= $next ?>"><?= esc(ucwords(str_replace('_',' ',$next))) ?></option><?php endforeach ?></select></div><div class="col-md-4"><label class="form-label">Reason/Notes</label><input class="form-control" name="reason" placeholder="Required for cancellation"></div><div class="col-12 text-end"><button class="btn btn-success">Update Status</button></div></div><div class="ipd-ot-form-notice mt-2"></div></form>
<?php endif ?>
<?php if ($can_postop) : ?><div class="mt-3"><button type="button" class="btn btn-outline-secondary ipd-ot-postop-open" data-case-id="<?= $caseId ?>">Post-operative Assessment</button></div><?php endif ?>
<?= view('ipd_ot/workflow_script') ?>
<script>$(document).off('click.otPostop','.ipd-ot-postop-open').on('click.otPostop','.ipd-ot-postop-open',function(){load_form_div('<?= site_url('ot/cases/' . $caseId . '/postop') ?>',$(this).closest('.modal-body').attr('id'));});</script>

View File

@ -0,0 +1,107 @@
<?php
/** @var int $ipd_id */
/** @var string $form_key */
/** @var array<string, mixed> $form */
/** @var string $department_name */
/** @var array<string, mixed> $record */
/** @var array<string, array<string, string>> $values */
/** @var bool $can_manage */
$isCompleted = (string) ($record['status'] ?? '') === 'completed';
?>
<form id="ipd_ophthalmology_preop_form" action="<?= site_url('ipd/ot/' . (int) $ipd_id . '/examination/' . rawurlencode($form_key)) ?>" method="post">
<?= csrf_field() ?>
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h5 class="mb-1"><?= esc((string) ($form['title'] ?? 'Pre-operative Examination')) ?></h5>
<div class="text-muted small"><?= esc($department_name) ?></div>
</div>
<?php if ($isCompleted) : ?>
<span class="badge bg-success">Completed</span>
<?php endif ?>
</div>
<div id="ipd_ot_exam_notice"></div>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead class="table-light">
<tr>
<th style="width:34%;">Parameter</th>
<?php foreach (($form['columns'] ?? []) as $columnLabel) : ?>
<th><?= esc((string) $columnLabel) ?></th>
<?php endforeach ?>
</tr>
</thead>
<tbody>
<?php foreach (($form['rows'] ?? []) as $rowKey => $rowLabel) : ?>
<tr>
<th><?= esc((string) $rowLabel) ?></th>
<?php foreach (($form['columns'] ?? []) as $columnKey => $columnLabel) : ?>
<td>
<textarea class="form-control form-control-sm" name="values[<?= esc((string) $rowKey, 'attr') ?>][<?= esc((string) $columnKey, 'attr') ?>]" rows="2" <?= $can_manage ? '' : 'readonly' ?>><?= esc((string) ($values[$rowKey][$columnKey] ?? '')) ?></textarea>
</td>
<?php endforeach ?>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<?php if ($can_manage) : ?>
<?php if ($isCompleted) : ?>
<div class="mb-3">
<label class="form-label" for="ipd_ot_edit_reason">Reason for editing completed examination</label>
<input type="text" class="form-control" id="ipd_ot_edit_reason" name="edit_reason" maxlength="255" required>
</div>
<?php endif ?>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-outline-primary" name="status" value="draft">Save Draft</button>
<button type="submit" class="btn btn-success" name="status" value="completed">Complete Examination</button>
</div>
<?php endif ?>
</form>
<script>
(function() {
var form = document.getElementById('ipd_ophthalmology_preop_form');
if (!form) {
return;
}
$(form).off('submit.ipdOtSave').on('submit.ipdOtSave', function(event) {
event.preventDefault();
var submitter = event.originalEvent && event.originalEvent.submitter ? event.originalEvent.submitter : null;
var formData = new window.FormData(form);
formData.set('status', submitter ? String(submitter.value || 'draft') : 'draft');
fetch(form.action, {
method: 'POST',
body: formData,
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).then(function(response) {
return response.json().then(function(data) {
return {ok: response.ok, data: data};
});
}).then(function(result) {
var data = result.data || {};
var notice = document.getElementById('ipd_ot_exam_notice');
if (data.csrfName && data.csrfHash) {
var csrf = form.querySelector('input[name="' + data.csrfName + '"]');
if (csrf) {
csrf.value = data.csrfHash;
}
}
if (!result.ok || parseInt(data.update || 0, 10) !== 1) {
notice.innerHTML = '<div class="alert alert-danger">' + $('<div>').text(data.error_text || 'Unable to save examination.').html() + '</div>';
return;
}
notice.innerHTML = '<div class="alert alert-success">' + $('<div>').text(data.error_text || 'Examination saved.').html() + '</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('ipdOtExamModal')).hide();
if (typeof window.refreshIpdOtPanel === 'function') {
window.refreshIpdOtPanel();
}
}).catch(function() {
document.getElementById('ipd_ot_exam_notice').innerHTML = '<div class="alert alert-danger">Unable to save examination.</div>';
});
});
})();
</script>

120
app/Views/ipd_ot/panel.php Normal file
View File

@ -0,0 +1,120 @@
<?php
/** @var int $ipd_id */
/** @var string $department_name */
/** @var array<string, array<string, mixed>> $forms */
/** @var bool $can_manage */
/** @var array<int, array<string, mixed>> $cases */
/** @var bool $can_request */
/** @var bool $can_status */
?>
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h5 class="mb-1">Surgery / OT</h5>
<div class="text-muted small">Department: <?= esc($department_name !== '' ? $department_name : 'Not assigned') ?></div>
</div>
<?php if ($can_request) : ?>
<button type="button" class="btn btn-primary btn-sm" id="ipd_ot_new_request">New Surgery Request</button>
<?php endif ?>
</div>
<?php if (! empty($cases)) : ?>
<div class="table-responsive mb-4">
<table class="table table-sm table-bordered align-middle mb-0">
<thead class="table-light"><tr><th>Request</th><th>Procedure</th><th>Requested/Scheduled</th><th>Status</th><th>Call</th><th style="width:90px;">Action</th></tr></thead>
<tbody>
<?php foreach ($cases as $case) : ?>
<tr>
<td><?= esc((string) ($case['request_no'] ?? '')) ?></td>
<td><?= esc((string) ($case['procedure_name'] ?? '')) ?></td>
<td><?= esc((string) (($case['scheduled_start_at'] ?? '') ?: ($case['requested_date'] ?? ''))) ?></td>
<td><span class="badge bg-info text-dark"><?= esc(ucwords(str_replace('_', ' ', (string) ($case['status'] ?? 'requested')))) ?></span></td>
<td><?= esc(ucwords(str_replace('_', ' ', (string) ($case['call_status'] ?? 'not_called')))) ?></td>
<td><button type="button" class="btn btn-sm btn-outline-primary ipd-ot-case-open" data-case-id="<?= (int) ($case['id'] ?? 0) ?>">Open</button></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<?php endif ?>
<?php if (empty($forms)) : ?>
<div class="alert alert-info mb-0">No specialty pre-operative examination is configured for this department.</div>
<?php else : ?>
<div class="table-responsive">
<table class="table table-sm table-bordered align-middle mb-0">
<thead class="table-light">
<tr>
<th>Pre-operative Examination</th>
<th style="width:140px;">Status</th>
<th style="width:110px;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($forms as $form) : ?>
<?php $recordStatus = (string) ($form['record']['status'] ?? 'Not started'); ?>
<tr>
<td><?= esc((string) ($form['title'] ?? 'Specialty Examination')) ?></td>
<td>
<span class="badge <?= $recordStatus === 'completed' ? 'bg-success' : ($recordStatus === 'draft' ? 'bg-warning text-dark' : 'bg-secondary') ?>">
<?= esc(ucfirst($recordStatus)) ?>
</span>
</td>
<td>
<button type="button" class="btn btn-sm btn-primary ipd-ot-open-exam" data-form-key="<?= esc((string) $form['key'], 'attr') ?>">
<?= $can_manage ? 'Fill' : 'View' ?>
</button>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<?php endif ?>
<div class="modal fade" id="ipdOtExamModal" tabindex="-1" aria-labelledby="ipdOtExamModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ipdOtExamModalLabel">Specialty Examination</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="ipdOtExamModalBody">Loading...</div>
</div>
</div>
</div>
<div class="modal fade" id="ipdOtWorkflowModal" tabindex="-1" aria-labelledby="ipdOtWorkflowModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="ipdOtWorkflowModalLabel">OT Management</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
<div class="modal-body" id="ipdOtWorkflowModalBody">Loading...</div>
</div></div>
</div>
<script>
(function() {
var panelUrl = '<?= site_url('ipd/ot/' . (int) $ipd_id) ?>';
var examBaseUrl = panelUrl + '/examination/';
window.refreshIpdOtPanel = function() {
load_form_div(panelUrl, 'tab_ot_content');
};
$(document).off('click.ipdOtExam', '.ipd-ot-open-exam').on('click.ipdOtExam', '.ipd-ot-open-exam', function() {
var formKey = String($(this).data('form-key') || '');
if (formKey === '') {
return;
}
$('#ipdOtExamModalBody').html('Loading...');
bootstrap.Modal.getOrCreateInstance(document.getElementById('ipdOtExamModal')).show();
load_form_div(examBaseUrl + encodeURIComponent(formKey), 'ipdOtExamModalBody');
});
function openWorkflow(url) {
$('#ipdOtWorkflowModalBody').html('Loading...');
bootstrap.Modal.getOrCreateInstance(document.getElementById('ipdOtWorkflowModal')).show();
load_form_div(url, 'ipdOtWorkflowModalBody');
}
$(document).off('click.ipdOtRequest', '#ipd_ot_new_request').on('click.ipdOtRequest', '#ipd_ot_new_request', function() { openWorkflow(panelUrl + '/request'); });
$(document).off('click.ipdOtCase', '.ipd-ot-case-open').on('click.ipdOtCase', '.ipd-ot-case-open', function() { openWorkflow('<?= site_url('ot/cases') ?>/' + parseInt($(this).data('case-id') || 0, 10)); });
})();
</script>

View File

@ -0,0 +1,13 @@
<?php
/** @var array<string, mixed> $case */
/** @var array<int, array<string, mixed>> $assessments */
/** @var bool $can_manage */
$caseId=(int)($case['id']??0);
?>
<h5>Post-operative Assessment &amp; Handover</h5><p class="small text-muted"><?= esc((string)($case['procedure_name']??'')) ?></p>
<?php if ($can_manage) : ?><form class="ipd-ot-ajax-form" action="<?= site_url('ot/cases/'.$caseId.'/postop') ?>" method="post" data-refresh="queue"><?= csrf_field() ?><div class="row g-2">
<div class="col-md-3"><label class="form-label">Observed At</label><input type="datetime-local" class="form-control" name="observed_at" value="<?= date('Y-m-d\TH:i') ?>" required></div><div class="col-md-3"><label class="form-label">Consciousness</label><input class="form-control" name="consciousness"></div><div class="col-md-6"><label class="form-label">Airway/Breathing</label><input class="form-control" name="airway_breathing"></div>
<div class="col-md-2"><label class="form-label">BP</label><input class="form-control" name="bp"></div><div class="col-md-2"><label class="form-label">Pulse</label><input class="form-control" name="pulse"></div><div class="col-md-2"><label class="form-label">SpO2</label><input class="form-control" name="spo2"></div><div class="col-md-2"><label class="form-label">Temperature</label><input class="form-control" name="temperature"></div><div class="col-md-2"><label class="form-label">Pain (0-10)</label><input type="number" min="0" max="10" class="form-control" name="pain_score"></div><div class="col-md-2"><label class="form-label">Nausea/Vomiting</label><input class="form-control" name="nausea_vomiting"></div>
<div class="col-md-6"><label class="form-label">Bleeding/Wound/Drain</label><textarea class="form-control" name="bleeding_wound_drain"></textarea></div><div class="col-md-6"><label class="form-label">Complications</label><textarea class="form-control" name="complications"></textarea></div><div class="col-md-6"><label class="form-label">Interventions</label><textarea class="form-control" name="interventions"></textarea></div><div class="col-md-6"><label class="form-label">Disposition</label><input class="form-control" name="disposition" placeholder="PACU / ICU / Ward"></div><div class="col-12"><label class="form-label">Handover Notes</label><textarea class="form-control" name="handover_notes" required></textarea></div><div class="col-12 text-end"><button class="btn btn-success">Sign Assessment</button></div></div><div class="ipd-ot-form-notice mt-2"></div></form><?php endif ?>
<?php if (!empty($assessments)) : ?><hr><h6>Signed Assessments</h6><div class="table-responsive"><table class="table table-sm table-bordered"><thead><tr><th>Observed</th><th>Vitals</th><th>Pain</th><th>Disposition/Handover</th></tr></thead><tbody><?php foreach($assessments as $row): ?><tr><td><?= esc((string)$row['observed_at']) ?></td><td>BP <?= esc((string)$row['bp']) ?>, Pulse <?= esc((string)$row['pulse']) ?>, SpO2 <?= esc((string)$row['spo2']) ?></td><td><?= (int)$row['pain_score'] ?></td><td><?= esc((string)$row['disposition']) ?><br><?= esc((string)$row['handover_notes']) ?></td></tr><?php endforeach ?></tbody></table></div><?php endif ?>
<?= view('ipd_ot/workflow_script') ?>

View File

@ -0,0 +1,31 @@
<?php
/** @var array<int, array<string, mixed>> $rows */
/** @var array<int, array<string, mixed>> $departments */
/** @var string $filter_date */
/** @var int $filter_department_id */
/** @var string $filter_status */
?>
<section class="content"><div class="card"><div class="card-header"><h5 class="mb-0">OT Surgery Queue</h5></div><div class="card-body">
<form class="row g-2 mb-3" id="ipd_ot_queue_filter">
<div class="col-md-3"><label class="form-label">Date</label><input type="date" class="form-control" name="date" value="<?= esc($filter_date, 'attr') ?>"></div>
<div class="col-md-3"><label class="form-label">Department</label><select class="form-select" name="department_id"><option value="0">All departments</option><?php foreach ($departments as $department) : ?><option value="<?= (int) $department['iId'] ?>" <?= (int) $department['iId'] === $filter_department_id ? 'selected' : '' ?>><?= esc((string) $department['vName']) ?></option><?php endforeach ?></select></div>
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="">All statuses</option><?php foreach (['requested','scheduled','called','in_progress','completed','cancelled'] as $status) : ?><option value="<?= $status ?>" <?= $filter_status === $status ? 'selected' : '' ?>><?= esc(ucwords(str_replace('_', ' ', $status))) ?></option><?php endforeach ?></select></div>
<div class="col-md-3 d-flex align-items-end"><button type="submit" class="btn btn-primary">Apply</button></div>
</form>
<div class="table-responsive"><table class="table table-bordered table-sm align-middle"><thead class="table-light"><tr><th>Date/Time</th><th>Patient</th><th>Department</th><th>Procedure</th><th>Surgeon</th><th>Status</th><th>Call</th><th>Action</th></tr></thead><tbody>
<?php if (empty($rows)) : ?><tr><td colspan="8" class="text-center text-muted">No surgery cases for selected filters.</td></tr><?php endif ?>
<?php foreach ($rows as $row) : ?><tr>
<td><?= esc((string) (($row['scheduled_start_at'] ?? '') ?: (($row['requested_date'] ?? '') . ' ' . ($row['requested_time'] ?? '')))) ?></td>
<td><strong><?= esc((string) ($row['p_fname'] ?? '')) ?></strong><br><small><?= esc((string) ($row['ipd_code'] ?? '')) ?> / <?= esc((string) ($row['p_code'] ?? '')) ?></small></td>
<td><?= esc((string) ($row['department_name_snapshot'] ?? '')) ?></td><td><?= esc((string) ($row['procedure_name'] ?? '')) ?></td><td><?= esc((string) ($row['surgeon_name_snapshot'] ?? '')) ?></td>
<td><?= esc(ucwords(str_replace('_', ' ', (string) ($row['status'] ?? '')))) ?></td><td><?= esc(ucwords(str_replace('_', ' ', (string) ($row['call_status'] ?? '')))) ?></td>
<td><button type="button" class="btn btn-sm btn-outline-primary ipd-ot-queue-open" data-case-id="<?= (int) ($row['id'] ?? 0) ?>">Open</button></td>
</tr><?php endforeach ?>
</tbody></table></div>
</div></div></section>
<div class="modal fade" id="ipdOtQueueModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">OT Case</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body" id="ipdOtQueueModalBody"></div></div></div></div>
<script>(function(){
$('#ipd_ot_queue_filter').off('submit.otQueue').on('submit.otQueue',function(e){e.preventDefault();var query=new window.URLSearchParams(new window.FormData(this)).toString();load_form('<?= site_url('ot/queue') ?>?'+query,'OT Surgery Queue');});
$(document).off('click.otQueue','.ipd-ot-queue-open').on('click.otQueue','.ipd-ot-queue-open',function(){var id=parseInt($(this).data('case-id')||0,10);bootstrap.Modal.getOrCreateInstance(document.getElementById('ipdOtQueueModal')).show();load_form_div('<?= site_url('ot/cases') ?>/'+id,'ipdOtQueueModalBody');});
window.refreshIpdOtQueue=function(){load_form('<?= site_url('ot/queue') ?>?'+$('#ipd_ot_queue_filter').serialize(),'OT Surgery Queue');};
})();</script>

View File

@ -0,0 +1,22 @@
<?php
/** @var int $ipd_id */
/** @var string $department_name */
/** @var array<int, object> $doctors */
?>
<form class="ipd-ot-ajax-form" action="<?= site_url('ipd/ot/' . $ipd_id . '/requests') ?>" method="post" data-refresh="panel">
<?= csrf_field() ?>
<h5>New Surgery Request</h5>
<p class="text-muted small">Department: <?= esc($department_name) ?></p>
<div class="ipd-ot-form-notice"></div>
<div class="row g-3">
<div class="col-md-8"><label class="form-label">Planned Procedure</label><input type="text" name="procedure_name" class="form-control" maxlength="255" required></div>
<div class="col-md-4"><label class="form-label">Side/Site</label><select name="procedure_side" class="form-select"><option value="not_applicable">Not applicable</option><option value="left">Left</option><option value="right">Right</option><option value="bilateral">Bilateral</option></select></div>
<div class="col-md-3"><label class="form-label">Priority</label><select name="priority" class="form-select"><option value="routine">Routine</option><option value="urgent">Urgent</option><option value="emergency">Emergency</option></select></div>
<div class="col-md-3"><label class="form-label">Requested Date</label><input type="date" name="requested_date" class="form-control" value="<?= date('Y-m-d') ?>" required></div>
<div class="col-md-2"><label class="form-label">Preferred Time</label><input type="time" name="requested_time" class="form-control"></div>
<div class="col-md-4"><label class="form-label">Surgeon</label><select name="surgeon_id" class="form-select"><option value="0">Select later</option><?php foreach ($doctors as $doctor) : ?><option value="<?= (int) ($doctor->id ?? 0) ?>"><?= esc((string) ($doctor->DocSpecName ?? $doctor->p_fname ?? '')) ?></option><?php endforeach ?></select></div>
<div class="col-12"><label class="form-label">Clinical/Scheduling Notes</label><textarea name="requested_notes" class="form-control" rows="3"></textarea></div>
<div class="col-12 text-end"><button type="submit" class="btn btn-primary">Create Request</button></div>
</div>
</form>
<?= view('ipd_ot/workflow_script') ?>

View File

@ -0,0 +1,3 @@
<script>(function(){
$(document).off('submit.ipdOtWorkflow','.ipd-ot-ajax-form').on('submit.ipdOtWorkflow','.ipd-ot-ajax-form',function(event){event.preventDefault();var form=this;var notice=$(form).find('.ipd-ot-form-notice');fetch(form.action,{method:'POST',body:new window.FormData(form),headers:{'X-Requested-With':'XMLHttpRequest'}}).then(function(response){return response.json().then(function(data){return{ok:response.ok,data:data};});}).then(function(result){var data=result.data||{};if(data.csrfName&&data.csrfHash){$(form).find('input[name="'+data.csrfName+'"]').val(data.csrfHash);}if(!result.ok||parseInt(data.update||0,10)!==1){notice.html('<div class="alert alert-danger py-2">'+$('<div>').text(data.error_text||'Unable to save.').html()+'</div>');return;}notice.html('<div class="alert alert-success py-2">'+$('<div>').text(data.error_text||'Saved.').html()+'</div>');if(form.dataset.refresh==='panel'&&window.refreshIpdOtPanel){window.refreshIpdOtPanel();}if(form.dataset.refresh==='queue'&&window.refreshIpdOtQueue){window.refreshIpdOtQueue();}}).catch(function(){notice.html('<div class="alert alert-danger py-2">Unable to save.</div>');});});
})();</script>

View File

@ -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')) {
</ul>
</li>
<?php } ?>
<?php if ($canIpdNursing || $canIpdDischarge) { ?>
<?php if ($canIpdNursing || $canIpdDischarge || $canIpdOt) { ?>
<li class="nav-heading">In-Patient & Nursing Care</li>
<?php if ($canIpdNursing) { ?>
<li class="nav-item">
@ -339,6 +343,14 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) {
</a>
</li>
<?php } ?>
<?php if ($canIpdOt) { ?>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('ot/queue') ?>','OT Surgery Queue')">
<i class="bi bi-calendar2-week"></i>
<span>OT Surgery Queue</span>
</a>
</li>
<?php } ?>
<?php if ($canIpdDischarge) { ?>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('Ipd_discharge/search_patient') ?>','Discharge Management')">

View File

@ -92,7 +92,7 @@ $locked = ($cashStatus !== '' && $cashStatus !== 'open')
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label for="payment-edit-amount" class="form-label">Correct Amount</label>
<input class="form-control" type="number" min="0.01" step="0.01" id="payment-edit-amount" value="<?= esc(number_format((float) ($payment['amount'] ?? 0), 2, '.', '')) ?>" <?= $locked ? 'disabled' : '' ?>>
<input class="form-control" type="number" min="0" step="0.01" id="payment-edit-amount" value="<?= esc(number_format((float) ($payment['amount'] ?? 0), 2, '.', '')) ?>" <?= $locked ? 'disabled' : '' ?>>
</div>
<div class="col-md-3 d-grid"><button class="btn btn-primary" id="payment-edit-change-amount" <?= $locked ? 'disabled' : '' ?>>Change Amount</button></div>
</div>

View File

@ -0,0 +1,27 @@
<?php
namespace Tests\Unit;
use Config\IpdOtForms;
use PHPUnit\Framework\TestCase;
final class IpdOtConfigTest extends TestCase
{
public function testOphthalmologyAliasesResolvePreoperativeForm(): void
{
$config = new IpdOtForms();
foreach (['Ophthalmology', 'EYE DEPARTMENT', 'Department-of-Ophthalmology', 'Ophthalmology Department'] as $departmentName) {
$forms = $config->formsForDepartment($departmentName);
$this->assertArrayHasKey('ophthalmology_preop_v1', $forms);
}
}
public function testUnrelatedDepartmentDoesNotReceiveOphthalmologyForm(): void
{
$config = new IpdOtForms();
$this->assertSame([], $config->formsForDepartment('Orthopedics'));
$this->assertSame([], $config->formsForDepartment(''));
}
}