PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.9
VikBooking Hotel Booking Engine & PMS v1.8.9
1.8.16 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 All 37 releases
vikbooking / admin / helpers / report / rms_occupancy_pace.php

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

2,060 lines 94.1 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 // Gross Booking Revenue
846 [
847 'key' => 'grossrev',
848 'label' => JText::translate('VBO_GROSS_BOOKING_VALUE'),
849 'sortable' => $sortingAllowed,
850 'center' => 1,
851 ],
852 ];
853
854 // build final report columns by pushing one column per showcased listing
855 $this->cols = [];
856 foreach ($defaultCols as $defaultCol) {
857 // push column
858 $this->cols[] = $defaultCol;
859
860 // check for exact insert-point
861 if ($defaultCol['key'] === 'abrn') {
862 // iterate all listings to showcase
863 foreach ($showcaseListings as $listing_id => $listing_data) {
864 // build "compact" listing name
865 $listingCompactName = $this->compactName($listing_data['name'], 12, '..');
866 $nameCompacted = $listingCompactName != $listing_data['name'];
867
868 // push listing-level "abrn" column
869 $this->cols[] = [
870 'key' => 'abrn_' . $listing_id,
871 'label' => $listingCompactName,
872 'sortable' => 1,
873 'center' => 1,
874 'tip' => ($nameCompacted ? $listing_data['name'] : null),
875 'tip_pos' => 'bottom',
876 'tip_label' => ($nameCompacted ? strtoupper($listingCompactName) : null),
877 ];
878 }
879 }
880 }
881
882 // count all pace data targets
883 $paceTargetsCount = count($paceData['pace']);
884
885 // start global and target line counters
886 $lineCounter = 0;
887 $lineTargetCounter = 0;
888
889 // start metric indexes and values for the various column keys
890 $metricIndexes = [];
891 $metricValues = [];
892
893 // start target names
894 $startingTs = strtotime($this->paceOptions['target']['from']);
895 $targetNames = [
896 // main target
897 substr($this->paceOptions['target']['from'], 0, 4),
898 ];
899 foreach ($this->paceOptions['compare'] as $cmp) {
900 // push target comparison year as name
901 $targetNames[] = date('Y', strtotime($cmp['to'], $startingTs));
902 }
903
904 // build report row classes list
905 $rowClasses = [];
906 $rowGroupIndex = 0;
907
908 // iterate the main pace data target dates
909 foreach (($paceData['pace'][0] ?? []) as $parseIndex => $metrics) {
910 // increase main target line counter
911 $lineTargetCounter++;
912
913 // loop through all pace data target indexes
914 for ($targetIndex = 0; $targetIndex < $paceTargetsCount; $targetIndex++) {
915 // prior loading of comparison metrics for main target
916 $comparisonResults = [];
917 if (!$targetIndex && $paceTargetsCount > 1) {
918 $comparisonResults = [
919 'abrn' => $paceData['pace'][($targetIndex + 1)][$parseIndex]['abrn'] ?? null,
920 ];
921 }
922
923 // determine the current period (date) in Y-m-d format
924 $currentPeriodYmd = $paceData['pace'][$targetIndex][$parseIndex]['date']->format('Y-m-d');
925
926 // build default row columns to be pushed
927 $defaultMetricRowCols = [
928 // line number
929 [
930 'key' => 'line',
931 'value' => $lineCounter,
932 'ignore_view' => 1,
933 'ignore_export' => 1,
934 ],
935 // period start date
936 [
937 'key' => 'date',
938 'callback' => function($dt) use ($targetIndex, $df, $datesep, $wdays_map) {
939 $icn = $targetIndex ? '<i class="' . VikBookingIcons::i('history') . '"></i> ' : '';
940 return $icn . sprintf('%s, %s', $wdays_map[$dt->format('w')] ?? '', $dt->format(str_replace("/", $datesep, $df)));
941 },
942 'callback_export' => function($dt) use ($df, $datesep, $wdays_map) {
943 return sprintf('%s, %s', $wdays_map[$dt->format('w')] ?? '', $dt->format(str_replace("/", $datesep, $df)));
944 },
945 'value' => $paceData['pace'][$targetIndex][$parseIndex]['date'],
946 ],
947 // events
948 [
949 'key' => 'events',
950 'html' => 1,
951 'callback' => function($events) use ($targetIndex, $currentPeriodYmd) {
952 if (!$events) {
953 if ($targetIndex) {
954 return '';
955 }
956
957 // add button to create a new event
958 $addEvIcn = '<i class="' . VikBookingIcons::i('plus', 'icn-nomargin') . '"></i>';
959 return <<<HTML
960 <button class="btn vbo-btn-square" data-event-day="$currentPeriodYmd">$addEvIcn</button>
961 HTML;
962 }
963 $evString = implode(', ', array_column($events, 'name'));
964 return <<<HTML
965 <div class="vbo-report-cell-texts"><span>$evString</span></div>
966 HTML;
967 },
968 'callback_export' => function($events) {
969 return $events ? implode(', ', array_column($events, 'name')) : '';
970 },
971 'value' => $paceData['pace'][$targetIndex][$parseIndex]['events'],
972 'center' => 1,
973 ],
974 // abrn
975 [
976 'key' => 'abrn',
977 'html' => 1,
978 'callback' => function($value) use ($targetIndex, $totalInventoryCount, $comparisonResults, $currentPeriodYmd) {
979 $pcentOcc = $value * 100 / ($totalInventoryCount ?: 1);
980 if ($pcentOcc > 60) {
981 $highlightCls = ' label-success';
982 } elseif ($pcentOcc > 20) {
983 $highlightCls = ' label-warning';
984 } else {
985 $highlightCls = ' label-error';
986 }
987
988 if ($targetIndex) {
989 // comparison row will get no coloring
990 $highlightCls = '';
991 }
992
993 // build comparison result template
994 $compareTpl = '';
995 if (($comparisonResults['abrn'] ?? null) !== null) {
996 // tell if we are up or down compared to a past year
997 $going_up = $value > (int) $comparisonResults['abrn'];
998 $goingCls = $going_up ? 'up' : 'down';
999 $goingIcn = '<i class="' . VikBookingIcons::i($going_up ? 'arrow-up' : 'arrow-down') . '"></i>';
1000 $highlightCls = $going_up ? ' label-success' : ' label-error';
1001
1002 // set comparison result template
1003 $compareTpl = <<<HTML
1004 <span class="vbo-report-cell-compare-result $goingCls">$goingIcn</span>
1005 HTML;
1006
1007 if ($value == $comparisonResults['abrn']) {
1008 // do not set any comparison result when the result is identical
1009 $compareTpl = '';
1010 $highlightCls = '';
1011 }
1012 }
1013
1014 return <<<HTML
1015 <div class="vbo-report-cell-comparison" data-type="abrn" data-day="$currentPeriodYmd">
1016 <span class="label$highlightCls">$value</span>
1017 $compareTpl
1018 </div>
1019 HTML;
1020 },
1021 'callback_export' => function($value) {
1022 return $value;
1023 },
1024 'value' => $paceData['pace'][$targetIndex][$parseIndex]['abrn'],
1025 'center' => 1,
1026 ],
1027 // sellable (unsold) units
1028 [
1029 'key' => 'sellable',
1030 'callback' => function($value) use ($targetIndex) {
1031 if (!$value) {
1032 return '<span class="label label-success">' . $value . '</span>';
1033 }
1034 if (!$targetIndex) {
1035 return '<span class="label label-warning">' . $value . '</span>';
1036 }
1037 return '<span class="label">' . $value . '</span>';
1038 },
1039 'callback_export' => function($value) {
1040 return $value;
1041 },
1042 'value' => $paceData['pace'][$targetIndex][$parseIndex]['sellable'],
1043 'center' => 1,
1044 ],
1045 // adr
1046 [
1047 'key' => 'adr',
1048 'value' => $paceData['pace'][$targetIndex][$parseIndex]['adr'],
1049 'center' => 1,
1050 'callback' => function($metric) {
1051 return VikBooking::formatCurrencyNumber($metric);
1052 },
1053 ],
1054 // RevPar
1055 [
1056 'key' => 'revpar',
1057 'value' => $paceData['pace'][$targetIndex][$parseIndex]['revpar'],
1058 'center' => 1,
1059 'callback' => function($metric) {
1060 return VikBooking::formatCurrencyNumber($metric);
1061 },
1062 ],
1063 // rate variation breakpoint
1064 [
1065 'key' => 'ratevr',
1066 'html' => 1,
1067 'callback' => function($rateVrData) use ($listing_id, $currentPeriodYmd) {
1068 list($rateVrDt, $rateVrPlus, $rateVrMinus) = $rateVrData;
1069 if (!$rateVrPlus && !$rateVrMinus) {
1070 return '';
1071 }
1072
1073 // build variation plus and minus template variables
1074 $icnPlusCls = VikBookingIcons::i('plus');
1075 $icnMinusCls = VikBookingIcons::i('minus');
1076
1077 return <<<HTML
1078 <div class="vbo-report-cell-metrics">
1079 <div class="vbo-report-cell-metric" data-type="vrplus" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1080 <span class="label label-success"><i class="$icnPlusCls"></i> $rateVrPlus</span>
1081 </div>
1082 <div class="vbo-report-cell-metric" data-type="vrminus" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1083 <span class="label label-error"><i class="$icnMinusCls"></i> $rateVrMinus</span>
1084 </div>
1085 </div>
1086 HTML;
1087 },
1088 'callback_export' => function($rateVrData) {
1089 list($rateVrDt, $rateVrPlus, $rateVrMinus) = $rateVrData;
1090 if (!$rateVrPlus && !$rateVrMinus) {
1091 return '';
1092 }
1093 return sprintf('+%d, -%d', $rateVrPlus, $rateVrMinus);
1094 },
1095 'value' => [
1096 $paceData['pace'][$targetIndex][$parseIndex]['ratevrdt'],
1097 $paceData['pace'][$targetIndex][$parseIndex]['ratevrplus'],
1098 $paceData['pace'][$targetIndex][$parseIndex]['ratevrminus'],
1099 ],
1100 'center' => 1,
1101 ],
1102 // Gross Booking Revenue
1103 [
1104 'key' => 'grossrev',
1105 'value' => array_sum((array) ($paceData['pace'][$targetIndex][$parseIndex]['grossrev'] ?? [])),
1106 'center' => 1,
1107 'callback' => function($metric) {
1108 return VikBooking::formatCurrencyNumber($metric);
1109 },
1110 ],
1111 ];
1112
1113 // build final row columns to be pushed
1114 $metricRowCols = [];
1115 foreach ($defaultMetricRowCols as $defaultMetricRowCol) {
1116 // push column
1117 $metricRowCols[] = $defaultMetricRowCol;
1118
1119 // check for exact insert-point
1120 if ($defaultMetricRowCol['key'] === 'abrn') {
1121 // iterate all listings to showcase
1122 foreach ($showcaseListings as $listing_id => $listing_data) {
1123 // gather listing-level nightly rate and total units
1124 $listingNightlyRate = $paceData['pace'][$targetIndex][$parseIndex]['nightlyrates'][$listing_id] ?? null;
1125 $listingUnits = $paceData['listings'][$listing_id]['units'] ?? 0;
1126
1127 // push listing-level "abrn" column value
1128 $metricRowCols[] = [
1129 'key' => 'abrn_' . $listing_id,
1130 'html' => 1,
1131 'callback' => function($value) use ($targetIndex, $listing_id, $currentPeriodYmd, $listingNightlyRate, $listingUnits) {
1132 $pcentOcc = $value * 100 / ($listingUnits ?: 1);
1133 if ($pcentOcc > 60) {
1134 $highlightCls = ' label-success';
1135 } elseif ($pcentOcc > 20) {
1136 $highlightCls = ' label-warning';
1137 } else {
1138 $highlightCls = ' label-error';
1139 }
1140
1141 if ($targetIndex) {
1142 // comparison row will get no coloring
1143 $highlightCls = '';
1144 }
1145
1146 // build nightly rate metric template
1147 $metricTpl = '';
1148 if ($listingNightlyRate) {
1149 $listingNightlyRate = VikBooking::formatCurrencyNumber($listingNightlyRate);
1150 $metricTpl = <<<HTML
1151 <div class="vbo-report-cell-metric" data-type="nightly-rate" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1152 <span class="label label-info">$listingNightlyRate</span>
1153 </div>
1154 HTML;
1155 } else {
1156 // display an empty box when no rate details available
1157 $metricTpl = <<<HTML
1158 <div class="vbo-report-cell-metric" data-type="empty-nightly-rate">
1159 <span class="label">---</span>
1160 </div>
1161 HTML;
1162 }
1163
1164 return <<<HTML
1165 <div class="vbo-report-cell-metrics">
1166 <div class="vbo-report-cell-metric" data-type="abrn" data-day="$currentPeriodYmd" data-listing-id="$listing_id">
1167 <span class="label$highlightCls">$value</span>
1168 </div>
1169 $metricTpl
1170 </div>
1171 HTML;
1172 },
1173 'callback_export' => function($value) {
1174 return $value;
1175 },
1176 'value' => $paceData['pace'][$targetIndex][$parseIndex]['bookedrooms'][$listing_id] ?? 0,
1177 'center' => 1,
1178 ];
1179 }
1180 }
1181 }
1182
1183 if (!$metricIndexes) {
1184 // store the indexes for each metric key
1185 $metricIndexes = array_values(array_column(array_filter($metricRowCols, function($colData) {
1186 return empty($colData['ignore_view']);
1187 }), 'key'));
1188 }
1189
1190 // update metric values for current target to be used for the footer calculations
1191 $metricValues['abrn'][$targetIndex] = ($metricValues['abrn'][$targetIndex] ?? 0) + $paceData['pace'][$targetIndex][$parseIndex]['abrn'];
1192 $metricValues['adr'][$targetIndex] = ($metricValues['adr'][$targetIndex] ?? 0) + $paceData['pace'][$targetIndex][$parseIndex]['adr'];
1193 $metricValues['revpar'][$targetIndex] = ($metricValues['revpar'][$targetIndex] ?? 0) + $paceData['pace'][$targetIndex][$parseIndex]['revpar'];
1194 $metricValues['grossrev'][$targetIndex] = ($metricValues['grossrev'][$targetIndex] ?? []) + (array) ($paceData['pace'][$targetIndex][$parseIndex]['grossrev'] ?? []);
1195
1196 // push metric row with calculated columns
1197 $this->rows[] = $metricRowCols;
1198
1199 // push row group class(es)
1200 $rowClasses[] = array_values(array_filter([
1201 sprintf('vbo-report-row-group-%d', $rowGroupIndex),
1202 ($targetIndex ? 'vbo-report-row-comparison' : ''),
1203 ]));
1204
1205 // increase counter
1206 $lineCounter++;
1207 }
1208
1209 // update rows group index
1210 $rowGroupIndex = 1 - $rowGroupIndex;
1211 }
1212
1213 // set report row classes
1214 $this->setReportRowClasses($rowClasses);
1215
1216 if ($sortingAllowed) {
1217 // sort rows
1218 $this->sortRows($krsort, $krorder);
1219 }
1220
1221 // count total cells per row and total metric values for the footer
1222 $cellsPerRow = count($metricIndexes);
1223 $footerColsCount = count($metricValues) ?: 1;
1224 $footerColspan = floor($cellsPerRow / $footerColsCount);
1225
1226 // build report footer row
1227 $this->footerRow[] = [
1228 // push one single column for "abrn", "adr", "RevPar" and "Gross Booking Revenue"
1229 [
1230 'callback' => function($allMetrics) use ($lineTargetCounter, $totalInventoryCount, $targetNames) {
1231 // build all metrics template
1232 $allMetricsTpl = <<<HTML
1233 <div class="vbo-report-footer-rms-list">
1234 HTML;
1235
1236 // abrn
1237 $metrics = $allMetrics['abrn'] ?? [];
1238 $blockTitle = JText::translate('VBOROOMSOCCUPANCY');
1239 $blockMetricsTpl = '';
1240 $targetMetrics = [];
1241 foreach ($metrics as $targetIndex => $totalValue) {
1242 // calculate metric average value
1243 $avgValue = round($totalValue / ($lineTargetCounter ?: 1), 2);
1244
1245 // set metric for this target
1246 $targetMetrics[$targetIndex] = $avgValue;
1247
1248 // calculate average occupancy percent
1249 $avgOccPcent = round($avgValue * 100 / ($totalInventoryCount ?: 1), 2);
1250
1251 // obtain target name
1252 $targetName = $targetNames[$targetIndex] ?? '';
1253
1254 // set metric template value
1255 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1256 $blockMetricsTpl .= <<<HTML
1257 <div class="vbo-report-footer-rms-metric $metricCls">
1258 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1259 <div class="vbo-report-footer-rms-metric-value">$totalValue <span>($avgOccPcent%)</span></div>
1260 </div>
1261 HTML;
1262 }
1263
1264 // growth template
1265 $growthTpl = '';
1266 if (count($targetMetrics) > 1) {
1267 // calculate the percent of growth
1268 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1269 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1270 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1271 $growthTpl = <<<HTML
1272 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1273 HTML;
1274 }
1275
1276 // set the HTML string
1277 $allMetricsTpl .= <<<HTML
1278 <div class="vbo-report-footer-rms-wrap">
1279 <div class="vbo-report-footer-rms-title">
1280 <span>$blockTitle</span>
1281 $growthTpl
1282 </div>
1283 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1284 </div>
1285 HTML;
1286
1287 // adr
1288 $metrics = $allMetrics['adr'] ?? [];
1289 $blockTitle = JText::translate('VBOREPORTREVENUEADR');
1290 $blockMetricsTpl = '';
1291 $targetMetrics = [];
1292 foreach ($metrics as $targetIndex => $totalValue) {
1293 // calculate metric average value
1294 $avgValue = round($totalValue / ($lineTargetCounter ?: 1), 2);
1295 $avgValueFormat = VikBooking::formatCurrencyNumber($avgValue);
1296
1297 // set metric for this target
1298 $targetMetrics[$targetIndex] = $avgValue;
1299
1300 // obtain target name
1301 $targetName = $targetNames[$targetIndex] ?? '';
1302
1303 // set metric template value
1304 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1305 $blockMetricsTpl .= <<<HTML
1306 <div class="vbo-report-footer-rms-metric $metricCls">
1307 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1308 <div class="vbo-report-footer-rms-metric-value">$avgValueFormat</div>
1309 </div>
1310 HTML;
1311 }
1312
1313 // growth template
1314 $growthTpl = '';
1315 if (count($targetMetrics) > 1) {
1316 // calculate the percent of growth
1317 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1318 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1319 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1320 $growthTpl = <<<HTML
1321 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1322 HTML;
1323 }
1324
1325 // set the HTML string
1326 $allMetricsTpl .= <<<HTML
1327 <div class="vbo-report-footer-rms-wrap">
1328 <div class="vbo-report-footer-rms-title">
1329 <span>$blockTitle</span>
1330 $growthTpl
1331 </div>
1332 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1333 </div>
1334 HTML;
1335
1336 // RevPAR
1337 $metrics = $allMetrics['revpar'] ?? [];
1338 $blockTitle = JText::translate('VBOREPORTREVENUEREVPAR');
1339 $blockMetricsTpl = '';
1340 $targetMetrics = [];
1341 foreach ($metrics as $targetIndex => $totalValue) {
1342 // calculate metric average value
1343 $avgValue = round($totalValue / ($lineTargetCounter ?: 1), 2);
1344 $avgValueFormat = VikBooking::formatCurrencyNumber($avgValue);
1345
1346 // set metric for this target
1347 $targetMetrics[$targetIndex] = $avgValue;
1348
1349 // obtain target name
1350 $targetName = $targetNames[$targetIndex] ?? '';
1351
1352 // set metric template value
1353 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1354 $blockMetricsTpl .= <<<HTML
1355 <div class="vbo-report-footer-rms-metric $metricCls">
1356 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1357 <div class="vbo-report-footer-rms-metric-value">$avgValueFormat</div>
1358 </div>
1359 HTML;
1360 }
1361
1362 // growth template
1363 $growthTpl = '';
1364 if (count($targetMetrics) > 1) {
1365 // calculate the percent of growth
1366 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1367 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1368 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1369 $growthTpl = <<<HTML
1370 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1371 HTML;
1372 }
1373
1374 // set the HTML string
1375 $allMetricsTpl .= <<<HTML
1376 <div class="vbo-report-footer-rms-wrap">
1377 <div class="vbo-report-footer-rms-title">
1378 <span>$blockTitle</span>
1379 $growthTpl
1380 </div>
1381 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1382 </div>
1383 HTML;
1384
1385 // gross booking revenue
1386 $metrics = $allMetrics['grossrev'] ?? [];
1387 $blockTitle = JText::translate('VBO_GROSS_BOOKING_VALUE');
1388 $blockMetricsTpl = '';
1389 $targetMetrics = [];
1390 foreach ($metrics as $targetIndex => $totalAssoc) {
1391 // calculate metric total value
1392 $metricTotalValue = array_sum((array) $totalAssoc);
1393
1394 // set metric for this target
1395 $targetMetrics[$targetIndex] = $metricTotalValue;
1396
1397 // obtain target name
1398 $targetName = $targetNames[$targetIndex] ?? '';
1399
1400 // count total value per current target
1401 $totalValue = VikBooking::formatCurrencyNumber($metricTotalValue);
1402
1403 // set metric template value
1404 $metricCls = !$targetIndex ? 'vbo-report-footer-rms-metric-target' : 'vbo-report-footer-rms-metric-comparison';
1405 $blockMetricsTpl .= <<<HTML
1406 <div class="vbo-report-footer-rms-metric $metricCls">
1407 <div class="vbo-report-footer-rms-metric-year"><span>$targetName:</span></div>
1408 <div class="vbo-report-footer-rms-metric-value">$totalValue</div>
1409 </div>
1410 HTML;
1411 }
1412
1413 // growth template
1414 $growthTpl = '';
1415 if (count($targetMetrics) > 1) {
1416 // calculate the percent of growth
1417 $growthPcent = round(abs(($targetMetrics[0] - $targetMetrics[1]) / ($targetMetrics[1] ?: 1) * 100), 2);
1418 $growthCls = $targetMetrics[0] > $targetMetrics[1] ? 'up' : 'down';
1419 $growthIcn = '<i class="' . VikBookingIcons::i($targetMetrics[0] > $targetMetrics[1] ? 'arrow-up' : 'arrow-down') . '"></i>';
1420 $growthTpl = <<<HTML
1421 <div class="vbo-report-footer-rms-growth $growthCls">$growthIcn $growthPcent%</div>
1422 HTML;
1423 }
1424
1425 // return the HTML string to build this footer cell
1426 $allMetricsTpl .= <<<HTML
1427 <div class="vbo-report-footer-rms-wrap">
1428 <div class="vbo-report-footer-rms-title">
1429 <span>$blockTitle</span>
1430 $growthTpl
1431 </div>
1432 <div class="vbo-report-footer-rms-metrics">$blockMetricsTpl</div>
1433 </div>
1434 HTML;
1435
1436 // close list-wrapper div
1437 $allMetricsTpl .= <<<HTML
1438 </div>
1439 HTML;
1440
1441 // return the final HTML string template
1442 return $allMetricsTpl;
1443 },
1444 'value' => $metricValues,
1445 'html' => 1,
1446 'colspan' => $cellsPerRow,
1447 'ignore_export' => 1,
1448 ],
1449 ];
1450
1451 // process completed with success
1452 return true;
1453 }
1454
1455 /**
1456 * @inheritDoc
1457 */
1458 public function getDefaultLayoutType(bool $hasChart = false)
1459 {
1460 // always display just the data-sheet by default with no chart
1461 return 'sheet';
1462 }
1463
1464 /**
1465 * @inheritDoc
1466 */
1467 public function getChartTitle()
1468 {
1469 $df = $this->getDateFormat();
1470 $datesep = VikBooking::getDateSeparator();
1471
1472 $target_title_parts = [
1473 $this->paceOptions['target']['from'] ?? null,
1474 $this->paceOptions['target']['to'] ?? null,
1475 ];
1476
1477 return JText::translate('VBO_RMS_OCCUPANCY_PACE') .
1478 ' (' . date(str_replace('/', $datesep, $df), strtotime($this->paceOptions['pickup']['date'])) . ') ' .
1479 implode(' - ', array_map(function($dt) use ($df, $datesep) {
1480 return date(str_replace('/', $datesep, $df), strtotime($dt));
1481 }, array_filter($target_title_parts)));
1482 }
1483
1484 /**
1485 * @inheritDoc
1486 */
1487 public function getChart(?array $data = null)
1488 {
1489 if (!$this->getReportRows() && !$this->getReportData()) {
1490 return '';
1491 }
1492
1493 // count number of datasets (target + comparison count)
1494 $datasetsCount = count((array) ($this->paceOptions['compare'] ?? [])) + 1;
1495
1496 // build target date start datetime object
1497 $targetStartDt = new DateTime($this->paceOptions['target']['from'], new DateTimeZone(date_default_timezone_get()));
1498
1499 // build chart labels and dataset values
1500 $chartLabels = [];
1501 $chartDatasetValues = [];
1502 $chartDatasetLabels = [];
1503
1504 // scan all target date rows to build the labels
1505 for ($targetIndex = 0; $targetIndex < count($this->rows); $targetIndex += $datasetsCount) {
1506 // push formatted target date label (second row index)
1507 $chartLabels[] = $this->rows[$targetIndex][1]['callback']($this->rows[$targetIndex][1]['value']);
1508 }
1509
1510 // build dataset containers
1511 for ($i = 0; $i < $datasetsCount; $i++) {
1512 // start empty dataset container
1513 $chartDatasetValues[$i] = [];
1514 }
1515
1516 // scan all report rows to build the dataset values
1517 foreach ($this->rows as $index => $row) {
1518 // find the apposite dataset index
1519 $datasetIndex = $index % $datasetsCount;
1520
1521 // iterate all row columns to find the needed ones
1522 foreach ($row as $col) {
1523 if (($col['key'] ?? '') === 'date' && !isset($chartDatasetLabels[$datasetIndex])) {
1524 // build dataset label
1525 if (!$index) {
1526 // first target date
1527 $chartDatasetLabels[$datasetIndex] = $col['value']->format('Y');
1528 } elseif ($index && isset($chartDatasetLabels[0])) {
1529 // comparison date, calculate the years of difference
1530 $dateDiff = $targetStartDt->diff($col['value']);
1531 $yearsDiff = (int) ($dateDiff->y ?? 0);
1532 if (($dateDiff->m ?? 0) > 10) {
1533 $yearsDiff += 1;
1534 }
1535 $chartDatasetLabels[$datasetIndex] = $yearsDiff === 1 ? JText::translate('VBO_SAMET_LAST_YEAR') : JText::sprintf('VBO_SAMET_YEARS_AGO', $yearsDiff);
1536 }
1537
1538 // scan next
1539 continue;
1540 }
1541 if (($col['key'] ?? '') === 'abrn') {
1542 // push "abrn" metric as current dataset value
1543 $chartDatasetValues[$datasetIndex][] = $col['value'];
1544 break;
1545 }
1546 }
1547
1548 if (!($chartDatasetLabels[$datasetIndex] ?? null)) {
1549 // prevent empty dataset labels
1550 $chartDatasetLabels[$datasetIndex] = 'date';
1551 }
1552 }
1553
1554 // the canvas element ID and tag
1555 $canvas_id = 'vbo-report-occupancy-pace-chart-canvas-' . uniqid();
1556 $canvas_html = '<canvas id="' . $canvas_id . '" class="vbo-report-chart-canvas"></canvas>';
1557
1558 if (empty($this->chartScript)) {
1559 // prepare JSON variables
1560 $chartLabelsJSON = json_encode($chartLabels);
1561 $chartDatasetValuesJSON = json_encode($chartDatasetValues);
1562 $chartDatasetLabelsJSON = json_encode($chartDatasetLabels);
1563
1564 // prepare the necessary script to render the Chart
1565 $this->chartScript = <<<JAVASCRIPT
1566 VBOCore.DOMLoaded(() => {
1567 const vboReportCanvas = document.getElementById('$canvas_id');
1568 const vboReportCtx = vboReportCanvas.getContext('2d');
1569 const vboReportData = {
1570 labels: $chartLabelsJSON,
1571 datasets: [],
1572 };
1573 const vboReportDatasetValues = $chartDatasetValuesJSON;
1574 const vboReportDatasetLabels = $chartDatasetLabelsJSON;
1575 vboReportDatasetValues.forEach((datasetData, index) => {
1576 let borderColor = 'rgba(255, 178, 102, 1)';
1577 let backgroundColor = 'rgba(255, 178, 102, 0.2)';
1578 if (!index) {
1579 // target dates
1580 borderColor = 'rgba(30, 144, 255, 1)';
1581 backgroundColor = 'rgba(30, 144, 255, 0.2)';
1582 } else if (!index) {
1583 // more than one comparison value
1584 borderColor = 'rgba(255, 255, 153, 1)';
1585 backgroundColor = 'rgba(255, 255, 153, 0.2)';
1586 }
1587 vboReportData.datasets.push({
1588 label: vboReportDatasetLabels[index] || '',
1589 data: datasetData,
1590 borderColor: borderColor,
1591 backgroundColor: backgroundColor,
1592 });
1593 });
1594 const vboReportChart = new Chart(vboReportCtx, {
1595 type: 'line',
1596 data: vboReportData,
1597 options: {
1598 responsive: true,
1599 legend: {
1600 display: true,
1601 },
1602 // axes handling
1603 scales: {
1604 // Y Axis properties
1605 yAxes: [{
1606 // make sure the chart starts at 0
1607 ticks: {
1608 // format value as currency
1609 callback: (value) => parseInt(value),
1610 beginAtZero: true,
1611 stepSize: 1,
1612 },
1613 }],
1614 },
1615 },
1616 });
1617 });
1618 JAVASCRIPT;
1619
1620 // set the necessary script
1621 $this->setScript($this->chartScript);
1622 }
1623
1624 // return the canvas HTML
1625 return $canvas_html;
1626 }
1627
1628 /**
1629 * Renders the rate history for a given listing and date.
1630 *
1631 * @param int $listingId The VikBooking listing ID.
1632 * @param string $night The targeted night of stay (Y-m-d).
1633 *
1634 * @return array
1635 *
1636 * @throws Exception
1637 */
1638 public function renderRateHistory(int $listingId, string $night)
1639 {
1640 // default environment values
1641 $df = $this->getDateFormat();
1642 $datesep = VikBooking::getDateSeparator();
1643 $currency_symb = VikBooking::getCurrencySymb();
1644 $todayDt = new DateTime(date('Y-m-d H:i:s'), new DateTimeZone(date_default_timezone_get()));
1645
1646 // validate arguments
1647 $listingDetails = VikBooking::getRoomInfo($listingId);
1648 $stayTs = VikBooking::getDateTimestamp($night);
1649
1650 if (!$listingDetails) {
1651 throw new Exception('Invalid listing ID.', 404);
1652 }
1653
1654 if (!$stayTs) {
1655 throw new Exception('Invalid target stay date.', 400);
1656 }
1657
1658 // listing full name and stay date
1659 $listingName = $listingDetails['name'];
1660 $stayDate = date(str_replace('/', $datesep, $df), $stayTs);
1661 $stayDateDt = new DateTime(date('Y-m-d H:i:s', $stayTs), new DateTimeZone(date_default_timezone_get()));
1662 $stayInfo = getdate($stayTs);
1663 $stayDayYmd = date('Y-m-d', $stayTs);
1664 $prevDayYmd = date('Y-m-d', strtotime('-1 day', $stayTs));
1665 $nextDayYmd = date('Y-m-d', strtotime('+1 day', $stayTs));
1666 $isPastStay = $stayTs < strtotime('00:00:00');
1667
1668 // week days short names map
1669 $wdays_map = [
1670 JText::translate('VBSUN'),
1671 JText::translate('VBMON'),
1672 JText::translate('VBTUE'),
1673 JText::translate('VBWED'),
1674 JText::translate('VBTHU'),
1675 JText::translate('VBFRI'),
1676 JText::translate('VBSAT'),
1677 ];
1678
1679 // env
1680 $targetTitle = JText::translate('VBO_TARGET_DATE');
1681 $dtTitle = JText::translate('VBPVIEWORDERSONE');
1682 $rateTitle = JText::translate('VBEDITORDERSEVEN');
1683 $durationTitle = JText::translate('VBO_DURATION');
1684 $variationTitle = JText::translate('VBO_VARIATION');
1685 $daysTitle = strtolower(JText::translate('VBCONFIGSEARCHPMAXDATEDAYS'));
1686 $dayTitle = strtolower(JText::translate('VBODAY'));
1687 $tabTitleList = JText::translate('VBO_LIST');
1688 $tabTitleChart = JText::translate('VBOCHARTONLY');
1689 $prevTitle = JText::translate('VBO_PREV');
1690 $nextTitle = JText::translate('VBO_NEXT');
1691 $newRateTitle = ucfirst(strtolower(JText::translate('VBRATESOVWSETNEWRATE')));
1692 $listingIcn = '<i class="' . VikBookingIcons::i('home') . '"></i>';
1693 $clockIcn = '<i class="' . VikBookingIcons::i('clock') . '"></i>';
1694 $chartIcn = '<i class="' . VikBookingIcons::i('chart-line') . '"></i>';
1695 $plusIcn = '<i class="' . VikBookingIcons::i('plus') . '"></i>';
1696 $minusIcn = '<i class="' . VikBookingIcons::i('minus') . '"></i>';
1697 $prevIcn = '<i class="' . VikBookingIcons::i('chevron-left') . '"></i>';
1698 $nextIcn = '<i class="' . VikBookingIcons::i('chevron-right') . '"></i>';
1699 $ratesIcn = '<i class="' . VikBookingIcons::i('tag') . '"></i>';
1700 $wdayName = $wdays_map[$stayInfo['wday']];
1701
1702 // fetch all confirmed and cancelled bookings intersecting the targeted stay date
1703 $bookings = VBORmsPace::getInstance()->getIntersectingBookings([
1704 'target' => [
1705 'from_ts' => $stayTs,
1706 'to_ts' => strtotime('23:59:59', $stayTs),
1707 ],
1708 'listings' => [$listingDetails['id']],
1709 'cancellation_dt' => 1,
1710 'tariff_taxes' => 1,
1711 ]);
1712
1713 // construct the RMS rates registry object for the targeted stay date
1714 $ratesRegistry = (new VBORmsRatesRegistry([
1715 'target' => [
1716 'from_ts' => $stayTs,
1717 'to_ts' => $stayTs,
1718 ],
1719 'listings' => [$listingDetails['id']],
1720 ]))->preloadFlowRecords();
1721
1722 // build rate history intervals for all pricing modifications
1723 $rateHistoryIntervals = [];
1724
1725 // obtain main rate plan ID under evaluation
1726 $mainRatePlanId = $ratesRegistry->getMainRatePlanId();
1727
1728 // obtain flow records data
1729 $flowRecords = $ratesRegistry->getFlowRecords($ascending = true);
1730
1731 // iterate flow records
1732 $flowData = [];
1733 foreach ($flowRecords as $flowRecord) {
1734 // pricing modification record date
1735 $creationDt = new DateTime($flowRecord['created_on'], new DateTimeZone(date_default_timezone_get()));
1736
1737 if (!$flowData) {
1738 // start container
1739 $flowData = [
1740 'created_on' => (clone $creationDt),
1741 'lasted_until' => null,
1742 'nightly_fee' => $flowRecord['nightly_fee'],
1743 ];
1744
1745 // go next
1746 continue;
1747 }
1748
1749 if ($flowRecord['nightly_fee'] != $flowData['nightly_fee']) {
1750 // pricing modification will adjust the "lasted until" date in previous node
1751 $prevCreationDt = clone $creationDt;
1752 $prevCreationDt->modify('-1 second');
1753 $flowData['lasted_until'] = $prevCreationDt;
1754
1755 // push rate history interval
1756 $rateHistoryIntervals[] = $flowData;
1757
1758 // start a new container
1759 $flowData = [
1760 'created_on' => (clone $creationDt),
1761 'lasted_until' => null,
1762 'nightly_fee' => $flowRecord['nightly_fee'],
1763 ];
1764 }
1765 }
1766
1767 if ($flowRecords && empty($flowData['lasted_until'])) {
1768 // push last, even first-and-only, rate history interval
1769 $rateHistoryIntervals[] = $flowData;
1770 }
1771
1772 // build history template
1773 $historyTpl = '';
1774
1775 // scan all pricing modification intervals
1776 foreach ($rateHistoryIntervals as $flowIndex => $pricingModification) {
1777 // obtain record details
1778 $creationYmd = $pricingModification['created_on']->format(str_replace('/', $datesep, $df) . ' H:i:s');
1779 $nightlyRate = VikBooking::formatCurrencyNumber($pricingModification['nightly_fee']);
1780
1781 // start rate duration and booking variation metrics
1782 $durationDays = '---';
1783 $variationBookings = '---';
1784
1785 // determine the "lasted until" date
1786 if ($pricingModification['lasted_until']) {
1787 // known end date
1788 $lastedUntilDt = $pricingModification['lasted_until'];
1789 } else {
1790 // last rate applied to target date
1791 $lastedUntilDt = $stayDateDt < $todayDt ? $stayDateDt : $todayDt;
1792 }
1793
1794 // calculate rate duration
1795 $datesDiff = $lastedUntilDt->diff($pricingModification['created_on']);
1796 if ($datesDiff->days) {
1797 // duration is at least one day
1798 $durationDays = sprintf('%d %s', $datesDiff->days, ($datesDiff->days == 1 ? $dayTitle : $daysTitle));
1799 } else {
1800 // duration is just a few hours/minutes
1801 $durationDays = sprintf('%d %s', 0, JText::translate('VBTRKDIFFSECS'));
1802 $durationHours = $datesDiff->h;
1803 $durationMinutes = $datesDiff->i;
1804 $durationSeconds = $datesDiff->s;
1805 if ($durationHours) {
1806 $durationDays = sprintf('%d %s', $durationHours, ($durationHours == 1 ? JText::translate('VBO_HOUR') : JText::translate('VBCONFIGONETENEIGHT')));
1807 } elseif ($durationMinutes) {
1808 $durationDays = sprintf('%d %s', $durationMinutes, ($durationMinutes == 1 ? JText::translate('VBO_MINUTE') : JText::translate('VBTRKDIFFMINS')));
1809 } elseif ($durationSeconds) {
1810 $durationDays = sprintf('%d %s', $durationSeconds, ($durationSeconds == 1 ? JText::translate('VBO_SECOND') : JText::translate('VBTRKDIFFSECS')));
1811 }
1812 }
1813
1814 // calculate booking variations
1815 $variationData = [
1816 'nb' => 0,
1817 'cb' => 0,
1818 ];
1819 $variationFromTs = $pricingModification['created_on']->format('U');
1820 $variationToTs = $pricingModification['lasted_until'] ? $pricingModification['lasted_until']->format('U') : null;
1821 // scan all bookings intersecting the stay date
1822 foreach ($bookings as $booking) {
1823 if (($variationToTs !== null && $booking['ts'] >= $variationFromTs && $booking['ts'] < $variationToTs) || (!$variationToTs && $booking['ts'] >= $variationFromTs)) {
1824 // there was a new confirmed booking
1825 $variationData['nb']++;
1826 }
1827 if (!empty($booking['cancellation_ts'])) {
1828 if (($variationToTs !== null && $booking['cancellation_ts'] >= $variationFromTs && $booking['cancellation_ts'] < $variationToTs) || (!$variationToTs && $booking['cancellation_ts'] >= $variationFromTs)) {
1829 // there was a booking cancellation
1830 $variationData['cb']++;
1831 }
1832 }
1833 }
1834
1835 if ($variationData['nb'] || $variationData['cb']) {
1836 // build variation bookings template
1837 $variationNb = $variationData['nb'];
1838 $variationCb = $variationData['cb'];
1839 $variationBookings = <<<HTML
1840 <span class="label label-success" data-type="vrplus">$plusIcn $variationNb</span>
1841 <span class="label label-error" data-type="vrminus">$minusIcn $variationCb</span>
1842 HTML;
1843 }
1844
1845 // set history record
1846 $historyTpl .= <<<HTML
1847 <div class="vbo-rms-rate-history-record">
1848 <span class="vbo-rms-rate-history-record-dt">
1849 <span class="vbo-rms-rate-history-record-title">$dtTitle</span>
1850 <span class="vbo-rms-rate-history-record-value">$clockIcn $creationYmd</span>
1851 </span>
1852 <span class="vbo-rms-rate-history-record-duration">
1853 <span class="vbo-rms-rate-history-record-title">$durationTitle</span>
1854 <span class="vbo-rms-rate-history-record-value">$durationDays</span>
1855 </span>
1856 <span class="vbo-rms-rate-history-record-variation">
1857 <span class="vbo-rms-rate-history-record-title">$variationTitle</span>
1858 <span class="vbo-rms-rate-history-record-value">$variationBookings</span>
1859 </span>
1860 <span class="vbo-rms-rate-history-record-rate">
1861 <span class="vbo-rms-rate-history-record-title">$rateTitle</span>
1862 <span class="vbo-rms-rate-history-record-value">$nightlyRate</span>
1863 </span>
1864 </div>
1865 HTML;
1866 }
1867
1868 if (!$rateHistoryIntervals) {
1869 // add message that no rate history records were found
1870 $historyTpl = '<p class="warn">' . JText::translate('VBO_NO_RECORDS_FOUND') . '</p>';
1871 }
1872
1873 // build chart data (if any), tabs and panels template strings
1874 $chartData = [];
1875 $tabChartTpl = '';
1876 $panelChartTpl = '';
1877 if (count($rateHistoryIntervals) > 1) {
1878 // chart is available only if we have at least one rate modification record
1879 $tabChartTpl = <<<HTML
1880 <div class="vbo-panel-tabs-tab" data-tab="chart"><span>$chartIcn $tabTitleChart</span></div>
1881 HTML;
1882
1883 $panelChartTpl = <<<HTML
1884 <div class="vbo-panel-tabs-panel vbo-rms-rate-history-chart" data-panel="chart" style="display: none;">
1885 <canvas class="vbo-report-chart-inmodal vbo-rms-rate-history-chart-canvas"></canvas>
1886 </div>
1887 HTML;
1888
1889 // build chart data for rendering the chart
1890 $chartData = [
1891 'labels' => [],
1892 'datasets' => [
1893 // push dataset for "rate"
1894 [
1895 'label' => $rateTitle,
1896 'data' => [],
1897 'borderColor' => 'rgba(30, 144, 255, 1)',
1898 'backgroundColor' => 'rgba(30, 144, 255, 0)',
1899 'yAxisID' => 'y',
1900 'pointRadius' => 0,
1901 'pointHoverRadius' => 0,
1902 ],
1903 // push dataset for "bookings"
1904 [
1905 'label' => JText::translate('VBMENUTHREE'),
1906 'data' => [],
1907 'borderColor' => 'rgba(0, 100, 0, 1)',
1908 'backgroundColor' => 'rgba(0, 100, 0, 0)',
1909 'yAxisID' => 'y1',
1910 'pointRadius' => 0,
1911 'pointHoverRadius' => 0,
1912 ],
1913 ],
1914 ];
1915
1916 // construct the first and last dates
1917 $firstIntervalDt = clone $rateHistoryIntervals[0]['created_on'];
1918 if ($rateHistoryIntervals[count($rateHistoryIntervals) - 1]['lasted_until']) {
1919 // known end date
1920 $lastIntervalDt = clone $pricingModification['lasted_until'];
1921 } else {
1922 // calculate end date
1923 $lastIntervalDt = clone ($stayDateDt < $todayDt ? $stayDateDt : $todayDt);
1924 }
1925
1926 // obtain iterable date period from first and last interval dates
1927 $intervalPeriod = new DatePeriod(
1928 $firstIntervalDt,
1929 new DateInterval('P1D'),
1930 $lastIntervalDt
1931 );
1932
1933 // set chart data
1934 foreach ($intervalPeriod as $periodDt) {
1935 // push chart label (x-axis)
1936 $chartData['labels'][] = $periodDt->format(str_replace('/', $datesep, $df));
1937
1938 // obtain start and end timestamps for the current day
1939 $periodTsFrom = strtotime('00:00:00', $periodDt->format('U'));
1940 $periodTsTo = strtotime('23:59:59', $periodDt->format('U'));
1941
1942 // get the rate as of the current period and push it to the apposite dataset
1943 $periodRate = 0;
1944 foreach ($rateHistoryIntervals as $pricingModification) {
1945 if ($periodDt >= $pricingModification['created_on'] && (empty($pricingModification['lasted_until']) || $periodDt < $pricingModification['lasted_until'])) {
1946 $periodRate = $pricingModification['nightly_fee'];
1947 break;
1948 }
1949 }
1950 $chartData['datasets'][0]['data'][] = $periodRate;
1951
1952 // iterate all bookings to count the "OTB" as of the current period
1953 $otbCount = 0;
1954 foreach ($bookings as $booking) {
1955 if ($booking['ts'] >= $periodTsFrom && $booking['ts'] <= $periodTsTo) {
1956 // new booking
1957 $otbCount++;
1958 }
1959 if (!empty($booking['cancellation_ts']) && $booking['cancellation_ts'] >= $periodTsFrom && $booking['cancellation_ts'] <= $periodTsTo) {
1960 // booking cancellation
1961 $otbCount--;
1962 }
1963 }
1964
1965 // push OTB value to apposite dataset
1966 $chartData['datasets'][1]['data'][] = $otbCount;
1967 }
1968 }
1969
1970 // build set-new-rate template
1971 $newRateTpl = '';
1972 if (!$isPastStay) {
1973 $newRateTpl = <<<HTML
1974 <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>
1975 HTML;
1976 }
1977
1978 // build template for rendering
1979 $tpl = <<<HTML
1980 <div class="vbo-rms-rate-history-wrap">
1981 <div class="vbo-rms-rate-history-details">
1982 <div class="vbo-rms-rate-history-listing">$listingIcn $listingName</div>
1983 <div class="vbo-rms-rate-history-target">
1984 <span class="vbo-rms-rate-history-target-title">$targetTitle</span>
1985 <span class="vbo-rms-rate-history-target-value">$wdayName, $stayDate</span>
1986 </div>
1987 </div>
1988 <div class="vbo-panel-tabs-wrap">
1989 <div class="vbo-panel-tabs-tabs">
1990 <div class="vbo-panel-tabs-tab active" data-tab="list"><span>$clockIcn $tabTitleList</span></div>
1991 $tabChartTpl
1992 <div class="vbo-panel-tabs-right">
1993 $newRateTpl
1994 <div class="vbo-panel-tabs-cmd" data-cmd="prev" data-day="$prevDayYmd" data-listing-id="$listingId"><span>$prevIcn $prevTitle</span></div>
1995 <div class="vbo-panel-tabs-cmd" data-cmd="next" data-day="$nextDayYmd" data-listing-id="$listingId"><span>$nextTitle $nextIcn</span></div>
1996 </div>
1997 </div>
1998 <div class="vbo-panel-tabs-panels">
1999 <div class="vbo-panel-tabs-panel vbo-rms-rate-history-list" data-panel="list">$historyTpl</div>
2000 $panelChartTpl
2001 </div>
2002 </div>
2003 </div>
2004 HTML;
2005
2006 return [
2007 'html' => $tpl,
2008 'chart' => $chartData ?: null,
2009 ];
2010 }
2011
2012 /**
2013 * Given a name, usually a listing name, compacts it into a code.
2014 *
2015 * @param string $name The full (listing) name to compact.
2016 * @param ?int $length Optional string length to obtain.
2017 * @param ?string $concat Optional string to contact when shortening.
2018 *
2019 * @return string The compacted name.
2020 */
2021 protected function compactName(string $name, ?int $length = null, ?string $concat = null)
2022 {
2023 // strip vowels and white spaces
2024 $name = preg_replace('/[a|e|i|o|u|y|\s]/i', '', $name);
2025
2026 if (intval($length) > 0 && strlen($name) > $length) {
2027 if (function_exists('mb_substr')) {
2028 $name = mb_substr($name, 0, $length, 'UTF-8');
2029 } else {
2030 $name = substr($name, 0, $length);
2031 }
2032 if ($concat) {
2033 $name .= $concat;
2034 }
2035 }
2036
2037 return $name;
2038 }
2039
2040 /**
2041 * Registers the name to give to the CSV file being exported.
2042 *
2043 * @return void
2044 */
2045 protected function registerExportCSVFileName()
2046 {
2047 $app = JFactory::getApplication();
2048
2049 $nameValues = array_map(function($dtString) {
2050 return date('Y-m-d', VikBooking::getDateTimestamp($dtString));
2051 }, array_filter([
2052 $app->input->getString('pickup_date', ''),
2053 $app->input->getString('target_date_from', ''),
2054 $app->input->getString('target_date_to', ''),
2055 ]));
2056
2057 $this->setExportCSVFileName($this->reportName . '-' . implode('_', array_filter($nameValues)) . '.csv');
2058 }
2059 }
2060