= $summary['total_actions'] ?? 0 ?>
+Total Actions
+= $summary['active_users'] ?? 0 ?>
+Activity Users
+= $summary['activity_roles'] ?? 0 ?>
+Active Roles
+diff --git a/app/Config/Images.php b/app/Config/Images.php index 68e415e..c1b51c1 100644 --- a/app/Config/Images.php +++ b/app/Config/Images.php @@ -19,7 +19,7 @@ class Images extends BaseConfig * * @deprecated 4.7.0 No longer used. */ - public string $libraryPath = '/usr/local/bin/convert'; +b public string $libraryPath = '/usr/local/bin/convert'; /** * The available handler classes. diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 0c295b4..c885ab8 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -24,6 +24,10 @@ $routes->post('/admin/patients/add', 'Admin::storePatient'); $routes->get('/admin/patients/edit/(:any)', 'Admin::editPatient/$1'); $routes->post('/admin/patients/edit/(:any)', 'Admin::updatePatient/$1'); $routes->get('/admin/appointments', 'Admin::appointments'); +$routes->get('/admin/appointment-preview', 'Admin::appointmentPreview'); +$routes->post('/admin/appointments/create', 'Admin::createAppointment'); +$routes->post('/admin/appointments/update-status', 'Admin::updateAppointmentStatus'); +$routes->get('/admin/appointments/delete/(:num)', 'Admin::deleteAppointment/$1'); $routes->get('/admin/deleteDoctor/(:num)', 'Admin::deleteDoctor/$1'); $routes->get('/admin/deletePatient/(:num)', 'Admin::deletePatient/$1'); @@ -42,13 +46,19 @@ $routes->post('/forgot-password', 'Auth::processForgotPassword'); $routes->get('/reset-password/(:any)', 'Auth::resetPassword/$1'); $routes->post('/reset-password', 'Auth::processResetPassword'); -$routes->get('/admin/dashboard', 'Admin::dashboard'); $routes->post('/check-email', 'Admin::checkEmail'); $routes->get('admin/doctors/data', 'Admin::getDoctors'); $routes->get('admin/patients/data', 'Admin::getPatients'); +$routes->get('admin/appointmentsData', 'Admin::appointmentsData'); $routes->get('/admin/activity-log', 'ActivityLog::index'); $routes->get('/admin/activity/analytics', 'ActivityLog::analytics'); -$routes->post('/admin/activity-log/clear', 'ActivityLog::clear'); -// $routes->post('/admin/activity-log/datatable', 'ActivityLog::datatable'); -// $routes->get('/admin/activity-log/summary', 'ActivityLog::getSummary'); -// $routes->get('/admin/activity-log/critical', 'ActivityLog::getCritical'); +$routes->post('admin/available-doctors', 'Admin::getAvailableDoctorsForPatient'); +$routes->post('admin/check-doctor-status', 'Admin::checkDoctorStatus'); +$routes->get('admin/specializations', 'Admin::getSpecializations'); +$routes->post('admin/availability/save', 'AvailabilityController::save'); +$routes->get('admin/availability/(:num)', 'AvailabilityController::getAvailability/$1'); +$routes->get('admin/availability/diagnostic', 'AvailabilityController::diagnostic'); +$routes->get('/admin/patients/add/(:num)', 'Admin::addPatient/$1'); +$routes->get('admin/appointments/create-form/(:num)', 'Admin::appointmentForm/$1'); +$routes->get('/admin/doctor-search', 'Admin::doctorSearch'); + diff --git a/app/Controllers/ActivityLog.php b/app/Controllers/ActivityLog.php index c4980ac..79b4bd2 100644 --- a/app/Controllers/ActivityLog.php +++ b/app/Controllers/ActivityLog.php @@ -12,85 +12,39 @@ class ActivityLog extends BaseController return $r; } - $activityModel = new ActivityLogModel(); + $logModel = new ActivityLogModel(); + $summary = $logModel->getActivitySummary(); + $availableActions = $logModel->getAvailableActions(); - // Get filter parameters - $search = $this->request->getGet('search') ?? ''; - $action = $this->request->getGet('action') ?? ''; - $userType = $this->request->getGet('user_type') ?? ''; - $dateFrom = $this->request->getGet('date_from') ?? ''; - $dateTo = $this->request->getGet('date_to') ?? ''; - $ip = $this->request->getGet('ip') ?? ''; + $action = $this->request->getGet('action'); + if (is_array($action)) { + $action = array_values(array_filter(array_map('trim', $action))); + } else { + $action = trim((string) $action); + } - // Build filters array - $filters = []; - if (!empty($search)) $filters['search'] = $search; - if (!empty($action)) $filters['action'] = $action; - if (!empty($userType)) $filters['user_type'] = $userType; - if (!empty($dateFrom)) $filters['date_from'] = $dateFrom; - if (!empty($dateTo)) $filters['date_to'] = $dateTo; - if (!empty($ip)) $filters['ip'] = $ip; + $role = $this->request->getGet('role'); + if (is_array($role)) { + $role = array_values(array_filter(array_map('trim', $role))); + } else { + $role = trim((string) $role); + } - // Get filtered logs - $logs = $activityModel->getFilteredLogs($filters, 200); - - // Debug: Check database connection and count - $db = \Config\Database::connect(); - $totalInDb = $db->table('activity_logs')->countAll(); - log_message('debug', 'Activity logs in database: ' . $totalInDb . ', retrieved: ' . count($logs)); + $filters = [ + 'action' => $action, + 'role' => $role, + 'date_from' => trim((string) $this->request->getGet('date_from')), + 'date_to' => trim((string) $this->request->getGet('date_to')), + ]; return view('admin/activity_log', [ - 'logs' => $logs, - 'totalLogs' => count($logs), - 'totalInDb' => $totalInDb, - 'actionSummary' => $activityModel->getActionSummary(), - 'roleSummary' => $activityModel->getRoleSummary(), - 'filters' => [ - 'search' => $search, - 'action' => $action, - 'user_type' => $userType, - 'date_from' => $dateFrom, - 'date_to' => $dateTo, - 'ip' => $ip, - ], - 'availableActions' => $activityModel->getAvailableActions(), - 'actionIcons' => [ - 'login' => 'box-arrow-in-right', - 'logout' => 'box-arrow-right', - 'register_patient' => 'person-plus', - 'register_doctor' => 'person-badge-plus', - 'add_doctor' => 'person-badge-plus', - 'update_doctor' => 'person-badge', - 'delete_doctor' => 'person-badge-x', - 'add_patient' => 'person-plus', - 'update_patient' => 'person', - 'delete_patient' => 'person-x', - 'book_appointment' => 'calendar-plus', - 'update_appointment' => 'calendar-check', - 'cancel_appointment' => 'calendar-x', - 'view_appointment' => 'calendar-event', - ], + 'logs' => $logModel->getFilteredLogs($filters), + 'filters' => $filters, + 'summary' => $summary, + 'availableActions' => $availableActions, ]); } - - public function clear() - { - if ($r = $this->requireRole('admin')) { - return $r; - } - - if (strtolower($this->request->getMethod()) === 'post') { - $activityModel = new ActivityLogModel(); - if ($activityModel->clearAll()) { - return redirect()->to(base_url('admin/activity-log'))->with('success', 'All activity logs have been cleared.'); - } else { - return redirect()->to(base_url('admin/activity-log'))->with('error', 'Failed to clear activity logs.'); - } - } - - return redirect()->to(base_url('admin/activity-log')); - } - + public function analytics() { if ($r = $this->requireRole('admin')) { @@ -98,25 +52,59 @@ class ActivityLog extends BaseController } $period = $this->request->getGet('period') ?? '7_days'; - if (! in_array($period, ['7_days', '30_days'], true)) { - $period = '7_days'; - } + $days = ($period === '30_days') ? 30 : 7; + $dateFrom = date('Y-m-d', strtotime("-$days days")); - $activityModel = new ActivityLogModel(); - $summary = $activityModel->getSummary($period); + $db = \Config\Database::connect(); + + $summary = [ + 'total_actions' => $db->table('activity_logs')->where('activity_at >=', $dateFrom)->countAllResults(), + 'by_action' => $db->table('activity_logs')->select('action, COUNT(id) as count')->where('activity_at >=', $dateFrom)->groupBy('action')->get()->getResultArray(), + 'by_role' => $db->table('activity_logs')->select('activity_user_type as actor_role, COUNT(id) as count')->where('activity_at >=', $dateFrom)->groupBy('activity_user_type')->get()->getResultArray(), + ]; + + $mostActive = $db->table('activity_logs al') + ->select("CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, '')) as name, COUNT(al.id) as count") + ->join('users u', 'u.id = al.activity_user_id', 'left') + ->where('al.activity_at >=', $dateFrom) + ->groupBy('al.activity_user_id') + ->orderBy('count', 'DESC') + ->limit(10) + ->get() + ->getResultArray(); + + $summary['most_active_users'] = $mostActive; + + $actionLabels = array_column($summary['by_action'], 'action'); + $actionCounts = array_column($summary['by_action'], 'count'); + $typeLabels = array_column($summary['by_role'], 'actor_role'); + $typeCounts = array_column($summary['by_role'], 'count'); + + $userLabels = array_map(function($user) { return trim((string) $user['name']) ?: 'System'; }, $mostActive); + $userCounts = array_column($mostActive, 'count'); + + $uniqueIPs = $db->table('activity_logs')->select('ip as ip, COUNT(id) as count')->where('activity_at >=', $dateFrom)->where('ip IS NOT NULL')->groupBy('ip')->orderBy('count', 'DESC')->get()->getResultArray(); + + $criticalActions = $db->table('activity_logs al') + ->select("al.activity_at as activity_at, CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, '')) AS actor_name, u.email AS actor_email, al.action, al.target_user_type as target_user_type, al.target_user_id as target_user_id, al.description") + ->join('users u', 'u.id = al.activity_user_id', 'left') + ->like('al.action', 'delete') + ->orderBy('al.activity_at', 'DESC') + ->limit(50) + ->get() + ->getResultArray(); return view('admin/activity_analytics', [ 'period' => $period, 'summary' => $summary, - 'actionLabels' => json_encode(array_keys($summary['by_action'])), - 'actionCounts' => json_encode(array_values($summary['by_action'])), - 'typeLabels' => json_encode(array_keys($summary['by_role'])), - 'typeCounts' => json_encode(array_values($summary['by_role'])), - 'userLabels' => json_encode(array_column($summary['most_active_users'], 'actor')), - 'userCounts' => json_encode(array_column($summary['most_active_users'], 'count')), - 'uniqueIPs' => $summary['unique_ips'], - 'criticalActions' => $activityModel->getCriticalActions(50), + 'uniqueIPs' => $uniqueIPs, + 'criticalActions' => $criticalActions, + 'actionLabels' => json_encode($actionLabels), + 'actionCounts' => json_encode($actionCounts), + 'typeLabels' => json_encode($typeLabels), + 'typeCounts' => json_encode($typeCounts), + 'userLabels' => json_encode($userLabels), + 'userCounts' => json_encode($userCounts) ]); } - } diff --git a/app/Controllers/Admin.php b/app/Controllers/Admin.php index 9ecf7f7..32e2977 100644 --- a/app/Controllers/Admin.php +++ b/app/Controllers/Admin.php @@ -8,6 +8,8 @@ use App\Models\DoctorModel; use App\Models\DoctorSpecializationModel; use App\Models\PatientModel; use App\Models\AppointmentModel; +use App\Models\DoctorBookingModel; +// ...existing code... use App\Models\SpecializationModel; class Admin extends BaseController @@ -88,6 +90,69 @@ class Admin extends BaseController ]; } + private function normalizeAppointmentTime(string $time): ?string + { + $time = trim($time); + + if (preg_match('/^\d{2}:\d{2}$/', $time)) { + return $time . ':00'; + } + + return preg_match('/^\d{2}:\d{2}:\d{2}$/', $time) ? $time : null; + } + + private function isPastAppointmentDateTime(string $date, string $time): bool + { + $timezone = new \DateTimeZone(config('App')->appTimezone); + $appointmentDateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . ' ' . $time, $timezone); + + if (! $appointmentDateTime || $appointmentDateTime->format('Y-m-d H:i:s') !== $date . ' ' . $time) { + return true; + } + + return $appointmentDateTime < new \DateTimeImmutable('now', $timezone); + } + + private function doctorHasAvailabilityAt(int $doctorId, string $date, string $time): bool + { + try { + $dayNumber = (int) (new \DateTimeImmutable($date))->format('N'); + } catch (\Exception $e) { + return false; + } + + return \Config\Database::connect() + ->table('doctor_availability') + ->where('doctor_id', $doctorId) + ->where('day_number', $dayNumber) + ->where('status', 1) + ->where('start_time <=', $time) + ->where('end_time >', $time) + ->countAllResults() > 0; + } + + private function normalizeServiceIds($services): array + { + if (is_string($services)) { + $decoded = json_decode($services, true); + $services = is_array($decoded) ? $decoded : [$services]; + } + + if (! is_array($services)) { + return [ + 'ids' => [], + 'has_duplicates' => false, + ]; + } + + $ids = array_values(array_filter(array_map('intval', $services), static fn ($id) => $id > 0)); + + return [ + 'ids' => array_values(array_unique($ids)), + 'has_duplicates' => count($ids) !== count(array_unique($ids)), + ]; + } + private function getPatientFormData(int $userId): ?array { $userModel = new UserModel(); @@ -225,7 +290,12 @@ class Admin extends BaseController return $r; } - return view('admin/patients'); + $specializationModel = new SpecializationModel(); + $specializations = $specializationModel->getOptionNames(); + + return view('admin/patients', [ + 'specializations' => $specializations, + ]); } public function deletePatient($id) @@ -266,6 +336,414 @@ class Admin extends BaseController return view('admin/appointments', $data); } + public function appointmentPreview() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $patientId = (int) $this->request->getGet('patient_id'); + $rawServices = $this->request->getGet('services'); + $preferredDoctorId = (int) ($this->request->getGet('doctor_id') ?? 0); + $preferredDoctorName = trim((string) ($this->request->getGet('doctor_name') ?? '')); + $requestedDate = (string) ($this->request->getGet('date') ?? ''); + $requestedTime = (string) ($this->request->getGet('time') ?? ''); + + $serviceData = $this->normalizeServiceIds($rawServices); + $serviceIds = $serviceData['ids']; + + $patient = null; + if ($patientId > 0) { + $patient = \Config\Database::connect() + ->table('patients p') + ->select(" + p.id as patient_id, + p.user_id, + p.phone, + p.dob, + p.gender as patient_gender, + u.first_name, + u.last_name, + u.email, + u.gender as user_gender, + u.status, + CASE + WHEN u.formatted_user_id IS NULL + OR u.formatted_user_id = '' + OR u.formatted_user_id LIKE 'PHY%' + THEN CONCAT('PT', LPAD(u.id, 7, '0')) + ELSE u.formatted_user_id + END as formatted_user_id + ") + ->join('users u', 'u.id = p.user_id', 'left') + ->where('p.id', $patientId) + ->get() + ->getRowArray(); + } + + $serviceMap = []; + $specializationModel = new SpecializationModel(); + foreach ($specializationModel->findAll() as $specialization) { + $id = (int) ($specialization['id'] ?? 0); + $name = trim((string) ($specialization['name'] ?? '')); + if ($id > 0 && $name !== '') { + $serviceMap[$id] = $name; + } + } + + $serviceNames = []; + foreach ($serviceIds as $serviceId) { + if (isset($serviceMap[$serviceId])) { + $serviceNames[] = $serviceMap[$serviceId]; + } + } + + if ($preferredDoctorName === '' && $preferredDoctorId > 0) { + $preferredDoctorName = \Config\Database::connect() + ->table('doctors d') + ->select("TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS doctor_name") + ->join('users u', 'u.id = d.user_id', 'left') + ->where('d.id', $preferredDoctorId) + ->get() + ->getRowArray()['doctor_name'] ?? ''; + $preferredDoctorName = trim((string) $preferredDoctorName); + } + + return view('admin/appointment_preview', [ + 'patient' => $patient, + 'patient_id' => $patientId, + 'services' => $serviceIds, + 'service_names' => array_values(array_unique($serviceNames)), + 'date' => $requestedDate, + 'time' => $requestedTime, + 'doctor' => $preferredDoctorName, + 'preview_id' => 0, + ]); + } + + public function deleteAppointment($id) + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $id = (int) $id; + if ($id < 1) { + return redirect()->to(site_url('admin/appointments'))->with('error', 'Invalid appointment.'); + } + + $appointmentModel = new AppointmentModel(); + $bookingModel = new DoctorBookingModel(); + $appointment = $appointmentModel->find($id); + + if (! $appointment) { + return redirect()->to(site_url('admin/appointments'))->with('error', 'Appointment not found.'); + } + + // Update booking status if exists + $bookingModel->where('appointment_id', $id)->set(['status' => 'cancelled'])->update(); + + // ...existing code... + $previewModel = new \App\Models\AppointmentPreviewModel(); + $previewModel + ->where('patient_id', (int) ($appointment['patient_id'] ?? 0)) + ->where('assigned_doctor_id', (int) ($appointment['doctor_id'] ?? 0)) + ->where('requested_date', $appointment['appointment_date'] ?? null) + ->where('requested_time', $appointment['appointment_time'] ?? null) + ->set([ + 'status' => 'cancelled', + ]) + ->update(); + + $appointmentModel->delete($id); + + $logModel = new ActivityLogModel(); + $logModel->log('delete_appointment', 'Admin deleted appointment ID ' . $id, 'appointment', $id); + + return redirect()->to(site_url('admin/appointments'))->with('success', 'Appointment cleared successfully.'); + } + + public function updateAppointmentStatus() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $id = (int) $this->request->getPost('appointment_id'); + $status = $this->request->getPost('status'); + + if ($id < 1 || empty($status)) { + return $this->response->setJSON(['success' => false, 'message' => 'Invalid data']); + } + + $appointmentModel = new AppointmentModel(); + $bookingModel = new DoctorBookingModel(); + + $appointment = $appointmentModel->find($id); + if (!$appointment) { + return $this->response->setJSON(['success' => false, 'message' => 'Appointment not found']); + } + + $status = AppointmentModel::normalizeStatus($status); + + $db = \Config\Database::connect(); + $db->transStart(); + + $appointmentModel->update($id, ['status' => $status]); + + // Sync with doctor_bookings table + $bookingModel->where('appointment_id', $id)->set(['status' => $status])->update(); + + $db->transComplete(); + + if ($db->transStatus() === false) { + return $this->response->setJSON(['success' => false, 'message' => 'Failed to update status']); + } + + $logModel = new ActivityLogModel(); + $logModel->log('update_appointment_status', "Admin updated appointment ID {$id} status to {$status}", 'appointment', $id); + + return $this->response->setJSON(['success' => true, 'message' => 'Status updated successfully']); + } + + public function createAppointment() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + // Check if this is a JSON request or form request + $isJson = $this->request->isAJAX() && stripos($this->request->getHeaderLine('Content-Type'), 'json') !== false; + + if ($isJson) { + return $this->createAppointmentFromJson(); + } + + // Get the appointment_datetime and split it + $appointmentDateTime = $this->request->getPost('appointment_datetime'); + + if(!$appointmentDateTime){ + return redirect()->back() + ->withInput() + ->with('error','Appointment date and time are required'); + } + + // Split the datetime-local format (YYYY-MM-DDTHH:mm) + $parts = explode('T', $appointmentDateTime); + $appointmentDate = $parts[0] ?? null; + $appointmentTime = isset($parts[1]) ? $this->normalizeAppointmentTime($parts[1]) : null; + + if(!$appointmentDate || !$appointmentTime){ + return redirect()->back() + ->withInput() + ->with('error','Invalid appointment date and time format'); + } + + if ($this->isPastAppointmentDateTime($appointmentDate, $appointmentTime)) { + return redirect()->back() + ->withInput() + ->with('error', 'Past date or time booking is not allowed.'); + } + + $appointmentModel = new AppointmentModel(); + + $serviceInput = $this->request->getPost('services'); + $serviceData = $this->normalizeServiceIds($serviceInput); + if($serviceData['ids'] === []){ + return redirect()->back() + ->withInput() + ->with('error','Please select at least one service'); + } + + if ($serviceData['has_duplicates']) { + return redirect()->back() + ->withInput() + ->with('error', 'Duplicate services are not allowed. Please select each service only once.'); + } + + $patientId = (int)$this->request->getPost('patient_id'); + $doctorId = $this->request->getPost('doctor_id') ? (int)$this->request->getPost('doctor_id') : null; + $note = $this->request->getPost('note'); + + $data = [ + 'patient_id' => $patientId, + 'doctor_id' => $doctorId, + 'services' => json_encode($serviceData['ids']), + 'created_by' => session()->get('id'), + 'appointment_date' => $appointmentDate, + 'appointment_time' => $appointmentTime, + 'note' => !empty($note) ? trim($note) : null + ]; + + // Only check for slot availability if doctor is selected + if($doctorId){ + if (! $this->doctorHasAvailabilityAt($doctorId, $appointmentDate, $appointmentTime)) { + return redirect()->back() + ->withInput() + ->with('error', 'Doctor is not available at the selected time.'); + } + + $taken = $appointmentModel + ->where('doctor_id',$doctorId) + ->where('appointment_date',$appointmentDate) + ->where('appointment_time',$appointmentTime) + ->whereIn('status',['pending','approved']) + ->first(); + + if($taken){ + return redirect()->back() + ->withInput() + ->with('error','Slot already booked'); + } + } + + if($appointmentModel->insert($data)){ + // Log activity + $activityLogModel = new ActivityLogModel(); + $activityLogModel->log( + 'appointment_created', + 'Appointment created for patient ID ' . $data['patient_id'] . ' with services ' . $data['services'], + null, + null, + session()->get('id') + ); + + return redirect() + ->to(site_url('admin/appointments')) + ->with('success','Appointment created successfully'); + } else { + return redirect()->back() + ->withInput() + ->with('error','Failed to create appointment'); + } + } + + private function createAppointmentFromJson() + { + $json = $this->request->getJSON(); + + $patientId = (int) ($json->patient_id ?? 0); + $doctorId = (int) ($json->doctor_id ?? 0); + $appointmentDate = (string) ($json->appointment_date ?? ''); + $appointmentTime = $this->normalizeAppointmentTime((string) ($json->appointment_time ?? '')); + $note = (string) ($json->note ?? ''); + $serviceData = $this->normalizeServiceIds($json->services ?? []); + + if (!$patientId || !$doctorId || !$appointmentDate || !$appointmentTime) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Invalid appointment data' + ]); + } + + if ($this->isPastAppointmentDateTime($appointmentDate, $appointmentTime)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Past date or time booking is not allowed' + ]); + } + + if ($serviceData['has_duplicates']) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Duplicate services are not allowed' + ]); + } + + $appointmentModel = new AppointmentModel(); + $bookingModel = new DoctorBookingModel(); + $patientModel = new PatientModel(); + $doctorModel = new DoctorModel(); + + // Verify patient exists + $patient = $patientModel->find($patientId); + if (!$patient) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Patient not found' + ]); + } + + // Verify doctor exists + $doctor = $doctorModel->find($doctorId); + if (!$doctor) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Doctor not found' + ]); + } + + if (! $this->doctorHasAvailabilityAt($doctorId, $appointmentDate, $appointmentTime)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Doctor is not available at the selected time' + ]); + } + + // Check if doctor is already booked using the source of truth (doctor_bookings table) + if ($bookingModel->isDoctorBooked($doctorId, $appointmentDate, $appointmentTime)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Doctor already booked for this time' + ]); + } + + $data = [ + 'patient_id' => $patientId, + 'doctor_id' => $doctorId, + 'appointment_date' => $appointmentDate, + 'appointment_time' => $appointmentTime, + 'status' => 'assigned', + 'note' => !empty($note) ? trim($note) : null + ]; + + if ($serviceData['ids'] !== []) { + $data['services'] = json_encode($serviceData['ids']); + } + + if ($appointmentModel->insert($data)) { + $appointmentId = (int) $appointmentModel->getInsertID(); + + // Sync with doctor_bookings table + $bookingModel->syncBooking($appointmentId, $doctorId, $appointmentDate, $appointmentTime, 'assigned', (int) session()->get('id')); + $previewId = (int) ($json->preview_id ?? 0); + + if ($previewId > 0) { + $doctorUser = $doctor['user_id'] ? (new UserModel())->find((int) $doctor['user_id']) : null; + $assignedDoctorFormattedId = $doctorUser && ! empty($doctorUser['formatted_user_id']) + ? $doctorUser['formatted_user_id'] + : 'PHY' . str_pad((string) ($doctor['user_id'] ?: $doctorId), 7, '0', STR_PAD_LEFT); + + // ...existing code... + $previewModel->update($previewId, [ + 'assigned_doctor_id' => $doctorId, + 'assigned_doctor_formatted_id' => $assignedDoctorFormattedId, + 'status' => 'booked', + ]); + } + + // Log activity + $activityLogModel = new ActivityLogModel(); + $activityLogModel->log( + 'appointment_created', + 'Appointment created for patient ID ' . $patientId . ' with doctor ID ' . $doctorId, + 'appointment', + $appointmentId, + session()->get('id') + ); + + return $this->response->setJSON([ + 'success' => true, + 'message' => 'Appointment created successfully' + ]); + } else { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Failed to create appointment' + ]); + } + } + public function addDoctor() { if ($r = $this->requireRole('admin')) { @@ -280,17 +758,18 @@ class Admin extends BaseController ]); } - public function addPatient() - { - if ($r = $this->requireRole('admin')) { - return $r; - } - - return view('admin/add_patient', [ - 'isEdit' => false, - ]); + public function addPatient($patientId = null) +{ + if ($r = $this->requireRole('admin')) { + return $r; } + return view('admin/add_patient', [ + 'isEdit' => false, + 'patient_id' => $patientId + ]); +} + public function editDoctor($encryptedId) { if ($r = $this->requireRole('admin')) { @@ -341,8 +820,9 @@ class Admin extends BaseController 'first_name' => 'required|min_length[2]|max_length[50]|alpha_space', 'last_name' => 'required|min_length[2]|max_length[50]|alpha_space', 'email' => 'required|valid_email|is_unique[users.email]', - 'experience_years' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[60]', - 'experience_months' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[11]', + 'gender' => 'required|in_list[Male,Female,Other]', + 'experience_years' => 'permit_empty|integer|greater_than_equal_to[0]|less_than_equal_to[60]', + 'experience_months' => 'permit_empty|integer|greater_than_equal_to[0]|less_than_equal_to[11]', 'fees' => 'permit_empty|decimal', ]; @@ -371,10 +851,10 @@ class Admin extends BaseController return redirect()->back()->withInput()->with('error', 'Selected specializations are too long. Please reduce them.'); } - $yearsRaw = (string) $this->request->getPost('experience_years'); - $monthsRaw = (string) $this->request->getPost('experience_months'); - $years = $yearsRaw === '' ? null : (int) $yearsRaw; - $months = $monthsRaw === '' ? null : (int) $monthsRaw; + $yearsRaw = $this->request->getPost('experience_years'); + $monthsRaw = $this->request->getPost('experience_months'); + $years = ($yearsRaw === '' || $yearsRaw === null) ? 0 : (int) $yearsRaw; + $months = ($monthsRaw === '' || $monthsRaw === null) ? 0 : (int) $monthsRaw; $experience = $this->buildExperienceString($years, $months); $generatedPassword = $this->generateAccountPassword(); @@ -385,6 +865,7 @@ class Admin extends BaseController 'first_name' => $firstName, 'last_name' => $lastName, 'email' => trim((string) $this->request->getPost('email')), + 'gender' => trim((string) $this->request->getPost('gender')), 'password' => password_hash($generatedPassword, PASSWORD_DEFAULT), 'role' => 'doctor', 'status' => 'active', @@ -525,6 +1006,7 @@ class Admin extends BaseController 'first_name' => 'required|min_length[2]|max_length[50]|alpha_space', 'last_name' => 'required|min_length[2]|max_length[50]|alpha_space', 'email' => 'required|valid_email|is_unique[users.email,id,' . $userId . ']', + 'gender' => 'required|in_list[Male,Female,Other]', 'experience_months' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[11]', 'experience_years' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[60]', 'fees' => 'permit_empty|decimal', @@ -554,10 +1036,10 @@ class Admin extends BaseController return redirect()->back()->withInput()->with('error', 'Selected specializations are too long. Please reduce them.'); } - $yearsRaw = (string) $this->request->getPost('experience_years'); - $monthsRaw = (string) $this->request->getPost('experience_months'); - $years = $yearsRaw === '' ? null : (int) $yearsRaw; - $months = $monthsRaw === '' ? null : (int) $monthsRaw; + $yearsRaw = $this->request->getPost('experience_years'); + $monthsRaw = $this->request->getPost('experience_months'); + $years = ($yearsRaw === '' || $yearsRaw === null) ? 0 : (int) $yearsRaw; + $months = ($monthsRaw === '' || $monthsRaw === null) ? 0 : (int) $monthsRaw; $experience = $this->buildExperienceString($years, $months); $db->transStart(); @@ -566,6 +1048,7 @@ class Admin extends BaseController 'first_name' => $firstName, 'last_name' => $lastName, 'email' => trim((string) $this->request->getPost('email')), + 'gender' => trim((string) $this->request->getPost('gender')), ]); $doctorModel->update($doctorData['doctor']['id'], [ @@ -683,6 +1166,7 @@ class Admin extends BaseController foreach ($doctors as $doc) { $payload[] = [ + 'doctor_id' => (int) ($doc->doctor_id ?? 0), 'user_id' => (int) ($doc->user_id ?? 0), 'formatted_user_id' => $doc->formatted_user_id ?? null, 'name' => $doc->name ?? null, @@ -728,4 +1212,514 @@ class Admin extends BaseController 'data' => $payload, ]); } + public function getAvailableDoctorsForPatient() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + helper('encryption'); + + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(403); + } + + $date = $this->request->getPost('date'); + $time = $this->request->getPost('time'); + $previewId = (int) $this->request->getPost('preview_id'); + $specializationIds = $this->request->getPost('specialization_id'); + + if (is_string($specializationIds)) { + $decoded = json_decode($specializationIds, true); + if (is_array($decoded)) { + $specializationIds = $decoded; + } elseif ($specializationIds !== '') { + $specializationIds = preg_split('/\s*,\s*/', $specializationIds, -1, PREG_SPLIT_NO_EMPTY); + } else { + $specializationIds = []; + } + } + + if (! is_array($specializationIds) || empty($specializationIds)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'No specialization selected', + 'data' => [], + 'doctors' => [], + ]); + } + + $specializationIds = array_values(array_filter(array_map('intval', $specializationIds), fn ($id) => $id > 0)); + + if (empty($specializationIds)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'No valid specialization selected', + 'data' => [], + 'doctors' => [], + ]); + } + + $dayNumber = null; + $requestedTime = $this->normalizeAppointmentTime((string) $time); + if (! empty($date)) { + try { + $dateObj = new \DateTime($date); + $dayNumber = (int) $dateObj->format('N'); + } catch (\Exception $e) { + $dayNumber = null; + } + } + + $db = \Config\Database::connect(); + + // Get doctors with that specialization + $builder = $db->table('doctors d'); + + $builder->select("\n d.id,\n d.user_id,\n d.experience,\n CONCAT(u.first_name,' ',u.last_name) as name,\n u.gender\n "); + $builder->join('users u', 'u.id=d.user_id'); + $builder->join('doctor_specializations ds', 'ds.doctor_id=d.id'); + $builder->whereIn('ds.specialization_id', $specializationIds); + $builder->groupBy(['d.id', 'd.user_id', 'd.experience', 'u.first_name', 'u.last_name', 'u.gender']); + $builder->orderBy('u.first_name', 'ASC'); + + $doctors = $builder->get()->getResultArray(); + + $appointmentModel = new AppointmentModel(); + $bookingModel = new DoctorBookingModel(); + $availableDoctors = []; + + foreach ($doctors as $doctor) { + + // Format doctor ID as PHY + 7-digit user_id (from users table) + $formattedDoctorId = null; + if (!empty($doctor['user_id'])) { + $formattedDoctorId = 'PHY' . str_pad($doctor['user_id'], 7, '0', STR_PAD_LEFT); + } else { + $formattedDoctorId = 'PHY' . str_pad($doctor['id'], 7, '0', STR_PAD_LEFT); + } + $doctor['formatted_doctor_id'] = $formattedDoctorId; + $doctor['doctor_edit_token'] = encrypt_id($doctor['user_id'] ?? 0); + + $doctor['is_available'] = false; + $doctor['status'] = 'No Slots'; + $doctor['time_slots'] = []; + $doctor['available_days'] = []; + $doctor['available_days_text'] = 'No availability'; + + $availableDayRows = $db->table('doctor_availability da') + ->distinct() + ->select('da.day_number, days.day_name') + ->join('days', 'days.day_number = da.day_number', 'left') + ->where('da.doctor_id', $doctor['id']) + ->where('da.status', 1) + ->orderBy('da.day_number', 'ASC') + ->get() + ->getResultArray(); + + foreach ($availableDayRows as $availableDay) { + $availableDayNumber = (int) ($availableDay['day_number'] ?? 0); + $dayName = trim((string) ($availableDay['day_name'] ?? '')); + + if ($dayName === '') { + $dayName = [ + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + 7 => 'Sunday', + ][$availableDayNumber] ?? ''; + } + + if ($dayName !== '') { + $doctor['available_days'][] = $dayName; + } + } + + $doctor['available_days'] = array_values(array_unique($doctor['available_days'])); + if ($doctor['available_days'] !== []) { + $doctor['available_days_text'] = implode(', ', $doctor['available_days']); + } + + // Fetch ALL availability slots for this doctor across ALL days + $allSlotsBuilder = $db->table('doctor_availability da') + ->select('da.start_time, da.end_time, da.day_number, days.day_name') + ->join('days', 'days.day_number = da.day_number', 'left') + ->where('da.doctor_id', $doctor['id']) + ->where('da.status', 1) + ->orderBy('da.day_number', 'ASC') + ->orderBy('da.start_time', 'ASC'); + + $allSlots = $allSlotsBuilder->get()->getResultArray(); + $doctor['all_slots'] = []; + + foreach ($allSlots as $slot) { + $dayName = trim((string) ($slot['day_name'] ?? '')); + if ($dayName === '') { + $dayName = [ + 1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', + 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 7 => 'Sunday', + ][$slot['day_number']] ?? 'Unknown'; + } + + $doctor['all_slots'][] = [ + 'day' => $dayName, + 'start_time' => $slot['start_time'], + 'end_time' => $slot['end_time'], + 'start_time_formatted' => date('h:i A', strtotime($slot['start_time'])), + 'end_time_formatted' => date('h:i A', strtotime($slot['end_time'])), + ]; + } + + // Fetch availability slots for this doctor on the SPECIFIED day and time for booking status + if ($dayNumber !== null) { + $availabilityBuilder = $db->table('doctor_availability') + ->where('doctor_id', $doctor['id']) + ->where('day_number', $dayNumber) + ->where('status', 1) + ->orderBy('start_time', 'ASC'); + + if ($requestedTime !== null) { + $availabilityBuilder + ->where('start_time <=', $requestedTime) + ->where('end_time >', $requestedTime); + } + + $availability = $availabilityBuilder->get()->getResultArray(); + + if ($availability) { + foreach ($availability as $slot) { + $doctor['time_slots'][] = [ + 'start_time' => $slot['start_time'], + 'end_time' => $slot['end_time'], + 'start_time_formatted' => date('h:i A', strtotime($slot['start_time'])), + 'end_time_formatted' => date('h:i A', strtotime($slot['end_time'])), + ]; + } + } + } + + $doctor['is_available'] = $doctor['time_slots'] !== []; + + if ($doctor['is_available']) { + $doctor['status'] = 'Available'; + } else { + $doctor['status'] = 'Not Available'; + } + + if ($doctor['is_available'] && $requestedTime !== null && ! empty($date)) { + if ($bookingModel->isDoctorBooked($doctor['id'], $date, $requestedTime)) { + $doctor['is_available'] = false; + $doctor['status'] = 'Booked'; + } + } + + $availableDoctors[] = $doctor; + } + + // Fetch specializations for all available doctors + if (!empty($availableDoctors)) { + foreach ($availableDoctors as &$doc) { + $doc['specializations'] = $db->table('doctor_specializations ds') + ->select('s.name, s.bg_color, s.text_color') + ->join('specializations s', 's.id = ds.specialization_id') + ->where('ds.doctor_id', $doc['id']) + ->get() + ->getResultArray(); + } + } + + if ($previewId > 0) { + $availableDaysSnapshot = []; + $availableSlotsSnapshot = []; + + foreach ($availableDoctors as $doctor) { + $doctorId = (int) ($doctor['id'] ?? 0); + if ($doctorId < 1) { + continue; + } + + $snapshotKey = (string) ($doctor['formatted_doctor_id'] ?? $doctorId); + $availableDaysSnapshot[$snapshotKey] = [ + 'doctor_id' => $doctorId, + 'name' => $doctor['name'] ?? null, + 'days' => $doctor['available_days'] ?? [], + ]; + $availableSlotsSnapshot[$snapshotKey] = [ + 'doctor_id' => $doctorId, + 'name' => $doctor['name'] ?? null, + 'slots' => $doctor['time_slots'] ?? [], + ]; + } + + // ...existing code... + $previewModel->update($previewId, [ + 'available_days_snapshot' => $availableDaysSnapshot, + 'available_slots_snapshot' => $availableSlotsSnapshot, + 'status' => 'viewed', + ]); + } + + return $this->response->setJSON([ + 'success' => true, + 'message' => 'Doctors loaded successfully', + 'day_number' => $dayNumber, + 'data' => $availableDoctors, + 'doctors' => $availableDoctors, + ]); + } + + public function checkDoctorStatus() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $doctorId = (int) $this->request->getPost('doctor_id'); + $date = $this->request->getPost('date'); + $time = $this->request->getPost('time'); + + if ($doctorId <= 0 || empty($date) || empty($time)) { + return $this->response->setJSON(['status' => 'Available']); + } + + $requestedTime = $this->normalizeAppointmentTime((string) $time); + + $bookingModel = new DoctorBookingModel(); + $isBooked = $bookingModel->isDoctorBooked($doctorId, $date, $requestedTime); + + return $this->response->setJSON([ + 'status' => $isBooked ? 'Booked' : 'Available' + ]); + } + + public function getSpecializations() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + if (!$this->request->isAJAX()) { + return $this->response->setStatusCode(403); + } + + $specializationModel = new SpecializationModel(); + $specializations = $specializationModel->orderBy('name', 'ASC')->findAll(); + + return $this->response->setJSON([ + 'success' => true, + 'specializations' => $specializations + ]); + } + public function appointmentForm($patientId) +{ + if ($r = $this->requireRole('admin')) { + return $r; + } + + $patientModel = new PatientModel(); + $userModel = new UserModel(); + + $patient = $patientModel->findByUserId((int)$patientId); + + if (!$patient) { + return redirect() + ->to(site_url('admin/patients')) + ->with('error','Patient not found'); + } + + $user = $userModel->find((int)$patientId); + + $formattedPatientId = + (!empty($user['formatted_user_id']) && + strpos($user['formatted_user_id'],'PHY') !== 0) + + ? $user['formatted_user_id'] + + : 'PT' . str_pad($user['id'],7,'0',STR_PAD_LEFT); + + return view('admin/add_appointment',[ + 'patient' => $patient, + 'user' => $user, + 'formattedPatientId' => $formattedPatientId + ]); +} +public function doctorSearch() +{ + if ($r = $this->requireRole('admin')) { + return $r; + } + + $doctorModel = new \App\Models\DoctorModel(); + $availabilityModel = new \App\Models\AvailabilityModel(); + $specializationModel = new \App\Models\SpecializationModel(); + + $specializations = $specializationModel->select('name')->orderBy('name', 'ASC')->findAll(); + + $doctors = $doctorModel + ->select('doctors.id, doctors.user_id, doctors.specialization, doctors.experience, doctors.fees, users.first_name, users.last_name') + ->join('users', 'users.id = doctors.user_id') + ->findAll(); + $dayNumberToNextDate = []; + for ($i = 1; $i <= 7; $i++) { + $todayDayNum = (int) date('N'); + $daysAhead = ($i - $todayDayNum + 7) % 7; + $dayNumberToNextDate[$i] = date('Y-m-d', strtotime("+{$daysAhead} days")); + } + + $doctorsData = []; + foreach ($doctors as $doctor) { + $db = \Config\Database::connect(); + $doctorSpecializations = $db->table('doctor_specializations ds') + ->select('s.name,s.bg_color,s.text_color') + ->join('specializations s','s.id=ds.specialization_id') + ->where('ds.doctor_id',$doctor['id']) + ->get() + ->getResultArray(); + + // Create fresh instance for each doctor to avoid query builder state issues + $availabilityModel = new \App\Models\AvailabilityModel(); + + $availability = $availabilityModel + ->where('doctor_id', $doctor['id']) + ->where('status', 1) + ->orderBy('day_number', 'ASC') + ->orderBy('start_time', 'ASC') + ->findAll(); + + // Log for debugging + log_message('info', "Doctor: {$doctor['first_name']} {$doctor['last_name']} (ID: {$doctor['id']}) - Availability records: " . count($availability)); + + $timeSlots = []; + $availabilityByDate = []; + + foreach ($availability as $avail) { + $dayNum = (int) $avail['day_number']; + + // ✅ CORRECT: map the weekday number to the next real calendar date + $date = $dayNumberToNextDate[$dayNum] + ?? date('Y-m-d', strtotime("+{$dayNum} days")); // fallback + + // Human-readable label e.g. "Mon, Apr 28" + $dateLabel = date('D, M j', strtotime($date)); + + // Safely format the start time + $startTimeFormatted = false; + if (!empty($avail['start_time'])) { + + $parsed = strtotime($avail['start_time']); + + if ($parsed !== false && !empty($avail['end_time'])) { + + $endParsed = strtotime($avail['end_time']); + + if($endParsed !== false){ + + $timeSlots[] = [ + 'day' => date('D', strtotime($date)), + 'start' => date('g:ia', $parsed), + 'end' => date('g:ia', $endParsed) + ]; + + } + } + } + + if (!isset($availabilityByDate[$date])) { + $availabilityByDate[$date] = [ + 'label' => $dateLabel, + 'day' => $dayNum, + 'slots' => [], + ]; + } + + // Only add slot if times were parsed successfully + if ($parsed !== false && !empty($avail['end_time'])) { + $endParsed = strtotime($avail['end_time']); + if ($endParsed !== false) { + $availabilityByDate[$date]['slots'][] = [ + 'start' => date('h:i A', $parsed), + 'end' => date('h:i A', $endParsed), + 'start_24' => date('H:i', $parsed), + 'end_24' => date('H:i', $endParsed), + ]; + } + } + } + + // Sort by date ascending so the nearest day shows first + ksort($availabilityByDate); + + $doctorsData[] = [ + 'id' => $doctor['id'], + 'first_name' => $doctor['first_name'], + 'last_name' => $doctor['last_name'], + 'specializations' => $doctorSpecializations, + 'experience' => $doctor['experience'], + 'fees' => $doctor['fees'], + 'timeSlots' => array_slice($timeSlots, 0, 4), + 'extraSlots' => array_slice($timeSlots, 4), + 'availabilityByDate' => $availabilityByDate, + 'hasAvailability' => !empty($availability), // explicit flag for the view + 'avatar' => strtoupper( + substr($doctor['first_name'] ?? '', 0, 1) . + substr($doctor['last_name'] ?? '', 0, 1) + ), + ]; + } + + return view('admin/search_doctors', [ + 'doctors' => $doctorsData, + 'specializations' => $specializations, + ]); +} + public function appointmentsData() + { + $appointmentModel = new \App\Models\AppointmentModel(); + $specializationModel = new \App\Models\SpecializationModel(); + + $appointments = $appointmentModel->getAdminAppointments(); // better than findAll() + $specializations = $specializationModel->findAll(); + $serviceMap = []; + + foreach ($specializations as $specialization) { + $serviceMap[(int) ($specialization['id'] ?? 0)] = trim((string) ($specialization['name'] ?? '')); + } + + $data = []; + + foreach ($appointments as $a) { + $serviceIds = json_decode((string) ($a->services ?? '[]'), true); + $serviceNames = []; + $doctorName = trim((string) ($a->doctor_name ?? '')); + + if (is_array($serviceIds)) { + foreach ($serviceIds as $serviceId) { + $serviceId = (int) $serviceId; + if ($serviceId > 0 && ! empty($serviceMap[$serviceId])) { + $serviceNames[] = $serviceMap[$serviceId]; + } + } + } + + $data[] = [ + 'id' => $a->id, + 'sl_no' => 'APT' . str_pad((string) ($a->id ?? 0), 7, '0', STR_PAD_LEFT), + 'patient_id' => $a->patient_id, + 'services' => $serviceIds, + 'patient_name' => $a->patient_name, + 'service_request' => $serviceNames !== [] ? implode(', ', array_unique($serviceNames)) : 'Not Specified', + 'doctor_id' => (int) ($a->doctor_id ?? 0), + 'doctor_name' => $doctorName !== '' ? $doctorName : 'Not Assigned', + 'appointment_date' => $a->appointment_date, + 'appointment_time' => $a->appointment_time, + 'status' => $a->status ?: 'pending' + ]; + } + + return $this->response->setJSON(['data' => $data]); + } + // ...existing code... } diff --git a/app/Controllers/AvailabilityController.php b/app/Controllers/AvailabilityController.php new file mode 100644 index 0000000..f8d060d --- /dev/null +++ b/app/Controllers/AvailabilityController.php @@ -0,0 +1,122 @@ +request->getPost('doctor_id'); + $availabilityJson = $this->request->getPost('availability_json'); + + // Validation + if (!$doctorId || !$availabilityJson) { + return redirect()->back() + ->with('error', 'Doctor ID or availability data missing'); + } + + $availability = json_decode($availabilityJson, true); + foreach ($availability as $day => $slots) { + foreach ($slots as $slot) { + + $start = $slot['start']; + $end = $slot['end']; + + // Start >= End + if ($start >= $end) { + return redirect()->back()->with('error', 'Invalid time slot: Start must be before End'); + } + + // Less than 1 hour + $startTime = strtotime($start); + $endTime = strtotime($end); + + if (($endTime - $startTime) < 3600) { + return redirect()->back()->with('error', 'Minimum slot must be 1 hour'); + } + } + } + // VALIDATION END + + + if (!is_array($availability) || empty($availability)) { + return redirect()->back() + ->with('error', 'No availability slots provided'); + } + + $model = new AvailabilityModel(); + + // Deactivate current active schedule for this doctor + $deactivated = $model->where('doctor_id', $doctorId) + ->where('status', 1) + ->set(['status' => 0]) + ->update(); + + log_message('info', "Deactivated {$deactivated} old availability records for doctor {$doctorId}"); + + // Generate ONE batch id for this save + $batchId = strtoupper(bin2hex(random_bytes(16))); + + $insertedCount = 0; + + // Insert new active schedule + foreach ($availability as $day => $slots) { + foreach ($slots as $slot) { + $inserted = $model->insert([ + 'doctor_id' => $doctorId, + 'batch_id' => $batchId, + 'day_number' => (int) $day, + 'start_time' => $slot['start'], + 'end_time' => $slot['end'], + 'status' => 1 + ]); + + if ($inserted) { + $insertedCount++; + } + } + } + + log_message('info', "Inserted {$insertedCount} new availability records for doctor {$doctorId}"); + + if ($insertedCount === 0) { + return redirect()->back() + ->with('warning', 'No availability slots were saved. Please try again.'); + } + + return redirect()->back() + ->with('success', "Availability updated ({$insertedCount} slots saved)"); +} + public function getAvailability($doctorId) + { + $model = new AvailabilityModel(); + + $slots = $model->where('doctor_id', $doctorId) + ->where('status', 1) + ->orderBy('day_number', 'ASC') + ->orderBy('start_time', 'ASC') + ->findAll(); + + return $this->response->setJSON($slots); + } + + /** + * Diagnostic endpoint to verify availability data for all doctors + */ + public function diagnostic() + { + $model = new AvailabilityModel(); + + $allRecords = $model->where('status', 1) + ->orderBy('doctor_id', 'ASC') + ->orderBy('day_number', 'ASC') + ->findAll(); + + return $this->response->setJSON([ + 'total_active_records' => count($allRecords), + 'records' => $allRecords + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/Doctor.php b/app/Controllers/Doctor.php index 702ed03..7e20ce8 100644 --- a/app/Controllers/Doctor.php +++ b/app/Controllers/Doctor.php @@ -197,6 +197,12 @@ class Doctor extends BaseController $status = AppointmentModel::normalizeStatus($status); $appointmentModel->update($appointmentId, ['status' => $status]); + // Sync with doctor_bookings table + $bookingModel = new \App\Models\DoctorBookingModel(); + $bookingModel->where('appointment_id', $appointmentId) + ->set(['status' => $status]) + ->update(); + $logModel = new ActivityLogModel(); $logModel->log('update_appointment_status', "Doctor changed appointment status to {$status}", 'appointment', $appointmentId); diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index 5934333..a9547c8 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -9,3 +9,23 @@ class Home extends BaseController return view('welcome_message'); } } + + +// namespace App\Controllers; + +// class Home extends BaseController +// { +// public function index(): string +// { +// $model = new \App\Models\AvailabilityModel(); + +// $model->save([ +// 'doctor_id' => 1, +// 'day_number'=> 1, +// 'start_time'=> '10:00:00', +// 'end_time' => '12:00:00' +// ]); + +// return "Inserted Successfully"; +// } +// } \ No newline at end of file diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index e85b71f..3dccb76 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -15,6 +15,17 @@ class Patient extends BaseController return preg_match('/^\d{2}:\d{2}$/', $time) ? $time . ':00' : $time; } + private function isPastAppointmentDateTime(string $date, string $time): bool + { + $appointmentDateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . ' ' . $time); + + if (! $appointmentDateTime || $appointmentDateTime->format('Y-m-d H:i:s') !== $date . ' ' . $time) { + return true; + } + + return $appointmentDateTime < new \DateTimeImmutable('now'); + } + public function dashboard() { if ($r = $this->requireRole('patient')) { @@ -81,11 +92,16 @@ class Patient extends BaseController } $appointmentTime = $this->normalizeAppointmentTime((string) $this->request->getPost('time')); + $appointmentDate = (string) $this->request->getPost('date'); + + if ($this->isPastAppointmentDateTime($appointmentDate, $appointmentTime)) { + return redirect()->back()->withInput()->with('error', 'Past date or time booking is not allowed.'); + } $data = [ 'patient_id' => $patient['id'], 'doctor_id' => (int) $this->request->getPost('doctor_id'), - 'appointment_date' => $this->request->getPost('date'), + 'appointment_date' => $appointmentDate, 'appointment_time' => $appointmentTime, ]; diff --git a/app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php b/app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php index aaaa1f4..79db3da 100644 --- a/app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php +++ b/app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php @@ -10,7 +10,7 @@ class BackfillFormattedUserIdsByRole extends Migration { $this->db->query(" UPDATE users - SET formatted_user_id = CONCAT('PAT', LPAD(id, 7, '0')) + SET formatted_user_id = CONCAT('PT', LPAD(id, 7, '0')) WHERE role = 'patient' "); @@ -25,7 +25,7 @@ class BackfillFormattedUserIdsByRole extends Migration { $this->db->query(" UPDATE users - SET formatted_user_id = CONCAT('PHY', LPAD(id, 7, '0')) + SET formatted_user_id = CONCAT('PT', LPAD(id, 7, '0')) WHERE role = 'patient' "); } diff --git a/app/Database/Migrations/2026-04-20-094846_AddServiceAndNoteToAppointments.php b/app/Database/Migrations/2026-04-20-094846_AddServiceAndNoteToAppointments.php new file mode 100644 index 0000000..07c7641 --- /dev/null +++ b/app/Database/Migrations/2026-04-20-094846_AddServiceAndNoteToAppointments.php @@ -0,0 +1,24 @@ +forge->addColumn('appointments', [ + 'note' => [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'status' + ] + ]); + } + + public function down() + { + $this->forge->dropColumn('appointments', 'note'); + } +} diff --git a/app/Database/Migrations/2026-04-22-000000_CreateDaysTable.php b/app/Database/Migrations/2026-04-22-000000_CreateDaysTable.php new file mode 100644 index 0000000..324323c --- /dev/null +++ b/app/Database/Migrations/2026-04-22-000000_CreateDaysTable.php @@ -0,0 +1,27 @@ +forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'day_number' => ['type' => 'INT', 'unsigned' => true, 'comment' => '1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday, 7=Sunday'], + 'day_name' => ['type' => 'VARCHAR', 'constraint' => 20], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('day_number'); + $this->forge->createTable('days', true); + } + + public function down(): void + { + $this->forge->dropTable('days', true); + } +} diff --git a/app/Database/Migrations/2026-04-28-000000_AddGenderToUsers.php b/app/Database/Migrations/2026-04-28-000000_AddGenderToUsers.php new file mode 100644 index 0000000..4ddeb14 --- /dev/null +++ b/app/Database/Migrations/2026-04-28-000000_AddGenderToUsers.php @@ -0,0 +1,20 @@ +forge->addColumn('users', [ + 'gender' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true, 'after' => 'status'], + ]); + } + + public function down(): void + { + $this->forge->dropColumn('users', 'gender'); + } +} diff --git a/app/Database/Migrations/2026-05-05-000000_CreateAppointmentPreviews.php b/app/Database/Migrations/2026-05-05-000000_CreateAppointmentPreviews.php new file mode 100644 index 0000000..420a056 --- /dev/null +++ b/app/Database/Migrations/2026-05-05-000000_CreateAppointmentPreviews.php @@ -0,0 +1,28 @@ +forge->addField([ + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('appointment_id'); + $this->forge->addKey('patient_id'); + $this->forge->addKey('selected_doctor_id'); + $this->forge->addKey(['requested_date', 'requested_time']); + $this->forge->addKey('status'); + $this->forge->addKey('expires_at'); + $this->forge->addUniqueKey('preview_token'); + } + + public function down(): void + { + } + // Removed migration for appointment_previews table +} diff --git a/app/Database/Migrations/2026-05-05-000001_PreventDuplicateAppointmentSlots.php b/app/Database/Migrations/2026-05-05-000001_PreventDuplicateAppointmentSlots.php new file mode 100644 index 0000000..6875cc4 --- /dev/null +++ b/app/Database/Migrations/2026-05-05-000001_PreventDuplicateAppointmentSlots.php @@ -0,0 +1,24 @@ +query( + 'ALTER TABLE `appointments` ADD UNIQUE KEY `appointments_doctor_slot_unique` (`doctor_id`, `appointment_date`, `appointment_time`)' + ); + } + + public function down(): void + { + $db = \Config\Database::connect(); + + $db->query('ALTER TABLE `appointments` DROP INDEX `appointments_doctor_slot_unique`'); + } +} diff --git a/app/Database/Migrations/2026-05-05-000002_UseEnumStatusForAppointmentPreviews.php b/app/Database/Migrations/2026-05-05-000002_UseEnumStatusForAppointmentPreviews.php new file mode 100644 index 0000000..7c8e419 --- /dev/null +++ b/app/Database/Migrations/2026-05-05-000002_UseEnumStatusForAppointmentPreviews.php @@ -0,0 +1,23 @@ +forge->modifyColumn('appointment_previews', [ + 'status' => [ + 'name' => 'status', + 'type' => 'ENUM', + 'constraint' => ['draft', 'viewed', 'selected', 'booked', 'expired', 'cancelled'], + 'default' => 'draft', + 'null' => false, + ], + ]); + } + + // Removed migration for appointment_previews table +} diff --git a/app/Database/Migrations/2026-05-05-000003_UseFormattedIdsForAppointmentPreviews.php b/app/Database/Migrations/2026-05-05-000003_UseFormattedIdsForAppointmentPreviews.php new file mode 100644 index 0000000..60fe7c4 --- /dev/null +++ b/app/Database/Migrations/2026-05-05-000003_UseFormattedIdsForAppointmentPreviews.php @@ -0,0 +1,139 @@ +fieldExists('appointment_id', 'appointment_previews')) { + $db->query('ALTER TABLE `appointment_previews` DROP INDEX `appointment_id`'); + $this->forge->dropColumn('appointment_previews', 'appointment_id'); + } + + if (! $db->fieldExists('patient_formatted_user_id', 'appointment_previews')) { + $this->forge->addColumn('appointment_previews', [ + 'patient_formatted_user_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + 'after' => 'patient_id', + ], + ]); + } + + if ($db->fieldExists('selected_doctor_id', 'appointment_previews')) { + $db->query('ALTER TABLE `appointment_previews` DROP INDEX `selected_doctor_id`'); + $this->forge->modifyColumn('appointment_previews', [ + 'selected_doctor_id' => [ + 'name' => 'assigned_doctor_id', + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + ], + ]); + } elseif (! $db->fieldExists('assigned_doctor_id', 'appointment_previews')) { + $this->forge->addColumn('appointment_previews', [ + 'assigned_doctor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + 'after' => 'requested_time', + ], + ]); + } + + if (! $db->fieldExists('assigned_doctor_formatted_id', 'appointment_previews')) { + $this->forge->addColumn('appointment_previews', [ + 'assigned_doctor_formatted_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + 'after' => 'assigned_doctor_id', + ], + ]); + } + + if ($db->fieldExists('candidate_doctor_ids', 'appointment_previews')) { + $this->forge->dropColumn('appointment_previews', 'candidate_doctor_ids'); + } + + $this->addIndexIfMissing('assigned_doctor_id', 'assigned_doctor_id'); + $this->addIndexIfMissing('patient_formatted_user_id', 'patient_formatted_user_id'); + $this->addIndexIfMissing('assigned_doctor_formatted_id', 'assigned_doctor_formatted_id'); + } + + public function down(): void + { + $db = \Config\Database::connect(); + + if (! $db->fieldExists('appointment_id', 'appointment_previews')) { + $this->forge->addColumn('appointment_previews', [ + 'appointment_id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + 'after' => 'id', + ], + ]); + $this->addIndexIfMissing('appointment_id', 'appointment_id'); + } + + if ($db->fieldExists('assigned_doctor_id', 'appointment_previews')) { + $this->dropIndexIfExists('assigned_doctor_id'); + $this->forge->modifyColumn('appointment_previews', [ + 'assigned_doctor_id' => [ + 'name' => 'selected_doctor_id', + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + ], + ]); + $this->addIndexIfMissing('selected_doctor_id', 'selected_doctor_id'); + } + + if (! $db->fieldExists('candidate_doctor_ids', 'appointment_previews')) { + $this->forge->addColumn('appointment_previews', [ + 'candidate_doctor_ids' => [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'selected_doctor_id', + ], + ]); + } + + foreach (['patient_formatted_user_id', 'assigned_doctor_formatted_id'] as $column) { + if ($db->fieldExists($column, 'appointment_previews')) { + $this->dropIndexIfExists($column); + $this->forge->dropColumn('appointment_previews', $column); + } + } + } + + private function addIndexIfMissing(string $indexName, string $columnName): void + { + $db = \Config\Database::connect(); + $escapedIndex = $db->escapeIdentifiers($indexName); + $escapedColumn = $db->escapeIdentifiers($columnName); + $exists = $db->query("SHOW INDEX FROM `appointment_previews` WHERE Key_name = " . $db->escape($indexName))->getRowArray(); + + if (! $exists) { + $db->query("ALTER TABLE `appointment_previews` ADD INDEX {$escapedIndex} ({$escapedColumn})"); + } + } + + private function dropIndexIfExists(string $indexName): void + { + $db = \Config\Database::connect(); + $exists = $db->query("SHOW INDEX FROM `appointment_previews` WHERE Key_name = " . $db->escape($indexName))->getRowArray(); + + if ($exists) { + $escapedIndex = $db->escapeIdentifiers($indexName); + $db->query("ALTER TABLE `appointment_previews` DROP INDEX {$escapedIndex}"); + } + } +} diff --git a/app/Database/Migrations/2026-05-07-110807_CreateDoctorBookingsTable.php b/app/Database/Migrations/2026-05-07-110807_CreateDoctorBookingsTable.php new file mode 100644 index 0000000..1d53bac --- /dev/null +++ b/app/Database/Migrations/2026-05-07-110807_CreateDoctorBookingsTable.php @@ -0,0 +1,52 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => false, // Match appointments.id + 'auto_increment' => true, + ], + 'appointment_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => false, // Match appointments.id + ], + 'doctor_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => false, // Match doctors.id + ], + 'booking_date' => [ + 'type' => 'DATE', + ], + 'booking_time' => [ + 'type' => 'TIME', + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['assigned', 'completed', 'cancelled'], + 'default' => 'assigned', + ], + 'created_at datetime default current_timestamp', + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey(['doctor_id', 'booking_date', 'booking_time']); + $this->forge->addForeignKey('appointment_id', 'appointments', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('doctor_bookings'); + } + + public function down() + { + $this->forge->dropTable('doctor_bookings'); + } +} diff --git a/app/Database/Migrations/2026-05-07-120525_AddCreatedByToDoctorBookings.php b/app/Database/Migrations/2026-05-07-120525_AddCreatedByToDoctorBookings.php new file mode 100644 index 0000000..0346235 --- /dev/null +++ b/app/Database/Migrations/2026-05-07-120525_AddCreatedByToDoctorBookings.php @@ -0,0 +1,26 @@ +forge->addColumn('doctor_bookings', [ + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => false, + 'null' => true, + 'after' => 'status', + ], + ]); + } + + public function down() + { + $this->forge->dropColumn('doctor_bookings', 'created_by'); + } +} diff --git a/app/Database/Seeds/DaysSeeder.php b/app/Database/Seeds/DaysSeeder.php new file mode 100644 index 0000000..324c1d5 --- /dev/null +++ b/app/Database/Seeds/DaysSeeder.php @@ -0,0 +1,23 @@ + 1, 'day_name' => 'Monday'], + ['day_number' => 2, 'day_name' => 'Tuesday'], + ['day_number' => 3, 'day_name' => 'Wednesday'], + ['day_number' => 4, 'day_name' => 'Thursday'], + ['day_number' => 5, 'day_name' => 'Friday'], + ['day_number' => 6, 'day_name' => 'Saturday'], + ['day_number' => 7, 'day_name' => 'Sunday'], + ]; + + $this->db->table('days')->insertBatch($data); + } +} diff --git a/app/Models/ActivityLogModel.php b/app/Models/ActivityLogModel.php index 382aed6..e4ee5dc 100644 --- a/app/Models/ActivityLogModel.php +++ b/app/Models/ActivityLogModel.php @@ -3,6 +3,7 @@ namespace App\Models; use CodeIgniter\Model; +use CodeIgniter\I18n\Time; class ActivityLogModel extends Model { @@ -12,7 +13,7 @@ class ActivityLogModel extends Model protected $returnType = 'array'; protected $useSoftDeletes = false; protected $protectFields = true; - protected $allowedFields = ['ip', 'action', 'description', 'activity_user_id', 'activity_user_type', 'target_user_id', 'target_user_type', 'activity_page', 'activity_at']; + protected $allowedFields = ['activity_user_id', 'activity_user_type', 'action', 'description', 'target_user_id', 'target_user_type', 'ip', 'activity_page', 'activity_at']; protected bool $allowEmptyInserts = false; protected bool $updateOnlyChanged = true; @@ -22,7 +23,7 @@ class ActivityLogModel extends Model protected $useTimestamps = false; protected $dateFormat = 'datetime'; - protected $createdField = 'activity_at'; + protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; protected $deletedField = 'deleted_at'; @@ -59,9 +60,9 @@ class ActivityLogModel extends Model 'activity_user_id' => $actorId, 'activity_user_type' => $actorRole, 'target_user_id' => $targetId, - 'target_user_type' => $targetUserType, - 'activity_page' => $request->getPath(), - 'activity_at' => date('Y-m-d H:i:s'), + 'target_user_type' => $targetType, + 'activity_page' => $this->request ? $this->request->getPath() : null, + 'activity_at' => gmdate('Y-m-d H:i:s') ]; try { @@ -78,7 +79,7 @@ class ActivityLogModel extends Model public function getFilteredLogs(array $filters = [], int $limit = 200): array { - $builder = $this->select('activity_logs.*, COALESCE(NULLIF(CONCAT(users.first_name, " ", users.last_name), " "), users.email, "Guest") AS actor_name') + $builder = $this->select('activity_logs.*, activity_logs.activity_at AS created_at, activity_logs.activity_user_type AS actor_role, activity_logs.ip AS ip_address, COALESCE(NULLIF(CONCAT(users.first_name, " ", users.last_name), " "), users.email, "Guest") AS actor_name, users.email as actor_email') ->join('users', 'users.id = activity_logs.activity_user_id', 'left'); // Apply filters @@ -93,11 +94,19 @@ class ActivityLogModel extends Model } if (!empty($filters['action'])) { - $builder->where('activity_logs.action', $filters['action']); + if (is_array($filters['action'])) { + $builder->whereIn('activity_logs.action', array_filter($filters['action'])); + } else { + $builder->where('activity_logs.action', $filters['action']); + } } - if (!empty($filters['user_type'])) { - $builder->where('activity_logs.activity_user_type', $filters['user_type']); + if (!empty($filters['role'])) { + if (is_array($filters['role'])) { + $builder->whereIn('activity_logs.activity_user_type', array_filter($filters['role'])); + } else { + $builder->where('activity_logs.activity_user_type', $filters['role']); + } } if (!empty($filters['date_from'])) { @@ -194,7 +203,7 @@ class ActivityLogModel extends Model protected function getUniqueIPs(string $startDate, int $limit = 10): array { - return $this->select('ip, COUNT(*) AS count') + return $this->select('ip as ip, COUNT(*) AS count') ->where('activity_at >=', $startDate) ->groupBy('ip') ->orderBy('count', 'DESC') @@ -205,7 +214,7 @@ class ActivityLogModel extends Model { $criticalActions = ['admin_login', 'admin_password_change', 'system_error', 'security_breach']; - return $this->select('activity_logs.*, COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), ""), users.email, "System") AS actor_name, users.email AS actor_email') + return $this->select('activity_logs.*, activity_logs.activity_at AS created_at, activity_logs.activity_user_type AS actor_role, activity_logs.ip AS ip_address, COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), " "), users.email, "System") AS actor_name, users.email AS actor_email') ->join('users', 'users.id = activity_logs.activity_user_id', 'left') ->whereIn('action', $criticalActions) ->orderBy('activity_at', 'DESC') @@ -216,4 +225,17 @@ class ActivityLogModel extends Model { return $this->truncate(); } + + public function getActivitySummary(): array + { + $totalActions = $this->db->table('activity_logs')->countAll(); + $activeUsers = $this->db->table('activity_logs')->select('activity_user_id')->where('activity_user_id IS NOT NULL')->distinct()->countAllResults(); + $activityRoles = $this->db->table('activity_logs')->select('activity_user_type')->where('activity_user_type IS NOT NULL')->distinct()->countAllResults(); + + return [ + 'total_actions' => $totalActions, + 'active_users' => $activeUsers, + 'activity_roles'=> $activityRoles, + ]; + } } diff --git a/app/Models/AppointmentModel.php b/app/Models/AppointmentModel.php index 4b40076..7c2361a 100644 --- a/app/Models/AppointmentModel.php +++ b/app/Models/AppointmentModel.php @@ -13,12 +13,15 @@ class AppointmentModel extends Model protected $useSoftDeletes = false; protected $protectFields = true; protected $allowedFields = [ - 'patient_id', - 'doctor_id', - 'appointment_date', - 'appointment_time', - 'status' - ]; + 'patient_id', + 'doctor_id', + 'services', + 'created_by', + 'appointment_date', + 'appointment_time', + 'status', + 'note' +]; protected bool $allowEmptyInserts = false; protected bool $updateOnlyChanged = true; @@ -44,11 +47,11 @@ class AppointmentModel extends Model protected $beforeInsert = ['setDefaultStatus']; protected $afterInsert = []; - protected static array $validStatuses = ['pending', 'approved', 'rejected']; + protected static array $validStatuses = ['pending', 'approved', 'rejected', 'assigned', 'completed', 'cancelled']; public static function normalizeStatus(?string $status): string { - $status = trim((string) $status); + $status = strtolower(trim((string) $status)); if ($status === '' || ! in_array($status, self::$validStatuses, true)) { return 'pending'; @@ -57,6 +60,15 @@ class AppointmentModel extends Model return $status; } + public function isDoctorBooked($doctorId, $date, $time): bool + { + return $this->where('doctor_id', $doctorId) + ->where('appointment_date', $date) + ->where('appointment_time', $time) + ->where('status', 'assigned') + ->countAllResults() > 0; + } + protected function setDefaultStatus(array $eventData): array { if (! isset($eventData['data']['status']) || trim((string) $eventData['data']['status']) === '') { @@ -73,28 +85,48 @@ class AppointmentModel extends Model return $this->where('appointment_date', $date)->countAllResults(); } - public function getRecentActivity(int $limit = 6): array - { - return $this->select("appointments.status, appointments.appointment_date, appointments.appointment_time, TRIM(CONCAT(COALESCE(u1.first_name, ''), ' ', COALESCE(u1.last_name, ''))) AS patient_name, TRIM(CONCAT(COALESCE(u2.first_name, ''), ' ', COALESCE(u2.last_name, ''))) AS doctor_name") - ->join('patients p', 'p.id = appointments.patient_id') - ->join('users u1', 'u1.id = p.user_id') - ->join('doctors d', 'd.id = appointments.doctor_id') - ->join('users u2', 'u2.id = d.user_id') - ->orderBy('appointments.id', 'DESC') - ->findAll($limit); - } - + public function getRecentActivity(int $limit = 6): array +{ + return $this->select(" + appointments.status, + appointments.appointment_date, + appointments.appointment_time, + TRIM(CONCAT(COALESCE(u1.first_name,''),' ',COALESCE(u1.last_name,''))) AS patient_name, + TRIM(CONCAT(COALESCE(u2.first_name,''),' ',COALESCE(u2.last_name,''))) AS doctor_name + ") + ->join('patients p', 'p.id = appointments.patient_id') + ->join('users u1', 'u1.id = p.user_id') + ->join('doctors d', 'd.id = appointments.doctor_id', 'left') + ->join('users u2', 'u2.id = d.user_id', 'left') + ->orderBy('appointments.id', 'DESC') + ->findAll($limit); +} + // public function getAdminAppointments(): array + // { + // return $this->asObject() + // ->select("appointments.*, TRIM(CONCAT(COALESCE(u1.first_name, ''), ' ', COALESCE(u1.last_name, ''))) AS patient_name, TRIM(CONCAT(COALESCE(u2.first_name, ''), ' ', COALESCE(u2.last_name, ''))) AS doctor_name") + // ->join('patients p', 'p.id = appointments.patient_id') + // ->join('users u1', 'u1.id = p.user_id') + // ->join('doctors d', 'd.id = appointments.doctor_id') + // ->join('users u2', 'u2.id = d.user_id') + // ->findAll(); + // } public function getAdminAppointments(): array - { - return $this->asObject() - ->select("appointments.*, TRIM(CONCAT(COALESCE(u1.first_name, ''), ' ', COALESCE(u1.last_name, ''))) AS patient_name, TRIM(CONCAT(COALESCE(u2.first_name, ''), ' ', COALESCE(u2.last_name, ''))) AS doctor_name") - ->join('patients p', 'p.id = appointments.patient_id') - ->join('users u1', 'u1.id = p.user_id') - ->join('doctors d', 'd.id = appointments.doctor_id') - ->join('users u2', 'u2.id = d.user_id') - ->findAll(); - } - +{ + return $this->asObject() + ->select(" + appointments.*, + CONCAT('APT', LPAD(appointments.id, 7, '0')) AS formatted_appointment_id, + TRIM(CONCAT(COALESCE(u1.first_name,''),' ',COALESCE(u1.last_name,''))) AS patient_name, + TRIM(CONCAT(COALESCE(u2.first_name,''),' ',COALESCE(u2.last_name,''))) AS doctor_name + ") + ->join('patients p', 'p.id = appointments.patient_id') + ->join('users u1', 'u1.id = p.user_id') + ->join('doctors d', 'd.id = appointments.doctor_id', 'left') + ->join('users u2', 'u2.id = d.user_id', 'left') + ->orderBy('appointments.id','DESC') + ->findAll(); +} public function deleteByDoctorId(int $doctorId): void { $this->where('doctor_id', $doctorId)->delete(); diff --git a/app/Models/AppointmentPreviewModel.php b/app/Models/AppointmentPreviewModel.php new file mode 100644 index 0000000..a19b1a6 --- /dev/null +++ b/app/Models/AppointmentPreviewModel.php @@ -0,0 +1,78 @@ + 'required|integer', + 'requested_date' => 'required|valid_date[Y-m-d]', + 'requested_time' => 'required', + 'status' => 'permit_empty|in_list[draft,viewed,selected,booked,expired,cancelled]', + 'preview_token' => 'permit_empty|max_length[64]', + ]; + + protected $validationMessages = []; + protected $skipValidation = false; + protected $cleanValidationRules = true; + + protected $allowCallbacks = true; + protected $beforeInsert = ['preparePreview']; + protected $beforeUpdate = ['normalizeJsonFields']; + + protected function preparePreview(array $eventData): array + { + $eventData = $this->normalizeJsonFields($eventData); + + if (empty($eventData['data']['preview_token'])) { + $eventData['data']['preview_token'] = bin2hex(random_bytes(32)); + } + + if (empty($eventData['data']['status'])) { + $eventData['data']['status'] = 'draft'; + } + + return $eventData; + } + + protected function normalizeJsonFields(array $eventData): array + { + foreach (['services', 'available_days_snapshot', 'available_slots_snapshot'] as $field) { + if (isset($eventData['data'][$field]) && is_array($eventData['data'][$field])) { + $eventData['data'][$field] = json_encode($eventData['data'][$field]); + // Removed AppointmentPreviewModel as appointment_previews table is dropped + // Removed the entire class definition as it is no longer needed + diff --git a/app/Models/AvailabilityModel.php b/app/Models/AvailabilityModel.php new file mode 100644 index 0000000..75ec099 --- /dev/null +++ b/app/Models/AvailabilityModel.php @@ -0,0 +1,20 @@ +where('day_number', $dayNumber)->first()['day_name'] ?? null; + } + + // Get all days + public function getAllDays(): array + { + return $this->orderBy('day_number', 'ASC')->findAll(); + } +} diff --git a/app/Models/DoctorBookingModel.php b/app/Models/DoctorBookingModel.php new file mode 100644 index 0000000..d05eb79 --- /dev/null +++ b/app/Models/DoctorBookingModel.php @@ -0,0 +1,51 @@ +where('doctor_id', $doctorId) + ->where('booking_date', $date) + ->where('booking_time', $time) + ->where('status', 'assigned') + ->countAllResults() > 0; + } + + public function syncBooking(int $appointmentId, int $doctorId, string $date, string $time, string $status, ?int $createdBy = null) + { + $existing = $this->where('appointment_id', $appointmentId)->first(); + + $data = [ + 'appointment_id' => $appointmentId, + 'doctor_id' => $doctorId, + 'booking_date' => $date, + 'booking_time' => $time, + 'status' => $status, + 'created_by' => $createdBy + ]; + + if ($existing) { + return $this->update($existing['id'], $data); + } + + return $this->insert($data); + } +} diff --git a/app/Models/DoctorModel.php b/app/Models/DoctorModel.php index 0ba8db1..b2bc5e1 100644 --- a/app/Models/DoctorModel.php +++ b/app/Models/DoctorModel.php @@ -69,7 +69,7 @@ class DoctorModel extends Model $sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC'; return $this->asObject() - ->select("users.id as user_id, COALESCE(NULLIF(users.formatted_user_id, ''), CONCAT('PHY', LPAD(users.id, 7, '0'))) as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, doctors.id, doctors.specialization, doctors.experience, doctors.fees") + ->select("doctors.id as doctor_id, users.id as user_id, COALESCE(NULLIF(users.formatted_user_id, ''), CONCAT('PHY', LPAD(users.id, 7, '0'))) as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, doctors.specialization, doctors.experience, doctors.fees") ->join('users', 'users.id = doctors.user_id') ->where('users.role', 'doctor') ->orderBy($sortColumn, $sortDir) diff --git a/app/Models/PatientModel.php b/app/Models/PatientModel.php index c6a6436..8ce08dc 100644 --- a/app/Models/PatientModel.php +++ b/app/Models/PatientModel.php @@ -67,7 +67,7 @@ class PatientModel extends Model $sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC'; return $this->asObject() - ->select("users.id as user_id, CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PAT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, patients.id, patients.phone") + ->select("users.id as user_id, CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, patients.id, patients.phone") ->join('users', 'users.id = patients.user_id') ->where('users.role', 'patient') ->orderBy($sortColumn, $sortDir) @@ -76,9 +76,26 @@ class PatientModel extends Model public function getLatestPatients(int $limit = 5): array { - return $this->select("CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PAT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, ''))) as name, patients.phone") + return $this->select("CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, ''))) as name, patients.phone") ->join('users', 'users.id = patients.user_id') ->orderBy('patients.id', 'DESC') ->findAll($limit); } + public function getPatientWithFormattedId($userId) +{ + return $this->asObject() + ->select(" + users.id as user_id, + CASE + WHEN users.formatted_user_id IS NULL + OR users.formatted_user_id = '' + OR users.formatted_user_id LIKE 'PHY%' + THEN CONCAT('PT', LPAD(users.id, 7, '0')) + ELSE users.formatted_user_id + END as formatted_user_id + ") + ->join('users', 'users.id = patients.user_id') + ->where('users.id', $userId) + ->first(); +} } diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index 909cbbf..219f209 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -20,6 +20,7 @@ class UserModel extends Model 'password', 'role', 'status', + 'gender', 'session_token', 'reset_token', 'reset_token_expires', @@ -98,7 +99,7 @@ class UserModel extends Model public function formatUserId(int $userId, ?string $role = null): string { $prefix = match (strtolower((string) $role)) { - 'patient' => 'PAT', + 'patient' => 'PT', 'doctor' => 'PHY', }; diff --git a/app/Views/admin/activity_analytics.php b/app/Views/admin/activity_analytics.php index e592e38..f114602 100644 --- a/app/Views/admin/activity_analytics.php +++ b/app/Views/admin/activity_analytics.php @@ -72,36 +72,12 @@ function formatLabel($label) {
= $summary['total_actions'] ?? 0 ?>
Total Actions
= count($summary['by_role'] ?? []) ?>
@@ -109,7 +85,6 @@ function formatLabel($label) {= count($summary['most_active_users'] ?? []) ?>
Active Users
@@ -237,14 +212,11 @@ function toggleSidebar() { icon.className = sidebar.classList.contains('collapsed') ? 'bi bi-layout-sidebar' : 'bi bi-list'; } -<<<<<<< HEAD function toggleNavDropdown(event, element) { event.preventDefault(); element.parentElement.classList.toggle('active'); } -======= ->>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39 function changeAnalyticsPeriod(period) { window.location.href = '= base_url('admin/activity/analytics') ?>?period=' + period; } diff --git a/app/Views/admin/activity_log.php b/app/Views/admin/activity_log.php index ce465c3..9adb16c 100644 --- a/app/Views/admin/activity_log.php +++ b/app/Views/admin/activity_log.php @@ -6,74 +6,12 @@= $summary['total_actions'] ?? 0 ?>
+Total Actions
+= $summary['active_users'] ?? 0 ?>
+Activity Users
+= $summary['activity_roles'] ?? 0 ?>
+Active Roles
+