diff --git a/application/config/config.php b/application/config/config.php index 5a659a3d..96df7658 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -9,7 +9,7 @@ | */ $config['version'] = '1.4.0'; // This must be changed manually. -$config['release_label'] = 'Dev'; // Leave empty for no title or add Alpha, Beta etc ... +$config['release_label'] = 'Beta.1'; // Leave empty for no title or add Alpha, Beta etc ... $config['debug'] = Config::DEBUG_MODE; /* @@ -302,7 +302,7 @@ $config['cache_path'] = __DIR__ . '/../../storage/cache/'; | new release. | */ -$config['cache_busting_token'] = '93GX4'; +$config['cache_busting_token'] = '824HX'; /* |-------------------------------------------------------------------------- diff --git a/application/controllers/Appointments.php b/application/controllers/Appointments.php index 2276cf05..5a6c117d 100755 --- a/application/controllers/Appointments.php +++ b/application/controllers/Appointments.php @@ -11,11 +11,6 @@ * @since v1.0.0 * ---------------------------------------------------------------------------- */ -use EA\Engine\Notifications\Email as EmailClient; -use EA\Engine\Types\Email; -use EA\Engine\Types\Text; -use EA\Engine\Types\Url; - /** * Appointments Controller * @@ -35,11 +30,14 @@ use EA\Engine\Types\Url; * @property Services_Model $services_model * @property Customers_Model $customers_model * @property Settings_Model $settings_model - * @property Timezones $timezones * @property Roles_Model $roles_model * @property Secretaries_Model $secretaries_model * @property Admins_Model $admins_model * @property User_Model $user_model + * @property Timezones $timezones + * @property Synchronization $synchronization + * @property Notifications $notifications + * @property Availability $availability * * @package Controllers */ @@ -51,7 +49,6 @@ class Appointments extends CI_Controller { { parent::__construct(); - $this->load->library('session'); $this->load->helper('installation'); $this->load->helper('google_analytics'); $this->load->model('appointments_model'); @@ -61,7 +58,11 @@ class Appointments extends CI_Controller { $this->load->model('services_model'); $this->load->model('customers_model'); $this->load->model('settings_model'); + $this->load->library('session'); $this->load->library('timezones'); + $this->load->library('synchronization'); + $this->load->library('notifications'); + $this->load->library('availability'); if ($this->session->userdata('language')) { @@ -147,8 +148,8 @@ class Appointments extends CI_Controller { return; } - // If the requested apppointment begin date is lower than book_advance_timeout. Display - // a message to the customer. + // If the requested appointment begin date is lower than book_advance_timeout. Display a message to the + // customer. $startDate = strtotime($results[0]['start_datetime']); $limit = strtotime('+' . $book_advance_timeout . ' minutes', strtotime('now')); @@ -175,6 +176,7 @@ class Appointments extends CI_Controller { $customer_token = md5(uniqid(mt_rand(), TRUE)); $this->load->driver('cache', ['adapter' => 'file']); + // Save the token for 10 minutes. $this->cache->save('customer-token-' . $customer_token, $customer['id'], 600); } @@ -261,96 +263,8 @@ class Appointments extends CI_Controller { throw new Exception('Appointment could not be deleted from the database.'); } - // Remove the appointment from Google Calendar if needed. - if ($appointment['id_google_calendar'] != NULL) - { - try - { - $google_sync = filter_var( - $this->providers_model->get_setting('google_sync', $appointment['id_users_provider']), - FILTER_VALIDATE_BOOLEAN); - - if ($google_sync === TRUE) - { - $google_token = json_decode($this->providers_model - ->get_setting('google_token', $provider['id'])); - $this->load->library('Google_sync'); - $this->google_sync->refresh_token($google_token->refresh_token); - $this->google_sync->delete_appointment($provider, $appointment['id_google_calendar']); - } - } - catch (Exception $exception) - { - $exceptions[] = $exception; - } - } - - // Send email notification to customer and provider. - try - { - $email = new EmailClient($this, $this->config->config); - - $send_provider = filter_var($this->providers_model - ->get_setting('notifications', $provider['id']), - FILTER_VALIDATE_BOOLEAN); - - if ($send_provider === TRUE) - { - $email->sendDeleteAppointment($appointment, $provider, - $service, $customer, $settings, new Email($provider['email']), - new Text($this->input->post('cancel_reason'))); - } - - $send_customer = filter_var( - $this->settings_model->get_setting('customer_notifications'), - FILTER_VALIDATE_BOOLEAN); - - if ($send_customer === TRUE) - { - $email->sendDeleteAppointment($appointment, $provider, - $service, $customer, $settings, new Email($customer['email']), - new Text($this->input->post('cancel_reason'))); - } - - // Notify admins - $admins = $this->admins_model->get_batch(); - - foreach($admins as $admin) - { - if (!$admin['settings']['notifications'] === '0') - { - continue; - } - - $email->sendDeleteAppointment($appointment, $provider, - $service, $customer, $settings, new Email($admin['email']), - new Text($this->input->post('cancel_reason'))); - } - - // Notify secretaries - $secretaries = $this->secretaries_model->get_batch(); - - foreach($secretaries as $secretary) - { - if (!$secretary['settings']['notifications'] === '0') - { - continue; - } - - if (in_array($provider['id'], $secretary['providers'])) - { - continue; - } - - $email->sendDeleteAppointment($appointment, $provider, - $service, $customer, $settings, new Email($secretary['email']), - new Text($this->input->post('cancel_reason'))); - } - } - catch (Exception $exception) - { - $exceptions[] = $exception; - } + $this->synchronization->sync_appointment_deleted($appointment, $provider); + $this->notifications->notify_appointment_deleted($appointment, $service, $provider, $customer, $settings); } catch (Exception $exception) { @@ -445,12 +359,7 @@ class Appointments extends CI_Controller { // If manage mode is TRUE then the following we should not consider the selected appointment when // calculating the available time periods of the provider. - $exclude_appointments = []; - - if ($this->input->post('manage_mode') === 'true') - { - $exclude_appointments[] = $this->input->post('appointment_id'); - } + $exclude_appointment_id = $this->input->post('manage_mode') === 'true' ? $this->input->post('appointment_id') : NULL; // If the user has selected the "any-provider" option then we will need to search for an available provider // that will provide the requested service. @@ -469,21 +378,10 @@ class Appointments extends CI_Controller { } $service = $this->services_model->get_row($service_id); + $provider = $this->providers_model->get_row($provider_id); - $empty_periods = $this->get_provider_available_time_periods($provider_id, - $selected_date, $exclude_appointments); - - $available_hours = $this->calculate_available_hours($empty_periods, $selected_date, - $service['duration'], $service['availabilities_type']); - - if ($service['attendants_number'] > 1) - { - $available_hours = $this->get_multiple_attendants_hours($selected_date, $service, - $provider); - } - - $response = $this->consider_book_advance_timeout($selected_date, $available_hours, $provider); + $response = $this->availability->get_available_hours($selected_date, $service, $provider, $exclude_appointment_id); } catch (Exception $exception) { @@ -505,14 +403,14 @@ class Appointments extends CI_Controller { * * This method will return the database ID of the provider with the most available periods. * + * @param string $date The date to be searched (Y-m-d). * @param int $service_id The requested service ID. - * @param string $selected_date The date to be searched. * * @return int Returns the ID of the provider that can provide the service at the selected date. * * @throws Exception */ - protected function search_any_provider($service_id, $selected_date) + protected function search_any_provider($date, $service_id) { $available_providers = $this->providers_model->get_available_providers(); @@ -529,16 +427,7 @@ class Appointments extends CI_Controller { if ($provider_service_id == $service_id) { // Check if the provider is available for the requested date. - $empty_periods = $this->get_provider_available_time_periods($provider['id'], $selected_date); - - $available_hours = $this->calculate_available_hours($empty_periods, $selected_date, - $service['duration'], $service['availabilities_type']); - - if ($service['attendants_number'] > 1) - { - $available_hours = $this->get_multiple_attendants_hours($selected_date, $service, - $provider); - } + $available_hours = $this->availability->get_available_hours($date, $service, $provider); if (count($available_hours) > $max_hours_count) { @@ -552,454 +441,6 @@ class Appointments extends CI_Controller { return $provider_id; } - /** - * Get an array containing the free time periods (start - end) of a selected date. - * - * This method is very important because there are many cases where the system needs to know when a provider is - * available for an appointment. This method will return an array that belongs to the selected date and contains - * values that have the start and the end time of an available time period. - * - * @param int $provider_id Provider record ID. - * @param string $selected_date Date to be checked (MySQL formatted string). - * @param array $excluded_appointment_ids Array containing the IDs of the appointments that will not be taken into - * consideration when the available time periods are calculated. - * - * @return array Returns an array with the available time periods of the provider. - * - * @throws Exception - */ - protected function get_provider_available_time_periods( - $provider_id, - $selected_date, - $excluded_appointment_ids = [] - ) - { - // Get the service, provider's working plan and provider appointments. - $working_plan = json_decode($this->providers_model->get_setting('working_plan', $provider_id), TRUE); - - // Get the provider's working plan exceptions. - $working_plan_exceptions = json_decode($this->providers_model->get_setting('working_plan_exceptions', $provider_id), TRUE); - - $provider_appointments = $this->appointments_model->get_batch([ - 'id_users_provider' => $provider_id, - ]); - - // Sometimes it might be necessary to not take into account some appointment records in order to display what - // the providers' available time periods would be without them. - foreach ($excluded_appointment_ids as $excluded_appointment_id) - { - foreach ($provider_appointments as $index => $reserved) - { - if ($reserved['id'] == $excluded_appointment_id) - { - unset($provider_appointments[$index]); - } - } - } - - // Find the empty spaces on the plan. The first split between the plan is due to a break (if any). After that - // every reserved appointment is considered to be a taken space in the plan. - $selected_date_working_plan = $working_plan[strtolower(date('l', strtotime($selected_date)))]; - - // Search if the $selected_date is an custom availability period added outside the normal working plan. - if (isset($working_plan_exceptions[$selected_date])) - { - $selected_date_working_plan = $working_plan_exceptions[$selected_date]; - } - - $periods = []; - - if (isset($selected_date_working_plan['breaks'])) - { - $periods[] = [ - 'start' => $selected_date_working_plan['start'], - 'end' => $selected_date_working_plan['end'] - ]; - - $day_start = new DateTime($selected_date_working_plan['start']); - $day_end = new DateTime($selected_date_working_plan['end']); - - // Split the working plan to available time periods that do not contain the breaks in them. - foreach ($selected_date_working_plan['breaks'] as $index => $break) - { - $break_start = new DateTime($break['start']); - $break_end = new DateTime($break['end']); - - if ($break_start < $day_start) - { - $break_start = $day_start; - } - - if ($break_end > $day_end) - { - $break_end = $day_end; - } - - if ($break_start >= $break_end) - { - continue; - } - - foreach ($periods as $key => $period) - { - $period_start = new DateTime($period['start']); - $period_end = new DateTime($period['end']); - - $remove_current_period = FALSE; - - if ($break_start > $period_start && $break_start < $period_end && $break_end > $period_start) - { - $periods[] = [ - 'start' => $period_start->format('H:i'), - 'end' => $break_start->format('H:i') - ]; - - $remove_current_period = TRUE; - } - - if ($break_start < $period_end && $break_end > $period_start && $break_end < $period_end) - { - $periods[] = [ - 'start' => $break_end->format('H:i'), - 'end' => $period_end->format('H:i') - ]; - - $remove_current_period = TRUE; - } - - if ($break_start == $period_start && $break_end == $period_end) - { - $remove_current_period = TRUE; - } - - if ($remove_current_period) - { - unset($periods[$key]); - } - } - } - } - - // Break the empty periods with the reserved appointments. - foreach ($provider_appointments as $provider_appointment) - { - foreach ($periods as $index => &$period) - { - $appointment_start = new DateTime($provider_appointment['start_datetime']); - $appointment_end = new DateTime($provider_appointment['end_datetime']); - - if ($appointment_start >= $appointment_end) - { - continue; - } - - $period_start = new DateTime($selected_date . ' ' . $period['start']); - $period_end = new DateTime($selected_date . ' ' . $period['end']); - - if ($appointment_start <= $period_start && $appointment_end <= $period_end && $appointment_end <= $period_start) - { - // The appointment does not belong in this time period, so we will not change anything. - continue; - } - else - { - if ($appointment_start <= $period_start && $appointment_end <= $period_end && $appointment_end >= $period_start) - { - // The appointment starts before the period and finishes somewhere inside. We will need to break - // this period and leave the available part. - $period['start'] = $appointment_end->format('H:i'); - } - else - { - if ($appointment_start >= $period_start && $appointment_end < $period_end) - { - // The appointment is inside the time period, so we will split the period into two new - // others. - unset($periods[$index]); - - $periods[] = [ - 'start' => $period_start->format('H:i'), - 'end' => $appointment_start->format('H:i') - ]; - - $periods[] = [ - 'start' => $appointment_end->format('H:i'), - 'end' => $period_end->format('H:i') - ]; - } - else if ($appointment_start == $period_start && $appointment_end == $period_end) - { - unset($periods[$index]); // The whole period is blocked so remove it from the available periods array. - } - else - { - if ($appointment_start >= $period_start && $appointment_end >= $period_start && $appointment_start <= $period_end) - { - // The appointment starts in the period and finishes out of it. We will need to remove - // the time that is taken from the appointment. - $period['end'] = $appointment_start->format('H:i'); - } - else - { - if ($appointment_start >= $period_start && $appointment_end >= $period_end && $appointment_start >= $period_end) - { - // The appointment does not belong in the period so do not change anything. - continue; - } - else - { - if ($appointment_start <= $period_start && $appointment_end >= $period_end && $appointment_start <= $period_end) - { - // The appointment is bigger than the period, so this period needs to be removed. - unset($periods[$index]); - } - } - } - } - } - } - } - } - - return array_values($periods); - } - - /** - * Calculate the available appointment hours. - * - * Calculate the available appointment hours for the given date. The empty spaces - * are broken down to 15 min and if the service fit in each quarter then a new - * available hour is added to the "$available_hours" array. - * - * @param array $empty_periods Contains the empty periods as generated by the "get_provider_available_time_periods" - * method. - * @param string $selected_date The selected date to be search (format ) - * @param int $service_duration The service duration is required for the hour calculation. - * @param string $availabilities_type Optional ('flexible'), the service availabilities type. - * - * @return array Returns an array with the available hours for the appointment. - * @throws Exception - */ - protected function calculate_available_hours( - array $empty_periods, - $selected_date, - $service_duration, - $availabilities_type = 'flexible' - ) - { - $available_hours = []; - - foreach ($empty_periods as $period) - { - $start_hour = new DateTime($selected_date . ' ' . $period['start']); - $end_hour = new DateTime($selected_date . ' ' . $period['end']); - $interval = $availabilities_type === AVAILABILITIES_TYPE_FIXED ? (int)$service_duration : 15; - - $current_hour = $start_hour; - $diff = $current_hour->diff($end_hour); - - while (($diff->h * 60 + $diff->i) >= intval($service_duration)) - { - $available_hours[] = $current_hour->format('H:i'); - $current_hour->add(new DateInterval('PT' . $interval . 'M')); - $diff = $current_hour->diff($end_hour); - } - } - - return $available_hours; - } - - /** - * Get multiple attendants hours. - * - * This method will add the additional appointment hours whenever a service accepts multiple attendants. - * - * @param string $selected_date The selected appointment date. - * @param array $service Selected service data. - * @param array $provider Selected provider data. - * - * @return array Returns the available hours array. - * @throws Exception - */ - protected function get_multiple_attendants_hours( - $selected_date, - $service, - $provider - ) - { - $unavailability_events = $this->appointments_model->get_batch([ - 'is_unavailable' => TRUE, - 'DATE(start_datetime)' => $selected_date, - 'id_users_provider' => $provider['id'] - ]); - - $working_plan = json_decode($provider['settings']['working_plan'], TRUE); - $working_day = strtolower(date('l', strtotime($selected_date))); - $working_hours = $working_plan[$working_day]; - - $periods = [ - [ - 'start' => new DateTime($selected_date . ' ' . $working_hours['start']), - 'end' => new DateTime($selected_date . ' ' . $working_hours['end']) - ] - ]; - - $periods = $this->remove_breaks($selected_date, $periods, $working_hours['breaks']); - $periods = $this->remove_unavailability_events($periods, $unavailability_events); - - $hours = []; - - $interval_value = $service['availabilities_type'] == AVAILABILITIES_TYPE_FIXED ? $service['duration'] : '15'; - $interval = new DateInterval('PT' . (int)$interval_value . 'M'); - $duration = new DateInterval('PT' . (int)$service['duration'] . 'M'); - - foreach ($periods as $period) - { - $slot_start = clone $period['start']; - $slot_end = clone $slot_start; - $slot_end->add($duration); - - while ($slot_end <= $period['end']) - { - // Check reserved attendants for this time slot and see if current attendants fit. - $appointment_attendants_number = $this->appointments_model->get_attendants_number_for_period($slot_start, - $slot_end, $service['id']); - - if ($appointment_attendants_number < $service['attendants_number']) - { - $hours[] = $slot_start->format('H:i'); - } - - $slot_start->add($interval); - $slot_end->add($interval); - } - } - - return $hours; - } - - /** - * Remove breaks from available time periods. - * - * @param string $selected_date Selected data (Y-m-d format). - * @param array $periods Time periods of the current date. - * @param array $breaks Breaks array for the current date. - * - * @return array Returns the available time periods without the breaks. - * @throws Exception - */ - public function remove_breaks($selected_date, $periods, $breaks) - { - if ( ! $breaks) - { - return $periods; - } - - foreach ($breaks as $break) - { - $break_start = new DateTime($selected_date . ' ' . $break['start']); - $break_end = new DateTime($selected_date . ' ' . $break['end']); - - foreach ($periods as &$period) - { - $period_start = $period['start']; - $period_end = $period['end']; - - if ($break_start <= $period_start && $break_end >= $period_start && $break_end <= $period_end) - { - // left - $period['start'] = $break_end; - continue; - } - - if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_start && $break_end <= $period_end) - { - // middle - $period['end'] = $break_start; - $periods[] = [ - 'start' => $break_end, - 'end' => $period_end - ]; - continue; - } - - if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_end) - { - // right - $period['end'] = $break_start; - continue; - } - - if ($break_start <= $period_start && $break_end >= $period_end) - { - // break contains period - $period['start'] = $break_end; - continue; - } - } - } - - return $periods; - } - - /** - * Remove the unavailability entries from the available time periods of the selected date. - * - * @param array $periods Available time periods. - * @param array $unavailability_events Unavailability events of the current date. - * - * @return array Returns the available time periods without the unavailability events. - * - * @throws Exception - */ - public function remove_unavailability_events($periods, $unavailability_events) - { - foreach ($unavailability_events as $unavailability_event) - { - $unavailability_start = new DateTime($unavailability_event['start_datetime']); - $unavailability_end = new DateTime($unavailability_event['end_datetime']); - - foreach ($periods as &$period) - { - $period_start = $period['start']; - $period_end = $period['end']; - - if ($unavailability_start <= $period_start && $unavailability_end >= $period_start && $unavailability_end <= $period_end) - { - // left - $period['start'] = $unavailability_end; - continue; - } - - if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_start && $unavailability_end <= $period_end) - { - // middle - $period['end'] = $unavailability_start; - $periods[] = [ - 'start' => $unavailability_end, - 'end' => $period_end - ]; - continue; - } - - if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_end) - { - // right - $period['end'] = $unavailability_start; - continue; - } - - if ($unavailability_start <= $period_start && $unavailability_end >= $period_end) - { - // Unavailability contains period - $period['start'] = $unavailability_end; - continue; - } - } - } - - return $periods; - } /** * [AJAX] Register the appointment to the database. @@ -1017,6 +458,8 @@ class Appointments extends CI_Controller { $this->load->model('services_model'); $this->load->model('customers_model'); $this->load->model('settings_model'); + $this->load->library('notifications'); + $this->load->library('synchronization'); $post_data = $this->input->post('post_data'); $captcha = $this->input->post('captcha'); @@ -1077,139 +520,8 @@ class Appointments extends CI_Controller { 'time_format' => $this->settings_model->get_setting('time_format') ]; - // Synchronize the appointment with the provider's Google Calendar. - try - { - $google_sync = filter_var( - $this->providers_model->get_setting('google_sync', $appointment['id_users_provider']), - FILTER_VALIDATE_BOOLEAN); - - if ($google_sync === TRUE) - { - $google_token = json_decode( - $this->providers_model->get_setting('google_token', $appointment['id_users_provider'])); - - $this->load->library('google_sync'); - - $this->google_sync->refresh_token($google_token->refresh_token); - - if ($manage_mode === FALSE) - { - // Add appointment to Google Calendar. - $google_event = $this->google_sync->add_appointment($appointment, $provider, - $service, $customer, $settings); - $appointment['id_google_calendar'] = $google_event->id; - $this->appointments_model->add($appointment); - } - else - { - // Update appointment to Google Calendar. - $appointment['id_google_calendar'] = $this->appointments_model - ->get_value('id_google_calendar', $appointment['id']); - - $this->google_sync->update_appointment($appointment, $provider, - $service, $customer, $settings); - } - } - } - catch (Exception $exception) - { - log_message('error', $exception->getMessage()); - log_message('error', $exception->getTraceAsString()); - } - - // Send email notifications to customer and provider. - try - { - $this->config->load('email'); - - $email = new EmailClient($this, $this->config->config); - - if ($manage_mode === FALSE) - { - $customer_title = new Text(lang('appointment_booked')); - $customer_message = new Text(lang('thank_you_for_appointment')); - $provider_title = new Text(lang('appointment_added_to_your_plan')); - $provider_message = new Text(lang('appointment_link_description')); - - } - else - { - $customer_title = new Text(lang('appointment_changes_saved')); - $customer_message = new Text(''); - $provider_title = new Text(lang('appointment_details_changed')); - $provider_message = new Text(''); - } - - $customer_link = new Url(site_url('appointments/index/' . $appointment['hash'])); - $provider_link = new Url(site_url('backend/index/' . $appointment['hash'])); - - $send_customer = filter_var( - $this->settings_model->get_setting('customer_notifications'), - FILTER_VALIDATE_BOOLEAN); - - $this->load->library('ics_file'); - - $ics_stream = $this->ics_file->get_stream($appointment, $service, $provider, $customer); - - if ($send_customer === TRUE) - { - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $customer_title, - $customer_message, $customer_link, new Email($customer['email']), new Text($ics_stream)); - } - - $send_provider = filter_var( - $this->providers_model->get_setting('notifications', $provider['id']), - FILTER_VALIDATE_BOOLEAN); - - if ($send_provider === TRUE) - { - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $provider_title, - $provider_message, $provider_link, new Email($provider['email']), new Text($ics_stream)); - } - - // Notify admins - $admins = $this->admins_model->get_batch(); - - foreach($admins as $admin) - { - if (!$admin['settings']['notifications'] === '0') - { - continue; - } - - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $provider_title, - $provider_message, $provider_link, new Email($admin['email']), new Text($ics_stream)); - } - - // Notify secretaries - $secretaries = $this->secretaries_model->get_batch(); - - foreach($secretaries as $secretary) - { - if (!$secretary['settings']['notifications'] === '0') - { - continue; - } - - if (in_array($provider['id'], $secretary['providers'])) - { - continue; - } - - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $provider_title, - $provider_message, $provider_link, new Email($secretary['email']), new Text($ics_stream)); - } - } - catch (Exception $exception) - { - log_message('error', $exception->getMessage()); - log_message('error', $exception->getTraceAsString()); - } + $this->synchronization->sync_appointment_saved($appointment, $service, $provider, $customer, $settings); + $this->notifications->notify_appointment_saved($appointment, $service, $provider, $customer, $settings, $manage_mode); $response = [ 'appointment_id' => $appointment['id'], @@ -1246,69 +558,43 @@ class Appointments extends CI_Controller { */ protected function check_datetime_availability() { - $this->load->model('services_model'); - $this->load->model('appointments_model'); - $post_data = $this->input->post('post_data'); $appointment = $post_data['appointment']; - $service_duration = $this->services_model->get_value('duration', $appointment['id_services']); - - $exclude_appointments = []; - - if (isset($appointment['id'])) - { - $exclude_appointments[] = $appointment['id']; - } - - $attendants_number = $this->services_model->get_value('attendants_number', $appointment['id_services']); - - if ($attendants_number > 1) - { - // Exclude all the appointments that are currently registered. - $existing_appointments = $this->appointments_model->get_batch([ - 'id_services' => $appointment['id_services'], - 'start_datetime' => $appointment['start_datetime'] - ]); - - if ( ! empty($existing_appointments) && count($existing_appointments) < $attendants_number) - { - foreach ($existing_appointments as $existing_appointment) - { - $exclude_appointments[] = $existing_appointment['id']; - } - } - } + $date = date('Y-m-d', strtotime($appointment['start_datetime'])); if ($appointment['id_users_provider'] === ANY_PROVIDER) { - $appointment['id_users_provider'] = $this->search_any_provider($appointment['id_services'], - date('Y-m-d', strtotime($appointment['start_datetime']))); + + $appointment['id_users_provider'] = $this->search_any_provider($date, $appointment['id_services']); return $appointment['id_users_provider']; } - $available_periods = $this->get_provider_available_time_periods( - $appointment['id_users_provider'], - date('Y-m-d', strtotime($appointment['start_datetime'])), - $exclude_appointments); + $service = $this->services_model->get_row($appointment['id_services']); + + $exclude_appointment_id = isset($appointment['id']) ? $appointment['id'] : NULL; + + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + + $available_periods = $this->availability->get_available_periods($date, $provider, $exclude_appointment_id); $is_still_available = FALSE; - foreach ($available_periods as $period) + foreach ($available_periods as $available_period) { - $appt_start = new DateTime($appointment['start_datetime']); - $appt_start = $appt_start->format('H:i'); + $appointment_start = new DateTime($appointment['start_datetime']); + $appointment_start = $appointment_start->format('H:i'); - $appt_end = new DateTime($appointment['start_datetime']); - $appt_end->add(new DateInterval('PT' . $service_duration . 'M')); - $appt_end = $appt_end->format('H:i'); + $appointment_end = new DateTime($appointment['start_datetime']); + $appointment_end->add(new DateInterval('PT' . $service['duration'] . 'M')); + $appointment_end = $appointment_end->format('H:i'); - $period_start = date('H:i', strtotime($period['start'])); - $period_end = date('H:i', strtotime($period['end'])); + $available_period_start = date('H:i', strtotime($available_period['start'])); + $available_period_end = date('H:i', strtotime($available_period['end'])); - if ($period_start <= $appt_start && $period_end >= $appt_end) + if ($available_period_start <= $appointment_start && $available_period_end >= $appointment_end) { $is_still_available = TRUE; break; @@ -1343,17 +629,12 @@ class Appointments extends CI_Controller { $number_of_days_in_month = (int)$selected_date->format('t'); $unavailable_dates = []; - $exclude_appointments = []; - - if ($manage_mode === 'true') - { - $exclude_appointments[] = $appointment_id; - } - - $provider_list = $provider_id === ANY_PROVIDER + $provider_ids = $provider_id === ANY_PROVIDER ? $this->search_providers_by_service($service_id) : [$provider_id]; + $exclude_appointment_id = $manage_mode ? $appointment_id : NULL; + // Get the service record. $service = $this->services_model->get_row($service_id); @@ -1369,35 +650,21 @@ class Appointments extends CI_Controller { } // Finding at least one slot of availability. - foreach ($provider_list as $current_provider_id) + foreach ($provider_ids as $current_provider_id) { - // Get the provider record. - $current_provider = $this->providers_model->get_row($current_provider_id); + $provider = $this->providers_model->get_row($current_provider_id); - $empty_periods = $this->get_provider_available_time_periods($current_provider_id, - $current_date->format('Y-m-d'), $exclude_appointments); - - $available_hours = $this->calculate_available_hours($empty_periods, $current_date->format('Y-m-d'), - $service['duration'], $service['availabilities_type']); + $available_hours = $this->availability->get_available_hours( + $current_date->format('Y-m-d'), + $service, + $provider, + $exclude_appointment_id + ); if ( ! empty($available_hours)) { break; } - - if ($service['attendants_number'] > 1) - { - $available_hours = $this->get_multiple_attendants_hours($current_date->format('Y-m-d'), - $service, $current_provider); - - if ( ! empty($available_hours)) - { - break; - } - } - - $available_hours = $this->consider_book_advance_timeout($current_date->format('Y-m-d'), - $available_hours, $current_provider); } // No availability amongst all the provider. @@ -1454,41 +721,5 @@ class Appointments extends CI_Controller { return $provider_list; } - /** - * Consider the book advance timeout and remove available hours that have passed the threshold. - * - * If the selected date is today, remove past hours. It is important include the timeout before booking - * that is set in the back-office the system. Normally we might want the customer to book an appointment - * that is at least half or one hour from now. The setting is stored in minutes. - * - * @param string $selected_date The selected date. - * @param array $available_hours Already generated available hours. - * @param array $provider Provider information. - * - * @return array Returns the updated available hours. - * - * @throws Exception - */ - protected function consider_book_advance_timeout($selected_date, $available_hours, $provider) - { - $provider_timezone = new DateTimeZone($provider['timezone']); - $book_advance_timeout = $this->settings_model->get_setting('book_advance_timeout'); - - $threshold = new DateTime('+' . $book_advance_timeout . ' minutes', $provider_timezone); - - foreach ($available_hours as $index => $value) - { - $available_hour = new DateTime($selected_date . ' ' . $value, $provider_timezone); - - if ($available_hour->getTimestamp() <= $threshold->getTimestamp()) - { - unset($available_hours[$index]); - } - } - - $available_hours = array_values($available_hours); - sort($available_hours, SORT_STRING); - return array_values($available_hours); - } } diff --git a/application/controllers/Backend_api.php b/application/controllers/Backend_api.php index eb34d50f..5a6e7896 100755 --- a/application/controllers/Backend_api.php +++ b/application/controllers/Backend_api.php @@ -38,6 +38,8 @@ use EA\Engine\Types\Url; * @property Customers_Model $customers_model * @property Settings_Model $settings_model * @property Timezones $timezones + * @property Synchronization $synchronization + * @property Notifications $notifications * @property Roles_Model $roles_model * @property Secretaries_Model $secretaries_model * @property Admins_Model $admins_model @@ -102,7 +104,7 @@ class Backend_api extends CI_Controller { 'start_datetime >=' => $startDate, 'end_datetime <=' => $endDate ]), - 'unavailabilities' => $this->appointments_model->get_batch([ + 'unavailability_events' => $this->appointments_model->get_batch([ 'is_unavailable' => TRUE, 'start_datetime >=' => $startDate, 'end_datetime <=' => $endDate @@ -130,11 +132,11 @@ class Backend_api extends CI_Controller { } } - foreach ($response['unavailabilities'] as $index => $unavailability) + foreach ($response['unavailability_events'] as $index => $unavailability_event) { - if ((int)$unavailability['id_users_provider'] !== (int)$userId) + if ((int)$unavailability_event['id_users_provider'] !== (int)$userId) { - unset($response['unavailabilities'][$index]); + unset($response['unavailability_events'][$index]); } } } @@ -152,11 +154,11 @@ class Backend_api extends CI_Controller { } } - foreach ($response['unavailabilities'] as $index => $unavailability) + foreach ($response['unavailability_events'] as $index => $unavailability_event) { - if ( ! in_array((int)$unavailability['id_users_provider'], $providers)) + if ( ! in_array((int)$unavailability_event['id_users_provider'], $providers)) { - unset($response['unavailabilities'][$index]); + unset($response['unavailability_events'][$index]); } } } @@ -283,6 +285,8 @@ class Backend_api extends CI_Controller { $this->load->model('customers_model'); $this->load->model('settings_model'); $this->load->library('timezones'); + $this->load->library('synchronization'); + $this->load->library('notifications'); $this->load->model('user_model'); // Save customer changes to the database. @@ -290,10 +294,10 @@ class Backend_api extends CI_Controller { { $customer = json_decode($this->input->post('customer_data'), TRUE); - $required_privilegesileges = ( ! isset($customer['id'])) + $required_privileges = ( ! isset($customer['id'])) ? $this->privileges[PRIV_CUSTOMERS]['add'] : $this->privileges[PRIV_CUSTOMERS]['edit']; - if ($required_privilegesileges == FALSE) + if ($required_privileges == FALSE) { throw new Exception('You do not have the required privileges for this task.'); } @@ -306,17 +310,17 @@ class Backend_api extends CI_Controller { { $appointment = json_decode($this->input->post('appointment_data'), TRUE); - $required_privilegesileges = ( ! isset($appointment['id'])) + $required_privileges = ( ! isset($appointment['id'])) ? $this->privileges[PRIV_APPOINTMENTS]['add'] : $this->privileges[PRIV_APPOINTMENTS]['edit']; - if ($required_privilegesileges == FALSE) + if ($required_privileges == FALSE) { throw new Exception('You do not have the required privileges for this task.'); } $manage_mode = isset($appointment['id']); - // If the appointment does not contain the customer record id, then it - // means that is is going to be inserted. Get the customer's record id. + // If the appointment does not contain the customer record id, then it means that is is going to be + // inserted. Get the customer's record ID. if ( ! isset($appointment['id_users_customer'])) { $appointment['id_users_customer'] = $customer['id']; @@ -348,139 +352,10 @@ class Backend_api extends CI_Controller { 'time_format' => $this->settings_model->get_setting('time_format') ]; - // Sync appointment changes with Google Calendar. - try - { - $google_sync = $this->providers_model->get_setting('google_sync', - $appointment['id_users_provider']); + $this->synchronization->sync_appointment_deleted($appointment, $provider); + $this->notifications->notify_appointment_deleted($appointment, $service, $provider, $customer, $settings); - if ($google_sync == TRUE) - { - $google_token = json_decode($this->providers_model->get_setting('google_token', - $appointment['id_users_provider'])); - - $this->load->library('Google_sync'); - $this->google_sync->refresh_token($google_token->refresh_token); - - if ($appointment['id_google_calendar'] == NULL) - { - $google_event = $this->google_sync->add_appointment($appointment, $provider, - $service, $customer, $settings); - $appointment['id_google_calendar'] = $google_event->id; - $this->appointments_model->add($appointment); // Store google calendar id. - } - else - { - $this->google_sync->update_appointment($appointment, $provider, - $service, $customer, $settings); - } - } - } - catch (Exception $exception) - { - $warnings[] = [ - 'message' => $exception->getMessage(), - 'trace' => config('debug') ? $exception->getTrace() : [] - ]; - } - - // Send email notifications to provider and customer. - try - { - $this->config->load('email'); - $email = new EmailClient($this, $this->config->config); - - $send_provider = $this->providers_model - ->get_setting('notifications', $provider['id']); - - if ( ! $manage_mode) - { - $customer_title = new Text(lang('appointment_booked')); - $customer_message = new Text(lang('thank_you_for_appointment')); - $provider_title = new Text(lang('appointment_added_to_your_plan')); - $provider_message = new Text(lang('appointment_link_description')); - } - else - { - $customer_title = new Text(lang('appointment_details_changed')); - $customer_message = new Text(''); - $provider_title = new Text(lang('appointment_changes_saved')); - $provider_message = new Text(''); - } - - $customer_link = new Url(site_url('appointments/index/' . $appointment['hash'])); - $provider_link = new Url(site_url('backend/index/' . $appointment['hash'])); - - $send_customer = $this->settings_model->get_setting('customer_notifications'); - - $this->load->library('ics_file'); - $ics_stream = $this->ics_file->get_stream($appointment, $service, $provider, $customer); - - if ((bool)$send_customer === TRUE) - { - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $customer_title, - $customer_message, $customer_link, new Email($customer['email']), new Text($ics_stream)); - } - - if ($send_provider == TRUE) - { - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $provider_title, - $provider_message, $provider_link, new Email($provider['email']), new Text($ics_stream)); - } - - // Notify admins - $admins = $this->admins_model->get_batch(); - - foreach($admins as $admin) - { - if (!$admin['settings']['notifications'] === '0') - { - continue; - } - - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $provider_title, - $provider_message, $provider_link, new Email($admin['email']), new Text($ics_stream)); - } - - // Notify secretaries - $secretaries = $this->secretaries_model->get_batch(); - - foreach($secretaries as $secretary) - { - if (!$secretary['settings']['notifications'] === '0') - { - continue; - } - - if (in_array($provider['id'], $secretary['providers'])) - { - continue; - } - - $email->sendAppointmentDetails($appointment, $provider, - $service, $customer, $settings, $provider_title, - $provider_message, $provider_link, new Email($secretary['email']), new Text($ics_stream)); - } - } - catch (Exception $exception) - { - $warnings[] = [ - 'message' => $exception->getMessage(), - 'trace' => config('debug') ? $exception->getTrace() : [] - ]; - } - - if (empty($warnings)) - { - $response = AJAX_SUCCESS; - } - else - { - $response = ['warnings' => $warnings]; - } + $response = AJAX_SUCCESS; } catch (Exception $exception) { @@ -1032,10 +907,10 @@ class Backend_api extends CI_Controller { $this->load->model('customers_model'); $customer = json_decode($this->input->post('customer'), TRUE); - $required_privilegesileges = ( ! isset($customer['id'])) + $required_privileges = ( ! isset($customer['id'])) ? $this->privileges[PRIV_CUSTOMERS]['add'] : $this->privileges[PRIV_CUSTOMERS]['edit']; - if ($required_privilegesileges == FALSE) + if ($required_privileges == FALSE) { throw new Exception('You do not have the required privileges for this task.'); } @@ -1105,10 +980,10 @@ class Backend_api extends CI_Controller { $this->load->model('services_model'); $service = json_decode($this->input->post('service'), TRUE); - $required_privilegesileges = ( ! isset($service['id'])) + $required_privileges = ( ! isset($service['id'])) ? $this->privileges[PRIV_SERVICES]['add'] : $this->privileges[PRIV_SERVICES]['edit']; - if ($required_privilegesileges == FALSE) + if ($required_privileges == FALSE) { throw new Exception('You do not have the required privileges for this task.'); } @@ -1215,10 +1090,10 @@ class Backend_api extends CI_Controller { $this->load->model('services_model'); $category = json_decode($this->input->post('category'), TRUE); - $required_privilegesileges = ( ! isset($category['id'])) + $required_privileges = ( ! isset($category['id'])) ? $this->privileges[PRIV_SERVICES]['add'] : $this->privileges[PRIV_SERVICES]['edit']; - if ($required_privilegesileges == FALSE) + if ($required_privileges == FALSE) { throw new Exception('You do not have the required privileges for this task.'); } diff --git a/application/controllers/api/v1/API_V1_Controller.php b/application/controllers/api/v1/API_V1_Controller.php index b27e529b..4107d45f 100644 --- a/application/controllers/api/v1/API_V1_Controller.php +++ b/application/controllers/api/v1/API_V1_Controller.php @@ -62,7 +62,7 @@ class API_V1_Controller extends CI_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -142,7 +142,7 @@ class API_V1_Controller extends CI_Controller { * * @param Exception $exception Thrown exception to be outputted. */ - protected function _handleException(Exception $exception) + protected function handle_exception(Exception $exception) { $error = [ 'code' => $exception->getCode() ?: 500, @@ -166,7 +166,7 @@ class API_V1_Controller extends CI_Controller { * * @throws \EA\Engine\Api\V1\Exception */ - protected function _throwRecordNotFound() + protected function throw_record_not_found() { throw new \EA\Engine\Api\V1\Exception('The requested record was not found!', 404, 'Not Found'); } diff --git a/application/controllers/api/v1/Admins.php b/application/controllers/api/v1/Admins.php index 3c4cea7c..d2dcaf51 100644 --- a/application/controllers/api/v1/Admins.php +++ b/application/controllers/api/v1/Admins.php @@ -76,7 +76,7 @@ class Admins extends API_V1_Controller { if ($id !== NULL && count($admins) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($admins); @@ -92,7 +92,7 @@ class Admins extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -123,7 +123,7 @@ class Admins extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -141,7 +141,7 @@ class Admins extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); @@ -158,7 +158,7 @@ class Admins extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -182,7 +182,7 @@ class Admins extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } } diff --git a/application/controllers/api/v1/Appointments.php b/application/controllers/api/v1/Appointments.php index d479dab8..4a7ae2b8 100644 --- a/application/controllers/api/v1/Appointments.php +++ b/application/controllers/api/v1/Appointments.php @@ -37,6 +37,8 @@ use EA\Engine\Types\NonEmptyText; * @property Customers_Model $customers_model * @property Settings_Model $settings_model * @property Timezones $timezones + * @property Notifications $notifications + * @property Synchronization $synchronization * @property Roles_Model $roles_model * @property Secretaries_Model $secretaries_model * @property Admins_Model $admins_model @@ -59,6 +61,12 @@ class Appointments extends API_V1_Controller { { parent::__construct(); $this->load->model('appointments_model'); + $this->load->model('services_model'); + $this->load->model('providers_model'); + $this->load->model('customers_model'); + $this->load->model('settings_model'); + $this->load->library('synchronization'); + $this->load->library('notifications'); $this->parser = new \EA\Engine\Api\V1\Parsers\Appointments; } @@ -84,7 +92,7 @@ class Appointments extends API_V1_Controller { if ($id !== NULL && count($appointments) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($appointments); @@ -100,7 +108,7 @@ class Appointments extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -109,8 +117,6 @@ class Appointments extends API_V1_Controller { */ public function post() { - $this->load->model('services_model'); - try { // Insert the appointment to the database. @@ -138,6 +144,20 @@ class Appointments extends API_V1_Controller { $id = $this->appointments_model->add($appointment); + $service = $this->services_model->get_row($appointment['id_services']); + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + $customer = $this->customers_model->get_row($appointment['id_users_customer']); + $settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_email' => $this->settings_model->get_setting('company_email'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'date_format' => $this->settings_model->get_setting('date_format'), + 'time_format' => $this->settings_model->get_setting('time_format') + ]; + + $this->synchronization->sync_appointment_saved($appointment, $service, $provider, $customer, $settings, FALSE); + $this->notifications->notify_appointment_saved($appointment, $service, $provider, $customer, $settings, FALSE); + // Fetch the new object from the database and return it to the client. $batch = $this->appointments_model->get_batch('id = ' . $id); $response = new Response($batch); @@ -146,7 +166,7 @@ class Appointments extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -164,15 +184,30 @@ class Appointments extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); - $updatedAppointment = $request->getBody(); - $baseAppointment = $batch[0]; - $this->parser->decode($updatedAppointment, $baseAppointment); - $updatedAppointment['id'] = $id; - $id = $this->appointments_model->add($updatedAppointment); + $updated_appointment = $request->getBody(); + $base_appointment = $batch[0]; + $this->parser->decode($updated_appointment, $base_appointment); + $updated_appointment['id'] = $id; + $id = $this->appointments_model->add($updated_appointment); + + $service = $this->services_model->get_row($updated_appointment['id_services']); + $provider = $this->providers_model->get_row($updated_appointment['id_users_provider']); + $customer = $this->customers_model->get_row($updated_appointment['id_users_customer']); + $settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_email' => $this->settings_model->get_setting('company_email'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'date_format' => $this->settings_model->get_setting('date_format'), + 'time_format' => $this->settings_model->get_setting('time_format') + ]; + + $this->synchronization->sync_appointment_saved($updated_appointment, $service, $provider, $customer, $settings, TRUE); + $this->notifications->notify_appointment_saved($updated_appointment, $service, $provider, $customer, $settings, TRUE); + // Fetch the updated object from the database and return it to the client. $batch = $this->appointments_model->get_batch('id = ' . $id); @@ -181,7 +216,7 @@ class Appointments extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -194,8 +229,23 @@ class Appointments extends API_V1_Controller { { try { + $appointment = $this->appointments_model->get_row($id); + $service = $this->services_model->get_row($appointment['id_services']); + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + $customer = $this->customers_model->get_row($appointment['id_users_customer']); + $settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_email' => $this->settings_model->get_setting('company_email'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'date_format' => $this->settings_model->get_setting('date_format'), + 'time_format' => $this->settings_model->get_setting('time_format') + ]; + $this->appointments_model->delete($id); + $this->synchronization->sync_appointment_deleted($appointment, $provider); + $this->notifications->notify_appointment_deleted($appointment, $service, $provider, $customer, $settings); + $response = new Response([ 'code' => 200, 'message' => 'Record was deleted successfully!' @@ -205,7 +255,7 @@ class Appointments extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } } diff --git a/application/controllers/api/v1/Availabilities.php b/application/controllers/api/v1/Availabilities.php index a3501637..a7840228 100644 --- a/application/controllers/api/v1/Availabilities.php +++ b/application/controllers/api/v1/Availabilities.php @@ -66,8 +66,8 @@ class Availabilities extends API_V1_Controller { { try { - $providerId = new UnsignedInteger($this->input->get('providerId')); - $serviceId = new UnsignedInteger($this->input->get('serviceId')); + $provider_id = new UnsignedInteger($this->input->get('providerId')); + $service_id = new UnsignedInteger($this->input->get('serviceId')); if ($this->input->get('date')) { @@ -78,49 +78,49 @@ class Availabilities extends API_V1_Controller { $date = new DateTime(); } - $provider = $this->providers_model->get_row($providerId->get()); - $service = $this->services_model->get_row($serviceId->get()); + $provider = $this->providers_model->get_row($provider_id->get()); + $service = $this->services_model->get_row($service_id->get()); - $emptyPeriods = $this->_getProviderAvailableTimePeriods($providerId->get(), + $empty_periods = $this->get_provider_available_time_periods($provider_id->get(), $date->format('Y-m-d'), []); - $availableHours = $this->_calculateAvailableHours($emptyPeriods, + $available_hours = $this->calculate_available_hours($empty_periods, $date->format('Y-m-d'), $service['duration'], FALSE, $service['availabilities_type']); if ($service['attendants_number'] > 1) { - $availableHours = $this->_getMultipleAttendantsHours($date->format('Y-m-d'), $service, $provider); + $available_hours = $this->get_multiple_attendants_hours($date->format('Y-m-d'), $service, $provider); } - // If the selected date is today, remove past hours. It is important include the timeout before - // booking that is set in the back-office the system. Normally we might want the customer to book - // an appointment that is at least half or one hour from now. The setting is stored in minutes. + // If the selected date is today, remove past hours. It is important include the timeout before booking + // that is set in the back-office the system. Normally we might want the customer to book an appointment + // that is at least half or one hour from now. The setting is stored in minutes. if ($date->format('Y-m-d') === date('Y-m-d')) { - $bookAdvanceTimeout = $this->settings_model->get_setting('book_advance_timeout'); + $book_advance_timeout = $this->settings_model->get_setting('book_advance_timeout'); - foreach ($availableHours as $index => $value) + foreach ($available_hours as $index => $value) { - $availableHour = strtotime($value); - $currentHour = strtotime('+' . $bookAdvanceTimeout . ' minutes', strtotime('now')); - if ($availableHour <= $currentHour) + $available_hour = strtotime($value); + $currentHour = strtotime('+' . $book_advance_timeout . ' minutes', strtotime('now')); + if ($available_hour <= $currentHour) { - unset($availableHours[$index]); + unset($available_hours[$index]); } } } - $availableHours = array_values($availableHours); - sort($availableHours, SORT_STRING); - $availableHours = array_values($availableHours); + $available_hours = array_values($available_hours); + sort($available_hours, SORT_STRING); + $available_hours = array_values($available_hours); $this->output ->set_content_type('application/json') - ->set_output(json_encode($availableHours)); + ->set_output(json_encode($available_hours)); } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -139,7 +139,7 @@ class Availabilities extends API_V1_Controller { * * @return array Returns an array with the available time periods of the provider. */ - protected function _getProviderAvailableTimePeriods( + protected function get_provider_available_time_periods( $provider_id, $selected_date, $exclude_appointments = [] @@ -342,7 +342,7 @@ class Availabilities extends API_V1_Controller { * * @return array Returns an array with the available hours for the appointment. */ - protected function _calculateAvailableHours( + protected function calculate_available_hours( array $empty_periods, $selected_date, $service_duration, @@ -385,7 +385,7 @@ class Availabilities extends API_V1_Controller { * * @return array Returns the available hours array. */ - protected function _getMultipleAttendantsHours( + protected function get_multiple_attendants_hours( $selected_date, $service, $provider @@ -412,8 +412,8 @@ class Availabilities extends API_V1_Controller { ] ]; - $periods = $this->_removeBreaks($selected_date, $periods, $working_hours['breaks']); - $periods = $this->_removeUnavailabilities($periods, $unavailabilities); + $periods = $this->remove_breaks($selected_date, $periods, $working_hours['breaks']); + $periods = $this->remove_unavailabilities($periods, $unavailabilities); $hours = []; @@ -455,7 +455,7 @@ class Availabilities extends API_V1_Controller { * * @return array Returns the available time periods without the breaks. */ - public function _removeBreaks($selected_date, $periods, $breaks) + public function remove_breaks($selected_date, $periods, $breaks) { if ( ! $breaks) { @@ -517,7 +517,7 @@ class Availabilities extends API_V1_Controller { * * @return array Returns the available time periods without the unavailabilities. */ - public function _removeUnavailabilities($periods, $unavailabilities) + public function remove_unavailabilities($periods, $unavailabilities) { foreach ($unavailabilities as $unavailability) { diff --git a/application/controllers/api/v1/Categories.php b/application/controllers/api/v1/Categories.php index eb5a6649..3e1c8b86 100644 --- a/application/controllers/api/v1/Categories.php +++ b/application/controllers/api/v1/Categories.php @@ -76,7 +76,7 @@ class Categories extends API_V1_Controller { if ($id !== NULL && count($categories) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($categories); @@ -92,7 +92,7 @@ class Categories extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -123,7 +123,7 @@ class Categories extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -141,15 +141,15 @@ class Categories extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); - $updatedCategory = $request->getBody(); - $baseCategory = $batch[0]; - $this->parser->decode($updatedCategory, $baseCategory); - $updatedCategory['id'] = $id; - $id = $this->services_model->add_category($updatedCategory); + $updated_category = $request->getBody(); + $base_category = $batch[0]; + $this->parser->decode($updated_category, $base_category); + $updated_category['id'] = $id; + $id = $this->services_model->add_category($updated_category); // Fetch the updated object from the database and return it to the client. $batch = $this->services_model->get_all_categories('id = ' . $id); @@ -158,7 +158,7 @@ class Categories extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -182,7 +182,7 @@ class Categories extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } } diff --git a/application/controllers/api/v1/Customers.php b/application/controllers/api/v1/Customers.php index e2d9a332..ce95ea8e 100644 --- a/application/controllers/api/v1/Customers.php +++ b/application/controllers/api/v1/Customers.php @@ -76,7 +76,7 @@ class Customers extends API_V1_Controller { if ($id !== NULL && count($customers) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($customers); @@ -92,7 +92,7 @@ class Customers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -123,7 +123,7 @@ class Customers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -141,15 +141,15 @@ class Customers extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); - $updatedCustomer = $request->getBody(); - $baseCustomer = $batch[0]; - $this->parser->decode($updatedCustomer, $baseCustomer); - $updatedCustomer['id'] = $id; - $id = $this->customers_model->add($updatedCustomer); + $updated_customer = $request->getBody(); + $base_customer = $batch[0]; + $this->parser->decode($updated_customer, $base_customer); + $updated_customer['id'] = $id; + $id = $this->customers_model->add($updated_customer); // Fetch the updated object from the database and return it to the client. $batch = $this->customers_model->get_batch('id = ' . $id); @@ -158,7 +158,7 @@ class Customers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -182,7 +182,7 @@ class Customers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } } diff --git a/application/controllers/api/v1/Providers.php b/application/controllers/api/v1/Providers.php index 2918e053..fe342cbb 100644 --- a/application/controllers/api/v1/Providers.php +++ b/application/controllers/api/v1/Providers.php @@ -76,7 +76,7 @@ class Providers extends API_V1_Controller { if ($id !== NULL && count($providers) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($providers); @@ -92,7 +92,7 @@ class Providers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -123,7 +123,7 @@ class Providers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -141,15 +141,15 @@ class Providers extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); - $updatedProvider = $request->getBody(); - $baseProvider = $batch[0]; - $this->parser->decode($updatedProvider, $baseProvider); - $updatedProvider['id'] = $id; - $id = $this->providers_model->add($updatedProvider); + $updated_provider = $request->getBody(); + $base_provider = $batch[0]; + $this->parser->decode($updated_provider, $base_provider); + $updated_provider['id'] = $id; + $id = $this->providers_model->add($updated_provider); // Fetch the updated object from the database and return it to the client. $batch = $this->providers_model->get_batch('id = ' . $id); @@ -158,7 +158,7 @@ class Providers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -182,7 +182,7 @@ class Providers extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } } diff --git a/application/controllers/api/v1/Secretaries.php b/application/controllers/api/v1/Secretaries.php index 605fd711..fcff6060 100644 --- a/application/controllers/api/v1/Secretaries.php +++ b/application/controllers/api/v1/Secretaries.php @@ -76,7 +76,7 @@ class Secretaries extends API_V1_Controller { if ($id !== NULL && count($secretaries) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($secretaries); @@ -92,7 +92,7 @@ class Secretaries extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -123,7 +123,7 @@ class Secretaries extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -141,15 +141,15 @@ class Secretaries extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); - $updatedSecretary = $request->getBody(); - $baseSecretary = $batch[0]; - $this->parser->decode($updatedSecretary, $baseSecretary); - $updatedSecretary['id'] = $id; - $id = $this->secretaries_model->add($updatedSecretary); + $updated_secretary = $request->getBody(); + $base_secretary = $batch[0]; + $this->parser->decode($updated_secretary, $base_secretary); + $updated_secretary['id'] = $id; + $id = $this->secretaries_model->add($updated_secretary); // Fetch the updated object from the database and return it to the client. $batch = $this->secretaries_model->get_batch('id = ' . $id); @@ -158,7 +158,7 @@ class Secretaries extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -182,7 +182,7 @@ class Secretaries extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } } diff --git a/application/controllers/api/v1/Services.php b/application/controllers/api/v1/Services.php index 6aa9ccf1..0aace4a0 100644 --- a/application/controllers/api/v1/Services.php +++ b/application/controllers/api/v1/Services.php @@ -76,7 +76,7 @@ class Services extends API_V1_Controller { if ($id !== NULL && count($services) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($services); @@ -92,7 +92,7 @@ class Services extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -123,7 +123,7 @@ class Services extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -141,15 +141,15 @@ class Services extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); - $updatedService = $request->getBody(); - $baseService = $batch[0]; - $this->parser->decode($updatedService, $baseService); - $updatedService['id'] = $id; - $id = $this->services_model->add($updatedService); + $updated_service = $request->getBody(); + $base_service = $batch[0]; + $this->parser->decode($updated_service, $base_service); + $updated_service['id'] = $id; + $id = $this->services_model->add($updated_service); // Fetch the updated object from the database and return it to the client. $batch = $this->services_model->get_batch('id = ' . $id); @@ -158,7 +158,7 @@ class Services extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } @@ -182,7 +182,7 @@ class Services extends API_V1_Controller { } catch (Exception $exception) { - $this->_handleException($exception); + $this->handle_exception($exception); } } } diff --git a/application/controllers/api/v1/Settings.php b/application/controllers/api/v1/Settings.php index 19a82f28..2936d4f4 100644 --- a/application/controllers/api/v1/Settings.php +++ b/application/controllers/api/v1/Settings.php @@ -87,7 +87,7 @@ class Settings extends API_V1_Controller { if (empty($setting)) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } unset($setting['id']); @@ -110,7 +110,7 @@ class Settings extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -138,7 +138,7 @@ class Settings extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -162,7 +162,7 @@ class Settings extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } } diff --git a/application/controllers/api/v1/Unavailabilities.php b/application/controllers/api/v1/Unavailabilities.php index bc2cc2c9..c1d81b9c 100644 --- a/application/controllers/api/v1/Unavailabilities.php +++ b/application/controllers/api/v1/Unavailabilities.php @@ -76,7 +76,7 @@ class Unavailabilities extends API_V1_Controller { if ($id !== NULL && count($unavailabilities) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $response = new Response($unavailabilities); @@ -92,7 +92,7 @@ class Unavailabilities extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -123,7 +123,7 @@ class Unavailabilities extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -141,7 +141,7 @@ class Unavailabilities extends API_V1_Controller { if ($id !== NULL && count($batch) === 0) { - $this->_throwRecordNotFound(); + $this->throw_record_not_found(); } $request = new Request(); @@ -158,7 +158,7 @@ class Unavailabilities extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } @@ -182,7 +182,7 @@ class Unavailabilities extends API_V1_Controller { } catch (Exception $exception) { - exit($this->_handleException($exception)); + exit($this->handle_exception($exception)); } } } diff --git a/application/libraries/Availability.php b/application/libraries/Availability.php new file mode 100644 index 00000000..08daad31 --- /dev/null +++ b/application/libraries/Availability.php @@ -0,0 +1,552 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.4.0 + * ---------------------------------------------------------------------------- */ + + +/** + * Class Availability + * + * Handles the availability generation of providers, based on their working plan and their schedule. + */ +class Availability { + /** + * @var CI_Controller + */ + protected $CI; + + /** + * Availability constructor. + */ + public function __construct() + { + $this->CI =& get_instance(); + $this->CI->load->model('providers_model'); + $this->CI->load->model('secretaries_model'); + $this->CI->load->model('secretaries_model'); + $this->CI->load->model('admins_model'); + $this->CI->load->model('appointments_model'); + $this->CI->load->model('settings_model'); + $this->CI->load->library('ics_file'); + } + + /** + * Get the available hours of a provider. + * + * @param string $date Selected date (Y-m-d). + * @param array $service Service record. + * @param array $provider Provider record. + * @param int|null $exclude_appointment_id Exclude an appointment from the availability generation. + * + * @return array + * + * @throws Exception + */ + public function get_available_hours($date, $service, $provider, $exclude_appointment_id = NULL) + { + $available_periods = $this->get_available_periods($date, $provider, $exclude_appointment_id); + + $available_hours = $this->generate_available_hours($date, $service, $available_periods); + + if ($service['attendants_number'] > 1) + { + $available_hours = $this->consider_multiple_attendants($date, $service, $provider); + } + + return $this->consider_book_advance_timeout($date, $available_hours, $provider); + } + + /** + * Get an array containing the free time periods (start - end) of a selected date. + * + * This method is very important because there are many cases where the system needs to know when a provider is + * available for an appointment. This method will return an array that belongs to the selected date and contains + * values that have the start and the end time of an available time period. + * + * @param string $date Select date string. + * @param array $provider Provider record. + * @param int|null $exclude_appointment_id Exclude an appointment from the availability generation. + * + * @return array Returns an array with the available time periods of the provider. + * + * @throws Exception + */ + public function get_available_periods( + $date, + $provider, + $exclude_appointment_id = NULL + ) + { + // Get the service, provider's working plan and provider appointments. + $working_plan = json_decode($provider['settings']['working_plan'], TRUE); + + // Get the provider's working plan exceptions. + $working_plan_exceptions = json_decode($provider['settings']['working_plan_exceptions'], TRUE); + + $conditions = [ + 'id_users_provider' => $provider['id'], + ]; + + // Sometimes it might be necessary to exclude an appointment from the calculation (e.g. when editing an + // existing appointment). + if ($exclude_appointment_id) + { + $conditions['id !='] = $exclude_appointment_id; + } + + $appointments = $this->CI->appointments_model->get_batch($conditions); + + // Find the empty spaces on the plan. The first split between the plan is due to a break (if any). After that + // every reserved appointment is considered to be a taken space in the plan. + $date_working_plan = $working_plan[strtolower(date('l', strtotime($date)))]; + + // Search if the $date is an custom availability period added outside the normal working plan. + if (isset($working_plan_exceptions[$date])) + { + $date_working_plan = $working_plan_exceptions[$date]; + } + + $periods = []; + + if (isset($date_working_plan['breaks'])) + { + $periods[] = [ + 'start' => $date_working_plan['start'], + 'end' => $date_working_plan['end'] + ]; + + $day_start = new DateTime($date_working_plan['start']); + $day_end = new DateTime($date_working_plan['end']); + + // Split the working plan to available time periods that do not contain the breaks in them. + foreach ($date_working_plan['breaks'] as $index => $break) + { + $break_start = new DateTime($break['start']); + $break_end = new DateTime($break['end']); + + if ($break_start < $day_start) + { + $break_start = $day_start; + } + + if ($break_end > $day_end) + { + $break_end = $day_end; + } + + if ($break_start >= $break_end) + { + continue; + } + + foreach ($periods as $key => $period) + { + $period_start = new DateTime($period['start']); + $period_end = new DateTime($period['end']); + + $remove_current_period = FALSE; + + if ($break_start > $period_start && $break_start < $period_end && $break_end > $period_start) + { + $periods[] = [ + 'start' => $period_start->format('H:i'), + 'end' => $break_start->format('H:i') + ]; + + $remove_current_period = TRUE; + } + + if ($break_start < $period_end && $break_end > $period_start && $break_end < $period_end) + { + $periods[] = [ + 'start' => $break_end->format('H:i'), + 'end' => $period_end->format('H:i') + ]; + + $remove_current_period = TRUE; + } + + if ($break_start == $period_start && $break_end == $period_end) + { + $remove_current_period = TRUE; + } + + if ($remove_current_period) + { + unset($periods[$key]); + } + } + } + } + + // Break the empty periods with the reserved appointments. + foreach ($appointments as $appointment) + { + foreach ($periods as $index => &$period) + { + $appointment_start = new DateTime($appointment['start_datetime']); + $appointment_end = new DateTime($appointment['end_datetime']); + + if ($appointment_start >= $appointment_end) + { + continue; + } + + $period_start = new DateTime($date . ' ' . $period['start']); + $period_end = new DateTime($date . ' ' . $period['end']); + + if ($appointment_start <= $period_start && $appointment_end <= $period_end && $appointment_end <= $period_start) + { + // The appointment does not belong in this time period, so we will not change anything. + continue; + } + else + { + if ($appointment_start <= $period_start && $appointment_end <= $period_end && $appointment_end >= $period_start) + { + // The appointment starts before the period and finishes somewhere inside. We will need to break + // this period and leave the available part. + $period['start'] = $appointment_end->format('H:i'); + } + else + { + if ($appointment_start >= $period_start && $appointment_end < $period_end) + { + // The appointment is inside the time period, so we will split the period into two new + // others. + unset($periods[$index]); + + $periods[] = [ + 'start' => $period_start->format('H:i'), + 'end' => $appointment_start->format('H:i') + ]; + + $periods[] = [ + 'start' => $appointment_end->format('H:i'), + 'end' => $period_end->format('H:i') + ]; + } + else if ($appointment_start == $period_start && $appointment_end == $period_end) + { + unset($periods[$index]); // The whole period is blocked so remove it from the available periods array. + } + else + { + if ($appointment_start >= $period_start && $appointment_end >= $period_start && $appointment_start <= $period_end) + { + // The appointment starts in the period and finishes out of it. We will need to remove + // the time that is taken from the appointment. + $period['end'] = $appointment_start->format('H:i'); + } + else + { + if ($appointment_start >= $period_start && $appointment_end >= $period_end && $appointment_start >= $period_end) + { + // The appointment does not belong in the period so do not change anything. + continue; + } + else + { + if ($appointment_start <= $period_start && $appointment_end >= $period_end && $appointment_start <= $period_end) + { + // The appointment is bigger than the period, so this period needs to be removed. + unset($periods[$index]); + } + } + } + } + } + } + } + } + + return array_values($periods); + } + + + /** + * Calculate the available appointment hours. + * + * Calculate the available appointment hours for the given date. The empty spaces + * are broken down to 15 min and if the service fit in each quarter then a new + * available hour is added to the "$available_hours" array. + * + * @param string $date Selected date (Y-m-d). + * @param array $service Service record. + * @param array $empty_periods Empty periods as generated by the "get_provider_available_time_periods" + * method. + * + * @return array Returns an array with the available hours for the appointment. + * + * @throws Exception + */ + protected function generate_available_hours( + $date, + $service, + $empty_periods + ) + { + $available_hours = []; + + foreach ($empty_periods as $period) + { + $start_hour = new DateTime($date . ' ' . $period['start']); + $end_hour = new DateTime($date . ' ' . $period['end']); + $interval = $service['availabilities_type'] === AVAILABILITIES_TYPE_FIXED ? (int)$service['duration'] : 15; + + $current_hour = $start_hour; + $diff = $current_hour->diff($end_hour); + + while (($diff->h * 60 + $diff->i) >= (int)$service['duration']) + { + $available_hours[] = $current_hour->format('H:i'); + $current_hour->add(new DateInterval('PT' . $interval . 'M')); + $diff = $current_hour->diff($end_hour); + } + } + + return $available_hours; + } + + /** + * Get multiple attendants hours. + * + * This method will add the additional appointment hours whenever a service accepts multiple attendants. + * + * @param string $date Selected date (Y-m-d). + * @param array $service Service record. + * @param array $provider Provider record. + * + * @return array Returns the available hours array. + * + * @throws Exception + */ + protected function consider_multiple_attendants( + $date, + $service, + $provider + ) + { + $unavailability_events = $this->CI->appointments_model->get_batch([ + 'is_unavailable' => TRUE, + 'DATE(start_datetime)' => $date, + 'id_users_provider' => $provider['id'] + ]); + + $working_plan = json_decode($provider['settings']['working_plan'], TRUE); + $working_day = strtolower(date('l', strtotime($date))); + $working_hours = $working_plan[$working_day]; + + $periods = [ + [ + 'start' => new DateTime($date . ' ' . $working_hours['start']), + 'end' => new DateTime($date . ' ' . $working_hours['end']) + ] + ]; + + $periods = $this->remove_breaks($date, $periods, $working_hours['breaks']); + $periods = $this->remove_unavailability_events($periods, $unavailability_events); + + $hours = []; + + $interval_value = $service['availabilities_type'] == AVAILABILITIES_TYPE_FIXED ? $service['duration'] : '15'; + $interval = new DateInterval('PT' . (int)$interval_value . 'M'); + $duration = new DateInterval('PT' . (int)$service['duration'] . 'M'); + + foreach ($periods as $period) + { + $slot_start = clone $period['start']; + $slot_end = clone $slot_start; + $slot_end->add($duration); + + while ($slot_end <= $period['end']) + { + // Check reserved attendants for this time slot and see if current attendants fit. + $appointment_attendants_number = $this->CI->appointments_model->get_attendants_number_for_period( + $slot_start, + $slot_end, + $service['id'] + ); + + if ($appointment_attendants_number < $service['attendants_number']) + { + $hours[] = $slot_start->format('H:i'); + } + + $slot_start->add($interval); + $slot_end->add($interval); + } + } + + return $hours; + } + + /** + * Remove breaks from available time periods. + * + * @param string $selected_date Selected data (Y-m-d format). + * @param array $periods Time periods of the current date. + * @param array $breaks Breaks array for the current date. + * + * @return array Returns the available time periods without the breaks. + * @throws Exception + */ + public function remove_breaks($selected_date, $periods, $breaks) + { + if ( ! $breaks) + { + return $periods; + } + + foreach ($breaks as $break) + { + $break_start = new DateTime($selected_date . ' ' . $break['start']); + $break_end = new DateTime($selected_date . ' ' . $break['end']); + + foreach ($periods as &$period) + { + $period_start = $period['start']; + $period_end = $period['end']; + + if ($break_start <= $period_start && $break_end >= $period_start && $break_end <= $period_end) + { + // left + $period['start'] = $break_end; + continue; + } + + if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_start && $break_end <= $period_end) + { + // middle + $period['end'] = $break_start; + $periods[] = [ + 'start' => $break_end, + 'end' => $period_end + ]; + continue; + } + + if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_end) + { + // right + $period['end'] = $break_start; + continue; + } + + if ($break_start <= $period_start && $break_end >= $period_end) + { + // break contains period + $period['start'] = $break_end; + continue; + } + } + } + + return $periods; + } + + /** + * Remove the unavailability entries from the available time periods of the selected date. + * + * @param array $periods Available time periods. + * @param array $unavailability_events Unavailability events of the current date. + * + * @return array Returns the available time periods without the unavailability events. + * + * @throws Exception + */ + public function remove_unavailability_events($periods, $unavailability_events) + { + foreach ($unavailability_events as $unavailability_event) + { + $unavailability_start = new DateTime($unavailability_event['start_datetime']); + $unavailability_end = new DateTime($unavailability_event['end_datetime']); + + foreach ($periods as &$period) + { + $period_start = $period['start']; + $period_end = $period['end']; + + if ($unavailability_start <= $period_start && $unavailability_end >= $period_start && $unavailability_end <= $period_end) + { + // left + $period['start'] = $unavailability_end; + continue; + } + + if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_start && $unavailability_end <= $period_end) + { + // middle + $period['end'] = $unavailability_start; + $periods[] = [ + 'start' => $unavailability_end, + 'end' => $period_end + ]; + continue; + } + + if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_end) + { + // right + $period['end'] = $unavailability_start; + continue; + } + + if ($unavailability_start <= $period_start && $unavailability_end >= $period_end) + { + // Unavailability contains period + $period['start'] = $unavailability_end; + continue; + } + } + } + + return $periods; + } + + /** + * Consider the book advance timeout and remove available hours that have passed the threshold. + * + * If the selected date is today, remove past hours. It is important include the timeout before booking + * that is set in the back-office the system. Normally we might want the customer to book an appointment + * that is at least half or one hour from now. The setting is stored in minutes. + * + * @param string $selected_date The selected date. + * @param array $available_hours Already generated available hours. + * @param array $provider Provider information. + * + * @return array Returns the updated available hours. + * + * @throws Exception + */ + protected function consider_book_advance_timeout($selected_date, $available_hours, $provider) + { + $provider_timezone = new DateTimeZone($provider['timezone']); + + $book_advance_timeout = $this->CI->settings_model->get_setting('book_advance_timeout'); + + $threshold = new DateTime('+' . $book_advance_timeout . ' minutes', $provider_timezone); + + foreach ($available_hours as $index => $value) + { + $available_hour = new DateTime($selected_date . ' ' . $value, $provider_timezone); + + if ($available_hour->getTimestamp() <= $threshold->getTimestamp()) + { + unset($available_hours[$index]); + } + } + + $available_hours = array_values($available_hours); + sort($available_hours, SORT_STRING); + return array_values($available_hours); + } +} diff --git a/application/libraries/Notifications.php b/application/libraries/Notifications.php new file mode 100644 index 00000000..1b675669 --- /dev/null +++ b/application/libraries/Notifications.php @@ -0,0 +1,227 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.4.0 + * ---------------------------------------------------------------------------- */ + +use EA\Engine\Notifications\Email as EmailClient; +use EA\Engine\Types\Email; +use EA\Engine\Types\Text; +use EA\Engine\Types\Url; + +/** + * Class Notifications + * + * Handles the system notifications (mostly related to scheduling changes). + */ +class Notifications { + /** + * @var CI_Controller + */ + protected $CI; + + /** + * Notifications constructor. + */ + public function __construct() + { + $this->CI =& get_instance(); + $this->CI->load->model('providers_model'); + $this->CI->load->model('secretaries_model'); + $this->CI->load->model('secretaries_model'); + $this->CI->load->model('admins_model'); + $this->CI->load->model('appointments_model'); + $this->CI->load->model('settings_model'); + $this->CI->load->library('ics_file'); + } + + /** + * Send the required notifications, related to an appointment creation/modification. + * + * @param array $appointment Appointment record. + * @param array $service Service record. + * @param array $provider Provider record. + * @param array $customer Customer record. + * @param array $settings Required settings for the notification content. + * @param bool|false $manage_mode + */ + public function notify_appointment_saved($appointment, $service, $provider, $customer, $settings, $manage_mode = FALSE) + { + // Send email notifications to customer and provider. + try + { + $this->CI->config->load('email'); + + $email = new EmailClient($this->CI, $this->CI->config->config); + + if ($manage_mode === FALSE) + { + $customer_title = new Text(lang('appointment_booked')); + $customer_message = new Text(lang('thank_you_for_appointment')); + $provider_title = new Text(lang('appointment_added_to_your_plan')); + $provider_message = new Text(lang('appointment_link_description')); + + } + else + { + $customer_title = new Text(lang('appointment_changes_saved')); + $customer_message = new Text(''); + $provider_title = new Text(lang('appointment_details_changed')); + $provider_message = new Text(''); + } + + $customer_link = new Url(site_url('appointments/index/' . $appointment['hash'])); + $provider_link = new Url(site_url('backend/index/' . $appointment['hash'])); + + $send_customer = filter_var( + $this->CI->settings_model->get_setting('customer_notifications'), + FILTER_VALIDATE_BOOLEAN); + + + $ics_stream = $this->CI->ics_file->get_stream($appointment, $service, $provider, $customer); + + if ($send_customer === TRUE) + { + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $customer_title, + $customer_message, $customer_link, new Email($customer['email']), new Text($ics_stream)); + } + + $send_provider = filter_var( + $this->CI->providers_model->get_setting('notifications', $provider['id']), + FILTER_VALIDATE_BOOLEAN); + + if ($send_provider === TRUE) + { + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($provider['email']), new Text($ics_stream)); + } + + // Notify admins + $admins = $this->CI->admins_model->get_batch(); + + foreach ($admins as $admin) + { + if ( ! $admin['settings']['notifications'] === '0') + { + continue; + } + + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($admin['email']), new Text($ics_stream)); + } + + // Notify secretaries + $secretaries = $this->CI->secretaries_model->get_batch(); + + foreach ($secretaries as $secretary) + { + if ( ! $secretary['settings']['notifications'] === '0') + { + continue; + } + + if (in_array($provider['id'], $secretary['providers'])) + { + continue; + } + + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($secretary['email']), new Text($ics_stream)); + } + } + catch (Exception $exception) + { + log_message('error', $exception->getMessage()); + log_message('error', $exception->getTraceAsString()); + } + } + + /** + * Send the required notifications, related to an appointment removal. + * + * @param array $appointment Appointment record. + * @param array $service Service record. + * @param array $provider Provider record. + * @param array $customer Customer record. + * @param array $settings Required settings for the notification content. + */ + public function notify_appointment_deleted($appointment, $service, $provider, $customer, $settings) + { + // Send email notification to customer and provider. + try + { + $email = new EmailClient($this->CI, $this->CI->config->config); + + $send_provider = filter_var($this->CI->providers_model->get_setting('notifications', $provider['id']), + FILTER_VALIDATE_BOOLEAN); + + if ($send_provider === TRUE) + { + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($provider['email']), + new Text($this->CI->input->post('cancel_reason'))); + } + + $send_customer = filter_var( + $this->CI->settings_model->get_setting('customer_notifications'), + FILTER_VALIDATE_BOOLEAN); + + if ($send_customer === TRUE) + { + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($customer['email']), + new Text($this->CI->input->post('cancel_reason'))); + } + + // Notify admins + $admins = $this->CI->admins_model->get_batch(); + + foreach ($admins as $admin) + { + if ( ! $admin['settings']['notifications'] === '0') + { + continue; + } + + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($admin['email']), + new Text($this->CI->input->post('cancel_reason'))); + } + + // Notify secretaries + $secretaries = $this->CI->secretaries_model->get_batch(); + + foreach ($secretaries as $secretary) + { + if ( ! $secretary['settings']['notifications'] === '0') + { + continue; + } + + if (in_array($provider['id'], $secretary['providers'])) + { + continue; + } + + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($secretary['email']), + new Text($this->CI->input->post('cancel_reason'))); + } + } + catch (Exception $exception) + { + $exceptions[] = $exception; + } + } +} diff --git a/application/libraries/Synchronization.php b/application/libraries/Synchronization.php new file mode 100644 index 00000000..a8a0100d --- /dev/null +++ b/application/libraries/Synchronization.php @@ -0,0 +1,120 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.4.0 + * ---------------------------------------------------------------------------- */ + +/** + * Class Synchronization + * + * Handles the external calendar synchronization. + */ +class Synchronization { + /** + * @var CI_Controller + */ + protected $CI; + + /** + * Synchronization constructor. + */ + public function __construct() + { + $this->CI =& get_instance(); + $this->CI->load->model('providers_model'); + $this->CI->load->model('appointments_model'); + $this->CI->load->library('google_sync'); + } + + /** + * Synchronize changes made to the appointment with external calendars. + * + * @param array $appointment Appointment record. + * @param array $service Service record. + * @param array $provider Provider record. + * @param array $customer Customer record. + * @param array $settings Required settings for the notification content. + * @param bool|false $manage_mode True if the appointment is being edited. + */ + public function sync_appointment_saved($appointment, $service, $provider, $customer, $settings, $manage_mode = FALSE) + { + try + { + $google_sync = filter_var( + $this->CI->providers_model->get_setting('google_sync', $appointment['id_users_provider']), + FILTER_VALIDATE_BOOLEAN + ); + + if ($google_sync === TRUE) + { + $google_token = json_decode( + $this->CI->providers_model->get_setting('google_token', $appointment['id_users_provider'])); + + $this->CI->load->library('google_sync'); + + $this->CI->google_sync->refresh_token($google_token->refresh_token); + + if ($manage_mode === FALSE) + { + // Add appointment to Google Calendar. + $google_event = $this->CI->google_sync->add_appointment($appointment, $provider, + $service, $customer, $settings); + $appointment['id_google_calendar'] = $google_event->id; + $this->CI->appointments_model->add($appointment); + } + else + { + // Update appointment to Google Calendar. + $appointment['id_google_calendar'] = $this->CI->appointments_model + ->get_value('id_google_calendar', $appointment['id']); + + $this->CI->google_sync->update_appointment($appointment, $provider, + $service, $customer, $settings); + } + } + } + catch (Exception $exception) + { + log_message('error', $exception->getMessage()); + log_message('error', $exception->getTraceAsString()); + } + } + + /** + * Synchronize removal of an appointment with external calendars. + * + * @param array $appointment Appointment record. + * @param array $provider Provider record. + */ + public function sync_appointment_deleted($appointment, $provider) + { + if ($appointment['id_google_calendar'] != NULL) + { + try + { + $google_sync = filter_var( + $this->CI->providers_model->get_setting('google_sync', $appointment['id_users_provider']), + FILTER_VALIDATE_BOOLEAN); + + if ($google_sync === TRUE) + { + $google_token = json_decode($this->CI->providers_model->get_setting('google_token', $provider['id'])); + $this->CI->load->library('Google_sync'); + $this->CI->google_sync->refresh_token($google_token->refresh_token); + $this->CI->google_sync->delete_appointment($provider, $appointment['id_google_calendar']); + } + } + catch (Exception $exception) + { + $exceptions[] = $exception; + } + } + } +} diff --git a/application/views/appointments/book.php b/application/views/appointments/book.php index adc8912c..ec8a3e7f 100755 --- a/application/views/appointments/book.php +++ b/application/views/appointments/book.php @@ -10,7 +10,6 @@ - @@ -32,17 +31,17 @@
-
+
1
-
+
2
-
+
3
-
+
4
@@ -450,9 +449,10 @@ - + + diff --git a/application/views/backend/calendar.php b/application/views/backend/calendar.php index 6e886e11..00af0857 100755 --- a/application/views/backend/calendar.php +++ b/application/views/backend/calendar.php @@ -9,7 +9,7 @@ - + + + - @@ -38,7 +38,8 @@
@@ -192,10 +192,10 @@
- -
diff --git a/application/views/backend/settings.php b/application/views/backend/settings.php index 56b209c3..e95de6f2 100755 --- a/application/views/backend/settings.php +++ b/application/views/backend/settings.php @@ -70,7 +70,7 @@ @@ -223,7 +223,7 @@ @@ -311,7 +311,7 @@ @@ -392,7 +392,7 @@ diff --git a/application/views/backend/users.php b/application/views/backend/users.php index 0bf0b5e4..139c2d90 100755 --- a/application/views/backend/users.php +++ b/application/views/backend/users.php @@ -68,10 +68,10 @@
- -
@@ -284,7 +284,7 @@

@@ -374,10 +374,10 @@
- -
@@ -580,10 +580,10 @@
- -
diff --git a/application/views/general/installation.php b/application/views/general/installation.php index bc311d4c..67a3e925 100755 --- a/application/views/general/installation.php +++ b/application/views/general/installation.php @@ -17,10 +17,9 @@
- - Easy!Appointments Installation Banner - +
+

Easy!Appointments Installation

+
diff --git a/assets/css/backend.css b/assets/css/backend.css index d024de10..1df4a747 100644 --- a/assets/css/backend.css +++ b/assets/css/backend.css @@ -29,6 +29,12 @@ root { #header #header-logo { padding: 5px; + line-height: 0; +} + +#header #header-logo small { + font-size: 9px; + color: #2e6a5b; } #header #header-logo img { @@ -38,12 +44,11 @@ root { margin-right: 10px; } -#header #header-logo span { - float: left; - font-size: 14px; +#header #header-logo h6 { + margin-top: 6px; + font-size: 15px; font-weight: bold; color: white; - margin-top: 12px; } #header .navbar-toggler { diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 49c62c0a..f7e2ecf0 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -147,6 +147,7 @@ body { text-align: center; color: #0bb98d; transition: all .3s linear; + cursor: default; } #book-appointment-wizard .active-step { @@ -171,10 +172,6 @@ body { border-top: 1px solid #ebeef1; } -#book-appointment-wizard #steps .custom-qtip { - border-width: 2px; -} - #book-appointment-wizard #available-hours { overflow: auto; margin: 15px 0; diff --git a/assets/css/general.css b/assets/css/general.css index 4fc0841d..cc06b981 100644 --- a/assets/css/general.css +++ b/assets/css/general.css @@ -15,17 +15,6 @@ } } -body .custom-qtip { - border: none; - border-radius: 0; - padding: 10px; - box-shadow: 1px 1px 3px #767676; - background: #EFFDF6; - font-size: 12px; - line-height: 20px; - color: #258D53; -} - body .ui-widget { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1.1em; diff --git a/assets/css/installation.css b/assets/css/installation.css index 80164eea..2ba60736 100644 --- a/assets/css/installation.css +++ b/assets/css/installation.css @@ -3,11 +3,21 @@ header { margin-bottom: 25px; } +header .page-title { + font-weight: lighter; + padding: 40px 0; + color: #429a82; +} + .content { margin: 32px; max-width: 980px; } +.content p { + word-break: break-all; +} + .alert { margin: 25px 0 10px 0; } diff --git a/assets/ext/jquery-qtip/jquery.qtip.min.css b/assets/ext/jquery-qtip/jquery.qtip.min.css deleted file mode 100644 index 5adc4b75..00000000 --- a/assets/ext/jquery-qtip/jquery.qtip.min.css +++ /dev/null @@ -1 +0,0 @@ -#qtip-overlay.blurs,.qtip-close{cursor:pointer}.qtip{position:absolute;left:-28000px;top:-28000px;display:none;max-width:280px;min-width:50px;font-size:10.5px;line-height:12px;direction:ltr;box-shadow:none;padding:0}.qtip-content,.qtip-titlebar{position:relative;overflow:hidden}.qtip-content{padding:5px 9px;text-align:left;word-wrap:break-word}.qtip-titlebar{padding:5px 35px 5px 10px;border-width:0 0 1px;font-weight:700}.qtip-titlebar+.qtip-content{border-top-width:0!important}.qtip-close{position:absolute;right:-9px;top:-9px;z-index:11;outline:0;border:1px solid transparent}.qtip-titlebar .qtip-close{right:4px;top:50%;margin-top:-9px}* html .qtip-titlebar .qtip-close{top:16px}.qtip-icon .ui-icon,.qtip-titlebar .ui-icon{display:block;text-indent:-1000em;direction:ltr}.qtip-icon,.qtip-icon .ui-icon{-moz-border-radius:3px;-webkit-border-radius:3px;border-radius:3px;text-decoration:none}.qtip-icon .ui-icon{width:18px;height:14px;line-height:14px;text-align:center;text-indent:0;font:normal 700 10px/13px Tahoma,sans-serif;color:inherit;background:-100em -100em no-repeat}.qtip-default{border:1px solid #F1D031;background-color:#FFFFA3;color:#555}.qtip-default .qtip-titlebar{background-color:#FFEF93}.qtip-default .qtip-icon{border-color:#CCC;background:#F1F1F1;color:#777}.qtip-default .qtip-titlebar .qtip-close{border-color:#AAA;color:#111}.qtip-light{background-color:#fff;border-color:#E2E2E2;color:#454545}.qtip-light .qtip-titlebar{background-color:#f1f1f1}.qtip-dark{background-color:#505050;border-color:#303030;color:#f3f3f3}.qtip-dark .qtip-titlebar{background-color:#404040}.qtip-dark .qtip-icon{border-color:#444}.qtip-dark .qtip-titlebar .ui-state-hover{border-color:#303030}.qtip-cream{background-color:#FBF7AA;border-color:#F9E98E;color:#A27D35}.qtip-red,.qtip-red .qtip-icon,.qtip-red .qtip-titlebar .ui-state-hover{border-color:#D95252}.qtip-cream .qtip-titlebar{background-color:#F0DE7D}.qtip-cream .qtip-close .qtip-icon{background-position:-82px 0}.qtip-red{background-color:#F78B83;color:#912323}.qtip-red .qtip-titlebar{background-color:#F06D65}.qtip-red .qtip-close .qtip-icon{background-position:-102px 0}.qtip-green{background-color:#CAED9E;border-color:#90D93F;color:#3F6219}.qtip-green .qtip-titlebar{background-color:#B0DE78}.qtip-green .qtip-close .qtip-icon{background-position:-42px 0}.qtip-blue{background-color:#E5F6FE;border-color:#ADD9ED;color:#5E99BD}.qtip-blue .qtip-titlebar{background-color:#D0E9F5}.qtip-blue .qtip-close .qtip-icon{background-position:-2px 0}.qtip-shadow{-webkit-box-shadow:1px 1px 3px 1px rgba(0,0,0,.15);-moz-box-shadow:1px 1px 3px 1px rgba(0,0,0,.15);box-shadow:1px 1px 3px 1px rgba(0,0,0,.15)}.qtip-bootstrap,.qtip-rounded,.qtip-tipsy{-moz-border-radius:5px;-webkit-border-radius:5px;border-radius:5px}.qtip-rounded .qtip-titlebar{-moz-border-radius:4px 4px 0 0;-webkit-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0}.qtip-youtube{-moz-border-radius:2px;-webkit-border-radius:2px;border-radius:2px;-webkit-box-shadow:0 0 3px #333;-moz-box-shadow:0 0 3px #333;box-shadow:0 0 3px #333;color:#fff;border:0 solid transparent;background:#4A4A4A;background-image:-webkit-gradient(linear,left top,left bottom,color-stop(0,#4A4A4A),color-stop(100%,#000));background-image:-webkit-linear-gradient(top,#4A4A4A 0,#000 100%);background-image:-moz-linear-gradient(top,#4A4A4A 0,#000 100%);background-image:-ms-linear-gradient(top,#4A4A4A 0,#000 100%);background-image:-o-linear-gradient(top,#4A4A4A 0,#000 100%)}.qtip-youtube .qtip-titlebar{background-color:#4A4A4A;background-color:rgba(0,0,0,0)}.qtip-youtube .qtip-content{padding:.75em;font:12px arial,sans-serif;filter:progid:DXImageTransform.Microsoft.Gradient(GradientType=0, StartColorStr=#4a4a4a, EndColorStr=#000000);-ms-filter:"progid:DXImageTransform.Microsoft.Gradient(GradientType=0,StartColorStr=#4a4a4a,EndColorStr=#000000);"}.qtip-youtube .qtip-icon{border-color:#222}.qtip-youtube .qtip-titlebar .ui-state-hover{border-color:#303030}.qtip-jtools{background:#232323;background:rgba(0,0,0,.7);background-image:-webkit-gradient(linear,left top,left bottom,from(#717171),to(#232323));background-image:-moz-linear-gradient(top,#717171,#232323);background-image:-webkit-linear-gradient(top,#717171,#232323);background-image:-ms-linear-gradient(top,#717171,#232323);background-image:-o-linear-gradient(top,#717171,#232323);border:2px solid #ddd;border:2px solid rgba(241,241,241,1);-moz-border-radius:2px;-webkit-border-radius:2px;border-radius:2px;-webkit-box-shadow:0 0 12px #333;-moz-box-shadow:0 0 12px #333;box-shadow:0 0 12px #333}.qtip-jtools .qtip-titlebar{background-color:transparent;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#717171, endColorstr=#4A4A4A);-ms-filter:"progid:DXImageTransform.Microsoft.gradient(startColorstr=#717171,endColorstr=#4A4A4A)"}.qtip-jtools .qtip-content{filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#4A4A4A, endColorstr=#232323);-ms-filter:"progid:DXImageTransform.Microsoft.gradient(startColorstr=#4A4A4A,endColorstr=#232323)"}.qtip-jtools .qtip-content,.qtip-jtools .qtip-titlebar{background:0 0;color:#fff;border:0 dashed transparent}.qtip-jtools .qtip-icon{border-color:#555}.qtip-jtools .qtip-titlebar .ui-state-hover{border-color:#333}.qtip-cluetip{-webkit-box-shadow:4px 4px 5px rgba(0,0,0,.4);-moz-box-shadow:4px 4px 5px rgba(0,0,0,.4);box-shadow:4px 4px 5px rgba(0,0,0,.4);background-color:#D9D9C2;color:#111;border:0 dashed transparent}.qtip-cluetip .qtip-titlebar{background-color:#87876A;color:#fff;border:0 dashed transparent}.qtip-cluetip .qtip-icon{border-color:#808064}.qtip-cluetip .qtip-titlebar .ui-state-hover{border-color:#696952;color:#696952}.qtip-tipsy{background:#000;background:rgba(0,0,0,.87);color:#fff;border:0 solid transparent;font-size:11px;font-family:'Lucida Grande',sans-serif;font-weight:700;line-height:16px;text-shadow:0 1px #000}.qtip-tipsy .qtip-titlebar{padding:6px 35px 0 10px;background-color:transparent}.qtip-tipsy .qtip-content{padding:6px 10px}.qtip-tipsy .qtip-icon{border-color:#222;text-shadow:none}.qtip-tipsy .qtip-titlebar .ui-state-hover{border-color:#303030}.qtip-tipped{border:3px solid #959FA9;-moz-border-radius:3px;-webkit-border-radius:3px;border-radius:3px;background-color:#F9F9F9;color:#454545;font-weight:400;font-family:serif}.qtip-tipped .qtip-titlebar{border-bottom-width:0;color:#fff;background:#3A79B8;background-image:-webkit-gradient(linear,left top,left bottom,from(#3A79B8),to(#2E629D));background-image:-webkit-linear-gradient(top,#3A79B8,#2E629D);background-image:-moz-linear-gradient(top,#3A79B8,#2E629D);background-image:-ms-linear-gradient(top,#3A79B8,#2E629D);background-image:-o-linear-gradient(top,#3A79B8,#2E629D);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#3A79B8, endColorstr=#2E629D);-ms-filter:"progid:DXImageTransform.Microsoft.gradient(startColorstr=#3A79B8,endColorstr=#2E629D)"}.qtip-tipped .qtip-icon{border:2px solid #285589;background:#285589}.qtip-tipped .qtip-icon .ui-icon{background-color:#FBFBFB;color:#555}.qtip-bootstrap{font-size:14px;line-height:20px;color:#333;padding:1px;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);-moz-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box}.qtip-bootstrap .qtip-titlebar{padding:8px 14px;margin:0;font-size:14px;font-weight:400;line-height:18px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;-webkit-border-radius:5px 5px 0 0;-moz-border-radius:5px 5px 0 0;border-radius:5px 5px 0 0}.qtip-bootstrap .qtip-titlebar .qtip-close{right:11px;top:45%;border-style:none}.qtip-bootstrap .qtip-content{padding:9px 14px}.qtip-bootstrap .qtip-icon{background:0 0}.qtip-bootstrap .qtip-icon .ui-icon{width:auto;height:auto;float:right;font-size:20px;font-weight:700;line-height:18px;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}#qtip-overlay,#qtip-overlay div{left:0;top:0;width:100%;height:100%}.qtip-bootstrap .qtip-icon .ui-icon:hover{color:#000;text-decoration:none;cursor:pointer;opacity:.4;filter:alpha(opacity=40)}.qtip:not(.ie9haxors) div.qtip-content,.qtip:not(.ie9haxors) div.qtip-titlebar{filter:none;-ms-filter:none}.qtip .qtip-tip{margin:0 auto;overflow:hidden;z-index:10}.qtip .qtip-tip,x:-o-prefocus{visibility:hidden}.qtip .qtip-tip,.qtip .qtip-tip .qtip-vml,.qtip .qtip-tip canvas{position:absolute;color:#123456;background:0 0;border:0 dashed transparent}.qtip .qtip-tip canvas{top:0;left:0}.qtip .qtip-tip .qtip-vml{behavior:url(#default#VML);display:inline-block;visibility:visible}#qtip-overlay{position:fixed}#qtip-overlay div{position:absolute;background-color:#000;opacity:.7;filter:alpha(opacity=70);-ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=70)"}.qtipmodal-ie6fix{position:absolute!important} \ No newline at end of file diff --git a/assets/ext/jquery-qtip/jquery.qtip.min.js b/assets/ext/jquery-qtip/jquery.qtip.min.js deleted file mode 100644 index d2f28c56..00000000 --- a/assets/ext/jquery-qtip/jquery.qtip.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/* qtip2 v3.0.3 | Plugins: tips modal viewport svg imagemap ie6 | Styles: core basic css3 | qtip2.com | Licensed MIT | Wed May 11 2016 22:31:31 */ - -!function(a,b,c){!function(a){"use strict";"function"==typeof define&&define.amd?define(["jquery"],a):jQuery&&!jQuery.fn.qtip&&a(jQuery)}(function(d){"use strict";function e(a,b,c,e){this.id=c,this.target=a,this.tooltip=F,this.elements={target:a},this._id=S+"-"+c,this.timers={img:{}},this.options=b,this.plugins={},this.cache={event:{},target:d(),disabled:E,attr:e,onTooltip:E,lastClass:""},this.rendered=this.destroyed=this.disabled=this.waiting=this.hiddenDuringWait=this.positioning=this.triggering=E}function f(a){return a===F||"object"!==d.type(a)}function g(a){return!(d.isFunction(a)||a&&a.attr||a.length||"object"===d.type(a)&&(a.jquery||a.then))}function h(a){var b,c,e,h;return f(a)?E:(f(a.metadata)&&(a.metadata={type:a.metadata}),"content"in a&&(b=a.content,f(b)||b.jquery||b.done?(c=g(b)?E:b,b=a.content={text:c}):c=b.text,"ajax"in b&&(e=b.ajax,h=e&&e.once!==E,delete b.ajax,b.text=function(a,b){var f=c||d(this).attr(b.options.content.attr)||"Loading...",g=d.ajax(d.extend({},e,{context:b})).then(e.success,F,e.error).then(function(a){return a&&h&&b.set("content.text",a),a},function(a,c,d){b.destroyed||0===a.status||b.set("content.text",c+": "+d)});return h?f:(b.set("content.text",f),g)}),"title"in b&&(d.isPlainObject(b.title)&&(b.button=b.title.button,b.title=b.title.text),g(b.title||E)&&(b.title=E))),"position"in a&&f(a.position)&&(a.position={my:a.position,at:a.position}),"show"in a&&f(a.show)&&(a.show=a.show.jquery?{target:a.show}:a.show===D?{ready:D}:{event:a.show}),"hide"in a&&f(a.hide)&&(a.hide=a.hide.jquery?{target:a.hide}:{event:a.hide}),"style"in a&&f(a.style)&&(a.style={classes:a.style}),d.each(R,function(){this.sanitize&&this.sanitize(a)}),a)}function i(a,b){for(var c,d=0,e=a,f=b.split(".");e=e[f[d++]];)d0?setTimeout(d.proxy(a,this),b):void a.call(this)}function m(a){this.tooltip.hasClass(aa)||(clearTimeout(this.timers.show),clearTimeout(this.timers.hide),this.timers.show=l.call(this,function(){this.toggle(D,a)},this.options.show.delay))}function n(a){if(!this.tooltip.hasClass(aa)&&!this.destroyed){var b=d(a.relatedTarget),c=b.closest(W)[0]===this.tooltip[0],e=b[0]===this.options.show.target[0];if(clearTimeout(this.timers.show),clearTimeout(this.timers.hide),this!==b[0]&&"mouse"===this.options.position.target&&c||this.options.hide.fixed&&/mouse(out|leave|move)/.test(a.type)&&(c||e))try{a.preventDefault(),a.stopImmediatePropagation()}catch(f){}else this.timers.hide=l.call(this,function(){this.toggle(E,a)},this.options.hide.delay,this)}}function o(a){!this.tooltip.hasClass(aa)&&this.options.hide.inactive&&(clearTimeout(this.timers.inactive),this.timers.inactive=l.call(this,function(){this.hide(a)},this.options.hide.inactive))}function p(a){this.rendered&&this.tooltip[0].offsetWidth>0&&this.reposition(a)}function q(a,c,e){d(b.body).delegate(a,(c.split?c:c.join("."+S+" "))+"."+S,function(){var a=y.api[d.attr(this,U)];a&&!a.disabled&&e.apply(a,arguments)})}function r(a,c,f){var g,i,j,k,l,m=d(b.body),n=a[0]===b?m:a,o=a.metadata?a.metadata(f.metadata):F,p="html5"===f.metadata.type&&o?o[f.metadata.name]:F,q=a.data(f.metadata.name||"qtipopts");try{q="string"==typeof q?d.parseJSON(q):q}catch(r){}if(k=d.extend(D,{},y.defaults,f,"object"==typeof q?h(q):F,h(p||o)),i=k.position,k.id=c,"boolean"==typeof k.content.text){if(j=a.attr(k.content.attr),k.content.attr===E||!j)return E;k.content.text=j}if(i.container.length||(i.container=m),i.target===E&&(i.target=n),k.show.target===E&&(k.show.target=n),k.show.solo===D&&(k.show.solo=i.container.closest("body")),k.hide.target===E&&(k.hide.target=n),k.position.viewport===D&&(k.position.viewport=i.container),i.container=i.container.eq(0),i.at=new A(i.at,D),i.my=new A(i.my),a.data(S))if(k.overwrite)a.qtip("destroy",!0);else if(k.overwrite===E)return E;return a.attr(T,c),k.suppress&&(l=a.attr("title"))&&a.removeAttr("title").attr(ca,l).attr("title",""),g=new e(a,k,c,!!j),a.data(S,g),g}function s(a){return a.charAt(0).toUpperCase()+a.slice(1)}function t(a,b){var d,e,f=b.charAt(0).toUpperCase()+b.slice(1),g=(b+" "+va.join(f+" ")+f).split(" "),h=0;if(ua[b])return a.css(ua[b]);for(;d=g[h++];)if((e=a.css(d))!==c)return ua[b]=d,e}function u(a,b){return Math.ceil(parseFloat(t(a,b)))}function v(a,b){this._ns="tip",this.options=b,this.offset=b.offset,this.size=[b.width,b.height],this.qtip=a,this.init(a)}function w(a,b){this.options=b,this._ns="-modal",this.qtip=a,this.init(a)}function x(a){this._ns="ie6",this.qtip=a,this.init(a)}var y,z,A,B,C,D=!0,E=!1,F=null,G="x",H="y",I="width",J="height",K="top",L="left",M="bottom",N="right",O="center",P="flipinvert",Q="shift",R={},S="qtip",T="data-hasqtip",U="data-qtip-id",V=["ui-widget","ui-tooltip"],W="."+S,X="click dblclick mousedown mouseup mousemove mouseleave mouseenter".split(" "),Y=S+"-fixed",Z=S+"-default",$=S+"-focus",_=S+"-hover",aa=S+"-disabled",ba="_replacedByqTip",ca="oldtitle",da={ie:function(){var a,c;for(a=4,c=b.createElement("div");(c.innerHTML="")&&c.getElementsByTagName("i")[0];a+=1);return a>4?a:NaN}(),iOS:parseFloat((""+(/CPU.*OS ([0-9_]{1,5})|(CPU like).*AppleWebKit.*Mobile/i.exec(navigator.userAgent)||[0,""])[1]).replace("undefined","3_2").replace("_",".").replace("_",""))||E};z=e.prototype,z._when=function(a){return d.when.apply(d,a)},z.render=function(a){if(this.rendered||this.destroyed)return this;var b=this,c=this.options,e=this.cache,f=this.elements,g=c.content.text,h=c.content.title,i=c.content.button,j=c.position,k=[];return d.attr(this.target[0],"aria-describedby",this._id),e.posClass=this._createPosClass((this.position={my:j.my,at:j.at}).my),this.tooltip=f.tooltip=d("
",{id:this._id,"class":[S,Z,c.style.classes,e.posClass].join(" "),width:c.style.width||"",height:c.style.height||"",tracking:"mouse"===j.target&&j.adjust.mouse,role:"alert","aria-live":"polite","aria-atomic":E,"aria-describedby":this._id+"-content","aria-hidden":D}).toggleClass(aa,this.disabled).attr(U,this.id).data(S,this).appendTo(j.container).append(f.content=d("
",{"class":S+"-content",id:this._id+"-content","aria-atomic":D})),this.rendered=-1,this.positioning=D,h&&(this._createTitle(),d.isFunction(h)||k.push(this._updateTitle(h,E))),i&&this._createButton(),d.isFunction(g)||k.push(this._updateContent(g,E)),this.rendered=D,this._setWidget(),d.each(R,function(a){var c;"render"===this.initialize&&(c=this(b))&&(b.plugins[a]=c)}),this._unassignEvents(),this._assignEvents(),this._when(k).then(function(){b._trigger("render"),b.positioning=E,b.hiddenDuringWait||!c.show.ready&&!a||b.toggle(D,e.event,E),b.hiddenDuringWait=E}),y.api[this.id]=this,this},z.destroy=function(a){function b(){if(!this.destroyed){this.destroyed=D;var a,b=this.target,c=b.attr(ca);this.rendered&&this.tooltip.stop(1,0).find("*").remove().end().remove(),d.each(this.plugins,function(){this.destroy&&this.destroy()});for(a in this.timers)this.timers.hasOwnProperty(a)&&clearTimeout(this.timers[a]);b.removeData(S).removeAttr(U).removeAttr(T).removeAttr("aria-describedby"),this.options.suppress&&c&&b.attr("title",c).removeAttr(ca),this._unassignEvents(),this.options=this.elements=this.cache=this.timers=this.plugins=this.mouse=F,delete y.api[this.id]}}return this.destroyed?this.target:(a===D&&"hide"!==this.triggering||!this.rendered?b.call(this):(this.tooltip.one("tooltiphidden",d.proxy(b,this)),!this.triggering&&this.hide()),this.target)},B=z.checks={builtin:{"^id$":function(a,b,c,e){var f=c===D?y.nextid:c,g=S+"-"+f;f!==E&&f.length>0&&!d("#"+g).length?(this._id=g,this.rendered&&(this.tooltip[0].id=this._id,this.elements.content[0].id=this._id+"-content",this.elements.title[0].id=this._id+"-title")):a[b]=e},"^prerender":function(a,b,c){c&&!this.rendered&&this.render(this.options.show.ready)},"^content.text$":function(a,b,c){this._updateContent(c)},"^content.attr$":function(a,b,c,d){this.options.content.text===this.target.attr(d)&&this._updateContent(this.target.attr(c))},"^content.title$":function(a,b,c){return c?(c&&!this.elements.title&&this._createTitle(),void this._updateTitle(c)):this._removeTitle()},"^content.button$":function(a,b,c){this._updateButton(c)},"^content.title.(text|button)$":function(a,b,c){this.set("content."+b,c)},"^position.(my|at)$":function(a,b,c){"string"==typeof c&&(this.position[b]=a[b]=new A(c,"at"===b))},"^position.container$":function(a,b,c){this.rendered&&this.tooltip.appendTo(c)},"^show.ready$":function(a,b,c){c&&(!this.rendered&&this.render(D)||this.toggle(D))},"^style.classes$":function(a,b,c,d){this.rendered&&this.tooltip.removeClass(d).addClass(c)},"^style.(width|height)":function(a,b,c){this.rendered&&this.tooltip.css(b,c)},"^style.widget|content.title":function(){this.rendered&&this._setWidget()},"^style.def":function(a,b,c){this.rendered&&this.tooltip.toggleClass(Z,!!c)},"^events.(render|show|move|hide|focus|blur)$":function(a,b,c){this.rendered&&this.tooltip[(d.isFunction(c)?"":"un")+"bind"]("tooltip"+b,c)},"^(show|hide|position).(event|target|fixed|inactive|leave|distance|viewport|adjust)":function(){if(this.rendered){var a=this.options.position;this.tooltip.attr("tracking","mouse"===a.target&&a.adjust.mouse),this._unassignEvents(),this._assignEvents()}}}},z.get=function(a){if(this.destroyed)return this;var b=i(this.options,a.toLowerCase()),c=b[0][b[1]];return c.precedance?c.string():c};var ea=/^position\.(my|at|adjust|target|container|viewport)|style|content|show\.ready/i,fa=/^prerender|show\.ready/i;z.set=function(a,b){if(this.destroyed)return this;var c,e=this.rendered,f=E,g=this.options;return"string"==typeof a?(c=a,a={},a[c]=b):a=d.extend({},a),d.each(a,function(b,c){if(e&&fa.test(b))return void delete a[b];var h,j=i(g,b.toLowerCase());h=j[0][j[1]],j[0][j[1]]=c&&c.nodeType?d(c):c,f=ea.test(b)||f,a[b]=[j[0],j[1],c,h]}),h(g),this.positioning=D,d.each(a,d.proxy(j,this)),this.positioning=E,this.rendered&&this.tooltip[0].offsetWidth>0&&f&&this.reposition("mouse"===g.position.target?F:this.cache.event),this},z._update=function(a,b){var c=this,e=this.cache;return this.rendered&&a?(d.isFunction(a)&&(a=a.call(this.elements.target,e.event,this)||""),d.isFunction(a.then)?(e.waiting=D,a.then(function(a){return e.waiting=E,c._update(a,b)},F,function(a){return c._update(a,b)})):a===E||!a&&""!==a?E:(a.jquery&&a.length>0?b.empty().append(a.css({display:"block",visibility:"visible"})):b.html(a),this._waitForContent(b).then(function(a){c.rendered&&c.tooltip[0].offsetWidth>0&&c.reposition(e.event,!a.length)}))):E},z._waitForContent=function(a){var b=this.cache;return b.waiting=D,(d.fn.imagesLoaded?a.imagesLoaded():(new d.Deferred).resolve([])).done(function(){b.waiting=E}).promise()},z._updateContent=function(a,b){this._update(a,this.elements.content,b)},z._updateTitle=function(a,b){this._update(a,this.elements.title,b)===E&&this._removeTitle(E)},z._createTitle=function(){var a=this.elements,b=this._id+"-title";a.titlebar&&this._removeTitle(),a.titlebar=d("
",{"class":S+"-titlebar "+(this.options.style.widget?k("header"):"")}).append(a.title=d("
",{id:b,"class":S+"-title","aria-atomic":D})).insertBefore(a.content).delegate(".qtip-close","mousedown keydown mouseup keyup mouseout",function(a){d(this).toggleClass("ui-state-active ui-state-focus","down"===a.type.substr(-4))}).delegate(".qtip-close","mouseover mouseout",function(a){d(this).toggleClass("ui-state-hover","mouseover"===a.type)}),this.options.content.button&&this._createButton()},z._removeTitle=function(a){var b=this.elements;b.title&&(b.titlebar.remove(),b.titlebar=b.title=b.button=F,a!==E&&this.reposition())},z._createPosClass=function(a){return S+"-pos-"+(a||this.options.position.my).abbrev()},z.reposition=function(c,e){if(!this.rendered||this.positioning||this.destroyed)return this;this.positioning=D;var f,g,h,i,j=this.cache,k=this.tooltip,l=this.options.position,m=l.target,n=l.my,o=l.at,p=l.viewport,q=l.container,r=l.adjust,s=r.method.split(" "),t=k.outerWidth(E),u=k.outerHeight(E),v=0,w=0,x=k.css("position"),y={left:0,top:0},z=k[0].offsetWidth>0,A=c&&"scroll"===c.type,B=d(a),C=q[0].ownerDocument,F=this.mouse;if(d.isArray(m)&&2===m.length)o={x:L,y:K},y={left:m[0],top:m[1]};else if("mouse"===m)o={x:L,y:K},(!r.mouse||this.options.hide.distance)&&j.origin&&j.origin.pageX?c=j.origin:!c||c&&("resize"===c.type||"scroll"===c.type)?c=j.event:F&&F.pageX&&(c=F),"static"!==x&&(y=q.offset()),C.body.offsetWidth!==(a.innerWidth||C.documentElement.clientWidth)&&(g=d(b.body).offset()),y={left:c.pageX-y.left+(g&&g.left||0),top:c.pageY-y.top+(g&&g.top||0)},r.mouse&&A&&F&&(y.left-=(F.scrollX||0)-B.scrollLeft(),y.top-=(F.scrollY||0)-B.scrollTop());else{if("event"===m?c&&c.target&&"scroll"!==c.type&&"resize"!==c.type?j.target=d(c.target):c.target||(j.target=this.elements.target):"event"!==m&&(j.target=d(m.jquery?m:this.elements.target)),m=j.target,m=d(m).eq(0),0===m.length)return this;m[0]===b||m[0]===a?(v=da.iOS?a.innerWidth:m.width(),w=da.iOS?a.innerHeight:m.height(),m[0]===a&&(y={top:(p||m).scrollTop(),left:(p||m).scrollLeft()})):R.imagemap&&m.is("area")?f=R.imagemap(this,m,o,R.viewport?s:E):R.svg&&m&&m[0].ownerSVGElement?f=R.svg(this,m,o,R.viewport?s:E):(v=m.outerWidth(E),w=m.outerHeight(E),y=m.offset()),f&&(v=f.width,w=f.height,g=f.offset,y=f.position),y=this.reposition.offset(m,y,q),(da.iOS>3.1&&da.iOS<4.1||da.iOS>=4.3&&da.iOS<4.33||!da.iOS&&"fixed"===x)&&(y.left-=B.scrollLeft(),y.top-=B.scrollTop()),(!f||f&&f.adjustable!==E)&&(y.left+=o.x===N?v:o.x===O?v/2:0,y.top+=o.y===M?w:o.y===O?w/2:0)}return y.left+=r.x+(n.x===N?-t:n.x===O?-t/2:0),y.top+=r.y+(n.y===M?-u:n.y===O?-u/2:0),R.viewport?(h=y.adjusted=R.viewport(this,y,l,v,w,t,u),g&&h.left&&(y.left+=g.left),g&&h.top&&(y.top+=g.top),h.my&&(this.position.my=h.my)):y.adjusted={left:0,top:0},j.posClass!==(i=this._createPosClass(this.position.my))&&(j.posClass=i,k.removeClass(j.posClass).addClass(i)),this._trigger("move",[y,p.elem||p],c)?(delete y.adjusted,e===E||!z||isNaN(y.left)||isNaN(y.top)||"mouse"===m||!d.isFunction(l.effect)?k.css(y):d.isFunction(l.effect)&&(l.effect.call(k,this,d.extend({},y)),k.queue(function(a){d(this).css({opacity:"",height:""}),da.ie&&this.style.removeAttribute("filter"),a()})),this.positioning=E,this):this},z.reposition.offset=function(a,c,e){function f(a,b){c.left+=b*a.scrollLeft(),c.top+=b*a.scrollTop()}if(!e[0])return c;var g,h,i,j,k=d(a[0].ownerDocument),l=!!da.ie&&"CSS1Compat"!==b.compatMode,m=e[0];do"static"!==(h=d.css(m,"position"))&&("fixed"===h?(i=m.getBoundingClientRect(),f(k,-1)):(i=d(m).position(),i.left+=parseFloat(d.css(m,"borderLeftWidth"))||0,i.top+=parseFloat(d.css(m,"borderTopWidth"))||0),c.left-=i.left+(parseFloat(d.css(m,"marginLeft"))||0),c.top-=i.top+(parseFloat(d.css(m,"marginTop"))||0),g||"hidden"===(j=d.css(m,"overflow"))||"visible"===j||(g=d(m)));while(m=m.offsetParent);return g&&(g[0]!==k[0]||l)&&f(g,1),c};var ga=(A=z.reposition.Corner=function(a,b){a=(""+a).replace(/([A-Z])/," $1").replace(/middle/gi,O).toLowerCase(),this.x=(a.match(/left|right/i)||a.match(/center/)||["inherit"])[0].toLowerCase(),this.y=(a.match(/top|bottom|center/i)||["inherit"])[0].toLowerCase(),this.forceY=!!b;var c=a.charAt(0);this.precedance="t"===c||"b"===c?H:G}).prototype;ga.invert=function(a,b){this[a]=this[a]===L?N:this[a]===N?L:b||this[a]},ga.string=function(a){var b=this.x,c=this.y,d=b!==c?"center"===b||"center"!==c&&(this.precedance===H||this.forceY)?[c,b]:[b,c]:[b];return a!==!1?d.join(" "):d},ga.abbrev=function(){var a=this.string(!1);return a[0].charAt(0)+(a[1]&&a[1].charAt(0)||"")},ga.clone=function(){return new A(this.string(),this.forceY)},z.toggle=function(a,c){var e=this.cache,f=this.options,g=this.tooltip;if(c){if(/over|enter/.test(c.type)&&e.event&&/out|leave/.test(e.event.type)&&f.show.target.add(c.target).length===f.show.target.length&&g.has(c.relatedTarget).length)return this;e.event=d.event.fix(c)}if(this.waiting&&!a&&(this.hiddenDuringWait=D),!this.rendered)return a?this.render(1):this;if(this.destroyed||this.disabled)return this;var h,i,j,k=a?"show":"hide",l=this.options[k],m=this.options.position,n=this.options.content,o=this.tooltip.css("width"),p=this.tooltip.is(":visible"),q=a||1===l.target.length,r=!c||l.target.length<2||e.target[0]===c.target;return(typeof a).search("boolean|number")&&(a=!p),h=!g.is(":animated")&&p===a&&r,i=h?F:!!this._trigger(k,[90]),this.destroyed?this:(i!==E&&a&&this.focus(c),!i||h?this:(d.attr(g[0],"aria-hidden",!a),a?(this.mouse&&(e.origin=d.event.fix(this.mouse)),d.isFunction(n.text)&&this._updateContent(n.text,E),d.isFunction(n.title)&&this._updateTitle(n.title,E),!C&&"mouse"===m.target&&m.adjust.mouse&&(d(b).bind("mousemove."+S,this._storeMouse),C=D),o||g.css("width",g.outerWidth(E)),this.reposition(c,arguments[2]),o||g.css("width",""),l.solo&&("string"==typeof l.solo?d(l.solo):d(W,l.solo)).not(g).not(l.target).qtip("hide",new d.Event("tooltipsolo"))):(clearTimeout(this.timers.show),delete e.origin,C&&!d(W+'[tracking="true"]:visible',l.solo).not(g).length&&(d(b).unbind("mousemove."+S),C=E),this.blur(c)),j=d.proxy(function(){a?(da.ie&&g[0].style.removeAttribute("filter"),g.css("overflow",""),"string"==typeof l.autofocus&&d(this.options.show.autofocus,g).focus(),this.options.show.target.trigger("qtip-"+this.id+"-inactive")):g.css({display:"",visibility:"",opacity:"",left:"",top:""}),this._trigger(a?"visible":"hidden")},this),l.effect===E||q===E?(g[k](),j()):d.isFunction(l.effect)?(g.stop(1,1),l.effect.call(g,this),g.queue("fx",function(a){j(),a()})):g.fadeTo(90,a?1:0,j),a&&l.target.trigger("qtip-"+this.id+"-inactive"),this))},z.show=function(a){return this.toggle(D,a)},z.hide=function(a){return this.toggle(E,a)},z.focus=function(a){if(!this.rendered||this.destroyed)return this;var b=d(W),c=this.tooltip,e=parseInt(c[0].style.zIndex,10),f=y.zindex+b.length;return c.hasClass($)||this._trigger("focus",[f],a)&&(e!==f&&(b.each(function(){this.style.zIndex>e&&(this.style.zIndex=this.style.zIndex-1)}),b.filter("."+$).qtip("blur",a)),c.addClass($)[0].style.zIndex=f),this},z.blur=function(a){return!this.rendered||this.destroyed?this:(this.tooltip.removeClass($),this._trigger("blur",[this.tooltip.css("zIndex")],a),this)},z.disable=function(a){return this.destroyed?this:("toggle"===a?a=!(this.rendered?this.tooltip.hasClass(aa):this.disabled):"boolean"!=typeof a&&(a=D),this.rendered&&this.tooltip.toggleClass(aa,a).attr("aria-disabled",a),this.disabled=!!a,this)},z.enable=function(){return this.disable(E)},z._createButton=function(){var a=this,b=this.elements,c=b.tooltip,e=this.options.content.button,f="string"==typeof e,g=f?e:"Close tooltip";b.button&&b.button.remove(),e.jquery?b.button=e:b.button=d("",{"class":"qtip-close "+(this.options.style.widget?"":S+"-icon"),title:g,"aria-label":g}).prepend(d("",{"class":"ui-icon ui-icon-close",html:"×"})),b.button.appendTo(b.titlebar||c).attr("role","button").click(function(b){return c.hasClass(aa)||a.hide(b),E})},z._updateButton=function(a){if(!this.rendered)return E;var b=this.elements.button;a?this._createButton():b.remove()},z._setWidget=function(){var a=this.options.style.widget,b=this.elements,c=b.tooltip,d=c.hasClass(aa);c.removeClass(aa),aa=a?"ui-state-disabled":"qtip-disabled",c.toggleClass(aa,d),c.toggleClass("ui-helper-reset "+k(),a).toggleClass(Z,this.options.style.def&&!a),b.content&&b.content.toggleClass(k("content"),a),b.titlebar&&b.titlebar.toggleClass(k("header"),a),b.button&&b.button.toggleClass(S+"-icon",!a)},z._storeMouse=function(a){return(this.mouse=d.event.fix(a)).type="mousemove",this},z._bind=function(a,b,c,e,f){if(a&&c&&b.length){var g="."+this._id+(e?"-"+e:"");return d(a).bind((b.split?b:b.join(g+" "))+g,d.proxy(c,f||this)),this}},z._unbind=function(a,b){return a&&d(a).unbind("."+this._id+(b?"-"+b:"")),this},z._trigger=function(a,b,c){var e=new d.Event("tooltip"+a);return e.originalEvent=c&&d.extend({},c)||this.cache.event||F,this.triggering=a,this.tooltip.trigger(e,[this].concat(b||[])),this.triggering=E,!e.isDefaultPrevented()},z._bindEvents=function(a,b,c,e,f,g){var h=c.filter(e).add(e.filter(c)),i=[];h.length&&(d.each(b,function(b,c){var e=d.inArray(c,a);e>-1&&i.push(a.splice(e,1)[0])}),i.length&&(this._bind(h,i,function(a){var b=this.rendered?this.tooltip[0].offsetWidth>0:!1;(b?g:f).call(this,a)}),c=c.not(h),e=e.not(h))),this._bind(c,a,f),this._bind(e,b,g)},z._assignInitialEvents=function(a){function b(a){return this.disabled||this.destroyed?E:(this.cache.event=a&&d.event.fix(a),this.cache.target=a&&d(a.target),clearTimeout(this.timers.show),void(this.timers.show=l.call(this,function(){this.render("object"==typeof a||c.show.ready)},c.prerender?0:c.show.delay)))}var c=this.options,e=c.show.target,f=c.hide.target,g=c.show.event?d.trim(""+c.show.event).split(" "):[],h=c.hide.event?d.trim(""+c.hide.event).split(" "):[];this._bind(this.elements.target,["remove","removeqtip"],function(){this.destroy(!0)},"destroy"),/mouse(over|enter)/i.test(c.show.event)&&!/mouse(out|leave)/i.test(c.hide.event)&&h.push("mouseleave"),this._bind(e,"mousemove",function(a){this._storeMouse(a),this.cache.onTarget=D}),this._bindEvents(g,h,e,f,b,function(){return this.timers?void clearTimeout(this.timers.show):E}),(c.show.ready||c.prerender)&&b.call(this,a)},z._assignEvents=function(){var c=this,e=this.options,f=e.position,g=this.tooltip,h=e.show.target,i=e.hide.target,j=f.container,k=f.viewport,l=d(b),q=d(a),r=e.show.event?d.trim(""+e.show.event).split(" "):[],s=e.hide.event?d.trim(""+e.hide.event).split(" "):[];d.each(e.events,function(a,b){c._bind(g,"toggle"===a?["tooltipshow","tooltiphide"]:["tooltip"+a],b,null,g)}),/mouse(out|leave)/i.test(e.hide.event)&&"window"===e.hide.leave&&this._bind(l,["mouseout","blur"],function(a){/select|option/.test(a.target.nodeName)||a.relatedTarget||this.hide(a)}),e.hide.fixed?i=i.add(g.addClass(Y)):/mouse(over|enter)/i.test(e.show.event)&&this._bind(i,"mouseleave",function(){clearTimeout(this.timers.show)}),(""+e.hide.event).indexOf("unfocus")>-1&&this._bind(j.closest("html"),["mousedown","touchstart"],function(a){var b=d(a.target),c=this.rendered&&!this.tooltip.hasClass(aa)&&this.tooltip[0].offsetWidth>0,e=b.parents(W).filter(this.tooltip[0]).length>0;b[0]===this.target[0]||b[0]===this.tooltip[0]||e||this.target.has(b[0]).length||!c||this.hide(a)}),"number"==typeof e.hide.inactive&&(this._bind(h,"qtip-"+this.id+"-inactive",o,"inactive"),this._bind(i.add(g),y.inactiveEvents,o)),this._bindEvents(r,s,h,i,m,n),this._bind(h.add(g),"mousemove",function(a){if("number"==typeof e.hide.distance){var b=this.cache.origin||{},c=this.options.hide.distance,d=Math.abs;(d(a.pageX-b.pageX)>=c||d(a.pageY-b.pageY)>=c)&&this.hide(a)}this._storeMouse(a)}),"mouse"===f.target&&f.adjust.mouse&&(e.hide.event&&this._bind(h,["mouseenter","mouseleave"],function(a){return this.cache?void(this.cache.onTarget="mouseenter"===a.type):E}),this._bind(l,"mousemove",function(a){this.rendered&&this.cache.onTarget&&!this.tooltip.hasClass(aa)&&this.tooltip[0].offsetWidth>0&&this.reposition(a)})),(f.adjust.resize||k.length)&&this._bind(d.event.special.resize?k:q,"resize",p),f.adjust.scroll&&this._bind(q.add(f.container),"scroll",p)},z._unassignEvents=function(){var c=this.options,e=c.show.target,f=c.hide.target,g=d.grep([this.elements.target[0],this.rendered&&this.tooltip[0],c.position.container[0],c.position.viewport[0],c.position.container.closest("html")[0],a,b],function(a){return"object"==typeof a});e&&e.toArray&&(g=g.concat(e.toArray())),f&&f.toArray&&(g=g.concat(f.toArray())),this._unbind(g)._unbind(g,"destroy")._unbind(g,"inactive")},d(function(){q(W,["mouseenter","mouseleave"],function(a){var b="mouseenter"===a.type,c=d(a.currentTarget),e=d(a.relatedTarget||a.target),f=this.options;b?(this.focus(a),c.hasClass(Y)&&!c.hasClass(aa)&&clearTimeout(this.timers.hide)):"mouse"===f.position.target&&f.position.adjust.mouse&&f.hide.event&&f.show.target&&!e.closest(f.show.target[0]).length&&this.hide(a),c.toggleClass(_,b)}),q("["+U+"]",X,o)}),y=d.fn.qtip=function(a,b,e){var f=(""+a).toLowerCase(),g=F,i=d.makeArray(arguments).slice(1),j=i[i.length-1],k=this[0]?d.data(this[0],S):F;return!arguments.length&&k||"api"===f?k:"string"==typeof a?(this.each(function(){var a=d.data(this,S);if(!a)return D;if(j&&j.timeStamp&&(a.cache.event=j),!b||"option"!==f&&"options"!==f)a[f]&&a[f].apply(a,i);else{if(e===c&&!d.isPlainObject(b))return g=a.get(b),E;a.set(b,e)}}),g!==F?g:this):"object"!=typeof a&&arguments.length?void 0:(k=h(d.extend(D,{},a)),this.each(function(a){var b,c;return c=d.isArray(k.id)?k.id[a]:k.id,c=!c||c===E||c.length<1||y.api[c]?y.nextid++:c,b=r(d(this),c,k),b===E?D:(y.api[c]=b,d.each(R,function(){"initialize"===this.initialize&&this(b)}),void b._assignInitialEvents(j))}))},d.qtip=e,y.api={},d.each({attr:function(a,b){if(this.length){var c=this[0],e="title",f=d.data(c,"qtip");if(a===e&&f&&f.options&&"object"==typeof f&&"object"==typeof f.options&&f.options.suppress)return arguments.length<2?d.attr(c,ca):(f&&f.options.content.attr===e&&f.cache.attr&&f.set("content.text",b),this.attr(ca,b))}return d.fn["attr"+ba].apply(this,arguments)},clone:function(a){var b=d.fn["clone"+ba].apply(this,arguments);return a||b.filter("["+ca+"]").attr("title",function(){return d.attr(this,ca)}).removeAttr(ca),b}},function(a,b){if(!b||d.fn[a+ba])return D;var c=d.fn[a+ba]=d.fn[a];d.fn[a]=function(){return b.apply(this,arguments)||c.apply(this,arguments)}}),d.ui||(d["cleanData"+ba]=d.cleanData,d.cleanData=function(a){for(var b,c=0;(b=d(a[c])).length;c++)if(b.attr(T))try{b.triggerHandler("removeqtip")}catch(e){}d["cleanData"+ba].apply(this,arguments)}),y.version="3.0.3",y.nextid=0,y.inactiveEvents=X,y.zindex=15e3,y.defaults={prerender:E,id:E,overwrite:D,suppress:D,content:{text:D,attr:"title",title:E,button:E},position:{my:"top left",at:"bottom right",target:E,container:E,viewport:E,adjust:{x:0,y:0,mouse:D,scroll:D,resize:D,method:"flipinvert flipinvert"},effect:function(a,b){d(this).animate(b,{duration:200,queue:E})}},show:{target:E,event:"mouseenter",effect:D,delay:90,solo:E,ready:E,autofocus:E},hide:{target:E,event:"mouseleave",effect:D,delay:0,fixed:E,inactive:E,leave:"window",distance:E},style:{classes:"",widget:E,width:E,height:E,def:D},events:{render:F,move:F,show:F,hide:F,toggle:F,visible:F,hidden:F,focus:F,blur:F}};var ha,ia,ja,ka,la,ma="margin",na="border",oa="color",pa="background-color",qa="transparent",ra=" !important",sa=!!b.createElement("canvas").getContext,ta=/rgba?\(0, 0, 0(, 0)?\)|transparent|#123456/i,ua={},va=["Webkit","O","Moz","ms"];sa?(ka=a.devicePixelRatio||1,la=function(){var a=b.createElement("canvas").getContext("2d");return a.backingStorePixelRatio||a.webkitBackingStorePixelRatio||a.mozBackingStorePixelRatio||a.msBackingStorePixelRatio||a.oBackingStorePixelRatio||1}(),ja=ka/la):ia=function(a,b,c){return"'},d.extend(v.prototype,{init:function(a){var b,c;c=this.element=a.elements.tip=d("
",{"class":S+"-tip"}).prependTo(a.tooltip),sa?(b=d("").appendTo(this.element)[0].getContext("2d"),b.lineJoin="miter",b.miterLimit=1e5,b.save()):(b=ia("shape",'coordorigin="0,0"',"position:absolute;"),this.element.html(b+b),a._bind(d("*",c).add(c),["click","mousedown"],function(a){a.stopPropagation()},this._ns)),a._bind(a.tooltip,"tooltipmove",this.reposition,this._ns,this),this.create()},_swapDimensions:function(){this.size[0]=this.options.height,this.size[1]=this.options.width},_resetDimensions:function(){this.size[0]=this.options.width,this.size[1]=this.options.height},_useTitle:function(a){var b=this.qtip.elements.titlebar;return b&&(a.y===K||a.y===O&&this.element.position().top+this.size[1]/2+this.options.offsetl&&!ta.test(e[1])&&(e[0]=e[1]),this.border=l=p.border!==D?p.border:l):this.border=l=0,k=this.size=this._calculateSize(b),n.css({width:k[0],height:k[1],lineHeight:k[1]+"px"}),j=b.precedance===H?[s(r.x===L?l:r.x===N?k[0]-q[0]-l:(k[0]-q[0])/2),s(r.y===K?k[1]-q[1]:0)]:[s(r.x===L?k[0]-q[0]:0),s(r.y===K?l:r.y===M?k[1]-q[1]-l:(k[1]-q[1])/2)],sa?(g=o[0].getContext("2d"),g.restore(),g.save(),g.clearRect(0,0,6e3,6e3),h=this._calculateTip(r,q,ja),i=this._calculateTip(r,this.size,ja),o.attr(I,k[0]*ja).attr(J,k[1]*ja),o.css(I,k[0]).css(J,k[1]),this._drawCoords(g,i),g.fillStyle=e[1],g.fill(),g.translate(j[0]*ja,j[1]*ja),this._drawCoords(g,h),g.fillStyle=e[0],g.fill()):(h=this._calculateTip(r),h="m"+h[0]+","+h[1]+" l"+h[2]+","+h[3]+" "+h[4]+","+h[5]+" xe",j[2]=l&&/^(r|b)/i.test(b.string())?8===da.ie?2:1:0,o.css({coordsize:k[0]+l+" "+k[1]+l,antialias:""+(r.string().indexOf(O)>-1),left:j[0]-j[2]*Number(f===G),top:j[1]-j[2]*Number(f===H),width:k[0]+l,height:k[1]+l}).each(function(a){var b=d(this);b[b.prop?"prop":"attr"]({coordsize:k[0]+l+" "+k[1]+l,path:h,fillcolor:e[0],filled:!!a,stroked:!a}).toggle(!(!l&&!a)),!a&&b.html(ia("stroke",'weight="'+2*l+'px" color="'+e[1]+'" miterlimit="1000" joinstyle="miter"'))})),a.opera&&setTimeout(function(){m.tip.css({display:"inline-block",visibility:"visible"})},1),c!==E&&this.calculate(b,k)},calculate:function(a,b){if(!this.enabled)return E;var c,e,f=this,g=this.qtip.elements,h=this.element,i=this.options.offset,j={}; -return a=a||this.corner,c=a.precedance,b=b||this._calculateSize(a),e=[a.x,a.y],c===G&&e.reverse(),d.each(e,function(d,e){var h,k,l;e===O?(h=c===H?L:K,j[h]="50%",j[ma+"-"+h]=-Math.round(b[c===H?0:1]/2)+i):(h=f._parseWidth(a,e,g.tooltip),k=f._parseWidth(a,e,g.content),l=f._parseRadius(a),j[e]=Math.max(-f.border,d?k:i+(l>h?l:-h)))}),j[a[c]]-=b[c===G?0:1],h.css({margin:"",top:"",bottom:"",left:"",right:""}).css(j),j},reposition:function(a,b,d){function e(a,b,c,d,e){a===Q&&j.precedance===b&&k[d]&&j[c]!==O?j.precedance=j.precedance===G?H:G:a!==Q&&k[d]&&(j[b]=j[b]===O?k[d]>0?d:e:j[b]===d?e:d)}function f(a,b,e){j[a]===O?p[ma+"-"+b]=o[a]=g[ma+"-"+b]-k[b]:(h=g[e]!==c?[k[b],-g[b]]:[-k[b],g[b]],(o[a]=Math.max(h[0],h[1]))>h[0]&&(d[b]-=k[b],o[b]=E),p[g[e]!==c?e:b]=o[a])}if(this.enabled){var g,h,i=b.cache,j=this.corner.clone(),k=d.adjusted,l=b.options.position.adjust.method.split(" "),m=l[0],n=l[1]||l[0],o={left:E,top:E,x:0,y:0},p={};this.corner.fixed!==D&&(e(m,G,H,L,N),e(n,H,G,K,M),j.string()===i.corner.string()&&i.cornerTop===k.top&&i.cornerLeft===k.left||this.update(j,E)),g=this.calculate(j),g.right!==c&&(g.left=-g.right),g.bottom!==c&&(g.top=-g.bottom),g.user=this.offset,o.left=m===Q&&!!k.left,o.left&&f(G,L,N),o.top=n===Q&&!!k.top,o.top&&f(H,K,M),this.element.css(p).toggle(!(o.x&&o.y||j.x===O&&o.y||j.y===O&&o.x)),d.left-=g.left.charAt?g.user:m!==Q||o.top||!o.left&&!o.top?g.left+this.border:0,d.top-=g.top.charAt?g.user:n!==Q||o.left||!o.left&&!o.top?g.top+this.border:0,i.cornerLeft=k.left,i.cornerTop=k.top,i.corner=j.clone()}},destroy:function(){this.qtip._unbind(this.qtip.tooltip,this._ns),this.qtip.elements.tip&&this.qtip.elements.tip.find("*").remove().end().remove()}}),ha=R.tip=function(a){return new v(a,a.options.style.tip)},ha.initialize="render",ha.sanitize=function(a){if(a.style&&"tip"in a.style){var b=a.style.tip;"object"!=typeof b&&(b=a.style.tip={corner:b}),/string|boolean/i.test(typeof b.corner)||(b.corner=D)}},B.tip={"^position.my|style.tip.(corner|mimic|border)$":function(){this.create(),this.qtip.reposition()},"^style.tip.(height|width)$":function(a){this.size=[a.width,a.height],this.update(),this.qtip.reposition()},"^content.title|style.(classes|widget)$":function(){this.update()}},d.extend(D,y.defaults,{style:{tip:{corner:D,mimic:E,width:6,height:6,border:D,offset:0}}});var wa,xa,ya="qtip-modal",za="."+ya;xa=function(){function a(a){if(d.expr[":"].focusable)return d.expr[":"].focusable;var b,c,e,f=!isNaN(d.attr(a,"tabindex")),g=a.nodeName&&a.nodeName.toLowerCase();return"area"===g?(b=a.parentNode,c=b.name,a.href&&c&&"map"===b.nodeName.toLowerCase()?(e=d("img[usemap=#"+c+"]")[0],!!e&&e.is(":visible")):!1):/input|select|textarea|button|object/.test(g)?!a.disabled:"a"===g?a.href||f:f}function c(a){j.length<1&&a.length?a.not("body").blur():j.first().focus()}function e(a){if(h.is(":visible")){var b,e=d(a.target),g=f.tooltip,i=e.closest(W);b=i.length<1?E:parseInt(i[0].style.zIndex,10)>parseInt(g[0].style.zIndex,10),b||e.closest(W)[0]===g[0]||c(e)}}var f,g,h,i=this,j={};d.extend(i,{init:function(){return h=i.elem=d("
",{id:"qtip-overlay",html:"
",mousedown:function(){return E}}).hide(),d(b.body).bind("focusin"+za,e),d(b).bind("keydown"+za,function(a){f&&f.options.show.modal.escape&&27===a.keyCode&&f.hide(a)}),h.bind("click"+za,function(a){f&&f.options.show.modal.blur&&f.hide(a)}),i},update:function(b){f=b,j=b.options.show.modal.stealfocus!==E?b.tooltip.find("*").filter(function(){return a(this)}):[]},toggle:function(a,e,j){var k=a.tooltip,l=a.options.show.modal,m=l.effect,n=e?"show":"hide",o=h.is(":visible"),p=d(za).filter(":visible:not(:animated)").not(k);return i.update(a),e&&l.stealfocus!==E&&c(d(":focus")),h.toggleClass("blurs",l.blur),e&&h.appendTo(b.body),h.is(":animated")&&o===e&&g!==E||!e&&p.length?i:(h.stop(D,E),d.isFunction(m)?m.call(h,e):m===E?h[n]():h.fadeTo(parseInt(j,10)||90,e?1:0,function(){e||h.hide()}),e||h.queue(function(a){h.css({left:"",top:""}),d(za).length||h.detach(),a()}),g=e,f.destroyed&&(f=F),i)}}),i.init()},xa=new xa,d.extend(w.prototype,{init:function(a){var b=a.tooltip;return this.options.on?(a.elements.overlay=xa.elem,b.addClass(ya).css("z-index",y.modal_zindex+d(za).length),a._bind(b,["tooltipshow","tooltiphide"],function(a,c,e){var f=a.originalEvent;if(a.target===b[0])if(f&&"tooltiphide"===a.type&&/mouse(leave|enter)/.test(f.type)&&d(f.relatedTarget).closest(xa.elem[0]).length)try{a.preventDefault()}catch(g){}else(!f||f&&"tooltipsolo"!==f.type)&&this.toggle(a,"tooltipshow"===a.type,e)},this._ns,this),a._bind(b,"tooltipfocus",function(a,c){if(!a.isDefaultPrevented()&&a.target===b[0]){var e=d(za),f=y.modal_zindex+e.length,g=parseInt(b[0].style.zIndex,10);xa.elem[0].style.zIndex=f-1,e.each(function(){this.style.zIndex>g&&(this.style.zIndex-=1)}),e.filter("."+$).qtip("blur",a.originalEvent),b.addClass($)[0].style.zIndex=f,xa.update(c);try{a.preventDefault()}catch(h){}}},this._ns,this),void a._bind(b,"tooltiphide",function(a){a.target===b[0]&&d(za).filter(":visible").not(b).last().qtip("focus",a)},this._ns,this)):this},toggle:function(a,b,c){return a&&a.isDefaultPrevented()?this:void xa.toggle(this.qtip,!!b,c)},destroy:function(){this.qtip.tooltip.removeClass(ya),this.qtip._unbind(this.qtip.tooltip,this._ns),xa.toggle(this.qtip,E),delete this.qtip.elements.overlay}}),wa=R.modal=function(a){return new w(a,a.options.show.modal)},wa.sanitize=function(a){a.show&&("object"!=typeof a.show.modal?a.show.modal={on:!!a.show.modal}:"undefined"==typeof a.show.modal.on&&(a.show.modal.on=D))},y.modal_zindex=y.zindex-200,wa.initialize="render",B.modal={"^show.modal.(on|blur)$":function(){this.destroy(),this.init(),this.qtip.elems.overlay.toggle(this.qtip.tooltip[0].offsetWidth>0)}},d.extend(D,y.defaults,{show:{modal:{on:E,effect:D,blur:D,stealfocus:D,escape:D}}}),R.viewport=function(c,d,e,f,g,h,i){function j(a,b,c,e,f,g,h,i,j){var k=d[f],s=u[a],t=v[a],w=c===Q,x=s===f?j:s===g?-j:-j/2,y=t===f?i:t===g?-i:-i/2,z=q[f]+r[f]-(n?0:m[f]),A=z-k,B=k+j-(h===I?o:p)-z,C=x-(u.precedance===a||s===u[b]?y:0)-(t===O?i/2:0);return w?(C=(s===f?1:-1)*x,d[f]+=A>0?A:B>0?-B:0,d[f]=Math.max(-m[f]+r[f],k-C,Math.min(Math.max(-m[f]+r[f]+(h===I?o:p),k+C),d[f],"center"===s?k-x:1e9))):(e*=c===P?2:0,A>0&&(s!==f||B>0)?(d[f]-=C+e,l.invert(a,f)):B>0&&(s!==g||A>0)&&(d[f]-=(s===O?-C:C)+e,l.invert(a,g)),d[f]B&&(d[f]=k,l=u.clone())),d[f]-k}var k,l,m,n,o,p,q,r,s=e.target,t=c.elements.tooltip,u=e.my,v=e.at,w=e.adjust,x=w.method.split(" "),y=x[0],z=x[1]||x[0],A=e.viewport,B=e.container,C={left:0,top:0};return A.jquery&&s[0]!==a&&s[0]!==b.body&&"none"!==w.method?(m=B.offset()||C,n="static"===B.css("position"),k="fixed"===t.css("position"),o=A[0]===a?A.width():A.outerWidth(E),p=A[0]===a?A.height():A.outerHeight(E),q={left:k?0:A.scrollLeft(),top:k?0:A.scrollTop()},r=A.offset()||C,"shift"===y&&"shift"===z||(l=u.clone()),C={left:"none"!==y?j(G,H,y,w.x,L,N,I,f,h):0,top:"none"!==z?j(H,G,z,w.y,K,M,J,g,i):0,my:l}):C},R.polys={polygon:function(a,b){var c,d,e,f={width:0,height:0,position:{top:1e10,right:0,bottom:0,left:1e10},adjustable:E},g=0,h=[],i=1,j=1,k=0,l=0;for(g=a.length;g--;)c=[parseInt(a[--g],10),parseInt(a[g+1],10)],c[0]>f.position.right&&(f.position.right=c[0]),c[0]f.position.bottom&&(f.position.bottom=c[1]),c[1]0&&e>0&&i>0&&j>0;)for(d=Math.floor(d/2),e=Math.floor(e/2),b.x===L?i=d:b.x===N?i=f.width-d:i+=Math.floor(d/2),b.y===K?j=e:b.y===M?j=f.height-e:j+=Math.floor(e/2),g=h.length;g--&&!(h.length<2);)k=h[g][0]-f.position.left,l=h[g][1]-f.position.top,(b.x===L&&k>=i||b.x===N&&i>=k||b.x===O&&(i>k||k>f.width-i)||b.y===K&&l>=j||b.y===M&&j>=l||b.y===O&&(j>l||l>f.height-j))&&h.splice(g,1);f.position={left:h[0][0],top:h[0][1]}}return f},rect:function(a,b,c,d){return{width:Math.abs(c-a),height:Math.abs(d-b),position:{left:Math.min(a,c),top:Math.min(b,d)}}},_angles:{tc:1.5,tr:7/4,tl:5/4,bc:.5,br:.25,bl:.75,rc:2,lc:1,c:0},ellipse:function(a,b,c,d,e){var f=R.polys._angles[e.abbrev()],g=0===f?0:c*Math.cos(f*Math.PI),h=d*Math.sin(f*Math.PI);return{width:2*c-Math.abs(g),height:2*d-Math.abs(h),position:{left:a+g,top:b+h},adjustable:E}},circle:function(a,b,c,d){return R.polys.ellipse(a,b,c,c,d)}},R.svg=function(a,c,e){for(var f,g,h,i,j,k,l,m,n,o=c[0],p=d(o.ownerSVGElement),q=o.ownerDocument,r=(parseInt(c.css("stroke-width"),10)||0)/2;!o.getBBox;)o=o.parentNode;if(!o.getBBox||!o.parentNode)return E;switch(o.nodeName){case"ellipse":case"circle":m=R.polys.ellipse(o.cx.baseVal.value,o.cy.baseVal.value,(o.rx||o.r).baseVal.value+r,(o.ry||o.r).baseVal.value+r,e);break;case"line":case"polygon":case"polyline":for(l=o.points||[{x:o.x1.baseVal.value,y:o.y1.baseVal.value},{x:o.x2.baseVal.value,y:o.y2.baseVal.value}],m=[],k=-1,i=l.numberOfItems||l.length;++k';d.extend(x.prototype,{_scroll:function(){var b=this.qtip.elements.overlay;b&&(b[0].style.top=d(a).scrollTop()+"px")},init:function(c){var e=c.tooltip;d("select, object").length<1&&(this.bgiframe=c.elements.bgiframe=d(Ba).appendTo(e),c._bind(e,"tooltipmove",this.adjustBGIFrame,this._ns,this)),this.redrawContainer=d("
",{id:S+"-rcontainer"}).appendTo(b.body),c.elements.overlay&&c.elements.overlay.addClass("qtipmodal-ie6fix")&&(c._bind(a,["scroll","resize"],this._scroll,this._ns,this),c._bind(e,["tooltipshow"],this._scroll,this._ns,this)),this.redraw()},adjustBGIFrame:function(){var a,b,c=this.qtip.tooltip,d={height:c.outerHeight(E),width:c.outerWidth(E)},e=this.qtip.plugins.tip,f=this.qtip.elements.tip;b=parseInt(c.css("borderLeftWidth"),10)||0,b={left:-b,top:-b},e&&f&&(a="x"===e.corner.precedance?[I,L]:[J,K],b[a[1]]-=f[a[0]]()),this.bgiframe.css(b).css(d)},redraw:function(){if(this.qtip.rendered<1||this.drawing)return this;var a,b,c,d,e=this.qtip.tooltip,f=this.qtip.options.style,g=this.qtip.options.position.container;return this.qtip.drawing=1,f.height&&e.css(J,f.height),f.width?e.css(I,f.width):(e.css(I,"").appendTo(this.redrawContainer),b=e.width(),1>b%2&&(b+=1),c=e.css("maxWidth")||"",d=e.css("minWidth")||"",a=(c+d).indexOf("%")>-1?g.width()/100:0,c=(c.indexOf("%")>-1?a:1*parseInt(c,10))||b,d=(d.indexOf("%")>-1?a:1*parseInt(d,10))||0,b=c+d?Math.min(Math.max(b,d),c):b,e.css(I,Math.round(b)).appendTo(g)),this.drawing=0,this},destroy:function(){this.bgiframe&&this.bgiframe.remove(),this.qtip._unbind([a,this.qtip.tooltip],this._ns)}}),Aa=R.ie6=function(a){return 6===da.ie?new x(a):E},Aa.initialize="render",B.ie6={"^content|style$":function(){this.redraw()}}})}(window,document); -//# sourceMappingURL=jquery.qtip.min.map \ No newline at end of file diff --git a/assets/ext/popper/popper.min.js b/assets/ext/popper/popper.min.js new file mode 100644 index 00000000..4298fe74 --- /dev/null +++ b/assets/ext/popper/popper.min.js @@ -0,0 +1,6 @@ +/** + * @popperjs/core v2.5.3 - MIT License + */ + +"use strict";!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).Popper={})}(this,(function(e){function t(e){return{width:(e=e.getBoundingClientRect()).width,height:e.height,top:e.top,right:e.right,bottom:e.bottom,left:e.left,x:e.left,y:e.top}}function n(e){return"[object Window]"!==e.toString()?(e=e.ownerDocument)&&e.defaultView||window:e}function r(e){return{scrollLeft:(e=n(e)).pageXOffset,scrollTop:e.pageYOffset}}function o(e){return e instanceof n(e).Element||e instanceof Element}function i(e){return e instanceof n(e).HTMLElement||e instanceof HTMLElement}function a(e){return e?(e.nodeName||"").toLowerCase():null}function s(e){return((o(e)?e.ownerDocument:e.document)||window.document).documentElement}function f(e){return t(s(e)).left+r(e).scrollLeft}function c(e){return n(e).getComputedStyle(e)}function p(e){return e=c(e),/auto|scroll|overlay|hidden/.test(e.overflow+e.overflowY+e.overflowX)}function l(e,o,c){void 0===c&&(c=!1);var l=s(o);e=t(e);var u=i(o),d={scrollLeft:0,scrollTop:0},m={x:0,y:0};return(u||!u&&!c)&&(("body"!==a(o)||p(l))&&(d=o!==n(o)&&i(o)?{scrollLeft:o.scrollLeft,scrollTop:o.scrollTop}:r(o)),i(o)?((m=t(o)).x+=o.clientLeft,m.y+=o.clientTop):l&&(m.x=f(l))),{x:e.left+d.scrollLeft-m.x,y:e.top+d.scrollTop-m.y,width:e.width,height:e.height}}function u(e){return{x:e.offsetLeft,y:e.offsetTop,width:e.offsetWidth,height:e.offsetHeight}}function d(e){return"html"===a(e)?e:e.assignedSlot||e.parentNode||e.host||s(e)}function m(e,t){void 0===t&&(t=[]);var r=function e(t){return 0<=["html","body","#document"].indexOf(a(t))?t.ownerDocument.body:i(t)&&p(t)?t:e(d(t))}(e);e="body"===a(r);var o=n(r);return r=e?[o].concat(o.visualViewport||[],p(r)?r:[]):r,t=t.concat(r),e?t:t.concat(m(d(r)))}function h(e){if(!i(e)||"fixed"===c(e).position)return null;if(e=e.offsetParent){var t=s(e);if("body"===a(e)&&"static"===c(e).position&&"static"!==c(t).position)return t}return e}function g(e){for(var t=n(e),r=h(e);r&&0<=["table","td","th"].indexOf(a(r))&&"static"===c(r).position;)r=h(r);if(r&&"body"===a(r)&&"static"===c(r).position)return t;if(!r)e:{for(e=d(e);i(e)&&0>["html","body"].indexOf(a(e));){if("none"!==(r=c(e)).transform||"none"!==r.perspective||r.willChange&&"auto"!==r.willChange){r=e;break e}e=e.parentNode}r=null}return r||t}function v(e){var t=new Map,n=new Set,r=[];return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||function e(o){n.add(o.name),[].concat(o.requires||[],o.requiresIfExists||[]).forEach((function(r){n.has(r)||(r=t.get(r))&&e(r)})),r.push(o)}(e)})),r}function b(e){var t;return function(){return t||(t=new Promise((function(n){Promise.resolve().then((function(){t=void 0,n(e())}))}))),t}}function y(e){return e.split("-")[0]}function O(e,t){var r=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(r instanceof n(r).ShadowRoot||r instanceof ShadowRoot)do{if(t&&e.isSameNode(t))return!0;t=t.parentNode||t.host}while(t);return!1}function w(e){return Object.assign(Object.assign({},e),{},{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function x(e,o){if("viewport"===o){o=n(e);var a=s(e);o=o.visualViewport;var p=a.clientWidth;a=a.clientHeight;var l=0,u=0;o&&(p=o.width,a=o.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(l=o.offsetLeft,u=o.offsetTop)),e=w(e={width:p,height:a,x:l+f(e),y:u})}else i(o)?((e=t(o)).top+=o.clientTop,e.left+=o.clientLeft,e.bottom=e.top+o.clientHeight,e.right=e.left+o.clientWidth,e.width=o.clientWidth,e.height=o.clientHeight,e.x=e.left,e.y=e.top):(u=s(e),e=s(u),l=r(u),o=u.ownerDocument.body,p=Math.max(e.scrollWidth,e.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),a=Math.max(e.scrollHeight,e.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),u=-l.scrollLeft+f(u),l=-l.scrollTop,"rtl"===c(o||e).direction&&(u+=Math.max(e.clientWidth,o?o.clientWidth:0)-p),e=w({width:p,height:a,x:u,y:l}));return e}function j(e,t,n){return t="clippingParents"===t?function(e){var t=m(d(e)),n=0<=["absolute","fixed"].indexOf(c(e).position)&&i(e)?g(e):e;return o(n)?t.filter((function(e){return o(e)&&O(e,n)&&"body"!==a(e)})):[]}(e):[].concat(t),(n=(n=[].concat(t,[n])).reduce((function(t,n){return n=x(e,n),t.top=Math.max(n.top,t.top),t.right=Math.min(n.right,t.right),t.bottom=Math.min(n.bottom,t.bottom),t.left=Math.max(n.left,t.left),t}),x(e,n[0]))).width=n.right-n.left,n.height=n.bottom-n.top,n.x=n.left,n.y=n.top,n}function M(e){return 0<=["top","bottom"].indexOf(e)?"x":"y"}function E(e){var t=e.reference,n=e.element,r=(e=e.placement)?y(e):null;e=e?e.split("-")[1]:null;var o=t.x+t.width/2-n.width/2,i=t.y+t.height/2-n.height/2;switch(r){case"top":o={x:o,y:t.y-n.height};break;case"bottom":o={x:o,y:t.y+t.height};break;case"right":o={x:t.x+t.width,y:i};break;case"left":o={x:t.x-n.width,y:i};break;default:o={x:t.x,y:t.y}}if(null!=(r=r?M(r):null))switch(i="y"===r?"height":"width",e){case"start":o[r]=Math.floor(o[r])-Math.floor(t[i]/2-n[i]/2);break;case"end":o[r]=Math.floor(o[r])+Math.ceil(t[i]/2-n[i]/2)}return o}function D(e){return Object.assign(Object.assign({},{top:0,right:0,bottom:0,left:0}),e)}function P(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}function L(e,n){void 0===n&&(n={});var r=n;n=void 0===(n=r.placement)?e.placement:n;var i=r.boundary,a=void 0===i?"clippingParents":i,f=void 0===(i=r.rootBoundary)?"viewport":i;i=void 0===(i=r.elementContext)?"popper":i;var c=r.altBoundary,p=void 0!==c&&c;r=D("number"!=typeof(r=void 0===(r=r.padding)?0:r)?r:P(r,T));var l=e.elements.reference;c=e.rects.popper,a=j(o(p=e.elements[p?"popper"===i?"reference":"popper":i])?p:p.contextElement||s(e.elements.popper),a,f),p=E({reference:f=t(l),element:c,strategy:"absolute",placement:n}),c=w(Object.assign(Object.assign({},c),p)),f="popper"===i?c:f;var u={top:a.top-f.top+r.top,bottom:f.bottom-a.bottom+r.bottom,left:a.left-f.left+r.left,right:f.right-a.right+r.right};if(e=e.modifiersData.offset,"popper"===i&&e){var d=e[n];Object.keys(u).forEach((function(e){var t=0<=["right","bottom"].indexOf(e)?1:-1,n=0<=["top","bottom"].indexOf(e)?"y":"x";u[e]+=d[n]*t}))}return u}function k(){for(var e=arguments.length,t=Array(e),n=0;n(v.devicePixelRatio||1)?"translate("+e+"px, "+l+"px)":"translate3d("+e+"px, "+l+"px, 0)",d)):Object.assign(Object.assign({},r),{},((t={})[h]=a?l+"px":"",t[m]=u?e+"px":"",t.transform="",t))}function A(e){return e.replace(/left|right|bottom|top/g,(function(e){return G[e]}))}function H(e){return e.replace(/start|end/g,(function(e){return J[e]}))}function R(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function S(e){return["top","right","bottom","left"].some((function(t){return 0<=e[t]}))}var T=["top","bottom","right","left"],q=T.reduce((function(e,t){return e.concat([t+"-start",t+"-end"])}),[]),C=[].concat(T,["auto"]).reduce((function(e,t){return e.concat([t,t+"-start",t+"-end"])}),[]),N="beforeRead read afterRead beforeMain main afterMain beforeWrite write afterWrite".split(" "),V={placement:"bottom",modifiers:[],strategy:"absolute"},I={passive:!0},_={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(e){var t=e.state,r=e.instance,o=(e=e.options).scroll,i=void 0===o||o,a=void 0===(e=e.resize)||e,s=n(t.elements.popper),f=[].concat(t.scrollParents.reference,t.scrollParents.popper);return i&&f.forEach((function(e){e.addEventListener("scroll",r.update,I)})),a&&s.addEventListener("resize",r.update,I),function(){i&&f.forEach((function(e){e.removeEventListener("scroll",r.update,I)})),a&&s.removeEventListener("resize",r.update,I)}},data:{}},U={name:"popperOffsets",enabled:!0,phase:"read",fn:function(e){var t=e.state;t.modifiersData[e.name]=E({reference:t.rects.reference,element:t.rects.popper,strategy:"absolute",placement:t.placement})},data:{}},z={top:"auto",right:"auto",bottom:"auto",left:"auto"},F={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(e){var t=e.state,n=e.options;e=void 0===(e=n.gpuAcceleration)||e,n=void 0===(n=n.adaptive)||n,e={placement:y(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:e},null!=t.modifiersData.popperOffsets&&(t.styles.popper=Object.assign(Object.assign({},t.styles.popper),W(Object.assign(Object.assign({},e),{},{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:n})))),null!=t.modifiersData.arrow&&(t.styles.arrow=Object.assign(Object.assign({},t.styles.arrow),W(Object.assign(Object.assign({},e),{},{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1})))),t.attributes.popper=Object.assign(Object.assign({},t.attributes.popper),{},{"data-popper-placement":t.placement})},data:{}},X={name:"applyStyles",enabled:!0,phase:"write",fn:function(e){var t=e.state;Object.keys(t.elements).forEach((function(e){var n=t.styles[e]||{},r=t.attributes[e]||{},o=t.elements[e];i(o)&&a(o)&&(Object.assign(o.style,n),Object.keys(r).forEach((function(e){var t=r[e];!1===t?o.removeAttribute(e):o.setAttribute(e,!0===t?"":t)})))}))},effect:function(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach((function(e){var r=t.elements[e],o=t.attributes[e]||{};e=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce((function(e,t){return e[t]="",e}),{}),i(r)&&a(r)&&(Object.assign(r.style,e),Object.keys(o).forEach((function(e){r.removeAttribute(e)})))}))}},requires:["computeStyles"]},Y={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(e){var t=e.state,n=e.name,r=void 0===(e=e.options.offset)?[0,0]:e,o=(e=C.reduce((function(e,n){var o=t.rects,i=y(n),a=0<=["left","top"].indexOf(i)?-1:1,s="function"==typeof r?r(Object.assign(Object.assign({},o),{},{placement:n})):r;return o=(o=s[0])||0,s=((s=s[1])||0)*a,i=0<=["left","right"].indexOf(i)?{x:s,y:o}:{x:o,y:s},e[n]=i,e}),{}))[t.placement],i=o.x;o=o.y,null!=t.modifiersData.popperOffsets&&(t.modifiersData.popperOffsets.x+=i,t.modifiersData.popperOffsets.y+=o),t.modifiersData[n]=e}},G={left:"right",right:"left",bottom:"top",top:"bottom"},J={start:"end",end:"start"},K={name:"flip",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options;if(e=e.name,!t.modifiersData[e]._skip){var r=n.mainAxis;r=void 0===r||r;var o=n.altAxis;o=void 0===o||o;var i=n.fallbackPlacements,a=n.padding,s=n.boundary,f=n.rootBoundary,c=n.altBoundary,p=n.flipVariations,l=void 0===p||p,u=n.allowedAutoPlacements;p=y(n=t.options.placement),i=i||(p!==n&&l?function(e){if("auto"===y(e))return[];var t=A(e);return[H(e),t,H(t)]}(n):[A(n)]);var d=[n].concat(i).reduce((function(e,n){return e.concat("auto"===y(n)?function(e,t){void 0===t&&(t={});var n=t.boundary,r=t.rootBoundary,o=t.padding,i=t.flipVariations,a=t.allowedAutoPlacements,s=void 0===a?C:a,f=t.placement.split("-")[1];0===(i=(t=f?i?q:q.filter((function(e){return e.split("-")[1]===f})):T).filter((function(e){return 0<=s.indexOf(e)}))).length&&(i=t);var c=i.reduce((function(t,i){return t[i]=L(e,{placement:i,boundary:n,rootBoundary:r,padding:o})[y(i)],t}),{});return Object.keys(c).sort((function(e,t){return c[e]-c[t]}))}(t,{placement:n,boundary:s,rootBoundary:f,padding:a,flipVariations:l,allowedAutoPlacements:u}):n)}),[]);n=t.rects.reference,i=t.rects.popper;var m=new Map;p=!0;for(var h=d[0],g=0;gi[x]&&(O=A(O)),x=A(O),w=[],r&&w.push(0>=j[b]),o&&w.push(0>=j[O],0>=j[x]),w.every((function(e){return e}))){h=v,p=!1;break}m.set(v,w)}if(p)for(r=function(e){var t=d.find((function(t){if(t=m.get(t))return t.slice(0,e).every((function(e){return e}))}));if(t)return h=t,"break"},o=l?3:1;0-1}function s(t,e){return"function"==typeof t?t.apply(void 0,e):t}function u(t,e){return 0===e?t:function(r){clearTimeout(n),n=setTimeout((function(){t(r)}),e)};var n}function c(t,e){var n=Object.assign({},t);return e.forEach((function(t){delete n[t]})),n}function p(t){return[].concat(t)}function f(t,e){-1===t.indexOf(e)&&t.push(e)}function l(t){return t.split("-")[0]}function d(t){return[].slice.call(t)}function v(){return document.createElement("div")}function m(t){return["Element","Fragment"].some((function(e){return a(t,e)}))}function g(t){return a(t,"MouseEvent")}function h(t){return!(!t||!t._tippy||t._tippy.reference!==t)}function b(t){return m(t)?[t]:function(t){return a(t,"NodeList")}(t)?d(t):Array.isArray(t)?t:d(document.querySelectorAll(t))}function y(t,e){t.forEach((function(t){t&&(t.style.transitionDuration=e+"ms")}))}function x(t,e){t.forEach((function(t){t&&t.setAttribute("data-state",e)}))}function w(t){var e=p(t)[0];return e&&e.ownerDocument||document}function E(t,e,n){var r=e+"EventListener";["transitionend","webkitTransitionEnd"].forEach((function(e){t[r](e,n)}))}var T={isTouch:!1},C=0;function A(){T.isTouch||(T.isTouch=!0,window.performance&&document.addEventListener("mousemove",O))}function O(){var t=performance.now();t-C<20&&(T.isTouch=!1,document.removeEventListener("mousemove",O)),C=t}function L(){var t=document.activeElement;if(h(t)){var e=t._tippy;t.blur&&!e.state.isVisible&&t.blur()}}var D=Object.assign({appendTo:function(){return document.body},aria:{content:"auto",expanded:"auto"},delay:0,duration:[300,250],getReferenceClientRect:null,hideOnClick:!0,ignoreAttributes:!1,interactive:!1,interactiveBorder:2,interactiveDebounce:0,moveTransition:"",offset:[0,10],onAfterUpdate:function(){},onBeforeUpdate:function(){},onCreate:function(){},onDestroy:function(){},onHidden:function(){},onHide:function(){},onMount:function(){},onShow:function(){},onShown:function(){},onTrigger:function(){},onUntrigger:function(){},onClickOutside:function(){},placement:"top",plugins:[],popperOptions:{},render:null,showOnCreate:!1,touch:!0,trigger:"mouseenter focus",triggerTarget:null},{animateFill:!1,followCursor:!1,inlinePositioning:!1,sticky:!1},{},{allowHTML:!1,animation:"fade",arrow:!0,content:"",inertia:!1,maxWidth:350,role:"tooltip",theme:"",zIndex:9999}),k=Object.keys(D);function R(t){var e=(t.plugins||[]).reduce((function(e,n){var r=n.name,i=n.defaultValue;return r&&(e[r]=void 0!==t[r]?t[r]:i),e}),{});return Object.assign({},t,{},e)}function M(t,e){var n=Object.assign({},e,{content:s(e.content,[t])},e.ignoreAttributes?{}:function(t,e){return(e?Object.keys(R(Object.assign({},D,{plugins:e}))):k).reduce((function(e,n){var r=(t.getAttribute("data-tippy-"+n)||"").trim();if(!r)return e;if("content"===n)e[n]=r;else try{e[n]=JSON.parse(r)}catch(t){e[n]=r}return e}),{})}(t,e.plugins));return n.aria=Object.assign({},D.aria,{},n.aria),n.aria={expanded:"auto"===n.aria.expanded?e.interactive:n.aria.expanded,content:"auto"===n.aria.content?e.interactive?null:"describedby":n.aria.content},n}function P(t,e){t.innerHTML=e}function V(t){var e=v();return!0===t?e.className="tippy-arrow":(e.className="tippy-svg-arrow",m(t)?e.appendChild(t):P(e,t)),e}function j(t,e){m(e.content)?(P(t,""),t.appendChild(e.content)):"function"!=typeof e.content&&(e.allowHTML?P(t,e.content):t.textContent=e.content)}function I(t){var e=t.firstElementChild,n=d(e.children);return{box:e,content:n.find((function(t){return t.classList.contains("tippy-content")})),arrow:n.find((function(t){return t.classList.contains("tippy-arrow")||t.classList.contains("tippy-svg-arrow")})),backdrop:n.find((function(t){return t.classList.contains("tippy-backdrop")}))}}function S(t){var e=v(),n=v();n.className="tippy-box",n.setAttribute("data-state","hidden"),n.setAttribute("tabindex","-1");var r=v();function i(n,r){var i=I(e),o=i.box,a=i.content,s=i.arrow;r.theme?o.setAttribute("data-theme",r.theme):o.removeAttribute("data-theme"),"string"==typeof r.animation?o.setAttribute("data-animation",r.animation):o.removeAttribute("data-animation"),r.inertia?o.setAttribute("data-inertia",""):o.removeAttribute("data-inertia"),o.style.maxWidth="number"==typeof r.maxWidth?r.maxWidth+"px":r.maxWidth,r.role?o.setAttribute("role",r.role):o.removeAttribute("role"),n.content===r.content&&n.allowHTML===r.allowHTML||j(a,t.props),r.arrow?s?n.arrow!==r.arrow&&(o.removeChild(s),o.appendChild(V(r.arrow))):o.appendChild(V(r.arrow)):s&&o.removeChild(s)}return r.className="tippy-content",r.setAttribute("data-state","hidden"),j(r,t.props),e.appendChild(n),n.appendChild(r),i(t.props,t.props),{popper:e,onUpdate:i}}S.$$tippy=!0;var B=1,H=[],N=[];function U(e,n){var a,c,m,h,b,C,A,O,L,k=M(e,Object.assign({},D,{},R((a=n,Object.keys(a).reduce((function(t,e){return void 0!==a[e]&&(t[e]=a[e]),t}),{}))))),P=!1,V=!1,j=!1,S=!1,U=[],_=u(bt,k.interactiveDebounce),z=B++,F=(L=k.plugins).filter((function(t,e){return L.indexOf(t)===e})),W={id:z,reference:e,popper:v(),popperInstance:null,props:k,state:{isEnabled:!0,isVisible:!1,isDestroyed:!1,isMounted:!1,isShown:!1},plugins:F,clearDelayTimeouts:function(){clearTimeout(c),clearTimeout(m),cancelAnimationFrame(h)},setProps:function(t){if(W.state.isDestroyed)return;it("onBeforeUpdate",[W,t]),gt();var n=W.props,r=M(e,Object.assign({},W.props,{},t,{ignoreAttributes:!0}));W.props=r,mt(),n.interactiveDebounce!==r.interactiveDebounce&&(st(),_=u(bt,r.interactiveDebounce));n.triggerTarget&&!r.triggerTarget?p(n.triggerTarget).forEach((function(t){t.removeAttribute("aria-expanded")})):r.triggerTarget&&e.removeAttribute("aria-expanded");at(),rt(),q&&q(n,r);W.popperInstance&&(Et(),Ct().forEach((function(t){requestAnimationFrame(t._tippy.popperInstance.forceUpdate)})));it("onAfterUpdate",[W,t])},setContent:function(t){W.setProps({content:t})},show:function(){var t=W.state.isVisible,e=W.state.isDestroyed,n=!W.state.isEnabled,r=T.isTouch&&!W.props.touch,i=o(W.props.duration,0,D.duration);if(t||e||n||r)return;if(Z().hasAttribute("disabled"))return;if(it("onShow",[W],!1),!1===W.props.onShow(W))return;W.state.isVisible=!0,Q()&&(Y.style.visibility="visible");rt(),ft(),W.state.isMounted||(Y.style.transition="none");if(Q()){var a=et(),u=a.box,c=a.content;y([u,c],0)}A=function(){if(W.state.isVisible&&!S){if(S=!0,Y.offsetHeight,Y.style.transition=W.props.moveTransition,Q()&&W.props.animation){var t=et(),e=t.box,n=t.content;y([e,n],i),x([e,n],"visible")}ot(),at(),f(N,W),W.state.isMounted=!0,it("onMount",[W]),W.props.animation&&Q()&&function(t,e){dt(t,e)}(i,(function(){W.state.isShown=!0,it("onShown",[W])}))}},function(){var t,e=W.props.appendTo,n=Z();t=W.props.interactive&&e===D.appendTo||"parent"===e?n.parentNode:s(e,[n]);t.contains(Y)||t.appendChild(Y);Et()}()},hide:function(){var t=!W.state.isVisible,e=W.state.isDestroyed,n=!W.state.isEnabled,r=o(W.props.duration,1,D.duration);if(t||e||n)return;if(it("onHide",[W],!1),!1===W.props.onHide(W))return;W.state.isVisible=!1,W.state.isShown=!1,S=!1,P=!1,Q()&&(Y.style.visibility="hidden");if(st(),lt(),rt(),Q()){var i=et(),a=i.box,s=i.content;W.props.animation&&(y([a,s],r),x([a,s],"hidden"))}ot(),at(),W.props.animation?Q()&&function(t,e){dt(t,(function(){!W.state.isVisible&&Y.parentNode&&Y.parentNode.contains(Y)&&e()}))}(r,W.unmount):W.unmount()},hideWithInteractivity:function(t){tt().addEventListener("mousemove",_),f(H,_),_(t)},enable:function(){W.state.isEnabled=!0},disable:function(){W.hide(),W.state.isEnabled=!1},unmount:function(){W.state.isVisible&&W.hide();if(!W.state.isMounted)return;Tt(),Ct().forEach((function(t){t._tippy.unmount()})),Y.parentNode&&Y.parentNode.removeChild(Y);N=N.filter((function(t){return t!==W})),W.state.isMounted=!1,it("onHidden",[W])},destroy:function(){if(W.state.isDestroyed)return;W.clearDelayTimeouts(),W.unmount(),gt(),delete e._tippy,W.state.isDestroyed=!0,it("onDestroy",[W])}};if(!k.render)return W;var X=k.render(W),Y=X.popper,q=X.onUpdate;Y.setAttribute("data-tippy-root",""),Y.id="tippy-"+W.id,W.popper=Y,e._tippy=W,Y._tippy=W;var $=F.map((function(t){return t.fn(W)})),J=e.hasAttribute("aria-expanded");return mt(),at(),rt(),it("onCreate",[W]),k.showOnCreate&&At(),Y.addEventListener("mouseenter",(function(){W.props.interactive&&W.state.isVisible&&W.clearDelayTimeouts()})),Y.addEventListener("mouseleave",(function(t){W.props.interactive&&W.props.trigger.indexOf("mouseenter")>=0&&(tt().addEventListener("mousemove",_),_(t))})),W;function G(){var t=W.props.touch;return Array.isArray(t)?t:[t,0]}function K(){return"hold"===G()[0]}function Q(){var t;return!!(null==(t=W.props.render)?void 0:t.$$tippy)}function Z(){return O||e}function tt(){var t=Z().parentNode;return t?w(t):document}function et(){return I(Y)}function nt(t){return W.state.isMounted&&!W.state.isVisible||T.isTouch||b&&"focus"===b.type?0:o(W.props.delay,t?0:1,D.delay)}function rt(){Y.style.pointerEvents=W.props.interactive&&W.state.isVisible?"":"none",Y.style.zIndex=""+W.props.zIndex}function it(t,e,n){var r;(void 0===n&&(n=!0),$.forEach((function(n){n[t]&&n[t].apply(void 0,e)})),n)&&(r=W.props)[t].apply(r,e)}function ot(){var t=W.props.aria;if(t.content){var n="aria-"+t.content,r=Y.id;p(W.props.triggerTarget||e).forEach((function(t){var e=t.getAttribute(n);if(W.state.isVisible)t.setAttribute(n,e?e+" "+r:r);else{var i=e&&e.replace(r,"").trim();i?t.setAttribute(n,i):t.removeAttribute(n)}}))}}function at(){!J&&W.props.aria.expanded&&p(W.props.triggerTarget||e).forEach((function(t){W.props.interactive?t.setAttribute("aria-expanded",W.state.isVisible&&t===Z()?"true":"false"):t.removeAttribute("aria-expanded")}))}function st(){tt().removeEventListener("mousemove",_),H=H.filter((function(t){return t!==_}))}function ut(t){if(!(T.isTouch&&(j||"mousedown"===t.type)||W.props.interactive&&Y.contains(t.target))){if(Z().contains(t.target)){if(T.isTouch)return;if(W.state.isVisible&&W.props.trigger.indexOf("click")>=0)return}else it("onClickOutside",[W,t]);!0===W.props.hideOnClick&&(W.clearDelayTimeouts(),W.hide(),V=!0,setTimeout((function(){V=!1})),W.state.isMounted||lt())}}function ct(){j=!0}function pt(){j=!1}function ft(){var t=tt();t.addEventListener("mousedown",ut,!0),t.addEventListener("touchend",ut,i),t.addEventListener("touchstart",pt,i),t.addEventListener("touchmove",ct,i)}function lt(){var t=tt();t.removeEventListener("mousedown",ut,!0),t.removeEventListener("touchend",ut,i),t.removeEventListener("touchstart",pt,i),t.removeEventListener("touchmove",ct,i)}function dt(t,e){var n=et().box;function r(t){t.target===n&&(E(n,"remove",r),e())}if(0===t)return e();E(n,"remove",C),E(n,"add",r),C=r}function vt(t,n,r){void 0===r&&(r=!1),p(W.props.triggerTarget||e).forEach((function(e){e.addEventListener(t,n,r),U.push({node:e,eventType:t,handler:n,options:r})}))}function mt(){var t;K()&&(vt("touchstart",ht,{passive:!0}),vt("touchend",yt,{passive:!0})),(t=W.props.trigger,t.split(/\s+/).filter(Boolean)).forEach((function(t){if("manual"!==t)switch(vt(t,ht),t){case"mouseenter":vt("mouseleave",yt);break;case"focus":vt(r?"focusout":"blur",xt);break;case"focusin":vt("focusout",xt)}}))}function gt(){U.forEach((function(t){var e=t.node,n=t.eventType,r=t.handler,i=t.options;e.removeEventListener(n,r,i)})),U=[]}function ht(t){var e,n=!1;if(W.state.isEnabled&&!wt(t)&&!V){var r="focus"===(null==(e=b)?void 0:e.type);b=t,O=t.currentTarget,at(),!W.state.isVisible&&g(t)&&H.forEach((function(e){return e(t)})),"click"===t.type&&(W.props.trigger.indexOf("mouseenter")<0||P)&&!1!==W.props.hideOnClick&&W.state.isVisible?n=!0:At(t),"click"===t.type&&(P=!n),n&&!r&&Ot(t)}}function bt(t){var e=t.target,n=Z().contains(e)||Y.contains(e);"mousemove"===t.type&&n||function(t,e){var n=e.clientX,r=e.clientY;return t.every((function(t){var e=t.popperRect,i=t.popperState,o=t.props.interactiveBorder,a=l(i.placement),s=i.modifiersData.offset;if(!s)return!0;var u="bottom"===a?s.top.y:0,c="top"===a?s.bottom.y:0,p="right"===a?s.left.x:0,f="left"===a?s.right.x:0,d=e.top-r+u>o,v=r-e.bottom-c>o,m=e.left-n+p>o,g=n-e.right-f>o;return d||v||m||g}))}(Ct().concat(Y).map((function(t){var e,n=null==(e=t._tippy.popperInstance)?void 0:e.state;return n?{popperRect:t.getBoundingClientRect(),popperState:n,props:k}:null})).filter(Boolean),t)&&(st(),Ot(t))}function yt(t){wt(t)||W.props.trigger.indexOf("click")>=0&&P||(W.props.interactive?W.hideWithInteractivity(t):Ot(t))}function xt(t){W.props.trigger.indexOf("focusin")<0&&t.target!==Z()||W.props.interactive&&t.relatedTarget&&Y.contains(t.relatedTarget)||Ot(t)}function wt(t){return!!T.isTouch&&K()!==t.type.indexOf("touch")>=0}function Et(){Tt();var n=W.props,r=n.popperOptions,i=n.placement,o=n.offset,a=n.getReferenceClientRect,s=n.moveTransition,u=Q()?I(Y).arrow:null,c=a?{getBoundingClientRect:a,contextElement:a.contextElement||Z()}:e,p=[{name:"offset",options:{offset:o}},{name:"preventOverflow",options:{padding:{top:2,bottom:2,left:5,right:5}}},{name:"flip",options:{padding:5}},{name:"computeStyles",options:{adaptive:!s}},{name:"$$tippy",enabled:!0,phase:"beforeWrite",requires:["computeStyles"],fn:function(t){var e=t.state;if(Q()){var n=et().box;["placement","reference-hidden","escaped"].forEach((function(t){"placement"===t?n.setAttribute("data-placement",e.placement):e.attributes.popper["data-popper-"+t]?n.setAttribute("data-"+t,""):n.removeAttribute("data-"+t)})),e.attributes.popper={}}}}];Q()&&u&&p.push({name:"arrow",options:{element:u,padding:3}}),p.push.apply(p,(null==r?void 0:r.modifiers)||[]),W.popperInstance=t.createPopper(c,Y,Object.assign({},r,{placement:i,onFirstUpdate:A,modifiers:p}))}function Tt(){W.popperInstance&&(W.popperInstance.destroy(),W.popperInstance=null)}function Ct(){return d(Y.querySelectorAll("[data-tippy-root]"))}function At(t){W.clearDelayTimeouts(),t&&it("onTrigger",[W,t]),ft();var e=nt(!0),n=G(),r=n[0],i=n[1];T.isTouch&&"hold"===r&&i&&(e=i),e?c=setTimeout((function(){W.show()}),e):W.show()}function Ot(t){if(W.clearDelayTimeouts(),it("onUntrigger",[W,t]),W.state.isVisible){if(!(W.props.trigger.indexOf("mouseenter")>=0&&W.props.trigger.indexOf("click")>=0&&["mouseleave","mousemove"].indexOf(t.type)>=0&&P)){var e=nt(!1);e?m=setTimeout((function(){W.state.isVisible&&W.hide()}),e):h=requestAnimationFrame((function(){W.hide()}))}}else lt()}}function _(t,e){void 0===e&&(e={});var n=D.plugins.concat(e.plugins||[]);document.addEventListener("touchstart",A,i),window.addEventListener("blur",L);var r=Object.assign({},e,{plugins:n}),o=b(t).reduce((function(t,e){var n=e&&U(e,r);return n&&t.push(n),t}),[]);return m(t)?o[0]:o}_.defaultProps=D,_.setDefaultProps=function(t){Object.keys(t).forEach((function(e){D[e]=t[e]}))},_.currentInput=T;var z={mouseover:"mouseenter",focusin:"focus",click:"click"};var F={name:"animateFill",defaultValue:!1,fn:function(t){var e;if(!(null==(e=t.props.render)?void 0:e.$$tippy))return{};var n=I(t.popper),r=n.box,i=n.content,o=t.props.animateFill?function(){var t=v();return t.className="tippy-backdrop",x([t],"hidden"),t}():null;return{onCreate:function(){o&&(r.insertBefore(o,r.firstElementChild),r.setAttribute("data-animatefill",""),r.style.overflow="hidden",t.setProps({arrow:!1,animation:"shift-away"}))},onMount:function(){if(o){var t=r.style.transitionDuration,e=Number(t.replace("ms",""));i.style.transitionDelay=Math.round(e/10)+"ms",o.style.transitionDuration=t,x([o],"visible")}},onShow:function(){o&&(o.style.transitionDuration="0ms")},onHide:function(){o&&x([o],"hidden")}}}};var W={clientX:0,clientY:0},X=[];function Y(t){var e=t.clientX,n=t.clientY;W={clientX:e,clientY:n}}var q={name:"followCursor",defaultValue:!1,fn:function(t){var e=t.reference,n=w(t.props.triggerTarget||e),r=!1,i=!1,o=!0,a=t.props;function s(){return"initial"===t.props.followCursor&&t.state.isVisible}function u(){n.addEventListener("mousemove",f)}function c(){n.removeEventListener("mousemove",f)}function p(){r=!0,t.setProps({getReferenceClientRect:null}),r=!1}function f(n){var r=!n.target||e.contains(n.target),i=t.props.followCursor,o=n.clientX,a=n.clientY,s=e.getBoundingClientRect(),u=o-s.left,c=a-s.top;!r&&t.props.interactive||t.setProps({getReferenceClientRect:function(){var t=e.getBoundingClientRect(),n=o,r=a;"initial"===i&&(n=t.left+u,r=t.top+c);var s="horizontal"===i?t.top:r,p="vertical"===i?t.right:n,f="horizontal"===i?t.bottom:r,l="vertical"===i?t.left:n;return{width:p-l,height:f-s,top:s,right:p,bottom:f,left:l}}})}function l(){t.props.followCursor&&(X.push({instance:t,doc:n}),function(t){t.addEventListener("mousemove",Y)}(n))}function d(){0===(X=X.filter((function(e){return e.instance!==t}))).filter((function(t){return t.doc===n})).length&&function(t){t.removeEventListener("mousemove",Y)}(n)}return{onCreate:l,onDestroy:d,onBeforeUpdate:function(){a=t.props},onAfterUpdate:function(e,n){var o=n.followCursor;r||void 0!==o&&a.followCursor!==o&&(d(),o?(l(),!t.state.isMounted||i||s()||u()):(c(),p()))},onMount:function(){t.props.followCursor&&!i&&(o&&(f(W),o=!1),s()||u())},onTrigger:function(t,e){g(e)&&(W={clientX:e.clientX,clientY:e.clientY}),i="focus"===e.type},onHidden:function(){t.props.followCursor&&(p(),c(),o=!0)}}}};var $={name:"inlinePositioning",defaultValue:!1,fn:function(t){var e,n=t.reference;var r=-1,i=!1,o={name:"tippyInlinePositioning",enabled:!0,phase:"afterWrite",fn:function(i){var o=i.state;t.props.inlinePositioning&&(e!==o.placement&&t.setProps({getReferenceClientRect:function(){return function(t){return function(t,e,n,r){if(n.length<2||null===t)return e;if(2===n.length&&r>=0&&n[0].left>n[1].right)return n[r]||e;switch(t){case"top":case"bottom":var i=n[0],o=n[n.length-1],a="top"===t,s=i.top,u=o.bottom,c=a?i.left:o.left,p=a?i.right:o.right;return{top:s,bottom:u,left:c,right:p,width:p-c,height:u-s};case"left":case"right":var f=Math.min.apply(Math,n.map((function(t){return t.left}))),l=Math.max.apply(Math,n.map((function(t){return t.right}))),d=n.filter((function(e){return"left"===t?e.left===f:e.right===l})),v=d[0].top,m=d[d.length-1].bottom;return{top:v,bottom:m,left:f,right:l,width:l-f,height:m-v};default:return e}}(l(t),n.getBoundingClientRect(),d(n.getClientRects()),r)}(o.placement)}}),e=o.placement)}};function a(){var e;i||(e=function(t,e){var n;return{popperOptions:Object.assign({},t.popperOptions,{modifiers:[].concat(((null==(n=t.popperOptions)?void 0:n.modifiers)||[]).filter((function(t){return t.name!==e.name})),[e])})}}(t.props,o),i=!0,t.setProps(e),i=!1)}return{onCreate:a,onAfterUpdate:a,onTrigger:function(e,n){if(g(n)){var i=d(t.reference.getClientRects()),o=i.find((function(t){return t.left-2<=n.clientX&&t.right+2>=n.clientX&&t.top-2<=n.clientY&&t.bottom+2>=n.clientY}));r=i.indexOf(o)}},onUntrigger:function(){r=-1}}}};var J={name:"sticky",defaultValue:!1,fn:function(t){var e=t.reference,n=t.popper;function r(e){return!0===t.props.sticky||t.props.sticky===e}var i=null,o=null;function a(){var s=r("reference")?(t.popperInstance?t.popperInstance.state.elements.reference:e).getBoundingClientRect():null,u=r("popper")?n.getBoundingClientRect():null;(s&&G(i,s)||u&&G(o,u))&&t.popperInstance&&t.popperInstance.update(),i=s,o=u,t.state.isMounted&&requestAnimationFrame(a)}return{onMount:function(){t.props.sticky&&a()}}}};function G(t,e){return!t||!e||(t.top!==e.top||t.right!==e.right||t.bottom!==e.bottom||t.left!==e.left)}return e&&function(t){var e=document.createElement("style");e.textContent=t,e.setAttribute("data-tippy-stylesheet","");var n=document.head,r=document.querySelector("head>style,head>link");r?n.insertBefore(e,r):n.appendChild(e)}('.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}'),_.setDefaultProps({plugins:[F,q,$,J],render:S}),_.createSingleton=function(t,e){void 0===e&&(e={});var n,r=t,i=[],o=e.overrides,a=[];function s(){i=r.map((function(t){return t.reference}))}function u(t){r.forEach((function(e){t?e.enable():e.disable()}))}function p(t){return r.map((function(e){var r=e.setProps;return e.setProps=function(i){r(i),e.reference===n&&t.setProps(i)},function(){e.setProps=r}}))}u(!1),s();var f={fn:function(){return{onDestroy:function(){u(!0)},onTrigger:function(t,e){var a=e.currentTarget,s=i.indexOf(a);if(a!==n){n=a;var u=(o||[]).concat("content").reduce((function(t,e){return t[e]=r[s].props[e],t}),{});t.setProps(Object.assign({},u,{getReferenceClientRect:"function"==typeof u.getReferenceClientRect?u.getReferenceClientRect:function(){return a.getBoundingClientRect()}}))}}}}},l=_(v(),Object.assign({},c(e,["overrides"]),{plugins:[f].concat(e.plugins||[]),triggerTarget:i})),d=l.setProps;return l.setProps=function(t){o=t.overrides||o,d(t)},l.setInstances=function(t){u(!0),a.forEach((function(t){return t()})),r=t,u(!1),s(),p(l),l.setProps({triggerTarget:i})},a=p(l),l},_.delegate=function(t,e){var n=[],r=[],i=!1,o=e.target,a=c(e,["target"]),s=Object.assign({},a,{trigger:"manual",touch:!1}),u=Object.assign({},a,{showOnCreate:!0}),f=_(t,s);function l(t){if(t.target&&!i){var n=t.target.closest(o);if(n){var a=n.getAttribute("data-tippy-trigger")||e.trigger||D.trigger;if(!n._tippy&&!("touchstart"===t.type&&"boolean"==typeof u.touch||"touchstart"!==t.type&&a.indexOf(z[t.type])<0)){var s=_(n,u);s&&(r=r.concat(s))}}}}function d(t,e,r,i){void 0===i&&(i=!1),t.addEventListener(e,r,i),n.push({node:t,eventType:e,handler:r,options:i})}return p(f).forEach((function(t){var e=t.destroy,o=t.enable,a=t.disable;t.destroy=function(t){void 0===t&&(t=!0),t&&r.forEach((function(t){t.destroy()})),r=[],n.forEach((function(t){var e=t.node,n=t.eventType,r=t.handler,i=t.options;e.removeEventListener(n,r,i)})),n=[],e()},t.enable=function(){o(),r.forEach((function(t){return t.enable()})),i=!1},t.disable=function(){a(),r.forEach((function(t){return t.disable()})),i=!0},function(t){var e=t.reference;d(e,"touchstart",l),d(e,"mouseover",l),d(e,"focusin",l),d(e,"click",l)}(t)})),f},_.hideAll=function(t){var e=void 0===t?{}:t,n=e.exclude,r=e.duration;N.forEach((function(t){var e=!1;if(n&&(e=h(n)?t.reference===n:t.popper===n.popper),!e){var i=t.props.duration;t.setProps({duration:r}),t.hide(),t.state.isDestroyed||t.setProps({duration:i})}}))},_.roundArrow='',_})); +//# sourceMappingURL=tippy-bundle.umd.min.js.map diff --git a/assets/img/favicon.ico b/assets/img/favicon.ico index 0a46dd9e..244f6211 100644 Binary files a/assets/img/favicon.ico and b/assets/img/favicon.ico differ diff --git a/assets/img/installation-banner.png b/assets/img/installation-banner.png deleted file mode 100644 index 6fb4cab1..00000000 Binary files a/assets/img/installation-banner.png and /dev/null differ diff --git a/assets/js/backend.js b/assets/js/backend.js index 311c55af..a60833c1 100644 --- a/assets/js/backend.js +++ b/assets/js/backend.js @@ -25,7 +25,7 @@ window.Backend = window.Backend || {}; /** * Main javascript code for the backend of Easy!Appointments. */ - $(document).ready(function () { + $(function () { $(window) .on('resize', function () { Backend.placeFooterToBottom(); @@ -40,15 +40,7 @@ window.Backend = window.Backend || {}; $('#loading').hide(); }); - $('.menu-item').qtip({ - position: { - my: 'top center', - at: 'bottom center' - }, - style: { - classes: 'qtip-green qtip-shadow custom-qtip' - } - }); + tippy('[data-tippy-content]'); GeneralFunctions.enableLanguageSelection($('#select-language')); }); diff --git a/assets/js/backend_calendar.js b/assets/js/backend_calendar.js index a004e82c..4441e2fc 100755 --- a/assets/js/backend_calendar.js +++ b/assets/js/backend_calendar.js @@ -114,7 +114,7 @@ window.BackendCalendar = window.BackendCalendar || {}; exports.initialize = function (view) { BackendCalendarGoogleSync.initialize(); BackendCalendarAppointmentsModal.initialize(); - BackendCalendarUnavailabilitiesModal.initialize(); + BackendCalendarUnavailabilityEventsModal.initialize(); // Load and initialize the calendar view. if (view === 'table') { diff --git a/assets/js/backend_calendar_default_view.js b/assets/js/backend_calendar_default_view.js index b5120707..aaca4e7d 100755 --- a/assets/js/backend_calendar_default_view.js +++ b/assets/js/backend_calendar_default_view.js @@ -142,7 +142,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; endDatetime = Date.parseExact(unavailable.end_datetime, 'yyyy-MM-dd HH:mm:ss'); $dialog = $('#manage-unavailable'); - BackendCalendarUnavailabilitiesModal.resetUnavailableDialog(); + BackendCalendarUnavailabilityEventsModal.resetUnavailableDialog(); // Apply unavailable data to dialog. $dialog.find('.modal-header h3').text('Edit Unavailable Period'); @@ -1066,7 +1066,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; // Add custom unavailable periods (they are always displayed on the calendar, even if the provider won't // work on that day). - var unavailableEvents = []; + var unavailabilityEvents = []; response.unavailables.forEach(function (unavailable) { var notes = unavailable.notes ? ' - ' + unavailable.notes : ''; @@ -1074,7 +1074,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; notes = unavailable.notes.substring(0, 30) + '...' } - var unavailableEvent = { + var unavailabilityEvent = { title: EALang.unavailable + notes, start: moment(unavailable.start_datetime), end: moment(unavailable.end_datetime), @@ -1085,10 +1085,10 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; data: unavailable }; - unavailableEvents.push(unavailableEvent); + unavailabilityEvents.push(unavailabilityEvent); }); - $calendar.fullCalendar('addEventSource', unavailableEvents); + $calendar.fullCalendar('addEventSource', unavailabilityEvents); var calendarView = $('#calendar').fullCalendar('getView'); @@ -1103,7 +1103,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; var workingPlan = jQuery.parseJSON(provider.settings.working_plan); var workingPlanExceptions = jQuery.parseJSON(provider.settings.working_plan_exceptions); - var unavailableEvent; + var unavailabilityEvent; var viewStart; var viewEnd; var breakStart; @@ -1157,7 +1157,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; // Non-working day. if (sortedWorkingPlan[weekdayName] === null) { // Working plan exception. - unavailableEvent = { + unavailabilityEvent = { title: EALang.not_working, start: calendarView.intervalStart.clone(), end: calendarView.intervalEnd.clone(), @@ -1167,7 +1167,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; className: 'fc-unavailable' }; - $calendar.fullCalendar('renderEvent', unavailableEvent, false); + $calendar.fullCalendar('renderEvent', unavailabilityEvent, false); return; // Go to next loop. } @@ -1276,7 +1276,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; // Non-working day. if (sortedWorkingPlan[weekdayName] === null) { // Add a full day unavailable event. - unavailableEvent = { + unavailabilityEvent = { title: EALang.not_working, start: calendarDate.clone(), end: calendarDate.clone().add(1, 'day'), @@ -1286,7 +1286,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; className: 'fc-unavailable' }; - $calendar.fullCalendar('renderEvent', unavailableEvent, true); + $calendar.fullCalendar('renderEvent', unavailabilityEvent, true); calendarDate.add(1, 'day'); @@ -1300,7 +1300,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; workDateStart.minute(parseInt(startHour[1])); if (calendarDate < workDateStart) { - unavailableEvent = { + unavailabilityEvent = { title: EALang.not_working, start: calendarDate.clone(), end: moment(calendarDate.format('YYYY-MM-DD') + ' ' + sortedWorkingPlan[weekdayName].start + ':00'), @@ -1310,7 +1310,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; className: 'fc-unavailable' }; - $calendar.fullCalendar('renderEvent', unavailableEvent, true); + $calendar.fullCalendar('renderEvent', unavailabilityEvent, true); } // Add unavailable period after work ends. @@ -1320,7 +1320,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; workDateEnd.minute(parseInt(endHour[1])); if (calendarView.end > workDateEnd) { - unavailableEvent = { + unavailabilityEvent = { title: EALang.not_working, start: moment(calendarDate.format('YYYY-MM-DD') + ' ' + sortedWorkingPlan[weekdayName].end + ':00'), end: calendarDate.clone().add(1, 'day'), @@ -1330,7 +1330,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; className: 'fc-unavailable' }; - $calendar.fullCalendar('renderEvent', unavailableEvent, false); + $calendar.fullCalendar('renderEvent', unavailabilityEvent, false); } // Add unavailable periods during day breaks. @@ -1345,7 +1345,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; breakEnd.hour(parseInt(breakEndString[0])); breakEnd.minute(parseInt(breakEndString[1])); - var unavailableEvent = { + var unavailabilityEvent = { title: EALang.break, start: moment(calendarDate.format('YYYY-MM-DD') + ' ' + breakPeriod.start), end: moment(calendarDate.format('YYYY-MM-DD') + ' ' + breakPeriod.end), @@ -1355,7 +1355,7 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; className: 'fc-unavailable fc-break' }; - $calendar.fullCalendar('renderEvent', unavailableEvent, false); + $calendar.fullCalendar('renderEvent', unavailabilityEvent, false); }); calendarDate.add(1, 'day'); @@ -1612,27 +1612,6 @@ window.BackendCalendarDefaultView = window.BackendCalendarDefaultView || {}; $dialog.modal('show'); } - // Apply qtip to control tooltips. - $('#calendar-toolbar button').qtip({ - position: { - my: 'top center', - at: 'bottom center' - }, - style: { - classes: 'qtip-green qtip-shadow custom-qtip' - } - }); - - $('#select-filter-item').qtip({ - position: { - my: 'middle left', - at: 'middle right' - }, - style: { - classes: 'qtip-green qtip-shadow custom-qtip' - } - }); - if (!$('#select-filter-item option').length) { $('#calendar-actions button').prop('disabled', true); } diff --git a/assets/js/backend_calendar_table_view.js b/assets/js/backend_calendar_table_view.js index f4e886b6..a4562e4b 100755 --- a/assets/js/backend_calendar_table_view.js +++ b/assets/js/backend_calendar_table_view.js @@ -91,13 +91,16 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; $providerColumn.find('.calendar-wrapper').fullCalendar('removeEvents'); - createNonWorkingHours($providerColumn.find('.calendar-wrapper'), JSON.parse($providerColumn.data('provider').settings.working_plan)); + createNonWorkingHours( + $providerColumn.find('.calendar-wrapper'), + $providerColumn.data('provider') + ); // Add the appointments to the column. createAppointments($providerColumn, response.appointments); - // Add the unavailabilities to the column. - createUnavailabilities($providerColumn, response.unavailabilities); + // Add the unavailability events to the column. + createUnavailabilityEvents($providerColumn, response.unavailability_events); // Add the provider breaks to the column. var workingPlan = JSON.parse(provider.settings.working_plan); @@ -145,7 +148,36 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; var $dialog; - if (lastFocusedEventData.data.is_unavailable === '0') { + if (lastFocusedEventData.data.workingPlanException) { + var date = lastFocusedEventData.data.date; + var workingPlanException = lastFocusedEventData.data.workingPlanException; + var provider = lastFocusedEventData.data.provider; + + WorkingPlanExceptionsModal + .edit(date, workingPlanException) + .done(function (date, workingPlanException) { + var successCallback = function () { + Backend.displayNotification(EALang.working_plan_exception_saved); + + var workingPlanExceptions = jQuery.parseJSON(provider.settings.working_plan_exceptions) || {}; + + workingPlanExceptions[date] = workingPlanException; + + for (var index in GlobalVariables.availableProviders) { + var availableProvider = GlobalVariables.availableProviders[index]; + + if (Number(availableProvider.id) === Number(provider.id)) { + availableProvider.settings.working_plan_exceptions = JSON.stringify(workingPlanExceptions); + break; + } + } + + $('#select-filter-item').trigger('change'); // Update the calendar. + }; + + BackendCalendarApi.saveWorkingPlanException(date, workingPlanException, provider.id, successCallback, null); + }); + } else if (lastFocusedEventData.data.is_unavailable === '0') { var appointment = lastFocusedEventData.data; $dialog = $('#manage-appointment'); @@ -186,7 +218,7 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; var endDatetime = Date.parseExact(unavailable.end_datetime, 'yyyy-MM-dd HH:mm:ss'); $dialog = $('#manage-unavailable'); - BackendCalendarUnavailabilitiesModal.resetUnavailableDialog(); + BackendCalendarUnavailabilityEventsModal.resetUnavailableDialog(); // Apply unavailable data to dialog. $dialog.find('.modal-header h3').text('Edit Unavailable Period'); @@ -592,13 +624,16 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; createCalendar($providerColumn, date, provider); // Create non working hours. - createNonWorkingHours($providerColumn.find('.calendar-wrapper'), JSON.parse(provider.settings.working_plan)) + createNonWorkingHours( + $providerColumn.find('.calendar-wrapper'), + provider + ); // Add the appointments to the column. createAppointments($providerColumn, events.appointments); - // Add the unavailabilities to the column. - createUnavailabilities($providerColumn, events.unavailabilities); + // Add the unavailability events to the column. + createUnavailabilityEvents($providerColumn, events.unavailability_events); Backend.placeFooterToBottom(); } @@ -606,8 +641,8 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; /** * Get Calendar Component Height * - * This method calculates the proper calendar height, in order to be displayed correctly, even when the - * browser window is resizing. + * This method calculates the proper calendar height, in order to be displayed correctly, even when the browser + * window is resizing. * * @return {Number} Returns the calendar element height in pixels. */ @@ -793,11 +828,38 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; $(element).fullCalendar('option', 'height', getCalendarHeight()); } - function createNonWorkingHours($calendar, workingPlan) { + function createNonWorkingHours($calendar, provider) { + var workingPlan = JSON.parse(provider.settings.working_plan); + var workingPlanExceptions = JSON.parse(provider.settings.working_plan_exceptions); var view = $calendar.fullCalendar('getView'); var start = view.start.clone(); var end = view.end.clone(); var selDayName = start.toDate().toString('dddd').toLowerCase(); + var selDayDate = start.format('YYYY-MM-DD'); + + if (workingPlanExceptions[selDayDate]) { + workingPlan[selDayName] = workingPlanExceptions[selDayDate]; + + var workingPlanExceptionStart = selDayDate + ' ' + workingPlan[selDayName].start; + var workingPlanExceptionEnd = selDayDate + ' ' + workingPlan[selDayName].end; + + var workingPlanExceptionEvent = { + title: EALang.working_plan_exception, + start: moment(workingPlanExceptionStart, 'YYYY-MM-DD HH:mm', true), + end: moment(workingPlanExceptionEnd, 'YYYY-MM-DD HH:mm', true).add(1, 'day'), + allDay: true, + color: '#879DB4', + editable: false, + className: 'fc-working-plan-exception fc-custom', + data: { + date: selDayDate, + workingPlanException: workingPlanExceptions[selDayDate], + provider: provider + } + }; + + $calendar.fullCalendar('renderEvent', workingPlanExceptionEvent, false); + } if (workingPlan[selDayName] === null) { var nonWorkingDay = { @@ -912,20 +974,20 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; } /** - * Create Unavailabilities Events + * Create Unavailability Events * * This method will add the unavailability events on the table view. * * @param {jQuery} $providerColumn The provider column container. - * @param {Object[]} unavailabilities Contains the unavailability events data. + * @param {Object[]} unavailabilityEvents Contains the unavailability events data. */ - function createUnavailabilities($providerColumn, unavailabilities) { - if (unavailabilities.length === 0) { + function createUnavailabilityEvents($providerColumn, unavailabilityEvents) { + if (unavailabilityEvents.length === 0) { return; } - for (var index in unavailabilities) { - var unavailability = unavailabilities[index]; + for (var index in unavailabilityEvents) { + var unavailability = unavailabilityEvents[index]; if (unavailability.id_users_provider !== $providerColumn.data('provider').id) { continue; @@ -1036,10 +1098,10 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; if ($(this).hasClass('fc-unavailable') || $parent.hasClass('fc-unavailable') || $altParent.hasClass('fc-unavailable')) { displayEdit = (($parent.hasClass('fc-custom') || $altParent.hasClass('fc-custom')) && GlobalVariables.user.privileges.appointments.edit === true) - ? 'mr-2' : 'd-none'; + ? '' : 'd-none'; displayDelete = (($parent.hasClass('fc-custom') || $altParent.hasClass('fc-custom')) && GlobalVariables.user.privileges.appointments.delete === true) - ? 'mr-2' : 'd-none'; // Same value at the time. + ? '' : 'd-none'; // Same value at the time. $html = $('
', { 'html': [ @@ -1070,8 +1132,30 @@ window.BackendCalendarTableView = window.BackendCalendarTableView || {}; $('
'), $('
', { - 'class': 'd-flex justify-content-between', + 'class': 'd-flex justify-content-center', 'html': [ + $('