From 52a7120edfe099420c7e2c664d867c70d058a75a Mon Sep 17 00:00:00 2001 From: Sayan Das Date: Mon, 13 Apr 2026 19:04:12 +0530 Subject: [PATCH] Updated folder structure --- app/Config/Autoload.php | 2 +- app/Config/Routes.php | 12 +- app/Controllers/Admin.php | 702 ++++++++++++++---- app/Controllers/Auth.php | 26 +- app/Controllers/Doctor.php | 74 +- app/Controllers/Patient.php | 7 +- ...026-03-29-120000_InitAppointmentSchema.php | 3 +- ...-07-000000_CreateDoctorSpecializations.php | 84 +++ ...4-07-000001_SeedDefaultSpecializations.php | 31 + ..._AddAuditFieldsToDoctorSpecializations.php | 85 +++ ...eNumericStatusForDoctorSpecializations.php | 48 ++ ...-09-000000_AddFirstNameLastNameToUsers.php | 58 ++ ...04-10-000000_AddFormattedUserIdToUsers.php | 25 + ...-000000_BackfillFormattedUserIdsByRole.php | 32 + .../2026-04-13-120000_DropNameFromUsers.php | 39 + app/Helpers/encryption_helper.php | 27 + app/Models/AppointmentModel.php | 38 + app/Models/DoctorModel.php | 42 +- app/Models/DoctorSpecializationModel.php | 74 ++ app/Models/PatientModel.php | 38 + app/Models/SpecializationModel.php | 54 ++ app/Models/UserModel.php | 58 +- app/Views/admin/add_doctor.php | 331 ++++++--- app/Views/admin/add_patient.php | 299 ++++++++ app/Views/admin/appointments.php | 126 +++- app/Views/admin/dashboard.php | 334 ++++++++- app/Views/admin/doctors.php | 385 ++++++++-- app/Views/admin/overview.php | 689 ----------------- app/Views/admin/patients.php | 296 +++++++- app/Views/auth/login.php | 13 +- app/Views/auth/register.php | 168 ++++- app/Views/auth/reset_password.php | 88 ++- app/Views/components/name_field.php | 181 +++++ app/Views/doctor/dashboard.php | 166 +++-- app/Views/doctor/profile.php | 175 +++-- app/Views/patient/dashboard.php | 236 +++--- public/css/add_doctor.css | 44 ++ public/css/app.css | 88 ++- public/css/dashboard.css | 479 ++++++++++++ public/css/doctors.css | 77 ++ public/js/script.js | 14 + 41 files changed, 4374 insertions(+), 1374 deletions(-) create mode 100644 app/Database/Migrations/2026-04-07-000000_CreateDoctorSpecializations.php create mode 100644 app/Database/Migrations/2026-04-07-000001_SeedDefaultSpecializations.php create mode 100644 app/Database/Migrations/2026-04-07-000002_AddAuditFieldsToDoctorSpecializations.php create mode 100644 app/Database/Migrations/2026-04-07-000003_UseNumericStatusForDoctorSpecializations.php create mode 100644 app/Database/Migrations/2026-04-09-000000_AddFirstNameLastNameToUsers.php create mode 100644 app/Database/Migrations/2026-04-10-000000_AddFormattedUserIdToUsers.php create mode 100644 app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php create mode 100644 app/Database/Migrations/2026-04-13-120000_DropNameFromUsers.php create mode 100644 app/Helpers/encryption_helper.php create mode 100644 app/Models/DoctorSpecializationModel.php create mode 100644 app/Models/SpecializationModel.php create mode 100644 app/Views/admin/add_patient.php delete mode 100644 app/Views/admin/overview.php create mode 100644 app/Views/components/name_field.php create mode 100644 public/css/add_doctor.css create mode 100644 public/css/dashboard.css create mode 100644 public/css/doctors.css create mode 100644 public/js/script.js diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 724c991..4e6e184 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -88,5 +88,5 @@ class Autoload extends AutoloadConfig * * @var list */ - public $helpers = ['form', 'url']; + public $helpers = ['form', 'url', 'encryption']; } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 6f0c766..b56a59f 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -13,11 +13,16 @@ $routes->post('/register', 'Auth::registerProcess'); $routes->get('/logout', 'Auth::logout'); $routes->get('/admin/dashboard', 'Admin::dashboard'); -$routes->get('/admin/overview', 'Admin::overview'); $routes->get('/admin/doctors', 'Admin::doctors'); $routes->get('/admin/doctors/add', 'Admin::addDoctor'); $routes->post('/admin/doctors/add', 'Admin::storeDoctor'); +$routes->get('/admin/doctors/edit/(:any)', 'Admin::editDoctor/$1'); +$routes->post('/admin/doctors/edit/(:any)', 'Admin::updateDoctor/$1'); $routes->get('/admin/patients', 'Admin::patients'); +$routes->get('/admin/patients/add', 'Admin::addPatient'); +$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/deleteDoctor/(:num)', 'Admin::deleteDoctor/$1'); @@ -37,4 +42,7 @@ $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'); \ No newline at end of file +$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'); diff --git a/app/Controllers/Admin.php b/app/Controllers/Admin.php index 747902d..43c5268 100644 --- a/app/Controllers/Admin.php +++ b/app/Controllers/Admin.php @@ -4,11 +4,139 @@ namespace App\Controllers; use App\Models\UserModel; use App\Models\DoctorModel; +use App\Models\DoctorSpecializationModel; use App\Models\PatientModel; use App\Models\AppointmentModel; +use App\Models\SpecializationModel; class Admin extends BaseController { + private function formatFullName(?string $firstName, ?string $lastName, string $fallback = ''): string + { + $fullName = trim(trim((string) $firstName) . ' ' . trim((string) $lastName)); + + return $fullName !== '' ? $fullName : $fallback; + } + + private function generateAccountPassword(int $length = 12): string + { + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%'; + $maxIndex = strlen($alphabet) - 1; + $password = ''; + + for ($i = 0; $i < $length; $i++) { + $password .= $alphabet[random_int(0, $maxIndex)]; + } + + return $password; + } + + private function parseSpecializations($specializationInput): array + { + $specializations = []; + + if (is_array($specializationInput)) { + foreach ($specializationInput as $item) { + $item = trim((string) $item); + if ($item !== '' && ! in_array($item, $specializations, true)) { + $specializations[] = $item; + } + } + } else { + $single = trim((string) $specializationInput); + if ($single !== '') { + $specializations[] = $single; + } + } + + return $specializations; + } + + private function buildExperienceString(?int $years, ?int $months): ?string + { + $experienceParts = []; + + if ($years !== null && $years > 0) { + $experienceParts[] = $years . ' year' . ($years === 1 ? '' : 's'); + } + + if ($months !== null && $months > 0) { + $experienceParts[] = $months . ' month' . ($months === 1 ? '' : 's'); + } + + return $experienceParts !== [] ? implode(' ', $experienceParts) : null; + } + + private function parseExperienceString(?string $experience): array + { + $experience = trim((string) $experience); + $years = 0; + $months = 0; + + if ($experience !== '' && preg_match('/(\d+)\s+year/i', $experience, $yearMatch)) { + $years = (int) $yearMatch[1]; + } + + if ($experience !== '' && preg_match('/(\d+)\s+month/i', $experience, $monthMatch)) { + $months = (int) $monthMatch[1]; + } + + return [ + 'experience_years' => $years, + 'experience_months' => $months, + ]; + } + + private function getPatientFormData(int $userId): ?array + { + $userModel = new UserModel(); + $patientModel = new PatientModel(); + + $user = $userModel->find($userId); + $patient = $patientModel->findByUserId($userId); + + if (! $user || ! $patient) { + return null; + } + + return [ + 'patient' => $patient, + 'user' => $user, + 'first_name' => $user['first_name'] ?? '', + 'last_name' => $user['last_name'] ?? '', + ]; + } + + private function getDoctorFormData(int $userId): ?array + { + $userModel = new UserModel(); + $doctorModel = new DoctorModel(); + $specializationModel = new SpecializationModel(); + $doctorSpecializationModel = new DoctorSpecializationModel(); + + $user = $userModel->find($userId); + $doctor = $doctorModel->findByUserId($userId); + + if (! $user || ! $doctor) { + return null; + } + + $selectedSpecializations = $doctorSpecializationModel->getNamesForDoctor((int) $doctor['id']); + + if ($selectedSpecializations === []) { + $selectedSpecializations = $this->parseSpecializations($doctor['specialization'] ?? []); + } + + return array_merge($this->parseExperienceString($doctor['experience'] ?? null), [ + 'doctor' => $doctor, + 'user' => $user, + 'first_name' => $user['first_name'] ?? '', + 'last_name' => $user['last_name'] ?? '', + 'specializationOptions' => $specializationModel->getOptionNames(), + 'selectedSpecializations' => $selectedSpecializations, + ]); + } + public function dashboard() { if ($r = $this->requireRole('admin')) { @@ -18,42 +146,30 @@ class Admin extends BaseController $doctorModel = new DoctorModel(); $patientModel = new PatientModel(); $appointmentModel = new AppointmentModel(); + $userModel = new UserModel(); + $adminId = (int) session()->get('id'); + $adminUser = $userModel->find($adminId); $data['totalDoctors'] = $doctorModel->countAll(); $data['totalPatients'] = $patientModel->countAll(); $data['totalAppointments'] = $appointmentModel->countAll(); + $data['activeToday'] = $appointmentModel->countForDate(date('Y-m-d')); + $data['recentActivity'] = $appointmentModel->getRecentActivity(6); + $data['latestDoctors'] = $doctorModel->getLatestDoctors(5); + $data['latestPatients'] = $patientModel->getLatestPatients(5); + $data['adminName'] = $this->formatFullName($adminUser['first_name'] ?? '', $adminUser['last_name'] ?? '', 'Administrator'); + $data['adminEmail'] = $adminUser['email'] ?? ''; return view('admin/dashboard', $data); } - public function overview() - { - if ($r = $this->requireRole('admin')) { - return $r; - } - - $data=[]; - - return view('admin/overview', $data); - } public function doctors() { if ($r = $this->requireRole('admin')) { return $r; } - $db = \Config\Database::connect(); - - $query = $db->query(" - SELECT users.id, users.name, users.email, doctors.specialization - FROM users - JOIN doctors ON doctors.user_id = users.id - WHERE users.role = 'doctor' - "); - - $data['doctors'] = $query->getResult(); - - return view('admin/doctors', $data); + return view('admin/doctors'); } public function deleteDoctor($id) @@ -67,13 +183,15 @@ class Admin extends BaseController return redirect()->to(site_url('admin/doctors')); } - $userModel = new UserModel(); - $doctorModel = new DoctorModel(); - $db = \Config\Database::connect(); + $userModel = new UserModel(); + $doctorModel = new DoctorModel(); + $appointmentModel = new AppointmentModel(); + $doctorSpecializationModel = new DoctorSpecializationModel(); - $doctor = $doctorModel->where('user_id', $id)->first(); + $doctor = $doctorModel->findByUserId($id); if ($doctor) { - $db->table('appointments')->where('doctor_id', $doctor['id'])->delete(); + $appointmentModel->deleteByDoctorId((int) $doctor['id']); + $doctorSpecializationModel->deleteByDoctorId((int) $doctor['id']); $doctorModel->delete($doctor['id']); } @@ -88,18 +206,7 @@ class Admin extends BaseController return $r; } - $db = \Config\Database::connect(); - - $query = $db->query(" - SELECT users.id, users.name, users.email, patients.phone - FROM users - JOIN patients ON patients.user_id = users.id - WHERE users.role = 'patient' - "); - - $data['patients'] = $query->getResult(); - - return view('admin/patients', $data); + return view('admin/patients'); } public function deletePatient($id) @@ -113,13 +220,13 @@ class Admin extends BaseController return redirect()->to(site_url('admin/patients')); } - $userModel = new UserModel(); - $patientModel = new PatientModel(); - $db = \Config\Database::connect(); + $userModel = new UserModel(); + $patientModel = new PatientModel(); + $appointmentModel = new AppointmentModel(); - $patient = $patientModel->where('user_id', $id)->first(); + $patient = $patientModel->findByUserId($id); if ($patient) { - $db->table('appointments')->where('patient_id', $patient['id'])->delete(); + $appointmentModel->deleteByPatientId((int) $patient['id']); $patientModel->delete($patient['id']); } @@ -134,18 +241,8 @@ class Admin extends BaseController return $r; } - $db = \Config\Database::connect(); - - $query = $db->query(" - SELECT a.*, u1.name as patient_name, u2.name as doctor_name - FROM appointments a - JOIN patients p ON p.id = a.patient_id - JOIN users u1 ON u1.id = p.user_id - JOIN doctors d ON d.id = a.doctor_id - JOIN users u2 ON u2.id = d.user_id - "); - - $data['appointments'] = $query->getResult(); + $appointmentModel = new AppointmentModel(); + $data['appointments'] = $appointmentModel->getAdminAppointments(); return view('admin/appointments', $data); } @@ -156,7 +253,63 @@ class Admin extends BaseController return $r; } - return view('admin/add_doctor'); + $specializationModel = new SpecializationModel(); + + return view('admin/add_doctor', [ + 'specializationOptions' => $specializationModel->getOptionNames(), + 'isEdit' => false, + ]); + } + + public function addPatient() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + return view('admin/add_patient', [ + 'isEdit' => false, + ]); + } + + public function editDoctor($encryptedId) + { + if ($r = $this->requireRole('admin')) { + return $r; + } + $id = decrypt_id($encryptedId); + if (! ctype_digit((string) $id)) { + return redirect()->to(site_url('admin/doctors'))->with('error', 'Invalid doctor link.'); + } + $doctorData = $this->getDoctorFormData((int) $id); + if (! $doctorData) { + return redirect()->to(site_url('admin/doctors'))->with('error', 'Doctor not found.'); + } + + return view('admin/add_doctor', array_merge($doctorData, [ + 'isEdit' => true, + ])); + } + + public function editPatient($encryptedId) + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $id = decrypt_id($encryptedId); + if (! ctype_digit((string) $id)) { + return redirect()->to(site_url('admin/patients'))->with('error', 'Invalid patient link.'); + } + + $patientData = $this->getPatientFormData((int) $id); + if (! $patientData) { + return redirect()->to(site_url('admin/patients'))->with('error', 'Patient not found.'); + } + + return view('admin/add_patient', array_merge($patientData, [ + 'isEdit' => true, + ])); } public function storeDoctor() @@ -165,112 +318,154 @@ class Admin extends BaseController return $r; } + $rules = [ + '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]', + 'fees' => 'permit_empty|decimal', + ]; + + if (! $this->validate($rules)) { + return redirect()->back()->withInput(); + } + $userModel = new UserModel(); $doctorModel = new DoctorModel(); + $specializationModel = new SpecializationModel(); + $doctorSpecializationModel = new DoctorSpecializationModel(); $db = \Config\Database::connect(); - $validation = \Config\Services::validation(); - $entries = $this->request->getPost('doctors'); - if (! is_array($entries) || $entries === []) { - $entries = [[ - 'name' => $this->request->getPost('name'), - 'email' => $this->request->getPost('email'), - 'password' => $this->request->getPost('password'), - 'specialization' => $this->request->getPost('specialization'), - 'experience' => $this->request->getPost('experience'), - 'fees' => $this->request->getPost('fees'), - 'available_from' => $this->request->getPost('available_from'), - 'available_to' => $this->request->getPost('available_to'), - ]]; + $firstName = trim((string) $this->request->getPost('first_name')); + $lastName = trim((string) $this->request->getPost('last_name')); + $specializationInput = $this->request->getPost('specialization'); + + $specializations = $this->parseSpecializations($specializationInput); + + if ($specializations === []) { + return redirect()->back()->withInput()->with('error', 'Please select at least one specialization.'); } - $rules = [ - 'name' => 'required|min_length[3]|max_length[100]', - 'email' => 'required|valid_email', - 'password' => 'required|min_length[8]', - 'specialization' => 'required|min_length[2]|max_length[191]', - 'experience' => 'permit_empty|max_length[100]', - 'fees' => 'permit_empty|decimal', - 'available_from' => 'permit_empty|valid_date[H:i]', - 'available_to' => 'permit_empty|valid_date[H:i]', - ]; - - $emailsSeen = []; - $cleanRows = []; - $error = null; - - foreach ($entries as $i => $row) { - $row = [ - 'name' => trim((string) ($row['name'] ?? '')), - 'email' => trim((string) ($row['email'] ?? '')), - 'password' => (string) ($row['password'] ?? ''), - 'specialization' => trim((string) ($row['specialization'] ?? '')), - 'experience' => trim((string) ($row['experience'] ?? '')), - 'fees' => trim((string) ($row['fees'] ?? '')), - 'available_from' => trim((string) ($row['available_from'] ?? '')), - 'available_to' => trim((string) ($row['available_to'] ?? '')), - ]; - - $rowNumber = $i + 1; - - if (! $validation->setRules($rules)->run($row)) { - $rowErrors = $validation->getErrors(); - $error = 'Row ' . $rowNumber . ': ' . implode(', ', array_values($rowErrors)); - break; - } - - $emailKey = strtolower($row['email']); - if (isset($emailsSeen[$emailKey])) { - $error = 'Row ' . $rowNumber . ': Duplicate email in submitted rows.'; - break; - } - $emailsSeen[$emailKey] = true; - - if ($userModel->where('email', $row['email'])->first()) { - $error = 'Row ' . $rowNumber . ': Email already exists.'; - break; - } - - $cleanRows[] = $row; + $specializationValue = implode(', ', $specializations); + if (strlen($specializationValue) > 191) { + return redirect()->back()->withInput()->with('error', 'Selected specializations are too long. Please reduce them.'); } - if ($error !== null || $cleanRows === []) { - return redirect()->back()->withInput()->with('error', $error ?? 'Please provide at least one doctor row.'); - } + $yearsRaw = (string) $this->request->getPost('experience_years'); + $monthsRaw = (string) $this->request->getPost('experience_months'); + $years = $yearsRaw === '' ? null : (int) $yearsRaw; + $months = $monthsRaw === '' ? null : (int) $monthsRaw; + + $experience = $this->buildExperienceString($years, $months); + $generatedPassword = $this->generateAccountPassword(); $db->transStart(); - foreach ($cleanRows as $row) { - $userData = [ - 'name' => $row['name'], - 'email' => $row['email'], - 'password' => password_hash($row['password'], PASSWORD_DEFAULT), - 'role' => 'doctor', - 'status' => 'active', - ]; + $userData = [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => trim((string) $this->request->getPost('email')), + 'password' => password_hash($generatedPassword, PASSWORD_DEFAULT), + 'role' => 'doctor', + 'status' => 'active', + ]; - if (! $userModel->skipValidation(true)->insert($userData)) { - $db->transRollback(); + if (! $userModel->skipValidation(true)->insert($userData)) { + $db->transRollback(); - return redirect()->back()->withInput()->with('error', 'Could not create user for ' . $row['email'] . '.'); - } + return redirect()->back()->withInput()->with('error', 'Could not create doctor login account.'); + } - $userId = (int) $userModel->getInsertID(); + $userId = (int) $userModel->getInsertID(); - $doctorRow = [ - 'user_id' => $userId, - 'specialization' => $row['specialization'], - 'experience' => $row['experience'] !== '' ? $row['experience'] : null, - 'fees' => $row['fees'] !== '' ? $row['fees'] : null, - 'available_from' => $row['available_from'] !== '' ? $row['available_from'] : null, - 'available_to' => $row['available_to'] !== '' ? $row['available_to'] : null, - ]; + $doctorRow = [ + 'user_id' => $userId, + 'specialization' => $specializationValue, + 'experience' => $experience, + 'fees' => $this->request->getPost('fees') !== '' && $this->request->getPost('fees') !== null + ? $this->request->getPost('fees') + : null, + ]; - if (! $doctorModel->skipValidation(true)->insert($doctorRow)) { - $db->transRollback(); + if (! $doctorModel->skipValidation(true)->insert($doctorRow)) { + $db->transRollback(); - return redirect()->back()->withInput()->with('error', 'Could not create doctor profile for ' . $row['email'] . '.'); - } + return redirect()->back()->withInput()->with('error', 'Could not create doctor profile.'); + } + + $doctorId = (int) $doctorModel->getInsertID(); + $specializationMap = $specializationModel->ensureNamesExist($specializations); + $specializationIds = array_values($specializationMap); + $doctorSpecializationModel->syncDoctorSpecializations($doctorId, $specializationIds, (int) session()->get('id')); + + $db->transComplete(); + + if (! $db->transStatus()) { + return redirect()->back()->withInput()->with('error', 'Transaction failed.'); + } + + return redirect()->to(site_url('admin/doctors'))->with('success', 'Doctor account created. Generated password: ' . $generatedPassword); + } + + public function storePatient() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $rules = [ + '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]', + 'phone' => 'required|min_length[10]|max_length[15]', + 'age' => 'permit_empty|integer|greater_than_equal_to[0]|less_than_equal_to[120]', + 'gender' => 'permit_empty|in_list[male,female,other]', + ]; + + if (! $this->validate($rules)) { + return redirect()->back()->withInput(); + } + + $userModel = new UserModel(); + $patientModel = new PatientModel(); + $db = \Config\Database::connect(); + + $firstName = trim((string) $this->request->getPost('first_name')); + $lastName = trim((string) $this->request->getPost('last_name')); + $generatedPassword = $this->generateAccountPassword(); + $ageRaw = (string) $this->request->getPost('age'); + + $db->transStart(); + + $userData = [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => trim((string) $this->request->getPost('email')), + 'password' => password_hash($generatedPassword, PASSWORD_DEFAULT), + 'role' => 'patient', + 'status' => 'active', + ]; + + if (! $userModel->skipValidation(true)->insert($userData)) { + $db->transRollback(); + + return redirect()->back()->withInput()->with('error', 'Could not create patient login account.'); + } + + $userId = (int) $userModel->getInsertID(); + + $patientRow = [ + 'user_id' => $userId, + 'age' => $ageRaw !== '' ? (int) $ageRaw : null, + 'gender' => $this->request->getPost('gender') !== '' ? $this->request->getPost('gender') : null, + 'phone' => trim((string) $this->request->getPost('phone')), + ]; + + if (! $patientModel->skipValidation(true)->insert($patientRow)) { + $db->transRollback(); + + return redirect()->back()->withInput()->with('error', 'Could not create patient profile.'); } $db->transComplete(); @@ -279,8 +474,219 @@ class Admin extends BaseController return redirect()->back()->withInput()->with('error', 'Transaction failed.'); } - $created = count($cleanRows); + return redirect()->to(site_url('admin/patients'))->with('success', 'Patient account created. Generated password: ' . $generatedPassword); + } - return redirect()->to(site_url('admin/doctors'))->with('success', $created . ' doctor account(s) created.'); + public function updateDoctor($encryptedId) + { + if ($r = $this->requireRole('admin')) { + return $r; + } + $id = decrypt_id($encryptedId); + if (! ctype_digit((string) $id)) { + return redirect()->to(site_url('admin/doctors'))->with('error', 'Invalid doctor link.'); + } + $userId = (int) $id; + $doctorData = $this->getDoctorFormData($userId); + if (! $doctorData) { + return redirect()->to(site_url('admin/doctors'))->with('error', 'Doctor not found.'); + } + + $rules = [ + '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 . ']', + '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', + ]; + + if (! $this->validate($rules)) { + return redirect()->back()->withInput(); + } + + $userModel = new UserModel(); + $doctorModel = new DoctorModel(); + $specializationModel = new SpecializationModel(); + $doctorSpecializationModel = new DoctorSpecializationModel(); + $db = \Config\Database::connect(); + + $firstName = trim((string) $this->request->getPost('first_name')); + $lastName = trim((string) $this->request->getPost('last_name')); + $specializationInput = $this->request->getPost('specialization'); + $specializations = $this->parseSpecializations($specializationInput); + + if ($specializations === []) { + return redirect()->back()->withInput()->with('error', 'Please select at least one specialization.'); + } + + $specializationValue = implode(', ', $specializations); + if (strlen($specializationValue) > 191) { + 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; + $experience = $this->buildExperienceString($years, $months); + + $db->transStart(); + + $userModel->update($userId, [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => trim((string) $this->request->getPost('email')), + ]); + + $doctorModel->update($doctorData['doctor']['id'], [ + 'specialization' => $specializationValue, + 'experience' => $experience, + 'fees' => $this->request->getPost('fees') !== '' && $this->request->getPost('fees') !== null + ? $this->request->getPost('fees') + : null, + ]); + + $specializationMap = $specializationModel->ensureNamesExist($specializations); + $doctorSpecializationModel->syncDoctorSpecializations((int) $doctorData['doctor']['id'], array_values($specializationMap), (int) session()->get('id')); + + $db->transComplete(); + + if (! $db->transStatus()) { + return redirect()->back()->withInput()->with('error', 'Could not update doctor.'); + } + + return redirect()->to(site_url('admin/doctors'))->with('success', 'Doctor updated successfully.'); + } + + public function updatePatient($encryptedId) + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $id = decrypt_id($encryptedId); + if (! ctype_digit((string) $id)) { + return redirect()->to(site_url('admin/patients'))->with('error', 'Invalid patient link.'); + } + + $userId = (int) $id; + $patientData = $this->getPatientFormData($userId); + if (! $patientData) { + return redirect()->to(site_url('admin/patients'))->with('error', 'Patient not found.'); + } + + $rules = [ + '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 . ']', + 'phone' => 'required|min_length[10]|max_length[15]', + 'age' => 'permit_empty|integer|greater_than_equal_to[0]|less_than_equal_to[120]', + 'gender' => 'permit_empty|in_list[male,female,other]', + ]; + + if (! $this->validate($rules)) { + return redirect()->back()->withInput(); + } + + $userModel = new UserModel(); + $patientModel = new PatientModel(); + $db = \Config\Database::connect(); + + $firstName = trim((string) $this->request->getPost('first_name')); + $lastName = trim((string) $this->request->getPost('last_name')); + $ageRaw = (string) $this->request->getPost('age'); + + $db->transStart(); + + $userModel->update($userId, [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => trim((string) $this->request->getPost('email')), + ]); + + $patientModel->update($patientData['patient']['id'], [ + 'age' => $ageRaw !== '' ? (int) $ageRaw : null, + 'gender' => $this->request->getPost('gender') !== '' ? $this->request->getPost('gender') : null, + 'phone' => trim((string) $this->request->getPost('phone')), + ]); + + $db->transComplete(); + + if (! $db->transStatus()) { + return redirect()->back()->withInput()->with('error', 'Could not update patient.'); + } + + return redirect()->to(site_url('admin/patients'))->with('success', 'Patient updated successfully.'); + } + public function checkEmail() + { + $email = (string) $this->request->getPost('email'); + $excludeId = (int) $this->request->getPost('exclude_id'); + + $userModel = new UserModel(); + + return $this->response->setJSON([ + 'exists' => $userModel->emailExistsExcept($email, $excludeId > 0 ? $excludeId : null) + ]); + } + public function getDoctors() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + helper('encryption'); + + $doctorModel = new DoctorModel(); + $doctors = $doctorModel->getAdminDoctorList('doctor_id', 'asc'); + $payload = []; + + foreach ($doctors as $doc) { + $payload[] = [ + 'user_id' => (int) ($doc->user_id ?? 0), + 'formatted_user_id' => $doc->formatted_user_id ?? null, + 'name' => $doc->name ?? null, + 'email' => $doc->email ?? null, + 'specialization' => $doc->specialization ?? null, + 'experience' => $doc->experience ?? null, + 'fees' => $doc->fees ?? null, + 'status' => $doc->status ?? null, + 'edit_token' => encrypt_id($doc->user_id ?? 0), + ]; + } + + return $this->response->setJSON([ + 'data' => $payload, + ]); + } + + public function getPatients() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + helper('encryption'); + + $patientModel = new PatientModel(); + $patients = $patientModel->getAdminPatientList('patient_id', 'asc'); + $payload = []; + + foreach ($patients as $patient) { + $payload[] = [ + 'user_id' => (int) ($patient->user_id ?? 0), + 'formatted_user_id' => $patient->formatted_user_id ?? null, + 'name' => $patient->name ?? null, + 'email' => $patient->email ?? null, + 'phone' => $patient->phone ?? null, + 'status' => $patient->status ?? null, + 'edit_token' => encrypt_id($patient->user_id ?? 0), + ]; + } + + return $this->response->setJSON([ + 'data' => $payload, + ]); } } diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 7674289..2affb55 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -20,10 +20,11 @@ class Auth extends BaseController public function registerProcess() { $rules = [ - 'name' => 'required|min_length[3]|max_length[100]|alpha_numeric_punct', - 'email' => 'required|valid_email|is_unique[users.email]', - 'phone' => 'required|min_length[10]|max_length[10]', - 'password' => 'required|min_length[8]', + '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]', + 'phone' => 'required|regex_match[/^[6-9]\d{9}$/]', + 'password' => 'required|min_length[8]|regex_match[/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z\d]).+$/]', ]; if (! $this->validate($rules)) { @@ -31,13 +32,16 @@ class Auth extends BaseController } $userModel = new UserModel(); + $firstName = trim((string) $this->request->getPost('first_name')); + $lastName = trim((string) $this->request->getPost('last_name')); $data = [ - 'name' => $this->request->getPost('name'), - 'email' => $this->request->getPost('email'), - 'password' => password_hash((string) $this->request->getPost('password'), PASSWORD_DEFAULT), - 'role' => 'patient', - 'status' => 'active', + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => $this->request->getPost('email'), + 'password' => password_hash((string) $this->request->getPost('password'), PASSWORD_DEFAULT), + 'role' => 'patient', + 'status' => 'active', ]; if (! $userModel->skipValidation(true)->insert($data)) { @@ -49,7 +53,7 @@ class Auth extends BaseController $patientModel = new PatientModel(); $patientModel->insert([ 'user_id' => $user_id, - 'phone' => $this->request->getPost('phone'), + 'phone' => '+91' . $this->request->getPost('phone'), ]); return redirect()->to(site_url('/'))->with('success', 'Account created. You can log in now.'); @@ -166,7 +170,7 @@ class Auth extends BaseController { $rules = [ 'token' => 'required', - 'password' => 'required|min_length[8]', + 'password' => 'required|min_length[8]|regex_match[/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z\d]).+$/]', ]; if (! $this->validate($rules)) { diff --git a/app/Controllers/Doctor.php b/app/Controllers/Doctor.php index b6795e0..ebe10d7 100644 --- a/app/Controllers/Doctor.php +++ b/app/Controllers/Doctor.php @@ -4,11 +4,52 @@ namespace App\Controllers; use App\Models\AppointmentModel; use App\Models\DoctorModel; +use App\Models\DoctorSpecializationModel; +use App\Models\SpecializationModel; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\ResponseInterface; class Doctor extends BaseController { + private function parseSpecializations($specializationInput): array + { + $specializations = []; + + if (is_array($specializationInput)) { + foreach ($specializationInput as $item) { + $item = trim((string) $item); + if ($item !== '' && ! in_array($item, $specializations, true)) { + $specializations[] = $item; + } + } + } else { + $parts = explode(',', (string) $specializationInput); + foreach ($parts as $item) { + $item = trim($item); + if ($item !== '' && ! in_array($item, $specializations, true)) { + $specializations[] = $item; + } + } + } + + return $specializations; + } + + private function getDoctorSpecializationNames(int $doctorId): array + { + $db = \Config\Database::connect(); + + $rows = $db->table('doctor_specializations ds') + ->select('s.name') + ->join('specializations s', 's.id = ds.specialization_id') + ->where('ds.doctor_id', $doctorId) + ->orderBy('s.name', 'ASC') + ->get() + ->getResultArray(); + + return array_map(static fn ($row) => $row['name'], $rows); + } + public function dashboard() { if ($r = $this->requireRole('doctor')) { @@ -27,7 +68,8 @@ class Doctor extends BaseController $doctorId = (int) $doctor['id']; $query = $db->query(' - SELECT a.*, u.name as patient_name + SELECT a.*, + TRIM(CONCAT(COALESCE(u.first_name, \'\'), \' \', COALESCE(u.last_name, \'\'))) AS patient_name FROM appointments a JOIN patients p ON p.id = a.patient_id JOIN users u ON u.id = p.user_id @@ -46,6 +88,7 @@ class Doctor extends BaseController } $doctorModel = new DoctorModel(); + $specializationModel = new SpecializationModel(); $userId = (int) session()->get('id'); $doctor = $doctorModel->where('user_id', $userId)->first(); @@ -55,35 +98,50 @@ class Doctor extends BaseController if ($this->request->is('post')) { $rules = [ - 'specialization' => 'required|min_length[2]|max_length[191]', + 'specialization' => 'required', 'experience' => 'required|max_length[100]', 'fees' => 'permit_empty|decimal', - 'available_from' => 'permit_empty', - 'available_to' => 'permit_empty', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput(); } + $specializations = $this->parseSpecializations($this->request->getPost('specialization')); + + if ($specializations === []) { + return redirect()->back()->withInput()->with('error', 'Please select at least one specialization.'); + } + $update = [ - 'specialization' => $this->request->getPost('specialization'), + 'specialization' => implode(', ', $specializations), 'experience' => $this->request->getPost('experience') ?: null, 'fees' => $this->request->getPost('fees') !== '' && $this->request->getPost('fees') !== null ? $this->request->getPost('fees') : null, - 'available_from' => $this->request->getPost('available_from') ?: null, - 'available_to' => $this->request->getPost('available_to') ?: null, ]; if (! $doctorModel->update($doctor['id'], $update)) { return redirect()->back()->withInput()->with('error', 'Could not update profile.'); } + $specializationMap = $specializationModel->ensureNamesExist($specializations); + $doctorSpecializationModel = new DoctorSpecializationModel(); + $doctorSpecializationModel->syncDoctorSpecializations($doctor['id'], array_values($specializationMap), (int) session()->get('id')); + return redirect()->to(site_url('doctor/profile'))->with('success', 'Profile updated.'); } - return view('doctor/profile', ['doctor' => $doctor]); + $selectedSpecializations = $this->getDoctorSpecializationNames((int) $doctor['id']); + if ($selectedSpecializations === [] && ! empty($doctor['specialization'])) { + $selectedSpecializations = $this->parseSpecializations($doctor['specialization']); + } + + return view('doctor/profile', [ + 'doctor' => $doctor, + 'specializationOptions' => $specializationModel->getOptionNames(), + 'selectedSpecializations' => $selectedSpecializations, + ]); } public function accept($id): ResponseInterface diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 4144afe..a614224 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -23,7 +23,9 @@ class Patient extends BaseController $db = \Config\Database::connect(); $query = $db->query(" - SELECT doctors.id AS doctor_id, users.name, doctors.specialization + SELECT doctors.id AS doctor_id, + TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, ''))) AS name, + doctors.specialization FROM users JOIN doctors ON doctors.user_id = users.id WHERE users.role = 'doctor' @@ -38,7 +40,8 @@ class Patient extends BaseController if ($patient) { $data['myAppointments'] = $db->query(' SELECT a.id, a.appointment_date, a.appointment_time, a.status, - u.name AS doctor_name, doctors.specialization + TRIM(CONCAT(COALESCE(u.first_name, \'\'), \' \', COALESCE(u.last_name, \'\'))) AS doctor_name, + doctors.specialization FROM appointments a JOIN doctors ON doctors.id = a.doctor_id JOIN users u ON u.id = doctors.user_id diff --git a/app/Database/Migrations/2026-03-29-120000_InitAppointmentSchema.php b/app/Database/Migrations/2026-03-29-120000_InitAppointmentSchema.php index d87f336..f62d481 100644 --- a/app/Database/Migrations/2026-03-29-120000_InitAppointmentSchema.php +++ b/app/Database/Migrations/2026-03-29-120000_InitAppointmentSchema.php @@ -13,7 +13,8 @@ class InitAppointmentSchema extends Migration { $this->forge->addField([ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], - 'name' => ['type' => 'VARCHAR', 'constraint' => 100], + 'first_name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'last_name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'email' => ['type' => 'VARCHAR', 'constraint' => 191], 'password' => ['type' => 'VARCHAR', 'constraint' => 255], 'role' => ['type' => 'VARCHAR', 'constraint' => 20], diff --git a/app/Database/Migrations/2026-04-07-000000_CreateDoctorSpecializations.php b/app/Database/Migrations/2026-04-07-000000_CreateDoctorSpecializations.php new file mode 100644 index 0000000..7d6c74a --- /dev/null +++ b/app/Database/Migrations/2026-04-07-000000_CreateDoctorSpecializations.php @@ -0,0 +1,84 @@ +forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'name' => ['type' => 'VARCHAR', 'constraint' => 100], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('name'); + $this->forge->createTable('specializations', true); + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'doctor_id' => ['type' => 'INT', 'unsigned' => true], + 'specialization_id' => ['type' => 'INT', 'unsigned' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('doctor_id'); + $this->forge->addKey('specialization_id'); + $this->forge->addUniqueKey(['doctor_id', 'specialization_id']); + $this->forge->createTable('doctor_specializations', true); + + $db = \Config\Database::connect(); + + if (! $db->tableExists('doctors')) { + return; + } + + $doctors = $db->table('doctors') + ->select('id, specialization') + ->where('specialization IS NOT NULL') + ->where('specialization !=', '') + ->get() + ->getResultArray(); + + foreach ($doctors as $doctor) { + $rawNames = explode(',', (string) $doctor['specialization']); + $names = []; + + foreach ($rawNames as $name) { + $name = trim($name); + if ($name !== '' && ! in_array($name, $names, true)) { + $names[] = $name; + } + } + + foreach ($names as $name) { + $existing = $db->table('specializations')->where('name', $name)->get()->getRowArray(); + + if ($existing) { + $specializationId = (int) $existing['id']; + } else { + $db->table('specializations')->insert(['name' => $name]); + $specializationId = (int) $db->insertID(); + } + + $pivotExists = $db->table('doctor_specializations') + ->where('doctor_id', (int) $doctor['id']) + ->where('specialization_id', $specializationId) + ->countAllResults() > 0; + + if (! $pivotExists) { + $db->table('doctor_specializations')->insert([ + 'doctor_id' => (int) $doctor['id'], + 'specialization_id' => $specializationId, + ]); + } + } + } + } + + public function down(): void + { + $this->forge->dropTable('doctor_specializations', true); + $this->forge->dropTable('specializations', true); + } +} diff --git a/app/Database/Migrations/2026-04-07-000001_SeedDefaultSpecializations.php b/app/Database/Migrations/2026-04-07-000001_SeedDefaultSpecializations.php new file mode 100644 index 0000000..bd2a4aa --- /dev/null +++ b/app/Database/Migrations/2026-04-07-000001_SeedDefaultSpecializations.php @@ -0,0 +1,31 @@ +db->tableExists('specializations')) { + return; + } + + $model = new SpecializationModel(); + $model->ensureNamesExist(SpecializationModel::defaultNames()); + } + + public function down(): void + { + if (! $this->db->tableExists('specializations')) { + return; + } + + $this->db->table('specializations') + ->whereIn('name', SpecializationModel::defaultNames()) + ->delete(); + } +} diff --git a/app/Database/Migrations/2026-04-07-000002_AddAuditFieldsToDoctorSpecializations.php b/app/Database/Migrations/2026-04-07-000002_AddAuditFieldsToDoctorSpecializations.php new file mode 100644 index 0000000..7708d7f --- /dev/null +++ b/app/Database/Migrations/2026-04-07-000002_AddAuditFieldsToDoctorSpecializations.php @@ -0,0 +1,85 @@ +db->tableExists('doctor_specializations')) { + return; + } + + $fields = []; + + if (! $this->db->fieldExists('status', 'doctor_specializations')) { + $fields['status'] = [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + 'after' => 'specialization_id', + ]; + } + + if (! $this->db->fieldExists('created_at', 'doctor_specializations')) { + $fields['created_at'] = [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'status', + ]; + } + + if (! $this->db->fieldExists('updated_at', 'doctor_specializations')) { + $fields['updated_at'] = [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'created_at', + ]; + } + + if (! $this->db->fieldExists('created_by', 'doctor_specializations')) { + $fields['created_by'] = [ + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + 'after' => 'updated_at', + ]; + } + + if (! $this->db->fieldExists('updated_by', 'doctor_specializations')) { + $fields['updated_by'] = [ + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + 'after' => 'created_by', + ]; + } + + if ($fields !== []) { + $this->forge->addColumn('doctor_specializations', $fields); + } + + $now = date('Y-m-d H:i:s'); + + $this->db->table('doctor_specializations')->set([ + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ])->update(); + } + + public function down(): void + { + if (! $this->db->tableExists('doctor_specializations')) { + return; + } + + foreach (['updated_by', 'created_by', 'updated_at', 'created_at', 'status'] as $field) { + if ($this->db->fieldExists($field, 'doctor_specializations')) { + $this->forge->dropColumn('doctor_specializations', $field); + } + } + } +} diff --git a/app/Database/Migrations/2026-04-07-000003_UseNumericStatusForDoctorSpecializations.php b/app/Database/Migrations/2026-04-07-000003_UseNumericStatusForDoctorSpecializations.php new file mode 100644 index 0000000..370cf2c --- /dev/null +++ b/app/Database/Migrations/2026-04-07-000003_UseNumericStatusForDoctorSpecializations.php @@ -0,0 +1,48 @@ +db->tableExists('doctor_specializations') || ! $this->db->fieldExists('status', 'doctor_specializations')) { + return; + } + + $this->db->query("UPDATE `doctor_specializations` SET `status` = '1' WHERE `status` IS NULL OR `status` = '' OR LOWER(`status`) = 'active'"); + $this->db->query("UPDATE `doctor_specializations` SET `status` = '0' WHERE LOWER(`status`) = 'inactive'"); + + $this->forge->modifyColumn('doctor_specializations', [ + 'status' => [ + 'name' => 'status', + 'type' => 'TINYINT', + 'constraint' => 1, + 'null' => false, + 'default' => 1, + ], + ]); + } + + public function down(): void + { + if (! $this->db->tableExists('doctor_specializations') || ! $this->db->fieldExists('status', 'doctor_specializations')) { + return; + } + + $this->db->query("UPDATE `doctor_specializations` SET `status` = 'active' WHERE `status` = 1"); + $this->db->query("UPDATE `doctor_specializations` SET `status` = 'inactive' WHERE `status` = 0"); + + $this->forge->modifyColumn('doctor_specializations', [ + 'status' => [ + 'name' => 'status', + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => false, + 'default' => 'active', + ], + ]); + } +} diff --git a/app/Database/Migrations/2026-04-09-000000_AddFirstNameLastNameToUsers.php b/app/Database/Migrations/2026-04-09-000000_AddFirstNameLastNameToUsers.php new file mode 100644 index 0000000..9f73298 --- /dev/null +++ b/app/Database/Migrations/2026-04-09-000000_AddFirstNameLastNameToUsers.php @@ -0,0 +1,58 @@ +db->fieldExists('first_name', 'users')) { + $this->forge->addColumn('users', [ + 'first_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + ]); + } + + if (! $this->db->fieldExists('last_name', 'users')) { + $this->forge->addColumn('users', [ + 'last_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + ]); + } + + if ($this->db->fieldExists('name', 'users')) { + $this->db->query(" + UPDATE users + SET + first_name = CASE + WHEN LOCATE(' ', name) > 0 THEN SUBSTRING_INDEX(name, ' ', 1) + ELSE name + END, + last_name = CASE + WHEN LOCATE(' ', name) > 0 THEN SUBSTRING_INDEX(name, ' ', -1) + ELSE NULL + END + WHERE first_name IS NULL AND name IS NOT NULL + "); + } + } + + public function down(): void + { + if ($this->db->fieldExists('first_name', 'users')) { + $this->forge->dropColumn('users', 'first_name'); + } + + if ($this->db->fieldExists('last_name', 'users')) { + $this->forge->dropColumn('users', 'last_name'); + } + } +} diff --git a/app/Database/Migrations/2026-04-10-000000_AddFormattedUserIdToUsers.php b/app/Database/Migrations/2026-04-10-000000_AddFormattedUserIdToUsers.php new file mode 100644 index 0000000..0fa8801 --- /dev/null +++ b/app/Database/Migrations/2026-04-10-000000_AddFormattedUserIdToUsers.php @@ -0,0 +1,25 @@ +forge->addColumn('users', [ + 'formatted_user_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 10, + 'null' => false, + 'after' => 'id', + ], + ]); + } + public function down() + { + $this->forge->dropColumn('users', 'formatted_user_id'); + } +} + \ No newline at end of file diff --git a/app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php b/app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php new file mode 100644 index 0000000..aaaa1f4 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000000_BackfillFormattedUserIdsByRole.php @@ -0,0 +1,32 @@ +db->query(" + UPDATE users + SET formatted_user_id = CONCAT('PAT', LPAD(id, 7, '0')) + WHERE role = 'patient' + "); + + $this->db->query(" + UPDATE users + SET formatted_user_id = CONCAT('PHY', LPAD(id, 7, '0')) + WHERE role = 'doctor' + "); + } + + public function down() + { + $this->db->query(" + UPDATE users + SET formatted_user_id = CONCAT('PHY', LPAD(id, 7, '0')) + WHERE role = 'patient' + "); + } +} diff --git a/app/Database/Migrations/2026-04-13-120000_DropNameFromUsers.php b/app/Database/Migrations/2026-04-13-120000_DropNameFromUsers.php new file mode 100644 index 0000000..f5a2553 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-120000_DropNameFromUsers.php @@ -0,0 +1,39 @@ +db->fieldExists('name', 'users')) { + $this->forge->dropColumn('users', 'name'); + } + } + + public function down(): void + { + if ($this->db->fieldExists('name', 'users')) { + return; + } + + $this->forge->addColumn('users', [ + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + 'after' => 'id', + ], + ]); + + if ($this->db->fieldExists('first_name', 'users') && $this->db->fieldExists('last_name', 'users')) { + $this->db->query(" + UPDATE users + SET name = TRIM(CONCAT(COALESCE(first_name, ''), ' ', COALESCE(last_name, ''))) + WHERE name IS NULL + "); + } + } +} diff --git a/app/Helpers/encryption_helper.php b/app/Helpers/encryption_helper.php new file mode 100644 index 0000000..b13bd35 --- /dev/null +++ b/app/Helpers/encryption_helper.php @@ -0,0 +1,27 @@ +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 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 deleteByDoctorId(int $doctorId): void + { + $this->where('doctor_id', $doctorId)->delete(); + } + + public function deleteByPatientId(int $patientId): void + { + $this->where('patient_id', $patientId)->delete(); + } + protected $beforeUpdate = []; protected $afterUpdate = []; protected $beforeFind = []; diff --git a/app/Models/DoctorModel.php b/app/Models/DoctorModel.php index 40e59ee..5d88ce6 100644 --- a/app/Models/DoctorModel.php +++ b/app/Models/DoctorModel.php @@ -12,7 +12,7 @@ class DoctorModel extends Model protected $returnType = 'array'; protected $useSoftDeletes = false; protected $protectFields = true; - protected $allowedFields = ['user_id','specialization','experience','fees','available_from','available_to']; + protected $allowedFields = ['user_id','specialization','experience','fees']; protected bool $allowEmptyInserts = false; protected bool $updateOnlyChanged = true; @@ -43,4 +43,44 @@ class DoctorModel extends Model protected $afterFind = []; protected $beforeDelete = []; protected $afterDelete = []; + + public function findByUserId(int $userId): ?array + { + $row = $this->where('user_id', $userId)->first(); + + return $row ?: null; + } + + public function getAdminDoctorList(string $sortBy = 'doctor_id', string $sortDir = 'asc'): array + { + $fullNameSql = "TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, '')))"; + + $sortColumns = [ + 'doctor_id' => 'users.id', + 'name' => $fullNameSql, + 'email' => 'users.email', + 'specialization' => 'doctors.specialization', + 'experience' => 'doctors.experience', + 'fees' => 'doctors.fees', + 'status' => 'users.status', + ]; + + $sortColumn = $sortColumns[$sortBy] ?? 'users.id'; + $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") + ->join('users', 'users.id = doctors.user_id') + ->where('users.role', 'doctor') + ->orderBy($sortColumn, $sortDir) + ->findAll(); + } + + public function getLatestDoctors(int $limit = 5): array + { + return $this->select("TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, ''))) as name, doctors.specialization") + ->join('users', 'users.id = doctors.user_id') + ->orderBy('doctors.id', 'DESC') + ->findAll($limit); + } } diff --git a/app/Models/DoctorSpecializationModel.php b/app/Models/DoctorSpecializationModel.php new file mode 100644 index 0000000..891cd0c --- /dev/null +++ b/app/Models/DoctorSpecializationModel.php @@ -0,0 +1,74 @@ +select('specializations.name') + ->join('specializations', 'specializations.id = doctor_specializations.specialization_id') + ->where('doctor_specializations.doctor_id', $doctorId) + ->findAll(); + + return array_values(array_filter(array_map( + static fn (array $row) => ! empty($row['name']) ? $row['name'] : null, + $rows + ))); + } + + public function deleteByDoctorId(int $doctorId): void + { + $this->where('doctor_id', $doctorId)->delete(); + } + + public function syncDoctorSpecializations(int $doctorId, array $specializationIds, ?int $actorId = null): void + { + $specializationIds = array_values(array_unique(array_map('intval', $specializationIds))); + $existingRows = $this->where('doctor_id', $doctorId)->findAll(); + $existingBySpecId = []; + + foreach ($existingRows as $row) { + $existingBySpecId[(int) $row['specialization_id']] = $row; + } + + $existingIds = array_keys($existingBySpecId); + $toDelete = array_diff($existingIds, $specializationIds); + $toInsert = array_diff($specializationIds, $existingIds); + + if ($toDelete !== []) { + $this->where('doctor_id', $doctorId) + ->whereIn('specialization_id', $toDelete) + ->delete(); + } + + foreach ($toInsert as $specializationId) { + $this->insert([ + 'doctor_id' => $doctorId, + 'specialization_id' => $specializationId, + 'status' => 1, + 'created_by' => $actorId, + 'updated_by' => $actorId, + ]); + } + } +} diff --git a/app/Models/PatientModel.php b/app/Models/PatientModel.php index 43c3224..41d168f 100644 --- a/app/Models/PatientModel.php +++ b/app/Models/PatientModel.php @@ -43,4 +43,42 @@ class PatientModel extends Model protected $afterFind = []; protected $beforeDelete = []; protected $afterDelete = []; + + public function findByUserId(int $userId): ?array + { + $row = $this->where('user_id', $userId)->first(); + + return $row ?: null; + } + + public function getAdminPatientList(string $sortBy = 'patient_id', string $sortDir = 'asc'): array + { + $fullNameSql = "TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, '')))"; + + $sortColumns = [ + 'patient_id' => 'users.id', + 'name' => $fullNameSql, + 'email' => 'users.email', + 'phone' => 'patients.phone', + 'status' => 'users.status', + ]; + + $sortColumn = $sortColumns[$sortBy] ?? 'users.id'; + $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") + ->join('users', 'users.id = patients.user_id') + ->where('users.role', 'patient') + ->orderBy($sortColumn, $sortDir) + ->findAll(); + } + + public function getLatestPatients(int $limit = 5): array + { + return $this->select("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); + } } diff --git a/app/Models/SpecializationModel.php b/app/Models/SpecializationModel.php new file mode 100644 index 0000000..d472b44 --- /dev/null +++ b/app/Models/SpecializationModel.php @@ -0,0 +1,54 @@ +select('name')->orderBy('name', 'ASC')->findColumn('name') ?: []; + natcasesort($databaseNames); + return array_values(array_unique($databaseNames)); + } + + public function ensureNamesExist(array $names): array + { + $names = array_values(array_unique(array_filter(array_map( + static fn ($name) => trim((string) $name), + $names + )))); + + if ($names === []) { + return []; + } + + $existingRows = $this->whereIn('name', $names)->findAll(); + $nameToId = []; + + foreach ($existingRows as $row) { + $nameToId[$row['name']] = (int) $row['id']; + } + + foreach ($names as $name) { + if (isset($nameToId[$name])) { + continue; + } + + $this->insert(['name' => $name, 'status' => 1]); + $nameToId[$name] = (int) $this->getInsertID(); + } + + return $nameToId; + } +} diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index 53be8d3..909cbbf 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -13,7 +13,9 @@ class UserModel extends Model protected $useSoftDeletes = false; protected $protectFields = true; protected $allowedFields = [ - 'name', + 'formatted_user_id', + 'first_name', + 'last_name', 'email', 'password', 'role', @@ -45,11 +47,61 @@ class UserModel extends Model // Callbacks protected $allowCallbacks = true; protected $beforeInsert = []; - protected $afterInsert = []; + protected $afterInsert = ['assignFormattedUserId']; protected $beforeUpdate = []; protected $afterUpdate = []; protected $beforeFind = []; protected $afterFind = []; protected $beforeDelete = []; protected $afterDelete = []; -} + + public function emailExists(string $email): bool + { + return $this->where('email', trim($email))->first() !== null; + } + + public function emailExistsExcept(string $email, ?int $excludeUserId = null): bool + { + $builder = $this->where('email', trim($email)); + + if ($excludeUserId !== null && $excludeUserId > 0) { + $builder->where('id !=', $excludeUserId); + } + + return $builder->first() !== null; + } + + protected function assignFormattedUserId(array $data): array + { + $insertId = (int) ($data['id'] ?? 0); + $role = strtolower((string) ($data['data']['role'] ?? '')); + + if ($insertId < 1) { + return $data; + } + + if ($role === '') { + $user = $this->find($insertId); + $role = strtolower((string) ($user['role'] ?? '')); + } + + $this->builder() + ->where('id', $insertId) + ->where('(formatted_user_id IS NULL OR formatted_user_id = "")') + ->update([ + 'formatted_user_id' => $this->formatUserId($insertId, $role), + ]); + + return $data; + } + + public function formatUserId(int $userId, ?string $role = null): string + { + $prefix = match (strtolower((string) $role)) { + 'patient' => 'PAT', + 'doctor' => 'PHY', + }; + + return $prefix . str_pad((string) $userId, 7, '0', STR_PAD_LEFT); + } +} \ No newline at end of file diff --git a/app/Views/admin/add_doctor.php b/app/Views/admin/add_doctor.php index dcb93a7..4b43c50 100644 --- a/app/Views/admin/add_doctor.php +++ b/app/Views/admin/add_doctor.php @@ -3,147 +3,250 @@ - Add doctor + Add Doctor + + + + - - - '', - 'email' => '', - 'password' => '', - 'specialization' => '', - 'experience' => '', - 'fees' => '', - 'available_from' => '', - 'available_to' => '', - ]]; + + + - -
- -

Add Doctors

- - getFlashdata('error')): ?> -
getFlashdata('error')) ?>
- - -
- - -
-

Add one or many doctors, then submit once.

- + -
- $doctor): ?> -
-
-
Doctor No:
- -
+
+
+
+ +

Doctor Profile

+
+
+ +
+ getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+ +
+ Fields marked with * are required. + + +
- - -
-
- - + 'first_name', + 'fieldLabel' => 'First name', + 'fieldId' => 'first_name', + 'fieldValue' => old('first_name', $first_name ?? ''), + 'required' => true, + 'validationErrors' => $validationErrors, + 'placeholder' => 'Enter first name' + ]); + ?>
- - -
-
- - + 'last_name', + 'fieldLabel' => 'Last name', + 'fieldId' => 'last_name', + 'fieldValue' => old('last_name', $last_name ?? ''), + 'required' => true, + 'validationErrors' => $validationErrors, + 'placeholder' => 'Enter last name' + ]); + ?>
-
- - +
+ + + +
-
- - + +
+ +
+ + +
+
-
- - + +
+ +
+
+
+ + Years +
+ +
+
+
+ + Months +
+ +
+
-
- - + +
+ + +
+
-
- -
-
- Cancel - +
+ Cancel + +
+ +
- - +
+ + + diff --git a/app/Views/admin/add_patient.php b/app/Views/admin/add_patient.php new file mode 100644 index 0000000..68cf632 --- /dev/null +++ b/app/Views/admin/add_patient.php @@ -0,0 +1,299 @@ + + + + + + Add Patient + + + + + + + + + + + + +
+
+
+ +

Patient Profile

+
+
+ +
+ getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+ +
+ Fields marked with * are required. + +
+ + +
+
+ 'first_name', + 'fieldLabel' => 'First name', + 'fieldId' => 'first_name', + 'fieldValue' => old('first_name', $first_name ?? ''), + 'required' => true, + 'validationErrors' => $validationErrors, + 'placeholder' => 'Enter first name', + ]) ?> +
+ +
+ 'last_name', + 'fieldLabel' => 'Last name', + 'fieldId' => 'last_name', + 'fieldValue' => old('last_name', $last_name ?? ''), + 'required' => true, + 'validationErrors' => $validationErrors, + 'placeholder' => 'Enter last name', + ]) ?> +
+ +
+ + + + +
+ +
+ + +
+ +91 + +
+ +
+
+ + +
+ + + + + +
+ + + + + +
    +
  • At least 8 characters
  • +
  • One uppercase letter
  • +
  • One lowercase letter
  • +
  • One number
  • +
  • One special character
  • +
+
+ +
+ + + +
+ +
+ + + + +
+
+ +
+ Cancel + +
+
+
+
+
+
+ + + + diff --git a/app/Views/admin/appointments.php b/app/Views/admin/appointments.php index 013d085..8e955d9 100644 --- a/app/Views/admin/appointments.php +++ b/app/Views/admin/appointments.php @@ -5,50 +5,94 @@ Appointments + + - + + -
- -

Appointments

- -
- - - - - - - - - - - - - - - - - - - - - - - status ?? '')); ?> - - - - -
Sl NoPatientDoctorDateTimeStatus
patient_name) ?>doctor_name) ?>appointment_date) ?>appointment_time) ?>
- -
- - +
+
+
+ +

Appointments

+
+
+
+
+
+

Appointments

+ Back to dashboard +
+
+ + + + + + + + + + + + + + + status ?? '')); ?> + + + + + + + + + + +
Sl NoPatientDoctorDateTimeStatus
patient_name) ?>doctor_name) ?>appointment_date) ?>appointment_time) ?>
+
+
+
+ + - + diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php index ab36252..5d72bb3 100644 --- a/app/Views/admin/dashboard.php +++ b/app/Views/admin/dashboard.php @@ -1,4 +1,4 @@ - + + + + + + + Admin Dashboard + + + + + + + + + + + +
+ + +
+
+ +

Dashboard

+
+ + +
+ + + +
+
+ + +
+ + +
+
+
+
+
+
Doctors
+

+
+
+
+ +
+
+
+
+
Patients
+

+
+
+
+ +
+
+
+
+
Appointments
+

+
+
+
+ +
+
+
+
+
Active Today
+

+
+
+
+
+ + +
+ + +
+
+
+

Quick Actions

+
+ +
+
+ + +
+
+
+

Recent Activity

+
+
+ + + '#22c55e', + 'rejected' => '#ef4444', + default => '#eab308', + }; + $badgeClass = match($status) { + 'approved' => 'ov-badge--success', + 'rejected' => 'ov-badge--danger', + default => 'ov-badge--warning', + }; + $badgeLabel = ucfirst($status); + ?> +
+
+
+
+ + booked with + + +
+
·
+
+
+ + +

No recent activity.

+ +
+
+
+ +
+ + +
+ +
+
+
+

Doctors

+ View all +
+
+ + + + + + + + + + + $doctor) : ?> + + + + + + + + + + +
#NameSpecialization
Dr.
No doctors found.
+
+
+
+ +
+
+
+

Patients

+ View all +
+
+ + + + + + + + + + + $patient) : ?> + + + + + + + + + + +
#NamePhone
No patients found.
+
+
+
+ +
+ +
+
+ + + diff --git a/app/Views/admin/doctors.php b/app/Views/admin/doctors.php index 074f29d..06a73fe 100644 --- a/app/Views/admin/doctors.php +++ b/app/Views/admin/doctors.php @@ -5,64 +5,347 @@ Doctors + + + + + - - -
- -

Doctors

- - getFlashdata('success')): ?> -
getFlashdata('success')) ?>
- - getFlashdata('error')): ?> -
getFlashdata('error')) ?>
- - -
- Add doctor + + - +
+
+
+ +

Doctors

+
+
+
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
+

Doctor List

+
+ +
+
+
+ + + + + + + + + + + + + +
User IDDoctor NameEmailSpecializationExperienceConsultation FeeStatusActions
+
+
+
+ + + + + + + + + + + + + diff --git a/app/Views/admin/overview.php b/app/Views/admin/overview.php deleted file mode 100644 index fbb5e63..0000000 --- a/app/Views/admin/overview.php +++ /dev/null @@ -1,689 +0,0 @@ - - - - - - Admin Overview - - - - - - - - - - - -
- - -
-

Overview

- - -
- - -
-
-

Administrator

- admin@medadmin.com -
- - My Profile - - - Settings - -
- - Logout - -
-
-
- - -
- - -
-
-
-
-
-
Doctors
-

12

-
-
-
- -
-
-
-
-
Patients
-

48

-
-
-
- -
-
-
-
-
Appointments
-

134

-
-
-
- -
-
-
-
-
Active Today
-

7

-
-
-
-
- - -
- - -
-
-
-

Quick Actions

-
- -
-
- - -
-
-
-

Recent Activity

-
-
-
-
-
-
Sarah Johnson booked with Dr. Alan Carter Approved
-
2026-04-06 · 09:00
-
-
-
-
-
-
Mark Evans booked with Dr. Priya Nair Pending
-
2026-04-06 · 10:30
-
-
-
-
-
-
Lisa Ray booked with Dr. James White Rejected
-
2026-04-05 · 14:00
-
-
-
-
-
-
Tom Harris booked with Dr. Alan Carter Approved
-
2026-04-05 · 11:00
-
-
-
-
-
-
Nina Patel booked with Dr. Priya Nair Pending
-
2026-04-04 · 16:15
-
-
-
-
-
- -
- - -
- -
-
-
-

Doctors

- View all -
-
- - - - - - - - - - - - - - - -
#NameSpecialization
1Dr. Alan CarterCardiology
2Dr. Priya NairDermatology
3Dr. James WhiteNeurology
4Dr. Maria LopezPediatrics
5Dr. Chen WeiOrthopedics
-
-
-
- -
-
-
-

Patients

- View all -
-
- - - - - - - - - - - - - - - -
#NamePhone
1Sarah Johnson+1 555-0101
2Mark Evans+1 555-0182
3Lisa Ray+1 555-0234
4Tom Harris+1 555-0317
5Nina Patel+1 555-0459
-
-
-
- -
- -
-
- - - - diff --git a/app/Views/admin/patients.php b/app/Views/admin/patients.php index 660ac4c..3220408 100644 --- a/app/Views/admin/patients.php +++ b/app/Views/admin/patients.php @@ -5,53 +5,267 @@ Patients + + + + + - + + -
+
+
+
+ +

Patients

+
+
-

Patients

- -
- - - - - - - - - - - - - - - - - - - - - - - -
Sl NoNamePhoneAction
name) ?>phone ?? 'N/A') ?> - - Delete - -
- -
- - +
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ +
+
+

Patient List

+
+ +
+
+
+ + + + + + + + + + + +
User IDPatient NameEmailPhoneStatusActions
+
+
+
+ + + + + + + + + + + + diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php index 0d342e7..f2fec5a 100644 --- a/app/Views/auth/login.php +++ b/app/Views/auth/login.php @@ -6,6 +6,7 @@ Login + @@ -35,9 +36,13 @@
- +
+ + +
@@ -54,6 +59,6 @@
- + diff --git a/app/Views/auth/register.php b/app/Views/auth/register.php index dd3944e..a85715d 100644 --- a/app/Views/auth/register.php +++ b/app/Views/auth/register.php @@ -6,6 +6,7 @@ Register + @@ -23,15 +24,35 @@
- - - + 'first_name', + 'fieldLabel' => 'First name', + 'fieldId' => 'first_name', + 'fieldValue' => old('first_name', $first_name ?? ''), + 'required' => true, + 'validationErrors' => $validationErrors, + 'placeholder' => 'Enter first name' + ]); + ?> +
+ +
+ 'last_name', + 'fieldLabel' => 'Last name', + 'fieldId' => 'last_name', + 'fieldValue' => old('last_name', $last_name ?? ''), + 'required' => true, + 'validationErrors' => $validationErrors, + 'placeholder' => 'Enter last name' + ]); + ?>
- + @@ -39,23 +60,52 @@
- - - -
+ +
+ +91 + + +
+ +
- - - -
+ + +
+ -

Register as a patient to book appointments. Doctor accounts are created by an administrator.

+ + + +
+ + + + + +
    +
  • At least 8 characters
  • +
  • One uppercase letter
  • +
  • One lowercase letter
  • +
  • One number
  • +
  • One special character
  • +
+
+ +

Register as a patient to book appointments.

@@ -66,6 +116,84 @@
+ + diff --git a/app/Views/auth/reset_password.php b/app/Views/auth/reset_password.php index dd94dd7..bcc98d3 100644 --- a/app/Views/auth/reset_password.php +++ b/app/Views/auth/reset_password.php @@ -26,14 +26,27 @@
- - - has('errors.password')): ?> -
- -
+ + + + + + + + + + has('errors.password')): ?> +
+ + @@ -44,6 +57,67 @@ + \ No newline at end of file diff --git a/app/Views/components/name_field.php b/app/Views/components/name_field.php new file mode 100644 index 0000000..bd491e0 --- /dev/null +++ b/app/Views/components/name_field.php @@ -0,0 +1,181 @@ + + +
+ + required + oninput="validateNameField(this)" + onblur="validateNameField(this)" + > +
+ +
+
+ + + + diff --git a/app/Views/doctor/dashboard.php b/app/Views/doctor/dashboard.php index 2dbe184..39ea5ba 100644 --- a/app/Views/doctor/dashboard.php +++ b/app/Views/doctor/dashboard.php @@ -5,38 +5,62 @@ Doctor Dashboard + + - + + -
+
+
+
+ +

Doctor Dashboard

+
+ +
+ -

Doctor dashboard

+ +
+
-

- Edit profile -

+
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ - getFlashdata('success')): ?> -
getFlashdata('success')) ?>
- - getFlashdata('error')): ?> -
getFlashdata('error')) ?>
- - -
- - -
- -
- -
👤 patient_name) ?>
- -

📅 appointment_date) ?>

-

appointment_time) ?>

- -

+ +

+ status); if ($st === '') { @@ -48,44 +72,68 @@ } $badgeClass = match ($st) { - 'pending' => 'bg-warning text-dark', - 'approved' => 'bg-success', - 'rejected' => 'bg-danger', - default => 'bg-secondary', + 'pending' => 'ov-badge ov-badge--warning', + 'approved' => 'ov-badge ov-badge--success', + 'rejected' => 'ov-badge ov-badge--danger', + default => 'ov-badge', }; ?> - -

+
+
+
+

patient_name) ?>

+ +
+
+

appointment_date) ?>

+

appointment_time) ?>

- -
-
- - -
-
- - -
+ +
+
+ + +
+
+ + +
+
+ +
+
- - +
- -
- - -
- - -

No appointments yet.

- - -
- Logout -
- + +
+
+

No appointments yet.

+
+
+ +
+ - \ No newline at end of file + diff --git a/app/Views/doctor/profile.php b/app/Views/doctor/profile.php index c48dc2e..9d0e401 100644 --- a/app/Views/doctor/profile.php +++ b/app/Views/doctor/profile.php @@ -1,79 +1,136 @@ - + - Doctor profile + Doctor Profile + + + - - + + +if (! is_array($selectedSpecializations)) { + $selectedSpecializations = $selectedSpecializations !== '' + ? array_values(array_filter(array_map('trim', explode(',', (string) $selectedSpecializations)))) + : []; +} +?> -

Your profile

+ - getFlashdata('success')): ?> -
getFlashdata('success')) ?>
- - getFlashdata('error')): ?> -
getFlashdata('error')) ?>
- - -
- - -
- - - +
+
+
+ +

Doctor Profile

+ Back to dashboard +
-
- - - -
+
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ -
- - - -
- -
-
- - - +
+
+

Update Profile

-
- - - +
+ + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ Back + +
+
- -
- Back - -
- - +
+ + + + - \ No newline at end of file + diff --git a/app/Views/patient/dashboard.php b/app/Views/patient/dashboard.php index f47a59a..c639fdf 100644 --- a/app/Views/patient/dashboard.php +++ b/app/Views/patient/dashboard.php @@ -3,129 +3,147 @@ - Book Appointment + Patient Dashboard + + - - + -
+ -

Book appointment

- - getFlashdata('success')): ?> -
getFlashdata('success')) ?>
- - getFlashdata('error')): ?> -
getFlashdata('error')) ?>
- - -
- - - -
-

My appointments

-
- - - - - - - - - - - - - - - - - - - -
DoctorDateTimeStatus
- doctor_name) ?> - specialization)): ?> -
specialization) ?> - -
appointment_date) ?>appointment_time) ?> - status); - if ($st === '') { - $st = 'pending'; - } elseif ($st === 'confirmed') { - $st = 'approved'; - } elseif ($st === 'cancelled') { - $st = 'rejected'; - } - - $badgeClass = match ($st) { - 'pending' => 'bg-warning text-dark', - 'approved' => 'bg-success', - 'rejected' => 'bg-danger', - default => 'bg-secondary', - }; - ?> - -
+
+
+
+ +

Book Appointment

-
- + -

Available doctors

+
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+ -
- - -
- -
- -
👨‍⚕️ name) ?>
-

Specialization: specialization ?? 'N/A') ?>

- -
- - - -
- - -
- -
- - -
- - -
+ +
+
+

My Appointments

+
+
+ + + + + + + + + + + + + + + + + + + +
DoctorDateTimeStatus
+ doctor_name) ?> + specialization)): ?> +
specialization) ?> + +
appointment_date) ?>appointment_time) ?> + status); + if ($st === '') { + $st = 'pending'; + } elseif ($st === 'confirmed') { + $st = 'approved'; + } elseif ($st === 'cancelled') { + $st = 'rejected'; + } + $badgeClass = match ($st) { + 'pending' => 'ov-badge ov-badge--warning', + 'approved' => 'ov-badge ov-badge--success', + 'rejected' => 'ov-badge ov-badge--danger', + default => 'ov-badge', + }; + ?> + +
+
+ +
+
+

Available Doctors

+
+
+ +
+ +
+
+
Dr. name) ?>
+

Specialization: specialization ?? 'N/A') ?>

+ +
+ + + +
+ + +
+ +
+ + +
+ + +
+
+
+ +
+ +

No doctors available yet.

+ +
- - -
- - -

No doctors available yet.

- - -
- Logout -
- +
+ diff --git a/public/css/add_doctor.css b/public/css/add_doctor.css new file mode 100644 index 0000000..e3b7749 --- /dev/null +++ b/public/css/add_doctor.css @@ -0,0 +1,44 @@ +.experience-unit { + width: 96px; + justify-content: center; +} + +.experience-group .col-6:first-child .form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.experience-group .col-6:first-child .input-group-text { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.experience-group .col-6:last-child .form-control { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.experience-group .col-6:last-child .input-group-text { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.select2-container--default .select2-selection--multiple .select2-selection__choice { + margin-top: 0; + margin-bottom: 0; + padding: 0.1rem 0.45rem 0.1rem 1.05rem; + font-size: 0.90rem; + line-height: 1.15; +} + +.select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + left: 0rem; + top: 50%; + transform: translateY(-50%); + margin-right: 0; +} + +.ov-content { + display: flex; + justify-content: center; +} \ No newline at end of file diff --git a/public/css/app.css b/public/css/app.css index dd784d0..5b815ba 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -26,12 +26,12 @@ body.app-body { align-items: center; justify-content: center; padding: 1.5rem; - background: linear-gradient(160deg, #e0f2fe 0%, #ccfbf1 40%, #f0fdfa 100%); + background: linear-gradient(160deg, #fff 0%, #fff 40%, #fff 100%); } .auth-card { width: 100%; - max-width: 420px; + max-width: 420px; border: none !important; border-radius: var(--app-radius); background: var(--app-surface); @@ -114,6 +114,13 @@ a.btn-app-outline { border-color: #dc3545; } +/* Softer focus style to avoid strong blue outline */ +.app-form .form-control:focus, +.app-form .form-select:focus { + border-color: #9ec5fe; + box-shadow: 0 0 0 0.12rem rgba(13, 110, 253, 0.12); +} + .app-page--patient { min-height: 100vh; padding-bottom: 2rem; @@ -229,3 +236,80 @@ a.btn-app-outline { font-weight: 600; font-size: 0.875rem; } +.password-container { + position: relative; + width: 100%; +} + +.password-container input { + width: 100%; + padding-right: 2.5rem; +} + +.toggle-password { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + color: #64748b; + line-height: 1; +} + +.multi-select-arrow-wrap { + position: relative; +} + +.select2-arrow-hint { + position: absolute; + right: 0.85rem; + top: 50%; + transform: translateY(-50%); + color: #64748b; + pointer-events: auto; + cursor: pointer; + z-index: 2; +} + +/* Make Select2 single-line control visually match Bootstrap form controls */ +.select2-container .select2-selection--multiple { + min-height: calc(1.5em + 0.75rem + 2px); + height: calc(1.5em + 0.75rem + 2px); + border: 1px solid #ced4da; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + padding: 0.25rem 2rem 0.25rem 0.25rem; + overflow: hidden; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.select2-container .select2-selection--multiple .select2-selection__rendered { + font-size: 1rem; + font-weight: 400; + color: #495057; + line-height: 1.5; + padding: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.select2-container .select2-selection--multiple .select2-selection__placeholder { + color: #6c757d; + font-weight: 400; +} + +.select2-container .select2-search--inline .select2-search__field { + font-size: 15px; + font-weight: 400; + margin: 0; + line-height: 1.5; +} + +.select2-container--default.select2-container--focus .select2-selection--multiple, +.select2-container--default .select2-selection--multiple:focus { + border-color: #9ec5fe; + box-shadow: 0 0 0 0.12rem rgba(13, 110, 253, 0.12); +} diff --git a/public/css/dashboard.css b/public/css/dashboard.css new file mode 100644 index 0000000..9bf27cd --- /dev/null +++ b/public/css/dashboard.css @@ -0,0 +1,479 @@ +:root { + --sidebar-width: 230px; + --sidebar-bg: #1e293b; + --sidebar-text: #94a3b8; + --sidebar-text-active: #f1f5f9; + --topbar-bg: #ffffff; + --page-bg: #f8fafc; + --border: #e2e8f0; + --text: #1e293b; + --text-muted: #64748b; +} + +* { box-sizing: border-box; } + +body.overview-layout { + min-height: 100vh; + background: var(--page-bg); + display: flex; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--text); + margin: 0; + overflow-x: hidden; +} + +/* ── Sidebar ── */ +.ov-sidebar { + width: var(--sidebar-width); + min-height: 100vh; + background: var(--sidebar-bg); + display: flex; + flex-direction: column; + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 100; + overflow-x: hidden; +} + +.ov-brand { + padding: 1.25rem 1.25rem 1rem; + border-bottom: 1px solid rgba(255,255,255,0.07); +} + +.ov-brand h1 { + font-size: 1rem; + font-weight: 700; + color: #f1f5f9; + margin: 0; +} + +.ov-brand span { + font-size: 0.72rem; + color: var(--sidebar-text); +} + +.ov-nav { + padding: 0.75rem 0; + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.ov-nav__section { + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #475569; + padding: 0.75rem 1.25rem 0.25rem; +} + +.ov-nav__link { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 1.25rem; + color: var(--sidebar-text); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + border-left: 2px solid transparent; + transition: background 0.12s, color 0.12s; +} + +.ov-nav__link:hover { + background: rgba(255,255,255,0.05); + color: var(--sidebar-text-active); +} + +.ov-nav__link.active { + background: rgba(255,255,255,0.08); + color: var(--sidebar-text-active); + border-left-color: #38bdf8; +} + +.ov-nav__link i { font-size: 0.95rem; } + +.ov-sidebar__footer { + padding: 1rem 1.25rem; + border-top: 1px solid rgba(255,255,255,0.07); +} + +.ov-sidebar__footer a { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--sidebar-text); + font-size: 0.83rem; + text-decoration: none; +} + +.ov-sidebar__footer a:hover { color: #f1f5f9; } + +/* ── Main ── */ +.ov-main { + margin-left: var(--sidebar-width); + flex: 1; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ── Topbar ── */ +.ov-topbar { + background: var(--topbar-bg); + border-bottom: 1px solid var(--border); + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 50; +} + +.ov-topbar__title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text); + margin: 0; +} + +/* ── Profile / Avatar dropdown ── */ +.ov-profile { position: relative; } + +.ov-profile__btn { + display: flex; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 8px; + transition: background 0.12s; +} + +.ov-profile__btn:hover { background: #f1f5f9; } + +.ov-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + background: #334155; + display: flex; + align-items: center; + justify-content: center; + color: #f1f5f9; + font-size: 0.82rem; + font-weight: 700; + flex-shrink: 0; +} + +.ov-profile__name { + font-size: 0.83rem; + font-weight: 600; + color: var(--text); +} + +.ov-profile__caret { + font-size: 0.75rem; + color: var(--text-muted); +} + +.ov-dropdown { + display: none; + position: absolute; + right: 0; + top: calc(100% + 6px); + background: #fff; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0,0,0,0.1); + min-width: 180px; + z-index: 200; + padding: 0.4rem 0; +} + +.ov-dropdown.open { display: block; } + +.ov-dropdown__header { + padding: 0.6rem 1rem 0.5rem; + border-bottom: 1px solid var(--border); +} + +.ov-dropdown__name { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.ov-dropdown__role { + font-size: 0.72rem; + color: var(--text-muted); +} + +.ov-dropdown__item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.83rem; + color: var(--text); + text-decoration: none; + transition: background 0.1s; +} + +.ov-dropdown__item:hover { background: #f8fafc; color: var(--text); } +.ov-dropdown__item.danger { color: #dc2626; } +.ov-dropdown__item.danger:hover { background: #fef2f2; } + +.ov-dropdown__divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.3rem 0; +} + +/* ── Content ── */ +.ov-content { padding: 1.5rem; flex: 1; } + +/* ── Stat cards ── */ +.ov-stat { + background: #fff; + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.ov-stat__icon { + width: 44px; + height: 44px; + border-radius: 8px; + background: #f1f5f9; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + color: #475569; + flex-shrink: 0; +} + +.ov-stat__label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-bottom: 0.1rem; +} + +.ov-stat__value { + font-size: 1.75rem; + font-weight: 700; + color: var(--text); + line-height: 1; + margin: 0; +} + +/* ── Panels ── */ +.ov-panel { + background: #fff; + border: 1px solid var(--border); + border-radius: 10px; +} + +.ov-panel__header { + padding: 0.85rem 1.25rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ov-panel__title { + font-weight: 600; + font-size: 0.88rem; + color: var(--text); + margin: 0; +} + +.ov-panel__body { padding: 1rem 1.25rem; } + +/* ── Quick actions ── */ +.ov-action { + display: block; + text-align: center; + padding: 0.9rem 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #f8fafc; + color: var(--text); + text-decoration: none; + font-size: 0.8rem; + font-weight: 500; + transition: background 0.12s, border-color 0.12s; +} + +.ov-action:hover { + background: #f1f5f9; + border-color: #cbd5e1; + color: var(--text); +} + +.ov-action i { + display: block; + font-size: 1.35rem; + margin-bottom: 0.35rem; + color: #475569; +} + +/* ── Activity ── */ +.ov-activity-item { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.6rem 0; + border-bottom: 1px solid #f1f5f9; +} + +.ov-activity-item:last-child { border-bottom: none; } + +.ov-activity-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 0.35rem; + flex-shrink: 0; +} + +.ov-activity-text { + font-size: 0.82rem; + color: var(--text); +} + +.ov-activity-time { + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +/* ── Mini table ── */ +.ov-mini-table th { + background: #f8fafc; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + border-color: var(--border); +} + +.ov-mini-table td { + font-size: 0.83rem; + color: var(--text); + border-color: #f1f5f9; + vertical-align: middle; +} + +/* ── Badges ── */ +.ov-badge { + font-size: 0.7rem; + font-weight: 600; + border-radius: 999px; + padding: 0.18em 0.6em; +} + +.ov-badge--success { background: #dcfce7; color: #15803d; } +.ov-badge--warning { background: #fef9c3; color: #92400e; } +.ov-badge--danger { background: #fee2e2; color: #b91c1c; } + +/* ── Sidebar Toggle ── */ +.ov-sidebar { + transition: transform 0.25s ease; +} + +.ov-sidebar.collapsed { + transform: translateX(-100%); +} + +.ov-main { + transition: margin-left 0.25s ease; +} + +.ov-main.expanded { + margin-left: 0; +} + +.ov-toggle-btn { + background: none; + border: none; + cursor: pointer; + padding: 0.3rem 0.5rem; + border-radius: 6px; + color: var(--text-muted); + font-size: 1.2rem; + line-height: 1; + transition: background 0.12s; + margin-right: 0.75rem; +} + +.ov-toggle-btn:hover { + background: #f1f5f9; + color: var(--text); +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .ov-sidebar { display: none; } + .ov-main { margin-left: 0; } +} + +/* Hide dropdown initially */ +.ov-dropdown-menu { + display: none; + padding-left: 30px; +} + +/* Show when active */ +.ov-nav__dropdown.active .ov-dropdown-menu { + display: block; +} + +/* Sub-links */ +.ov-nav__sublink { + display: block; + padding: 8px 0; + font-size: 14px; + color: #aaa; + text-decoration: none; + transition: 0.3s; +} + +.ov-nav__sublink:hover { + color: #fff; + transform: translateX(5px); +} +/* Dropdown icon style */ +.dropdown-icon { + font-size: 12px; + color: inherit; + flex-shrink: 0; + transition: transform 0.3s ease; +} + +/* Rotate when open */ +.ov-nav__dropdown.active .dropdown-icon { + transform: rotate(180deg); +} +.ov-nav__link { + transition: 0.3s; +} + +.ov-nav__link:hover { + background: rgba(255,255,255,0.05); + border-radius: 8px; +} diff --git a/public/css/doctors.css b/public/css/doctors.css new file mode 100644 index 0000000..ae9048b --- /dev/null +++ b/public/css/doctors.css @@ -0,0 +1,77 @@ +.dataTables_wrapper { + padding: 1rem; +} + +.dataTables_filter input { + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 0.375rem 0.75rem; +} + +#doctorsTable thead th, +#patientsTable thead th { + white-space: nowrap; +} + +#doctorsTable.table.dataTable thead .sorting, +#doctorsTable.table.dataTable thead .sorting_asc, +#doctorsTable.table.dataTable thead .sorting_desc, +#doctorsTable.table.dataTable thead .sorting_asc_disabled, +#doctorsTable.table.dataTable thead .sorting_desc_disabled, +#patientsTable.table.dataTable thead .sorting, +#patientsTable.table.dataTable thead .sorting_asc, +#patientsTable.table.dataTable thead .sorting_desc, +#patientsTable.table.dataTable thead .sorting_asc_disabled, +#patientsTable.table.dataTable thead .sorting_desc_disabled { + background-position: center right 0.35rem; + padding-right: 2rem; +} + +.table-hover tbody tr:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.btn-group .btn { + border-radius: 0; +} + +.btn-group .btn:first-child { + border-top-left-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + +.btn-group .btn:last-child { + border-top-right-radius: 0.375rem; + border-bottom-right-radius: 0.375rem; +} + +.badge { + font-size: 0.75rem; +} + +.sort-indicator { + font-size: 0.85rem; + opacity: 0.55; +} + +.sort-indicator--active { + opacity: 0.9; + color: #0d6efd; +} + +.specialization-toggle { + cursor: pointer; +} + +.sort-icon-link { + display: inline-flex; + align-items: center; + margin-left: 0.2rem; + color: inherit; + text-decoration: none; +} + +.sort-icon-link:hover { + color: inherit; + text-decoration: none; +} diff --git a/public/js/script.js b/public/js/script.js new file mode 100644 index 0000000..dc8c5b4 --- /dev/null +++ b/public/js/script.js @@ -0,0 +1,14 @@ +function togglePassword() { + const password = document.getElementById("password"); + const icon = document.getElementById("eyeIcon"); + + if (password.type === "password") { + password.type = "text"; + icon.classList.remove("fa-eye"); + icon.classList.add("fa-eye-slash"); + } else { + password.type = "password"; + icon.classList.remove("fa-eye-slash"); + icon.classList.add("fa-eye"); + } +} \ No newline at end of file