/* ---------------------------------------------------------------------------- * Easy!Appointments - Online Appointment Scheduler * * @package EasyAppointments * @author A.Tselegidis <alextselegidis@gmail.com> * @copyright Copyright (c) 2013 - 2016, Alex Tselegidis * @license https://opensource.org/licenses/GPL-3.0 - GPLv3 * @link https://easyappointments.org * @since v1.5.0 * ---------------------------------------------------------------------------- */ /** * Working plan utility. * * This module implements the functionality of table calendar view. * * Old Name: BackendCalendarTableView */ App.Utils.CalendarTableView = (function () { const $calendar = $('#calendar'); const $calendarToolbar = $('#calendar-toolbar'); const $calendarFilter = $('#calendar-filter'); const $notification = $('#notification'); const $reloadAppointments = $('#reload-appointments'); const $selectFilterItem = $('#select-filter-item'); const $selectService = $('#select-service'); const $selectProvider = $('#select-provider'); const $appointmentsModal = $('#appointments-modal'); const $unavailabilitiesModal = $('#unavailabilities-modal'); const $header = $('#header'); const $footer = $('#footer'); let $filterProvider; let $filterService; let $selectDate; let lastFocusedEventData; /** * Add the utility event listeners. */ function addEventListeners() { $calendar.on('click', '.calendar-header .btn.previous', () => { const dayInterval = $selectFilterItem.val(); const currentDate = $selectDate.datepicker('getDate'); const startDate = moment(currentDate).subtract(1, 'days'); const endDate = startDate.clone().add(dayInterval - 1, 'days'); $selectDate.datepicker('setDate', startDate.toDate()); createView(startDate.toDate(), endDate.toDate()); }); $calendar.on('click', '.calendar-header .btn.next', () => { const dayInterval = $selectFilterItem.val(); const currentDate = $selectDate.datepicker('getDate'); const startDate = moment(currentDate).add(1, 'days'); const endDate = startDate.clone().add(dayInterval - 1, 'days'); $selectDate.datepicker('setDate', startDate.toDate()); createView(startDate.toDate(), endDate.toDate()); }); $calendarToolbar.on('change', '#select-filter-item', () => { const dayInterval = $selectFilterItem.val(); const currentDate = $selectDate.datepicker('getDate'); const startDate = moment(currentDate); const endDate = startDate.clone().add(dayInterval - 1, 'days'); createView(startDate.toDate(), endDate.toDate()); }); $calendarToolbar.on('click', '#reload-appointments', () => { // Fetch the events and place them in the existing HTML format. const dayInterval = $selectFilterItem.val(); const currentDate = $selectDate.datepicker('getDate'); const startDateMoment = moment(currentDate); const startDate = startDateMoment.toDate(); const endDateMoment = startDateMoment.clone().add(dayInterval - 1, 'days'); const endDate = endDateMoment.toDate(); App.Http.Calendar.getCalendarAppointmentsForTableView(startDate, endDate).done((response) => { let currentDate = startDate; while (currentDate <= endDate) { $('.calendar-view .date-column').each((index, dateColumn) => { const $dateColumn = $(dateColumn); const date = new Date($dateColumn.data('date')); if (moment(currentDate).format('YYYY-MM-DD') !== moment(date).format('YYYY-MM-DD')) { return true; } $dateColumn .find('.date-column-title') .text(App.Utils.Date.format(date, vars('date_format'), vars('time_format'))); $dateColumn.find('.provider-column').each((index, providerColumn) => { const $providerColumn = $(providerColumn); const provider = $providerColumn.data('provider'); $providerColumn .find('.calendar-wrapper') .data('fullCalendar') .getEventSources() .forEach((eventSource) => eventSource.remove()); 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 provider breaks to the column. const workingPlan = JSON.parse(provider.settings.working_plan); const day = moment(date).format('dddd').toLowerCase(); if (workingPlan[day]) { const breaks = workingPlan[day].breaks; createBreaks($providerColumn, breaks); } }); }); currentDate = moment(currentDate).add({days: 1}).toDate(); } // setCalendarViewSize(); App.Layouts.Backend.placeFooterToBottom(); }); }); /** * Event: On Window Resize */ $(window).on('resize', () => { setCalendarViewSize(); }); /** * Event: Popover Close Button "Click" * * Hides the open popover element. */ $calendar.on('click', '.close-popover', (event) => { $(event.target).parents('.popover').popover('dispose'); }); /** * Event: Popover Edit Button "Click" * * Enables the edit dialog of the selected table event. */ $calendar.on('click', '.edit-popover', (event) => { $(event.target).parents('.popover').popover('dispose'); let startMoment; let endMoment; if (lastFocusedEventData.extendedProps.data.workingPlanException) { const date = lastFocusedEventData.extendedProps.data.date; const workingPlanException = lastFocusedEventData.extendedProps.data.workingPlanException; const provider = lastFocusedEventData.extendedProps.data.provider; App.Components.WorkingPlanExceptionsModal.edit(date, workingPlanException).done( (date, workingPlanException) => { const successCallback = () => { App.Layouts.Backend.displayNotification(lang('working_plan_exception_saved')); const workingPlanExceptions = JSON.parse(provider.settings.working_plan_exceptions) || {}; workingPlanExceptions[date] = workingPlanException; for (const index in vars('available_providers')) { const availableProvider = vars('available_providers')[index]; if (Number(availableProvider.id) === Number(provider.id)) { availableProvider.settings.working_plan_exceptions = JSON.stringify(workingPlanExceptions); break; } } $reloadAppointments.trigger('click'); // Update the calendar. }; App.Http.Calendar.saveWorkingPlanException( date, workingPlanException, provider.id, successCallback, null ); } ); } else if (lastFocusedEventData.extendedProps.data.is_unavailability === false) { const appointment = lastFocusedEventData.extendedProps.data; App.Components.AppointmentsModal.resetModal(); // Apply appointment data and show modal dialog. $appointmentsModal.find('.modal-header h3').text(lang('edit_appointment_title')); $appointmentsModal.find('#appointment-id').val(appointment.id); $appointmentsModal.find('#select-service').val(appointment.id_services).trigger('change'); $appointmentsModal.find('#select-provider').val(appointment.id_users_provider); // Set the start and end datetime of the appointment. startMoment = moment(appointment.start_datetime); $appointmentsModal.find('#start-datetime').datetimepicker('setDate', startMoment.toDate()); endMoment = moment(appointment.end_datetime); $appointmentsModal.find('#end-datetime').datetimepicker('setDate', endMoment.toDate()); const customer = appointment.customer; $appointmentsModal.find('#customer-id').val(appointment.id_users_customer); $appointmentsModal.find('#first-name').val(customer.first_name); $appointmentsModal.find('#last-name').val(customer.last_name); $appointmentsModal.find('#email').val(customer.email); $appointmentsModal.find('#phone-number').val(customer.phone_number); $appointmentsModal.find('#address').val(customer.address); $appointmentsModal.find('#city').val(customer.city); $appointmentsModal.find('#zip-code').val(customer.zip_code); $appointmentsModal.find('#language').val(customer.language); $appointmentsModal.find('#timezone').val(customer.timezone); $appointmentsModal.find('#appointment-location').val(appointment.location); $appointmentsModal.find('#appointment-notes').val(appointment.notes); $appointmentsModal.find('#customer-notes').val(customer.notes); App.Components.ColorSelection.setColor( $appointmentsModal.find('#appointment-color'), appointment.color ); $appointmentsModal.modal('show'); } else { const unavailability = lastFocusedEventData.extendedProps.data; // Replace string date values with actual date objects. unavailability.start_datetime = moment(lastFocusedEventData.start).format('YYYY-MM-DD HH:mm:ss'); startMoment = moment(unavailability.start_datetime); unavailability.end_datetime = moment(lastFocusedEventData.end).format('YYYY-MM-DD HH:mm:ss'); endMoment = moment(unavailability.end_datetime); App.Components.UnavailabilitiesModal.resetModal(); // Apply unavailability data to dialog. $unavailabilitiesModal.find('.modal-header h3').text(lang('edit_unavailability_title')); $unavailabilitiesModal.find('#unavailability-start').datetimepicker('setDate', startMoment.toDate()); $unavailabilitiesModal.find('#unavailability-id').val(unavailability.id); $unavailabilitiesModal.find('#unavailability-provider').val(unavailability.id_users_provider); $unavailabilitiesModal.find('#unavailability-end').datetimepicker('setDate', endMoment.toDate()); $unavailabilitiesModal.find('#unavailability-notes').val(unavailability.notes); $unavailabilitiesModal.modal('show'); } }); /** * Event: Popover Delete Button "Click" * * Displays a prompt on whether the user wants the appointment to be deleted. If he confirms the * deletion then an ajax call is made to the server and deletes the appointment from the database. */ $calendar.on('click', '.delete-popover', (event) => { $(event.target).parents('.popover').popover('dispose'); // Hide the popover. // If id_role parameter exists the popover is an working plan exception. if (lastFocusedEventData.extendedProps.data.hasOwnProperty('id_roles')) { // Do not display confirmation prompt. const date = moment(lastFocusedEventData.start).format('YYYY-MM-DD'); const providerId = lastFocusedEventData.extendedProps.data.id; App.Http.Calendar.deleteWorkingPlanException(date, providerId).done(() => { $('#message-box').dialog('close'); const workingPlanExceptions = JSON.parse( lastFocusedEventData.extendedProps.data.settings.working_plan_exceptions ); delete workingPlanExceptions[moment(lastFocusedEventData.start).format('YYYY-MM-DD')]; lastFocusedEventData.extendedProps.data.settings.working_plan_exceptions = JSON.stringify(workingPlanExceptions); // Refresh calendar event items. $reloadAppointments.trigger('click'); }); } else if (!lastFocusedEventData.extendedProps.data.is_unavailability) { const buttons = [ { text: lang('cancel'), click: () => { $('#message-box').dialog('close'); } }, { text: lang('delete'), click: () => { const appointmentId = lastFocusedEventData.extendedProps.data.id; const deleteReason = $('#delete-reason').val(); App.Http.Calendar.deleteAppointment(appointmentId, deleteReason).done(() => { $('#message-box').dialog('close'); // Refresh calendar event items. $reloadAppointments.trigger('click'); }); } } ]; App.Utils.Message.show( lang('delete_appointment_title'), lang('write_appointment_removal_reason'), buttons ); $('<textarea/>', { 'class': 'form-control w-100', 'id': 'delete-reason', 'rows': '3' }).appendTo('#message-box'); } else { // Do not display confirmation prompt. const unavailabilityId = lastFocusedEventData.extendedProps.data.id; App.Http.Calendar.deleteUnavailability(unavailabilityId).done(() => { $('#message-box').dialog('close'); // Refresh calendar event items. $reloadAppointments.trigger('click'); }); } }); } /** * Create table view header container. * * The header contains the date navigation elements (buttons and datepicker). */ function createHeader() { $calendarFilter .find('select') .empty() .append(new Option('1 ' + lang('day'), 1)) .append(new Option('3 ' + lang('days'), 3)); const $calendarHeader = $('<div/>', { 'class': 'calendar-header' }).appendTo('#calendar'); $('<button/>', { 'class': 'btn btn-xs btn-outline-secondary previous me-2', 'html': [ $('<span/>', { 'class': 'fas fa-chevron-left' }) ] }).appendTo($calendarHeader); $selectDate = $('<input/>', { 'type': 'text', 'class': 'form-control d-inline-block select-date me-2', 'value': App.Utils.Date.format(new Date(), vars('date_format'), vars('time_format'), false) }).appendTo($calendarHeader); $('<button/>', { 'class': 'btn btn-xs btn-outline-secondary next', 'html': [ $('<span/>', { 'class': 'fas fa-chevron-right' }) ] }).appendTo($calendarHeader); let dateFormat; switch (vars('date_format')) { case 'DMY': dateFormat = 'dd/mm/yy'; break; case 'MDY': dateFormat = 'mm/dd/yy'; break; case 'YMD': dateFormat = 'yy/mm/dd'; break; default: throw new Error('Invalid date format setting provided: ' + vars('date_format')); } const firstWeekday = vars('first_weekday'); const firstWeekdayNumber = App.Utils.Date.getWeekdayId(firstWeekday); $calendarHeader.find('.select-date').datepicker({ defaultDate: new Date(), dateFormat: dateFormat, // Translation dayNames: [lang('sunday'), lang('monday'), lang('tuesday'), lang('wednesday'), lang('thursday'), lang('friday'), lang('saturday')], dayNamesShort: [lang('sunday').substr(0, 3), lang('monday').substr(0, 3), lang('tuesday').substr(0, 3), lang('wednesday').substr(0, 3), lang('thursday').substr(0, 3), lang('friday').substr(0, 3), lang('saturday').substr(0, 3)], dayNamesMin: [lang('sunday').substr(0, 2), lang('monday').substr(0, 2), lang('tuesday').substr(0, 2), lang('wednesday').substr(0, 2), lang('thursday').substr(0, 2), lang('friday').substr(0, 2), lang('saturday').substr(0, 2)], monthNames: [lang('january'), lang('february'), lang('march'), lang('april'), lang('may'), lang('june'), lang('july'), lang('august'), lang('september'), lang('october'), lang('november'), lang('december')], prevText: lang('previous'), nextText: lang('next'), currentText: lang('now'), closeText: lang('close'), timeOnlyTitle: lang('select_time'), timeText: lang('time'), hourText: lang('hour'), minuteText: lang('minutes'), firstDay: firstWeekdayNumber, onSelect: (dateText, instance) => { const startDate = new Date(instance.currentYear, instance.currentMonth, instance.currentDay); const endDate = new Date(startDate.getTime()).add({days: parseInt($selectFilterItem.val()) - 1}); createView(startDate, endDate); } }); const providers = vars('available_providers').filter( (provider) => vars('role_slug') === App.Layouts.Backend.DB_SLUG_ADMIN || (vars('role_slug') === App.Layouts.Backend.DB_SLUG_SECRETARY && vars('secretary_providers').indexOf(provider.id) !== -1) || (vars('role_slug') === App.Layouts.Backend.DB_SLUG_PROVIDER && Number(provider.id) === Number(vars('user_id'))) ); // Create providers and service filters. $('<label/>', { 'text': lang('provider') }).appendTo($calendarHeader); $filterProvider = $('<select/>', { 'id': 'filter-provider', 'multiple': 'multiple', 'on': { 'change': () => { const firstColumnDate = $('.calendar-view .date-column:first').data('date'); const startDateMoment = moment(firstColumnDate); const endDateMoment = moment(firstColumnDate).add({ days: parseInt($selectDate.val()) - 1 }); createView(startDateMoment.toDate(), endDateMoment.toDate()); } } }).appendTo($calendarHeader); if (vars('role_slug') !== App.Layouts.Backend.DB_SLUG_PROVIDER) { providers.forEach((provider) => { $filterProvider.append(new Option(provider.first_name + ' ' + provider.last_name, provider.id)); }); } else { providers.forEach((provider) => { if (Number(provider.id) === Number(vars('user_id'))) { $filterProvider.append(new Option(provider.first_name + ' ' + provider.last_name, provider.id)); } }); } App.Utils.UI.initializeDropdown($filterProvider); const services = vars('available_services').filter((service) => { const provider = providers.find((provider) => provider.services.indexOf(service.id) !== -1); return vars('role_slug') === App.Layouts.Backend.DB_SLUG_ADMIN || provider; }); $('<label/>', { 'text': lang('service') }).appendTo($calendarHeader); $filterService = $('<select/>', { 'id': 'filter-service', 'multiple': 'multiple', 'on': { 'change': () => { const firstColumnDate = $('.calendar-view .date-column:first').data('date'); const startDateMoment = moment(firstColumnDate); const endDateMoment = moment(firstColumnDate).add({days: parseInt($selectDate.val()) - 1}); createView(startDateMoment.toDate(), endDateMoment.toDate()); } } }).appendTo($calendarHeader); services.forEach((service) => { $filterService.append(new Option(service.name, service.id)); }); App.Utils.UI.initializeDropdown($filterService); } /** * Create table schedule view. * * This method will destroy any previous instances and create a new view for displaying the appointments in * a table format. * * @param {Date} startDate Start date to be displayed. * @param {Date} endDate End date to be displayed. */ function createView(startDate, endDate) { // Disable date navigation. $('#calendar .calendar-header .btn').addClass('disabled').prop('disabled', true); // Remember provider calendar view mode. const providerView = {}; $('.provider-column').each((index, providerColumn) => { const $providerColumn = $(providerColumn); const providerId = $providerColumn.data('provider').id; providerView[providerId] = $providerColumn.find('.calendar-wrapper').data('fullCalendar').view.type; }); $('#calendar .calendar-view').remove(); App.Layouts.Backend.placeFooterToBottom(); const $calendarView = $('<div/>', { 'class': 'calendar-view' }).appendTo('#calendar'); $calendarView.data({ startDate: moment(startDate).format('YYYY-MM-DD'), endDate: moment(endDate).format('YYYY-MM-DD') }); const $wrapper = $('<div/>').appendTo($calendarView); App.Http.Calendar.getCalendarAppointmentsForTableView(startDate, endDate).done((response) => { let currentDate = startDate; while (currentDate <= endDate) { createDateColumn($wrapper, currentDate, response); currentDate = moment(currentDate).add({days: 1}).toDate(); } setCalendarViewSize(); App.Layouts.Backend.placeFooterToBottom(); // Activate calendar navigation. $('#calendar .calendar-header .btn').removeClass('disabled').prop('disabled', false); // Apply provider calendar view mode. $('.provider-column').each((index, providerColumn) => { const $providerColumn = $(providerColumn); const providerId = $providerColumn.data('provider').id; $providerColumn .find('.calendar-wrapper') .data('fullCalendar') .changeView(providerView[providerId] || 'timeGridDay'); }); }); } /** * Create Date Column Container * * This element will contain the provider columns. * * @param {jQuery} $wrapper The wrapper div element of the table view. * @param {Date} date Selected date for the column. * @param {Object[]} events Events to be displayed on this date. */ function createDateColumn($wrapper, date, events) { const $dateColumn = $('<div/>', { 'class': 'date-column' }).appendTo($wrapper); $dateColumn.data('date', date.getTime()); $('<h5/>', { 'class': 'date-column-title', 'text': App.Utils.Date.format(date, vars('date_format'), vars('time_format')) }).appendTo($dateColumn); const filterProviderIds = $filterProvider.val(); const filterServiceIds = $filterService.val(); let providers = vars('available_providers').filter((provider) => { const servedServiceIds = provider.services.filter((serviceId) => { const matches = filterServiceIds.filter( (filterServiceId) => Number(serviceId) === Number(filterServiceId) ); return matches.length; }); return ( (!filterProviderIds.length && !filterServiceIds.length) || (filterProviderIds.length && !filterServiceIds.length && filterProviderIds.indexOf(provider.id) !== -1) || (!filterProviderIds.length && filterServiceIds.length && servedServiceIds.length) || (filterProviderIds.length && filterServiceIds.length && servedServiceIds.length && filterProviderIds.indexOf(provider.id) !== -1) ); }); if (vars('role_slug') === 'provider') { vars('available_providers').forEach((provider) => { if (Number(provider.id) === Number(vars('user_id'))) { providers = [provider]; } }); } if (vars('role_slug') === 'secretary') { providers = []; vars('available_providers').forEach((provider) => { if (vars('secretary_providers').indexOf(provider.id) > -1) { providers.push(provider); } }); } providers.forEach((provider) => { createProviderColumn($dateColumn, date, provider, events); }); } /** * Create Provider Column Container * * @param {jQuery} $dateColumn Element to container the provider's column. * @param {Date} date Selected date for the column. * @param {Object} provider Contains the provider data. * @param {Object[]} events Events to be displayed on this date. */ function createProviderColumn($dateColumn, date, provider, events) { if (provider.services.length === 0) { return; } const $providerColumn = $('<div/>', { 'class': 'provider-column' }).appendTo($dateColumn); $providerColumn.data('provider', provider); // Create calendar. createCalendar($providerColumn, date, provider); // Create non working hours. 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); App.Layouts.Backend.placeFooterToBottom(); } /** * Get Calendar Component Height * * 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. */ function getCalendarHeight() { const result = window.innerHeight - $footer.outerHeight() - $header.outerHeight() - 60; // 60 for fine tuning return result > 500 ? result : 500; // Minimum height is 500px } function createCalendar($providerColumn, goToDate, provider) { const $wrapper = $('<div/>', { 'class': 'calendar-wrapper' }).appendTo($providerColumn); let columnFormat = ''; switch (vars('date_format')) { case 'DMY': columnFormat = 'ddd D/M'; break; case 'MDY': case 'YMD': columnFormat = 'ddd M/D'; break; default: throw new Error('Invalid date format setting provided!', vars('date_format')); } // Time formats let timeFormat = ''; let slotTimeFormat = ''; switch (vars('time_format')) { case 'military': timeFormat = 'H:mm'; slotTimeFormat = 'H'; break; case 'regular': timeFormat = 'h:mm a'; slotTimeFormat = 'h a'; break; default: throw new Error('Invalid time format setting provided!' + vars('time_format')); } const firstWeekday = vars('first_weekday'); const firstWeekdayNumber = App.Utils.Date.getWeekdayId(firstWeekday); const fullCalendar = new FullCalendar.Calendar($wrapper[0], { initialView: 'timeGridDay', height: getCalendarHeight(), editable: true, firstDay: firstWeekdayNumber, slotDuration: '00:15:00', snapDuration: '00:15:00', slotLabelInterval: '01:00', eventTimeFormat: timeFormat, eventTextColor: '#333', slotLabelFormat: slotTimeFormat, allDayContent: lang('all_day'), dayHeaderFormat: columnFormat, headerToolbar: { left: 'listDay,timeGridDay', center: '', right: '' }, // Selectable selectable: true, selectHelper: true, select: (info) => { if (info.allDay) { return; } $('#insert-appointment').trigger('click'); // Preselect service & provider. const $providerColumn = $(info.jsEvent.target).parents('.provider-column'); const providerId = $providerColumn.data('provider').id; const provider = vars('available_providers').find( (provider) => Number(provider.id) === Number(providerId) ); const service = vars('available_services').find( (service) => provider.services.indexOf(service.id) !== -1 ); if (service) { $selectService.val(service.id); } if (!$selectService.val()) { $selectService.find('option:first').prop('selected', true); } $selectService.trigger('change'); if (provider) { $selectProvider.val(provider.id); } if (!$selectProvider.val()) { $('#select-provider option:first').prop('selected', true); } $selectProvider.trigger('change'); // Preselect time $('#start-datetime').datepicker('setDate', info.start); $('#end-datetime').datepicker('setDate', App.Pages.Calendar.getSelectionEndDate(info)); return false; }, buttonText: { today: lang('today'), day: lang('day'), week: lang('week'), month: lang('month'), timeGridDay: lang('calendar'), listDay: lang('list') }, // Calendar events need to be declared on initialization. eventClick: onEventClick, eventResize: onEventResize, eventDrop: onEventDrop // datesSet: onDatesSet }); fullCalendar.render(); $wrapper.data('fullCalendar', fullCalendar); fullCalendar.gotoDate(goToDate); $('<h6/>', { 'text': provider.first_name + ' ' + provider.last_name }).prependTo($providerColumn); } // function onDatesSet() { // $(element).fullCalendar('option', 'height', getCalendarHeight()); // } function createNonWorkingHours($calendar, provider) { const workingPlan = JSON.parse(provider.settings.working_plan); const workingPlanExceptions = JSON.parse(provider.settings.working_plan_exceptions) || {}; const view = $calendar.data('fullCalendar').view; const start = moment(view.currentStart).clone(); const end = moment(view.currentEnd).clone(); const selDayName = start.format('dddd').toLowerCase(); const selDayDate = start.format('YYYY-MM-DD'); const calendarEventSource = []; if (workingPlanExceptions[selDayDate]) { workingPlan[selDayName] = workingPlanExceptions[selDayDate]; const workingPlanExceptionStart = selDayDate + ' ' + workingPlan[selDayName].start; const workingPlanExceptionEnd = selDayDate + ' ' + workingPlan[selDayName].end; const workingPlanExceptionEvent = { title: lang('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 } }; calendarEventSource.push(workingPlanExceptionEvent); } if (workingPlan[selDayName] === null) { const nonWorkingDay = { title: lang('not_working'), start: start, end: end, allDay: false, color: '#BEBEBE', editable: false, className: 'fc-unavailability' }; calendarEventSource.push(nonWorkingDay); $calendar.data('fullCalendar').addEventSource(calendarEventSource); return; } const workDateStart = moment(start.format('YYYY-MM-DD') + ' ' + workingPlan[selDayName].start); if (start < workDateStart) { unavailabilityPeriod = { title: lang('not_working'), start: start.toDate(), end: workDateStart.toDate(), allDay: false, color: '#BEBEBE', editable: false, className: 'fc-unavailability' }; calendarEventSource.push(unavailabilityPeriod); } // Add unavailability period after work ends. const workDateEnd = moment(start.format('YYYY-MM-DD') + ' ' + workingPlan[selDayName].end); if (end > workDateEnd) { const unavailabilityPeriod = { title: lang('not_working'), start: workDateEnd.toDate(), end: end.toDate(), allDay: false, color: '#BEBEBE', editable: false, className: 'fc-unavailability' }; calendarEventSource.push(unavailabilityPeriod); } // Add unavailability periods for breaks. let breakStart; let breakEnd; workingPlan[selDayName].breaks.forEach((currentBreak) => { breakStart = moment(start.format('YYYY-MM-DD') + ' ' + currentBreak.start); breakEnd = moment(start.format('YYYY-MM-DD') + ' ' + currentBreak.end); const unavailabilityPeriod = { title: lang('break'), start: breakStart.toDate(), end: breakEnd.toDate(), allDay: false, color: '#BEBEBE', editable: false, className: 'fc-unavailability fc-break' }; calendarEventSource.push(unavailabilityPeriod); }); $calendar.data('fullCalendar').addEventSource(calendarEventSource); } /** * Create Appointment Events * * This method will add the appointment events on the table view. * * @param {jQuery} $providerColumn The provider column container. * @param {Object[]} appointments Contains the appointment events data. */ function createAppointments($providerColumn, appointments) { if (appointments.length === 0) { return; } const filterServiceIds = $filterService.val(); appointments = appointments.filter( (appointment) => !filterServiceIds.length || filterServiceIds.indexOf(appointment.id_services) !== -1 ); const calendarEvents = []; for (const index in appointments) { const appointment = appointments[index]; if (Number(appointment.id_users_provider) !== Number($providerColumn.data('provider').id)) { continue; } calendarEvents.push({ id: appointment.id, title: appointment.service.name + ' - ' + appointment.customer.first_name + ' ' + appointment.customer.last_name, start: moment(appointment.start_datetime).toDate(), end: moment(appointment.end_datetime).toDate(), allDay: false, color: appointment.color, data: appointment // Store appointment data for later use. }); } $providerColumn.find('.calendar-wrapper').data('fullCalendar').addEventSource(calendarEvents); } /** * Create Unavailability Events * * This method will add the unavailabilities on the table view. * * @param {jQuery} $providerColumn The provider column container. * @param {Object[]} unavailabilities Contains the unavailabilities data. */ function createUnavailabilities($providerColumn, unavailabilities) { if (unavailabilities.length === 0) { return; } const calendarEventSource = []; for (const index in unavailabilities) { const unavailability = unavailabilities[index]; if (Number(unavailability.id_users_provider) !== Number($providerColumn.data('provider').id)) { continue; } const event = { title: lang('unavailability'), start: moment(unavailability.start_datetime).toDate(), end: moment(unavailability.end_datetime).toDate(), allDay: false, color: '#879DB4', editable: true, className: 'fc-unavailability fc-custom', data: unavailability }; calendarEventSource.push(event); } $providerColumn.find('.calendar-wrapper').data('fullCalendar').addEventSource(calendarEventSource); } /** * Create break events in the table view. * * @param {jQuery} $providerColumn The provider column container. * @param {Object[]} breaks Contains the break events data. */ function createBreaks($providerColumn, breaks) { if (breaks.length === 0) { return; } const currentDate = new Date($providerColumn.parents('.date-column').data('date')); const $tbody = $providerColumn.find('table tbody'); for (const index in breaks) { const entry = breaks[index]; const startHour = entry.start.split(':'); const eventDate = new Date( currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate(), startHour[0], startHour[1] ); const endHour = entry.end.split(':'); const endDate = new Date( currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate(), endHour[0], endHour[1] ); const eventDuration = Math.round((endDate - eventDate) / 60000); const $event = $('<div/>', { 'class': 'event unavailability break' }); $event.html( lang('break') + ' <span class="hour">' + moment(eventDate).format('HH:mm') + '</span> (' + eventDuration + "')" ); $event.data(entry); $tbody.find('tr').each((index, tr) => { const $td = $(tr).find('td:first'); const cellDate = moment(currentDate) .set({ hour: parseInt($td.text().split(':')[0]), minute: parseInt($td.text().split(':')[1]) }) .toDate(); if (eventDate < cellDate) { // Remove the hour from the event if it is the same as the row. if (moment(eventDate).format('HH:mm') === $(tr).prev().find('td').eq(0).text()) { $event.find('.hour').remove(); } $(tr) .prev() .find('td:gt(0)') .each((index, td) => { $event.clone().appendTo($(td)); }); return false; } }); } } /** * Get the event notes for the popup widget. * * @param {*|Event} event */ function getEventNotes(event) { if (!event.extendedProps || !event.extendedProps.data || !event.extendedProps.data.notes) { return '-'; } const notes = event.extendedProps.data.notes; return notes.length > 100 ? notes.substring(0, 100) + '...' : notes; } /** * Calendar Event "Click" Callback * * When the user clicks on an appointment object on the calendar, then a data preview popover is display * above the calendar item. * * @param {Object} info */ function onEventClick(info) { const $popover = $('.popover'); $popover.popover('dispose'); // Close all open popovers. let $html; let displayEdit; let displayDelete; // Depending on where the user clicked the event (title or empty space) we need to use different selectors to // reach the parent element. const $target = $(info.el); if ($target.hasClass('fc-unavailability')) { displayEdit = $target.hasClass('fc-custom') && vars('privileges').appointments.edit === true ? '' : 'd-none'; displayDelete = $target.hasClass('fc-custom') && vars('privileges').appointments.delete === true ? '' : 'd-none'; // Same value at the time. $html = $('<div/>', { 'html': [ $('<strong/>', { 'text': lang('start') }), $('<span/>', { 'text': App.Utils.Date.format( moment(info.event.start).format('YYYY-MM-DD HH:mm:ss'), vars('date_format'), vars('time_format'), true ) }), $('<br/>'), $('<strong/>', { 'text': lang('end') }), $('<span/>', { 'text': App.Utils.Date.format( moment(info.event.end).format('YYYY-MM-DD HH:mm:ss'), vars('date_format'), vars('time_format'), true ) }), $('<br/>'), $('<strong/>', { 'text': lang('notes') }), $('<span/>', { 'text': getEventNotes(info.event) }), $('<br/>'), $('<hr/>'), $('<div/>', { 'class': 'd-flex justify-content-center', 'html': [ $('<button/>', { 'class': 'close-popover btn btn-outline-secondary me-2', 'html': [ $('<i/>', { 'class': 'fas fa-ban me-2' }), $('<span/>', { 'text': lang('close') }) ] }), $('<button/>', { 'class': 'delete-popover btn btn-outline-secondary me-2 ' + displayDelete, 'html': [ $('<i/>', { 'class': 'fas fa-trash-alt me-2' }), $('<span/>', { 'text': lang('delete') }) ] }), $('<button/>', { 'class': 'edit-popover btn btn-primary ' + displayEdit, 'html': [ $('<i/>', { 'class': 'fas fa-edit me-2' }), $('<span/>', { 'text': lang('edit') }) ] }) ] }) ] }); } else if ($target.hasClass('fc-working-plan-exception')) { displayEdit = $target.hasClass('fc-custom') && vars('privileges').appointments.edit === true ? '' : 'd-none'; // Same value at the time. displayDelete = $target.hasClass('fc-custom') && vars('privileges').appointments.delete === true ? '' : 'd-none'; // Same value at the time. $html = $('<div/>', { 'html': [ $('<strong/>', { 'text': lang('provider') }), $('<span/>', { 'text': info.event.extendedProps.data ? info.event.extendedProps.data.provider.first_name + ' ' + info.event.extendedProps.data.provider.last_name : '-' }), $('<br/>'), $('<strong/>', { 'text': lang('start') }), $('<span/>', { 'text': App.Utils.Date.format( info.event.extendedProps.data.date + ' ' + info.event.extendedProps.data.workingPlanException.start, vars('date_format'), vars('time_format'), true ) }), $('<br/>'), $('<strong/>', { 'text': lang('end') }), $('<span/>', { 'text': App.Utils.Date.format( info.event.extendedProps.data.date + ' ' + info.event.extendedProps.data.workingPlanException.end, vars('date_format'), vars('time_format'), true ) }), $('<br/>'), $('<strong/>', { 'text': lang('timezone') }), $('<span/>', { 'text': vars('timezones')[info.event.extendedProps.data.provider.timezone] }), $('<br/>'), $('<hr/>'), $('<div/>', { 'class': 'd-flex justify-content-center', 'html': [ $('<button/>', { 'class': 'close-popover btn btn-outline-secondary me-2', 'html': [ $('<i/>', { 'class': 'fas fa-ban me-2' }), $('<span/>', { 'text': lang('close') }) ] }), $('<button/>', { 'class': 'delete-popover btn btn-outline-secondary me-2 ' + displayDelete, 'html': [ $('<i/>', { 'class': 'fas fa-trash-alt me-2' }), $('<span/>', { 'text': lang('delete') }) ] }), $('<button/>', { 'class': 'edit-popover btn btn-primary ' + displayEdit, 'html': [ $('<i/>', { 'class': 'fas fa-edit me-2' }), $('<span/>', { 'text': lang('edit') }) ] }) ] }) ] }); } else { displayEdit = vars('privileges').appointments.edit === true ? '' : 'd-none'; displayDelete = vars('privileges').appointments.delete === true ? '' : 'd-none'; $html = $('<div/>', { 'html': [ $('<strong/>', { 'text': lang('start') }), $('<span/>', { 'text': App.Utils.Date.format( moment(info.event.start).format('YYYY-MM-DD HH:mm:ss'), vars('date_format'), vars('time_format'), true ) }), $('<br/>'), $('<strong/>', { 'text': lang('end') }), $('<span/>', { 'text': App.Utils.Date.format( moment(info.event.end).format('YYYY-MM-DD HH:mm:ss'), vars('date_format'), vars('time_format'), true ) }), $('<br/>'), $('<strong/>', { 'text': lang('timezone') }), $('<span/>', { 'text': vars('timezones')[info.event.extendedProps.data.provider.timezone] }), $('<br/>'), $('<strong/>', { 'text': lang('service') }), $('<span/>', { 'text': info.event.extendedProps.data.service.name }), $('<br/>'), $('<strong/>', { 'text': lang('provider') }), App.Utils.CalendarEventPopover.renderMapIcon(info.event.extendedProps.data.provider), $('<span/>', { 'text': info.event.extendedProps.data.provider.first_name + ' ' + info.event.extendedProps.data.provider.last_name }), $('<br/>'), $('<strong/>', { 'text': lang('customer') }), App.Utils.CalendarEventPopover.renderMapIcon(info.event.extendedProps.data.customer), $('<span/>', { 'text': info.event.extendedProps.data.customer.first_name + ' ' + info.event.extendedProps.data.customer.last_name }), $('<br/>'), $('<strong/>', { 'text': lang('email') }), App.Utils.CalendarEventPopover.renderMailIcon(info.event.extendedProps.data.customer.email), $('<span/>', { 'text': info.event.extendedProps.data.customer.email }), $('<br/>'), $('<strong/>', { 'text': lang('phone') }), App.Utils.CalendarEventPopover.renderPhoneIcon(info.event.extendedProps.data.customer.phone_number), $('<span/>', { 'text': info.event.extendedProps.data.customer.phone_number }), $('<br/>'), $('<strong/>', { 'text': lang('notes') }), $('<span/>', { 'text': getEventNotes(info.event) }), $('<br/>'), $('<hr/>'), $('<div/>', { 'class': 'd-flex justify-content-center', 'html': [ $('<button/>', { 'class': 'close-popover btn btn-outline-secondary me-2', 'html': [ $('<i/>', { 'class': 'fas fa-ban me-2' }), $('<span/>', { 'text': lang('close') }) ] }), $('<button/>', { 'class': 'delete-popover btn btn-outline-secondary me-2 ' + displayDelete, 'html': [ $('<i/>', { 'class': 'fas fa-trash-alt me-2' }), $('<span/>', { 'text': lang('delete') }) ] }), $('<button/>', { 'class': 'edit-popover btn btn-primary ' + displayEdit, 'html': [ $('<i/>', { 'class': 'fas fa-edit me-2' }), $('<span/>', { 'text': lang('edit') }) ] }) ] }) ] }); } $target.popover({ placement: 'top', title: info.event.title, content: $html, html: true, container: '#calendar', trigger: 'manual' }); lastFocusedEventData = info.event; $target.popover('toggle'); // Fix popover position. const $newPopover = $calendar.find('.popover'); if ($newPopover.length > 0 && $newPopover.position().top < 200) { $newPopover.css('top', '200px'); } } /** * Calendar Event "Resize" Callback * * The user can change the duration of an event by resizing an appointment object on the calendar. This * change needs to be stored to the database too and this is done via an ajax call. * * @see updateAppointmentData() * * @param {Object} info */ function onEventResize(info) { if (vars('privileges').appointments.edit === false) { info.revert(); App.Layouts.Backend.displayNotification(lang('no_privileges_edit_appointments')); return; } if ($notification.is(':visible')) { $notification.hide('bind'); } let successCallback; if (info.event.extendedProps.data.is_unavailability === false) { // Prepare appointment data. info.event.extendedProps.data.end_datetime = moment(info.event.extendedProps.data.end_datetime) .add({days: info.delta.days, milliseconds: info.delta.milliseconds}) .format('YYYY-MM-DD HH:mm:ss'); const appointment = {...info.event.extendedProps.data}; // Must delete the following because only appointment data should be provided to the AJAX call. delete appointment.customer; delete appointment.provider; delete appointment.service; // Success callback successCallback = () => { // Display success notification to user. const undoFunction = () => { appointment.end_datetime = info.event.extendedProps.data.end_datetime = moment( appointment.end_datetime ) .add({days: -info.delta.days, milliseconds: -info.delta.milliseconds}) .format('YYYY-MM-DD HH:mm:ss'); App.Http.Calendar.saveAppointment(appointment).done(() => { $notification.hide('blind'); }); info.revert(); }; App.Layouts.Backend.displayNotification(lang('appointment_updated'), [ { 'label': lang('undo'), 'function': undoFunction } ]); $footer.css('position', 'static'); // Footer position fix. // Update the event data for later use. info.event.setProp('data', event.extendedProps.data); }; // Update appointment data. App.Http.Calendar.saveAppointment(appointment, null, successCallback); } else { // Update unavailability time period. const unavailability = { id: info.event.extendedProps.data.id, start_datetime: moment(info.event.start).format('YYYY-MM-DD HH:mm:ss'), end_datetime: moment(info.event.end).format('YYYY-MM-DD HH:mm:ss'), id_users_provider: info.event.extendedProps.data.id_users_provider }; info.event.extendedProps.data.end_datetime = unavailability.end_datetime; // Define success callback function. successCallback = () => { // Display success notification to user. const undoFunction = () => { unavailability.end_datetime = info.event.extendedProps.data.end_datetime = moment( unavailability.end_datetime ) .add({days: -info.endDelta.days, milliseconds: -info.endDelta.milliseconds}) .format('YYYY-MM-DD HH:mm:ss'); App.Http.Calendar.saveUnavailability(unavailability).done(() => { $notification.hide('blind'); }); info.revert(); }; App.Layouts.Backend.displayNotification(lang('unavailability_updated'), [ { 'label': lang('undo'), 'function': undoFunction } ]); $footer.css('position', 'static'); // Footer position fix. // Update the event data for later use. info.event.setProp('data', info.event.extendedProps.data); }; App.Http.Calendar.saveUnavailability(unavailability, successCallback); } } /** * Calendar Event "Drop" Callback * * This event handler is triggered whenever the user drags and drops an event into a different position * on the calendar. We need to update the database with this change. This is done via an ajax call. * * @param {Object} info */ function onEventDrop(info) { if (vars('privileges').appointments.edit === false) { info.revert(); App.Layouts.Backend.displayNotification(lang('no_privileges_edit_appointments')); return; } if ($notification.is(':visible')) { $notification.hide('bind'); } let successCallback; if (info.event.extendedProps.data.is_unavailability === false) { // Prepare appointment data. const appointment = {...info.event.extendedProps.data}; // Must delete the following because only appointment data should be provided to the ajax call. delete appointment.customer; delete appointment.provider; delete appointment.service; appointment.start_datetime = moment(appointment.start_datetime) .add({days: delta.days(), hours: delta.hours(), minutes: delta.minutes()}) .format('YYYY-MM-DD HH:mm:ss'); appointment.end_datetime = moment(appointment.end_datetime) .add({days: delta.days(), hours: delta.hours(), minutes: delta.minutes()}) .format('YYYY-MM-DD HH:mm:ss'); info.event.extendedProps.data.start_datetime = appointment.start_datetime; info.event.extendedProps.data.end_datetime = appointment.end_datetime; // Define success callback function. successCallback = () => { // Define the undo function, if the user needs to reset the last change. const undoFunction = () => { appointment.start_datetime = moment(appointment.start_datetime) .add({days: -info.delta.days, milliseconds: -info.delta.milliseconds}) .format('YYYY-MM-DD HH:mm:ss'); appointment.end_datetime = moment(appointment.end_datetime) .add({days: -info.delta.days, milliseconds: -info.delta.milliseconds}) .format('YYYY-MM-DD HH:mm:ss'); info.event.extendedProps.data.start_datetime = appointment.start_datetime; info.event.extendedProps.data.end_datetime = appointment.end_datetime; App.Http.Calendar.saveAppointment(appointment).done(() => { $notification.hide('blind'); }); info.revert(); }; App.Layouts.Backend.displayNotification(lang('appointment_updated'), [ { 'label': lang('undo'), 'function': undoFunction } ]); $footer.css('position', 'static'); // Footer position fix. }; // Update appointment data. App.Http.Calendar.saveAppointment(appointment, null, successCallback); } else { // Update unavailability time period. const unavailability = { id: info.event.extendedProps.data.id, start_datetime: moment(info.event.start).format('YYYY-MM-DD HH:mm:ss'), end_datetime: moment(info.event.end).format('YYYY-MM-DD HH:mm:ss'), id_users_provider: info.event.extendedProps.data.id_users_provider }; successCallback = () => { const undoFunction = () => { unavailability.start_datetime = moment(unavailability.start_datetime) .add({days: -info.delta.days, milliseconds: -info.delta.milliseconds}) .format('YYYY-MM-DD HH:mm:ss'); unavailability.end_datetime = moment(unavailability.end_datetime) .add({days: -info.delta.days, milliseconds: -info.delta.milliseconds}) .format('YYYY-MM-DD HH:mm:ss'); info.event.extendedProps.data.start_datetime = unavailability.start_datetime; info.event.extendedProps.data.end_datetime = unavailability.end_datetime; App.Http.Calendar.saveUnavailability(unavailability).done(() => { $notification.hide('blind'); }); info.revert(); }; App.Layouts.Backend.displayNotification(lang('unavailability_updated'), [ { label: lang('undo'), function: undoFunction } ]); $footer.css('position', 'static'); // Footer position fix. }; App.Http.Calendar.saveUnavailability(unavailability, successCallback); } } /** * Set Table Calendar View * * This method will set the optimal size in the calendar view elements in order to fit in the page without * using scrollbars. */ function setCalendarViewSize() { let height = window.innerHeight - $header.outerHeight() - $footer.outerHeight() - $calendarToolbar.outerHeight() - $('.calendar-header').outerHeight() - 50; if (height < 500) { height = 500; } const $dateColumn = $('.date-column'); const $calendarViewDiv = $('.calendar-view > div'); $calendarViewDiv.css('min-width', '1000%'); let width = 0; $dateColumn.each((index, dateColumn) => { width += $(dateColumn).outerWidth(); }); $calendarViewDiv.css('min-width', width + 200); const dateColumnHeight = $dateColumn.outerHeight(); $('.calendar-view .not-working').outerHeight((dateColumnHeight > height ? dateColumnHeight : height) - 70); } /** * Initialize Page */ function initialize() { createHeader(); const startDate = moment().toDate(); const endDate = moment() .add(Number($selectFilterItem.val()) - 1, 'days') .toDate(); createView(startDate, endDate); $('#insert-working-plan-exception').hide(); addEventListeners(); // Hide Google Calendar Sync buttons cause they can not be used within this view. $('#enable-sync, #google-sync').hide(); } return { initialize }; })();