diff --git a/Pages/Ajax/ReservationAttributesPrintPage.php b/Pages/Ajax/ReservationAttributesPrintPage.php index e7192c410..71caff8c3 100644 --- a/Pages/Ajax/ReservationAttributesPrintPage.php +++ b/Pages/Ajax/ReservationAttributesPrintPage.php @@ -51,6 +51,7 @@ public function PageLoad() $userSession = ServiceLocator::GetServer()->GetUserSession(); $this->presenter->PageLoad($userSession); $this->Set('ReadOnly', BooleanConverter::ConvertValue($this->GetIsReadOnly())); + $this->Set('CustomAttributeTypeDateTime', CustomAttributeTypes::DATETIME); $this->Display('Ajax/reservation/reservation_attributes_print.tpl'); } diff --git a/Pages/Reservation/ReservationPage.php b/Pages/Reservation/ReservationPage.php index 64281ce0e..f98977682 100644 --- a/Pages/Reservation/ReservationPage.php +++ b/Pages/Reservation/ReservationPage.php @@ -219,6 +219,7 @@ public function PageLoad() 'weekly' => ['key' => 'Weekly', 'everyKey' => 'weeks'], 'monthly' => ['key' => 'Monthly', 'everyKey' => 'months'], 'yearly' => ['key' => 'Yearly', 'everyKey' => 'years'], + 'custom' => ['key' => 'Custom', 'everyKey' => ''], ] ); $this->Set( @@ -244,9 +245,218 @@ public function PageLoad() return; } + $this->SetReservationPdfConfig(); $this->Display($this->GetTemplateName()); } + private function SetReservationPdfConfig(): void + { + $repeatOptions = $this->SmartyVar('RepeatOptions', []); + $repeatType = $this->SmartyVar('RepeatType', RepeatType::None); + $repeatMonthlyType = $this->SmartyVar('RepeatMonthlyType', ''); + $showReservationDetails = (bool)$this->SmartyVar('ShowReservationDetails', false); + $showParticipation = (bool)$this->SmartyVar('ShowParticipation', false); + $accessories = $this->SmartyVar('Accessories', []); + $participants = $this->SmartyVar('Participants', []); + $invitees = $this->SmartyVar('Invitees', []); + $attachments = $this->SmartyVar('Attachments', []); + $repeatWeekdays = $this->SmartyVar('RepeatWeekdays', []); + $customRepeatDates = $this->SmartyVar('CustomRepeatDates', []); + $dayNames = Resources::GetInstance()->GetDays('full'); + $reminders = []; + + if ($this->SmartyVar('ReminderTimeStart', '') !== '') { + $reminders[] = [ + 'time' => (string)$this->SmartyVar('ReminderTimeStart'), + 'interval' => $this->Translate($this->SmartyVar('ReminderIntervalStart', '')), + 'text' => $this->Translate('ReminderBeforeStart'), + ]; + } + + if ($this->SmartyVar('ReminderTimeEnd', '') !== '') { + $reminders[] = [ + 'time' => (string)$this->SmartyVar('ReminderTimeEnd'), + 'interval' => $this->Translate($this->SmartyVar('ReminderIntervalEnd', '')), + 'text' => $this->Translate('ReminderBeforeEnd'), + ]; + } + + $config = [ + 'appTitle' => $this->SmartyVar('AppTitle', ''), + 'reservationDetailsTitle' => $this->SmartyCapitalize($this->Translate('ReservationDetails')), + 'referenceNumberLabel' => $this->Translate('ReferenceNumber'), + 'referenceNumber' => $this->SmartyVar('ReferenceNumber', ''), + 'userLabel' => $this->Translate('User'), + 'reservationUserName' => $this->DecodeHtml($this->SmartyVar('ReservationUserName', '')), + 'showUserDetailsAndReservationDetails' => (bool)$this->SmartyVar('ShowUserDetails', false) && $showReservationDetails, + + 'beginDateLabel' => $this->Translate('BeginDate'), + 'beginDateValue' => $this->FormatPdfDate($this->SmartyVar('StartDate'), 'dashboard'), + 'endDateLabel' => $this->Translate('EndDate'), + 'endDateValue' => $this->FormatPdfDate($this->SmartyVar('EndDate'), 'dashboard'), + 'reservationLengthLabel' => $this->Translate('ReservationLength'), + 'repeatPromptLabel' => $this->Translate('RepeatPrompt'), + 'isRecurring' => (bool)$this->SmartyVar('IsRecurring', false), + 'isCustomRepeat' => $repeatType === RepeatType::Custom, + 'repeatTypeLabel' => $this->Translate($repeatOptions[$repeatType]['key'] ?? ''), + 'repeatInterval' => (string)$this->SmartyVar('RepeatInterval', ''), + 'repeatEveryLabel' => $repeatType === RepeatType::Custom ? '' : $this->Translate($repeatOptions[$repeatType]['everyKey'] ?? ''), + 'repeatOnLabel' => $this->Translate('RepeatOn'), + 'typeLabel' => $this->Translate('Type'), + 'repeatMonthlyTypeLabel' => $repeatMonthlyType === '' ? '' : $this->Translate($repeatMonthlyType === RepeatMonthlyType::DayOfMonth ? 'repeatDayOfMonth' : 'repeatDayOfWeek'), + 'daysLabel' => $this->SmartyCapitalize($this->Translate('RepeatDaysPrompt')), + 'repeatWeekdays' => array_map( + fn ($day) => $dayNames[$day] ?? '', + is_array($repeatWeekdays) ? $repeatWeekdays : [] + ), + 'repeatCustomDates' => array_map( + fn ($date) => $this->FormatPdfDate($date, 'schedule_daily', $this->SmartyVar('Timezone')), + is_array($customRepeatDates) ? $customRepeatDates : [] + ), + 'repeatUntilPromptLabel' => $this->Translate('RepeatUntilPrompt'), + 'repeatUntilDate' => $this->FormatPdfDate($this->SmartyVar('RepeatTerminationDate'), 'dashboard'), + + 'additionalAttributesLabel' => $this->Translate('AdditionalAttributes'), + 'customAttributeTypeCheckbox' => CustomAttributeTypes::CHECKBOX, + + 'resourcesHeaderLabel' => $this->Translate('Resources'), + 'requiresApprovalLabel' => $this->Translate('RequiresApproval'), + 'requiresCheckInNotificationLabel' => $this->Translate('RequiresCheckInNotification'), + 'releasedInLabel' => sprintf('%s (%s)', $this->Translate('ReleasedIn'), $this->Translate('minutes')), + 'resources' => $this->PdfResources(), + + 'showAccessories' => $showReservationDetails && count($accessories) > 0, + 'accessoriesHeaderLabel' => $this->Translate('Accessories'), + 'quantityLabel' => $this->Translate('Quantity'), + 'accessories' => array_map( + fn ($accessory) => [ + 'name' => (string)$accessory->Name, + 'quantity' => (string)$accessory->QuantityReserved, + ], + $accessories + ), + + 'showParticipants' => $showReservationDetails && $showParticipation && count($participants) > 0, + 'participantsHeaderLabel' => $this->Translate('Participants'), + 'emailLabel' => $this->Translate('Email'), + 'participants' => $this->PdfUsers($participants), + + 'showInvitees' => $showReservationDetails && $showParticipation && count($invitees) > 0, + 'invitationListHeaderLabel' => $this->Translate('InvitationList'), + 'invitees' => $this->PdfUsers($invitees), + + 'reservationTitleLabel' => $this->Translate('ReservationTitle'), + 'reservationTitle' => $this->DecodeHtml($this->SmartyVar('ReservationTitle', '')), + 'reservationDescriptionLabel' => $this->Translate('ReservationDescription'), + 'reservationDescription' => $this->DecodeHtml($this->SmartyVar('Description', '')), + + 'remindersEnabled' => (bool)$this->SmartyVar('RemindersEnabled', false), + 'sendReminderLabel' => $this->Translate('SendReminder'), + 'reminders' => $reminders, + + 'attachmentsLabel' => $this->Translate('Attachments'), + 'attachments' => array_map( + fn ($attachment) => $attachment->FileName(), + $attachments + ), + + 'showTermsAcceptance' => $this->SmartyVar('Terms') !== null && (bool)$this->SmartyVar('TermsAccepted', false), + 'acceptTermsLabel' => sprintf('%s %s', $this->Translate('IAccept'), $this->Translate('TheTermsOfService')), + + 'logoUrl' => sprintf('%s/img/%s', $this->SmartyVar('ScriptUrl', ''), $this->SmartyVar('LogoUrl', '')), + ]; + + $this->Set('ReservationPdfConfigJson', json_encode( + $config, + // Keep the inline script assignment safe and fail fast if encoding ever breaks. + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + )); + } + + private function SmartyVar(string $name, mixed $default = null): mixed + { + // Bridge existing presenter-to-template assignments while building the PDF config in PHP. + $value = $this->smarty->getTemplateVars($name); + return $value === null ? $default : $value; + } + + private function Translate(string $key): string + { + if ($key === '') { + return ''; + } + + return Resources::GetInstance()->GetString($key, ''); + } + + private function SmartyCapitalize(string $value): string + { + $capitalize = $this->smarty->getModifierCallback('capitalize'); + + return $capitalize === null ? $value : (string)$capitalize($value); + } + + private function DecodeHtml(mixed $value): string + { + return html_entity_decode((string)$value, ENT_QUOTES | ENT_HTML5); + } + + private function FormatPdfDate(mixed $date, string $key, ?string $timezone = null): string + { + $params = [ + 'date' => $date, + 'key' => $key, + ]; + + if ($timezone !== null) { + $params['timezone'] = $timezone; + } + + return $this->smarty->FormatDate($params, $this->smarty); + } + + private function PdfResources(): array + { + $resources = []; + $primaryResource = $this->SmartyVar('Resource'); + + if ($primaryResource !== null) { + $resources[] = $this->PdfResource($primaryResource, ''); + } + + $additionalResourceIds = $this->SmartyVar('AdditionalResourceIds', []); + $availableResources = $this->SmartyVar('AvailableResources', []); + + foreach ($availableResources as $resource) { + if (in_array($resource->Id, is_array($additionalResourceIds) ? $additionalResourceIds : [])) { + $resources[] = $this->PdfResource($resource, ' - '); + } + } + + return $resources; + } + + private function PdfResource(IBookableResource $resource, string $emptyReleasedIn): array + { + return [ + 'name' => $resource->GetName(), + 'requiresApproval' => $resource->GetRequiresApproval(), + 'requiresCheckIn' => $resource->IsCheckInEnabled(), + 'releasedIn' => $resource->IsAutoReleased() ? (string)$resource->GetAutoReleaseMinutes() : $emptyReleasedIn, + ]; + } + + private function PdfUsers(array $users): array + { + return array_map( + fn ($user) => [ + 'fullName' => $this->DecodeHtml($user->FullName), + 'email' => (string)$user->Email, + ], + $users + ); + } + public function BindPeriods($startPeriods, $endPeriods, $lockPeriods) { $this->Set('StartPeriods', $startPeriods); diff --git a/Web/scripts/reservation-pdf.js b/Web/scripts/reservation-pdf.js new file mode 100644 index 000000000..442c6c191 --- /dev/null +++ b/Web/scripts/reservation-pdf.js @@ -0,0 +1,490 @@ +(function () { + 'use strict'; + + function getConfig() { + return window.ReservationPdfConfig || null; + } + + function getCheckboxAttributeType(config) { + return Number(config.customAttributeTypeCheckbox); + } + + function getSelectedResourceIds() { + const resourceIds = []; + const primaryResource = document.getElementById('primaryResourceId'); + if (primaryResource) { + resourceIds.push(parseInt(primaryResource.value, 10)); + } + + document.querySelectorAll('#additionalResources .resourceId').forEach((element) => { + resourceIds.push(parseInt(element.value, 10)); + }); + + return resourceIds.filter((resourceId) => !isNaN(resourceId)); + } + + async function loadCustomAttributesData() { + const uid = document.getElementById('userId'); + const rn = document.getElementById('referenceNumber'); + const ro = document.getElementById('reservation-box'); + const params = new URLSearchParams({ + uid: uid ? uid.value : '', + rn: rn ? rn.value : '', + ro: String(ro ? ro.classList.contains('readonly') : false), + }); + + getSelectedResourceIds().forEach((resourceId) => params.append('rid[]', resourceId)); + + try { + const response = await fetch('ajax/reservation_attributes_print.php?' + params.toString(), { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('fetch failed'); + } + + return (await response.json()) || {}; + } catch (error) { + console.warn('Error loading attributes', error); + return {}; + } + } + + function loadImageDataUrl(url) { + return new Promise((resolve) => { + const image = new Image(); + + image.crossOrigin = 'anonymous'; + image.onload = function () { + try { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + canvas.getContext('2d').drawImage(image, 0, 0); + resolve(canvas.toDataURL('image/png')); + } catch { + resolve(null); + } + }; + image.onerror = function () { + resolve(null); + }; + image.src = url; + }); + } + + function pdfCell(text, style, extra) { + const cell = { + text: text == null ? '' : String(text), + }; + + if (style) { + cell.style = style; + } + + if (extra) { + Object.assign(cell, extra); + } + + return cell; + } + + function pdfSpanCell(text, style, span, extra) { + const cell = pdfCell(text, style, extra); + cell.colSpan = span; + return cell; + } + + function pdfEmptyCells(count) { + return Array.from({ length: count }, function () { + return {}; + }); + } + + function pdfTable(body, widths, headerRows) { + return { + margin: [0, 0, 0, 6], + layout: { + hLineWidth: function () { + return 0.4; + }, + vLineWidth: function () { + return 0.4; + }, + hLineColor: function () { + return '#c9d1dc'; + }, + vLineColor: function () { + return '#c9d1dc'; + }, + paddingLeft: function () { + return 6; + }, + paddingRight: function () { + return 6; + }, + paddingTop: function () { + return 4; + }, + paddingBottom: function () { + return 4; + }, + }, + table: { + headerRows: headerRows || 0, + widths: widths, + body: body, + }, + }; + } + + function buildHeader(config, logoDataUrl) { + const content = []; + + content.push({ + columns: [ + logoDataUrl ? { image: logoDataUrl, fit: [64, 40], width: 64, margin: [0, 0, 10, 0] } : { text: '' }, + { + width: '*', + stack: [ + { text: config.appTitle, style: 'documentTitle' }, + { text: config.reservationDetailsTitle, style: 'documentSubtitle' }, + ], + margin: [0, 2, 0, 0], + }, + ], + columnGap: 10, + margin: [0, 0, 0, 4], + }); + + content.push({ + canvas: [ + { + type: 'line', + x1: 0, + y1: 0, + x2: 530, + y2: 0, + lineWidth: 0.8, + lineColor: '#8b99ad', + }, + ], + margin: [0, 0, 0, 8], + }); + + content.push({ + text: config.referenceNumberLabel + ': ' + config.referenceNumber, + style: 'sectionHeading', + alignment: 'left', + }); + + if (config.showUserDetailsAndReservationDetails) { + content.push( + pdfTable( + [[pdfCell(config.userLabel, 'labelCell'), pdfCell(config.reservationUserName, 'valueCell')]], + [150, '*'] + ) + ); + } + + return content; + } + + function buildDetails(config, durationText) { + const isCustomRepeat = config.isRecurring && config.isCustomRepeat; + const detailsBody = [ + [ + pdfCell(config.beginDateLabel, 'labelCell'), + pdfCell(config.beginDateValue, 'valueCell'), + pdfCell(config.endDateLabel, 'labelCell'), + pdfCell(config.endDateValue, 'valueCell'), + ], + [pdfCell(config.reservationLengthLabel, 'labelCell'), pdfSpanCell(durationText, 'valueCell', 3)].concat( + pdfEmptyCells(2) + ), + [pdfCell(config.repeatPromptLabel, 'labelCell')] + .concat( + config.isRecurring && !isCustomRepeat + ? [ + pdfCell(config.repeatTypeLabel, 'valueCell'), + pdfCell(config.repeatInterval, 'valueCell'), + pdfCell(config.repeatEveryLabel, 'valueCell'), + ] + : [pdfSpanCell(config.repeatTypeLabel, 'valueCell', 3)] + ) + .concat(config.isRecurring && !isCustomRepeat ? [] : pdfEmptyCells(2)), + ]; + + if (config.isRecurring) { + if (isCustomRepeat && config.repeatCustomDates.length > 0) { + detailsBody.push( + [ + pdfCell(config.repeatOnLabel, 'labelCell'), + pdfSpanCell(config.repeatCustomDates.join(', '), 'valueCell', 3), + ].concat(pdfEmptyCells(2)) + ); + } + + if (!isCustomRepeat && config.repeatMonthlyTypeLabel) { + detailsBody.push( + [pdfCell(config.typeLabel, 'labelCell'), pdfSpanCell(config.repeatMonthlyTypeLabel, 'valueCell', 3)].concat( + pdfEmptyCells(2) + ) + ); + } + + if (!isCustomRepeat && config.repeatWeekdays.length > 0) { + detailsBody.push( + [ + pdfCell(config.daysLabel, 'labelCell'), + pdfSpanCell(config.repeatWeekdays.join(', '), 'valueCell', 3), + ].concat(pdfEmptyCells(2)) + ); + } + + if (!isCustomRepeat && config.repeatUntilDate) { + detailsBody.push( + [ + pdfCell(config.repeatUntilPromptLabel, 'labelCell'), + pdfSpanCell(config.repeatUntilDate, 'valueCell', 3), + ].concat(pdfEmptyCells(2)) + ); + } + } + + return pdfTable(detailsBody, [95, '*', 85, '*']); + } + + function buildAttributes(config, attributesData) { + if (!attributesData || Object.keys(attributesData).length === 0) { + return null; + } + + const attributesBody = [ + [pdfSpanCell(config.additionalAttributesLabel, 'tableHeader', 2, { alignment: 'left' }), {}], + ]; + const checkboxAttributeType = getCheckboxAttributeType(config); + + Object.values(attributesData).forEach((attribute) => { + const attributeType = Number(attribute[0]); + const attributeValue = String(attribute[2] ?? '').toLowerCase(); + const isChecked = attributeValue === '1' || attributeValue === 'true' || attributeValue === 'on'; + + if (attributeType === checkboxAttributeType) { + attributesBody.push([ + pdfCell(attribute[1], 'labelCell'), + pdfCell(isChecked ? 'X' : '', 'valueCell', { alignment: 'left' }), + ]); + return; + } + + attributesBody.push([pdfCell(attribute[1], 'labelCell'), pdfCell(attribute[2], 'valueCell')]); + }); + + return pdfTable(attributesBody, [220, '*'], 1); + } + + function buildReservationPdfDefinition(config, logoDataUrl, attributesData) { + const durationElement = document.getElementsByClassName('durationText').item(0); + const durationText = durationElement ? durationElement.innerText : ''; + const reminders = []; + const content = []; + + Array.prototype.push.apply(content, buildHeader(config, logoDataUrl)); + content.push(buildDetails(config, durationText)); + + const resourcesBody = [ + [ + pdfCell(config.resourcesHeaderLabel, 'tableHeader'), + pdfCell(config.requiresApprovalLabel, 'tableHeaderCenter'), + pdfCell(config.requiresCheckInNotificationLabel, 'tableHeaderCenter'), + pdfCell(config.releasedInLabel, 'tableHeaderCenter'), + ], + ]; + + config.resources.forEach(function (resource) { + resourcesBody.push([ + pdfCell(resource.name, 'valueCell'), + pdfCell(resource.requiresApproval ? 'X' : '', 'centerCell'), + pdfCell(resource.requiresCheckIn ? 'X' : '', 'centerCell'), + pdfCell(resource.releasedIn, 'centerCell'), + ]); + }); + + content.push(pdfTable(resourcesBody, ['*', 72, 88, 72], 1)); + + if (config.showAccessories && config.accessories.length > 0) { + const accessoriesBody = [ + [pdfCell(config.accessoriesHeaderLabel, 'tableHeader'), pdfCell(config.quantityLabel, 'tableHeaderCenter')], + ]; + + config.accessories.forEach(function (accessory) { + accessoriesBody.push([pdfCell(accessory.name, 'valueCell'), pdfCell(accessory.quantity, 'centerCell')]); + }); + + content.push(pdfTable(accessoriesBody, ['*', 75], 1)); + } + + if (config.showParticipants && config.participants.length > 0) { + const participantsBody = [ + [pdfCell(config.participantsHeaderLabel, 'tableHeader'), pdfCell(config.emailLabel, 'tableHeader')], + ]; + + config.participants.forEach(function (participant) { + participantsBody.push([pdfCell(participant.fullName, 'valueCell'), pdfCell(participant.email, 'valueCell')]); + }); + + content.push(pdfTable(participantsBody, ['*', '*'], 1)); + } + + if (config.showInvitees && config.invitees.length > 0) { + const inviteesBody = [ + [pdfCell(config.invitationListHeaderLabel, 'tableHeader'), pdfCell(config.emailLabel, 'tableHeader')], + ]; + + config.invitees.forEach(function (invitee) { + inviteesBody.push([pdfCell(invitee.fullName, 'valueCell'), pdfCell(invitee.email, 'valueCell')]); + }); + + content.push(pdfTable(inviteesBody, ['*', '*'], 1)); + } + + content.push( + pdfTable( + [ + [pdfCell(config.reservationTitleLabel, 'tableHeader')], + [pdfCell(config.reservationTitle, 'valueCell')], + [pdfCell(config.reservationDescriptionLabel, 'tableHeader')], + [pdfCell(config.reservationDescription, 'valueCell')], + ], + ['*'] + ) + ); + + const attributesTable = buildAttributes(config, attributesData); + if (attributesTable) { + content.push(attributesTable); + } + + if (config.remindersEnabled && config.reminders.length > 0) { + config.reminders.forEach(function (reminder) { + reminders.push([reminder.time, reminder.interval, reminder.text].join(' ')); + }); + + content.push( + pdfTable( + [[pdfCell(config.sendReminderLabel, 'labelCell'), pdfCell(reminders.join('\n'), 'valueCell')]], + [150, '*'] + ) + ); + } + + if (config.attachments.length > 0) { + const attachmentsBody = [ + [pdfCell(config.attachmentsLabel + ' (' + config.attachments.length + ')', 'tableHeader')], + ]; + + config.attachments.forEach(function (attachmentName) { + attachmentsBody.push([pdfCell(attachmentName, 'valueCell')]); + }); + + content.push(pdfTable(attachmentsBody, ['*'])); + } + + if (config.showTermsAcceptance) { + content.push(pdfTable([[pdfCell(config.acceptTermsLabel, 'labelCell'), pdfCell('X', 'centerCell')]], ['*', 40])); + } + + return { + pageSize: 'A4', + pageMargins: [24, 24, 24, 24], + content: content, + defaultStyle: { + fontSize: 9, + lineHeight: 1.15, + }, + styles: { + documentTitle: { + fontSize: 14, + bold: true, + color: '#1f2a3d', + }, + documentSubtitle: { + fontSize: 9, + color: '#51627a', + margin: [0, 2, 0, 0], + }, + sectionHeading: { + fontSize: 10, + bold: true, + color: '#2e3b4e', + margin: [0, 2, 0, 6], + }, + labelCell: { + bold: true, + fillColor: '#f1f4f8', + color: '#2e3b4e', + }, + valueCell: { + color: '#1f2a3d', + }, + tableHeader: { + bold: true, + fillColor: '#dfe6ef', + color: '#1f2a3d', + }, + tableHeaderCenter: { + bold: true, + alignment: 'center', + fillColor: '#dfe6ef', + color: '#1f2a3d', + fontSize: 8, + }, + centerCell: { + alignment: 'center', + color: '#1f2a3d', + }, + }, + info: { + title: 'Reservation ' + config.referenceNumber, + author: config.appTitle, + subject: config.reservationDetailsTitle, + creator: 'pdfmake', + }, + }; + } + + function init() { + const config = getConfig(); + const pdfMakeInstance = window.pdfMake; + + if (!config || !pdfMakeInstance) { + return; + } + + document.querySelectorAll('.btnPDF').forEach(function (btn) { + btn.addEventListener('click', async function (e) { + e.preventDefault(); + const previewWindow = window.open('', '_blank'); + const attributesData = await loadCustomAttributesData(); + const logoDataUrl = await loadImageDataUrl(config.logoUrl); + const documentDefinition = buildReservationPdfDefinition(config, logoDataUrl, attributesData); + + if (previewWindow) { + pdfMakeInstance.createPdf(documentDefinition).open({}, previewWindow); + return; + } + + pdfMakeInstance.createPdf(documentDefinition).open(); + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/tests/Pages/ReservationPageTest.php b/tests/Pages/ReservationPageTest.php new file mode 100644 index 000000000..8a0adf740 --- /dev/null +++ b/tests/Pages/ReservationPageTest.php @@ -0,0 +1,268 @@ +Set('AppTitle', 'Libre & "PDF"'); + $page->Set('ReferenceNumber', 'rn-1'); + // Accented strings exercise Unicode behavior for HTML decoding and JSON encoding. + $page->Set('ReservationUserName', 'Renée O'Connor'); + $page->Set('ReservationTitle', 'Title </script> x' . "\u{2028}" . 'y' . "\u{2029}" . 'z'); + $page->Set('Description', 'Description & details'); + $page->Set('Resource', new ReservationPagePdfConfigResource()); + $page->Set('Terms', new stdClass()); + $page->Set('TermsAccepted', true); + $page->Set('RepeatOptions', [ + RepeatType::None => ['key' => 'DoesNotRepeat', 'everyKey' => ''], + ]); + $page->Set('RepeatType', RepeatType::None); + $page->Set('StartDate', Date::Create(2026, 4, 8, 14, 30, 0, 'America/Chicago')); + $page->Set('EndDate', Date::Create(2026, 4, 8, 15, 30, 0, 'America/Chicago')); + + $json = $page->BuildReservationPdfConfigJson(); + $config = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $this->assertStringNotContainsString('', $json); + $this->assertStringContainsString('\u003C/script\u003E', $json); + $this->assertStringContainsString('\u2028', $json); + $this->assertStringContainsString('\u2029', $json); + $this->assertStringNotContainsString("\u{2028}", $json); + $this->assertStringNotContainsString("\u{2029}", $json); + + $this->assertSame('Renée O\'Connor', $config['reservationUserName']); + $this->assertSame('Title x' . "\u{2028}" . 'y' . "\u{2029}" . 'z', $config['reservationTitle']); + $this->assertSame('Description & details', $config['reservationDescription']); + $this->assertTrue($config['showTermsAcceptance']); + $this->assertSame(CustomAttributeTypes::CHECKBOX, $config['customAttributeTypeCheckbox']); + } + + public function testReservationPdfConfigKeepsSmartyFormattingBehavior(): void + { + $page = new TestableReservationPage(); + $page->Set('Resource', new ReservationPagePdfConfigResource()); + $page->Set('RepeatOptions', [ + RepeatType::Weekly => ['key' => 'Weekly', 'everyKey' => 'weeks'], + ]); + $page->Set('RepeatType', RepeatType::Weekly); + $page->Set('IsRecurring', true); + $page->Set('RepeatWeekdays', [3]); + $page->Set('StartDate', Date::Create(2026, 4, 8, 14, 30, 0, 'America/Chicago')); + $page->Set('EndDate', Date::Create(2026, 4, 8, 15, 30, 0, 'America/Chicago')); + $page->Set('RepeatTerminationDate', Date::Create(2026, 4, 29, 15, 30, 0, 'America/Chicago')); + + $config = json_decode($page->BuildReservationPdfConfigJson(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame('Réservation Spéciale', $config['reservationDetailsTitle']); + $this->assertSame('Días Repetidos', $config['daysLabel']); + $this->assertSame('Miércoles 2026-04-08 14:30', $config['beginDateValue']); + $this->assertSame(['Miércoles'], $config['repeatWeekdays']); + $this->assertSame('semanal', $config['repeatTypeLabel']); + $this->assertSame('semanas', $config['repeatEveryLabel']); + } + + public function testReservationPdfConfigIncludesExpectedResourceData(): void + { + $page = new TestableReservationPage(); + $page->Set('Resource', new ReservationPagePdfConfigResource('Main Room', true, true, true, 15)); + $page->Set('AdditionalResourceIds', [2]); + $page->Set('AvailableResources', [ + new ReservationPagePdfConfigResource('Ignored Room', false, false, false, null, 1), + new ReservationPagePdfConfigResource('Overflow Room', false, true, false, null, 2), + ]); + $page->Set('RepeatOptions', [ + RepeatType::None => ['key' => 'DoesNotRepeat', 'everyKey' => ''], + ]); + $page->Set('RepeatType', RepeatType::None); + + $config = json_decode($page->BuildReservationPdfConfigJson(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertCount(2, $config['resources']); + $this->assertSame([ + 'name' => 'Main Room', + 'requiresApproval' => true, + 'requiresCheckIn' => true, + 'releasedIn' => '15', + ], $config['resources'][0]); + $this->assertSame([ + 'name' => 'Overflow Room', + 'requiresApproval' => false, + 'requiresCheckIn' => true, + 'releasedIn' => ' - ', + ], $config['resources'][1]); + } +} + +class TestableReservationPage extends ReservationPage +{ + public function BuildReservationPdfConfigJson(): string + { + $method = new ReflectionMethod(ReservationPage::class, 'SetReservationPdfConfig'); + $method->invoke($this); + + return (string)$this->smarty->getTemplateVars('ReservationPdfConfigJson'); + } + + protected function GetPresenter() + { + return new class () implements IReservationPresenter { + public function PageLoad() + { + } + }; + } + + protected function GetTemplateName() + { + return 'Reservation/pdf.tpl'; + } + + protected function GetReservationAction() + { + return ''; + } + + public function SetTermsAccepted($accepted) + { + $this->Set('TermsAccepted', $accepted); + } +} + +class ReservationPagePdfConfigResources extends FakeResources +{ + // Accented strings exercise Unicode behavior for Smarty capitalization and localized day names. + /** + * @var array + */ + private array $strings = [ + 'DoesNotRepeat' => 'no se repite', + 'ReservationDetails' => 'réservation spéciale', + 'RepeatDaysPrompt' => 'días repetidos', + 'Weekly' => 'semanal', + 'weeks' => 'semanas', + ]; + + public function GetString($key, $args = []): string + { + return $this->strings[$key] ?? parent::GetString($key, $args); + } + + public function GetDateFormat($key) + { + if ($key === 'dashboard') { + return 'l Y-m-d H:i'; + } + + if ($key === 'schedule_daily') { + return 'l Y-m-d'; + } + + return parent::GetDateFormat($key); + } + + public function GetDays($key) + { + return ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; + } +} + +class ReservationPagePdfConfigResource implements IBookableResource +{ + public function __construct( + private string $name = 'Conference Room', + private bool $requiresApproval = false, + private bool $checkInEnabled = false, + private bool $autoReleased = false, + private ?int $autoReleaseMinutes = null, + public int $Id = 1 + ) { + } + + public function GetId() + { + return $this->Id; + } + + public function GetName() + { + return $this->name; + } + + public function GetResourceId() + { + return $this->Id; + } + + public function GetAdminGroupId() + { + return 0; + } + + public function GetScheduleId() + { + return 0; + } + + public function GetScheduleAdminGroupId() + { + return 0; + } + + public function GetStatusId() + { + return 0; + } + + public function GetMinimumLength() + { + return TimeInterval::None(); + } + + public function GetRequiresApproval() + { + return $this->requiresApproval; + } + + public function IsCheckInEnabled() + { + return $this->checkInEnabled; + } + + public function IsAutoReleased() + { + return $this->autoReleased; + } + + public function GetAutoReleaseMinutes() + { + return $this->autoReleaseMinutes; + } + + public function GetResourceTypeId() + { + return 0; + } + + public function GetColor() + { + return null; + } + + public function GetTextColor() + { + return null; + } +} diff --git a/tpl/Ajax/reservation/reservation_attributes_print.tpl b/tpl/Ajax/reservation/reservation_attributes_print.tpl index 71bec15c6..90aedf6aa 100644 --- a/tpl/Ajax/reservation/reservation_attributes_print.tpl +++ b/tpl/Ajax/reservation/reservation_attributes_print.tpl @@ -1,14 +1,14 @@ {ldelim} {if $Attributes|default:array()|count > 0} {foreach from=$Attributes item=attribute name=attributes} - "{$attribute->Id()}" : - [ "{$attribute->Type()}" , - "{$attribute->Label()|default:''|escape:'json'}" , - {if $attribute->Type() eq '5'} - "{if $attribute->Value() neq null}{formatdate date=$attribute->Value() key=embedded_datetime}{/if}" ] {if $smarty.foreach.attributes.last}{else},{/if} - {else} - "{$attribute->Value()|default:''|escape:'json'}" ] {if $smarty.foreach.attributes.last}{else},{/if} + {assign var='attributeValue' value=$attribute->Value()|default:''} + {if $attribute->Type() eq $CustomAttributeTypeDateTime && $attribute->Value() neq null} + {capture assign='attributeValue'}{formatdate date=$attribute->Value() key=general_datetime}{/capture} {/if} + {$attribute->Id()|cat:''|json_encode} : + [ {$attribute->Type()|json_encode} , + {$attribute->Label()|default:''|json_encode} , + {$attributeValue|json_encode} ] {if $smarty.foreach.attributes.last}{else},{/if} {/foreach} {/if} {rdelim} diff --git a/tpl/Reservation/create.tpl b/tpl/Reservation/create.tpl index 0217199d2..d46acd006 100644 --- a/tpl/Reservation/create.tpl +++ b/tpl/Reservation/create.tpl @@ -538,9 +538,11 @@ {jsfile src="force-numeric.js"} {jsfile src="reservation-reminder.js"} {jsfile src="ajax-helpers.js"} +{jsfile src="reservation-pdf.js"} {vendor_js src="jqtree/1.8.11/js/tree.jquery.js"} {include file="Reservation/pdf_libraries.tpl"} +{include file="Reservation/pdf.tpl"} diff --git a/tpl/Reservation/pdf_libraries.tpl b/tpl/Reservation/pdf_libraries.tpl index 00e343a86..4e3800511 100644 --- a/tpl/Reservation/pdf_libraries.tpl +++ b/tpl/Reservation/pdf_libraries.tpl @@ -1,2 +1,2 @@ -{vendor_js src="jspdf/2.3.0/js/jspdf.umd.min.js"} -{vendor_js src="jspdf-autotable/3.5.14/js/jspdf.plugin.autotable.min.js"} \ No newline at end of file +{vendor_js src="pdfmake/0.1.53/js/pdfmake.min.js"} +{vendor_js src="pdfmake/0.1.53/js/vfs_fonts.js"} diff --git a/tpl/Reservation/view.tpl b/tpl/Reservation/view.tpl index d7237d883..6a0ef328c 100644 --- a/tpl/Reservation/view.tpl +++ b/tpl/Reservation/view.tpl @@ -377,9 +377,11 @@ {jsfile src="force-numeric.js"} {jsfile src="reservation-reminder.js"} {jsfile src="ajax-helpers.js"} +{jsfile src="reservation-pdf.js"} {vendor_js src="jqtree/1.8.11/js/tree.jquery.js"} {include file="Reservation/pdf_libraries.tpl"} +{include file="Reservation/pdf.tpl"} {include file='globalfooter.tpl'}