updated code

This commit is contained in:
Sayan Das 2026-05-08 12:22:08 +05:30
parent ad398e7627
commit a2bb5b48db
50 changed files with 6537 additions and 490 deletions

View File

@ -19,7 +19,7 @@ class Images extends BaseConfig
*
* @deprecated 4.7.0 No longer used.
*/
public string $libraryPath = '/usr/local/bin/convert';
b public string $libraryPath = '/usr/local/bin/convert';
/**
* The available handler classes.

View File

@ -24,6 +24,10 @@ $routes->post('/admin/patients/add', 'Admin::storePatient');
$routes->get('/admin/patients/edit/(:any)', 'Admin::editPatient/$1');
$routes->post('/admin/patients/edit/(:any)', 'Admin::updatePatient/$1');
$routes->get('/admin/appointments', 'Admin::appointments');
$routes->get('/admin/appointment-preview', 'Admin::appointmentPreview');
$routes->post('/admin/appointments/create', 'Admin::createAppointment');
$routes->post('/admin/appointments/update-status', 'Admin::updateAppointmentStatus');
$routes->get('/admin/appointments/delete/(:num)', 'Admin::deleteAppointment/$1');
$routes->get('/admin/deleteDoctor/(:num)', 'Admin::deleteDoctor/$1');
$routes->get('/admin/deletePatient/(:num)', 'Admin::deletePatient/$1');
@ -42,13 +46,19 @@ $routes->post('/forgot-password', 'Auth::processForgotPassword');
$routes->get('/reset-password/(:any)', 'Auth::resetPassword/$1');
$routes->post('/reset-password', 'Auth::processResetPassword');
$routes->get('/admin/dashboard', 'Admin::dashboard');
$routes->post('/check-email', 'Admin::checkEmail');
$routes->get('admin/doctors/data', 'Admin::getDoctors');
$routes->get('admin/patients/data', 'Admin::getPatients');
$routes->get('admin/appointmentsData', 'Admin::appointmentsData');
$routes->get('/admin/activity-log', 'ActivityLog::index');
$routes->get('/admin/activity/analytics', 'ActivityLog::analytics');
$routes->post('/admin/activity-log/clear', 'ActivityLog::clear');
// $routes->post('/admin/activity-log/datatable', 'ActivityLog::datatable');
// $routes->get('/admin/activity-log/summary', 'ActivityLog::getSummary');
// $routes->get('/admin/activity-log/critical', 'ActivityLog::getCritical');
$routes->post('admin/available-doctors', 'Admin::getAvailableDoctorsForPatient');
$routes->post('admin/check-doctor-status', 'Admin::checkDoctorStatus');
$routes->get('admin/specializations', 'Admin::getSpecializations');
$routes->post('admin/availability/save', 'AvailabilityController::save');
$routes->get('admin/availability/(:num)', 'AvailabilityController::getAvailability/$1');
$routes->get('admin/availability/diagnostic', 'AvailabilityController::diagnostic');
$routes->get('/admin/patients/add/(:num)', 'Admin::addPatient/$1');
$routes->get('admin/appointments/create-form/(:num)', 'Admin::appointmentForm/$1');
$routes->get('/admin/doctor-search', 'Admin::doctorSearch');

View File

@ -12,85 +12,39 @@ class ActivityLog extends BaseController
return $r;
}
$activityModel = new ActivityLogModel();
$logModel = new ActivityLogModel();
$summary = $logModel->getActivitySummary();
$availableActions = $logModel->getAvailableActions();
// Get filter parameters
$search = $this->request->getGet('search') ?? '';
$action = $this->request->getGet('action') ?? '';
$userType = $this->request->getGet('user_type') ?? '';
$dateFrom = $this->request->getGet('date_from') ?? '';
$dateTo = $this->request->getGet('date_to') ?? '';
$ip = $this->request->getGet('ip') ?? '';
$action = $this->request->getGet('action');
if (is_array($action)) {
$action = array_values(array_filter(array_map('trim', $action)));
} else {
$action = trim((string) $action);
}
// Build filters array
$filters = [];
if (!empty($search)) $filters['search'] = $search;
if (!empty($action)) $filters['action'] = $action;
if (!empty($userType)) $filters['user_type'] = $userType;
if (!empty($dateFrom)) $filters['date_from'] = $dateFrom;
if (!empty($dateTo)) $filters['date_to'] = $dateTo;
if (!empty($ip)) $filters['ip'] = $ip;
$role = $this->request->getGet('role');
if (is_array($role)) {
$role = array_values(array_filter(array_map('trim', $role)));
} else {
$role = trim((string) $role);
}
// Get filtered logs
$logs = $activityModel->getFilteredLogs($filters, 200);
// Debug: Check database connection and count
$db = \Config\Database::connect();
$totalInDb = $db->table('activity_logs')->countAll();
log_message('debug', 'Activity logs in database: ' . $totalInDb . ', retrieved: ' . count($logs));
$filters = [
'action' => $action,
'role' => $role,
'date_from' => trim((string) $this->request->getGet('date_from')),
'date_to' => trim((string) $this->request->getGet('date_to')),
];
return view('admin/activity_log', [
'logs' => $logs,
'totalLogs' => count($logs),
'totalInDb' => $totalInDb,
'actionSummary' => $activityModel->getActionSummary(),
'roleSummary' => $activityModel->getRoleSummary(),
'filters' => [
'search' => $search,
'action' => $action,
'user_type' => $userType,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'ip' => $ip,
],
'availableActions' => $activityModel->getAvailableActions(),
'actionIcons' => [
'login' => 'box-arrow-in-right',
'logout' => 'box-arrow-right',
'register_patient' => 'person-plus',
'register_doctor' => 'person-badge-plus',
'add_doctor' => 'person-badge-plus',
'update_doctor' => 'person-badge',
'delete_doctor' => 'person-badge-x',
'add_patient' => 'person-plus',
'update_patient' => 'person',
'delete_patient' => 'person-x',
'book_appointment' => 'calendar-plus',
'update_appointment' => 'calendar-check',
'cancel_appointment' => 'calendar-x',
'view_appointment' => 'calendar-event',
],
'logs' => $logModel->getFilteredLogs($filters),
'filters' => $filters,
'summary' => $summary,
'availableActions' => $availableActions,
]);
}
public function clear()
{
if ($r = $this->requireRole('admin')) {
return $r;
}
if (strtolower($this->request->getMethod()) === 'post') {
$activityModel = new ActivityLogModel();
if ($activityModel->clearAll()) {
return redirect()->to(base_url('admin/activity-log'))->with('success', 'All activity logs have been cleared.');
} else {
return redirect()->to(base_url('admin/activity-log'))->with('error', 'Failed to clear activity logs.');
}
}
return redirect()->to(base_url('admin/activity-log'));
}
public function analytics()
{
if ($r = $this->requireRole('admin')) {
@ -98,25 +52,59 @@ class ActivityLog extends BaseController
}
$period = $this->request->getGet('period') ?? '7_days';
if (! in_array($period, ['7_days', '30_days'], true)) {
$period = '7_days';
}
$days = ($period === '30_days') ? 30 : 7;
$dateFrom = date('Y-m-d', strtotime("-$days days"));
$activityModel = new ActivityLogModel();
$summary = $activityModel->getSummary($period);
$db = \Config\Database::connect();
$summary = [
'total_actions' => $db->table('activity_logs')->where('activity_at >=', $dateFrom)->countAllResults(),
'by_action' => $db->table('activity_logs')->select('action, COUNT(id) as count')->where('activity_at >=', $dateFrom)->groupBy('action')->get()->getResultArray(),
'by_role' => $db->table('activity_logs')->select('activity_user_type as actor_role, COUNT(id) as count')->where('activity_at >=', $dateFrom)->groupBy('activity_user_type')->get()->getResultArray(),
];
$mostActive = $db->table('activity_logs al')
->select("CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, '')) as name, COUNT(al.id) as count")
->join('users u', 'u.id = al.activity_user_id', 'left')
->where('al.activity_at >=', $dateFrom)
->groupBy('al.activity_user_id')
->orderBy('count', 'DESC')
->limit(10)
->get()
->getResultArray();
$summary['most_active_users'] = $mostActive;
$actionLabels = array_column($summary['by_action'], 'action');
$actionCounts = array_column($summary['by_action'], 'count');
$typeLabels = array_column($summary['by_role'], 'actor_role');
$typeCounts = array_column($summary['by_role'], 'count');
$userLabels = array_map(function($user) { return trim((string) $user['name']) ?: 'System'; }, $mostActive);
$userCounts = array_column($mostActive, 'count');
$uniqueIPs = $db->table('activity_logs')->select('ip as ip, COUNT(id) as count')->where('activity_at >=', $dateFrom)->where('ip IS NOT NULL')->groupBy('ip')->orderBy('count', 'DESC')->get()->getResultArray();
$criticalActions = $db->table('activity_logs al')
->select("al.activity_at as activity_at, CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, '')) AS actor_name, u.email AS actor_email, al.action, al.target_user_type as target_user_type, al.target_user_id as target_user_id, al.description")
->join('users u', 'u.id = al.activity_user_id', 'left')
->like('al.action', 'delete')
->orderBy('al.activity_at', 'DESC')
->limit(50)
->get()
->getResultArray();
return view('admin/activity_analytics', [
'period' => $period,
'summary' => $summary,
'actionLabels' => json_encode(array_keys($summary['by_action'])),
'actionCounts' => json_encode(array_values($summary['by_action'])),
'typeLabels' => json_encode(array_keys($summary['by_role'])),
'typeCounts' => json_encode(array_values($summary['by_role'])),
'userLabels' => json_encode(array_column($summary['most_active_users'], 'actor')),
'userCounts' => json_encode(array_column($summary['most_active_users'], 'count')),
'uniqueIPs' => $summary['unique_ips'],
'criticalActions' => $activityModel->getCriticalActions(50),
'uniqueIPs' => $uniqueIPs,
'criticalActions' => $criticalActions,
'actionLabels' => json_encode($actionLabels),
'actionCounts' => json_encode($actionCounts),
'typeLabels' => json_encode($typeLabels),
'typeCounts' => json_encode($typeCounts),
'userLabels' => json_encode($userLabels),
'userCounts' => json_encode($userCounts)
]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,122 @@
<?php
namespace App\Controllers;
use App\Models\AvailabilityModel;
class AvailabilityController extends BaseController
{
public function save()
{
$doctorId = $this->request->getPost('doctor_id');
$availabilityJson = $this->request->getPost('availability_json');
// Validation
if (!$doctorId || !$availabilityJson) {
return redirect()->back()
->with('error', 'Doctor ID or availability data missing');
}
$availability = json_decode($availabilityJson, true);
foreach ($availability as $day => $slots) {
foreach ($slots as $slot) {
$start = $slot['start'];
$end = $slot['end'];
// Start >= End
if ($start >= $end) {
return redirect()->back()->with('error', 'Invalid time slot: Start must be before End');
}
// Less than 1 hour
$startTime = strtotime($start);
$endTime = strtotime($end);
if (($endTime - $startTime) < 3600) {
return redirect()->back()->with('error', 'Minimum slot must be 1 hour');
}
}
}
// VALIDATION END
if (!is_array($availability) || empty($availability)) {
return redirect()->back()
->with('error', 'No availability slots provided');
}
$model = new AvailabilityModel();
// Deactivate current active schedule for this doctor
$deactivated = $model->where('doctor_id', $doctorId)
->where('status', 1)
->set(['status' => 0])
->update();
log_message('info', "Deactivated {$deactivated} old availability records for doctor {$doctorId}");
// Generate ONE batch id for this save
$batchId = strtoupper(bin2hex(random_bytes(16)));
$insertedCount = 0;
// Insert new active schedule
foreach ($availability as $day => $slots) {
foreach ($slots as $slot) {
$inserted = $model->insert([
'doctor_id' => $doctorId,
'batch_id' => $batchId,
'day_number' => (int) $day,
'start_time' => $slot['start'],
'end_time' => $slot['end'],
'status' => 1
]);
if ($inserted) {
$insertedCount++;
}
}
}
log_message('info', "Inserted {$insertedCount} new availability records for doctor {$doctorId}");
if ($insertedCount === 0) {
return redirect()->back()
->with('warning', 'No availability slots were saved. Please try again.');
}
return redirect()->back()
->with('success', "Availability updated ({$insertedCount} slots saved)");
}
public function getAvailability($doctorId)
{
$model = new AvailabilityModel();
$slots = $model->where('doctor_id', $doctorId)
->where('status', 1)
->orderBy('day_number', 'ASC')
->orderBy('start_time', 'ASC')
->findAll();
return $this->response->setJSON($slots);
}
/**
* Diagnostic endpoint to verify availability data for all doctors
*/
public function diagnostic()
{
$model = new AvailabilityModel();
$allRecords = $model->where('status', 1)
->orderBy('doctor_id', 'ASC')
->orderBy('day_number', 'ASC')
->findAll();
return $this->response->setJSON([
'total_active_records' => count($allRecords),
'records' => $allRecords
]);
}
}

View File

@ -197,6 +197,12 @@ class Doctor extends BaseController
$status = AppointmentModel::normalizeStatus($status);
$appointmentModel->update($appointmentId, ['status' => $status]);
// Sync with doctor_bookings table
$bookingModel = new \App\Models\DoctorBookingModel();
$bookingModel->where('appointment_id', $appointmentId)
->set(['status' => $status])
->update();
$logModel = new ActivityLogModel();
$logModel->log('update_appointment_status', "Doctor changed appointment status to {$status}", 'appointment', $appointmentId);

View File

@ -9,3 +9,23 @@ class Home extends BaseController
return view('welcome_message');
}
}
// namespace App\Controllers;
// class Home extends BaseController
// {
// public function index(): string
// {
// $model = new \App\Models\AvailabilityModel();
// $model->save([
// 'doctor_id' => 1,
// 'day_number'=> 1,
// 'start_time'=> '10:00:00',
// 'end_time' => '12:00:00'
// ]);
// return "Inserted Successfully";
// }
// }

View File

@ -15,6 +15,17 @@ class Patient extends BaseController
return preg_match('/^\d{2}:\d{2}$/', $time) ? $time . ':00' : $time;
}
private function isPastAppointmentDateTime(string $date, string $time): bool
{
$appointmentDateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . ' ' . $time);
if (! $appointmentDateTime || $appointmentDateTime->format('Y-m-d H:i:s') !== $date . ' ' . $time) {
return true;
}
return $appointmentDateTime < new \DateTimeImmutable('now');
}
public function dashboard()
{
if ($r = $this->requireRole('patient')) {
@ -81,11 +92,16 @@ class Patient extends BaseController
}
$appointmentTime = $this->normalizeAppointmentTime((string) $this->request->getPost('time'));
$appointmentDate = (string) $this->request->getPost('date');
if ($this->isPastAppointmentDateTime($appointmentDate, $appointmentTime)) {
return redirect()->back()->withInput()->with('error', 'Past date or time booking is not allowed.');
}
$data = [
'patient_id' => $patient['id'],
'doctor_id' => (int) $this->request->getPost('doctor_id'),
'appointment_date' => $this->request->getPost('date'),
'appointment_date' => $appointmentDate,
'appointment_time' => $appointmentTime,
];

View File

@ -10,7 +10,7 @@ class BackfillFormattedUserIdsByRole extends Migration
{
$this->db->query("
UPDATE users
SET formatted_user_id = CONCAT('PAT', LPAD(id, 7, '0'))
SET formatted_user_id = CONCAT('PT', LPAD(id, 7, '0'))
WHERE role = 'patient'
");
@ -25,7 +25,7 @@ class BackfillFormattedUserIdsByRole extends Migration
{
$this->db->query("
UPDATE users
SET formatted_user_id = CONCAT('PHY', LPAD(id, 7, '0'))
SET formatted_user_id = CONCAT('PT', LPAD(id, 7, '0'))
WHERE role = 'patient'
");
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddServiceAndNoteToAppointments extends Migration
{
public function up()
{
$this->forge->addColumn('appointments', [
'note' => [
'type' => 'TEXT',
'null' => true,
'after' => 'status'
]
]);
}
public function down()
{
$this->forge->dropColumn('appointments', 'note');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateDaysTable extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'day_number' => ['type' => 'INT', 'unsigned' => true, 'comment' => '1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday, 7=Sunday'],
'day_name' => ['type' => 'VARCHAR', 'constraint' => 20],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('day_number');
$this->forge->createTable('days', true);
}
public function down(): void
{
$this->forge->dropTable('days', true);
}
}

View File

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

View File

@ -0,0 +1,28 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAppointmentPreviews extends Migration
{
public function up(): void
{
$this->forge->addField([
]);
$this->forge->addKey('id', true);
$this->forge->addKey('appointment_id');
$this->forge->addKey('patient_id');
$this->forge->addKey('selected_doctor_id');
$this->forge->addKey(['requested_date', 'requested_time']);
$this->forge->addKey('status');
$this->forge->addKey('expires_at');
$this->forge->addUniqueKey('preview_token');
}
public function down(): void
{
}
// Removed migration for appointment_previews table
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class PreventDuplicateAppointmentSlots extends Migration
{
public function up(): void
{
$db = \Config\Database::connect();
$db->query(
'ALTER TABLE `appointments` ADD UNIQUE KEY `appointments_doctor_slot_unique` (`doctor_id`, `appointment_date`, `appointment_time`)'
);
}
public function down(): void
{
$db = \Config\Database::connect();
$db->query('ALTER TABLE `appointments` DROP INDEX `appointments_doctor_slot_unique`');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UseEnumStatusForAppointmentPreviews extends Migration
{
public function up(): void
{
$this->forge->modifyColumn('appointment_previews', [
'status' => [
'name' => 'status',
'type' => 'ENUM',
'constraint' => ['draft', 'viewed', 'selected', 'booked', 'expired', 'cancelled'],
'default' => 'draft',
'null' => false,
],
]);
}
// Removed migration for appointment_previews table
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UseFormattedIdsForAppointmentPreviews extends Migration
{
public function up(): void
{
$db = \Config\Database::connect();
if ($db->fieldExists('appointment_id', 'appointment_previews')) {
$db->query('ALTER TABLE `appointment_previews` DROP INDEX `appointment_id`');
$this->forge->dropColumn('appointment_previews', 'appointment_id');
}
if (! $db->fieldExists('patient_formatted_user_id', 'appointment_previews')) {
$this->forge->addColumn('appointment_previews', [
'patient_formatted_user_id' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => true,
'after' => 'patient_id',
],
]);
}
if ($db->fieldExists('selected_doctor_id', 'appointment_previews')) {
$db->query('ALTER TABLE `appointment_previews` DROP INDEX `selected_doctor_id`');
$this->forge->modifyColumn('appointment_previews', [
'selected_doctor_id' => [
'name' => 'assigned_doctor_id',
'type' => 'INT',
'unsigned' => true,
'null' => true,
],
]);
} elseif (! $db->fieldExists('assigned_doctor_id', 'appointment_previews')) {
$this->forge->addColumn('appointment_previews', [
'assigned_doctor_id' => [
'type' => 'INT',
'unsigned' => true,
'null' => true,
'after' => 'requested_time',
],
]);
}
if (! $db->fieldExists('assigned_doctor_formatted_id', 'appointment_previews')) {
$this->forge->addColumn('appointment_previews', [
'assigned_doctor_formatted_id' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => true,
'after' => 'assigned_doctor_id',
],
]);
}
if ($db->fieldExists('candidate_doctor_ids', 'appointment_previews')) {
$this->forge->dropColumn('appointment_previews', 'candidate_doctor_ids');
}
$this->addIndexIfMissing('assigned_doctor_id', 'assigned_doctor_id');
$this->addIndexIfMissing('patient_formatted_user_id', 'patient_formatted_user_id');
$this->addIndexIfMissing('assigned_doctor_formatted_id', 'assigned_doctor_formatted_id');
}
public function down(): void
{
$db = \Config\Database::connect();
if (! $db->fieldExists('appointment_id', 'appointment_previews')) {
$this->forge->addColumn('appointment_previews', [
'appointment_id' => [
'type' => 'INT',
'unsigned' => true,
'null' => true,
'after' => 'id',
],
]);
$this->addIndexIfMissing('appointment_id', 'appointment_id');
}
if ($db->fieldExists('assigned_doctor_id', 'appointment_previews')) {
$this->dropIndexIfExists('assigned_doctor_id');
$this->forge->modifyColumn('appointment_previews', [
'assigned_doctor_id' => [
'name' => 'selected_doctor_id',
'type' => 'INT',
'unsigned' => true,
'null' => true,
],
]);
$this->addIndexIfMissing('selected_doctor_id', 'selected_doctor_id');
}
if (! $db->fieldExists('candidate_doctor_ids', 'appointment_previews')) {
$this->forge->addColumn('appointment_previews', [
'candidate_doctor_ids' => [
'type' => 'TEXT',
'null' => true,
'after' => 'selected_doctor_id',
],
]);
}
foreach (['patient_formatted_user_id', 'assigned_doctor_formatted_id'] as $column) {
if ($db->fieldExists($column, 'appointment_previews')) {
$this->dropIndexIfExists($column);
$this->forge->dropColumn('appointment_previews', $column);
}
}
}
private function addIndexIfMissing(string $indexName, string $columnName): void
{
$db = \Config\Database::connect();
$escapedIndex = $db->escapeIdentifiers($indexName);
$escapedColumn = $db->escapeIdentifiers($columnName);
$exists = $db->query("SHOW INDEX FROM `appointment_previews` WHERE Key_name = " . $db->escape($indexName))->getRowArray();
if (! $exists) {
$db->query("ALTER TABLE `appointment_previews` ADD INDEX {$escapedIndex} ({$escapedColumn})");
}
}
private function dropIndexIfExists(string $indexName): void
{
$db = \Config\Database::connect();
$exists = $db->query("SHOW INDEX FROM `appointment_previews` WHERE Key_name = " . $db->escape($indexName))->getRowArray();
if ($exists) {
$escapedIndex = $db->escapeIdentifiers($indexName);
$db->query("ALTER TABLE `appointment_previews` DROP INDEX {$escapedIndex}");
}
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateDoctorBookingsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => false, // Match appointments.id
'auto_increment' => true,
],
'appointment_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => false, // Match appointments.id
],
'doctor_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => false, // Match doctors.id
],
'booking_date' => [
'type' => 'DATE',
],
'booking_time' => [
'type' => 'TIME',
],
'status' => [
'type' => 'ENUM',
'constraint' => ['assigned', 'completed', 'cancelled'],
'default' => 'assigned',
],
'created_at datetime default current_timestamp',
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['doctor_id', 'booking_date', 'booking_time']);
$this->forge->addForeignKey('appointment_id', 'appointments', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('doctor_bookings');
}
public function down()
{
$this->forge->dropTable('doctor_bookings');
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddCreatedByToDoctorBookings extends Migration
{
public function up()
{
$this->forge->addColumn('doctor_bookings', [
'created_by' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => false,
'null' => true,
'after' => 'status',
],
]);
}
public function down()
{
$this->forge->dropColumn('doctor_bookings', 'created_by');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class DaysSeeder extends Seeder
{
public function run(): void
{
$data = [
['day_number' => 1, 'day_name' => 'Monday'],
['day_number' => 2, 'day_name' => 'Tuesday'],
['day_number' => 3, 'day_name' => 'Wednesday'],
['day_number' => 4, 'day_name' => 'Thursday'],
['day_number' => 5, 'day_name' => 'Friday'],
['day_number' => 6, 'day_name' => 'Saturday'],
['day_number' => 7, 'day_name' => 'Sunday'],
];
$this->db->table('days')->insertBatch($data);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use CodeIgniter\Model;
use CodeIgniter\I18n\Time;
class ActivityLogModel extends Model
{
@ -12,7 +13,7 @@ class ActivityLogModel extends Model
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['ip', 'action', 'description', 'activity_user_id', 'activity_user_type', 'target_user_id', 'target_user_type', 'activity_page', 'activity_at'];
protected $allowedFields = ['activity_user_id', 'activity_user_type', 'action', 'description', 'target_user_id', 'target_user_type', 'ip', 'activity_page', 'activity_at'];
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;
@ -22,7 +23,7 @@ class ActivityLogModel extends Model
protected $useTimestamps = false;
protected $dateFormat = 'datetime';
protected $createdField = 'activity_at';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
@ -59,9 +60,9 @@ class ActivityLogModel extends Model
'activity_user_id' => $actorId,
'activity_user_type' => $actorRole,
'target_user_id' => $targetId,
'target_user_type' => $targetUserType,
'activity_page' => $request->getPath(),
'activity_at' => date('Y-m-d H:i:s'),
'target_user_type' => $targetType,
'activity_page' => $this->request ? $this->request->getPath() : null,
'activity_at' => gmdate('Y-m-d H:i:s')
];
try {
@ -78,7 +79,7 @@ class ActivityLogModel extends Model
public function getFilteredLogs(array $filters = [], int $limit = 200): array
{
$builder = $this->select('activity_logs.*, COALESCE(NULLIF(CONCAT(users.first_name, " ", users.last_name), " "), users.email, "Guest") AS actor_name')
$builder = $this->select('activity_logs.*, activity_logs.activity_at AS created_at, activity_logs.activity_user_type AS actor_role, activity_logs.ip AS ip_address, COALESCE(NULLIF(CONCAT(users.first_name, " ", users.last_name), " "), users.email, "Guest") AS actor_name, users.email as actor_email')
->join('users', 'users.id = activity_logs.activity_user_id', 'left');
// Apply filters
@ -93,11 +94,19 @@ class ActivityLogModel extends Model
}
if (!empty($filters['action'])) {
if (is_array($filters['action'])) {
$builder->whereIn('activity_logs.action', array_filter($filters['action']));
} else {
$builder->where('activity_logs.action', $filters['action']);
}
}
if (!empty($filters['user_type'])) {
$builder->where('activity_logs.activity_user_type', $filters['user_type']);
if (!empty($filters['role'])) {
if (is_array($filters['role'])) {
$builder->whereIn('activity_logs.activity_user_type', array_filter($filters['role']));
} else {
$builder->where('activity_logs.activity_user_type', $filters['role']);
}
}
if (!empty($filters['date_from'])) {
@ -194,7 +203,7 @@ class ActivityLogModel extends Model
protected function getUniqueIPs(string $startDate, int $limit = 10): array
{
return $this->select('ip, COUNT(*) AS count')
return $this->select('ip as ip, COUNT(*) AS count')
->where('activity_at >=', $startDate)
->groupBy('ip')
->orderBy('count', 'DESC')
@ -205,7 +214,7 @@ class ActivityLogModel extends Model
{
$criticalActions = ['admin_login', 'admin_password_change', 'system_error', 'security_breach'];
return $this->select('activity_logs.*, COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), ""), users.email, "System") AS actor_name, users.email AS actor_email')
return $this->select('activity_logs.*, activity_logs.activity_at AS created_at, activity_logs.activity_user_type AS actor_role, activity_logs.ip AS ip_address, COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), " "), users.email, "System") AS actor_name, users.email AS actor_email')
->join('users', 'users.id = activity_logs.activity_user_id', 'left')
->whereIn('action', $criticalActions)
->orderBy('activity_at', 'DESC')
@ -216,4 +225,17 @@ class ActivityLogModel extends Model
{
return $this->truncate();
}
public function getActivitySummary(): array
{
$totalActions = $this->db->table('activity_logs')->countAll();
$activeUsers = $this->db->table('activity_logs')->select('activity_user_id')->where('activity_user_id IS NOT NULL')->distinct()->countAllResults();
$activityRoles = $this->db->table('activity_logs')->select('activity_user_type')->where('activity_user_type IS NOT NULL')->distinct()->countAllResults();
return [
'total_actions' => $totalActions,
'active_users' => $activeUsers,
'activity_roles'=> $activityRoles,
];
}
}

View File

@ -15,10 +15,13 @@ class AppointmentModel extends Model
protected $allowedFields = [
'patient_id',
'doctor_id',
'services',
'created_by',
'appointment_date',
'appointment_time',
'status'
];
'status',
'note'
];
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;
@ -44,11 +47,11 @@ class AppointmentModel extends Model
protected $beforeInsert = ['setDefaultStatus'];
protected $afterInsert = [];
protected static array $validStatuses = ['pending', 'approved', 'rejected'];
protected static array $validStatuses = ['pending', 'approved', 'rejected', 'assigned', 'completed', 'cancelled'];
public static function normalizeStatus(?string $status): string
{
$status = trim((string) $status);
$status = strtolower(trim((string) $status));
if ($status === '' || ! in_array($status, self::$validStatuses, true)) {
return 'pending';
@ -57,6 +60,15 @@ class AppointmentModel extends Model
return $status;
}
public function isDoctorBooked($doctorId, $date, $time): bool
{
return $this->where('doctor_id', $doctorId)
->where('appointment_date', $date)
->where('appointment_time', $time)
->where('status', 'assigned')
->countAllResults() > 0;
}
protected function setDefaultStatus(array $eventData): array
{
if (! isset($eventData['data']['status']) || trim((string) $eventData['data']['status']) === '') {
@ -74,27 +86,47 @@ class AppointmentModel extends Model
}
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")
{
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')
->join('doctors d', 'd.id = appointments.doctor_id', 'left')
->join('users u2', 'u2.id = d.user_id', 'left')
->orderBy('appointments.id', 'DESC')
->findAll($limit);
}
}
// public function getAdminAppointments(): array
// {
// return $this->asObject()
// ->select("appointments.*, TRIM(CONCAT(COALESCE(u1.first_name, ''), ' ', COALESCE(u1.last_name, ''))) AS patient_name, TRIM(CONCAT(COALESCE(u2.first_name, ''), ' ', COALESCE(u2.last_name, ''))) AS doctor_name")
// ->join('patients p', 'p.id = appointments.patient_id')
// ->join('users u1', 'u1.id = p.user_id')
// ->join('doctors d', 'd.id = appointments.doctor_id')
// ->join('users u2', 'u2.id = d.user_id')
// ->findAll();
// }
public function getAdminAppointments(): array
{
{
return $this->asObject()
->select("appointments.*, TRIM(CONCAT(COALESCE(u1.first_name, ''), ' ', COALESCE(u1.last_name, ''))) AS patient_name, TRIM(CONCAT(COALESCE(u2.first_name, ''), ' ', COALESCE(u2.last_name, ''))) AS doctor_name")
->select("
appointments.*,
CONCAT('APT', LPAD(appointments.id, 7, '0')) AS formatted_appointment_id,
TRIM(CONCAT(COALESCE(u1.first_name,''),' ',COALESCE(u1.last_name,''))) AS patient_name,
TRIM(CONCAT(COALESCE(u2.first_name,''),' ',COALESCE(u2.last_name,''))) AS doctor_name
")
->join('patients p', 'p.id = appointments.patient_id')
->join('users u1', 'u1.id = p.user_id')
->join('doctors d', 'd.id = appointments.doctor_id')
->join('users u2', 'u2.id = d.user_id')
->join('doctors d', 'd.id = appointments.doctor_id', 'left')
->join('users u2', 'u2.id = d.user_id', 'left')
->orderBy('appointments.id','DESC')
->findAll();
}
}
public function deleteByDoctorId(int $doctorId): void
{
$this->where('doctor_id', $doctorId)->delete();

View File

@ -0,0 +1,78 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AppointmentPreviewModel extends Model
{
protected $table = 'appointment_previews';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'patient_id',
'patient_formatted_user_id',
'services',
'requested_date',
'requested_time',
'assigned_doctor_id',
'assigned_doctor_formatted_id',
'available_days_snapshot',
'available_slots_snapshot',
'status',
'preview_token',
'created_by',
'expires_at',
];
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'patient_id' => 'required|integer',
'requested_date' => 'required|valid_date[Y-m-d]',
'requested_time' => 'required',
'status' => 'permit_empty|in_list[draft,viewed,selected,booked,expired,cancelled]',
'preview_token' => 'permit_empty|max_length[64]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
protected $allowCallbacks = true;
protected $beforeInsert = ['preparePreview'];
protected $beforeUpdate = ['normalizeJsonFields'];
protected function preparePreview(array $eventData): array
{
$eventData = $this->normalizeJsonFields($eventData);
if (empty($eventData['data']['preview_token'])) {
$eventData['data']['preview_token'] = bin2hex(random_bytes(32));
}
if (empty($eventData['data']['status'])) {
$eventData['data']['status'] = 'draft';
}
return $eventData;
}
protected function normalizeJsonFields(array $eventData): array
{
foreach (['services', 'available_days_snapshot', 'available_slots_snapshot'] as $field) {
if (isset($eventData['data'][$field]) && is_array($eventData['data'][$field])) {
$eventData['data'][$field] = json_encode($eventData['data'][$field]);
// Removed AppointmentPreviewModel as appointment_previews table is dropped
// Removed the entire class definition as it is no longer needed

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AvailabilityModel extends Model
{
protected $table = 'doctor_availability';
protected $primaryKey = 'id';
protected $allowedFields = [
'doctor_id',
'batch_id',
'day_number',
'start_time',
'end_time',
'status'
];
}

28
app/Models/DaysModel.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class DaysModel extends Model
{
protected $table = 'days';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = ['day_number', 'day_name'];
protected $useTimestamps = false;
// Get day name by day number
public function getDayName(int $dayNumber): ?string
{
return $this->where('day_number', $dayNumber)->first()['day_name'] ?? null;
}
// Get all days
public function getAllDays(): array
{
return $this->orderBy('day_number', 'ASC')->findAll();
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class DoctorBookingModel extends Model
{
protected $table = 'doctor_bookings';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['appointment_id', 'doctor_id', 'booking_date', 'booking_time', 'status', 'created_by'];
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;
// Dates
protected $useTimestamps = false;
public function isDoctorBooked($doctorId, $date, $time): bool
{
return $this->where('doctor_id', $doctorId)
->where('booking_date', $date)
->where('booking_time', $time)
->where('status', 'assigned')
->countAllResults() > 0;
}
public function syncBooking(int $appointmentId, int $doctorId, string $date, string $time, string $status, ?int $createdBy = null)
{
$existing = $this->where('appointment_id', $appointmentId)->first();
$data = [
'appointment_id' => $appointmentId,
'doctor_id' => $doctorId,
'booking_date' => $date,
'booking_time' => $time,
'status' => $status,
'created_by' => $createdBy
];
if ($existing) {
return $this->update($existing['id'], $data);
}
return $this->insert($data);
}
}

View File

@ -69,7 +69,7 @@ class DoctorModel extends Model
$sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
return $this->asObject()
->select("users.id as user_id, COALESCE(NULLIF(users.formatted_user_id, ''), CONCAT('PHY', LPAD(users.id, 7, '0'))) as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, doctors.id, doctors.specialization, doctors.experience, doctors.fees")
->select("doctors.id as doctor_id, users.id as user_id, COALESCE(NULLIF(users.formatted_user_id, ''), CONCAT('PHY', LPAD(users.id, 7, '0'))) as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, doctors.specialization, doctors.experience, doctors.fees")
->join('users', 'users.id = doctors.user_id')
->where('users.role', 'doctor')
->orderBy($sortColumn, $sortDir)

View File

@ -67,7 +67,7 @@ class PatientModel extends Model
$sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
return $this->asObject()
->select("users.id as user_id, CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PAT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, patients.id, patients.phone")
->select("users.id as user_id, CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, {$fullNameSql} as name, users.email, users.first_name, users.last_name, users.status, patients.id, patients.phone")
->join('users', 'users.id = patients.user_id')
->where('users.role', 'patient')
->orderBy($sortColumn, $sortDir)
@ -76,9 +76,26 @@ class PatientModel extends Model
public function getLatestPatients(int $limit = 5): array
{
return $this->select("CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PAT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, ''))) as name, patients.phone")
return $this->select("CASE WHEN users.formatted_user_id IS NULL OR users.formatted_user_id = '' OR users.formatted_user_id LIKE 'PHY%' THEN CONCAT('PT', LPAD(users.id, 7, '0')) ELSE users.formatted_user_id END as formatted_user_id, TRIM(CONCAT(COALESCE(users.first_name, ''), ' ', COALESCE(users.last_name, ''))) as name, patients.phone")
->join('users', 'users.id = patients.user_id')
->orderBy('patients.id', 'DESC')
->findAll($limit);
}
public function getPatientWithFormattedId($userId)
{
return $this->asObject()
->select("
users.id as user_id,
CASE
WHEN users.formatted_user_id IS NULL
OR users.formatted_user_id = ''
OR users.formatted_user_id LIKE 'PHY%'
THEN CONCAT('PT', LPAD(users.id, 7, '0'))
ELSE users.formatted_user_id
END as formatted_user_id
")
->join('users', 'users.id = patients.user_id')
->where('users.id', $userId)
->first();
}
}

View File

@ -20,6 +20,7 @@ class UserModel extends Model
'password',
'role',
'status',
'gender',
'session_token',
'reset_token',
'reset_token_expires',
@ -98,7 +99,7 @@ class UserModel extends Model
public function formatUserId(int $userId, ?string $role = null): string
{
$prefix = match (strtolower((string) $role)) {
'patient' => 'PAT',
'patient' => 'PT',
'doctor' => 'PHY',
};

View File

@ -72,36 +72,12 @@ function formatLabel($label) {
<div class="ov-panel__body">
<!-- Summary Statistics -->
<div class="row mb-4">
<<<<<<< HEAD
<div class="col-md-3">
=======
<div class="col-md-4">
>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39
<div class="stat-card">
<p class="stat-value"><?= $summary['total_actions'] ?? 0 ?></p>
<p class="stat-label">Total Actions</p>
</div>
</div>
<<<<<<< HEAD
<div class="col-md-3">
=======
<!-- <div class="col-md-3">
>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39
<div class="stat-card green">
<p class="stat-value"><?= count($summary['by_action'] ?? []) ?></p>
<p class="stat-label">Action Types</p>
</div>
<<<<<<< HEAD
</div>
<div class="col-md-3">
<div class="stat-card blue">
<p class="stat-value"><?= count($summary['by_role'] ?? []) ?></p>
<p class="stat-label">Active Roles</p>
</div>
</div>
<div class="col-md-3">
=======
</div> -->
<div class="col-md-4">
<div class="stat-card blue">
<p class="stat-value"><?= count($summary['by_role'] ?? []) ?></p>
@ -109,7 +85,6 @@ function formatLabel($label) {
</div>
</div>
<div class="col-md-4">
>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39
<div class="stat-card orange">
<p class="stat-value"><?= count($summary['most_active_users'] ?? []) ?></p>
<p class="stat-label">Active Users</p>
@ -237,14 +212,11 @@ function toggleSidebar() {
icon.className = sidebar.classList.contains('collapsed') ? 'bi bi-layout-sidebar' : 'bi bi-list';
}
<<<<<<< HEAD
function toggleNavDropdown(event, element) {
event.preventDefault();
element.parentElement.classList.toggle('active');
}
=======
>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39
function changeAnalyticsPeriod(period) {
window.location.href = '<?= base_url('admin/activity/analytics') ?>?period=' + period;
}

View File

@ -6,74 +6,12 @@
<title>Activity Log</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
<link rel="stylesheet" href="<?= base_url('css/dashboard.css') ?>">
<style>
.stat-card .ov-stat__icon {
background: var(--bs-primary) !important;
color: white !important;
}
.stat-card:nth-child(2) .ov-stat__icon {
background: var(--bs-success) !important;
}
.stat-card:nth-child(3) .ov-stat__icon {
background: var(--bs-info) !important;
}
.avatar-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.avatar-circle.small {
width: 24px;
height: 24px;
font-size: 12px;
}
.description-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-responsive {
border-radius: 0.375rem;
}
.table thead th {
border-bottom: 2px solid #dee2e6;
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.5px;
}
.badge {
font-weight: 500;
}
.btn-link {
text-decoration: none;
}
.btn-link:hover {
text-decoration: underline;
}
.form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
.btn:disabled {
opacity: 0.6;
}
#selectedCount {
font-weight: 500;
color: #6c757d;
}
.input-group-text {
background-color: #f8f9fa;
}
</style> <link rel="stylesheet" href="<?= base_url('css/doctors.css') ?>">
<link rel="stylesheet" href="<?= base_url('css/activity-log.css') ?>">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
</head>
<body class="app-body overview-layout">
@ -118,81 +56,94 @@
</header>
<main class="ov-content">
<!-- Flash Messages -->
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i><?= esc(session()->getFlashdata('success')) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="row mb-4">
<div class="col-md-4">
<div class="stat-card">
<p class="stat-value"><?= $summary['total_actions'] ?? 0 ?></p>
<p class="stat-label">Total Actions</p>
</div>
</div>
<div class="col-md-4">
<div class="stat-card orange">
<p class="stat-value"><?= $summary['active_users'] ?? 0 ?></p>
<p class="stat-label">Activity Users</p>
</div>
</div>
<div class="col-md-4">
<div class="stat-card blue">
<p class="stat-value"><?= $summary['activity_roles'] ?? 0 ?></p>
<p class="stat-label">Active Roles</p>
</div>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i><?= esc(session()->getFlashdata('error')) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<!-- Search & Filter Section -->
<div class="ov-panel mb-4">
<div class="ov-panel__header">
<h2 class="ov-panel__title mb-0">
<button class="btn btn-link p-0 text-decoration-none" onclick="toggleFilters()" id="filterToggle">
<i class="bi bi-funnel me-2"></i>Search & Filters
<i class="bi bi-chevron-down ms-2" id="filterIcon"></i>
</button>
</h2>
<div class="ov-panel__header d-flex justify-content-between align-items-center">
<h2 class="ov-panel__title">Advance Filter</h2>
<div class="d-flex gap-2">
<a href="<?= base_url('admin/activity/analytics') ?>" class="btn btn-outline-primary px-3">
<i class="bi bi-graph-up me-1"></i> Analytics
</a>
</div>
<div class="ov-panel__body" id="filterSection" style="display: none;">
<form method="GET" action="<?= base_url('admin/activity-log') ?>" class="row g-3">
<div class="col-md-4">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
value="<?= esc($filters['search'] ?? '') ?>"
placeholder="Search by name, email, or description">
</div>
<div class="col-md-2">
<label for="action" class="form-label">Action</label>
<select class="form-select" id="action" name="action">
<option value="">All Actions</option>
<?php foreach ($availableActions as $actionType): ?>
<option value="<?= esc($actionType) ?>" <?= ($filters['action'] ?? '') === $actionType ? 'selected' : '' ?>>
<?= esc(ucwords(str_replace('_', ' ', $actionType))) ?>
</option>
<div class="ov-panel__body">
<form method="get" action="<?= base_url('admin/activity-log') ?>" class="row g-3">
<?php
$selectedActions = [];
if (! empty($filters['action'])) {
$selectedActions = is_array($filters['action']) ? array_filter($filters['action']) : [$filters['action']];
}
$selectedActions = array_values(array_filter($selectedActions));
$selectedRoles = [];
if (! empty($filters['role'])) {
$selectedRoles = is_array($filters['role']) ? array_filter($filters['role']) : [$filters['role']];
}
$selectedRoles = array_values(array_filter($selectedRoles));
?>
<div class="col-md-6">
<label class="form-label" for="actionType">Action Type</label>
<div class="multi-select-arrow-wrap">
<select name="action[]" id="actionType" class="form-select select2" multiple>
<option value="login" <?= in_array('login', $selectedActions, true) ? 'selected' : '' ?>>Login</option>
<option value="logout" <?= in_array('logout', $selectedActions, true) ? 'selected' : '' ?>>Logout</option>
<?php if (! empty($availableActions)): ?>
<?php foreach ($availableActions as $actionOption): ?>
<?php if ($actionOption !== 'login' && $actionOption !== 'logout'): ?>
<option value="<?= esc($actionOption) ?>" <?= in_array($actionOption, $selectedActions, true) ? 'selected' : '' ?>><?= esc(ucfirst($actionOption)) ?></option>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
</select>
<i class="bi bi-chevron-down select2-arrow-hint" aria-hidden="true"></i>
</div>
<div class="col-md-2">
<label for="user_type" class="form-label">User Type</label>
<select class="form-select" id="user_type" name="user_type">
<option value="">All Types</option>
<option value="admin" <?= ($filters['user_type'] ?? '') === 'admin' ? 'selected' : '' ?>>Admin</option>
<option value="doctor" <?= ($filters['user_type'] ?? '') === 'doctor' ? 'selected' : '' ?>>Doctor</option>
<option value="patient" <?= ($filters['user_type'] ?? '') === 'patient' ? 'selected' : '' ?>>Patient</option>
</div>
<div class="col-md-6">
<label class="form-label" for="roleType">Role Type</label>
<div class="multi-select-arrow-wrap">
<select name="role[]" id="roleType" class="form-select select2" multiple>
<option value="admin" <?= in_array('admin', $selectedRoles, true) ? 'selected' : '' ?>>Admin</option>
<option value="doctor" <?= in_array('doctor', $selectedRoles, true) ? 'selected' : '' ?>>Doctor</option>
<option value="patient" <?= in_array('patient', $selectedRoles, true) ? 'selected' : '' ?>>Patient</option>
</select>
<i class="bi bi-chevron-down select2-arrow-hint" aria-hidden="true"></i>
</div>
<div class="col-md-2">
<label for="date_from" class="form-label">From Date</label>
<input type="date" class="form-control" id="date_from" name="date_from"
value="<?= esc($filters['date_from'] ?? '') ?>">
</div>
<div class="col-md-2">
<label for="date_to" class="form-label">To Date</label>
<input type="date" class="form-control" id="date_to" name="date_to"
value="<?= esc($filters['date_to'] ?? '') ?>">
<div class="col-md-6">
<label class="form-label" for="date_from">From</label>
<input type="date" class="form-control" id="date_from" name="date_from" value="<?= esc($filters['date_from'] ?? '') ?>">
</div>
<div class="col-md-4">
<label for="ip" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ip" name="ip"
value="<?= esc($filters['ip'] ?? '') ?>"
placeholder="Filter by IP address">
<div class="col-md-6">
<label class="form-label" for="date_to">To</label>
<input type="date" class="form-control" id="date_to" name="date_to" value="<?= esc($filters['date_to'] ?? '') ?>">
</div>
<div class="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-1"></i>Search
</button>
<!-- <button type="submit" class="btn btn-outline-success">
Filter
</button> -->
<a href="<?= base_url('admin/activity-log') ?>" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Clear Filters
Clear Filters
</a>
</div>
</div>
@ -200,122 +151,47 @@
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="ov-stat stat-card">
<div class="ov-stat__icon bg-primary">
<i class="bi bi-clipboard-data"></i>
</div>
<div>
<div class="ov-stat__label">Total Entries</div>
<p class="ov-stat__value"><?= esc($totalLogs) ?></p>
<div class="ov-panel">
<div class="ov-panel__header">
<h2 class="ov-panel__title">Activity Log Entries</h2>
<div class="d-flex flex-wrap gap-2 align-items-center">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="exportTable('csv'); return false;"><i class="bi bi-file-earmark-text me-2"></i> CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportTable('excel'); return false;"><i class="bi bi-file-earmark-excel me-2"></i> Excel</a></li>
<li><a class="dropdown-item" href="#" onclick="exportTable('pdf'); return false;"><i class="bi bi-file-earmark-pdf me-2"></i> PDF</a></li>
</ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="ov-stat stat-card">
<div class="ov-stat__icon bg-success">
<i class="bi bi-list-check"></i>
</div>
<div>
<div class="ov-stat__label">Action Types</div>
<p class="ov-stat__value"><?= esc(count($actionSummary)) ?></p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="ov-stat stat-card">
<div class="ov-stat__icon bg-info">
<i class="bi bi-people"></i>
</div>
<div>
<div class="ov-stat__label">Actor Roles</div>
<p class="ov-stat__value"><?= esc(count($roleSummary)) ?></p>
</div>
</div>
</div>
</div>
<div class="ov-panel mb-4">
<div class="ov-panel__header d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2">
<div>
<h2 class="ov-panel__title mb-1">Recent Activity</h2>
<p class="text-muted mb-0">Latest actions recorded from the application.</p>
</div>
<div class="d-flex gap-2">
<a href="<?= base_url('admin/activity/analytics') ?>" class="btn btn-sm btn-outline-secondary">View Analytics</a>
<form method="post" action="<?= base_url('admin/activity-log/clear') ?>" style="display: inline;" onsubmit="return confirm('Are you sure you want to clear all activity logs? This action cannot be undone.')">
<button type="submit" class="btn btn-sm btn-outline-danger">Clear Log</button>
</form>
</div>
</div>
<div class="ov-panel__body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<div class="p-0">
<table id="activityTable" class="table table-hover" style="width:100%">
<thead class="table-light">
<tr>
<th class="text-nowrap">Timestamp</th>
<th>Actor</th>
<th>User</th>
<th>Role</th>
<th>Action</th>
<th>Target</th>
<th>Description</th>
<th>IP</th>
</tr>
</thead>
<tbody>
<?php if (empty($logs)): ?>
<tr>
<td colspan="7" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 d-block mb-2 text-muted"></i>
No activity logs found matching your criteria.
</td>
</tr>
<?php else: ?>
<?php if (! empty($logs)): ?>
<?php foreach ($logs as $log): ?>
<tr>
<td class="text-nowrap small">
<div class="d-flex flex-column">
<span class="fw-medium"><?= esc(date('M d, Y', strtotime($log['activity_at']))) ?></span>
<small class="text-muted"><?= esc(date('H:i:s', strtotime($log['activity_at']))) ?></small>
</div>
</td>
<td class="ps-3 text-nowrap"><?= esc($log['created_at']) ?></td>
<td>
<div class="d-flex align-items-center">
<div class="avatar-circle me-2 bg-<?= $log['activity_user_type'] === 'admin' ? 'primary' : ($log['activity_user_type'] === 'doctor' ? 'success' : 'info') ?> text-white small">
<?= strtoupper(substr(!empty($log['actor_name']) ? $log['actor_name'] : 'G', 0, 1)) ?>
</div>
<span class="fw-medium"><?= esc($log['actor_name'] ?? 'Guest') ?></span>
</div>
</td>
<td>
<span class="badge bg-<?= $log['activity_user_type'] === 'admin' ? 'primary' : ($log['activity_user_type'] === 'doctor' ? 'success' : 'info') ?> text-uppercase small">
<?= esc($log['activity_user_type'] ?? 'guest') ?>
</span>
</td>
<td>
<span class="badge bg-secondary text-uppercase small">
<i class="bi bi-<?= esc($actionIcons[$log['action']] ?? 'activity') ?> me-1"></i>
<?= esc(str_replace('_', ' ', $log['action'])) ?>
</span>
</td>
<td>
<?php if ($log['target_user_type']): ?>
<span class="badge bg-light text-dark small">
<?= esc($log['target_user_type']) ?> #<?= esc($log['target_user_id']) ?>
</span>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td>
<div class="description-cell">
<?= esc($log['description'] ?? '-') ?>
</div>
</td>
<td class="text-nowrap">
<code class="small bg-light px-2 py-1 rounded"><?= esc($log['ip'] ?? '-') ?></code>
<div class="fw-medium"><?= esc(trim((string) ($log['actor_name'] ?? ''))) !== '' ? esc(trim((string) $log['actor_name'])) : 'System' ?></div>
<div class="text-muted small"><?= esc($log['actor_email'] ?? '') ?></div>
</td>
<td><?= esc($log['actor_role'] ?? '-') ?></td>
<td><span class="badge bg-secondary"><?= esc($log['action']) ?></span></td>
<td><?= esc($log['description']) ?></td>
<td class="text-nowrap"><?= esc($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
@ -323,11 +199,154 @@
</table>
</div>
</div>
</div>
</main>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/vfs_fonts.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.print.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let activityTable = null;
$(document).ready(function() {
activityTable = $('#activityTable').DataTable({
pageLength: 25,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "All"]],
order: [[0, 'desc']],
dom: '<"row px-4 py-3"<"col-md-6 d-flex align-items-center"l><"col-md-6 d-flex justify-content-end align-items-center"f>>' +
'rt' +
'<"row px-4 py-3"<"col-md-5 d-flex align-items-center"i><"col-md-7 d-flex justify-content-end"p>>',
buttons: [
{
extend: 'csvHtml5',
className: 'buttons-csv',
title: 'Activity Log',
filename: 'activity_log_' + new Date().toISOString().split('T')[0],
exportOptions: { columns: [0, 1, 2, 3, 4, 6] }
},
{
extend: 'excelHtml5',
className: 'buttons-excel',
title: 'Activity Log',
filename: 'activity_log_' + new Date().toISOString().split('T')[0],
exportOptions: { columns: [0, 1, 2, 3, 4, 5, 6] }
},
{
extend: 'pdfHtml5',
className: 'buttons-pdf',
title: 'Activity Log',
filename: 'activity_log_' + new Date().toISOString().split('T')[0],
orientation: 'landscape',
pageSize: 'A4',
exportOptions: { columns: [0, 1, 2, 3, 4, 6] }
}
],
language: {
search: 'Search logs:',
lengthMenu: 'Show _MENU_ logs',
info: 'Showing _START_ to _END_ of _TOTAL_ logs',
paginate: {
first: 'First',
last: 'Last',
next: 'Next',
previous: 'Previous'
},
emptyTable: 'No logs found',
zeroRecords: 'No matching logs found'
}
});
});
function exportTable(format) {
if (!activityTable) return;
if (format === 'csv') {
activityTable.button('.buttons-csv').trigger();
} else if (format === 'excel') {
activityTable.button('.buttons-excel').trigger();
} else if (format === 'pdf') {
activityTable.button('.buttons-pdf').trigger();
}
}
$(function () {
$('.select2').select2({
placeholder: 'Select options',
closeOnSelect: false,
width: '100%',
allowClear: false,
dropdownParent: $('body')
});
$('.select2').on('select2:open', function () {
const $wrapper = $(this).closest('.multi-select-arrow-wrap');
$wrapper.find('.select2-arrow-hint').removeClass('bi-chevron-down').addClass('bi-chevron-up');
});
$('.select2').on('select2:close', function () {
const $wrapper = $(this).closest('.multi-select-arrow-wrap');
$wrapper.find('.select2-arrow-hint').removeClass('bi-chevron-up').addClass('bi-chevron-down');
});
$('.select2-arrow-hint').on('mousedown', function (event) {
event.preventDefault();
event.stopPropagation();
const $select = $(this).siblings('select');
if ($select.data('select2').isOpen()) {
$select.select2('close');
} else {
$select.select2('open');
}
});
let filterTimeout; //debouncer
$('#actionType, #roleType').on('change', function () {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
applyFilters();
}, 300);
});
$('input[type="date"]').on('change', function () {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
applyFilters();
}, 300);
});
function applyFilters() {
let formData = $('form').serialize();
$.ajax({
url: $('form').attr('action'),
type: 'GET',
data: formData,
success: function (response) {
// Extract table body only
let newTbody = $(response).find('#activityTable tbody').html();
$('#activityTable tbody').html(newTbody);
activityTable.destroy();
$('#activityTable tbody').html(newTbody);
activityTable = $('#activityTable').DataTable({
pageLength: 25,
order: [[0, 'desc']]
});
}
});
}
});
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const main = document.getElementById('mainContent');
@ -339,23 +358,8 @@ function toggleSidebar() {
function toggleNavDropdown(event, element) {
event.preventDefault();
<<<<<<< HEAD
element.parentElement.classList.toggle('active');
}
=======
element.parentElement.classList.toggle('open');
}
function toggleFilters() {
const filterSection = document.getElementById('filterSection');
const filterIcon = document.getElementById('filterIcon');
const isVisible = filterSection.style.display !== 'none';
filterSection.style.display = isVisible ? 'none' : 'block';
filterIcon.className = isVisible ? 'bi bi-chevron-down ms-2' : 'bi bi-chevron-up ms-2';
}
>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39
</script>
</body>
</html>

View File

@ -0,0 +1,639 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Create Appointment</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.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/dashboard.css') ?>">
<link rel="stylesheet"href="<?= base_url('css/appointment_form.css') ?>">
</head>
<body class="app-body overview-layout">
<aside class="ov-sidebar" id="sidebar">
<div class="ov-brand">
<h1><i class="bi bi-hospital me-1"></i> DoctGuide</h1>
<span>Control Panel</span>
</div>
<nav class="ov-nav">
<div class="ov-nav__section">Main</div>
<a href="<?= base_url('admin/dashboard') ?>"class="ov-nav__link"><i class="bi bi-speedometer2"></i>Dashboard</a>
<div class="ov-nav__section">Manage</div>
<div class="ov-nav__dropdown active">
<a href="#"class="ov-nav__link d-flex justify-content-between align-items-center"onclick="toggleNavDropdown(event,this)">
<span><i class="bi bi-people"></i> Patients</span>
<i class="bi bi-chevron-down dropdown-icon"></i>
</a>
<div class="ov-dropdown-menu">
<a href="<?= base_url('admin/patients') ?>"
class="ov-nav__sublink">
Patient List
</a>
</div>
</div>
<a href="<?= base_url('admin/appointments') ?>"
class="ov-nav__link active">
<i class="bi bi-calendar2-check"></i>
Appointments
</a>
</nav>
<div class="ov-sidebar__footer">
<a href="<?= base_url('logout') ?>">
<i class="bi bi-box-arrow-left"></i>Logout
</a>
</div>
</aside>
<div class="ov-main" id="mainContent">
<header class="ov-topbar">
<div class="d-flex align-items-center">
<button
class="ov-toggle-btn"
onclick="toggleSidebar()">
<i class="bi bi-list"
id="toggleIcon"></i>
</button>
<p class="ov-topbar__title mb-0">
Create Appointment
</p>
</div>
</header>
<main class="ov-content">
<?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; ?>
<div class="appointment-card">
<div class="card-header-custom">
<h4>Create Appointment</h4>
<a href="<?= base_url('admin/doctor-search') ?>" class="back-btn">
Doctor's Info
</a>
</div>
<div class="form-body">
<div class="info-badge">
Fields marked with <span class="req">*</span> are required.
</div>
<form
id="appointmentForm"
novalidate
method="post"
action="<?= base_url('admin/appointments/create') ?>">
<input type="hidden"
name="patient_id"
value="<?= $patient['id'] ?>">
<div class="row g-4">
<div class="col-md-6">
<label class="form-label">Patient ID <span class="req">*</span>
</label>
<input
type="text"
readonly
class="form-control readonly-box"
value="<?= $formattedPatientId ?>">
</div>
<div class="col-md-6">
<label class="form-label">
Patient Name <span class="req">*</span>
</label>
<input
type="text"
readonly
class="form-control readonly-box"cdddbcce
value="<?= ($user['first_name'] ?? '') .' '. ($user['last_name'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Service Request <span class="req">*</span></label>
<div class="multi-select-arrow-wrap">
<select
id="apptSpecialization"
class="form-select select2"
name="services[]"
multiple="multiple">
<!-- <option value="">
Select Services
</option> -->
</select>
<i class="bi bi-chevron-down select2-arrow-hint" aria-hidden="true"></i>
</div>
<div id="specError" class="error-text"></div>
</div>
<div class="col-md-6">
<label class="form-label">
Appointment Date & Time <span class="req">*</span>
</label>
<input
type="datetime-local"
id="apptDateTime"
name="appointment_datetime"
class="form-control">
<div id="dateTimeError" class="error-text"></div>
</div>
<div class="col-md-12">
<div id="doctorsContainer">
<label class="form-label fw-medium">
Preference (Physician)
<!-- <span class="text-danger">*</span> -->
</label>
<!-- <input
type="text"
id="doctorSearchBox"
class="form-control mb-3"
placeholder="Search doctors...">
<div id="doctorsList" class="doctor-list-container"></div> -->
<div class="single-select-wrap">
<select
id="doctorSelect"
class="form-select"
name="doctor_pick">
<option value=""></option>
</select>
<i class="bi bi-chevron-down single-arrow"></i>
</div>
<div id="doctorError" class="text-danger mt-1"></div>
<input
type="hidden"
id="selectedDoctorId"
name="doctor_id">
</div>
</div>
<div class="col-12">
<label class="form-label">
Notes
</label>
<textarea
rows="2"
name="note"
class="form-control"
placeholder="Enter notes..."></textarea>
</div>
</div>
<div class="action-row">
<button type="button"
onclick="history.back()"
class="cancel-btn">
Cancel
</button>
<button type="submit"
class="save-btn">
Save Appointment
</button>
</div>
</form>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
function loadSpecializations(){
fetch("<?= base_url('admin/specializations') ?>",{
headers:{
'X-Requested-With':'XMLHttpRequest'
}
})
.then(response=>response.json())
.then(data=>{
if(data.success){
let select=document.getElementById('apptSpecialization');
data.specializations.forEach(spec=>{
let option=document.createElement('option');
option.value=spec.id;
option.textContent=spec.name;
select.appendChild(option);
});
$('#apptSpecialization').select2({
placeholder:'Select one or more services',
closeOnSelect:false,
allowClear:false,
width:'100%',
dropdownParent:$('body'),
minimumResultsForSearch: Infinity
});
}
})
.catch(error=>{
console.error(error);
});
}
document.addEventListener('DOMContentLoaded', function(){
const dateTimeInput = document.getElementById('apptDateTime');
if (dateTimeInput) {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
dateTimeInput.min = now.toISOString().slice(0, 16);
}
loadSpecializations();
/* Initialize doctor select2 */
$('#doctorSelect').select2({
placeholder: '--Select--',
allowClear: true,
width: '100%'
});
/* trigger doctor fetch when changed */
$('#apptSpecialization').on('select2:select', function () {
console.log('Specialization selected');
fetchAvailableDoctors();
});
$('#apptSpecialization').on('select2:unselect', function () {
console.log('Specialization unselected');
fetchAvailableDoctors();
});
$('#apptSpecialization').on('change', function () {
console.log('Specialization changed');
fetchAvailableDoctors();
});
/* Chevron animation on open */
$('#apptSpecialization').on('select2:open', function () {
const $wrapper = $(this).closest('.multi-select-arrow-wrap');
$wrapper.find('.select2-arrow-hint').removeClass('bi-chevron-down').addClass('bi-chevron-up');
});
/* Chevron animation on close */
$('#apptSpecialization').on('select2:close', function () {
const $wrapper = $(this).closest('.multi-select-arrow-wrap');
$wrapper.find('.select2-arrow-hint').removeClass('bi-chevron-up').addClass('bi-chevron-down');
});
/* Click chevron to toggle dropdown */
$('.select2-arrow-hint').on('mousedown', function (event) {
event.preventDefault();
event.stopPropagation();
const $select = $(this).siblings('select');
if ($select.data('select2') && $select.data('select2').isOpen()) {
$select.select2('close');
} else {
$select.select2('open');
}
});
$('.single-arrow').on('click', function(){
let select=$(this).siblings('select');
if(select.data('select2') && select.data('select2').isOpen()){
select.select2('close');
}
else{
select.select2('open');
}
});
document
.getElementById('apptDateTime')
.addEventListener('change',fetchAvailableDoctors);
/* Doctor search functionality */
$('#doctorSearchBox').on('keyup', function(){
let searchTerm = $(this).val().toLowerCase();
$('.doctor-option').each(function(){
let doctorName = $(this).data('doctor-name').toLowerCase();
if(doctorName.includes(searchTerm)){
$(this).show();
} else {
$(this).hide();
}
});
});
});
function fetchAvailableDoctors(){
console.log("fetch fired");
let spec = $('#apptSpecialization').val();
console.log('Specializations selected:', spec);
if(!spec || spec.length===0){
let $doctorSelect = $('#doctorSelect');
if ($doctorSelect.hasClass("select2-hidden-accessible")) {
$doctorSelect.select2('destroy');
}
$doctorSelect.html('<option value=""></option>');
$doctorSelect.prop('disabled', true);
$doctorSelect.select2({
placeholder:'Select specialization first...',
allowClear:true,
width:'100%'
});
return;
}
let dateTime = document.getElementById('apptDateTime').value;
// Split date and time if available
let date = '';
let time = '';
if(dateTime){
let parts = dateTime.split("T");
date = parts[0];
time = parts[1];
}
let formData=new FormData();
formData.append('date', date);
formData.append('time', time);
spec.forEach(function(id){
formData.append('specialization_id[]', id);
});
fetch("<?= base_url('admin/available-doctors') ?>",{
method:'POST',
body:formData,
headers:{
'X-Requested-With':'XMLHttpRequest'
}
})
.then(response=>response.json())
.then(data=>{
if(data.success && data.doctors){
displayDoctors(data.doctors);
}else{
displayDoctors([]);
console.log(data);
}
})
.catch(error=>{
console.error(error);
});
}
function displayDoctors(doctors){
let select = $('#doctorSelect');
/* Destroy old select2 first */
if(select.hasClass("select2-hidden-accessible")){
select.select2('destroy');
}
select.empty();
select.append('<option value=""></option>');
if(!doctors.length){
select.select2({
placeholder: 'Search doctors...',
allowClear: true,
width: '100%'
});
return;
}
/* Add doctors */
doctors.forEach(function(doc){
let genderText = '';
if(doc.gender){
genderText = ' | ' + doc.gender.trim();
}
let label = 'Dr. ' + doc.name + genderText;
select.append(
new Option(label, doc.id, false, false)
);
});
/* Reinitialize once */
select.select2({
placeholder: 'Search doctors...',
allowClear: true,
width: '100%',
templateResult: function(data){
return data.text; // dropdown text
},
templateSelection: function(data){
return data.text; // selected field text
}
});
/* When user selects doctor */
select.off('select2:select').on('select2:select', function(e){
let selectedId = e.params.data.id;
$('#selectedDoctorId').val(selectedId);
/* force selected text into box */
$(this).val(selectedId).trigger('change');
});
}
function selectDoctor(id){
document.getElementById('selectedDoctorId').value = id;
}
document
.getElementById('appointmentForm')
.addEventListener('submit',function(e){
let spec =
document.getElementById('apptSpecialization');
let dateTime =
document.getElementById('apptDateTime');
let doctor =
document.getElementById('selectedDoctorId');
let valid=true;
/* reset */
[spec,dateTime].forEach(el=>{
el.classList.remove('field-error');
});
document.getElementById('specError').innerHTML='';
document.getElementById('dateTimeError').innerHTML='';
document.getElementById('doctorError').innerHTML='';
if($('#apptSpecialization').val().length===0){
spec.classList.add('field-error');
document.getElementById('specError')
.innerHTML='Please select at least one service';
valid=false;
}
let selectedServices = $('#apptSpecialization').val() || [];
let uniqueServices = new Set(selectedServices);
if (selectedServices.length !== uniqueServices.size) {
spec.classList.add('field-error');
document.getElementById('specError').innerHTML='Duplicate services are not allowed';
valid=false;
}
if(!dateTime.value){
dateTime.classList.add('field-error');
document.getElementById('dateTimeError')
.innerHTML='Please choose appointment date and time';
valid=false;
}
else if (new Date(dateTime.value) < new Date()) {
dateTime.classList.add('field-error');
document.getElementById('dateTimeError')
.innerHTML='Past date or time booking is not allowed';
valid=false;
}
// if(!doctor.value){
// document.getElementById('doctorError').innerHTML =
// 'Please select a doctor';
// valid=false;
// }
if(!valid){
e.preventDefault();
}
});
</script>
</main>
</div>
<script>
function toggleSidebar() {
const sidebar =
document.getElementById('sidebar');
const main =
document.getElementById('mainContent');
const icon =
document.getElementById('toggleIcon');
sidebar.classList.toggle('collapsed');
main.classList.toggle('expanded');
icon.className =
sidebar.classList.contains('collapsed')
? 'bi bi-layout-sidebar'
: 'bi bi-list';
}
function toggleNavDropdown(event,element){
event.preventDefault();
element.parentElement.classList.toggle('active');
}
</script>
</body>
</html>

View File

@ -67,7 +67,7 @@ if (! is_array($oldSpecializations)) {
<div class="alert alert-danger app-alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<div class="ov-panel" style="max-width: 1080px;">
<div class="ov-panel">
<div class="ov-panel__header">
<h2 class="ov-panel__title"><?= $isEdit ? 'Edit Doctor Account' : 'Create Doctor Account' ?></h2>
<a href="<?= base_url('admin/doctors') ?>" class="btn btn-sm btn-outline-secondary px-3">Back to doctors</a>
@ -115,6 +115,17 @@ if (! is_array($oldSpecializations)) {
</div>
<div class="col-md-6">
<label class="form-label" for="gender">Gender <span class="text-danger">*</span></label>
<select name="gender" id="gender" class="form-select <?= isset($validationErrors['gender']) ? 'is-invalid' : '' ?>" required>
<option value="">Select gender</option>
<option value="Male" <?= old('gender', $user['gender'] ?? '') === 'Male' ? 'selected' : '' ?>>Male</option>
<option value="Female" <?= old('gender', $user['gender'] ?? '') === 'Female' ? 'selected' : '' ?>>Female</option>
<option value="Other" <?= old('gender', $user['gender'] ?? '') === 'Other' ? 'selected' : '' ?>>Other</option>
</select>
<?= validation_show_error('gender') ?>
</div>
<div class="col-md-12">
<label class="form-label" for="specialization">Specialization <span class="text-danger">*</span></label>
<div class="multi-select-arrow-wrap">
<select name="specialization[]" id="specialization" class="form-select <?= isset($validationErrors['specialization']) ? 'is-invalid' : '' ?>" multiple required>

View File

@ -64,7 +64,7 @@
<div class="alert alert-danger app-alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<div class="ov-panel" style="max-width: 1080px;">
<div class="ov-panel">
<div class="ov-panel__header">
<h2 class="ov-panel__title"><?= $isEdit ? 'Edit Patient Account' : 'Create Patient Account' ?></h2>
<a href="<?= base_url('admin/patients') ?>" class="btn btn-sm btn-outline-secondary px-3">Back to patients</a>

View File

@ -0,0 +1,576 @@
<?php
$patient = $patient ?? null;
$service_names = $service_names ?? [];
$services = $services ?? [];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Appointment Preview</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
<link rel="stylesheet" href="<?= base_url('css/dashboard.css') ?>">
<style>
.toggle-expand-btn {
padding: 0;
font-size: 0.75rem;
text-decoration: none;
color: #0d6efd;
cursor: pointer;
display: inline-block;
margin-top: 4px;
}
.toggle-expand-btn:hover {
text-decoration: underline;
}
.expandable-container {
transition: all 0.3s ease;
}
</style>
</head>
<body class="app-body overview-layout">
<aside class="ov-sidebar" id="sidebar">
<div class="ov-brand"><h1><i class="bi bi-hospital me-1"></i> DoctGuide</h1><span>Control Panel</span></div>
<nav class="ov-nav">
<div class="ov-nav__section">Main</div>
<a href="<?= base_url('admin/dashboard') ?>" class="ov-nav__link"><i class="bi bi-speedometer2"></i> Dashboard</a>
<div class="ov-nav__section">Manage</div>
<div class="ov-nav__dropdown">
<a href="#" class="ov-nav__link d-flex justify-content-between align-items-center" onclick="toggleNavDropdown(event, this)">
<span><i class="bi bi-person-badge"></i> Doctors</span>
<i class="bi bi-chevron-down dropdown-icon"></i>
</a>
<div class="ov-dropdown-menu">
<a href="<?= base_url('admin/doctors') ?>" class="ov-nav__sublink">Doctor List</a>
<a href="<?= base_url('admin/doctors/add') ?>" class="ov-nav__sublink">Add Doctor</a>
</div>
</div>
<div class="ov-nav__dropdown">
<a href="#" class="ov-nav__link d-flex justify-content-between align-items-center" onclick="toggleNavDropdown(event, this)">
<span><i class="bi bi-people"></i> Patients</span>
<i class="bi bi-chevron-down dropdown-icon"></i>
</a>
<div class="ov-dropdown-menu">
<a href="<?= base_url('admin/patients') ?>" class="ov-nav__sublink">Patient List</a>
<a href="<?= base_url('admin/patients/add') ?>" class="ov-nav__sublink">Add Patient</a>
</div>
</div>
<a href="<?= base_url('admin/appointments') ?>" class="ov-nav__link active"><i class="bi bi-calendar2-check"></i> Appointments</a>
<a href="<?= base_url('admin/activity-log') ?>" class="ov-nav__link"><i class="bi bi-clipboard-data"></i> Activity Log</a>
</nav>
<div class="ov-sidebar__footer"><a href="<?= base_url('logout') ?>"><i class="bi bi-box-arrow-left"></i> Logout</a></div>
</aside>
<div class="ov-main" id="mainContent">
<header class="ov-topbar">
<div class="d-flex align-items-center w-100 gap-3">
<!-- Left Section -->
<div class="d-flex align-items-center gap-2">
<button class="ov-toggle-btn" onclick="toggleSidebar()" title="Toggle Sidebar">
<i class="bi bi-list" id="toggleIcon"></i>
</button>
<p class="ov-topbar__title mb-0">Appointment Preview</p>
</div>
<!-- Right Button -->
<a href="<?= base_url('admin/appointments') ?>"
class="btn btn-sm btn-outline-secondary px-3 ms-auto">
Back to Appointments
</a>
</div>
</header>
<main class="ov-content">
<div class="p-3">
<div class="card mb-3 shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-person-vcard me-2"></i>Appointment Details
</div>
<div class="card-body">
<?php
$patientRecord = is_array($patient ?? null) ? $patient : [];
$fullName = trim(((string) ($patientRecord['first_name'] ?? '')) . ' ' . ((string) ($patientRecord['last_name'] ?? '')));
if ($fullName === '') {
$fullName = 'Not Available';
}
$gender = $patientRecord['patient_gender'] ?? $patientRecord['user_gender'] ?? null;
$serviceNameList = is_array($service_names ?? null) ? $service_names : [];
$serviceSummary = $serviceNameList !== [] ? implode(', ', $serviceNameList) : 'Not Specified';
?>
<div class="row">
<!-- LEFT SIDE -->
<div class="col-md-6 border-end pe-4">
<h6 class="section-title">Patient Info</h6>
<div class="row g-2">
<div class="col-md-8">
<strong>Patient ID:</strong><br>
<?= esc($patientRecord['formatted_user_id'] ?? 'N/A') ?>
</div>
<div class="col-md-4">
<strong>Full Name:</strong><br>
<span class="fw-semibold"><?= esc($fullName) ?></span>
</div>
<div class="col-md-8">
<strong>Contact:</strong><br>
<?php
$phone = $patientRecord['phone'] ?? '';
$email = $patientRecord['email'] ?? '';
?>
<div class="d-flex align-items-center flex-wrap gap-3 mt-1">
<!-- PHONE -->
<a
href="<?= $phone ? 'tel:' . esc($phone) : '#' ?>"
class="text-decoration-none text-primary d-flex align-items-center gap-1"
>
<i class="bi bi-telephone-fill"></i>
<?= $phone ? esc($phone) : 'Not Available' ?>
</a>
<!-- EMAIL -->
<a
href="<?= $email ? 'mailto:' . esc($email) : '#' ?>"
class="text-decoration-none text-primary d-flex align-items-center gap-1"
>
<i class="bi bi-envelope-fill"></i>
<?= $email ? esc($email) : 'Not Available' ?>
</a>
</div>
</div>
<!-- <div class="col-md-6">
<strong>Email:</strong><br>
<?= esc($patientRecord['email'] ?? 'Not Available') ?>
</div>
<div class="col-md-6">
<strong>Phone:</strong><br>
<?= esc($patientRecord['phone'] ?? 'Not Available') ?>
</div> -->
<div class="col-md-4">
<strong>Gender:</strong><br>
<?= esc($gender ?: 'Not Available') ?>
</div>
<!-- <div class="col-md-12">
<strong>Requested Service:</strong><br>
<?= esc($serviceSummary) ?>
</div>
<div class="col-md-12">
<strong>Preferred Date & Time:</strong><br>
<?= esc(($date ?? '-') . ' ' . ($time ?? '-')) ?>
</div> -->
</div>
</div>
<!-- RIGHT SIDE -->
<div class="col-md-4 ps-4">
<h6 class="section-title">Preferences</h6>
<div class="info-box">
<strong>Requested Service:</strong><br>
<?= esc($serviceSummary) ?>
</div>
<div class="info-box">
<strong>Preferred Date & Time:</strong><br>
<?php
$displayDate = $date ?? '-';
$displayTime = $time ?? '-';
if ($displayTime !== '-' && $displayTime !== '') {
$displayTime = date('h:i A', strtotime($displayTime));
}
echo esc($displayDate . ' ' . $displayTime);
?>
</div>
<div class="info-box">
<strong>Preferred Physician:</strong><br>
<?= esc($doctor ?? 'Not Available') ?>
</div>
<!-- <div class="info-box">
<strong>Preferred Languages:</strong><br>
English, French
</div> -->
</div>
</div>
</div>
<!-- <div class="card-body">
<?php
$patientRecord = is_array($patient ?? null) ? $patient : [];
$fullName = trim(((string) ($patientRecord['first_name'] ?? '')) . ' ' . ((string) ($patientRecord['last_name'] ?? '')));
if ($fullName === '') {
$fullName = 'Not Available';
}
$gender = $patientRecord['patient_gender'] ?? $patientRecord['user_gender'] ?? null;
$serviceNameList = is_array($service_names ?? null) ? $service_names : [];
$serviceSummary = $serviceNameList !== [] ? implode(', ', $serviceNameList) : 'Not Specified';
?>
<div class="row g-3">
<div class="col-md-4"><strong>Patient ID:</strong> <?= esc($patientRecord['formatted_user_id'] ?? 'N/A') ?></div>
<div class="col-md-4"><strong>Full Name:</strong> <?= esc($fullName) ?></div>
<div class="col-md-4"><strong>Email:</strong> <?= esc($patientRecord['email'] ?? 'Not Available') ?></div>
<div class="col-md-4"><strong>Phone:</strong> <?= esc($patientRecord['phone'] ?? 'Not Available') ?></div>
<div class="col-md-4"><strong>DOB:</strong> <?= esc($patientRecord['dob'] ?? 'Not Available') ?></div>
<div class="col-md-4"><strong>Gender:</strong> <?= esc($gender ?: 'Not Available') ?></div>
<div class="col-md-4"><strong>Status:</strong> <?= esc($patientRecord['status'] ?? 'Not Available') ?></div>
<div class="col-md-8"><strong>Requested Service:</strong> <?= esc($serviceSummary) ?></div>
<div class="col-md-4"><strong>Preferred Date & Time:</strong> <?= esc(($date ?? '-') . ' ' . ($time ?? '-')) ?></div>
</div>
</div> -->
</div>
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-person-badge me-2"></i>Physician List
</div>
<div class="card-body">
<table id="doctorTable" class="table table-hover" style="width:100%">
<thead class="table-light">
<tr>
<th>#ID</th>
<th>Name</th>
<th>Specialization</th>
<th>Experience</th>
<th>Availability</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
<!-- <div class="ov-panel">
<div class="ov-panel__header">
<h2 class="ov-panel__title">Appointment Preview</h2>
</div>
</div> -->
</main>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let doctorTable = null;
const previewPatientId = <?= json_encode((int) ($patient_id ?? 0)) ?>;
const previewDate = <?= json_encode((string) ($date ?? '')) ?>;
const previewTime = <?= json_encode((string) ($time ?? '')) ?>;
const previewServices = <?= json_encode(array_values(is_array($services ?? []) ? $services : [])) ?>;
const previewId = <?= json_encode((int) ($preview_id ?? 0)) ?>;
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
$(document).ready(function() {
doctorTable = $('#doctorTable').DataTable({
processing: true,
ajax: {
url: "<?= base_url('admin/available-doctors') ?>",
method: 'POST',
data: {
date: previewDate,
time: previewTime,
preview_id: previewId,
specialization_id: previewServices
},
dataSrc: 'data',
error: function() {
$('#doctorTable tbody').html('<tr><td colspan="7" class="text-danger text-center">Failed to load doctors. Please try again later.</td></tr>');
}
},
columns: [
{
data: 'formatted_doctor_id',
render: function(data, type, row) {
const formattedId = escapeHtml(data || 'N/A');
if (type !== 'display' || !row.doctor_edit_token) {
return formattedId;
}
const url = "<?= base_url('admin/doctors/edit') ?>/" + encodeURIComponent(row.doctor_edit_token);
return `<a href="${url}" class="text-decoration-none fw-semibold">${formattedId}</a>`;
}
},
{ data: 'name' },
{
data: 'specializations',
render: function(data, type, row) {
if (!Array.isArray(data) || data.length === 0) {
return '<span class="text-muted">Not Assigned</span>';
}
const limit = 2;
const hasMore = data.length > limit;
const rowId = row.id || Math.random().toString(36).substr(2, 9);
let html = `<div class="expandable-container" id="spec-container-${rowId}">`;
data.forEach((spec, index) => {
const bg = spec.bg_color || '#e9ecef';
const text = spec.text_color || '#212529';
const isHidden = index >= limit ? 'd-none' : '';
html += `<span class="badge me-1 mb-1 ${isHidden}" style="background-color: ${escapeHtml(bg)}; color: ${escapeHtml(text)}; border: 1px solid rgba(0,0,0,0.1);">
${escapeHtml(spec.name)}
</span>`;
});
html += '</div>';
if (hasMore) {
html += `<a href="javascript:void(0);" class="toggle-expand-btn" onclick="toggleExpand(this, 'spec-container-${rowId}')">Show more</a>`;
}
return html;
}
},
{
data: 'experience',
render: function(data) {
if (!data || data === '0' || data === 0) {
return '<span class="text-muted">No Experience</span>';
}
let display = escapeHtml(data);
// If it's just a number (e.g. "12"), format it as "12 Years"
if (/^\d+$/.test(data)) {
display = data + (parseInt(data) === 1 ? ' Year' : ' Years');
}
return `<span class="badge bg-info-subtle text-info-emphasis border border-info-subtle px-2 py-1">
<i class="bi bi-briefcase me-1"></i>${display}
</span>`;
}
},
{
data: null,
render: function(data, type, row) {
const days = Array.isArray(row.available_days) ? row.available_days : [];
const allSlots = Array.isArray(row.all_slots) ? row.all_slots : [];
if (days.length === 0) {
return '<span class="text-muted">Not Available</span>';
}
const limit = 2;
let slotCount = 0;
const rowId = row.id || Math.random().toString(36).substr(2, 9);
let html = `<div class="expandable-container d-flex flex-wrap gap-2" id="slot-container-${rowId}">`;
days.forEach(day => {
const daySlots = allSlots.filter(slot => slot.day === day);
if (daySlots.length > 0) {
daySlots.forEach(slot => {
slotCount++;
const isHidden = slotCount > limit ? 'd-none' : '';
const dayAbbrev = day.substring(0, 3);
html += `
<span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle px-2 py-1 ${isHidden}">
<i class="bi bi-calendar-day me-1"></i>${escapeHtml(dayAbbrev)}
<span class="fw-bold mx-1">|</span>
<i class="bi bi-clock me-1"></i>${escapeHtml(slot.start_time_formatted)} - ${escapeHtml(slot.end_time_formatted)}
</span>
`;
});
} else {
slotCount++;
const isHidden = slotCount > limit ? 'd-none' : '';
const dayAbbrev = day.substring(0, 3);
html += `
<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle px-2 py-1 ${isHidden}">
<i class="bi bi-calendar-day me-1"></i>${escapeHtml(dayAbbrev)}
<span class="fw-bold mx-1">|</span>
<i class="bi bi-x-circle me-1"></i>No Slots
</span>
`;
}
});
html += '</div>';
if (slotCount > limit) {
html += `<a href="javascript:void(0);" class="toggle-expand-btn" onclick="toggleExpand(this, 'slot-container-${rowId}')">Show more</a>`;
}
return html;
}
},
// {
// data: 'available_days',
// render: function(data, type, row) {
// const days = Array.isArray(data) ? data : [];
// const text = row.available_days_text || 'Not Assigned';
// if (type !== 'display') {
// return text;
// }
// if (days.length === 0) {
// return '<span class="text-muted">Not Assigned</span>';
// }
// return days.map(day => `<span class="badge bg-info-subtle text-info-emphasis border border-info-subtle me-1 mb-1">${escapeHtml(day)}</span>`).join('');
// }
// },
// {
// data: 'time_slots',
// render: function(data) {
// if (!Array.isArray(data) || data.length === 0) {
// return '<span class="badge bg-warning text-dark">No Available Slots</span>';
// }
// return data.map(slot => `<span class="badge bg-info me-1">${escapeHtml(slot.start_time_formatted)} - ${escapeHtml(slot.end_time_formatted)}</span>`).join('<br>');
// }
// },
{
data: 'status',
render: function(data) {
if (data === 'Available') return '<span class="badge bg-success">Available</span>';
if (data === 'Booked') return '<span class="badge bg-danger">Assigned</span>';
return '<span class="badge bg-warning text-dark">Not Available</span>';
}
},
{
data: null,
render: function(data) {
if (data.status === 'Available') {
return `<button class="book-btn btn btn-success btn-sm" data-doctor="${escapeHtml(data.id)}">Assign</button>`;
} else if (data.status === 'Booked') {
return '<button class="btn btn-secondary btn-sm" disabled>Assigned</button>';
} else {
return '<button class="btn btn-secondary btn-sm" disabled>Not Available</button>';
}
}
}
],
pageLength: 10,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, 'All']],
order: [[0, 'asc']],
dom: '<"row mb-3"<"col-md-6 d-flex align-items-center"l><"col-md-6 d-flex justify-content-end"f>>rtip',
language: {
search: 'Search doctors:',
lengthMenu: 'Show _MENU_ doctors',
info: 'Showing _START_ to _END_ of _TOTAL_ doctors',
paginate: { first: 'First', last: 'Last', next: 'Next', previous: 'Previous' }
}
});
// Real-time update every 3 seconds
setInterval(function() {
if (doctorTable) {
doctorTable.ajax.reload(null, false); // false = don't reset paging
}
}, 3000);
});
$(document).on('click', '.book-btn', function() {
const btn = $(this);
const doctorId = btn.data('doctor');
btn.prop('disabled', true).text('Assigning...');
$.ajax({
url: "<?= base_url('admin/appointments/create') ?>",
method: 'POST',
contentType: 'application/json',
dataType: 'json',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': "<?= csrf_hash() ?>"
},
data: JSON.stringify({
doctor_id: doctorId,
patient_id: previewPatientId,
appointment_date: previewDate,
appointment_time: previewTime,
services: previewServices,
preview_id: previewId
}),
success: function(res) {
if (!res || !res.success) {
alert(res && res.message ? res.message : 'Assignment failed. Please try again.');
btn.prop('disabled', false).text('Assign');
return;
}
alert(res.message || 'Appointment assigned successfully.');
doctorTable.ajax.reload();
},
error: function(xhr) {
const message = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : 'Assignment failed. Please try again.';
alert(message);
btn.prop('disabled', false).text('Assign');
}
});
});
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const main = document.getElementById('mainContent');
const icon = document.getElementById('toggleIcon');
sidebar.classList.toggle('collapsed');
main.classList.toggle('expanded');
icon.className = sidebar.classList.contains('collapsed') ? 'bi bi-layout-sidebar' : 'bi bi-list';
}
function toggleExpand(btn, containerId) {
const container = document.getElementById(containerId);
const hiddenItems = container.querySelectorAll('.d-none');
const shownItems = container.querySelectorAll('.badge:not(.d-none)');
if (btn.innerText === 'Show more') {
// Find all badges that were hidden and show them
// We'll add a temporary class to track them
container.querySelectorAll('.badge.d-none').forEach(el => {
el.classList.remove('d-none');
el.classList.add('was-hidden');
});
btn.innerText = 'Show less';
} else {
// Find all badges that were originally hidden and hide them again
container.querySelectorAll('.badge.was-hidden').forEach(el => {
el.classList.add('d-none');
el.classList.remove('was-hidden');
});
btn.innerText = 'Show more';
}
}
function toggleNavDropdown(event, element) {
event.preventDefault();
element.parentElement.classList.toggle('active');
}
</script>
</body>
</html>

View File

@ -6,6 +6,8 @@
<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="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
<link rel="stylesheet" href="<?= base_url('css/dashboard.css') ?>">
</head>
@ -54,41 +56,272 @@
<div class="ov-panel">
<div class="ov-panel__header">
<h2 class="ov-panel__title">Appointments</h2>
<a href="<?= base_url('admin/dashboard') ?>" class="btn btn-sm btn-outline-secondary px-3">Back to dashboard</a>
<!-- <a href="<?= base_url('admin/dashboard') ?>" class="btn btn-sm btn-outline-secondary px-3">Back to dashboard</a> -->
<div class="d-flex flex-wrap gap-2 align-items-center">
<!-- DataTable export buttons will appear here -->
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="exportTable('csv'); return false;"><i class="bi bi-file-earmark-text me-2"></i> CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportTable('excel'); return false;"><i class="bi bi-file-earmark-excel me-2"></i> Excel</a></li>
<li><a class="dropdown-item" href="#" onclick="exportTable('pdf'); return false;"><i class="bi bi-file-earmark-pdf me-2"></i> PDF</a></li>
</ul>
</div>
<div class="p-0">
<table class="table ov-mini-table mb-0">
<thead>
</div>
</div>
<div class="p-3">
<table id="appointmentsTable" class="table table-hover" style="width:100%">
<thead class="table-light">
<tr>
<th class="ps-3">Sl No</th>
<th>#ID</th>
<th>Patient</th>
<th>Doctor</th>
<th>Date</th>
<th>Time</th>
<th>Service Request</th>
<th>Pref Date & Time</th>
<th>Pref Physician</th>
<th>Status</th>
<th width="130">Actions</th>
</tr>
</thead>
<tbody>
<?php $i = 1; ?>
<?php foreach ($appointments as $a): ?>
<?php $status = trim((string) ($a->status ?? '')); ?>
<tr>
<td class="ps-3"><?= $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>
<td><?= esc(ucfirst($status === '' ? 'pending' : $status)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</main>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/vfs_fonts.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let appointmentsTable = null;
$(document).ready(function() {
appointmentsTable = $('#appointmentsTable').DataTable({
processing: true,
ajax: {
url: "<?= base_url('admin/appointmentsData') ?>",
type: 'GET',
dataSrc: 'data',
error: function(xhr, status, error) {
let msg = 'Failed to load appointments. Please try again later.';
$('#appointmentsTable tbody').html('<tr><td colspan="6" class="text-danger text-center">' + msg + '</td></tr>');
}
},
columns: [
{ data: 'sl_no' },
{ data: 'patient_name' },
{ data: 'service_request' },
{
data: null,
render: function(data, type, row) {
let date = row.appointment_date;
let time = row.appointment_time;
if (!date) return '';
// Format Date
let formattedDate = new Date(date).toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
// Format Time
let formattedTime = '';
if (time) {
let t = new Date('1970-01-01T' + time);
formattedTime = t.toLocaleTimeString('en-IN', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
return `
<div class="d-flex flex-column">
<span class="fw-semibold">${formattedDate}</span>
<small class="text-muted">${formattedTime}</small>
</div>
`;
}
},
{
data: 'doctor_name',
render: function(data){
const name = String(data || '').trim();
return name !== '' ? name : 'Not Assigned';
}
},
{ data: 'status',
render: function(data){
let status = (data || 'pending').toLowerCase();
let cls = 'secondary';
if (status === 'confirmed' || status === 'approved' || status === 'assigned') cls = 'primary';
if (status === 'completed') cls = 'success';
if (status === 'cancelled' || status === 'rejected') cls = 'danger';
return `<span class="badge bg-${cls}">${status.charAt(0).toUpperCase() + status.slice(1)}</span>`;
}
},
{
data: null,
orderable: false,
searchable: false,
render: function (data, type, row) {
const params = new URLSearchParams();
params.append('patient_id', row.patient_id || '');
const deleteUrl = "<?= base_url('admin/appointments/delete') ?>/" + encodeURIComponent(row.id);
if (Array.isArray(row.services)) {
row.services.forEach(function(service) {
params.append('services[]', service);
});
} else if (row.services) {
params.append('services[]', row.services);
}
params.append('date', row.appointment_date || '');
params.append('time', row.appointment_time || '');
params.append('service_text', row.service_request || '');
params.append('doctor_id', row.doctor_id || '');
params.append('doctor_name', row.doctor_name || '');
const previewUrl = "<?= base_url('admin/appointment-preview') ?>?" + params.toString();
const currentStatus = (row.status || 'pending').toLowerCase();
let statusButtons = '';
// if (currentStatus === 'assigned' || currentStatus === 'approved' || currentStatus === 'pending') {
// statusButtons = `
// <button class="btn btn-outline-success btn-sm update-status-btn" data-id="${row.id}" data-status="completed" title="Mark Completed">
// <i class="bi bi-check-circle"></i>
// </button>
// <button class="btn btn-outline-warning btn-sm update-status-btn" data-id="${row.id}" data-status="cancelled" title="Cancel Appointment">
// <i class="bi bi-x-circle"></i>
// </button>
// `;
// }
return `
<div class="btn-group" role="group">
<a href="${previewUrl}" class="btn btn-outline-secondary btn-sm" title="Preview">
<i class="bi bi-eye"></i>
</a>
<a href="#" class="btn btn-outline-primary btn-sm" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="#"class="btn btn-outline-success btn-sm"title="Book Appointment">
<i class="bi bi-plus-lg"></i>
</a>
${statusButtons}
<a href="${deleteUrl}" class="btn btn-outline-danger btn-sm" onclick="return confirm('Clear this appointment? This will free the doctor time slot.');" title="Delete Permanent">
<i class="bi bi-trash"></i>
</a>
</div>
`;
}
}
],
pageLength: 10,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "All"]],
order: [[0, 'asc']],
dom: '<"row mb-3"<"col-md-6 d-flex align-items-center"l><"col-md-6 d-flex justify-content-end"f>>rtip',
buttons: [
{
extend: 'csvHtml5',
className: 'buttons-csv',
title: 'Appointments',
filename: 'appointments_list_' + new Date().toISOString().split('T')[0],
exportOptions: {
columns: [0, 1, 2, 3, 4, 5]
}
},
{
extend: 'excelHtml5',
className: 'buttons-excel',
title: 'Appointments',
filename: 'appointments_list_' + new Date().toISOString().split('T')[0],
exportOptions: {
columns: [0, 1, 2, 3, 4, 5]
}
},
{
extend: 'pdfHtml5',
className: 'buttons-pdf',
title: 'Appointments List',
filename: 'appointments_list_' + new Date().toISOString().split('T')[0],
orientation: 'landscape',
pageSize: 'A4',
exportOptions: {
columns: [0, 1, 2, 3, 4, 5]
}
}
],
language: {
search: 'Search appointments:',
lengthMenu: 'Show _MENU_ appointments',
info: 'Showing _START_ to _END_ of _TOTAL_ appointments',
paginate: {
first: 'First',
last: 'Last',
next: 'Next',
previous: 'Previous'
},
emptyTable: 'No appointments found',
zeroRecords: 'No matching appointments found'
}
});
$(document).on('click', '.update-status-btn', function() {
const btn = $(this);
const id = btn.data('id');
const status = btn.data('status');
if (!confirm(`Are you sure you want to mark this appointment as ${status}?`)) {
return;
}
btn.prop('disabled', true);
$.ajax({
url: "<?= base_url('admin/appointments/update-status') ?>",
method: 'POST',
data: {
appointment_id: id,
status: status,
"<?= csrf_token() ?>": "<?= csrf_hash() ?>"
},
success: function(res) {
if (res && res.success) {
appointmentsTable.ajax.reload(null, false);
} else {
alert(res.message || 'Failed to update status');
btn.prop('disabled', false);
}
},
error: function() {
alert('An error occurred. Please try again.');
btn.prop('disabled', false);
}
});
});
});
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const main = document.getElementById('mainContent');
@ -102,6 +335,22 @@ function toggleNavDropdown(event, element) {
event.preventDefault();
element.parentElement.classList.toggle('active');
}
function exportTable(type) {
if (!appointmentsTable) return;
if (type === 'csv') {
appointmentsTable.button('.buttons-csv').trigger();
}
else if (type === 'excel') {
appointmentsTable.button('.buttons-excel').trigger();
}
else if (type === 'pdf') {
appointmentsTable.button('.buttons-pdf').trigger();
}
// close dropdown
$('.dropdown-toggle').dropdown('hide');
}
</script>
</body>
</html>

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="<?= base_url('css/doctors.css') ?>">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body class="app-body overview-layout">
<aside class="ov-sidebar" id="sidebar">
@ -101,6 +102,129 @@
</div>
</main>
</div>
<!-- Add Availability Modal -->
<div class="modal fade" id="availabilityModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Availability</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="availabilityForm" method="POST" action="<?= base_url('admin/availability/save') ?>">
<!-- <div class="modal-body">
<input type="hidden" name="doctor_id" id="doctorId">
<div class="mb-3">
<label class="form-label">Doctor Name</label>
<input type="text" id="doctorName" class="form-control" readonly>
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<input type="date" name="date" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Time</label>
<input type="time" name="time" class="form-control" required>
</div>
</div> -->
<div class="modal-body modal-body-scrollable">
<input type="hidden" name="doctor_id" id="doctorId">
<div class="mb-2 d-flex align-items-center flex-wrap">
<label class="form-label mb-2 me-2 fw-bold">
Doctor Name :
</label>
<span id="doctorNameText" class="me-2 mb-2 fw-semibold"></span>
<!-- Keep badges here -->
<div id="selectedBadges" class="d-flex flex-wrap gap-3 mb-2"></div>
</div>
<style>
#availabilityModal .modal-dialog {
max-height: calc(100vh - 2rem);
margin: 1rem auto;
}
#availabilityModal .modal-content {
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
}
/* Make only modal body scrollable, not the modal itself */
.modal-body-scrollable {
max-height: calc(100vh - 180px);
flex: 1 1 auto;
overflow-y: auto;
}
.error-msg {
color: red;
font-size: 12px;
/* min-height: 14px; */
}
.is-invalid {
border: 1px solid red !important;
}
.is-invalid:focus {
border-color: red !important;
box-shadow: 0 0 0 0.2rem rgba(255, 0, 0, 0.25) !important;
}
/* Fix slot-box error alignment and input styling */
.slot-box {
/* align-items: flex-start !important;
display: inline-flex !important;
flex-wrap: nowrap !important;
width: fit-content; */
}
.slot-time-col {
width: 110px;
min-width: 110px;
flex: 0 0 110px;
}
.slot-box .form-control {
min-width: 110px;
}
.slot-box .error-msg {
/* min-height: 18px; */
font-size: 12px;
color: #e53935;
display: block;
margin-top: 2px;
/* line-height: 1.2; */
width: 110px;
max-width: 110px;
white-space: normal;
overflow-wrap: break-word;
}
.slot-box .form-control.is-invalid {
border-color: #e53935 !important;
box-shadow: 0 0 0 0.2rem rgba(229,57,53,0.08) !important;
}
.slot-remove-btn {
flex: 0 0 auto;
align-self: flex-start;
margin-left: 6px;
}
</style>
<div id="availabilityContainer"></div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-success">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
@ -117,7 +241,42 @@
<script>
let doctorsTable = null;
const expandedSpecializations = new Set();
const days = [
{ name: "Monday", value: 1 },
{ name: "Tuesday", value: 2 },
{ name: "Wednesday", value: 3 },
{ name: "Thursday", value: 4 },
{ name: "Friday", value: 5 },
{ name: "Saturday", value: 6 },
{ name: "Sunday", value: 7 }
];
function renderAvailabilityUI() {
let html = '';
days.forEach(day => {
html += `
<div class="day-row mb-3 p-2 border rounded" data-day="${day.value}">
<div class="d-flex align-items-center gap-2 flex-wrap">
<strong style="width:100px;">${day.name}</strong>
<button type="button" class="btn btn-sm btn-outline-primary add-slot" data-day="${day.value}">
<i class="bi bi-plus"></i> add slot
</button>
<div class="slots-container d-flex flex-wrap gap-2" id="slots-${day.value}">
</div>
</div>
</div>
`;
});
$('#availabilityContainer').html(html);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
@ -126,6 +285,135 @@ function escapeHtml(value) {
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function validateSlots() {
let isValid = true;
$('.slot-box').each(function () {
let startInput = $(this).find('.start-time');
let endInput = $(this).find('.end-time');
let startError = $(this).find('.slot-error');
let endError = $(this).find('.slot-error');
// Clear old errors
startError.text('');
endError.text('');
startInput.removeClass('is-invalid');
endInput.removeClass('is-invalid');
let start = startInput.val();
let end = endInput.val();
if (!start || !end) return;
let startTime = new Date(`1970-01-01T${start}:00`);
let endTime = new Date(`1970-01-01T${end}:00`);
let minTime = new Date("1970-01-01T09:00:00");
let maxTime = new Date("1970-01-01T19:00:00");
// Start time validation
if (startTime < minTime || startTime > maxTime) {
startError.text("Start time must be between 9:00 AM and 7:00 PM");
startInput.addClass('is-invalid').focus();
isValid = false;
return false;
}
// End time validation
if (endTime < minTime || endTime > maxTime) {
endError.text("End time must be between 9:00 AM and 7:00 PM");
endInput.addClass('is-invalid').focus();
isValid = false;
return false;
}
let diff = (endTime - startTime) / (1000 * 60);
// Invalid order
if (startTime >= endTime) {
endError.text("End time must be greater than start time");
endInput.addClass('is-invalid').focus();
isValid = false;
return false;
}
// Less than 15 min
if (diff < 15) {
endError.text("Minimum slot is 15 minutes");
endInput.addClass('is-invalid').focus();
isValid = false;
return false;
}
// More than 3 hours
if (diff > 180) {
endError.text("Maximum slot is 3 hours");
endInput.addClass('is-invalid').focus();
isValid = false;
return false;
}
});
return isValid;
}
$('#availabilityForm').on('submit', function (e) {
// 🔴 STEP 1: Validate time slots FIRST
if (!validateSlots()) {
e.preventDefault();
return false;
}
let availability = {};
$('.day-row').each(function () {
const day = $(this).data('day');
const slots = [];
$(this).find('.slot-box').each(function () {
const start = $(this).find('.start-time').val();
const end = $(this).find('.end-time').val();
if (start && end) {
slots.push({ start, end });
}
});
if (slots.length > 0) {
availability[day] = slots;
}
});
if (Object.keys(availability).length === 0) {
alert('Please add at least one time slot!');
e.preventDefault();
return false;
}
const doctorId = $('#doctorId').val();
if (!doctorId) {
alert('Doctor ID is missing!');
e.preventDefault();
return false;
}
// Remove old hidden input
$('input[name="availability_json"]').remove();
$('<input>')
.attr('type', 'hidden')
.attr('name', 'availability_json')
.val(JSON.stringify(availability))
.appendTo(this);
return true;
});
// $('#availabilityForm').on('submit', function (e) {
// if (!validateSlots()) {
// e.preventDefault();
// }
// });
function renderSpecializations(data, row) {
if (!data) {
@ -178,7 +466,7 @@ function toggleNavDropdown(event, element) {
$(document).ready(function() {
doctorsTable = $('#doctorsTable').DataTable({
processing: false,
processing: true,
ajax: {
url: "<?= base_url('admin/doctors/data') ?>",
type: 'GET',
@ -258,6 +546,12 @@ $(document).ready(function() {
onclick="return confirm('Are you sure you want to delete this doctor? This action cannot be undone.');">
<i class="bi bi-trash"></i>
</a>
<button class="btn btn-outline-success btn-sm open-availability-modal"
title="Add Availability"
data-id="${row.doctor_id}" // ← was row.user_id
data-name="${row.name}">
<i class="bi bi-calendar-plus"></i>
</button>
</div>
`;
}
@ -299,6 +593,8 @@ $(document).ready(function() {
}
],
language: {
// processing: '<span class="text-primary"><i class="bi bi-arrow-repeat me-1"></i>Loading doctors...</span>',
// loadingRecords: 'Loading doctors...',
search: 'Search doctors:',
lengthMenu: 'Show _MENU_ doctors',
info: 'Showing _START_ to _END_ of _TOTAL_ doctors',
@ -317,7 +613,7 @@ $(document).ready(function() {
if (doctorsTable) {
doctorsTable.ajax.reload(null, false);
}
}, 5000);
}, 15000);
});
function exportTable(format) {
@ -355,6 +651,290 @@ $(document).on('click', '.specialization-toggle', function () {
$allToggles.text($target.hasClass('d-none') ? showLabel : hideLabel);
});
// $(document).on('click', '.add-slot', function () {
// const day = $(this).data('day');
// const slotHtml = `
// <div class="slot-box d-flex align-items-center gap-1 border rounded p-1">
// <input type="time" class="form-control form-control-sm start-time" style="width:110px;">
// <span>-</span>
// <input type="time" class="form-control form-control-sm end-time" style="width:110px;">
// <button type="button" class="btn btn-sm btn-danger remove-slot">
// <i class="bi bi-x"></i>
// </button>
// </div>
// `;
// $(`#slots-${day}`).append(slotHtml);
// updateBadges();
// });
// Add slot handler: always render error containers under each input
$(document).on('click', '.add-slot', function () {
const day = $(this).data('day');
const slotHtml = `
<div class="slot-box border rounded p-1 mb-1">
<div class="d-flex align-items-center gap-1">
<div class="d-flex flex-column align-items-start slot-time-col">
<input type="time" class="form-control form-control-sm start-time" style="width:110px;">
</div>
<span>-</span>
<div class="d-flex flex-column align-items-start slot-time-col">
<input type="time" class="form-control form-control-sm end-time" style="width:110px;">
</div>
<button type="button" class="btn btn-sm btn-danger remove-slot slot-remove-btn" tabindex="-1">
<i class="bi bi-x"></i>
</button>
</div>
<span class="text-danger slot-error"></span>
</div>
`;
$(`#slots-${day}`).append(slotHtml);
});
// Remove slot handler
$(document).on('click', '.remove-slot', function () {
$(this).closest('.slot-box').remove();
});
// Real-time validation for slot time inputs
$(document).on('blur change', '.start-time, .end-time', function () {
const slotBox = $(this).closest('.slot-box');
const startInput = slotBox.find('.start-time');
const endInput = slotBox.find('.end-time');
const startError = slotBox.find('.slot-error');
const endError = slotBox.find('.slot-error');
// Clear previous errors
startError.text('');
endError.text('');
startInput.removeClass('is-invalid');
endInput.removeClass('is-invalid');
const start = startInput.val();
const end = endInput.val();
if (!start) {
startError.text('Start time is required');
startInput.addClass('is-invalid');
return;
}
if (!end) {
endError.text('End time is required');
endInput.addClass('is-invalid');
return;
}
const startTime = new Date(`1970-01-01T${start}:00`);
const endTime = new Date(`1970-01-01T${end}:00`);
const minTime = new Date('1970-01-01T09:00:00');
const maxTime = new Date('1970-01-01T19:00:00');
const diff = (endTime - startTime) / (1000 * 60);
if (startTime < minTime || startTime > maxTime) {
startError.text('Start time must be between 9:00 AM and 7:00 PM');
startInput.addClass('is-invalid');
return;
}
if (endTime < minTime || endTime > maxTime) {
endError.text('End time must be between 9:00 AM and 7:00 PM');
endInput.addClass('is-invalid');
return;
}
if (startTime >= endTime) {
endError.text('End time must be greater than start time');
endInput.addClass('is-invalid');
return;
}
if (diff < 15) {
endError.text('Minimum slot is 15 minutes');
endInput.addClass('is-invalid');
return;
}
if (diff > 180) {
endError.text('Maximum slot is 3 hours');
endInput.addClass('is-invalid');
return;
}
});
$(document).on('input focus', '.start-time, .end-time', function () {
$(this).removeClass('is-invalid');
$(this).closest('.slot-box').find('.error-msg').text('');
});
$(document).on('change', '.start-time, .end-time', function () {
updateBadges();
});
$(document).on('click', '.open-availability-modal', function () {
const doctorId = $(this).data('id');
const doctorName = $(this).data('name');
// Reset the form completely
document.getElementById('availabilityForm').reset();
$('#selectedBadges').html('');
// Set doctor ID and name
$('#doctorId').val(doctorId);
$('#doctorNameText').text(doctorName);
// Build empty day rows
renderAvailabilityUI();
// Fetch ACTIVE slots only
$.get('/admin/availability/' + doctorId, function(slots){
slots.forEach(function(slot){
const slotHtml = `
<div class="slot-box border rounded p-1 mb-2">
<div class="d-flex align-items-center gap-1">
<div class="d-flex flex-column align-items-start slot-time-col">
<input type="time" class="form-control form-control-sm start-time" value="${slot.start_time}" style="width:110px;">
</div>
<span>-</span>
<div class="d-flex flex-column align-items-start slot-time-col">
<input type="time" class="form-control form-control-sm end-time" value="${slot.end_time}" style="width:110px;">
</div>
<button type="button" class="btn btn-sm btn-danger remove-slot slot-remove-btn" tabindex="-1"> <i class="bi bi-x"></i></button>
</div>
<span class="text-danger slot-error"></span>
</div>
`;
$('#slots-' + slot.day_number).append(slotHtml);
});
updateBadges();
});
const modal = new bootstrap.Modal(
document.getElementById('availabilityModal')
);
modal.show();
});
// $('#availabilityForm').on('submit', function (e) {
// let availability = {};
// $('.day-row').each(function () {
// const day = $(this).data('day');
// const slots = [];
// $(this).find('.slot-box').each(function () {
// const start = $(this).find('.start-time').val();
// const end = $(this).find('.end-time').val();
// if (start && end) {
// slots.push({
// start: start,
// end: end
// });
// }
// });
// if (slots.length > 0) {
// availability[day] = slots;
// }
// });
// // Validate availability data
// if (Object.keys(availability).length === 0) {
// alert('Please add at least one time slot!');
// e.preventDefault();
// return false;
// }
// const doctorId = $('#doctorId').val();
// if (!doctorId) {
// alert('Doctor ID is missing!');
// e.preventDefault();
// return false;
// }
// console.log('Submitting availability for doctor:', doctorId);
// console.log('Availability data:', availability);
// // Remove any existing hidden input to avoid duplicates
// $('input[name="availability_json"]').remove();
// $('<input>')
// .attr('type', 'hidden')
// .attr('name', 'availability_json')
// .val(JSON.stringify(availability))
// .appendTo(this);
// return true;
// });
function updateBadges() {
const badgeContainer = $('#selectedBadges');
badgeContainer.html('');
let slotSet = new Set(); // track duplicates
$('.day-row').each(function () {
const dayValue = $(this).data('day');
const dayObj = days.find(d => d.value == dayValue);
const dayName = dayObj ? dayObj.name : '';
$(this).find('.slot-box').each(function () {
const start = $(this).find('.start-time').val();
const end = $(this).find('.end-time').val();
if (start && end) {
const uniqueKey = `${dayValue}-${start}-${end}`;
// ❌ DUPLICATE DETECTED
if (slotSet.has(uniqueKey)) {
$(this).addClass('border-danger'); // highlight duplicate
return;
}
slotSet.add(uniqueKey);
$(this).removeClass('border-danger');
const badge = `
<span class="badge bg-primary slot-badge"
data-day="${dayValue}"
data-start="${start}"
data-end="${end}"
style="cursor:pointer;">
${dayName.substring(0,3)} ${start} - ${end}
<i class="fa-solid fa-xmark ms-1"></i>
</span>
`;
badgeContainer.append(badge);
}
});
});
}
$(document).on('change', '.start-time, .end-time', function () {
updateBadges();
});
$(document).on('click', '.slot-badge', function () {
const day = $(this).data('day');
const start = $(this).data('start');
const end = $(this).data('end');
// find matching slot and remove
$(`#slots-${day} .slot-box`).each(function () {
const s = $(this).find('.start-time').val();
const e = $(this).find('.end-time').val();
if (s === start && e === end) {
$(this).remove();
}
});
updateBadges(); // refresh UI
});
</script>
</body>
</html>

View File

@ -6,6 +6,7 @@
<title>Patients</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
<link rel="stylesheet" href="<?= base_url('css/dashboard.css') ?>">
<link rel="stylesheet" href="<?= base_url('css/doctors.css') ?>">
@ -94,6 +95,101 @@
</div>
</main>
</div>
<!-- Appointment Modal -->
<div class="modal fade" id="appointmentModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create Appointment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="appointmentForm" method="POST" action="<?= base_url('admin/appointments/create') ?>">
<div class="modal-body">
<!-- Patient Info Section -->
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label">Formatted Patient ID</label>
<input type="text" id="apptFormattedId" class="form-control" readonly>
</div>
<div class="col-md-6">
<label class="form-label">Patient Name</label>
<input type="text" id="apptName" class="form-control" readonly>
</div>
</div>
<!-- Service/Specialization -->
<div class="mb-3">
<label class="form-label">Service Request <span class="text-danger">*</span></label>
<select id="apptSpecialization" class="form-select" style="width:100%;" required>
<option value="">Select a specialization...</option>
</select>
</div>
<!-- Date and Time -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Date <span class="text-danger">*</span></label>
<input type="date" id="apptDate" class="form-control" required>
</div>
<div class="col-md-6">
<label class="form-label">Time <span class="text-danger">*</span></label>
<input type="time" id="apptTime" class="form-control" required>
</div>
</div>
<!-- Doctors List -->
<div id="doctorsContainer" class="d-none mb-4">
<label class="form-label">Available Doctors <span class="text-danger">*</span></label>
<div class="row g-3" id="doctorsList"></div>
<input type="hidden" id="selectedDoctorId" name="doctor_id" required>
</div>
<!-- Note -->
<div class="mb-3">
<label class="form-label">Note</label>
<textarea id="apptNote" class="form-control" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-success" id="submitBtn" disabled>
<i class="bi bi-check-circle"></i> Save Appointment
</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* .modal-backdrop.show {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background-color: rgba(0, 0, 0, 0.3) !important;
}
.modal-content {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 16px;
border: none;
} */
.modal.fade .modal-dialog {
transform: scale(0.9);
transition: all 0.3s ease;
}
.modal.show .modal-dialog {
transform: scale(1);
}
</style>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
@ -106,6 +202,7 @@
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.print.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
let patientsTable = null;
@ -134,7 +231,7 @@ function toggleNavDropdown(event, element) {
$(document).ready(function() {
patientsTable = $('#patientsTable').DataTable({
processing: false,
processing: true,
ajax: {
url: "<?= base_url('admin/patients/data') ?>",
type: 'GET',
@ -188,21 +285,30 @@ $(document).ready(function() {
orderable: false,
searchable: false,
render: function (data, type, row) {
const editUrl = "<?= base_url('admin/patients/edit') ?>/" + encodeURIComponent(row.edit_token);
const deleteUrl = "<?= base_url('admin/deletePatient') ?>/" + encodeURIComponent(row.user_id);
return `
<div class="btn-group" role="group">
<a href="${editUrl}" class="btn btn-outline-primary btn-sm" title="Edit Patient">
<a href="${editUrl}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil"></i>
</a>
<a href="${deleteUrl}" class="btn btn-outline-danger btn-sm" title="Delete Patient"
onclick="return confirm('Are you sure you want to delete this patient? This action cannot be undone.');">
<a href="<?= base_url('admin/appointments/create-form') ?>/${row.user_id}"
class="btn btn-outline-success btn-sm"
title="Book Appointment">
<i class="bi bi-plus-lg"></i>
</a>
<a href="${deleteUrl}" class="btn btn-outline-danger btn-sm"
onclick="return confirm('Delete this patient?');">
<i class="bi bi-trash"></i>
</a>
</div>
`;
}
}
}
],
pageLength: 10,
@ -259,7 +365,13 @@ $(document).ready(function() {
if (patientsTable) {
patientsTable.ajax.reload(null, false);
}
}, 5000);
}, 15000);
// $('#apptService').select2({
// placeholder: "Select or search service",
// dropdownParent: $('#appointmentModal'),
// allowClear: true,
// width: '100%'
// });
});
function exportTable(format) {
@ -275,6 +387,236 @@ function exportTable(format) {
patientsTable.button('.buttons-pdf').trigger();
}
}
// $(document).on('click', '.open-appointment-modal', function () {
// const userId = $(this).data('id');
// const name = $(this).data('name');
// $('#apptUserId').val(userId);
// $('#apptName').val(name);
// // ✅ Reset fields
// $('#apptService').val(null).trigger('change');
// $('#apptTiming').val('');
// $('#apptNote').val('');
// const modal = new bootstrap.Modal(document.getElementById('appointmentModal'));
// modal.show();
// });
// Load specializations on page load
document.addEventListener('DOMContentLoaded', function() {
loadSpecializations();
const dateInput = document.getElementById('apptDate');
if (dateInput) {
dateInput.min = new Date().toISOString().slice(0, 10);
}
// Handle date and time change
document.getElementById('apptDate').addEventListener('change', fetchAvailableDoctors);
document.getElementById('apptTime').addEventListener('change', fetchAvailableDoctors);
document.getElementById('apptSpecialization').addEventListener('change', fetchAvailableDoctors);
// Handle form submission
document.getElementById('appointmentForm').addEventListener('submit', function(e) {
e.preventDefault();
submitAppointmentForm();
});
});
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function loadSpecializations() {
fetch('<?= base_url('admin/specializations') ?>', {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success && data.specializations.length > 0) {
const specSelect = document.getElementById('apptSpecialization');
specSelect.innerHTML = '<option value="">Select a specialization...</option>';
data.specializations.forEach(spec => {
const option = document.createElement('option');
option.value = spec.id;
option.textContent = spec.name;
specSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error loading specializations:', error);
});
}
function openAppointmentModal(userId, formattedId, patientName) {
// Reset form
document.getElementById('appointmentForm').reset();
document.getElementById('apptFormattedId').value = formattedId;
document.getElementById('apptName').value = patientName;
document.getElementById('selectedDoctorId').value = '';
document.getElementById('doctorsContainer').classList.add('d-none');
document.getElementById('submitBtn').disabled = true;
// Store user ID for later use
document.getElementById('appointmentForm').dataset.userId = userId;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('appointmentModal'));
modal.show();
}
function fetchAvailableDoctors() {
const date = document.getElementById('apptDate').value;
const time = document.getElementById('apptTime').value;
const specializationId = document.getElementById('apptSpecialization').value;
if (!date || !time || !specializationId) {
document.getElementById('doctorsContainer').classList.add('d-none');
document.getElementById('submitBtn').disabled = true;
return;
}
const formData = new FormData();
formData.append('date', date);
formData.append('time', time);
formData.append('specialization_id', specializationId);
fetch('<?= base_url('admin/available-doctors') ?>', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success && data.doctors.length > 0) {
displayDoctors(data.doctors);
document.getElementById('doctorsContainer').classList.remove('d-none');
} else {
document.getElementById('doctorsContainer').classList.add('d-none');
document.getElementById('submitBtn').disabled = true;
alert('No doctors available for the selected date, time, and specialization.');
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while fetching doctors.');
});
}
function displayDoctors(doctors) {
const doctorsList = document.getElementById('doctorsList');
doctorsList.innerHTML = '';
doctors.forEach(doctor => {
const col = document.createElement('div');
col.className = 'col-md-6';
const card = document.createElement('div');
const statusClass = doctor.is_available ? '' : 'opacity-50';
card.className = `border rounded-3 p-3 doctor-card ${statusClass}`;
if (!doctor.is_available) {
card.style.pointerEvents = 'none';
}
card.innerHTML = `
<div class="form-check">
<input class="form-check-input doctor-radio" type="radio" name="doctorSelection" id="doctor_${doctor.id}"
value="${doctor.id}" ${!doctor.is_available ? 'disabled' : ''}>
<label class="form-check-label w-100" for="doctor_${doctor.id}">
<h6 class="mb-1">Dr. ${escapeHtml(doctor.name)}</h6>
<p class="text-muted small mb-0">${doctor.is_available ? '✓ Available' : '✗ Not Available'}</p>
</label>
</div>
`;
col.appendChild(card);
doctorsList.appendChild(col);
// Add click listener to radio buttons
document.getElementById(`doctor_${doctor.id}`).addEventListener('change', function() {
if (this.checked) {
document.getElementById('selectedDoctorId').value = this.value;
document.getElementById('submitBtn').disabled = false;
highlightSelectedDoctor(doctor.id);
}
});
});
}
function highlightSelectedDoctor(doctorId) {
document.querySelectorAll('.doctor-card').forEach(card => {
card.classList.remove('border-primary', 'bg-light');
});
const selectedCard = document.getElementById(`doctor_${doctorId}`).closest('.doctor-card');
if (selectedCard) {
selectedCard.classList.add('border-primary', 'bg-light');
}
}
function submitAppointmentForm() {
const form = document.getElementById('appointmentForm');
const userId = parseInt(form.dataset.userId);
const doctorId = parseInt(document.getElementById('selectedDoctorId').value);
const date = document.getElementById('apptDate').value;
const time = document.getElementById('apptTime').value;
const note = document.getElementById('apptNote').value;
if (!userId || !doctorId || !date || !time) {
alert('Please select all required fields.');
return;
}
if (new Date(date + 'T' + time) < new Date()) {
alert('Past date or time booking is not allowed.');
return;
}
const submitData = {
patient_id: userId,
doctor_id: doctorId,
appointment_date: date,
appointment_time: time,
note: note
};
fetch('<?= base_url('admin/appointments/create') ?>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(submitData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Appointment booked successfully!');
const modal = bootstrap.Modal.getInstance(document.getElementById('appointmentModal'));
modal.hide();
location.reload();
} else {
alert(data.message || 'Failed to book appointment.');
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while booking the appointment.');
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,945 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Doctor Search</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="<?= base_url('css/dashboard.css') ?>">
<style>
body
{
background:#e6f1f2;
min-height:100vh;
font-family:system-ui,-apple-system,sans-serif;
}
/* Page Container */
.app-wrapper{
padding:20px;
width:100%;
max-width:none;
margin:0;
}
/* Top Filters */
.filter-bar{
background:#fff;
padding:18px;
border-radius:22px;
box-shadow:0 8px 30px rgba(0,0,0,.08);
margin-bottom:25px;
}
.search-group,
#specializationFilter,
#dateFilter,
#timeFilter,
#sortFilter{
height:48px !important;
border-radius:14px !important;
}
/* make select + inputs visually match */
#specializationFilter,
#dateFilter,
#timeFilter,
#sortFilter{
border:1px solid #d9e3ea !important;
padding:0 40px 0 16px !important;
box-shadow:none !important;
background:#fff !important;
appearance:none;
-webkit-appearance:none;
}
/* disabled time field */
#timeFilter:disabled{
background:#eef1f5 !important;
opacity:1;
}
/* align text vertically */
#specializationFilter,
#dateFilter,
#timeFilter{
line-height:48px;
}
/* Panels */
.panel-box{
background:#fff;
border-radius:22px;
padding:20px;
box-shadow:0 8px 30px rgba(0,0,0,.08);
height:100%;
}
/* Doctor Card */
.doctor-card{
padding:0;
margin-bottom:18px;
transition:.3s;
cursor:pointer;
height:auto; /* changed from 100% */
display:flex;
flex-direction:column;
}
.doctor-card{
border:1px solid transparent !important;
transition:all .25s ease;
}
.doctor-card:hover{
transform:translateY(-4px);
border:1px solid #7fd6d0 !important;
box-shadow:0 10px 20px rgba(127,214,208,.18);
background:#ffffff;
}
.doctor-card:hover .avatar{
transform:scale(1.05);
transition:.25s;
}
.doctor-top{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:10px;
}
.doctor-profile{
display:flex;
align-items:center;
gap:12px;
}
.avatar{
/* width:56px; */
height:56px;
border-radius:50%;
/* background:#dbeafe; */
display:flex;
align-items:center;
justify-content:center;
font-weight:bold;
font-size:28px;
}
.doc-name{
font-weight:600;
font-size:16px;
margin-bottom:2px;
}
.doc-specialty{
font-size:13px;
color:#666;
}
/* Badges */
.spec-badge{
display:inline-block;
padding:6px 12px;
border-radius:30px;
background:#dff7f7;
color:#045d68;
font-size:12px;
margin-right:6px;
margin-bottom:10px;
}
/* Time Slots */
.time-slots,
.extra-slots{
display:flex;
flex-wrap:wrap;
gap:8px;
list-style:none;
padding:0;
margin:8px 0 0;
}
.slot-item{
background:#d9f7f4;
padding:5px 10px;
border-radius:10px;
font-size:13px;
line-height:1.3;
transition:.3s;
}
.slot-item:hover{
background:#b8efea;
}
/* hidden extra slots */
.extra-slots{
display:none;
width:100%;
}
/* see more */
.see-more-btn{
border:none;
background:transparent;
color:#0d6efd;
font-size:13px;
font-weight:600;
margin-top:6px;
}
/* mobile 2 per row */
@media(max-width:768px){
.slot-item{
flex:0 0 calc(50% - 6px);
}
}
/* Selected doctor box */
.selected-box{
border:2px dashed #dce6ee;
border-radius:18px;
padding:30px;
text-align:center;
color:#777;
min-height:280px;
}
/* Mobile filter button */
.mobile-filter-btn{
display:none;
}
@media(max-width:991px){
.right-panel{
margin-top:20px;
}
.middle-panel{
margin-top:20px;
}
}
@media(max-width:768px){
.filter-bar .row > div{
margin-bottom:12px;
}
.mobile-filter-btn{
display:block;
margin-bottom:15px;
}
.desktop-filter{
display:none;
}
}
#doctorList{
row-gap:10px;
}
.ov-dropdown-menu {
display: none;
padding-left: 30px;
}
/* Show when active */
.ov-nav__dropdown.active .ov-dropdown-menu {
display: block;
}
.doctor-card .card-body{
padding:14px 16px 4px 20px !important;
}
.search-group{
display:flex;
align-items:center;
border:1px solid #d9e3ea;
border-radius:14px;
overflow:hidden;
background:#fff;
height:48px;
}
.search-box{
border:none !important;
height:48px;
box-shadow:none !important;
padding-left:15px;
}
.search-box:focus{
box-shadow:none !important;
border:none !important;
}
.search-icon{
background:transparent !important;
border:none !important;
padding:0 16px;
font-size:18px;
color:#6c757d;
display:flex;
align-items:center;
justify-content:center;
}
.search-icon:hover{
color:#0d6efd;
}
.select2-container{
width:100% !important;
}
.select2-container--default .select2-selection--multiple{
height:48px !important;
min-height:48px !important;
display:flex !important;
align-items:center !important;
flex-wrap:nowrap !important;
border:1px solid #d9e3ea !important;
border-radius:14px !important;
padding:5px 38px 5px 10px !important;
overflow:hidden !important;
background:#fff !important;
}
.select2-selection__choice{
background:#d9f7f4 !important;
border:none !important;
border-radius:18px !important;
/* padding:4px 10px !important; */
margin-right:6px !important;
}
.select2-selection__rendered{
display:flex !important;
align-items:center !important;
overflow:hidden !important;
white-space:nowrap !important;
}
.select2-search__field{
margin-top:0 !important;
height:28px !important;
}
.select2-container--default .select2-selection--multiple::after{
content:"\f078";
font-family:"Font Awesome 6 Free";
font-weight:900;
position:absolute;
right:14px;
top:50%;
transform:translateY(-50%);
font-size:12px;
color:#6c757d;
}
/* mouse pointer on multiselect box */
.select2-container--default .select2-selection--multiple{
cursor:pointer !important;
}
/* pointer specifically on chevron */
.select2-container--default
.select2-selection--multiple::after{
pointer-events:none; /* keep arrow decorative */
}
/* pointer on all visible text/chips too */
.select2-selection__rendered,
.select2-selection__choice{
cursor:pointer !important;
}
.sort-wrap{
position:relative;
}
.sort-wrap::after{
content:"\f078";
font-family:"Font Awesome 6 Free";
font-weight:900;
position:absolute;
right:14px;
top:50%;
transform:translateY(-50%);
font-size:12px;
color:#6c757d;
pointer-events:none;
}
.form-label{
font-size:14px;
color:#495057;
margin-left:4px;
}
</style>
</head>
<body class="app-body overview-layout">
<!-- Sidebar -->
<aside class="ov-sidebar" id="sidebar">
<div class="ov-brand">
<h1><i class="bi bi-hospital me-1"></i> DoctGuide</h1>
<span>Control Panel</span>
</div>
<nav class="ov-nav">
<div class="ov-nav__section">Main</div>
<a href="<?= base_url('admin/dashboard') ?>" class="ov-nav__link">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<div class="ov-nav__section">Manage</div>
<div class="ov-nav__dropdown active">
<a href="#" class="ov-nav__link d-flex justify-content-between align-items-center" onclick="toggleNavDropdown(event, this)">
<span><i class="bi bi-people"></i> Patients</span>
<i class="bi bi-chevron-down dropdown-icon"></i>
</a>
<div class="ov-dropdown-menu">
<a href="<?= base_url('admin/patients') ?>" class="ov-nav__sublink">Patient List</a>
</div>
</div>
<a href="<?= base_url('admin/appointments') ?>" class="ov-nav__link">
<i class="bi bi-calendar2-check"></i> Appointments
</a>
</nav>
<div class="ov-sidebar__footer">
<a href="<?= base_url('logout') ?>">
<i class="bi bi-box-arrow-left"></i> Logout
</a>
</div>
</aside>
<div class="ov-main" id="mainContent">
<header class="ov-topbar">
<div class="d-flex align-items-center">
<button class="ov-toggle-btn" onclick="toggleSidebar()">
<i class="bi bi-list" id="toggleIcon"></i>
</button>
<p class="ov-topbar__title mb-0">Doctor Search</p>
</div>
</header>
<main class="ov-content">
<div class="container-fluid">
<!-- Mobile Filter Trigger -->
<button class="btn btn-primary mobile-filter-btn"
data-bs-toggle="offcanvas"
data-bs-target="#filterCanvas">
Filters
</button>
<!-- Desktop Filters -->
<!-- Filter Card -->
<div class="card shadow-sm border-0 rounded-4 mb-4 desktop-filter">
<div class="card-header bg-white py-3 rounded-top-4">
<h5 class="mb-0">
<i class="bi bi-funnel me-2"></i>
Advance Search Filters
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-lg-4 col-md-6">
<div class="input-group search-group">
<input type="text"
id="searchInput"
class="form-control search-box"
placeholder="Search doctor or speciality">
<span class="input-group-text search-icon">
<i class="fa-solid fa-magnifying-glass"></i>
</span>
</div>
</div>
<div class="col-lg-3 col-md-6">
<?= view('components/select2_multiselect',[
'id' => 'specializationFilter',
'name' => 'specialization',
'placeholder' => 'Filter Specialization',
'options' => array_column($specializations,'name')
]); ?>
</div>
<div class="col-lg-2 col-md-6 col-6">
<input type="date"
id="dateFilter"
class="form-control">
</div>
<div class="col-lg-2 col-md-6 col-6">
<input type="time"
id="timeFilter"
class="form-control"
disabled>
</div>
<div class="col-lg-4 col-md-6 col-6">
<div class="d-flex align-items-center gap-2">
<label for="sortFilter"
class="mb-0 fw-semibold text-muted">
Sort By
</label>
<div class="sort-wrap flex-grow-1">
<select id="sortFilter" class="form-control">
<option value="name_asc" selected>Name (A-Z)</option>
<option value="name_desc">Name (Z-A)</option>
<option value="fees_low">Fees (Low to High)</option>
<option value="fees_high">Fees (High to Low)</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main 3 Panels -->
<div class="row g-4">
<div class="col-12">
<div class="card shadow-sm border-0 rounded-4">
<div class="card-header bg-white py-3 rounded-top-4">
<h5 class="mb-0">
<i class="bi bi-person-badge me-2"></i>
Available Doctors
</h5>
</div>
<div class="card-body">
<div class="row g-4" id="doctorList">
<?php foreach ($doctors as $doctor): ?>
<div class="col-lg-4 col-md-6 col-12 doctor-card-container"
data-doctor-id="<?= esc($doctor['id']) ?>"
data-doctor-name="<?= esc(strtolower($doctor['first_name'].' '.$doctor['last_name'])) ?>"
data-specialization="<?= esc(strtolower(implode(', ', array_column($doctor['specializations'] ?? [], 'name')))) ?>"
data-availability='<?= json_encode($doctor['availabilityByDate']) ?>'
data-fees="<?= !empty($doctor['fees']) ? (float)$doctor['fees'] : 0 ?>"
data-fees-missing="<?= empty($doctor['fees']) ? 1 : 0 ?>">
<div class="card h-100 shadow-sm border-0 rounded-4 doctor-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="d-flex align-items-center gap-3">
<div class="avatar">
<i class="fa-solid fa-user-doctor"></i>
</div>
<div>
<h6 class="mb-1 fw-bold">
Dr <?= esc($doctor['first_name']) ?>
<?= esc($doctor['last_name']) ?>
</h6>
<small class="text-muted">
<?php if(!empty($doctor['specializations'])): ?>
<?php $spec = $doctor['specializations'][0]; ?>
<span class="badge rounded-pill me-1"
style="
background:<?= esc($spec['bg_color']) ?>;
color:<?= esc($spec['text_color']) ?>;
">
<?= esc($spec['name']) ?>
</span>
<?php else: ?>
Not Set
<?php endif; ?>
|
<?php if(!empty($doctor['experience'])): ?>
<?= esc($doctor['experience']) ?> yrs exp.
<?php else: ?>
Not Set
<?php endif; ?>
</small>
</div>
</div>
<span></span>
</div>
<?php
if(!empty($doctor['specializations']) && count($doctor['specializations']) > 1):
?>
<div class="mb-2">
<?php for($i=1; $i<count($doctor['specializations']); $i++):
$spec = $doctor['specializations'][$i];
?>
<span class="badge rounded-pill me-1 mb-2"
style="
background:<?= esc($spec['bg_color']) ?>;
color:<?= esc($spec['text_color']) ?>;
">
<?= esc($spec['name']) ?>
</span>
<?php endfor; ?>
</div>
<?php endif; ?>
<p class="mb-2">
<strong>Fees:</strong>
<?php if(!empty($doctor['fees'])): ?>
<?= esc($doctor['fees']) ?>
<?php else: ?>
Not Set
<?php endif; ?>
</p>
<p class="mb-1 fw-semibold">
Availability
</p>
<ul class="time-slots">
<?php if(!empty($doctor['timeSlots'])): ?>
<?php foreach($doctor['timeSlots'] as $index=>$slot): ?>
<?php if($index < 4): ?>
<li class="slot-item">
<?= $slot['day'] ?> - <?= $slot['start'] ?> to <?= $slot['end'] ?>
</li>
<?php endif; ?>
<?php endforeach; ?>
<?php else: ?>
<li class="text-muted">Not Set</li>
<?php endif; ?>
</ul>
<?php if(isset($doctor['timeSlots']) && is_array($doctor['timeSlots']) && count($doctor['timeSlots']) > 4): ?>
<ul class="extra-slots">
<?php for($i=4; $i<count($doctor['timeSlots']); $i++):
$slot = $doctor['timeSlots'][$i];
?>
<li class="slot-item">
<?= $slot['day'] ?> - <?= $slot['start'] ?> to <?= $slot['end'] ?>
</li>
<?php endfor; ?>
</ul>
<button class="see-more-btn" onclick="toggleSlots(this)">
See More
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<!-- JavaScript for sidebar toggle -->
<script>
function toggleSidebar() {
const sidebar =
document.getElementById('sidebar');
const main =
document.getElementById('mainContent');
const icon =
document.getElementById('toggleIcon');
sidebar.classList.toggle('collapsed');
main.classList.toggle('expanded');
icon.className =
sidebar.classList.contains('collapsed')
? 'bi bi-layout-sidebar'
: 'bi bi-list';
}
function toggleNavDropdown(event, element){
event.preventDefault();
const parent =
element.closest('.ov-nav__dropdown');
const icon =
element.querySelector('.dropdown-icon');
parent.classList.toggle('active');
icon.classList.toggle('rotated');
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Toggle slots function
function toggleSlots(btn){
let extra = btn.previousElementSibling;
if(extra.style.display==="flex"){
extra.style.display="none";
btn.innerText="See More";
}else{
extra.style.display="flex";
btn.innerText="Show Less";
}
}
// Filter functionality
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const specializationFilter = document.getElementById('specializationFilter');
const dateFilter = document.getElementById('dateFilter');
const timeFilter = document.getElementById('timeFilter');
const doctorCards = document.querySelectorAll('.doctor-card-container');
const sortFilter = document.getElementById('sortFilter');
sortFilter.addEventListener('change', function(){
applyFilters();
});
// Enable/disable time filter based on date selection
dateFilter.addEventListener('change', function() {
if (this.value) {
timeFilter.disabled = false;
timeFilter.removeAttribute('title');
} else {
timeFilter.disabled = true;
timeFilter.value = '';
timeFilter.setAttribute('title', 'Please select a date first');
}
applyFilters();
});
// Add event listeners to all filter inputs
searchInput.addEventListener('keyup', applyFilters);
$('#specializationFilter').on('change',applyFilters);
timeFilter.addEventListener('change', applyFilters);
applyFilters();
function applyFilters() {
const searchValue = searchInput.value.toLowerCase().trim();
const specializationValues = $('#specializationFilter').val() || [];
const dateValue = dateFilter.value;
const timeValue = timeFilter.value;
let visibleCount = 0;
doctorCards.forEach(card => {
let show = true;
// Filter by search (doctor name or specialization)
if (searchValue) {
const doctorName = card.getAttribute('data-doctor-name');
const specialization = card.getAttribute('data-specialization');
if (!doctorName.includes(searchValue) && !specialization.includes(searchValue)) {
show = false;
}
}
// Filter by specialization
if(show && specializationValues.length > 0){
const specialization =
card.getAttribute('data-specialization');
const selected =
specializationValues.map(v=>v.toLowerCase());
// if(!selected.includes(specialization)){
// show = false;
// }
const doctorSpecs = specialization
.split(',')
.map(s => s.trim());
const matched = selected.some(s =>
doctorSpecs.includes(s)
);
if(!matched){
show = false;
}
}
// Filter by date and time
// Filter by date and time
if (show && dateValue) {
const availabilityJSON = card.getAttribute('data-availability');
const availability = JSON.parse(availabilityJSON);
// Check if doctor has availability on the selected date
if (!availability[dateValue]) {
show = false;
} else if (timeValue) {
// Check if any slot's start time matches the selected time
const slots = availability[dateValue].slots || [];
const match = slots.some(slot => {
// slot.start is "06:27 PM", convert timeValue "18:27" to compare
const [h, m] = timeValue.split(':').map(Number);
const slotDate = new Date();
slotDate.setHours(h, m, 0);
const slotStart = new Date();
const [sh, sm] = slot.start_24.split(':').map(Number);
slotStart.setHours(sh, sm, 0);
return slotDate >= slotStart;
});
if (!match) show = false;
}
}
// Show or hide the card
if (show) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
const doctorList = document.getElementById('doctorList');
let cardsArray =
Array.from(document.querySelectorAll('.doctor-card-container'))
.filter(card => card.style.display !== 'none');
const sortValue = sortFilter.value;
if(sortValue === 'name_asc'){
cardsArray.sort((a,b)=>
a.dataset.doctorName.localeCompare(b.dataset.doctorName)
);
}
if(sortValue === 'name_desc'){
cardsArray.sort((a,b)=>
b.dataset.doctorName.localeCompare(a.dataset.doctorName)
);
}
if(sortValue === 'fees_low'){
cardsArray.sort((a,b)=>{
let af = parseFloat(a.dataset.fees);
let bf = parseFloat(b.dataset.fees);
if(af===0) return 1;
if(bf===0) return -1;
return af-bf;
});
}
if(sortValue === 'fees_high'){
cardsArray.sort((a,b)=>{
let af = parseFloat(a.dataset.fees);
let bf = parseFloat(b.dataset.fees);
if(af===0) return 1;
if(bf===0) return -1;
return bf-af;
});
}
cardsArray.forEach(card=>{
doctorList.appendChild(card);
});
// Show "No results" message if no doctors match filters
let noResultsMsg = doctorList.querySelector('.no-results-msg');
if (visibleCount === 0) {
if (!noResultsMsg) {
noResultsMsg = document.createElement('div');
noResultsMsg.className = 'col-12 no-results-msg';
noResultsMsg.innerHTML = '<p class="text-center text-muted">No doctors match your filters.</p>';
doctorList.appendChild(noResultsMsg);
}
noResultsMsg.style.display = '';
} else if (noResultsMsg) {
noResultsMsg.style.display = 'none';
}
}
});
</script>
<script src="<?= base_url('js/components/select2-init.js') ?>"></script>
</body>
</html>

View File

@ -0,0 +1,17 @@
<select
id="<?= esc($id) ?>"
name="<?= esc($name) ?>[]"
class="form-select select2-multi"
multiple
data-placeholder="<?= esc($placeholder) ?>"
>
<?php foreach($options as $option): ?>
<option value="<?= esc($option) ?>">
<?= esc($option) ?>
</option>
<?php endforeach; ?>
</select>

View File

@ -0,0 +1,15 @@
<select
id="<?= esc($id) ?>"
name="<?= esc($name) ?>"
class="form-select select2-single"
data-placeholder="<?= esc($placeholder) ?>"
>
<option value=""></option>
<?php if(isset($options) && is_array($options)): ?>
<?php foreach($options as $value => $label): ?>
<option value="<?= esc($value) ?>">
<?= esc($label) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>

View File

@ -132,6 +132,7 @@
<?php endif; ?>
</div>
</div>
</main>
</div>
@ -145,5 +146,6 @@ function toggleSidebar() {
icon.className = sidebar.classList.contains('collapsed') ? 'bi bi-layout-sidebar' : 'bi bi-list';
}
</script>
</body>
</html>

123
public/css/activity-log.css Normal file
View File

@ -0,0 +1,123 @@
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.role-select-btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
color: #212529;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.375rem;
box-shadow: none;
text-align: left;
}
.role-select-btn:hover,
.role-select-btn:focus {
background-color: #fff;
border-color: #adb5bd;
box-shadow: none;
}
.dropdown-menu.show {
max-height: 220px;
overflow-y: auto;
}
.multi-select-arrow-wrap {
position: relative;
}
.multi-select-arrow-wrap .select2-arrow-hint {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
pointer-events: auto;
cursor: pointer;
color: #6c757d;
font-size: 1rem;
}
.multi-select-arrow-wrap .select2-arrow-hint:hover {
color: #0d6efd;
}
.stat-card.blue {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-card.orange {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
margin: 0;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Fix Select2 multi-select vertical alignment */
.select2-container--default .select2-selection--multiple {
min-height: 38px; /* match Bootstrap input height */
display: flex;
align-items: center; /* 🔥 THIS centers everything */
padding: 2px 6px;
}
/* Make tags align properly */
.select2-container--default .select2-selection__rendered {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
padding: 0 !important;
margin: 0;
}
/* Fix individual tag spacing */
.select2-container--default .select2-selection__choice {
margin-top: 2px;
}
/* Fix input cursor alignment */
.select2-container--default .select2-search--inline .select2-search__field {
margin-top: 0;
}
/* Wrapper for chevron positioning */
.multi-select-arrow-wrap {
position: relative;
}
.multi-select-arrow-wrap .select2-container--default .select2-selection--multiple {
padding: 2px 45px 2px 6px; /* Add space for chevron */
}
.multi-select-arrow-wrap .select2-arrow-hint {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
pointer-events: auto;
cursor: pointer;
color: #6c757d;
font-size: 1rem;
}
.multi-select-arrow-wrap .select2-arrow-hint:hover {
color: #0d6efd;
}

View File

@ -285,7 +285,7 @@ a.btn-app-outline {
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.select2-container .select2-selection--multiple .select2-selection__rendered {
/* .select2-container .select2-selection--multiple .select2-selection__rendered {
font-size: 1rem;
font-weight: 400;
color: #495057;
@ -294,6 +294,14 @@ a.btn-app-outline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} */
.select2-container .select2-selection--multiple .select2-selection__rendered {
font-size: 1rem;
font-weight: 400;
color: #495057;
line-height: 1.5;
padding: 0;
margin-bottom: 0;
}
.select2-container .select2-selection--multiple .select2-selection__placeholder {

View File

@ -0,0 +1,659 @@
/* Style single-select2 clear icon to look like multi-select tag remove button */
/* .select2-container--default .select2-selection--single .select2-selection__clear {
background: #eaf7f2;
border-radius: 4px;
color: #1a7f5a;
font-size: 18px;
margin-left: 8px;
padding: 0 8px;
cursor: pointer;
transition: background 0.2s;
position: relative;
top: -2px;
}
.select2-container--default .select2-selection--single .select2-selection__clear:hover {
background: #d1f2e0;
color: #0c5c3c;
} */
body{
background:#f6f8fb;
font-family:system-ui,-apple-system,Segoe UI,sans-serif;
}
.appointment-card{
width:100%;
max-width:none;
margin:12px 0;
background:#fff;
border:1px solid #dbe4ee;
border-radius:12px;
overflow:hidden;
}
.card-header-custom{
padding:16px 24px;
border-bottom:1px solid #dbe4ee;
display:flex;
justify-content:space-between;
align-items:center;
}
.card-header-custom h4{
margin:0;
font-size:22px;
font-weight:600;
color:#183153;
}
.back-btn{
border:1px solid #adb8c5;
padding:8px 18px;
border-radius:6px;
background:#fff;
text-decoration:none;
color:#4d6072;
font-size:14px;
}
.form-body{
padding:24px;
}
.info-badge{
display:inline-block;
padding:8px 12px;
border:1px solid #d2d9e3;
border-radius:8px;
background:#f9fafb;
font-weight:500;
font-size:14px;
margin-bottom:20px;
}
.form-label{
font-size:15px;
font-weight:500;
margin-bottom:8px;
color:#1f3550;
}
.req{
color:#dc3545;
}
.form-control,
.form-select{
height:44px;
border-radius:8px;
font-size:15px;
border:1px solid #ced4da;
}
textarea.form-control{
height:100px;
padding-top:12px;
}
.readonly-box{
background:#f4f6f8;
}
.action-row{
margin-top:30px;
display:flex;
justify-content:space-between;
align-items:center;
}
.cancel-btn{
padding:10px 24px;
border-radius:30px;
background:#fff;
border:1px solid #8e9aaa;
color:#556575;
font-size:15px;
}
.save-btn{
padding:11px 28px;
border:none;
border-radius:30px;
background:#0f8b83;
color:white;
font-size:15px;
font-weight:600;
box-shadow:0 4px 12px rgba(15,139,131,.18);
}
.save-btn:hover{
background:#0c746d;
}
.doctor-card{
padding:14px;
border:1px solid #dbe4ee;
border-radius:10px;
margin-bottom:10px;
transition:.2s;
}
.doctor-card.available{
border:2px solid #198754;
background:#eefaf4;
cursor:pointer;
}
.doctor-card.disabled{
background:#f4f5f6;
opacity:.6;
pointer-events:none;
}
#doctorFieldBox{
min-height:44px;
border:1px solid #ced4da;
border-radius:8px;
padding:12px;
background:#fff;
}
.doctor-placeholder{
color:#6c757d;
font-size:14px;
}
.doctor-empty{
height:20px;
display:flex;
align-items:center;
font-size:15px;
color:#495057;
margin:0;
padding:0;
}
.field-error{
border:2px solid #dc3545 !important;
box-shadow:0 0 0 .15rem rgba(220,53,69,.12);
}
.error-text{
font-size:13px;
color:#dc3545;
margin-top:6px;
}
@media(max-width:768px){
.appointment-card{
margin:15px;
}
.action-row{
flex-direction:column;
gap:14px;
align-items:stretch;
}
}
/* ===== Compact fit adjustments ===== */
.ov-content{
padding-top:10px !important;
}
.appointment-card{
width:100%;
max-width:none;
margin:8px 0;
}
.card-header-custom{
padding:10px 18px;
}
.card-header-custom h4{
font-size:18px;
}
.form-body{
padding:16px 20px;
}
/* reduce bootstrap row spacing */
.row.g-4{
--bs-gutter-y:1rem;
--bs-gutter-x:1rem;
}
.form-label{
font-size:14px;
margin-bottom:5px;
}
.info-badge{
padding:6px 10px;
font-size:13px;
margin-bottom:12px;
}
.form-control,
.form-select{
height:40px;
font-size:14px;
}
.readonly-box{
height:40px;
}
/* smaller notes box */
textarea.form-control{
height:72px;
padding-top:8px;
}
/* doctor field match input height */
#doctorFieldBox{
min-height:40px;
padding:8px 12px;
}
/* tighter doctor cards if shown */
.doctor-card{
padding:10px;
margin-bottom:8px;
}
.action-row{
margin-top:16px;
}
.cancel-btn,
.save-btn{
padding:8px 20px;
font-size:14px;
}
/* optional: keep textarea from stretching too much */
textarea{
resize:vertical;
min-height:72px;
}
/* =========================================
SELECT2 CUSTOM DESIGN FIX
========================================= */
.multi-select-arrow-wrap{
position:relative;
width:100%;
}
/* hide default select2 arrow (using custom icon) */
.select2-container--default .select2-selection__arrow{
display:none !important;
}
.select2-container{
width:100% !important;
display:block;
}
/* Main input box */
.select2-container--default
.select2-selection--multiple{
min-height:40px !important;
height:auto !important;
display:flex !important;
flex-wrap:wrap !important;
align-items:center !important;
padding:4px 40px 4px 8px !important;
border:1px solid #ced4da !important;
border-radius:8px !important;
background:#fff !important;
transition:.2s;
overflow:hidden;
}
/* Placeholder */
.select2-container--default
.select2-selection--multiple
.select2-search__field{
margin-top:0 !important;
height:28px !important;
font-size:14px !important;
}
/* Selected tags */
/* ===================================
SELECT2 BADGE FIX (full text visible)
=================================== */
.select2-container--default
.select2-selection--multiple
.select2-selection__choice{
display:inline-flex !important;
align-items:center !important;
width:auto !important;
max-width:none !important;
white-space:nowrap !important;
overflow:visible !important;
padding:0 !important;
margin:4px 8px 4px 0 !important;
background:#e9f7f5 !important;
border:1px solid #b8e4de !important;
border-radius:6px !important;
}
.select2-container--default
.select2-selection--multiple
.select2-selection__choice__remove{
position:relative !important;
display:inline-flex !important;
align-items:center;
justify-content:center;
width:24px !important;
height:24px !important;
margin:0 !important;
padding:0 !important;
border-right:1px solid #b8e4de;
background:#dff3ef;
color:#0f5e58 !important;
font-size:15px;
font-weight:600;
float:none !important;
}
/* ===== DOCTOR LIST STYLES ===== */
.doctor-list-container {
display: grid;
flex-direction: column;
gap: 10px;
margin-top: 10px;
grid-template-columns: 1fr 1fr 1fr;
}
.doctor-option {
padding: 12px 14px;
border: 1px solid #dee2e6;
border-radius: 8px;
background: #fff;
transition: all 0.2s ease;
display: flex;
align-items: center;
}
.doctor-option.available {
cursor: pointer;
border-color: #ced4da;
}
.doctor-option.available:hover {
background: #f8f9fa;
border-color: #0f8b83;
}
.doctor-option.disabled {
opacity: 0.6;
background: #f8f9fa;
pointer-events: none;
}
.doctor-option .form-check-input {
margin-right: 12px;
margin-top: 0;
cursor: pointer;
}
.doctor-option.disabled .form-check-input {
cursor: not-allowed;
}
.doctor-option .form-check-label {
margin-bottom:0;
cursor:pointer;
flex:1;
display:flex !important;
flex-direction:row !important;
align-items:center !important;
gap:10px !important;
white-space:nowrap;
}
.doctor-option .form-check-label strong {
font-size: 15px;
color: #1f3550;
}
.doctor-option .form-check-label small {
font-size: 12px;
color: #6c757d;
margin-top: 2px;
}
/* FULL SERVICE NAME */
.select2-container--default
.select2-selection--multiple
.select2-selection__choice__display{
display:inline-block !important;
padding-left:8px !important;
padding-right:10px !important;
margin-left:0 !important;
line-height:24px !important;
white-space:nowrap !important;
overflow:visible !important;
text-overflow:unset !important;
}
.select2-selection__choice__display{
overflow:visible !important;
text-overflow:unset !important;
}
/* Focus effect */
.select2-container--default.select2-container--focus
.select2-selection--multiple{
border-color:#0f8b83 !important;
box-shadow:
0 0 0 .15rem rgba(15,139,131,.12) !important;
}
/* Dropdown panel */
.select2-dropdown{
border:1px solid #ced4da !important;
border-radius:8px !important;
overflow:hidden;
box-shadow:
0 6px 20px rgba(0,0,0,.08);
}
/* Dropdown options */
.select2-results__option{
padding:10px 14px !important;
font-size:14px;
}
.select2-results__option--highlighted{
background:#0f8b83 !important;
color:#fff !important;
}
/* Custom right chevron */
.select2-arrow-hint{
position:absolute;
right:14px;
top:50%;
transform:translateY(-50%);
pointer-events:auto;
cursor:pointer;
font-size:14px;
color:#6b7280;
z-index:20;
transition:.2s;
}
.select2-arrow-hint:hover{
color:#0f8b83;
}
/* Validation error support */
.field-error + .select2-container
.select2-selection--multiple{
border:2px solid #dc3545 !important;
box-shadow:
0 0 0 .15rem rgba(220,53,69,.12);
}
.select2-search--inline{
display:none !important;
}
/* Mobile */
@media(max-width:768px){
.select2-container--default
.select2-selection--multiple{
padding-right:34px !important;
}
.select2-arrow-hint{
right:10px;
}
}
.doctor-meta-row{
display:flex;
align-items:center;
flex-wrap:nowwrap;
gap:10px;
margin:0;
line-height:1.4;
}
.doctor-name{
margin:0;
font-weight:600;
}
.doctor-exp,
.doctor-status,
.sep{
display:inline-block;
white-space:nowrap;
}
.sep{
opacity:.6;
}
.form-check{
display:flex;
align-items:center;
gap:12px;
}
.form-check-input{
margin-top:0 !important;
flex-shrink:0;
}
.doctor-radio{
display:none;
}
/* Doctor single select */
.single-select-wrap{
position:relative;
width:100%;
}
.single-arrow{
position:absolute;
right:14px;
top:50%;
transform:translateY(-50%);
z-index:20;
cursor:pointer;
color:#6b7280;
}
/* hide default select2 arrow */
.select2-container--default
.select2-selection--single
.select2-selection__arrow{
display:none !important;
}
/* doctor select box */
.select2-container--default
.select2-selection--single{
height:40px !important;
border:1px solid #ced4da !important;
border-radius:8px !important;
padding:5px 40px 5px 12px !important;
}
/* text vertically centered */
.select2-selection__rendered{
line-height:28px !important;
}

View File

@ -236,7 +236,10 @@ body.overview-layout {
}
/* ── Content ── */
.ov-content { padding: 1.5rem; flex: 1; }
.ov-content {
padding: 0.7rem;
flex: 1;
}
/* ── Stat cards ── */
.ov-stat {

View File

@ -87,3 +87,31 @@
color: inherit;
text-decoration: none;
}
.day-row {
transition: all 0.2s ease;
cursor: pointer;
}
.day-row:hover {
border: 2px solid #0d6efd !important;
background: rgba(13, 110, 253, 0.08);
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.15);
}
.day-row:hover strong {
color: #0d6efd;
}
.add-btn{
border-radius:8px;
padding:6px 14px;
font-weight:500;
display:flex;
align-items:center;
gap:4px;
min-width:85px;
justify-content:center;
}
.add-btn i{
font-size:14px;
}

View File

@ -0,0 +1,32 @@
function initMultiSelect(selector){
$(selector).select2({
placeholder: $(selector).data('placeholder'),
closeOnSelect:false,
allowClear:false,
width:'100%'
});
}
function initSingleSelect(selector){
$(selector).select2({
placeholder: $(selector).data('placeholder'),
allowClear:true,
width:'100%'
});
}
$(document).ready(function(){
$('.select2-multi').each(function(){
initMultiSelect(this);
});
$('.select2-single').each(function(){
initSingleSelect(this);
});
});

View File

@ -1,14 +1,14 @@
// function togglePassword() {
// const password = document.getElementById("password");
// const icon = document.getElementById("eyeIcon");
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");
// }
// }
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");
}
}

Binary file not shown.

51
update_patient_prefix.php Normal file
View File

@ -0,0 +1,51 @@
<?php
$hostname = "localhost";
$database = "doctor_appointment_system";
$username = "root";
$password = "";
$port = 3306;
try {
$mysqli = new mysqli($hostname, $username, $password, $database, $port);
if ($mysqli->connect_error) { die("Connection failed: " . $mysqli->connect_error); }
echo "=== Database Connection Successful ===" . PHP_EOL;
echo "Database: $database" . PHP_EOL . PHP_EOL;
$checkQuery = "SELECT COUNT(*) as count FROM users WHERE formatted_user_id LIKE 'PAT%'";
$result = $mysqli->query($checkQuery);
$row = $result->fetch_assoc();
$countBefore = $row["count"];
echo "Records with 'PAT' prefix BEFORE update: " . $countBefore . PHP_EOL;
if ($countBefore > 0) {
echo PHP_EOL . "Sample records before update:" . PHP_EOL;
$sampleQuery = "SELECT id, formatted_user_id FROM users WHERE formatted_user_id LIKE 'PAT%' LIMIT 5";
$result = $mysqli->query($sampleQuery);
while ($row = $result->fetch_assoc()) {
echo " - " . $row["formatted_user_id"] . PHP_EOL;
}
}
echo PHP_EOL . "=== Executing UPDATE Query ===" . PHP_EOL;
$updateQuery = "UPDATE users SET formatted_user_id = REPLACE(formatted_user_id, 'PAT', 'PT') WHERE formatted_user_id LIKE 'PAT%'";
if ($mysqli->query($updateQuery)) {
$affectedRows = $mysqli->affected_rows;
echo "UPDATE executed successfully!" . PHP_EOL;
echo "Rows affected: $affectedRows" . PHP_EOL;
$checkAfterQuery = "SELECT COUNT(*) as count FROM users WHERE formatted_user_id LIKE 'PAT%'";
$result = $mysqli->query($checkAfterQuery);
$row = $result->fetch_assoc();
$countAfter = $row["count"];
echo PHP_EOL . "Records with 'PAT' prefix AFTER update: " . $countAfter . PHP_EOL;
echo PHP_EOL . "Sample records after update:" . PHP_EOL;
$sampleAfterQuery = "SELECT id, formatted_user_id FROM users WHERE formatted_user_id LIKE 'PT%' LIMIT 5";
$result = $mysqli->query($sampleAfterQuery);
while ($row = $result->fetch_assoc()) {
echo " - " . $row["formatted_user_id"] . PHP_EOL;
}
echo PHP_EOL . "=== UPDATE COMPLETED SUCCESSFULLY ===" . PHP_EOL;
} else {
echo "Error: " . $mysqli->error . PHP_EOL;
}
$mysqli->close();
} catch (Exception $e) {
echo "Exception: " . $e->getMessage() . PHP_EOL;
}
?>