PluginProbe
VikBooking Hotel Booking Engine & PMS / trunk
VikBooking Hotel Booking Engine & PMS vtrunk
1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 All 36 releases
vikbooking / admin / helpers / report / rms_occupancy_pace.php

rms_occupancy_pace.php in VikBooking Hotel Booking Engine & PMS trunk, at admin/helpers/report/rms_occupancy_pace.php

2,076 lines 94.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage com_vikbooking
5 * @author Alessio Gaggii - E4J srl
6 * @copyright Copyright (C) 2026 E4J srl. All rights reserved.
7 * @license GNU General Public License version 2 or later; see LICENSE
8 * @link https://vikwp.com
9 */
10
11 defined('ABSPATH') or die('No script kiddies please!');
12
13 /**
14 * RMS Occupancy Pace report implementation.
15 *
16 * @since 1.18.6 (J) - 1.8.6 (WP)
17 */
18 class VikBookingReportRmsOccupancyPace extends VikBookingReport
19 {
20 /**
21 * Property 'defaultKeySort' is used by the View that renders the report.
22 */
23 public $defaultKeySort = 'line';
24
25 /**
26 * Property 'defaultKeyOrder' is used by the View that renders the report.
27 */
28 public $defaultKeyOrder = 'ASC';
29
30 /**
31 * Property 'exportAllowed' is used by the View to display the export button.
32 */
33 public $exportAllowed = 1;
34
35 /**
36 * @var array
37 */
38 protected array $paceOptions = [];
39
40 /**
41 * Class constructor will define some default properties.
42 */
43 public function __construct()
44 {
45 $this->reportFile = basename(__FILE__, '.php');
46 $this->reportName = JText::translate('VBO_RMS_OCCUPANCY_PACE');
47 $this->reportFilters = [];
48
49 $this->cols = [];
50 $this->rows = [];
51 $this->footerRow = [];
52
53 $this->registerExportCSVFileName();
54 }
55
56 /**
57 * @inheritDoc
58 */
59 public function preflight()
60 {
61 return class_exists('VikChannelManagerConfig');
62 }
63
64 /**
65 * Returns the name of this report.
66 *
67 * @return string
68 */
69 public function getName()
70 {
71 return $this->reportName;
72 }
73
74 /**
75 * Returns the name of this file without .php.
76 *
77 * @return string
78 */
79 public function getFileName()
80 {
81 return $this->reportFile;
82 }
83
84 /**
85 * Returns the filters of this report.
86 *
87 * @return array
88 */
89 public function getFilters()
90 {
91 if ($this->reportFilters) {
92 // do not run this method twice, as it could load JS and CSS files.
93 return $this->reportFilters;
94 }
95
96 $app = JFactory::getApplication();
97 $dbo = JFactory::getDbo();
98
99 // get VBO Application Object
100 $vbo_app = VikBooking::getVboApplication();
101
102 // load the jQuery UI Datepicker
103 $this->loadDatePicker();
104
105 // load Charts assets
106 $this->loadChartsAssets();
107
108 // default date format
109 $df = $this->getDateFormat();
110
111 // obtain min check-in and max check-out timestamps
112 $dbo->setQuery(
113 $dbo->getQuery(true)
114 ->select([
115 'MIN(' . $dbo->qn('checkin') . ') AS ' . $dbo->qn('mincheckin'),
116 'MAX(' . $dbo->qn('checkout') . ') AS ' . $dbo->qn('maxcheckout'),
117 ])
118 ->from($dbo->qn('#__vikbooking_orders'))
119 ->where($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
120 ->where($dbo->qn('closure') . ' = 0')
121 );
122 $data = $dbo->loadAssoc();
123 $mincheckindt = date($df, $data['mincheckin'] ?? time());
124 $maxcheckoutdt = date($df, $data['maxcheckout'] ?? time());
125 $minyear = date('Y', $data['mincheckin'] ?? time());
126 $maxyear = date('Y', $data['maxcheckout'] ?? time());
127
128 // default values
129 $compare_to = $app->input->getString('compare_to', '-1 year');
130
131 // comparison options
132 $compareOpts = [
133 [
134 'name' => JText::translate('VBO_SAMET_LAST_YEAR'),
135 'value' => '-1 year',
136 ],
137 [
138 'name' => JText::sprintf('VBO_SAMET_YEARS_AGO', 2),
139 'value' => '-2 years',
140 ],
141 [
142 'name' => JText::sprintf('VBO_SAMET_YEARS_AGO', 3),
143 'value' => '-3 years',
144 ],
145 [
146 'name' => JText::sprintf('VBO_SAMET_YEARS_AGO', 4),
147 'value' => '-4 years',
148 ],
149 [
150 'name' => JText::sprintf('VBO_SAMET_YEARS_AGO', 5),
151 'value' => '-5 years',
152 ],
153 ];
154
155 // start building filters
156 $this->reportFilters = [
157 // pickup date
158 [
159 'label' => '<label for="pickup_date">' . JText::translate('VBO_PICKUP_DATE') . '</label>',
160 'html' => '<input type="text" id="pickup_date" name="pickup_date" value="' . date($df) . '" class="vbo-report-datepicker vbo-report-datepicker-pickupdt vbo-report-datepicker-pickup-date" />',
161 'type' => 'calendar',
162 'name' => 'pickup_date',
163 'default' => date($df),
164 ],
165 // target date from
166 [
167 'label' => '<label for="target_date_from">' . JText::translate('VBO_TARGET_DATE_START') . '</label>',
168 'html' => '<input type="text" id="target_date_from" name="target_date_from" value="' . date(str_replace('d', '01', $df)) . '" class="vbo-report-datepicker vbo-report-datepicker-targetdt vbo-report-datepicker-target-date-from" />',
169 'type' => 'calendar',
170 'name' => 'target_date_from',
171 'default' => date(str_replace('d', '01', $df)),
172 ],
173 // target date to
174 [
175 'label' => '<label for="target_date_to">' . JText::translate('VBO_TARGET_DATE_END') . '</label>',
176 'html' => '<input type="text" id="target_date_to" name="target_date_to" value="' . date(str_replace('d', 't', $df)) . '" class="vbo-report-datepicker vbo-report-datepicker-targetdt vbo-report-datepicker-target-date-to" />',
177 'type' => 'calendar',
178 'name' => 'target_date_to',
179 'default' => date(str_replace('d', 't', $df)),
180 ],
181 // compare against
182 [
183 'label' => '<label for="compare_to">' . JText::translate('VBO_COMPARE_AGAINST') . '</label>',
184 'html' => '<select id="compare_to" name="compare_to"><option value=""></option>' . implode("\n", array_map(function($compareOpt) use ($compare_to) {
185 return '<option value="' . $compareOpt['value'] . '"' . ($compareOpt['value'] == $compare_to ? ' selected="selected"' : '') . '>' . $compareOpt['name'] . '</option>';
186 }, $compareOpts)) . '</select>',
187 'type' => 'select',
188 'name' => 'compare_to',
189 ],
190 // align weekdays
191 [
192 'label' => '<label>' . JText::translate('VBO_ALIGN_WEEKDAYS') . '</label>',
193 'html' => '<span class="vbo-toggle-small">' . $vbo_app->printYesNoButtons('align_wdays', JText::translate('VBYES'), JText::translate('VBNO'), (int) $app->input->getBool('align_wdays', false), 1, 0) . '</span>',
194 'type' => 'checkbox',
195 'name' => 'align_wdays',
196 ],
197 // listing IDs
198 [
199 'label' => '<label for="listingsfilt">' . ucfirst(strtolower(JText::translate('VBROOMFILTER'))) . '</label>',
200 'html' => '<span class="vbo-toolbar-multiselect-wrap">' . $vbo_app->renderElementsDropDown([
201 'id' => 'listingsfilt',
202 'elements' => 'listings',
203 'load_categories' => 1,
204 'categories_lbl' => JText::translate('VBOCATEGORYFILTER'),
205 'placeholder' => JText::translate('VBO_LISTINGS'),
206 'allow_clear' => 1,
207 'attributes' => [
208 'name' => 'listings[]',
209 'multiple' => 'multiple',
210 ],
211 'selected_values' => (array) $app->input->get('listings', [], 'array'),
212 ]) . '</span>',
213 'type' => 'select',
214 'multiple' => true,
215 'name' => 'listings',
216 ],
217 ];
218
219 // set script for rendering the datepicker calendars
220 $juidf = $this->getDateFormat('jui');
221 $defPickupDt = $app->input->getString('pickup_date') ?: date($df);
222 $defTargetFromDt = $app->input->getString('target_date_from') ?: date(str_replace('d', '01', $df));
223 $defTargetToDt = $app->input->getString('target_date_to') ?: date(str_replace('d', 't', $df));
224 $js = <<<JAVASCRIPT
225 jQuery(() => {
226 jQuery('.vbo-report-datepicker-pickupdt:input').datepicker({
227 minDate: '-5Y',
228 maxDate: '+0D',
229 dateFormat: '$juidf',
230 });
231 jQuery('.vbo-report-datepicker-targetdt:input').datepicker({
232 minDate: '$mincheckindt',
233 maxDate: '$maxcheckoutdt',
234 yearRange: '$minyear:$maxyear',
235 changeMonth: true,
236 changeYear: true,
237 dateFormat: '$juidf',
238 onSelect: vboReportCheckTargetDates,
239 });
240 jQuery('.vbo-report-datepicker-pickupdt').datepicker('setDate', '$defPickupDt');
241 jQuery('.vbo-report-datepicker-target-date-from').datepicker('setDate', '$defTargetFromDt');
242 jQuery('.vbo-report-datepicker-target-date-to').datepicker('setDate', '$defTargetToDt');
243 function vboReportCheckTargetDates(selectedDate, inst) {
244 if (selectedDate === null || inst === null) {
245 return;
246 }
247 let cur_from_date = jQuery(this).val();
248 if (jQuery(this).hasClass('vbo-report-datepicker-target-date-from') && cur_from_date.length) {
249 let nowstart = jQuery(this).datepicker('getDate');
250 let nowstartdate = new Date(nowstart.getTime());
251 jQuery('.vbo-report-datepicker-target-date-to').datepicker('option', {minDate: nowstartdate});
252 }
253 }
254 });
255 JAVASCRIPT;
256
257 $this->setScript($js);
258
259 // return all filters
260 return $this->reportFilters;
261 }
262
263 /**
264 * Allows the report to define the sub-filters template.
265 *
266 * @return ?string
267 */
268 public function getSubFiltersTpl()
269 {
270 $app = JFactory::getApplication();
271
272 // current target dates
273 $currentTargetFrom = $app->input->getString('target_date_from', '');
274 $currentTargetTo = $app->input->getString('target_date_to', '');
275
276 // default date format
277 $df = $this->getDateFormat();
278
279 // months map
280 $monthsMap = [
281 JText::translate('VBSHORTMONTHONE'),
282 JText::translate('VBSHORTMONTHTWO'),
283 JText::translate('VBSHORTMONTHTHREE'),
284 JText::translate('VBSHORTMONTHFOUR'),
285 JText::translate('VBSHORTMONTHFIVE'),
286 JText::translate('VBSHORTMONTHSIX'),
287 JText::translate('VBSHORTMONTHSEVEN'),
288 JText::translate('VBSHORTMONTHEIGHT'),
289 JText::translate('VBSHORTMONTHNINE'),
290 JText::translate('VBSHORTMONTHTEN'),
291 JText::translate('VBSHORTMONTHELEVEN'),
292 JText::translate('VBSHORTMONTHTWELVE'),
293 ];
294
295 // starting month
296 $monthStart = new DateTime(date('Y-m-01'), new DateTimeZone(date_default_timezone_get()));
297
298 // build selectable months template
299 $selMonths = '';
300 for ($i = 0; $i < 12; $i++) {
301 $monthIndex = (int) $monthStart->format('n') - 1;
302 $monthName = $monthsMap[$monthIndex] . ' ' . $monthStart->format('y');
303 $setTargetFrom = $monthStart->format(str_replace('d', '01', $df));
304 $setTargetTo = $monthStart->format(str_replace('d', 't', $df));
305 $activeClass = $currentTargetFrom == $setTargetFrom && $currentTargetTo == $setTargetTo ? ' vbo-report-subfilter-month-active' : '';
306 $selMonths .= <<<HTML
307 <span class="vbo-report-subfilter-month$activeClass" data-target-from="$setTargetFrom" data-target-to="$setTargetTo">$monthName</span>
308 HTML;
309 $monthStart->modify('+1 month');
310 }
311
312 // environment vars for template
313 $rateHistoryTitle = json_encode(JText::translate('VBO_RATE_HISTORY'));
314 $nameTitle = json_encode(JText::translate('VBPVIEWROOMONE'));
315 $cancText = json_encode(JText::translate('VBANNULLA'));
316 $applyText = json_encode(JText::translate('VBAPPLY'));
317 $reportAjaxUrl = $this->getAjaxUrl();
318 $addFestAjaxUrl = VikBooking::ajaxUrl('index.php?option=com_vikbooking&task=add_fest');
319
320 // build the sub-filters template string, inclusive of the necessary script
321 $tpl = <<<HTML
322 <div class="vbo-report-subfilters-rms">
323 <div class="vbo-report-subfilter-months">$selMonths</div>
324 </div>
325 <script>
326 /**
327 * @var HTMLElement
328 */
329 let rateHistoryModal;
330
331 /**
332 * Loads the rate history and appends it to the given element.
333 *
334 * @param HTMLElement modalBody The HTML element where the result should be appended.
335 * @param number listingId The listing ID for which rate history should be fetched.
336 * @param string day The day (night) for loading the rate history.
337 *
338 * @return void
339 */
340 const vboRenderRateHistory = function(modalBody, listingId, day) {
341 // start loading animation
342 VBOCore.emitEvent('vbo-rms-occpace-ratehistory-loading');
343
344 // make the request
345 VBOCore.doAjax(
346 "$reportAjaxUrl",
347 {
348 call: 'renderRateHistory',
349 call_args: [
350 listingId,
351 day,
352 ],
353 },
354 (resp) => {
355 // stop loading
356 VBOCore.emitEvent('vbo-rms-occpace-ratehistory-loading');
357
358 try {
359 // decode the response (if needed)
360 let obj_res = typeof resp === 'string' ? JSON.parse(resp) : resp;
361
362 if ((modalBody[0] || modalBody).querySelector('.vbo-rms-rate-history-wrap')) {
363 // modal body already has previously loaded contents
364 (modalBody[0] || modalBody).innerHTML = '';
365 }
366
367 // append response content to modal body
368 modalBody.append(obj_res['html']);
369
370 if (obj_res.hasOwnProperty('chart') && obj_res['chart']) {
371 let canvas = (modalBody[0] || modalBody).querySelector('canvas');
372 if (canvas) {
373 let canvasCtx = canvas.getContext('2d');
374 let chartObj = new Chart(canvasCtx, {
375 type: 'line',
376 data: obj_res['chart'],
377 options: {
378 responsive: true,
379 legend: {
380 display: true,
381 },
382 interaction: {
383 mode: 'index',
384 intersect: false,
385 },
386 scales: {
387 yAxes: [
388 {
389 id: 'y',
390 position: 'left',
391 scaleLabel: {
392 display: true,
393 labelString: obj_res?.chart?.datasets[0]?.label || '',
394 fontColor: obj_res?.chart?.datasets[0]?.borderColor,
395 },
396 },
397 {
398 id: 'y1',
399 position: 'right',
400 scaleLabel: {
401 display: true,
402 labelString: obj_res?.chart?.datasets[1]?.label || '',
403 fontColor: obj_res?.chart?.datasets[1]?.borderColor,
404 },
405 grid: {
406 drawOnChartArea: false,
407 },
408 },
409 ],
410 }
411 }
412 });
413 }
414 }
415 } catch (err) {
416 console.error('Error decoding the response', err, resp);
417 }
418 },
419 (error) => {
420 // display error message
421 alert(error.responseText);
422
423 // stop loading
424 VBOCore.emitEvent('vbo-rms-occpace-ratehistory-loading');
425 }
426 );
427 };
428
429 VBOCore.DOMLoaded(() => {
430
431 /**
432 * Register click event on months.
433 */
434 document.querySelectorAll('.vbo-report-subfilter-month').forEach((selMonth) => {
435 selMonth.addEventListener('click', () => {
436 // gather month dates
437 let fromDt = selMonth.getAttribute('data-target-from');
438 let toDt = selMonth.getAttribute('data-target-to');
439 // update values in datepicker calendars
440 jQuery('.vbo-report-datepicker-target-date-from').datepicker('setDate', fromDt);
441 jQuery('.vbo-report-datepicker-target-date-to').datepicker('setDate', toDt);
442 // remove active month class from all buttons
443 document.querySelectorAll('.vbo-report-subfilter-month.vbo-report-subfilter-month-active').forEach((activeMonth) => {
444 activeMonth.classList.remove('vbo-report-subfilter-month-active');
445 });
446 // add active month class to current button
447 selMonth.classList.add('vbo-report-subfilter-month-active');
448 // trigger event to (re)load report data
449 VBOCore.emitEvent('vbo_report_reload');
450 });
451 });
452
453 /**
454 * Subscribe to global event for when new rates have been applied.
455 * (i.e. through the widget "bookings_calendar")
456 */
457 document.addEventListener('vbo-room-rates-updated', (e) => {
458 if (rateHistoryModal && e?.detail?.room_id && e?.detail?.ymd) {
459 // refresh rate history modal content for the updated day
460 vboRenderRateHistory(rateHistoryModal, e.detail.room_id, e.detail.ymd);
461 }
462 });
463
464 /**
465 * Add body click event delegation for elements that may be later added to the DOM.
466 */
467 document.body.addEventListener('click', (e) => {
468 if (e.target.matches('.vbo-report-cell-metric') || e.target.closest('.vbo-report-cell-metric')) {
469 // metric clicked
470 const metricEl = !e.target.matches('.vbo-report-cell-metric') ? e.target.closest('.vbo-report-cell-metric') : e.target;
471 const metricType = metricEl.getAttribute('data-type');
472 if (metricType === 'nightly-rate' || metricType === 'vrplus' || metricType === 'vrminus') {
473 // show rates history for the clicked listing and stay date
474 let day = metricEl.getAttribute('data-day');
475 let listingId = metricEl.getAttribute('data-listing-id');
476
477 // display modal
478 rateHistoryModal = VBOCore.displayModal({
479 suffix: 'rms_occpace_ratehistory',
480 title: $rateHistoryTitle,
481 lock_scroll: true,
482 draggable: true,
483 enlargeable: true,
484 extra_class: 'vbo-modal-rounded vbo-modal-tall vbo-modal-nofooter vbo-modal-footerpad',
485 loading_event: 'vbo-rms-occpace-ratehistory-loading',
486 dismiss_event: 'vbo-rms-occpace-ratehistory-dismiss',
487 });
488
489 // load and render rate history
490 vboRenderRateHistory(rateHistoryModal, listingId, day);
491 } else if (metricType === 'abrn') {
492 // show bookings for this day and listing
493 let day = metricEl.getAttribute('data-day');
494 let listingId = metricEl.getAttribute('data-listing-id');
495 // display widget
496 VBOCore.handleDisplayWidgetNotification({
497 widget_id: 'bookings_calendar'
498 }, {
499 offset: day,
500 day: day,
501 id_room: listingId,
502 });
503 }
504
505 // do not proceed
506 return;
507 }
508
509 if (e.target.matches('.vbo-panel-tabs-cmd[data-newrate]') || e.target.closest('.vbo-panel-tabs-cmd[data-newrate]')) {
510 // set-new-rate button clicked
511 const btnEl = !e.target.matches('.vbo-panel-tabs-cmd[data-newrate]') ? e.target.closest('.vbo-panel-tabs-cmd[data-newrate]') : e.target;
512 let day = btnEl.getAttribute('data-day');
513 let listingId = btnEl.getAttribute('data-listing-id');
514 let priceId = btnEl.getAttribute('data-price-id');
515
516 // display widget
517 VBOCore.handleDisplayWidgetNotification({
518 widget_id: 'bookings_calendar'
519 }, {
520 offset: day,
521 day: day,
522 id_room: listingId,
523 id_price: priceId,
524 roomrates: 1,
525 });
526
527 // do not proceed
528 return;
529 }
530
531 if (e.target.matches('.vbo-panel-tabs-cmd[data-cmd]') || e.target.closest('.vbo-panel-tabs-cmd[data-cmd]')) {
532 // rate history navigation button clicked
533 const btnEl = !e.target.matches('.vbo-panel-tabs-cmd[data-cmd]') ? e.target.closest('.vbo-panel-tabs-cmd[data-cmd]') : e.target;
534 let day = btnEl.getAttribute('data-day');
535 let listingId = btnEl.getAttribute('data-listing-id');
536
537 // load and render rate history
538 vboRenderRateHistory(rateHistoryModal, listingId, day);
539
540 // do not proceed
541 return;
542 }
543
544 if (e.target.matches('.vbo-panel-tabs-tab[data-tab]') || e.target.closest('.vbo-panel-tabs-tab[data-tab]')) {
545 // panel tab clicked
546 const tabEl = !e.target.matches('.vbo-panel-tabs-tab[data-tab]') ? e.target.closest('.vbo-panel-tabs-tab[data-tab]') : e.target;
547 const panelsTabsWrap = tabEl.closest('.vbo-panel-tabs-wrap');
548 const tabType = tabEl.getAttribute('data-tab');
549 // iterate all panels
550 panelsTabsWrap.querySelectorAll('.vbo-panel-tabs-panel').forEach((panel) => {
551 let panelType = panel.getAttribute('data-panel');
552 if (panelType === tabType) {
553 // show panel
554 panel.style.display = '';
555 tabEl.classList.add('active');
556 } else {
557 // hide panel
558 panel.style.display = 'none';
559 // remove tab active class
560 panelsTabsWrap.querySelector('.vbo-panel-tabs-tab[data-tab="' + panelType + '"]').classList.remove('active');
561 }
562 });
563
564 // do not proceed
565 return;
566 }
567
568 if (e.target.matches('.vbo-report-cell-comparison[data-type="abrn"]') || e.target.closest('.vbo-report-cell-comparison[data-type="abrn"]')) {
569 // global rooms occupancy metric clicked
570 const btnEl = !e.target.matches('.vbo-report-cell-comparison[data-type="abrn"]') ? e.target.closest('.vbo-report-cell-comparison[data-type="abrn"]') : e.target;
571 let day = btnEl.getAttribute('data-day');
572
573 // display widget
574 VBOCore.handleDisplayWidgetNotification({
575 widget_id: 'bookings_calendar'
576 }, {
577 offset: day,
578 day: day,
579 });
580
581 // do not proceed
582 return;
583 }
584
585 if (e.target.matches('button[data-event-day]') || e.target.closest('button[data-event-day]')) {
586 // add event button clicked
587 const btnEl = !e.target.matches('button[data-event-day]') ? e.target.closest('button[data-event-day]') : e.target;
588 const day = btnEl.getAttribute('data-event-day');
589
590 // display dialog modal
591 let dialogBody = document.createElement('div');
592 dialogBody.classList.add('vbo-admin-container', 'vbo-admin-container-full', 'vbo-admin-container-compact');
593 let wrap = document.createElement('div');
594 wrap.classList.add('vbo-params-wrap');
595 let paramContainer = document.createElement('div');
596 paramContainer.classList.add('vbo-param-container');
597 let paramLabel = document.createElement('div');
598 paramLabel.classList.add('vbo-param-label');
599 paramLabel.innerText = $nameTitle;
600 let paramSetting = document.createElement('div');
601 paramSetting.classList.add('vbo-param-setting');
602 let festControl = document.createElement('input');
603 festControl.setAttribute('type', 'text');
604
605 // build dialog body
606 paramSetting.append(festControl);
607 paramContainer.append(paramLabel);
608 paramContainer.append(paramSetting);
609 wrap.append(paramContainer);
610 dialogBody.append(wrap);
611
612 // build dialog buttons
613 let dialogCancelBtn = document.createElement('button');
614 dialogCancelBtn.setAttribute('type', 'button');
615 dialogCancelBtn.classList.add('btn');
616 dialogCancelBtn.innerText = $cancText;
617 dialogCancelBtn.addEventListener('click', () => {
618 VBOCore.emitEvent('vbo-rms-occpace-newevent-dismiss');
619 });
620 let dialogApplyBtn = document.createElement('button');
621 dialogApplyBtn.setAttribute('type', 'button');
622 dialogApplyBtn.classList.add('btn', 'btn-success');
623 dialogApplyBtn.innerText = $applyText;
624 dialogApplyBtn.addEventListener('click', () => {
625 if (festControl.value) {
626 // start loading
627 VBOCore.emitEvent('vbo-rms-occpace-newevent-loading');
628
629 // make the request
630 VBOCore.doAjax(
631 "$addFestAjaxUrl",
632 {
633 dt: day,
634 name: festControl.value,
635 },
636 (resp) => {
637 // stop loading
638 VBOCore.emitEvent('vbo-rms-occpace-newevent-loading');
639
640 try {
641 // decode the response (if needed), and append the content to the modal body
642 let obj_res = typeof resp === 'string' ? JSON.parse(resp) : resp;
643
644 // add the festivity name to the cell that originated the click
645 let eventEl = document.createElement('div');
646 eventEl.classList.add('vbo-report-cell-texts');
647 eventEl.innerText = festControl.value;
648 btnEl.replaceWith(eventEl);
649
650 // dismiss modal
651 VBOCore.emitEvent('vbo-rms-occpace-newevent-dismiss');
652 } catch (err) {
653 console.error('Error decoding the response', err, resp);
654 }
655 },
656 (error) => {
657 // display error message
658 alert(error.responseText);
659
660 // stop loading
661 VBOCore.emitEvent('vbo-rms-occpace-newevent-loading');
662 }
663 );
664 }
665 });
666
667 // display modal
668 VBOCore.displayModal({
669 suffix: 'rms_occpace_newevent',
670 title: day,
671 body: dialogBody,
672 lock_scroll: true,
673 footer_left: dialogCancelBtn,
674 footer_right: dialogApplyBtn,
675 extra_class: 'vbo-modal-rounded vbo-modal-dialog',
676 loading_event: 'vbo-rms-occpace-newevent-loading',
677 dismiss_event: 'vbo-rms-occpace-newevent-dismiss',
678 });
679
680 // do not proceed
681 return;
682 }
683 });
684
685 });
686 </script>
687 HTML;
688
689 return $tpl;
690 }
691
692 /**
693 * Loads report data by setting the columns and rows to be displayed.
694 *
695 * @return bool
696 */
697 public function getReportData()
698 {
699 if ($this->getError()) {
700 // export functions may set errors rather than exiting the process, and
701 // the View may continue the execution to attempt to render the report.
702 return false;
703 }
704
705 if ($this->rows) {
706 // method must have run already
707 return true;
708 }
709
710 $app = JFactory::getApplication();
711
712 // default environment values
713 $df = $this->getDateFormat();
714 $datesep = VikBooking::getDateSeparator();
715 $currency_symb = VikBooking::getCurrencySymb();
716
717 // week days short names map
718 $wdays_map = [
719 JText::translate('VBSUN'),
720 JText::translate('VBMON'),
721 JText::translate('VBTUE'),
722 JText::translate('VBWED'),
723 JText::translate('VBTHU'),
724 JText::translate('VBFRI'),
725 JText::translate('VBSAT'),
726 ];
727
728 // get the possibly injected report options
729 $options = $this->getReportOptions();
730
731 // collect report filters
732 $pickup_date = $options->get('pickup_date') ?: $app->input->getString('pickup_date') ?: date($df);
733 $target_date_from = $options->get('target_date_from') ?: $app->input->getString('target_date_from') ?: date(str_replace('d', '01', $df));
734 $target_date_to = $options->get('target_date_to') ?: $app->input->getString('target_date_to') ?: date(str_replace('d', 't', $df));
735 $compare_to = $options->get('compare_to') ?: $app->input->getString('compare_to') ?: '';
736 $interval = $options->get('interval') ?: $app->input->getString('interval') ?: 'DAY';
737 $align_wdays = (bool) ($options->get('align_wdays') ?: $app->input->getString('align_wdays') ?: 0);
738 $listing_ids = (array) ($options->get('listings') ?: $app->input->getString('listings') ?: []);
739
740 // determine sorting and ordering
741 $krsort = $app->input->getString('krsort') ?: $this->defaultKeySort;
742 $krorder = $app->input->getString('krorder') ?: $this->defaultKeyOrder;
743 $krorder = $krorder == 'DESC' ? 'DESC' : 'ASC';
744
745 // build comparison data set(s)
746 $compareDatas = [];
747 if ($compare_to) {
748 $compareDatas[] = [
749 'to' => $compare_to,
750 'align_wdays' => $align_wdays,
751 ];
752 }
753
754 // tell whether sorting is allowed
755 $sortingAllowed = $compareDatas ? 0 : 1;
756
757 // build pace options
758 $this->paceOptions = [
759 'pickup' => [
760 'date' => date('Y-m-d', VikBooking::getDateTimestamp($pickup_date)),
761 ],
762 'target' => [
763 'from' => date('Y-m-d', VikBooking::getDateTimestamp($target_date_from)),
764 'to' => date('Y-m-d', VikBooking::getDateTimestamp($target_date_to)),
765 ],
766 'compare' => $compareDatas,
767 'interval' => $interval,
768 'listings' => $listing_ids,
769 'sort_rooms' => 'occupancy',
770 ];
771
772 try {
773 // obtain occupancy pace data
774 $paceData = VBORmsPace::getInstance()->getOccupancyData($this->paceOptions);
775 } catch (Exception $e) {
776 $this->setError($e->getMessage());
777 return false;
778 }
779
780 // obtain the total inventory count
781 $totalInventoryCount = $paceData['inventory_count'] ?? 1;
782
783 // obtain the first N listing details
784 $maxListingCols = (int) ($options->get('max_listings') ?: (!$listing_ids ? 4 : 10));
785 $showcaseListings = array_slice(($paceData['listings'] ?? []), 0, $maxListingCols, true);
786
787 // define the default report columns
788 $defaultCols = [
789 // line number
790 [
791 'key' => 'line',
792 'label' => 'Line',
793 // hide this field in the View and do not export it
794 'ignore_view' => 1,
795 'ignore_export' => 1,
796 ],
797 // period start date
798 [
799 'key' => 'date',
800 'label' => JText::translate('VBPVIEWORDERSONE'),
801 'sortable' => $sortingAllowed,
802 ],
803 // events
804 [
805 'key' => 'events',
806 'label' => JText::translate('VBO_EVENTS'),
807 'center' => 1,
808 ],
809 // abrn
810 [
811 'key' => 'abrn',
812 'label' => JText::translate('VBOROOMSOCCUPANCY'),
813 'sortable' => $sortingAllowed,
814 'center' => 1,
815 'tip' => JText::sprintf('VBOREPORTTOTROOMSHELP', $totalInventoryCount),
816 'tip_pos' => 'bottom',
817 ],
818 // sellable (unsold) units
819 [
820 'key' => 'sellable',
821 'label' => JText::translate('VBOROOMSUNSOLD'),
822 'sortable' => $sortingAllowed,
823 'center' => 1,
824 ],
825 // adr
826 [
827 'key' => 'adr',
828 'label' => JText::translate('VBOREPORTREVENUEADR'),
829 'sortable' => $sortingAllowed,
830 'center' => 1,
831 ],
832 // RevPar
833 [
834 'key' => 'revpar',
835 'label' => JText::translate('VBOREPORTREVENUEREVPAR'),
836 'sortable' => $sortingAllowed,
837 'center' => 1,
838 ],
839 // rate variation breakpoint
840 [
841 'key' => 'ratevr',
842 'label' => JText::translate('VBO_VARIATION'),
843 'center' => 1,
844 ],
845 // Room Revenue
846 [
847 'key' => 'roomrev',
848 'label' => JText::translate('VBO_ROOM_REVENUE'),
849 'sortable' => $sortingAllowed,
850 'center' => 1,
851 ],
852 // Gross Booking Revenue
853 [
854 'key' => 'grossrev',
855 'label' => JText::translate('VBO_GROSS_BOOKING_VALUE'),
856 'sortable' => $sortingAllowed,
857 'center' => 1,
858 ],
859 ];
860
861 // build final report columns by pushing one column per showcased listing
862 $this->cols = [];
863 foreach ($defaultCols as $defaultCol) {
864 // push column
865 $this->cols[] = $defaultCol;
866
867 // check for exact insert-point
868 if ($defaultCol['key'] === 'abrn') {
869 // iterate all listings to showcase
870 foreach ($showcaseListings as $listing_id => $listing_data) {
871 // build "compact" listing name
872 $listingCompactName = $this->compactName($listing_data['name'], 12, '..');
873 $nameCompacted = $listingCompactName != $listing_data['name'];
874
875 // push listing-level "abrn" column
876 $this->cols[] = [
877 'key' => 'abrn_' . $listing_id,
878 'label' => $listingCompactName,
879 'sortable' => 1,
880 'center' => 1,
881 'tip' => ($nameCompacted ? $listing_data['name'] : null),
882 'tip_pos' => 'bottom',
883 'tip_label' => ($nameCompacted ? strtoupper($listingCompactName) : null),
884 ];
885 }
886 }
887 }
888
889 // count all pace data targets
890 $paceTargetsCount = count($paceData['pace']);
891
892 // start global and target line counters
893 $lineCounter = 0;
894 $lineTargetCounter = 0;
895
896 // start metric indexes and values for the various column keys
897 $metricIndexes = [];
898 $metricValues = [];
899
900 // start target names
901 $startingTs = strtotime($this->paceOptions['target']['from']);
902 $targetNames = [
903 // main target
904 substr($this->paceOptions['target']['from'], 0, 4),
905 ];
906 foreach ($this->paceOptions['compare'] as $cmp) {
907 // push target comparison year as name
908 $targetNames[] = date('Y', strtotime($cmp['to'], $startingTs));
909 }
910
911 // build report row classes list
912 $rowClasses = [];
913 $rowGroupIndex = 0;
914
915 // iterate the main pace data target dates
916 foreach (($paceData['pace'][0] ?? []) as $parseIndex => $metrics) {
917 // increase main target line counter
918 $lineTargetCounter++;
919
920 // loop through all pace data target indexes
921 for ($targetIndex = 0; $targetIndex < $paceTargetsCount; $targetIndex++) {
922 // prior loading of comparison metrics for main target
923 $comparisonResults = [];
924 if (!$targetIndex && $paceTargetsCount > 1) {
925 $comparisonResults = [
926 'abrn' => $paceData['pace'][($targetIndex + 1)][$parseIndex]['abrn'] ?? null,
927 ];
928 }
929
930 // determine the current period (date) in Y-m-d format
931 $currentPeriodYmd = $paceData['pace'][$targetIndex][$parseIndex]['date']->format('Y-m-d');
932
933 // build default row columns to be pushed
934 $defaultMetricRowCols = [
935 // line number
936 [
937 'key' => 'line',
938 'value' => $lineCounter,
939 'ignore_view' => 1,
940 'ignore_export' => 1,
941 ],
942 // period start date
943 [
944 'key' => 'date',
945 'callback' => function($dt) use ($targetIndex, $df, $datesep, $wdays_map) {
946 $icn = $targetIndex ? '<i class="' . VikBookingIcons::i('history') . '"></i> ' : '';
947 return $icn . sprintf('%s, %s', $wdays_map[$dt->format('w')] ?? '', $dt->format(str_replace("/", $datesep, $df)));
948 },
949 'callback_export' => function($dt) use ($df, $datesep, $wdays_map) {
950 return sprintf('%s, %s', $wdays_map[$dt->format('w')] ?? '', $dt->format(str_replace("/", $datesep, $df)));
951 },
952 'value' => $paceData['pace'][$targetIndex][$parseIndex]['date'],
953 ],
954 // events
955 [
956 'key' => 'events',
957 'html' => 1,
958 'callback' => function($events) use ($targetIndex, $currentPeriodYmd) {
959 if (!$events) {
960 if ($targetIndex) {
961 return '';
962 }
963
964 // add button to create a new event
965 $addEvIcn = '<i class="' . VikBookingIcons::i('plus', 'icn-nomargin') . '"></i>';
966 return <<<HTML
967 <button class="btn vbo-btn-square" data-event-day="$currentPeriodYmd">$addEvIcn</button>
968 HTML;
969 }
970 $evString = implode(', ', array_column($events, 'name'));
971 return <<<HTML
972 <div class="vbo-report-cell-texts"><span>$evString</span></div>
973 HTML;
974 },
975 'callback_export' => function($events) {
976 return $events ? implode(', ', array_column($events, 'name')) : '';
977 },
978 'value' => $paceData['pace'][$targetIndex][$parseIndex]['events'],
979 'center' => 1,
980 ],
981 // abrn
982 [
983 'key' => 'abrn',
984 'html' => 1,
985 'callback' => function($value) use ($targetIndex, $totalInventoryCount, $comparisonResults, $currentPeriodYmd) {
986 $pcentOcc = $value * 100 / ($totalInventoryCount ?: 1);
987 if ($pcentOcc > 60) {
988 $highlightCls = ' label-success';
989 } elseif ($pcentOcc > 20) {
990 $highlightCls = ' label-warning';
991 } else {
992 $highlightCls = ' label-error';
993 }
994
995 if ($targetIndex) {
996 // comparison row will get no coloring
997 $highlightCls = '';
998 }
999
1000 // build comparison result template
1001 $compareTpl = '';
1002 if (($comparisonResults['abrn'] ?? null) !== null) {
1003 // tell if we are up or down compared to a past year
1004 $going_up = $value > (int) $comparisonResults['abrn'];
1005 $goingCls = $going_up ? 'up' : 'down';
1006 $goingIcn = '<i class="' . VikBookingIcons::i($going_up ? 'arrow-up' : 'arrow-down') . '"></i>';
1007 $highlightCls = $going_up ? ' label-success' : ' label-error';
1008
1009 // set comparison result template
1010 $compareTpl = <<<HTML
1011 <span class="vbo-report-cell-compare-result $goingCls">$goingIcn</span>
1012 HTML;
1013
1014 if ($value == $comparisonResults['abrn']) {
1015 // do not set any comparison result when the result is identical
1016 $compareTpl = '';
1017 $highlightCls = '';
1018 }
1019 }
1020
1021 return <<<HTML
1022 <div class="vbo-report-cell-comparison" data-type="abrn" data-day="$currentPeriodYmd">
1023 <span class="label$highlightCls">$value</span>
1024 $compareTpl
1025 </div>
1026 HTML;
1027 },
1028 'callback_export' => function($value) {
1029 return $value;
1030 },
1031 'value' => $paceData['pace'][$targetIndex][$parseIndex]['abrn'],
1032 'center' => 1,
1033 ],
1034 // sellable (unsold) units
1035 [
1036 'key' => 'sellable',
1037 'callback' => function($value) use ($targetIndex) {
1038 if (!$value) {
1039 return '<span class="label label-success">' . $value . '</span>';
1040 }
1041 if (!$targetIndex) {
1042 return '<span class="label label-warning">' . $value . '</span>';
1043 }
1044 return '<span class="label">' . $value . '</span>';
1045 },
1046 'callback_export' => function($value) {
1047 return $value;
1048 },
1049 'value' => $paceData['pace'][$targetIndex][$parseIndex]['sellable'],
1050 'center' => 1,
1051 ],
1052 // adr
1053 [
1054 'key' => 'adr',
1055 'value' => $paceData['pace'][$targetIndex][$parseIndex]['adr'],
1056 'center' => 1,
1057 'callback' => function($metric) {
1058 return VikBooking::formatCurrencyNumber($metric);
1059 },
1060 ],
1061 // RevPar
1062 [
1063 'key' => 'revpar',
1064 'value' => $paceData['pace'][$targetIndex][$parseIndex]['revpar'],
1065 'center' => 1,
1066 'callback' => function($metric) {
1067 return VikBooking::formatCurrencyNumber($metric);
1068 },
1069 ],
1070 // rate variation breakpoint
1071 [
1072 'key' => 'ratevr',
1073 'html' => 1,
1074 'callback' => function($rateVrData) use ($listing_id, $currentPeriodYmd) {
1075 list($rateVrDt, $rateVrPlus, $rateVrMinus) = $rateVrData;
1076 if (!$rateVrPlus && !$rateVrMinus) {
1077 return '';
1078 }
1079
1080 // build variation plus and minus template variables
1081 $icnPlusCls = VikBookingIcons::i('plus');
1082 $icnMinusCls = VikBookingIcons::i('minus');
1083
1084 return <<<HTML
1085 <div class="vbo-report-cell-metrics">
1086 <div class="vbo-report-cell-metric" data-type="vrplus" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1087 <span class="label label-success"><i class="$icnPlusCls"></i> $rateVrPlus</span>
1088 </div>
1089 <div class="vbo-report-cell-metric" data-type="vrminus" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1090 <span class="label label-error"><i class="$icnMinusCls"></i> $rateVrMinus</span>
1091 </div>
1092 </div>
1093 HTML;
1094 },
1095 'callback_export' => function($rateVrData) {
1096 list($rateVrDt, $rateVrPlus, $rateVrMinus) = $rateVrData;
1097 if (!$rateVrPlus && !$rateVrMinus) {
1098 return '';
1099 }
1100 return sprintf('+%d, -%d', $rateVrPlus, $rateVrMinus);
1101 },
1102 'value' => [
1103 $paceData['pace'][$targetIndex][$parseIndex]['ratevrdt'],
1104 $paceData['pace'][$targetIndex][$parseIndex]['ratevrplus'],
1105 $paceData['pace'][$targetIndex][$parseIndex]['ratevrminus'],
1106 ],
1107 'center' => 1,
1108 ],
1109 // Room Revenue
1110 [
1111 'key' => 'roomrev',
1112 'value' => array_sum((array) ($paceData['pace'][$targetIndex][$parseIndex]['roomrev'] ?? [])),
1113 'center' => 1,
1114 'callback' => function($metric) {
1115 return VikBooking::formatCurrencyNumber($metric);
1116 },
1117 ],
1118 // Gross Booking Revenue
1119 [
1120 'key' => 'grossrev',
1121 'value' => array_sum((array) ($paceData['pace'][$targetIndex][$parseIndex]['grossrev'] ?? [])),
1122 'center' => 1,
1123 'callback' => function($metric) {
1124 return VikBooking::formatCurrencyNumber($metric);
1125 },
1126 ],
1127 ];
1128
1129 // build final row columns to be pushed
1130 $metricRowCols = [];
1131 foreach ($defaultMetricRowCols as $defaultMetricRowCol) {
1132 // push column
1133 $metricRowCols[] = $defaultMetricRowCol;
1134
1135 // check for exact insert-point
1136 if ($defaultMetricRowCol['key'] === 'abrn') {
1137 // iterate all listings to showcase
1138 foreach ($showcaseListings as $listing_id => $listing_data) {
1139 // gather listing-level nightly rate and total units
1140 $listingNightlyRate = $paceData['pace'][$targetIndex][$parseIndex]['nightlyrates'][$listing_id] ?? null;
1141 $listingUnits = $paceData['listings'][$listing_id]['units'] ?? 0;
1142
1143 // push listing-level "abrn" column value
1144 $metricRowCols[] = [
1145 'key' => 'abrn_' . $listing_id,
1146 'html' => 1,
1147 'callback' => function($value) use ($targetIndex, $listing_id, $currentPeriodYmd, $listingNightlyRate, $listingUnits) {
1148 $pcentOcc = $value * 100 / ($listingUnits ?: 1);
1149 if ($pcentOcc > 60) {
1150 $highlightCls = ' label-success';
1151 } elseif ($pcentOcc > 20) {
1152 $highlightCls = ' label-warning';
1153 } else {
1154 $highlightCls = ' label-error';
1155 }
1156
1157 if ($targetIndex) {
1158 // comparison row will get no coloring
1159 $highlightCls = '';
1160 }
1161
1162 // build nightly rate metric template
1163 $metricTpl = '';
1164 if ($listingNightlyRate) {
1165 $listingNightlyRate = VikBooking::formatCurrencyNumber($listingNightlyRate);
1166 $metricTpl = <<<HTML
1167 <div class="vbo-report-cell-metric" data-type="nightly-rate" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1168 <span class="label label-info">$listingNightlyRate</span>
1169 </div>
1170 HTML;
1171 } else {
1172 // display an empty box when no rate details available
1173 $metricTpl = <<<HTML
1174 <div class="vbo-report-cell-metric" data-type="empty-nightly-rate">
1175 <span class="label">---</span>
1176 </div>
1177 HTML;
1178 }
1179
1180 return <<<HTML
1181 <div class="vbo-report-cell-metrics">
1182 <div class="vbo-report-cell-metric" data-type="abrn" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1183 <span class="label$highlightCls">$value</span>
1184 </div>
1185 $metricTpl
1186 </div>
1187 HTML;
1188 },
1189 'callback_export' => function($value) {
1190 return $value;
1191 },
1192 'value' => $paceData['pace'][$targetIndex][$parseIndex]['bookedrooms'][$listing_id] ?? 0,
1193 'center' => 1,
1194 ];
1195 }
1196 }
1197 }
1198
1199 if (!$metricIndexes) {
1200 // store the indexes for each metric key
1201 $metricIndexes = array_values(array_column(array_filter($metricRowCols, function($colData) {
1202 return empty($colData['ignore_view']);
1203 }), 'key'));
1204 }
1205
1206 // update metric values for current target to be used for the footer calculations
1207 $metricValues['abrn'][$targetIndex] = ($metricValues['abrn'][$targetIndex] ?? 0) + $paceData['pace'][$targetIndex][$parseIndex]['abrn'];
1208 $metricValues['adr'][$targetIndex] = ($metricValues['adr'][$targetIndex] ?? 0) + $paceData['pace'][$targetIndex][$parseIndex]['adr'];
1209 $metricValues['revpar'][$targetIndex] = ($metricValues['revpar'][$targetIndex] ?? 0) + $paceData['pace'][$targetIndex][$parseIndex]['revpar'];
1210 $metricValues['grossrev'][$targetIndex] = ($metricValues['grossrev'][$targetIndex] ?? []) + (array) ($paceData['pace'][$targetIndex][$parseIndex]['grossrev'] ?? []);
1211
1212 // push metric row with calculated columns
1213 $this->rows[] = $metricRowCols;
1214
1215 // push row group class(es)
1216 $rowClasses[] = array_values(array_filter([
1217 sprintf('vbo-report-row-group-%d', $rowGroupIndex),
1218 ($targetIndex ? 'vbo-report-row-comparison' : ''),
1219 ]));
1220
1221 // increase counter
1222 $lineCounter++;
1223 }
1224
1225 // update rows group index
1226 $rowGroupIndex = 1 - $rowGroupIndex;
1227 }
1228
1229 // set report row classes
1230 $this->setReportRowClasses($rowClasses);
1231
1232 if ($sortingAllowed) {
1233 // sort rows
1234 $this->sortRows($krsort, $krorder);
1235 }
1236
1237 // count total cells per row and total metric values for the footer
1238 $cellsPerRow = count($metricIndexes);
1239 $footerColsCount = count($metricValues) ?: 1;
1240 $footerColspan = floor($cellsPerRow / $footerColsCount);
1241
1242 // build report footer row
1243 $this->footerRow[] = [
1244 // push one single column for "abrn", "adr", "RevPar" and "Gross Booking Revenue"
1245 [
1246 'callback' => function($allMetrics) use ($lineTargetCounter, $totalInventoryCount, $targetNames) {
1247 // build all metrics template
1248 $allMetricsTpl = <<<HTML
1249 <div class="vbo-report-footer-rms-list">
1250 HTML;
1251
1252 // abrn
1253 $metrics = $allMetrics['abrn'] ?? [];
1254 $blockTitle = JText::translate('VBOROOMSOCCUPANCY');
1255 $blockMetricsTpl = '';
1256 $targetMetrics = [];
1257 foreach ($metrics as $targetIndex => $totalValue) {
1258 // calculate metric average value
1259 $avgValue = round($totalValue / ($lineTargetCounter ?: 1), 2);
1260
1261 // set metric for this target
1262 $targetMetrics[$targetIndex] = $avgValue;
1263
1264 // calculate average occupancy percent
1265 $avgOccPcent = round($avgValue * 100 / ($totalInventoryCount ?: 1), 2);
1266
1267 // obtain target name
1268 $targetName = $targetNames[$targetIndex] ?? '';
1269
1270 // set metric template value
1271 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1272 $blockMetricsTpl .= <<<HTML
1273 <div class="vbo-report-footer-rms-metric $metricCls">
1274 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1275 <div class="vbo-report-footer-rms-metric-value">$totalValue <span>($avgOccPcent%)</span></div>
1276 </div>
1277 HTML;
1278 }
1279
1280 // growth template
1281 $growthTpl = '';
1282 if (count($targetMetrics) > 1) {
1283 // calculate the percent of growth
1284 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1285 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1286 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1287 $growthTpl = <<<HTML
1288 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1289 HTML;
1290 }
1291
1292 // set the HTML string
1293 $allMetricsTpl .= <<<HTML
1294 <div class="vbo-report-footer-rms-wrap">
1295 <div class="vbo-report-footer-rms-title">
1296 <span>$blockTitle</span>
1297 $growthTpl
1298 </div>
1299 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1300 </div>
1301 HTML;
1302
1303 // adr
1304 $metrics = $allMetrics['adr'] ?? [];
1305 $blockTitle = JText::translate('VBOREPORTREVENUEADR');
1306 $blockMetricsTpl = '';
1307 $targetMetrics = [];
1308 foreach ($metrics as $targetIndex => $totalValue) {
1309 // calculate metric average value
1310 $avgValue = round($totalValue / ($lineTargetCounter ?: 1), 2);
1311 $avgValueFormat = VikBooking::formatCurrencyNumber($avgValue);
1312
1313 // set metric for this target
1314 $targetMetrics[$targetIndex] = $avgValue;
1315
1316 // obtain target name
1317 $targetName = $targetNames[$targetIndex] ?? '';
1318
1319 // set metric template value
1320 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1321 $blockMetricsTpl .= <<<HTML
1322 <div class="vbo-report-footer-rms-metric $metricCls">
1323 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1324 <div class="vbo-report-footer-rms-metric-value">$avgValueFormat</div>
1325 </div>
1326 HTML;
1327 }
1328
1329 // growth template
1330 $growthTpl = '';
1331 if (count($targetMetrics) > 1) {
1332 // calculate the percent of growth
1333 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1334 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1335 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1336 $growthTpl = <<<HTML
1337 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1338 HTML;
1339 }
1340
1341 // set the HTML string
1342 $allMetricsTpl .= <<<HTML
1343 <div class="vbo-report-footer-rms-wrap">
1344 <div class="vbo-report-footer-rms-title">
1345 <span>$blockTitle</span>
1346 $growthTpl
1347 </div>
1348 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1349 </div>
1350 HTML;
1351
1352 // RevPAR
1353 $metrics = $allMetrics['revpar'] ?? [];
1354 $blockTitle = JText::translate('VBOREPORTREVENUEREVPAR');
1355 $blockMetricsTpl = '';
1356 $targetMetrics = [];
1357 foreach ($metrics as $targetIndex => $totalValue) {
1358 // calculate metric average value
1359 $avgValue = round($totalValue / ($lineTargetCounter ?: 1), 2);
1360 $avgValueFormat = VikBooking::formatCurrencyNumber($avgValue);
1361
1362 // set metric for this target
1363 $targetMetrics[$targetIndex] = $avgValue;
1364
1365 // obtain target name
1366 $targetName = $targetNames[$targetIndex] ?? '';
1367
1368 // set metric template value
1369 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1370 $blockMetricsTpl .= <<<HTML
1371 <div class="vbo-report-footer-rms-metric $metricCls">
1372 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1373 <div class="vbo-report-footer-rms-metric-value">$avgValueFormat</div>
1374 </div>
1375 HTML;
1376 }
1377
1378 // growth template
1379 $growthTpl = '';
1380 if (count($targetMetrics) > 1) {
1381 // calculate the percent of growth
1382 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1383 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1384 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1385 $growthTpl = <<<HTML
1386 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1387 HTML;
1388 }
1389
1390 // set the HTML string
1391 $allMetricsTpl .= <<<HTML
1392 <div class="vbo-report-footer-rms-wrap">
1393 <div class="vbo-report-footer-rms-title">
1394 <span>$blockTitle</span>
1395 $growthTpl
1396 </div>
1397 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1398 </div>
1399 HTML;
1400
1401 // gross booking revenue
1402 $metrics = $allMetrics['grossrev'] ?? [];
1403 $blockTitle = JText::translate('VBO_GROSS_BOOKING_VALUE');
1404 $blockMetricsTpl = '';
1405 $targetMetrics = [];
1406 foreach ($metrics as $targetIndex => $totalAssoc) {
1407 // calculate metric total value
1408 $metricTotalValue = array_sum((array) $totalAssoc);
1409
1410 // set metric for this target
1411 $targetMetrics[$targetIndex] = $metricTotalValue;
1412
1413 // obtain target name
1414 $targetName = $targetNames[$targetIndex] ?? '';
1415
1416 // count total value per current target
1417 $totalValue = VikBooking::formatCurrencyNumber($metricTotalValue);
1418
1419 // set metric template value
1420 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1421 $blockMetricsTpl .= <<<HTML
1422 <div class="vbo-report-footer-rms-metric $metricCls">
1423 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1424 <div class="vbo-report-footer-rms-metric-value">$totalValue</div>
1425 </div>
1426 HTML;
1427 }
1428
1429 // growth template
1430 $growthTpl = '';
1431 if (count($targetMetrics) > 1) {
1432 // calculate the percent of growth
1433 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1434 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1435 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1436 $growthTpl = <<<HTML
1437 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1438 HTML;
1439 }
1440
1441 // return the HTML string to build this footer cell
1442 $allMetricsTpl .= <<<HTML
1443 <div class="vbo-report-footer-rms-wrap">
1444 <div class="vbo-report-footer-rms-title">
1445 <span>$blockTitle</span>
1446 $growthTpl
1447 </div>
1448 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1449 </div>
1450 HTML;
1451
1452 // close list-wrapper div
1453 $allMetricsTpl .= <<<HTML
1454 </div>
1455 HTML;
1456
1457 // return the final HTML string template
1458 return $allMetricsTpl;
1459 },
1460 'value' => $metricValues,
1461 'html' => 1,
1462 'colspan' => $cellsPerRow,
1463 'ignore_export' => 1,
1464 ],
1465 ];
1466
1467 // process completed with success
1468 return true;
1469 }
1470
1471 /**
1472 * @inheritDoc
1473 */
1474 public function getDefaultLayoutType(bool $hasChart = false)
1475 {
1476 // always display just the data-sheet by default with no chart
1477 return 'sheet';
1478 }
1479
1480 /**
1481 * @inheritDoc
1482 */
1483 public function getChartTitle()
1484 {
1485 $df = $this->getDateFormat();
1486 $datesep = VikBooking::getDateSeparator();
1487
1488 $target_title_parts = [
1489 $this->paceOptions['target']['from'] ?? null,
1490 $this->paceOptions['target']['to'] ?? null,
1491 ];
1492
1493 return JText::translate('VBO_RMS_OCCUPANCY_PACE') .
1494 ' (' . date(str_replace('/', $datesep, $df), strtotime($this->paceOptions['pickup']['date'])) . ') ' .
1495 implode(' - ', array_map(function($dt) use ($df, $datesep) {
1496 return date(str_replace('/', $datesep, $df), strtotime($dt));
1497 }, array_filter($target_title_parts)));
1498 }
1499
1500 /**
1501 * @inheritDoc
1502 */
1503 public function getChart(?array $data = null)
1504 {
1505 if (!$this->getReportRows() && !$this->getReportData()) {
1506 return '';
1507 }
1508
1509 // count number of datasets (target + comparison count)
1510 $datasetsCount = count((array) ($this->paceOptions['compare'] ?? [])) + 1;
1511
1512 // build target date start datetime object
1513 $targetStartDt = new DateTime($this->paceOptions['target']['from'], new DateTimeZone(date_default_timezone_get()));
1514
1515 // build chart labels and dataset values
1516 $chartLabels = [];
1517 $chartDatasetValues = [];
1518 $chartDatasetLabels = [];
1519
1520 // scan all target date rows to build the labels
1521 for ($targetIndex = 0; $targetIndex < count($this->rows); $targetIndex += $datasetsCount) {
1522 // push formatted target date label (second row index)
1523 $chartLabels[] = $this->rows[$targetIndex][1]['callback']($this->rows[$targetIndex][1]['value']);
1524 }
1525
1526 // build dataset containers
1527 for ($i = 0; $i < $datasetsCount; $i++) {
1528 // start empty dataset container
1529 $chartDatasetValues[$i] = [];
1530 }
1531
1532 // scan all report rows to build the dataset values
1533 foreach ($this->rows as $index => $row) {
1534 // find the apposite dataset index
1535 $datasetIndex = $index % $datasetsCount;
1536
1537 // iterate all row columns to find the needed ones
1538 foreach ($row as $col) {
1539 if (($col['key'] ?? '') === 'date' && !isset($chartDatasetLabels[$datasetIndex])) {
1540 // build dataset label
1541 if (!$index) {
1542 // first target date
1543 $chartDatasetLabels[$datasetIndex] = $col['value']->format('Y');
1544 } elseif ($index && isset($chartDatasetLabels[0])) {
1545 // comparison date, calculate the years of difference
1546 $dateDiff = $targetStartDt->diff($col['value']);
1547 $yearsDiff = (int) ($dateDiff->y ?? 0);
1548 if (($dateDiff->m ?? 0) > 10) {
1549 $yearsDiff += 1;
1550 }
1551 $chartDatasetLabels[$datasetIndex] = $yearsDiff === 1 ? JText::translate('VBO_SAMET_LAST_YEAR') : JText::sprintf('VBO_SAMET_YEARS_AGO', $yearsDiff);
1552 }
1553
1554 // scan next
1555 continue;
1556 }
1557 if (($col['key'] ?? '') === 'abrn') {
1558 // push "abrn" metric as current dataset value
1559 $chartDatasetValues[$datasetIndex][] = $col['value'];
1560 break;
1561 }
1562 }
1563
1564 if (!($chartDatasetLabels[$datasetIndex] ?? null)) {
1565 // prevent empty dataset labels
1566 $chartDatasetLabels[$datasetIndex] = 'date';
1567 }
1568 }
1569
1570 // the canvas element ID and tag
1571 $canvas_id = 'vbo-report-occupancy-pace-chart-canvas-' . uniqid();
1572 $canvas_html = '<canvas id="' . $canvas_id . '" class="vbo-report-chart-canvas"></canvas>';
1573
1574 if (empty($this->chartScript)) {
1575 // prepare JSON variables
1576 $chartLabelsJSON = json_encode($chartLabels);
1577 $chartDatasetValuesJSON = json_encode($chartDatasetValues);
1578 $chartDatasetLabelsJSON = json_encode($chartDatasetLabels);
1579
1580 // prepare the necessary script to render the Chart
1581 $this->chartScript = <<<JAVASCRIPT
1582 VBOCore.DOMLoaded(() => {
1583 const vboReportCanvas = document.getElementById('$canvas_id');
1584 const vboReportCtx = vboReportCanvas.getContext('2d');
1585 const vboReportData = {
1586 labels: $chartLabelsJSON,
1587 datasets: [],
1588 };
1589 const vboReportDatasetValues = $chartDatasetValuesJSON;
1590 const vboReportDatasetLabels = $chartDatasetLabelsJSON;
1591 vboReportDatasetValues.forEach((datasetData, index) => {
1592 let borderColor = 'rgba(255, 178, 102, 1)';
1593 let backgroundColor = 'rgba(255, 178, 102, 0.2)';
1594 if (!index) {
1595 // target dates
1596 borderColor = 'rgba(30, 144, 255, 1)';
1597 backgroundColor = 'rgba(30, 144, 255, 0.2)';
1598 } else if (!index) {
1599 // more than one comparison value
1600 borderColor = 'rgba(255, 255, 153, 1)';
1601 backgroundColor = 'rgba(255, 255, 153, 0.2)';
1602 }
1603 vboReportData.datasets.push({
1604 label: vboReportDatasetLabels[index] || '',
1605 data: datasetData,
1606 borderColor: borderColor,
1607 backgroundColor: backgroundColor,
1608 });
1609 });
1610 const vboReportChart = new Chart(vboReportCtx, {
1611 type: 'line',
1612 data: vboReportData,
1613 options: {
1614 responsive: true,
1615 legend: {
1616 display: true,
1617 },
1618 // axes handling
1619 scales: {
1620 // Y Axis properties
1621 yAxes: [{
1622 // make sure the chart starts at 0
1623 ticks: {
1624 // format value as currency
1625 callback: (value) => parseInt(value),
1626 beginAtZero: true,
1627 stepSize: 1,
1628 },
1629 }],
1630 },
1631 },
1632 });
1633 });
1634 JAVASCRIPT;
1635
1636 // set the necessary script
1637 $this->setScript($this->chartScript);
1638 }
1639
1640 // return the canvas HTML
1641 return $canvas_html;
1642 }
1643
1644 /**
1645 * Renders the rate history for a given listing and date.
1646 *
1647 * @param int $listingId The VikBooking listing ID.
1648 * @param string $night The targeted night of stay (Y-m-d).
1649 *
1650 * @return array
1651 *
1652 * @throws Exception
1653 */
1654 public function renderRateHistory(int $listingId, string $night)
1655 {
1656 // default environment values
1657 $df = $this->getDateFormat();
1658 $datesep = VikBooking::getDateSeparator();
1659 $currency_symb = VikBooking::getCurrencySymb();
1660 $todayDt = new DateTime(date('Y-m-d H:i:s'), new DateTimeZone(date_default_timezone_get()));
1661
1662 // validate arguments
1663 $listingDetails = VikBooking::getRoomInfo($listingId);
1664 $stayTs = VikBooking::getDateTimestamp($night);
1665
1666 if (!$listingDetails) {
1667 throw new Exception('Invalid listing ID.', 404);
1668 }
1669
1670 if (!$stayTs) {
1671 throw new Exception('Invalid target stay date.', 400);
1672 }
1673
1674 // listing full name and stay date
1675 $listingName = $listingDetails['name'];
1676 $stayDate = date(str_replace('/', $datesep, $df), $stayTs);
1677 $stayDateDt = new DateTime(date('Y-m-d H:i:s', $stayTs), new DateTimeZone(date_default_timezone_get()));
1678 $stayInfo = getdate($stayTs);
1679 $stayDayYmd = date('Y-m-d', $stayTs);
1680 $prevDayYmd = date('Y-m-d', strtotime('-1 day', $stayTs));
1681 $nextDayYmd = date('Y-m-d', strtotime('+1 day', $stayTs));
1682 $isPastStay = $stayTs < strtotime('00:00:00');
1683
1684 // week days short names map
1685 $wdays_map = [
1686 JText::translate('VBSUN'),
1687 JText::translate('VBMON'),
1688 JText::translate('VBTUE'),
1689 JText::translate('VBWED'),
1690 JText::translate('VBTHU'),
1691 JText::translate('VBFRI'),
1692 JText::translate('VBSAT'),
1693 ];
1694
1695 // env
1696 $targetTitle = JText::translate('VBO_TARGET_DATE');
1697 $dtTitle = JText::translate('VBPVIEWORDERSONE');
1698 $rateTitle = JText::translate('VBEDITORDERSEVEN');
1699 $durationTitle = JText::translate('VBO_DURATION');
1700 $variationTitle = JText::translate('VBO_VARIATION');
1701 $daysTitle = strtolower(JText::translate('VBCONFIGSEARCHPMAXDATEDAYS'));
1702 $dayTitle = strtolower(JText::translate('VBODAY'));
1703 $tabTitleList = JText::translate('VBO_LIST');
1704 $tabTitleChart = JText::translate('VBOCHARTONLY');
1705 $prevTitle = JText::translate('VBO_PREV');
1706 $nextTitle = JText::translate('VBO_NEXT');
1707 $newRateTitle = ucfirst(strtolower(JText::translate('VBRATESOVWSETNEWRATE')));
1708 $listingIcn = '<i class="' . VikBookingIcons::i('home') . '"></i>';
1709 $clockIcn = '<i class="' . VikBookingIcons::i('clock') . '"></i>';
1710 $chartIcn = '<i class="' . VikBookingIcons::i('chart-line') . '"></i>';
1711 $plusIcn = '<i class="' . VikBookingIcons::i('plus') . '"></i>';
1712 $minusIcn = '<i class="' . VikBookingIcons::i('minus') . '"></i>';
1713 $prevIcn = '<i class="' . VikBookingIcons::i('chevron-left') . '"></i>';
1714 $nextIcn = '<i class="' . VikBookingIcons::i('chevron-right') . '"></i>';
1715 $ratesIcn = '<i class="' . VikBookingIcons::i('tag') . '"></i>';
1716 $wdayName = $wdays_map[$stayInfo['wday']];
1717
1718 // fetch all confirmed and cancelled bookings intersecting the targeted stay date
1719 $bookings = VBORmsPace::getInstance()->getIntersectingBookings([
1720 'target' => [
1721 'from_ts' => $stayTs,
1722 'to_ts' => strtotime('23:59:59', $stayTs),
1723 ],
1724 'listings' => [$listingDetails['id']],
1725 'cancellation_dt' => 1,
1726 'tariff_taxes' => 1,
1727 ]);
1728
1729 // construct the RMS rates registry object for the targeted stay date
1730 $ratesRegistry = (new VBORmsRatesRegistry([
1731 'target' => [
1732 'from_ts' => $stayTs,
1733 'to_ts' => $stayTs,
1734 ],
1735 'listings' => [$listingDetails['id']],
1736 ]))->preloadFlowRecords();
1737
1738 // build rate history intervals for all pricing modifications
1739 $rateHistoryIntervals = [];
1740
1741 // obtain main rate plan ID under evaluation
1742 $mainRatePlanId = $ratesRegistry->getMainRatePlanId();
1743
1744 // obtain flow records data
1745 $flowRecords = $ratesRegistry->getFlowRecords($ascending = true);
1746
1747 // iterate flow records
1748 $flowData = [];
1749 foreach ($flowRecords as $flowRecord) {
1750 // pricing modification record date
1751 $creationDt = new DateTime($flowRecord['created_on'], new DateTimeZone(date_default_timezone_get()));
1752
1753 if (!$flowData) {
1754 // start container
1755 $flowData = [
1756 'created_on' => (clone $creationDt),
1757 'lasted_until' => null,
1758 'nightly_fee' => $flowRecord['nightly_fee'],
1759 ];
1760
1761 // go next
1762 continue;
1763 }
1764
1765 if ($flowRecord['nightly_fee'] != $flowData['nightly_fee']) {
1766 // pricing modification will adjust the "lasted until" date in previous node
1767 $prevCreationDt = clone $creationDt;
1768 $prevCreationDt->modify('-1 second');
1769 $flowData['lasted_until'] = $prevCreationDt;
1770
1771 // push rate history interval
1772 $rateHistoryIntervals[] = $flowData;
1773
1774 // start a new container
1775 $flowData = [
1776 'created_on' => (clone $creationDt),
1777 'lasted_until' => null,
1778 'nightly_fee' => $flowRecord['nightly_fee'],
1779 ];
1780 }
1781 }
1782
1783 if ($flowRecords && empty($flowData['lasted_until'])) {
1784 // push last, even first-and-only, rate history interval
1785 $rateHistoryIntervals[] = $flowData;
1786 }
1787
1788 // build history template
1789 $historyTpl = '';
1790
1791 // scan all pricing modification intervals
1792 foreach ($rateHistoryIntervals as $flowIndex => $pricingModification) {
1793 // obtain record details
1794 $creationYmd = $pricingModification['created_on']->format(str_replace('/', $datesep, $df) . ' H:i:s');
1795 $nightlyRate = VikBooking::formatCurrencyNumber($pricingModification['nightly_fee']);
1796
1797 // start rate duration and booking variation metrics
1798 $durationDays = '---';
1799 $variationBookings = '---';
1800
1801 // determine the "lasted until" date
1802 if ($pricingModification['lasted_until']) {
1803 // known end date
1804 $lastedUntilDt = $pricingModification['lasted_until'];
1805 } else {
1806 // last rate applied to target date
1807 $lastedUntilDt = $stayDateDt < $todayDt ? $stayDateDt : $todayDt;
1808 }
1809
1810 // calculate rate duration
1811 $datesDiff = $lastedUntilDt->diff($pricingModification['created_on']);
1812 if ($datesDiff->days) {
1813 // duration is at least one day
1814 $durationDays = sprintf('%d %s', $datesDiff->days, ($datesDiff->days == 1 ? $dayTitle : $daysTitle));
1815 } else {
1816 // duration is just a few hours/minutes
1817 $durationDays = sprintf('%d %s', 0, JText::translate('VBTRKDIFFSECS'));
1818 $durationHours = $datesDiff->h;
1819 $durationMinutes = $datesDiff->i;
1820 $durationSeconds = $datesDiff->s;
1821 if ($durationHours) {
1822 $durationDays = sprintf('%d %s', $durationHours, ($durationHours == 1 ? JText::translate('VBO_HOUR') : JText::translate('VBCONFIGONETENEIGHT')));
1823 } elseif ($durationMinutes) {
1824 $durationDays = sprintf('%d %s', $durationMinutes, ($durationMinutes == 1 ? JText::translate('VBO_MINUTE') : JText::translate('VBTRKDIFFMINS')));
1825 } elseif ($durationSeconds) {
1826 $durationDays = sprintf('%d %s', $durationSeconds, ($durationSeconds == 1 ? JText::translate('VBO_SECOND') : JText::translate('VBTRKDIFFSECS')));
1827 }
1828 }
1829
1830 // calculate booking variations
1831 $variationData = [
1832 'nb' => 0,
1833 'cb' => 0,
1834 ];
1835 $variationFromTs = $pricingModification['created_on']->format('U');
1836 $variationToTs = $pricingModification['lasted_until'] ? $pricingModification['lasted_until']->format('U') : null;
1837 // scan all bookings intersecting the stay date
1838 foreach ($bookings as $booking) {
1839 if (($variationToTs !== null && $booking['ts'] >= $variationFromTs && $booking['ts'] < $variationToTs) || (!$variationToTs && $booking['ts'] >= $variationFromTs)) {
1840 // there was a new confirmed booking
1841 $variationData['nb']++;
1842 }
1843 if (!empty($booking['cancellation_ts'])) {
1844 if (($variationToTs !== null && $booking['cancellation_ts'] >= $variationFromTs && $booking['cancellation_ts'] < $variationToTs) || (!$variationToTs && $booking['cancellation_ts'] >= $variationFromTs)) {
1845 // there was a booking cancellation
1846 $variationData['cb']++;
1847 }
1848 }
1849 }
1850
1851 if ($variationData['nb'] || $variationData['cb']) {
1852 // build variation bookings template
1853 $variationNb = $variationData['nb'];
1854 $variationCb = $variationData['cb'];
1855 $variationBookings = <<<HTML
1856 <span class="label label-success" data-type="vrplus">$plusIcn $variationNb</span>
1857 <span class="label label-error" data-type="vrminus">$minusIcn $variationCb</span>
1858 HTML;
1859 }
1860
1861 // set history record
1862 $historyTpl .= <<<HTML
1863 <div class="vbo-rms-rate-history-record">
1864 <span class="vbo-rms-rate-history-record-dt">
1865 <span class="vbo-rms-rate-history-record-title">$dtTitle</span>
1866 <span class="vbo-rms-rate-history-record-value">$clockIcn $creationYmd</span>
1867 </span>
1868 <span class="vbo-rms-rate-history-record-duration">
1869 <span class="vbo-rms-rate-history-record-title">$durationTitle</span>
1870 <span class="vbo-rms-rate-history-record-value">$durationDays</span>
1871 </span>
1872 <span class="vbo-rms-rate-history-record-variation">
1873 <span class="vbo-rms-rate-history-record-title">$variationTitle</span>
1874 <span class="vbo-rms-rate-history-record-value">$variationBookings</span>
1875 </span>
1876 <span class="vbo-rms-rate-history-record-rate">
1877 <span class="vbo-rms-rate-history-record-title">$rateTitle</span>
1878 <span class="vbo-rms-rate-history-record-value">$nightlyRate</span>
1879 </span>
1880 </div>
1881 HTML;
1882 }
1883
1884 if (!$rateHistoryIntervals) {
1885 // add message that no rate history records were found
1886 $historyTpl = '<p class="warn">' . JText::translate('VBO_NO_RECORDS_FOUND') . '</p>';
1887 }
1888
1889 // build chart data (if any), tabs and panels template strings
1890 $chartData = [];
1891 $tabChartTpl = '';
1892 $panelChartTpl = '';
1893 if (count($rateHistoryIntervals) > 1) {
1894 // chart is available only if we have at least one rate modification record
1895 $tabChartTpl = <<<HTML
1896 <div class="vbo-panel-tabs-tab" data-tab="chart"><span>$chartIcn $tabTitleChart</span></div>
1897 HTML;
1898
1899 $panelChartTpl = <<<HTML
1900 <div class="vbo-panel-tabs-panel vbo-rms-rate-history-chart" data-panel="chart" style="display: none;">
1901 <canvas class="vbo-report-chart-inmodal vbo-rms-rate-history-chart-canvas"></canvas>
1902 </div>
1903 HTML;
1904
1905 // build chart data for rendering the chart
1906 $chartData = [
1907 'labels' => [],
1908 'datasets' => [
1909 // push dataset for "rate"
1910 [
1911 'label' => $rateTitle,
1912 'data' => [],
1913 'borderColor' => 'rgba(30, 144, 255, 1)',
1914 'backgroundColor' => 'rgba(30, 144, 255, 0)',
1915 'yAxisID' => 'y',
1916 'pointRadius' => 0,
1917 'pointHoverRadius' => 0,
1918 ],
1919 // push dataset for "bookings"
1920 [
1921 'label' => JText::translate('VBMENUTHREE'),
1922 'data' => [],
1923 'borderColor' => 'rgba(0, 100, 0, 1)',
1924 'backgroundColor' => 'rgba(0, 100, 0, 0)',
1925 'yAxisID' => 'y1',
1926 'pointRadius' => 0,
1927 'pointHoverRadius' => 0,
1928 ],
1929 ],
1930 ];
1931
1932 // construct the first and last dates
1933 $firstIntervalDt = clone $rateHistoryIntervals[0]['created_on'];
1934 if ($rateHistoryIntervals[count($rateHistoryIntervals) - 1]['lasted_until']) {
1935 // known end date
1936 $lastIntervalDt = clone $pricingModification['lasted_until'];
1937 } else {
1938 // calculate end date
1939 $lastIntervalDt = clone ($stayDateDt < $todayDt ? $stayDateDt : $todayDt);
1940 }
1941
1942 // obtain iterable date period from first and last interval dates
1943 $intervalPeriod = new DatePeriod(
1944 $firstIntervalDt,
1945 new DateInterval('P1D'),
1946 $lastIntervalDt
1947 );
1948
1949 // set chart data
1950 foreach ($intervalPeriod as $periodDt) {
1951 // push chart label (x-axis)
1952 $chartData['labels'][] = $periodDt->format(str_replace('/', $datesep, $df));
1953
1954 // obtain start and end timestamps for the current day
1955 $periodTsFrom = strtotime('00:00:00', $periodDt->format('U'));
1956 $periodTsTo = strtotime('23:59:59', $periodDt->format('U'));
1957
1958 // get the rate as of the current period and push it to the apposite dataset
1959 $periodRate = 0;
1960 foreach ($rateHistoryIntervals as $pricingModification) {
1961 if ($periodDt >= $pricingModification['created_on'] && (empty($pricingModification['lasted_until']) || $periodDt < $pricingModification['lasted_until'])) {
1962 $periodRate = $pricingModification['nightly_fee'];
1963 break;
1964 }
1965 }
1966 $chartData['datasets'][0]['data'][] = $periodRate;
1967
1968 // iterate all bookings to count the "OTB" as of the current period
1969 $otbCount = 0;
1970 foreach ($bookings as $booking) {
1971 if ($booking['ts'] >= $periodTsFrom && $booking['ts'] <= $periodTsTo) {
1972 // new booking
1973 $otbCount++;
1974 }
1975 if (!empty($booking['cancellation_ts']) && $booking['cancellation_ts'] >= $periodTsFrom && $booking['cancellation_ts'] <= $periodTsTo) {
1976 // booking cancellation
1977 $otbCount--;
1978 }
1979 }
1980
1981 // push OTB value to apposite dataset
1982 $chartData['datasets'][1]['data'][] = $otbCount;
1983 }
1984 }
1985
1986 // build set-new-rate template
1987 $newRateTpl = '';
1988 if (!$isPastStay) {
1989 $newRateTpl = <<<HTML
1990 <div class="vbo-panel-tabs-cmd" data-newrate="1" data-day="$stayDayYmd" data-listing-id="$listingId" data-price-id="$mainRatePlanId"><span>$ratesIcn $newRateTitle</span></div>
1991 HTML;
1992 }
1993
1994 // build template for rendering
1995 $tpl = <<<HTML
1996 <div class="vbo-rms-rate-history-wrap">
1997 <div class="vbo-rms-rate-history-details">
1998 <div class="vbo-rms-rate-history-listing">$listingIcn $listingName</div>
1999 <div class="vbo-rms-rate-history-target">
2000 <span class="vbo-rms-rate-history-target-title">$targetTitle</span>
2001 <span class="vbo-rms-rate-history-target-value">$wdayName, $stayDate</span>
2002 </div>
2003 </div>
2004 <div class="vbo-panel-tabs-wrap">
2005 <div class="vbo-panel-tabs-tabs">
2006 <div class="vbo-panel-tabs-tab active" data-tab="list"><span>$clockIcn $tabTitleList</span></div>
2007 $tabChartTpl
2008 <div class="vbo-panel-tabs-right">
2009 $newRateTpl
2010 <div class="vbo-panel-tabs-cmd" data-cmd="prev" data-day="$prevDayYmd" data-listing-id="$listingId"><span>$prevIcn $prevTitle</span></div>
2011 <div class="vbo-panel-tabs-cmd" data-cmd="next" data-day="$nextDayYmd" data-listing-id="$listingId"><span>$nextTitle $nextIcn</span></div>
2012 </div>
2013 </div>
2014 <div class="vbo-panel-tabs-panels">
2015 <div class="vbo-panel-tabs-panel vbo-rms-rate-history-list" data-panel="list">$historyTpl</div>
2016 $panelChartTpl
2017 </div>
2018 </div>
2019 </div>
2020 HTML;
2021
2022 return [
2023 'html' => $tpl,
2024 'chart' => $chartData ?: null,
2025 ];
2026 }
2027
2028 /**
2029 * Given a name, usually a listing name, compacts it into a code.
2030 *
2031 * @param string $name The full (listing) name to compact.
2032 * @param ?int $length Optional string length to obtain.
2033 * @param ?string $concat Optional string to contact when shortening.
2034 *
2035 * @return string The compacted name.
2036 */
2037 protected function compactName(string $name, ?int $length = null, ?string $concat = null)
2038 {
2039 // strip vowels and white spaces
2040 $name = preg_replace('/[a|e|i|o|u|y|\s]/i', '', $name);
2041
2042 if (intval($length) > 0 && strlen($name) > $length) {
2043 if (function_exists('mb_substr')) {
2044 $name = mb_substr($name, 0, $length, 'UTF-8');
2045 } else {
2046 $name = substr($name, 0, $length);
2047 }
2048 if ($concat) {
2049 $name .= $concat;
2050 }
2051 }
2052
2053 return $name;
2054 }
2055
2056 /**
2057 * Registers the name to give to the CSV file being exported.
2058 *
2059 * @return void
2060 */
2061 protected function registerExportCSVFileName()
2062 {
2063 $app = JFactory::getApplication();
2064
2065 $nameValues = array_map(function($dtString) {
2066 return date('Y-m-d', VikBooking::getDateTimestamp($dtString));
2067 }, array_filter([
2068 $app->input->getString('pickup_date', ''),
2069 $app->input->getString('target_date_from', ''),
2070 $app->input->getString('target_date_to', ''),
2071 ]));
2072
2073 $this->setExportCSVFileName($this->reportName . '-' . implode('_', array_filter($nameValues)) . '.csv');
2074 }
2075 }
2076