diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index bd39ed5ce2..4b731ea726 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -58,6 +58,8 @@ import { UserRoute, ResubmitSurveyResponseRequest, ResubmitSurveyResponseRoute, + ExportSurveyResponseRequest, + ExportSurveyResponseRoute, } from '../routes'; import { attachAccessPolicy } from './middleware'; import { API_CLIENT_PERMISSIONS } from '../constants'; @@ -110,6 +112,10 @@ export async function createApp() { handleWith(ResubmitSurveyResponseRoute), ) .post('generateLoginToken', handleWith(GenerateLoginTokenRoute)) + .post( + 'export/:surveyResponseId', + handleWith(ExportSurveyResponseRoute), + ) // Forward auth requests to web-config .use('signup', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) .use('resendEmail', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) diff --git a/packages/datatrak-web-server/src/routes/ExportSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/ExportSurveyResponseRoute.ts new file mode 100644 index 0000000000..33791a0373 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/ExportSurveyResponseRoute.ts @@ -0,0 +1,49 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + * + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { downloadPageAsPDF } from '@tupaia/server-utils'; + +export type ExportSurveyResponseRequest = Request< + { + surveyResponseId: string; + }, + { + contents: Buffer; + type: string; + }, + { + baseUrl: string; + cookieDomain: string; + locale: string; + timezone: string; + }, + Record +>; + +export class ExportSurveyResponseRoute extends Route { + protected type = 'download' as const; + + public async buildResponse() { + const { surveyResponseId } = this.req.params; + const { baseUrl, cookieDomain, locale, timezone } = this.req.body; + const { cookie } = this.req.headers; + + if (!cookie) { + throw new Error(`Must have a valid session to export a dashboard`); + } + + const pdfPageUrl = `${baseUrl}/export/${surveyResponseId}?locale=${locale}`; + + const buffer = await downloadPageAsPDF(pdfPageUrl, cookie, cookieDomain, false, true, timezone); + + return { + contents: buffer, + type: 'application/pdf', + }; + } +} diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index 873ec90f1c..22aff75c12 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -20,6 +20,7 @@ const DEFAULT_FIELDS = [ 'assessor_name', 'country.name', 'data_time', + 'end_time', 'entity.name', 'entity.id', 'id', diff --git a/packages/datatrak-web-server/src/routes/SurveyResponsesRoute.ts b/packages/datatrak-web-server/src/routes/SurveyResponsesRoute.ts index e9f38c5a3b..bce66354f2 100644 --- a/packages/datatrak-web-server/src/routes/SurveyResponsesRoute.ts +++ b/packages/datatrak-web-server/src/routes/SurveyResponsesRoute.ts @@ -1,13 +1,7 @@ import { Request } from 'express'; import camelcaseKeys from 'camelcase-keys'; import { Route } from '@tupaia/server-boilerplate'; -import { - DatatrakWebSurveyResponsesRequest, - SurveyResponse, - Country, - Entity, - Survey, -} from '@tupaia/types'; +import { DatatrakWebSurveyResponsesRequest } from '@tupaia/types'; export type SurveyResponsesRequest = Request< DatatrakWebSurveyResponsesRequest.Params, @@ -16,16 +10,6 @@ export type SurveyResponsesRequest = Request< DatatrakWebSurveyResponsesRequest.ReqQuery >; -type SurveyResponseT = Record & { - assessor_name: SurveyResponse['assessor_name']; - 'country.name': Country['name']; - data_time: Date; - 'entity.name': Entity['name']; - id: SurveyResponse['id']; - 'survey.name': Survey['name']; - 'survey.project_id': Survey['project_id']; -}; - const DEFAULT_FIELDS = [ 'assessor_name', 'country.name', diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index f931a0c0cd..6b0296f97d 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -32,3 +32,7 @@ export { PermissionGroupUsersRequest, PermissionGroupUsersRoute, } from './PermissionGroupUsersRoute'; +export { + ExportSurveyResponseRequest, + ExportSurveyResponseRoute, +} from './ExportSurveyResponseRoute'; diff --git a/packages/datatrak-web/public/tupaia-logo-dark.svg b/packages/datatrak-web/public/tupaia-logo-dark.svg new file mode 100644 index 0000000000..d4e455fea2 --- /dev/null +++ b/packages/datatrak-web/public/tupaia-logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/datatrak-web/src/api/mutations/index.ts b/packages/datatrak-web/src/api/mutations/index.ts index 9cb8d90633..f3a2e4da44 100644 --- a/packages/datatrak-web/src/api/mutations/index.ts +++ b/packages/datatrak-web/src/api/mutations/index.ts @@ -15,3 +15,4 @@ export * from './useExportSurveyResponses'; export { useTupaiaRedirect } from './useTupaiaRedirect'; export { useCreateTask } from './useCreateTask'; export { useCreateTaskComment } from './useCreateTaskComment'; +export { useExportSurveyResponse } from './useExportSurveyResponse'; diff --git a/packages/datatrak-web/src/api/mutations/useExportSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useExportSurveyResponse.ts new file mode 100644 index 0000000000..debb161c5b --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useExportSurveyResponse.ts @@ -0,0 +1,36 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { useMutation } from '@tanstack/react-query'; +import download from 'downloadjs'; +import { API_URL, post } from '../api'; +import { successToast } from '../../utils'; + +// Requests a survey response PDF export from the server, and returns the response +export const useExportSurveyResponse = (surveyResponseId: string, timezone?: string | null) => { + return useMutation( + () => { + const baseUrl = `${window.location.protocol}//${window.location.host}`; + + // Auth cookies are saved against this domain. Pass this to server, so that when it pretends to be us, it can do the same. + const cookieDomain = new URL(API_URL).hostname; + + return post(`export/${surveyResponseId}`, { + responseType: 'blob', + data: { + cookieDomain, + baseUrl, + locale: window.navigator.language, + timezone: timezone, + }, + }); + }, + { + onSuccess: data => { + download(data, `survey_response_${surveyResponseId}.pdf`); + successToast('Survey response downloaded'); + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/constants/url.ts b/packages/datatrak-web/src/constants/url.ts index a416b0ef58..681806d991 100644 --- a/packages/datatrak-web/src/constants/url.ts +++ b/packages/datatrak-web/src/constants/url.ts @@ -28,6 +28,7 @@ export const ROUTES = { TASKS: '/tasks', TASK_DETAILS: '/tasks/:taskId', NOT_AUTHORISED: '/not-authorised', + EXPORT_SURVEY_RESPONSE: 'export/:surveyResponseId', }; export const PASSWORD_RESET_TOKEN_PARAM = 'passwordResetToken'; diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx index 0bd58dd843..ad4c198902 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx @@ -3,6 +3,7 @@ import React from 'react'; import styled from 'styled-components'; import { useFormContext, Controller } from 'react-hook-form'; import { FormHelperText } from '@material-ui/core'; +import { stripTimezoneFromDate } from '@tupaia/utils'; import { BinaryQuestion, DateQuestion, @@ -88,7 +89,9 @@ export const SurveyQuestion = ({ const getDefaultValue = () => { if (formData[name] !== undefined) return formData[name]; // This is so that the default value gets carried through to the component, and dates that have a visible value of 'today' have that value recognised when validating - if (type?.includes('Date')) return isResubmit ? null : new Date(); + if (type?.includes('Date')) { + return isResubmit ? null : stripTimezoneFromDate(new Date()); + } return undefined; }; diff --git a/packages/datatrak-web/src/features/SurveyResponseModal.tsx b/packages/datatrak-web/src/features/SurveyResponseModal.tsx index f913125b11..602922828d 100644 --- a/packages/datatrak-web/src/features/SurveyResponseModal.tsx +++ b/packages/datatrak-web/src/features/SurveyResponseModal.tsx @@ -3,29 +3,21 @@ import { useForm, FormProvider } from 'react-hook-form'; import { useSearchParams } from 'react-router-dom'; import styled from 'styled-components'; import { Dialog, Typography } from '@material-ui/core'; -import { - ModalContentProvider, - ModalFooter, - ModalHeader, - SpinningLoader, -} from '@tupaia/ui-components'; +import { ModalContentProvider, ModalFooter, SpinningLoader } from '@tupaia/ui-components'; import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; import { useSurveyResponse } from '../api/queries'; -import { Button, SurveyTickIcon } from '../components'; +import { Button, DownloadIcon, SurveyTickIcon } from '../components'; import { displayDate } from '../utils'; import { SurveyReviewSection, useSurveyResponseWithForm } from './Survey'; import { SurveyContext } from '.'; +import { useExportSurveyResponse } from '../api'; const Header = styled.div` display: flex; align-items: center; - padding: 0.5rem; + justify-content: space-between; + padding: 1.5rem 1.8rem 1.2rem; width: 100%; - - .MuiSvgIcon-root { - font-size: 2.5em; - margin-right: 0.35em; - } `; const Heading = styled(Typography).attrs({ @@ -50,15 +42,32 @@ const SubHeading = styled(Typography)` `; const Loader = styled(SpinningLoader)` - width: 25rem; + padding-block: 3rem; max-width: 100%; `; const Content = styled.div` + min-height: 10rem; width: 62rem; max-width: 100%; `; +const Icon = styled(SurveyTickIcon)` + font-size: 2.5rem; + margin-right: 0.35rem; +`; + +const DownloadButton = styled(Button).attrs({ + variant: 'outlined', +})` + margin-left: auto; + &.Mui-disabled.MuiButtonBase-root { + opacity: 0.5; + color: ${({ theme }) => theme.palette.primary.main}; + border-color: ${({ theme }) => theme.palette.primary.main}; + } +`; + const getSubHeadingText = surveyResponse => { if (!surveyResponse) { return null; @@ -85,20 +94,32 @@ const SurveyResponseModalContent = ({ const { surveyLoading } = useSurveyResponseWithForm(surveyResponse); const subHeading = getSubHeadingText(surveyResponse); const showLoading = isLoading || surveyLoading; + const [urlSearchParams] = useSearchParams(); + const surveyResponseId = urlSearchParams.get('responseId'); + const { mutate: downloadSurveyResponse, isLoading: isDownloadingSurveyResponse } = + useExportSurveyResponse(surveyResponseId!, surveyResponse?.timezone); return ( <> - +
{!showLoading && !error && ( -
- + <> +
{surveyResponse?.surveyName} {subHeading}
-
+ } + > + Download + + )} - +
{showLoading && } diff --git a/packages/datatrak-web/src/routes/Routes.tsx b/packages/datatrak-web/src/routes/Routes.tsx index 6bfd348f95..d196a561e3 100644 --- a/packages/datatrak-web/src/routes/Routes.tsx +++ b/packages/datatrak-web/src/routes/Routes.tsx @@ -17,6 +17,7 @@ import { TasksDashboardPage, TaskDetailsPage, NotAuthorisedPage, + ExportSurveyResponsePage, } from '../views'; import { useCurrentUserContext } from '../api'; import { ROUTES } from '../constants'; @@ -53,6 +54,7 @@ const AuthViewLoggedInRedirect = ({ children }) => { export const Routes = () => { return ( + } /> }> {/* PRIVATE ROUTES */} }> diff --git a/packages/datatrak-web/src/utils/date.ts b/packages/datatrak-web/src/utils/date.ts index 1d295e0b4d..deaadc1aee 100644 --- a/packages/datatrak-web/src/utils/date.ts +++ b/packages/datatrak-web/src/utils/date.ts @@ -1,18 +1,22 @@ -import { format } from 'date-fns'; - -export const displayDate = (date?: Date | null) => { +export const displayDate = (date?: Date | string | null, localeCode?: string) => { if (!date) { return ''; } - return new Date(date).toLocaleDateString(); + return new Date(date).toLocaleDateString(localeCode); }; -export const displayDateTime = (date?: Date | null) => { +export const displayDateTime = (date?: Date | string | null, locale?: string) => { if (!date) { return ''; } - const dateDisplay = displayDate(date); - const timeDisplay = format(new Date(date), 'p'); - return `${dateDisplay} ${timeDisplay}`; + return new Date(date) + .toLocaleString(locale, { + hour: '2-digit', + minute: '2-digit', + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) + .replace(',', ' '); }; diff --git a/packages/datatrak-web/src/views/ExportSurveyResponsePage/ExportSurveyResponsePage.tsx b/packages/datatrak-web/src/views/ExportSurveyResponsePage/ExportSurveyResponsePage.tsx new file mode 100644 index 0000000000..4a45bd6b14 --- /dev/null +++ b/packages/datatrak-web/src/views/ExportSurveyResponsePage/ExportSurveyResponsePage.tsx @@ -0,0 +1,119 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { useParams } from 'react-router'; +import styled from 'styled-components'; +import { useSurvey, useSurveyResponse } from '../../api'; +import { A4Page } from '@tupaia/ui-components'; +import { Typography } from '@material-ui/core'; +import { Question } from './Question'; +import { getIsQuestionVisible } from '../../features/Survey/SurveyContext/utils'; +import { useSearchParams } from 'react-router-dom'; +import { displayDate } from '../../utils'; + +const DARK_GREY = '#444'; + +const Page = styled(A4Page)` + background-color: white; + padding-block-start: 0; + padding-block-end: 1cm; + padding-inline: 1.55cm; + width: 21cm; // A4 width in cm +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding-block-end: 0.75rem; + margin-bottom: 1rem; + border-bottom: 1px solid ${DARK_GREY}; +`; + +const ScreenWrapper = styled.div` + padding-block-start: 0.5rem; + padding-block-end: 0.5rem; +`; + +const SurveyResponseDetailsWrapper = styled.div` + > * { + font-size: 0.75rem; + text-align: right; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + } +`; +const SurveyTitle = styled(Typography)` + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + line-height: 1.8; + color: ${DARK_GREY}; +`; + +const SurveyResponseDetails = styled(Typography)` + line-height: 1.5; + color: ${DARK_GREY}; +`; + +const ProjectLogo = styled.img` + max-height: 4rem; + width: auto; + max-width: 5rem; +`; + +export const ExportSurveyResponsePage = () => { + const { surveyResponseId } = useParams(); + const [urlSearchParams] = useSearchParams(); + const { data: surveyResponse, isLoading: isLoadingSurveyResponse } = + useSurveyResponse(surveyResponseId); + const locale = urlSearchParams.get('locale') || 'en-AU'; + const { data: survey, isLoading: isLoadingSurvey } = useSurvey(surveyResponse?.surveyCode); + + const isLoading = isLoadingSurveyResponse || isLoadingSurvey; + + if (isLoading || !surveyResponse) return null; + + const { answers, endTime, entityParentName, entityName, assessorName } = surveyResponse; + + const visibleScreens = + survey?.screens + ?.map(screen => + screen.surveyScreenComponents.filter(question => getIsQuestionVisible(question, answers)), + ) + ?.filter(screenComponents => screenComponents.length > 0) ?? []; + + // Format the date and time in the timezone provided in the URL because the server is in UTC + const formattedDataTime = displayDate(endTime as Date, locale); + + return ( + +
+ + + + {survey?.project?.name} | {survey?.name} + + + {entityName} {entityParentName && `| ${entityParentName}`} {formattedDataTime} + + Submitted by: {assessorName} + +
+ {visibleScreens.map((screenComponents, index) => ( + + {screenComponents.map((surveyScreenComponent, index) => ( + + ))} + + ))} +
+ ); +}; diff --git a/packages/datatrak-web/src/views/ExportSurveyResponsePage/Question.tsx b/packages/datatrak-web/src/views/ExportSurveyResponsePage/Question.tsx new file mode 100644 index 0000000000..57fbb08cc3 --- /dev/null +++ b/packages/datatrak-web/src/views/ExportSurveyResponsePage/Question.tsx @@ -0,0 +1,141 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import styled from 'styled-components'; +import { useSearchParams } from 'react-router-dom'; +import { DatatrakWebSingleSurveyResponseRequest, QuestionType } from '@tupaia/types'; +import { Typography, Avatar } from '@material-ui/core'; +import { useAutocompleteOptions, useEntityById } from '../../api'; +import { displayDate, displayDateTime } from '../../utils'; +import { SurveyScreenComponent } from '../../types'; + +type SurveyResponse = DatatrakWebSingleSurveyResponseRequest.ResBody; + +const QuestionWrapper = styled.div<{ $border?: boolean }>` + ${({ $border = true }) => $border && 'border-bottom: 1px solid #ccc;'} + page-break-inside: avoid; + max-width: 500px; + + & + & { + margin-block-start: 1.125rem; + } +`; + +const InstructionQuestionText = styled(Typography)` + max-width: 500px; + font-size: 0.875rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + line-height: 1.5; +`; + +const QuestionLabel = styled(Typography)` + font-size: 0.75rem; + line-height: 1.5; +`; + +const SmallText = styled(Typography)` + font-size: 0.625rem; +`; + +const Answer = styled(SmallText)` + margin-block: 0.75rem 0.3rem; +`; + +const StyledAvatar = styled(Avatar)` + width: 8rem; + height: 8rem; +`; + +const useDisplayAnswer = ( + surveyScreenComponent: SurveyScreenComponent, + surveyResponse: SurveyResponse, +) => { + const { id, type, options, optionSetId } = surveyScreenComponent; + // Extract answer + const answer = surveyResponse.answers[id!] as any; + + const [urlSearchParams] = useSearchParams(); + const locale = urlSearchParams.get('locale') || 'en-AU'; + const { data: entity } = useEntityById(answer, { + enabled: type === QuestionType.Entity, + }); + const { data: optionSet } = useAutocompleteOptions(optionSetId); + + if (type === QuestionType.Instruction) return null; + if (type === QuestionType.DateOfData || type === QuestionType.SubmissionDate) { + return displayDate(surveyResponse.dataTime, locale); + } + if (type === QuestionType.PrimaryEntity) { + return surveyResponse?.entityName; + } + + if (!answer) return 'No answer'; + + // If there are defined options, display the selected option label if set. Usually this is the same as the saved value but not always + if (options?.length && options?.length > 0) { + const selectedOption = options?.find(option => option.value === answer); + return selectedOption?.label ?? answer; + } + if (optionSetId) { + const selectedOption = optionSet?.find(option => option.value === answer); + return selectedOption?.label ?? answer; + } + + switch (type) { + // If the question is an entity question, display the entity name + case QuestionType.Entity: + return entity?.name; + // If the question is a date question, display the date in a readable format + case QuestionType.Date: + return displayDate(answer, locale); + case QuestionType.DateTime: + return displayDateTime(answer, locale); + // If the question is a geolocate question, display the latitude and longitude + case QuestionType.Geolocate: { + const { latitude, longitude } = JSON.parse(answer); + return `${latitude}, ${longitude} (latitude, longitude)`; + } + case QuestionType.File: { + // If the value is a file, split the value to get the file name + const withoutPrefix = answer.split('files/'); + const fileNameParts = withoutPrefix[withoutPrefix.length - 1].split('_'); + // remove first element of the array as it is the file id + return fileNameParts.slice(1).join('_'); + } + case QuestionType.Photo: { + return ; + } + default: + return answer; + } +}; + +export const Question = ({ + surveyScreenComponent, + surveyResponse, +}: { + surveyScreenComponent: SurveyScreenComponent; + surveyResponse: SurveyResponse; +}) => { + const { type, text, detailLabel } = surveyScreenComponent; + const displayAnswer = useDisplayAnswer(surveyScreenComponent, surveyResponse); + + if (type === QuestionType.Instruction) { + return ( + + {text} + {detailLabel && {detailLabel}} + + ); + } + + return ( + + {text} + {detailLabel && {detailLabel}} + {displayAnswer && {displayAnswer}} + + ); +}; diff --git a/packages/datatrak-web/src/views/ExportSurveyResponsePage/index.ts b/packages/datatrak-web/src/views/ExportSurveyResponsePage/index.ts new file mode 100644 index 0000000000..4a59905ba0 --- /dev/null +++ b/packages/datatrak-web/src/views/ExportSurveyResponsePage/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { ExportSurveyResponsePage } from './ExportSurveyResponsePage'; diff --git a/packages/datatrak-web/src/views/index.ts b/packages/datatrak-web/src/views/index.ts index a2626ee4ea..17b206a976 100644 --- a/packages/datatrak-web/src/views/index.ts +++ b/packages/datatrak-web/src/views/index.ts @@ -20,3 +20,4 @@ export { AccountSettingsPage } from './AccountSettingsPage'; export { ReportsPage } from './ReportsPage'; export { TasksDashboardPage, TaskDetailsPage } from './Tasks'; export { NotAuthorisedPage } from './NotAuthorisedPage'; +export { ExportSurveyResponsePage } from './ExportSurveyResponsePage'; diff --git a/packages/server-utils/src/downloadPageAsPDF.ts b/packages/server-utils/src/downloadPageAsPDF.ts index 7d3a2e8768..680e7d4a03 100644 --- a/packages/server-utils/src/downloadPageAsPDF.ts +++ b/packages/server-utils/src/downloadPageAsPDF.ts @@ -32,7 +32,26 @@ const buildParams = (pdfPageUrl: string, userCookie: string, cookieDomain: strin return { verifiedPDFPageUrl, cookies: finalisedCookieObjects }; }; -const pageNumberHTML = `
`; +const pageNumberHTML = ` +
+ + of + +
+ + +`; /** * @param pdfPageUrl the url to visit and download as a pdf @@ -46,6 +65,7 @@ export const downloadPageAsPDF = async ( cookieDomain: string | undefined, landscape = false, includePageNumber = false, + timezone?: string, ) => { let browser; let buffer; @@ -54,8 +74,14 @@ export const downloadPageAsPDF = async ( try { browser = await puppeteer.launch(); const page = await browser.newPage(); + + if (timezone) { + await page.emulateTimezone(timezone); + } + await page.setCookie(...cookies); await page.goto(verifiedPDFPageUrl, { timeout: 60000, waitUntil: 'networkidle0' }); + buffer = await page.pdf({ format: 'a4', printBackground: true, @@ -65,7 +91,7 @@ export const downloadPageAsPDF = async ( headerTemplate: `
`, footerTemplate: pageNumberHTML, //add a margin so the page number doesn't overlap with the content, and the top margin is set for overflow content - margin: includePageNumber ? { bottom: '10mm', top: '10mm' } : undefined, + margin: includePageNumber ? { bottom: '20mm', top: '10mm' } : undefined, }); } catch (e) { throw new Error(`puppeteer error: ${(e as Error).message}`);