/home/edulekha/crm.edulekha.com/modules/appointly/controllers/Appointment_clients.php
<?php
defined('BASEPATH') or exit('No direct script access allowed');
class Appointment_clients extends ClientsController
{
public function __construct()
{
parent::__construct();
$this->load->helper('appointly');
$this->load->model('appointly/appointly_model', 'apm');
}
/**
* Default index method - redirects to appointments
*/
public function appointments()
{
// Check permissions
$this->check_appointments_permission();
$contact_id = get_contact_user_id();
$appointments = $this->apm->get_client_appointments($contact_id);
// Load models once
$this->load->model('invoices_model');
$this->load->model('staff_model');
// Get all staff IDs at once for batch loading
$staff_ids = [];
foreach ($appointments as $appointment) {
$staff_id = !empty($appointment['provider_id']) ? $appointment['provider_id'] : (!empty($appointment['staff_id']) ? $appointment['staff_id'] : null);
if ($staff_id && !in_array($staff_id, $staff_ids)) {
$staff_ids[] = $staff_id;
}
}
// Batch load staff data
$staff_data = [];
if (!empty($staff_ids)) {
foreach ($staff_ids as $staff_id) {
$staff = $this->staff_model->get($staff_id);
if ($staff) {
$staff_data[$staff_id] = $staff;
}
}
}
// Get pending reschedule requests for all appointments
$appointment_ids = array_column($appointments, 'id');
$pending_reschedules = [];
if (!empty($appointment_ids)) {
$this->db->select('appointment_id');
$this->db->from(db_prefix() . 'appointly_reschedule_requests');
$this->db->where_in('appointment_id', $appointment_ids);
$this->db->where('status', 'pending');
$reschedule_results = $this->db->get()->result_array();
foreach ($reschedule_results as $reschedule) {
$pending_reschedules[$reschedule['appointment_id']] = true;
}
}
foreach ($appointments as &$appointment) {
// Get service details
if (!empty($appointment['service_id'])) {
$service = $this->apm->get_service($appointment['service_id']);
if ($service) {
$appointment['service_name'] = $service->name;
$appointment['service_color'] = $service->color;
$appointment['service_duration'] = $service->duration;
$appointment['service_price'] = $service->price;
}
}
// Get invoice details if invoice_id exists
if (!empty($appointment['invoice_id'])) {
try {
$invoice = $this->invoices_model->get($appointment['invoice_id']);
if ($invoice && !empty($invoice->hash)) {
$appointment['invoice_hash'] = $invoice->hash;
$appointment['invoice_status'] = $invoice->status;
$appointment['invoice_total'] = $invoice->total;
} else {
// Invoice exists but missing hash - log error
log_message('error', 'Invoice #' . $appointment['invoice_id'] . ' missing hash for appointment #' . $appointment['id']);
unset($appointment['invoice_id']); // Don't show broken invoice link
}
} catch (Exception $e) {
log_message('error', 'Error loading invoice #' . $appointment['invoice_id'] . ': ' . $e->getMessage());
unset($appointment['invoice_id']);
}
}
// Get staff/provider name using cached data
$staff_id = !empty($appointment['provider_id']) ? $appointment['provider_id'] : (!empty($appointment['staff_id']) ? $appointment['staff_id'] : null);
if ($staff_id && isset($staff_data[$staff_id])) {
$staff = $staff_data[$staff_id];
$appointment['provider_name'] = $staff->firstname . ' ' . $staff->lastname;
}
// Add reschedule status
$appointment['has_pending_reschedule'] = isset($pending_reschedules[$appointment['id']]) ? 1 : 0;
// Add cancellation status
$appointment['has_pending_cancellation'] = !empty($appointment['cancel_notes']) ? 1 : 0;
}
$data = [
'appointments' => $appointments,
'title' => _l('appointment_appointments')
];
$this->data($data);
$this->view('client_appointments_dashboard');
$this->layout();
}
/**
* View single appointment details for client
*/
public function view_single()
{
// Check permissions first
$this->check_appointments_permission();
$id = $this->input->post('id');
if (!$id) {
if ($this->input->is_ajax_request()) {
echo json_encode(['success' => false, 'message' => 'Invalid appointment ID']);
return;
} else {
show_404();
}
}
// Get appointment details (using get_appointment_data to bypass staff permission checks)
$appointment = $this->apm->get_appointment_data($id);
if (!$appointment) {
if ($this->input->is_ajax_request()) {
echo json_encode(['success' => false, 'message' => 'Appointment not found']);
return;
} else {
show_404();
}
}
// Verify this appointment belongs to the logged-in client
$client_id = get_client_user_id();
if (!$this->appointment_belongs_to_client($appointment, $client_id)) {
if ($this->input->is_ajax_request()) {
echo json_encode(['success' => false, 'message' => 'Access denied']);
return;
} else {
show_404();
}
}
// For AJAX requests, return JSON with HTML content
if ($this->input->is_ajax_request()) {
// Get service details
$service = null;
if (!empty($appointment['service_id'])) {
$service = $this->apm->get_service($appointment['service_id']);
}
// Get staff details - check for provider_id first, then staff_id
$staff = null;
$staff_id = !empty($appointment['provider_id']) ? $appointment['provider_id'] : (!empty($appointment['staff_id']) ? $appointment['staff_id'] : null);
if ($staff_id) {
$this->load->model('staff_model');
$staff = $this->staff_model->get($staff_id);
}
// Get latest reschedule request
$this->db->select('*');
$this->db->from(db_prefix() . 'appointly_reschedule_requests');
$this->db->where('appointment_id', $appointment['id']);
$this->db->order_by('requested_at', 'desc');
$this->db->limit(1);
$latest_reschedule = $this->db->get()->row_array();
// Build HTML content
$html = '<div class="row">';
$html .= '<div class="col-md-12">';
// Show cancellation status message
if (!empty($appointment['cancel_notes'])) {
$html .= '<div class="alert alert-warning">';
$html .= '<i class="fa fa-exclamation-triangle"></i> ';
$html .= _l('appointment_cancellation_pending_review');
$html .= '<br><strong>' . _l('appointment_cancellation_reason') . ':</strong> ' . e($appointment['cancel_notes']);
$html .= '</div>';
}
// Show reschedule status messages
if ($latest_reschedule) {
$reschedule_status = $latest_reschedule['status'];
$processed_at = $latest_reschedule['processed_at'];
$denial_reason = $latest_reschedule['denial_reason'];
if ($reschedule_status === 'pending') {
$html .= '<div class="alert alert-info">';
$html .= '<i class="fa fa-clock"></i> ';
$html .= _l('appointment_reschedule_pending_review');
$html .= '</div>';
} elseif ($reschedule_status === 'approved') {
$html .= '<div class="alert alert-success">';
$html .= '<i class="fa fa-check"></i> ';
$html .= _l('appointment_reschedule_approved');
if ($processed_at) {
$html .= ' <small>(' . _dt($processed_at) . ')</small>';
}
$html .= '</div>';
} elseif ($reschedule_status === 'denied') {
$html .= '<div class="alert alert-danger">';
$html .= '<i class="fa fa-times"></i> ';
$html .= _l('appointment_reschedule_rejected');
if ($processed_at) {
$html .= ' <small>(' . _dt($processed_at) . ')</small>';
}
if ($denial_reason) {
$html .= '<br><strong>' . _l('appointment_reschedule_denial_reason') . ':</strong> ' . e($denial_reason);
}
if ($appointment['status'] === 'cancelled') {
$html .= '<br><strong><i class="fa fa-ban"></i> ' . _l('appointment_cancelled_title') . '</strong>';
}
$html .= '</div>';
}
}
$html .= '<table class="table table-striped">';
$html .= '<tr><td><strong>' . _l('appointment_subject') . ':</strong></td><td>' . e($appointment['subject']) . '</td></tr>';
// Combine date and time with timezone
$date_time = _d($appointment['date']) . ' ' . e($appointment['start_hour']);
$timezone = get_option('default_timezone');
if (!empty($timezone)) {
$date_time .= ' (' . $timezone . ')';
}
$html .= '<tr><td><strong>' . _l('appointment_date_and_time') . ':</strong></td><td>' . $date_time . '</td></tr>';
// Status with proper mapping like in dashboard
$status = !empty($appointment['status']) ? $appointment['status'] : 'pending';
$status_mapping = [
'pending' => 'appointment_status_pending',
'in-progress' => 'appointment_status_in-progress',
'completed' => 'appointment_status_completed',
'cancelled' => 'appointment_status_cancelled',
'no-show' => 'appointment_status_no-show',
'approved' => 'appointment_status_pending',
'confirmed' => 'appointment_status_pending'
];
$lang_key = isset($status_mapping[$status]) ? $status_mapping[$status] : 'appointment_status_pending';
$status_text = _l($lang_key);
if ($status_text === $lang_key) {
$status_text = ucfirst(str_replace(['_', '-'], ' ', $status));
}
$html .= '<tr><td><strong>' . _l('appointment_status') . ':</strong></td><td>' . $status_text . '</td></tr>';
if ($staff) {
$staff_name = is_object($staff) ? $staff->firstname . ' ' . $staff->lastname : (is_array($staff) ? $staff['firstname'] . ' ' . $staff['lastname'] : _l('appointment_unknown'));
$html .= '<tr><td><strong>' . _l('appointment_provider') . ':</strong></td><td>' . e($staff_name) . '</td></tr>';
}
// Get and display attendees
$attendees = $this->apm->get_appointment_attendees($appointment['id']);
if (!empty($attendees)) {
$attendee_names = [];
foreach ($attendees as $attendee) {
$attendee_names[] = $attendee['firstname'] . ' ' . $attendee['lastname'];
}
$html .= '<tr><td><strong>' . _l('appointment_staff_attendees') . ':</strong></td><td>' . e(implode(', ', $attendee_names)) . '</td></tr>';
}
if ($service) {
$service_name = is_object($service) ? $service->name : (is_array($service) ? $service['name'] : _l('appointment_unknown'));
$html .= '<tr><td><strong>' . _l('appointment_service') . ':</strong></td><td>' . e($service_name) . '</td></tr>';
$html .= '<tr><td><strong>' . _l('appointment_service_price') . ':</strong></td><td>' . app_format_money($service->price, get_base_currency()) . '</td></tr>';
}
if (!empty($appointment['description'])) {
$html .= '<tr><td><strong>' . _l('appointment_description') . ':</strong></td><td>' . nl2br(e($appointment['description'])) . '</td></tr>';
}
if (!empty($appointment['location'])) {
$html .= '<tr><td><strong>' . _l('appointment_location') . ':</strong></td><td>' . nl2br(e($appointment['location'])) . '</td></tr>';
}
// Add invoice information display
if (!empty($appointment['invoice_id'])) {
$this->load->model('invoices_model');
$invoice = $this->invoices_model->get($appointment['invoice_id']);
if ($invoice) {
$invoice_number = format_invoice_number($appointment['invoice_id']);
$invoice_url = site_url('invoice/' . $invoice->id . '/' . $invoice->hash);
$html .= '<tr><td><strong>' . _l('invoice') . ':</strong></td><td>';
$html .= '<a href="' . $invoice_url . '" target="_blank" class="btn btn-sm btn-info">';
$html .= '<i class="fa fa-file-invoice"></i> ' . $invoice_number;
$html .= '</a>';
// Show invoice status
if ($invoice->status == 1) {
$html .= ' <span class="label label-warning">' . _l('invoice_status_unpaid') . '</span>';
} elseif ($invoice->status == 2) {
$html .= ' <span class="label label-success">' . _l('invoice_status_paid') . '</span>';
} elseif ($invoice->status == 6) {
$html .= ' <span class="label label-default">' . _l('invoice_status_draft') . '</span>';
}
// Show total amount
if (!empty($invoice->total)) {
$html .= '<br><small>' . _l('invoice_total') . ': ' . app_format_money($invoice->total, $invoice->currency_name) . '</small>';
}
$html .= '</td></tr>';
}
}
$html .= '</table>';
// Add calendar download button
$html .= '<div class="tw-mt-4 tw-pt-4 tw-border-t tw-border-neutral-200">';
$html .= '<a href="' . site_url('appointly/appointments_public/download_ics/' . $appointment['id']) . '" class="btn btn-default btn-sm" title="' . _l('appointment_download_ics_tooltip') . '" data-toggle="tooltip">';
$html .= '<i class="fa-solid fa-calendar-plus"></i>';
$html .= '</a>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
echo json_encode(['success' => true, 'html' => $html]);
return;
}
}
/**
* Handle appointment cancellation request from client
*/
public function cancel_request()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
// Check permissions
$this->check_appointments_permission();
$appointment_id = $this->input->post('appointment_id');
$reason = $this->input->post('reason');
// Get appointment and verify ownership
$appointment = $this->apm->get_appointment_data($appointment_id);
$client_id = get_client_user_id();
if (!$appointment || !$this->appointment_belongs_to_client($appointment, $client_id)) {
echo json_encode(['success' => false, 'message' => 'Appointment not found']);
return;
}
// Check if appointment can be cancelled
if (!in_array($appointment['status'], ['pending', 'in-progress'])) {
echo json_encode(['success' => false, 'message' => 'Appointment cannot be cancelled']);
return;
}
// Check if cancellation request already exists
if (!empty($appointment['cancel_notes'])) {
echo json_encode(['success' => false, 'message' => _l('appointment_cancellation_already_requested')]);
return;
}
// Submit cancellation request
$result = $this->apm->submit_client_cancellation_request($appointment_id, $reason);
echo json_encode(['success' => $result]);
}
/**
* Handle appointment reschedule request from client
*/
public function reschedule_request()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
// Check permissions
$this->check_appointments_permission();
$appointment_id = $this->input->post('appointment_id');
$new_date = $this->input->post('new_date');
$new_time = $this->input->post('new_time');
$reason = $this->input->post('reason');
// Get appointment and verify ownership
$appointment = $this->apm->get_appointment_data($appointment_id);
$client_id = get_client_user_id();
if (!$appointment || !$this->appointment_belongs_to_client($appointment, $client_id)) {
echo json_encode(['success' => false, 'message' => 'Appointment not found']);
return;
}
// Check if appointment can be rescheduled
if (!in_array($appointment['status'], ['pending', 'in-progress'])) {
echo json_encode(['success' => false, 'message' => 'Appointment cannot be rescheduled']);
return;
}
// Check if there's already a pending reschedule request
if ($this->apm->has_pending_reschedule($appointment_id)) {
echo json_encode(['success' => false, 'message' => 'There is already a pending reschedule request for this appointment. Please wait for the previous request to be processed.']);
return;
}
// Submit reschedule request
$result = $this->apm->submit_client_reschedule_request($appointment_id, $new_date, $new_time, $reason, get_contact_user_id());
echo json_encode(['success' => $result]);
}
/**
* Check if appointment belongs to client
*/
private function appointment_belongs_to_client($appointment, $client_id)
{
// Check if appointment is linked to this client through contact_id
if (isset($appointment['contact_id']) && !empty($appointment['contact_id'])) {
$this->load->model('clients_model');
$contact = $this->clients_model->get_contact($appointment['contact_id']);
return $contact && $contact->userid == $client_id;
}
// Check if appointment email matches any of the client's contact emails
if (isset($appointment['email']) && !empty($appointment['email'])) {
$this->load->model('clients_model');
$client_contacts = $this->clients_model->get_contacts($client_id);
foreach ($client_contacts as $contact) {
if (strtolower($contact['email']) === strtolower($appointment['email'])) {
return true;
}
}
}
return false;
}
/**
* Get blocked days for date picker
*/
public function get_blocked_days()
{
// Check permissions
$this->check_appointments_permission();
header('Content-Type: application/json');
try {
$blocked_days = get_appointly_blocked_days();
echo json_encode(['success' => true, 'blocked_days' => $blocked_days]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => 'Error loading blocked days']);
}
}
/**
* Get available times for reschedule
*/
public function get_available_times()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
// Set content type to JSON and disable error reporting to prevent HTML in JSON response
header('Content-Type: application/json');
// Check permissions
$this->check_appointments_permission();
$appointment_id = $this->input->post('appointment_id');
$date = $this->input->post('date');
// Get appointment and verify ownership
$appointment = $this->apm->get_appointment_data($appointment_id);
$client_id = get_client_user_id();
if (!$appointment || !$this->appointment_belongs_to_client($appointment, $client_id)) {
echo json_encode(['success' => false, 'message' => 'Appointment not found']);
return;
}
try {
// Get service information
$service = $this->apm->get_service($appointment['service_id']);
if (!$service) {
echo json_encode(['success' => false, 'message' => 'Service not found']);
return;
}
// Get provider's working hours for this day
$day_of_week = date('l', strtotime($date));
$provider_id = $appointment['provider_id'];
// Get working hours
$working_hours = [];
$provider_available = false;
// Try to get staff working hours
$staff_hours = $this->apm->get_staff_working_hours($provider_id);
$staff_has_custom_hours = false;
if ($staff_hours && isset($staff_hours[$day_of_week])) {
$day_schedule = $staff_hours[$day_of_week];
$staff_has_custom_hours = true;
// Check if staff uses company schedule or has custom hours
if (isset($day_schedule['use_company_schedule']) && $day_schedule['use_company_schedule']) {
// Use company schedule
$company_schedule = $this->apm->get_company_schedule();
if (isset($company_schedule[$day_of_week]) && $company_schedule[$day_of_week]['is_enabled']) {
$provider_available = true;
$working_hours['start_time'] = $company_schedule[$day_of_week]['start_time'];
$working_hours['end_time'] = $company_schedule[$day_of_week]['end_time'];
}
} elseif (
isset($day_schedule['is_available'], $day_schedule['start_time']) &&
$day_schedule['is_available'] &&
isset($day_schedule['end_time'])
) {
$provider_available = true;
$working_hours['start_time'] = $day_schedule['start_time'];
$working_hours['end_time'] = $day_schedule['end_time'];
}
}
// Fall back to company schedule if staff has NO custom hours set for this day
if (!$provider_available && !$staff_has_custom_hours) {
$company_schedule = $this->apm->get_company_schedule();
if (isset($company_schedule[$day_of_week]) && $company_schedule[$day_of_week]['is_enabled']) {
$provider_available = true;
$working_hours['start_time'] = $company_schedule[$day_of_week]['start_time'];
$working_hours['end_time'] = $company_schedule[$day_of_week]['end_time'];
}
}
// Check if provider is available for this day
if (!$provider_available || !isset($working_hours['start_time'], $working_hours['end_time'])) {
echo json_encode([
'success' => true,
'times' => [],
'date' => $date,
'message' => 'Provider not available'
]);
return;
}
// Get busy times for the provider on this date (excluding current appointment)
$busy_times = $this->apm->get_busy_times_by_date($provider_id, $date);
// Remove current appointment from busy times
$busy_times = array_filter($busy_times, function ($busy) use ($appointment_id) {
return isset($busy['id']) && $busy['id'] != $appointment_id;
});
// Service duration and buffers
$duration = (int) $service->duration;
$buffer_before = (int) ($service->buffer_before ?? 0);
$buffer_after = (int) ($service->buffer_after ?? 0);
// Generate time slots
$slots = [];
$start_time = strtotime($working_hours['start_time']);
$end_time = strtotime($working_hours['end_time']);
$current_time = $start_time;
// Total time needed including buffers
$total_time_needed = $duration + $buffer_before + $buffer_after;
while ($current_time + ($total_time_needed * 60) <= $end_time) {
// Actual appointment time (excluding buffers for display)
$slot_start = date('H:i', $current_time + ($buffer_before * 60));
$slot_end = date('H:i', $current_time + ($buffer_before * 60) + ($duration * 60));
// Time including buffers for conflict checking
$slot_with_buffer_start = $current_time;
$slot_with_buffer_end = $current_time + ($total_time_needed * 60);
// Check if slot is available (not in busy times)
$is_available = true;
foreach ($busy_times as $busy) {
$busy_start = strtotime($date . ' ' . $busy['start_hour']);
$busy_end = strtotime($date . ' ' . $busy['end_hour']);
// Add buffers to busy times if they exist
if (isset($busy['buffer_before']) && $busy['buffer_before']) {
$busy_start -= ($busy['buffer_before'] * 60);
}
if (isset($busy['buffer_after']) && $busy['buffer_after']) {
$busy_end += ($busy['buffer_after'] * 60);
}
// Check for overlap using buffered times
if (
($slot_with_buffer_start >= $busy_start && $slot_with_buffer_start < $busy_end) || // Slot start during busy
($slot_with_buffer_end > $busy_start && $slot_with_buffer_end <= $busy_end) || // Slot end during busy
($slot_with_buffer_start <= $busy_start && $slot_with_buffer_end >= $busy_end) // Slot contains busy
) {
$is_available = false;
break;
}
}
// Add slot to array
$slots[] = [
'start_time' => $slot_start,
'end_time' => $slot_end,
'available' => $is_available
];
// Move to next slot (30 minute intervals)
$current_time += 30 * 60;
}
echo json_encode([
'success' => true,
'times' => $slots,
'date' => $date
]);
} catch (Exception $e) {
log_message('error', 'Error in get_available_times: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => 'Error getting available times',
'debug' => $e->getMessage()
]);
}
}
/**
* Check if contact has appointments permission
*/
private function check_appointments_permission()
{
if (!is_client_logged_in()) {
redirect(site_url('authentication/login'));
}
if (!has_contact_permission('appointments')) {
access_denied('appointments');
}
}
}