updated_features

This commit is contained in:
Sayan Das 2026-04-03 15:54:05 +05:30
parent 3760875a3d
commit e38122209c
23 changed files with 936 additions and 84 deletions

70
.env.example Normal file
View File

@ -0,0 +1,70 @@
#--------------------------------------------------------------------
# Example Environment Configuration file
#
# This file can be used as a starting point for your own
# custom .env files, and contains most of the possible settings
# available in a default install.
#
# By default, all of the settings are commented out. If you want
# to override the setting, you must un-comment it by removing the '#'
# at the beginning of the line.
#--------------------------------------------------------------------
#--------------------------------------------------------------------
# ENVIRONMENT
#--------------------------------------------------------------------
CI_ENVIRONMENT = development
#--------------------------------------------------------------------
# APP
#--------------------------------------------------------------------
# Set this to your public URL (trailing slash). Example for XAMPP:
app.baseURL = 'http://localhost:8080/'
# If you have trouble with `.`, you could also use `_`.
# app_baseURL = ''
# app.forceGlobalSecureRequests = false
# app.CSPEnabled = false
#--------------------------------------------------------------------
# DATABASE
#--------------------------------------------------------------------
database.default.hostname = localhost
database.default.database =
database.default.username = root
database.default.password =
database.default.DBDriver = MySQLi
database.default.DBPrefix =
database.default.port = 3306
# If you use MySQLi as tests, first update the values of Config\Database::$tests.
# database.tests.hostname = localhost
# database.tests.database = ci4_test
# database.tests.username = root
# database.tests.password = root
# database.tests.DBDriver = MySQLi
# database.tests.DBPrefix =
# database.tests.charset = utf8mb4
# database.tests.DBCollat = utf8mb4_general_ci
# database.tests.port = 3306
#--------------------------------------------------------------------
# ENCRYPTION
#--------------------------------------------------------------------
encryption.key =
#--------------------------------------------------------------------
# SESSION
#--------------------------------------------------------------------
session.driver = 'CodeIgniter\Session\Handlers\FileHandler'
# session.savePath = null
#--------------------------------------------------------------------
# LOGGER
#--------------------------------------------------------------------
# logger.threshold = 4

View File

@ -40,7 +40,7 @@ class App extends BaseConfig
* something else. If you have configured your web server to remove this file * something else. If you have configured your web server to remove this file
* from your site URIs, set this variable to an empty string. * from your site URIs, set this variable to an empty string.
*/ */
public string $indexPage = 'index.php'; public string $indexPage = '';
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------

View File

@ -29,7 +29,7 @@ class Database extends Config
'hostname' => 'localhost', 'hostname' => 'localhost',
'username' => 'root', 'username' => 'root',
'password' => '', 'password' => '',
'database' => 'doctor_appointment_system', 'database' => '',
'DBDriver' => 'MySQLi', 'DBDriver' => 'MySQLi',
'DBPrefix' => '', 'DBPrefix' => '',
'pConnect' => false, 'pConnect' => false,

View File

@ -12,6 +12,7 @@ use CodeIgniter\Filters\InvalidChars;
use CodeIgniter\Filters\PageCache; use CodeIgniter\Filters\PageCache;
use CodeIgniter\Filters\PerformanceMetrics; use CodeIgniter\Filters\PerformanceMetrics;
use CodeIgniter\Filters\SecureHeaders; use CodeIgniter\Filters\SecureHeaders;
use App\Filters\AuthSession;
class Filters extends BaseFilters class Filters extends BaseFilters
{ {
@ -34,6 +35,7 @@ class Filters extends BaseFilters
'forcehttps' => ForceHTTPS::class, 'forcehttps' => ForceHTTPS::class,
'pagecache' => PageCache::class, 'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class, 'performance' => PerformanceMetrics::class,
'authSession' => AuthSession::class,
]; ];
/** /**
@ -72,7 +74,7 @@ class Filters extends BaseFilters
*/ */
public array $globals = [ public array $globals = [
'before' => [ 'before' => [
'csrf', // 'csrf', // temporarily disabled for local testing
], ],
'after' => [ 'after' => [
// 'honeypot', // 'honeypot',
@ -104,5 +106,9 @@ class Filters extends BaseFilters
* *
* @var array<string, array<string, list<string>>> * @var array<string, array<string, list<string>>>
*/ */
public array $filters = []; public array $filters = [
'authSession' => [
'before' => ['admin/*','doctor/*','patient/*','logout',],
],
];
} }

View File

@ -7,12 +7,33 @@ use CodeIgniter\Router\RouteCollection;
*/ */
// $routes->get('/', 'Home::index'); // $routes->get('/', 'Home::index');
$routes->get('/', 'Auth::login'); $routes->get('/', 'Auth::login');
$routes->post('/login', 'Auth::loginProcess'); $routes->post('/login', 'Auth::loginProcess', ['as' => 'login']);
$routes->get('/register', 'Auth::register'); $routes->get('/register', 'Auth::register');
$routes->post('/register', 'Auth::registerProcess'); $routes->post('/register', 'Auth::registerProcess');
$routes->get('/logout', 'Auth::logout'); $routes->get('/logout', 'Auth::logout');
$routes->get('/admin/dashboard', 'Admin::dashboard'); $routes->get('/admin/dashboard', 'Admin::dashboard');
$routes->get('/admin/doctors', 'Admin::doctors');
$routes->get('/admin/doctors/add', 'Admin::addDoctor');
$routes->post('/admin/doctors/add', 'Admin::storeDoctor');
$routes->get('/admin/patients', 'Admin::patients'); $routes->get('/admin/patients', 'Admin::patients');
$routes->get('/admin/deletePatient/(:num)', 'Admin::deletePatient/$1'); $routes->get('/admin/appointments', 'Admin::appointments');
$routes->get('/patient/dashboard', 'Patient::dashboard');
$routes->get('/admin/deleteDoctor/(:num)', 'Admin::deleteDoctor/$1');
$routes->get('/admin/deletePatient/(:num)', 'Admin::deletePatient/$1');
$routes->get('/patient/dashboard', 'Patient::dashboard');
$routes->post('/book-appointment', 'Patient::bookAppointment');
$routes->get('/doctor/dashboard', 'Doctor::dashboard');
$routes->get('/doctor/profile', 'Doctor::profile');
$routes->post('/doctor/profile', 'Doctor::profile');
$routes->post('/doctor/appointment/(:num)/accept', 'Doctor::accept/$1');
$routes->post('/doctor/appointment/(:num)/reject', 'Doctor::reject/$1');
$routes->get('/forgot-password', 'Auth::forgotPassword');
$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');

View File

@ -3,7 +3,9 @@
namespace App\Controllers; namespace App\Controllers;
use App\Models\UserModel; use App\Models\UserModel;
use App\Models\DoctorModel;
use App\Models\PatientModel; use App\Models\PatientModel;
use App\Models\AppointmentModel;
class Admin extends BaseController class Admin extends BaseController
{ {
@ -12,10 +14,62 @@ class Admin extends BaseController
if ($r = $this->requireRole('admin')) { if ($r = $this->requireRole('admin')) {
return $r; return $r;
} }
$patientModel = new PatientModel();
$doctorModel = new DoctorModel();
$patientModel = new PatientModel();
$appointmentModel = new AppointmentModel();
$data['totalDoctors'] = $doctorModel->countAll();
$data['totalPatients'] = $patientModel->countAll(); $data['totalPatients'] = $patientModel->countAll();
$data['totalAppointments'] = $appointmentModel->countAll();
return view('admin/dashboard', $data); return view('admin/dashboard', $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);
}
public function deleteDoctor($id)
{
if ($r = $this->requireRole('admin')) {
return $r;
}
$id = (int) $id;
if ($id < 1) {
return redirect()->to(site_url('admin/doctors'));
}
$userModel = new UserModel();
$doctorModel = new DoctorModel();
$db = \Config\Database::connect();
$doctor = $doctorModel->where('user_id', $id)->first();
if ($doctor) {
$db->table('appointments')->where('doctor_id', $doctor['id'])->delete();
$doctorModel->delete($doctor['id']);
}
$userModel->delete($id);
return redirect()->to(site_url('admin/doctors'));
}
public function patients() public function patients()
{ {
@ -63,4 +117,159 @@ class Admin extends BaseController
return redirect()->to(site_url('admin/patients')); return redirect()->to(site_url('admin/patients'));
} }
public function appointments()
{
if ($r = $this->requireRole('admin')) {
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();
return view('admin/appointments', $data);
}
public function addDoctor()
{
if ($r = $this->requireRole('admin')) {
return $r;
}
return view('admin/add_doctor');
}
public function storeDoctor()
{
if ($r = $this->requireRole('admin')) {
return $r;
}
$userModel = new UserModel();
$doctorModel = new DoctorModel();
$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'),
]];
}
$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;
}
if ($error !== null || $cleanRows === []) {
return redirect()->back()->withInput()->with('error', $error ?? 'Please provide at least one doctor row.');
}
$db->transStart();
foreach ($cleanRows as $row) {
$userData = [
'name' => $row['name'],
'email' => $row['email'],
'password' => password_hash($row['password'], PASSWORD_DEFAULT),
'role' => 'doctor',
'status' => 'active',
];
if (! $userModel->skipValidation(true)->insert($userData)) {
$db->transRollback();
return redirect()->back()->withInput()->with('error', 'Could not create user for ' . $row['email'] . '.');
}
$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,
];
if (! $doctorModel->skipValidation(true)->insert($doctorRow)) {
$db->transRollback();
return redirect()->back()->withInput()->with('error', 'Could not create doctor profile for ' . $row['email'] . '.');
}
}
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->back()->withInput()->with('error', 'Transaction failed.');
}
$created = count($cleanRows);
return redirect()->to(site_url('admin/doctors'))->with('success', $created . ' doctor account(s) created.');
}
} }

View File

@ -20,8 +20,9 @@ class Auth extends BaseController
public function registerProcess() public function registerProcess()
{ {
$rules = [ $rules = [
'name' => 'required|min_length[3]|max_length[100]', 'name' => 'required|min_length[3]|max_length[100]|alpha_numeric_punct',
'email' => 'required|valid_email|is_unique[users.email]', 'email' => 'required|valid_email|is_unique[users.email]',
'phone' => 'required|min_length[10]|max_length[10]',
'password' => 'required|min_length[8]', 'password' => 'required|min_length[8]',
]; ];
@ -36,6 +37,7 @@ class Auth extends BaseController
'email' => $this->request->getPost('email'), 'email' => $this->request->getPost('email'),
'password' => password_hash((string) $this->request->getPost('password'), PASSWORD_DEFAULT), 'password' => password_hash((string) $this->request->getPost('password'), PASSWORD_DEFAULT),
'role' => 'patient', 'role' => 'patient',
'status' => 'active',
]; ];
if (! $userModel->skipValidation(true)->insert($data)) { if (! $userModel->skipValidation(true)->insert($data)) {
@ -45,7 +47,10 @@ class Auth extends BaseController
$user_id = $userModel->getInsertID(); $user_id = $userModel->getInsertID();
$patientModel = new PatientModel(); $patientModel = new PatientModel();
$patientModel->insert(['user_id' => $user_id]); $patientModel->insert([
'user_id' => $user_id,
'phone' => $this->request->getPost('phone'),
]);
return redirect()->to(site_url('/'))->with('success', 'Account created. You can log in now.'); return redirect()->to(site_url('/'))->with('success', 'Account created. You can log in now.');
} }
@ -69,9 +74,17 @@ class Auth extends BaseController
$user = $userModel->where('email', $email)->first(); $user = $userModel->where('email', $email)->first();
if ($user && password_verify((string) $password, $user['password'])) { if ($user && password_verify((string) $password, $user['password'])) {
$loginToken = bin2hex(random_bytes(32));
if (! $userModel->update($user['id'], ['session_token' => $loginToken])) {
return redirect()->back()->withInput()->with('error', 'Could not start session. Please try again.');
}
session()->regenerate();
session()->set([ session()->set([
'id' => $user['id'], 'id' => $user['id'],
'role' => $user['role'], 'role' => $user['role'],
'login_token' => $loginToken,
]); ]);
if ($user['role'] === 'admin') { if ($user['role'] === 'admin') {
@ -89,8 +102,93 @@ class Auth extends BaseController
public function logout() public function logout()
{ {
$userId = (int) session()->get('id');
$token = (string) session()->get('login_token');
if ($userId > 0 && $token !== '') {
$db = \Config\Database::connect();
$db->table('users')
->where('id', $userId)
->where('session_token', $token)
->update(['session_token' => null]);
}
session()->destroy(); session()->destroy();
return redirect()->to(site_url('/')); return redirect()->to(site_url('/'));
} }
public function forgotPassword()
{
return view('auth/forgot_password');
}
public function processForgotPassword()
{
$rules = [
'email' => 'required|valid_email',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput();
}
$userModel = new UserModel();
$email = $this->request->getPost('email');
$user = $userModel->where('email', $email)->first();
if (! $user) {
return redirect()->back()->with('error', 'Email not found.');
}
$resetToken = bin2hex(random_bytes(32));
$tokenExpires = date('Y-m-d H:i:s', strtotime('+30 minutes'));
$userModel->update($user['id'], [
'reset_token' => $resetToken,
'reset_token_expires' => $tokenExpires,
]);
$resetLink = site_url("reset-password/$resetToken");
return redirect()->back()->with('success', "Reset link: <a href='$resetLink'>$resetLink</a>");
}
public function resetPassword($token)
{
$userModel = new UserModel();
$user = $userModel->where('reset_token', $token)->first();
if (! $user || strtotime($user['reset_token_expires']) < time()) {
return redirect()->to(site_url('/'))->with('error', 'Invalid or expired reset link.');
}
return view('auth/reset_password', ['token' => $token]);
}
public function processResetPassword()
{
$rules = [
'token' => 'required',
'password' => 'required|min_length[8]',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput();
}
$userModel = new UserModel();
$token = $this->request->getPost('token');
$newPassword = $this->request->getPost('password');
$user = $userModel->where('reset_token', $token)->first();
if (! $user || strtotime($user['reset_token_expires']) < time()) {
return redirect()->to(site_url('/'))->with('error', 'Invalid or expired reset link.');
}
$userModel->update($user['id'], [
'password' => password_hash($newPassword, PASSWORD_DEFAULT),
'reset_token' => null,
'reset_token_expires' => null,
]);
return redirect()->to(site_url('/'))->with('success', 'Password reset successful. You can now login.');
}
} }

View File

@ -132,7 +132,7 @@ class Doctor extends BaseController
return redirect()->back()->with('error', 'Invalid appointment.'); return redirect()->back()->with('error', 'Invalid appointment.');
} }
$status = \App\Models\AppointmentModel::normalizeStatus($status); $status = AppointmentModel::normalizeStatus($status);
$appointmentModel->update($appointmentId, ['status' => $status]); $appointmentModel->update($appointmentId, ['status' => $status]);
return redirect()->back()->with('success', 'Appointment updated.'); return redirect()->back()->with('success', 'Appointment updated.');

View File

@ -2,7 +2,7 @@
namespace App\Controllers; namespace App\Controllers;
use App\Models\AppointmentModel;
use App\Models\PatientModel; use App\Models\PatientModel;
class Patient extends BaseController class Patient extends BaseController
@ -43,7 +43,7 @@ class Patient extends BaseController
JOIN doctors ON doctors.id = a.doctor_id JOIN doctors ON doctors.id = a.doctor_id
JOIN users u ON u.id = doctors.user_id JOIN users u ON u.id = doctors.user_id
WHERE a.patient_id = ? WHERE a.patient_id = ?
ORDER BY a.appointment_date DESC, a.appointment_time DESC ORDER BY a.appointment_date ASC, a.appointment_time ASC
', [$patient['id']])->getResult(); ', [$patient['id']])->getResult();
} }
@ -66,7 +66,7 @@ class Patient extends BaseController
return redirect()->back()->withInput(); return redirect()->back()->withInput();
} }
$appointmentModel = new AppointmentModel();
$patientModel = new PatientModel(); $patientModel = new PatientModel();
$userId = (int) session()->get('id'); $userId = (int) session()->get('id');
@ -85,6 +85,21 @@ class Patient extends BaseController
'appointment_time' => $appointmentTime, 'appointment_time' => $appointmentTime,
]; ];
$taken = $appointmentModel
->where('doctor_id', $data['doctor_id'])
->where('appointment_date', $data['appointment_date'])
->where('appointment_time', $appointmentTime)
->whereIn('status', ['pending', 'approved'])
->first();
if ($taken) {
return redirect()->back()->withInput()->with('error', 'That time slot is already booked for this doctor. Please choose another date or time.');
}
if (! $appointmentModel->insert($data)) {
return redirect()->back()->withInput()->with('error', 'Could not book appointment.');
}
return redirect()->to(site_url('patient/dashboard'))->with('success', 'Appointment requested.');
} }
} }

View File

@ -53,7 +53,7 @@ class InitAppointmentSchema extends Migration
'doctor_id' => ['type' => 'INT', 'unsigned' => true], 'doctor_id' => ['type' => 'INT', 'unsigned' => true],
'appointment_date' => ['type' => 'DATE'], 'appointment_date' => ['type' => 'DATE'],
'appointment_time' => ['type' => 'TIME'], 'appointment_time' => ['type' => 'TIME'],
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'pending'], 'status' => ['type' => 'ENUM', 'constraint' => ['pending', 'approved', 'rejected'], 'default' => 'pending'],
]); ]);
$this->forge->addKey('id', true); $this->forge->addKey('id', true);
$this->forge->addKey(['doctor_id', 'appointment_date', 'appointment_time']); $this->forge->addKey(['doctor_id', 'appointment_date', 'appointment_time']);

View File

@ -0,0 +1,21 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddPasswordResetColumns extends Migration
{
public function up(): void
{
$this->forge->addColumn('users', [
'reset_token' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'reset_token_expires' => ['type' => 'DATETIME', 'null' => true],
]);
}
public function down(): void
{
$this->forge->dropColumn('users', ['reset_token', 'reset_token_expires']);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UpdateAppointmentStatusEnum extends Migration
{
public function up(): void
{
// Ensure enum values match the application status values.
$this->forge->modifyColumn('appointments', [
'status' => [
'name' => 'status',
'type' => 'ENUM',
'constraint' => ['pending', 'approved', 'rejected'],
'default' => 'pending',
'null' => false,
],
]);
// Convert legacy status values
$db = \Config\Database::connect();
$db->query("UPDATE `appointments` SET `status`='pending' WHERE `status` IS NULL OR `status`='' OR `status` NOT IN ('pending', 'approved', 'rejected')");
$db->query("UPDATE `appointments` SET `status`='approved' WHERE `status`='confirmed'");
$db->query("UPDATE `appointments` SET `status`='rejected' WHERE `status`='cancelled'");
}
public function down(): void
{
$this->forge->modifyColumn('appointments', [
'status' => [
'name' => 'status',
'type' => 'ENUM',
'constraint' => ['pending', 'confirmed', 'cancelled'],
'default' => 'pending',
'null' => false,
],
]);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSessionTokenToUsers extends Migration
{
public function up(): void
{
$fields = [
'session_token' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
'after' => 'status',
],
];
$this->forge->addColumn('users', $fields);
}
public function down(): void
{
$this->forge->dropColumn('users', 'session_token');
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Filters;
use App\Models\UserModel;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class AuthSession implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$session = session();
$userId = (int) $session->get('id');
$role = (string) $session->get('role');
$token = (string) $session->get('login_token');
if ($userId < 1 || $role === '' || $token === '') {
$session->destroy();
return redirect()->to(site_url('/'))->with('error', 'Please login first.');
}
$userModel = new UserModel();
$user = $userModel->where('id', $userId)->where('role', $role)->first();
if (! $user || empty($user['session_token']) || ! hash_equals((string) $user['session_token'], $token)) {
$session->destroy();
return redirect()->to(site_url('/'))->with('error', 'Your session ended because your account logged in from another device/browser.');
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// No-op
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AppointmentModel extends Model
{
protected $table = 'appointments';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'patient_id',
'doctor_id',
'appointment_date',
'appointment_time',
'status'
];
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;
protected array $casts = [];
protected array $castHandlers = [];
// Dates
protected $useTimestamps = false;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = ['setDefaultStatus'];
protected $afterInsert = [];
protected static array $validStatuses = ['pending', 'approved', 'rejected'];
public static function normalizeStatus(?string $status): string
{
$status = trim((string) $status);
if ($status === '' || ! in_array($status, self::$validStatuses, true)) {
return 'pending';
}
return $status;
}
protected function setDefaultStatus(array $eventData): array
{
if (! isset($eventData['data']['status']) || trim((string) $eventData['data']['status']) === '') {
$eventData['data']['status'] = 'pending';
} else {
$eventData['data']['status'] = self::normalizeStatus($eventData['data']['status']);
}
return $eventData;
}
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}

View File

@ -13,7 +13,14 @@ class UserModel extends Model
protected $useSoftDeletes = false; protected $useSoftDeletes = false;
protected $protectFields = true; protected $protectFields = true;
protected $allowedFields = [ protected $allowedFields = [
'name','email','password','role','status' 'name',
'email',
'password',
'role',
'status',
'session_token',
'reset_token',
'reset_token_expires',
]; ];
protected bool $allowEmptyInserts = false; protected bool $allowEmptyInserts = false;

View File

@ -7,88 +7,143 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>"> <link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
</head> </head>
<body class="app-body app-page--admin"> <body class="app-body app-page--admin"5>
<?php $validationErrors = validation_errors(); ?> <?php
$oldDoctors = old('doctors');
if (! is_array($oldDoctors) || $oldDoctors === []) {
$oldDoctors = [[
'name' => '',
'email' => '',
'password' => '',
'specialization' => '',
'experience' => '',
'fees' => '',
'available_from' => '',
'available_to' => '',
]];
}
?>
<div class="container py-5" style="max-width: 560px;"> <div class="container py-5" style="max-width: 980px;">
<h2 class="text-center mb-4 app-heading">Add doctor</h2> <h2 class="text-center mb-4 app-heading">Add Doctors</h2>
<?php if (session()->getFlashdata('error')): ?> <?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger app-alert text-center"><?= esc(session()->getFlashdata('error')) ?></div> <div class="alert alert-danger app-alert text-center"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?> <?php endif; ?>
<form method="post" action="<?= base_url('admin/doctors/add') ?>" class="app-form card app-card-dashboard p-4" novalidate> <form method="post" action="<?= base_url('admin/doctors/add') ?>" class="app-form card app-card-dashboard p-4" novalidate id="bulk-doctor-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<label class="form-label" for="name">Full name</label> <p class="mb-0 text-muted">Add one or many doctors, then submit once.</p>
<input type="text" name="name" id="name" value="<?= esc(old('name')) ?>" <button type="button" class="btn btn-sm btn-outline-primary rounded-pill px-3" id="add-doctor-row">+ Add another doctor</button>
class="form-control <?= isset($validationErrors['name']) ? 'is-invalid' : '' ?>">
<?= validation_show_error('name') ?>
</div> </div>
<div class="mb-3"> <div id="doctor-rows">
<label class="form-label" for="email">Email (login)</label> <?php foreach ($oldDoctors as $index => $doctor): ?>
<input type="text" name="email" id="email" value="<?= esc(old('email')) ?>" <div class="border rounded-4 p-3 mb-3 doctor-row" data-row>
class="form-control <?= isset($validationErrors['email']) ? 'is-invalid' : '' ?>" autocomplete="off"> <div class="d-flex justify-content-between align-items-center mb-3">
<?= validation_show_error('email') ?> <h6 class="mb-0">Doctor No: <span data-row-number><?= $index + 1 ?></span></h6>
</div> <button type="button" class="btn btn-sm btn-outline-danger remove-row" <?= $index === 0 ? 'style="display:none"' : '' ?>>Remove</button>
</div>
<div class="mb-3"> <div class="row g-3">
<label class="form-label" for="password"> Password</label> <div class="col-md-6">
<input type="password" name="password" id="password" <label class="form-label">Full name</label>
class="form-control <?= isset($validationErrors['password']) ? 'is-invalid' : '' ?>" autocomplete="new-password"> <input type="text" name="doctors[<?= $index ?>][name]" value="<?= esc($doctor['name'] ?? '') ?>" class="form-control">
<?= validation_show_error('password') ?> </div>
</div> <div class="col-md-6">
<label class="form-label">Email (login)</label>
<input type="email" name="doctors[<?= $index ?>][email]" value="<?= esc($doctor['email'] ?? '') ?>" class="form-control" autocomplete="off">
</div>
<div class="mb-3"> <div class="col-md-6">
<label class="form-label" for="specialization">Specialization</label> <label class="form-label">Password</label>
<input type="text" name="specialization" id="specialization" value="<?= esc(old('specialization')) ?>" <input type="password" name="doctors[<?= $index ?>][password]" value="<?= esc($doctor['password'] ?? '') ?>" class="form-control" autocomplete="new-password">
class="form-control <?= isset($validationErrors['specialization']) ? 'is-invalid' : '' ?>" </div>
placeholder="e.g. Cardiology"> <div class="col-md-6">
<?= validation_show_error('specialization') ?> <label class="form-label">Specialization</label>
</div> <input type="text" name="doctors[<?= $index ?>][specialization]" value="<?= esc($doctor['specialization'] ?? '') ?>" class="form-control" placeholder="e.g. Cardiology">
</div>
<div class="mb-3"> <div class="col-md-4">
<label class="form-label" for="experience">Experience (optional)</label> <label class="form-label">Experience (optional)</label>
<input type="text" name="experience" id="experience" value="<?= esc(old('experience')) ?>" <input type="text" name="doctors[<?= $index ?>][experience]" value="<?= esc($doctor['experience'] ?? '') ?>" class="form-control" placeholder="e.g. 10 years">
class="form-control" </div>
placeholder="e.g. 10 years"> <div class="col-md-4">
<?= validation_show_error('experience') ?> <label class="form-label">Consultation fee (optional)</label>
</div> <input type="number" name="doctors[<?= $index ?>][fees]" value="<?= esc($doctor['fees'] ?? '') ?>" class="form-control" placeholder="e.g. 500.00" step="0.01" min="0">
</div>
<div class="mb-3"> <div class="col-md-2">
<label class="form-label" for="fees">Consultation fee (optional)</label> <label class="form-label">Available from</label>
<input type="number" name="fees" id="fees" value="<?= esc(old('fees')) ?>" <input type="time" name="doctors[<?= $index ?>][available_from]" value="<?= esc($doctor['available_from'] ?? '') ?>" class="form-control">
class="form-control" </div>
placeholder="e.g. 500.00" step="0.01" min="0"> <div class="col-md-2">
<?= validation_show_error('fees') ?> <label class="form-label">Available to</label>
</div> <input type="time" name="doctors[<?= $index ?>][available_to]" value="<?= esc($doctor['available_to'] ?? '') ?>" class="form-control">
</div>
<div class="row"> </div>
<div class="col-md-6 mb-3"> </div>
<label class="form-label" for="available_from">Available from</label> <?php endforeach; ?>
<input type="time" name="available_from" id="available_from" value="<?= esc(old('available_from')) ?>"
class="form-control <?= isset($validationErrors['available_from']) ? 'is-invalid' : '' ?>">
<?= validation_show_error('available_from') ?>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="available_to">Available to</label>
<input type="time" name="available_to" id="available_to" value="<?= esc(old('available_to')) ?>"
class="form-control <?= isset($validationErrors['available_to']) ? 'is-invalid' : '' ?>">
<?= validation_show_error('available_to') ?>
</div>
</div> </div>
<div class="d-flex flex-wrap gap-2 justify-content-between mt-4"> <div class="d-flex flex-wrap gap-2 justify-content-between mt-4">
<a href="<?= base_url('admin/dashboard') ?>" class="btn btn-outline-secondary rounded-pill px-4">Cancel</a> <a href="<?= base_url('admin/dashboard') ?>" class="btn btn-outline-secondary rounded-pill px-4">Cancel</a>
<button type="submit" class="btn btn-app-primary px-4">Create doctor</button> <button type="submit" class="btn btn-app-primary px-4">Create doctors</button>
</div> </div>
</form> </form>
</div> </div>
<script>
(() => {
const rowsContainer = document.getElementById('doctor-rows');
const addBtn = document.getElementById('add-doctor-row');
const updateRowNumbers = () => {
const rows = rowsContainer.querySelectorAll('[data-row]');
rows.forEach((row, index) => {
const numberEl = row.querySelector('[data-row-number]');
if (numberEl) numberEl.textContent = String(index + 1);
row.querySelectorAll('input').forEach((input) => {
const name = input.getAttribute('name');
if (!name) return;
input.setAttribute('name', name.replace(/doctors\[\d+]/, `doctors[${index}]`));
});
const removeBtn = row.querySelector('.remove-row');
if (removeBtn) {
removeBtn.style.display = rows.length === 1 ? 'none' : 'inline-block';
}
});
};
addBtn.addEventListener('click', () => {
const firstRow = rowsContainer.querySelector('[data-row]');
if (!firstRow) return;
const clone = firstRow.cloneNode(true);
clone.querySelectorAll('input').forEach((input) => {
input.value = '';
});
rowsContainer.appendChild(clone);
updateRowNumbers();
});
rowsContainer.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.classList.contains('remove-row')) {
return;
}
const row = target.closest('[data-row]');
if (!row) return;
row.remove();
updateRowNumbers();
});
updateRowNumbers();
})();
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Appointments</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
</head>
<body class="app-body app-page--admin">
<div class="container py-5">
<h2 class="text-center mb-4 app-heading">Appointments</h2>
<div class="app-table-wrap">
<table class="table table-striped table-hover text-center align-middle">
<thead>
<tr>
<th>Sl No</th>
<th>Patient</th>
<th>Doctor</th>
<th>Date</th>
<th>Time</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php $i = 1; ?>
<?php foreach ($appointments as $a): ?>
<tr>
<td><?= $i++ ?></td>
<td><?= esc($a->patient_name) ?></td>
<td><?= esc($a->doctor_name) ?></td>
<td><?= esc($a->appointment_date) ?></td>
<td><?= esc($a->appointment_time) ?></td>
<?php $status = trim((string) ($a->status ?? '')); ?>
<td><?= esc(ucfirst($status === '' ? 'pending' : $status)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="text-center mt-4">
<a href="<?= base_url('admin/dashboard') ?>" class="btn btn-app-outline px-4">Back to dashboard</a>
</div>
</div>
</body>
</html>

View File

@ -18,7 +18,7 @@
<table class="table table-bordered table-hover text-center align-middle"> <table class="table table-bordered table-hover text-center align-middle">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>Sl No</th>
<th>Name</th> <th>Name</th>
<th>Phone</th> <th>Phone</th>
<th>Action</th> <th>Action</th>

View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Forgot Password</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
</head>
<body class="app-body app-page--auth">
<div class="card auth-card shadow">
<div class="card-body">
<h2 class="text-center mb-4 auth-title">Forgot Password</h2>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success app-alert">
<?= session()->getFlashdata('success') ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger app-alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<form method="post" action="<?= base_url('forgot-password') ?>" class="app-form" novalidate>
<?= csrf_field() ?>
<div class="mb-3">
<label for="email" class="form-label">Email Address</label>
<input type="email" name="email" id="email" class="form-control"
value="<?= esc(old('email')) ?>" required>
<?php if (session()->has('errors.email')): ?>
<div class="text-danger small mt-1"><?= session('errors.email') ?></div>
<?php endif; ?>
</div>
<button type="submit" class="btn btn-primary w-100">Send Reset Link</button>
</form>
<div class="text-center mt-3">
<p class="text-muted">
Remember your password? <a href="<?= base_url('/') ?>">Login here</a>
</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -22,7 +22,7 @@
<div class="alert alert-danger app-alert"><?= esc(session()->getFlashdata('error')) ?></div> <div class="alert alert-danger app-alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?> <?php endif; ?>
<form method="post" action="<?= base_url('login') ?>" class="app-form" novalidate> <form method="post" action="<?= route_to('login') ?>" class="app-form" novalidate>
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="mb-3"> <div class="mb-3">
@ -44,6 +44,10 @@
<button type="submit" class="btn btn-app-primary w-100">Login</button> <button type="submit" class="btn btn-app-primary w-100">Login</button>
</form> </form>
<div class="text-center mt-3">
<a href="<?= base_url('forgot-password') ?>" class="btn btn-link">Forgot Password?</a>
</div>
<div class="text-center mt-4"> <div class="text-center mt-4">
<p class="auth-divider-text mb-2">Don't have an account?</p> <p class="auth-divider-text mb-2">Don't have an account?</p>
<a href="<?= base_url('register') ?>" class="btn-app-secondary">Create account</a> <a href="<?= base_url('register') ?>" class="btn-app-secondary">Create account</a>

View File

@ -38,6 +38,14 @@
<?= validation_show_error('email') ?> <?= validation_show_error('email') ?>
</div> </div>
<div class="mb-3">
<label for="phone" class="form-label">Phone</label>
<input type="phone" name="phone" id="phone" value="<?= esc(old('phone')) ?>"
class="form-control <?= isset($validationErrors['phone']) ? 'is-invalid' : '' ?>"
placeholder="Phone number" autocomplete="tel" required>
<?= validation_show_error('phone') ?>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<input type="password" name="password" id="password" <input type="password" name="password" id="password"

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reset Password</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
</head>
<body class="app-body app-page--auth">
<div class="card auth-card shadow">
<div class="card-body">
<h2 class="text-center mb-4 auth-title">Reset Password</h2>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success app-alert"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger app-alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<form method="post" action="<?= base_url('reset-password') ?>" class="app-form" novalidate>
<?= csrf_field() ?>
<input type="hidden" name="token" value="<?= esc($token) ?>">
<div class="mb-3">
<label for="password" class="form-label">New Password</label>
<input type="password" name="password" id="password" class="form-control"
placeholder="Enter new password (min 8 characters)" required>
<?php if (session()->has('errors.password')): ?>
<div class="text-danger small mt-1"><?= session('errors.password') ?></div>
<?php endif; ?>
</div>
<button type="submit" class="btn btn-primary w-100">Reset Password</button>
</form>
<div class="text-center mt-3">
<p class="text-muted">
<a href="<?= base_url('/') ?>">Back to Login</a>
</p>
</div>
</div>
</div>
</body>
</html>