diff --git a/express-api/.gitignore b/express-api/.gitignore index 9b1c8b133c..34bc4290cf 100644 --- a/express-api/.gitignore +++ b/express-api/.gitignore @@ -1 +1,2 @@ /dist +/uploads diff --git a/react-app/package.json b/react-app/package.json index 59269ffa66..d90b4d55f7 100644 --- a/react-app/package.json +++ b/react-app/package.json @@ -27,7 +27,6 @@ "@mui/x-date-pickers": "7.18.0", "@turf/turf": "7.1.0", "dayjs": "1.11.10", - "node-xlsx": "0.24.0", "react": "18.3.1", "react-dom": "18.3.1", "react-error-boundary": "4.0.12", @@ -39,6 +38,7 @@ "supercluster": "8.0.1", "typescript-eslint": "8.8.0", "use-supercluster": "1.2.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "3.23.8" }, "devDependencies": { diff --git a/react-app/src/components/adminAreas/AdministrativeAreasTable.tsx b/react-app/src/components/adminAreas/AdministrativeAreasTable.tsx index 85095f6355..f265bee5de 100644 --- a/react-app/src/components/adminAreas/AdministrativeAreasTable.tsx +++ b/react-app/src/components/adminAreas/AdministrativeAreasTable.tsx @@ -9,6 +9,7 @@ import { Box } from '@mui/material'; import { SnackBarContext } from '@/contexts/snackbarContext'; import { LookupContext } from '@/contexts/lookupContext'; import useHistoryAwareNavigate from '@/hooks/useHistoryAwareNavigate'; +import { makeDateOrUndefined } from '@/utilities/helperFunctions'; const AdministrativeAreasTable = () => { const api = usePimsApi(); @@ -110,7 +111,7 @@ const AdministrativeAreasTable = () => { 'RegionalDistricts', adminArea.RegionalDistrictId, )?.Name, - 'Created On': adminArea.CreatedOn, + 'Created On': makeDateOrUndefined(adminArea.CreatedOn), Disabled: adminArea.IsDisabled, }; }); diff --git a/react-app/src/components/agencies/AgencyTable.tsx b/react-app/src/components/agencies/AgencyTable.tsx index 47d276a8e2..d9442c903a 100644 --- a/react-app/src/components/agencies/AgencyTable.tsx +++ b/react-app/src/components/agencies/AgencyTable.tsx @@ -10,6 +10,7 @@ import { dateColumnType } from '../table/CustomColumns'; import { SnackBarContext } from '@/contexts/snackbarContext'; import useHistoryAwareNavigate from '@/hooks/useHistoryAwareNavigate'; import { LookupContext } from '@/contexts/lookupContext'; +import { makeDateOrUndefined } from '@/utilities/helperFunctions'; interface IAgencyTable { rowClickHandler: GridEventListener<'rowClick'>; @@ -142,8 +143,8 @@ const AgencyTable = (props: IAgencyTable) => { Disabled: agency.IsDisabled, Notifications: agency.SendEmail, SendTo: agency.Email, - Created: agency.CreatedOn, - Updated: agency.UpdatedOn, + Created: makeDateOrUndefined(agency.CreatedOn), + Updated: makeDateOrUndefined(agency.UpdatedOn), }; }); }; diff --git a/react-app/src/components/projects/ProjectsTable.tsx b/react-app/src/components/projects/ProjectsTable.tsx index b21f64006c..908caf740c 100644 --- a/react-app/src/components/projects/ProjectsTable.tsx +++ b/react-app/src/components/projects/ProjectsTable.tsx @@ -14,6 +14,7 @@ import { ProjectTask } from '@/constants/projectTasks'; import { NotificationType } from '@/constants/notificationTypes'; import { MonetaryType } from '@/constants/monetaryTypes'; import { LookupContext } from '@/contexts/lookupContext'; +import { makeDateOrUndefined } from '@/utilities/helperFunctions'; const ProjectsTable = () => { const [totalCount, setTotalCount] = useState(0); @@ -148,7 +149,7 @@ const ProjectsTable = () => { : lookup.getLookupValueById('Agencies', project.AgencyId)?.Name, Agency: lookup.getLookupValueById('Agencies', project.AgencyId)?.Name, 'Created By': `${project.CreatedBy?.FirstName} ${project.CreatedBy?.LastName}`, - 'Created On': project.CreatedOn, + 'Created On': makeDateOrUndefined(project.CreatedOn), 'Exemption Requested': project.Tasks?.find( (task) => task.TaskId === ProjectTask.EXEMPTION_REQUESTED, )?.IsCompleted, @@ -186,32 +187,48 @@ const ProjectsTable = () => { AgencyResponseNote: project.Notes?.find( (note) => note.NoteTypeId === NoteTypes.AGENCY_INTEREST, )?.Note, - 'Submitted On': project.SubmittedOn, - 'Approved On': project.ApprovedOn, - 'ERP Initial Notification Sent On': project.Notifications.find( - (n) => n.TemplateId === NotificationType.NEW_PROPERTIES_ON_ERP, - )?.SendOn, - 'ERP Thirty Day Notification Sent On': project.Notifications.find( - (n) => n.TemplateId === NotificationType.THIRTY_DAY_ERP_NOTIFICATION_PARENT_AGENCIES, - )?.SendOn, - 'ERP Sixty Day Notification Sent On': project.Notifications.find( - (n) => n.TemplateId === NotificationType.SIXTY_DAY_ERP_NOTIFICATION_PARENT_AGENCIES, - )?.SendOn, - 'ERP Ninety Day Notification Sent On': project.Notifications.find( - (n) => n.TemplateId === NotificationType.NINTY_DAY_ERP_NOTIFICATION_PARENT_AGENCIES, - )?.SendOn, - 'Transferred Within GRE On': project.Timestamps.find( - (timestamp) => timestamp.TimestampTypeId === TimestampType.TRANSFERRED_WITHIN_GRE_ON, - )?.Date, - 'ERP Clearance Notification Sent On': project.Timestamps.find( - (timestamp) => timestamp.TimestampTypeId === TimestampType.CLEARANCE_NOTIFICATION_SENT_ON, - )?.Date, - 'Disposed On': project.Timestamps.find( - (timestamp) => timestamp.TimestampTypeId === TimestampType.DISPOSED_ON, - )?.Date, - 'Marketed On': project.Timestamps.find( - (timestamp) => timestamp.TimestampTypeId === TimestampType.MARKETED_ON, - )?.Date, + 'Submitted On': makeDateOrUndefined(project.SubmittedOn), + 'Approved On': makeDateOrUndefined(project.ApprovedOn), + 'ERP Initial Notification Sent On': makeDateOrUndefined( + project.Notifications.find((n) => n.TemplateId === NotificationType.NEW_PROPERTIES_ON_ERP) + ?.SendOn, + ), + 'ERP Thirty Day Notification Sent On': makeDateOrUndefined( + project.Notifications.find( + (n) => n.TemplateId === NotificationType.THIRTY_DAY_ERP_NOTIFICATION_PARENT_AGENCIES, + )?.SendOn, + ), + 'ERP Sixty Day Notification Sent On': makeDateOrUndefined( + project.Notifications.find( + (n) => n.TemplateId === NotificationType.SIXTY_DAY_ERP_NOTIFICATION_PARENT_AGENCIES, + )?.SendOn, + ), + 'ERP Ninety Day Notification Sent On': makeDateOrUndefined( + project.Notifications.find( + (n) => n.TemplateId === NotificationType.NINTY_DAY_ERP_NOTIFICATION_PARENT_AGENCIES, + )?.SendOn, + ), + 'Transferred Within GRE On': makeDateOrUndefined( + project.Timestamps.find( + (timestamp) => timestamp.TimestampTypeId === TimestampType.TRANSFERRED_WITHIN_GRE_ON, + )?.Date, + ), + 'ERP Clearance Notification Sent On': makeDateOrUndefined( + project.Timestamps.find( + (timestamp) => + timestamp.TimestampTypeId === TimestampType.CLEARANCE_NOTIFICATION_SENT_ON, + )?.Date, + ), + 'Disposed On': makeDateOrUndefined( + project.Timestamps.find( + (timestamp) => timestamp.TimestampTypeId === TimestampType.DISPOSED_ON, + )?.Date, + ), + 'Marketed On': makeDateOrUndefined( + project.Timestamps.find( + (timestamp) => timestamp.TimestampTypeId === TimestampType.MARKETED_ON, + )?.Date, + ), 'Offers Note': project.Notes?.find((note) => note.NoteTypeId === NoteTypes.OFFER)?.Note, Purchaser: project.Notes?.find((note) => note.NoteTypeId === NoteTypes.PURCHASER)?.Note, }; diff --git a/react-app/src/components/users/UsersTable.tsx b/react-app/src/components/users/UsersTable.tsx index 9ed8e1d75d..7d60db01a5 100644 --- a/react-app/src/components/users/UsersTable.tsx +++ b/react-app/src/components/users/UsersTable.tsx @@ -11,7 +11,7 @@ import { Agency } from '@/hooks/api/useAgencyApi'; import { User } from '@/hooks/api/useUsersApi'; import { LookupContext } from '@/contexts/lookupContext'; import { Role } from '@/constants/roles'; -import { getProvider } from '@/utilities/helperFunctions'; +import { getProvider, makeDateOrUndefined } from '@/utilities/helperFunctions'; const CustomMenuItem = (props: PropsWithChildren & { value: string }) => { const theme = useTheme(); @@ -227,9 +227,9 @@ const UsersTable = (props: IUsersTable) => { Email: user.Email, Status: user.Status, Agency: user.Agency?.Name, - 'Last Login': user.LastLogin, + 'Last Login': makeDateOrUndefined(user.LastLogin), Role: user.Role?.Name, - 'Created On': user.CreatedOn, + 'Created On': makeDateOrUndefined(user.CreatedOn), Position: user.Position, }; }); diff --git a/react-app/src/pages/BulkUpload.tsx b/react-app/src/pages/BulkUpload.tsx index 6fa83954af..4ff57f9bc9 100644 --- a/react-app/src/pages/BulkUpload.tsx +++ b/react-app/src/pages/BulkUpload.tsx @@ -19,8 +19,9 @@ import { Typography, } from '@mui/material'; import { DataGrid, gridClasses, GridColDef } from '@mui/x-data-grid'; -import React, { useEffect, useMemo, useState } from 'react'; -import xlsx from 'node-xlsx'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import sheetjs from 'xlsx'; +import { SnackBarContext } from '@/contexts/snackbarContext'; const ResultsPaper = (props: { results: ImportResult[]; @@ -96,6 +97,7 @@ const BulkUpload = () => { const [file, setFile] = useState(); const [fileProgress, setFileProgress] = useState(0); const api = usePimsApi(); + const snackbar = useContext(SnackBarContext); const { submit } = useDataSubmitter(api.properties.uploadBulkSpreadsheet); const { refreshData: refreshResults, data: importResults } = useDataLoader(() => api.properties.getImportResults({ quantity: 1, sortKey: 'CreatedOn', sortOrder: 'DESC' }), @@ -128,25 +130,10 @@ const BulkUpload = () => { ]; const resultRows = useMemo(() => { if (importResults?.length && importResults.at(0).Results != null) - return importResults - .at(0) - .Results.slice() - .sort((a, b) => a.rowNumber - b.rowNumber); + return importResults.at(0).Results.sort((a, b) => a.rowNumber - b.rowNumber); else return []; }, [importResults]); - const buildXlsxBuffer = (result: ImportResult) => { - const rows = []; - if (!result?.Results?.length) { - return xlsx.build([{ name: `Results`, data: [], options: {} }]); - } - rows.push(['Row No.', 'Action', 'Reason']); - for (const item of result.Results) { - rows.push([item.rowNumber, item.action, item.reason ?? '']); - } - return xlsx.build([{ name: `Results`, data: rows, options: {} }]); - }; - return ( { { - const buffer = buildXlsxBuffer(importResults?.at(0)); - const blob = new Blob([buffer]); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - document.body.appendChild(a); - a.href = url; - a.download = `Result_${importResults?.at(0)?.FileName}_${importResults?.at(0)?.CreatedOn}.xlsx`; - a.click(); - window.URL.revokeObjectURL(url); - a.remove(); + if (importResults?.at(0)) { + const columnHeaders = ['Row No.', 'Action', 'Reason']; + const fileName = `Result_${importResults?.at(0)?.FileName}_${new Date(importResults?.at(0)?.CreatedOn).toLocaleDateString('iso')}.xlsx`; + + // Build xlsx file + const worksheet = sheetjs.utils.aoa_to_sheet([ + columnHeaders, + ...resultRows.map((result) => [result.rowNumber, result.action, result.reason ?? '']), + ]); + const workbook = sheetjs.utils.book_new(); + sheetjs.utils.book_append_sheet(workbook, worksheet, 'Bulk Upload Results'); + // Download the file + sheetjs.writeFile(workbook, fileName); + } else { + // Should never hit this point based on UI, but just in case. + snackbar.setMessageState({ + style: snackbar.styles.warning, + open: true, + text: 'No results available for export.', + }); + } }} rows={resultRows} results={importResults} diff --git a/react-app/src/utilities/downloadExcelFile.ts b/react-app/src/utilities/downloadExcelFile.ts index 03b5a0ec80..679c725772 100644 --- a/react-app/src/utilities/downloadExcelFile.ts +++ b/react-app/src/utilities/downloadExcelFile.ts @@ -1,5 +1,5 @@ import { GridRowId, GridValidRowModel } from '@mui/x-data-grid'; -import xlsx from 'node-xlsx'; +import sheetjs from 'xlsx'; /** * @interface @@ -25,30 +25,18 @@ export const downloadExcelFile = (props: IExcelDownloadProps) => { if (data.length > 0) { // Extract column headers const columnHeaders = Object.keys(data.at(0).model); - // Build xlsx file as bit array buffer - const bitArray = xlsx.build([ - { - name: tableName, - data: [columnHeaders, ...data.map((row) => Object.values(row.model))], - options: {}, // Required even if empty - }, - ]); - // Combine into one string of data - const binaryString = bitArray.reduce((acc, cur) => (acc += String.fromCharCode(cur)), ''); - // Convert data into file - const file = window.btoa(binaryString); - const url = `data:application/xlsx;base64,${file}`; // Create file name - const fileName = `${includeDate ? new Date().toISOString().substring(0, 10) : ''}_${tableName}${ + const fileName = `${includeDate ? new Date().toLocaleDateString('iso') : ''}_${tableName}${ '-' + filterName || '' - }`; - // Download file - const a = document.createElement('a'); - a.href = url; - a.download = `${fileName}.xlsx`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); + }.xlsx`; + // Build xlsx file + const worksheet = sheetjs.utils.aoa_to_sheet( + [columnHeaders, ...data.map((row) => Object.values(row.model))], + { cellDates: true }, + ); + const workbook = sheetjs.utils.book_new(); + sheetjs.utils.book_append_sheet(workbook, worksheet, tableName); + // Download the file + sheetjs.writeFile(workbook, fileName); } }; diff --git a/react-app/src/utilities/helperFunctions.ts b/react-app/src/utilities/helperFunctions.ts index 5c1bcbad99..a2dbe90dac 100644 --- a/react-app/src/utilities/helperFunctions.ts +++ b/react-app/src/utilities/helperFunctions.ts @@ -76,3 +76,14 @@ export const getProvider = (username: string, bcscIdentifier?: string) => { export const validateEmail = (email: string): boolean => z.string().email().safeParse(email).success; + +/** + * SheetJS does not like to receive invalid dates. Use this function to avoid that if value is unpopulated. + * @param date + * @returns Date or undefined + */ +export const makeDateOrUndefined = (date: unknown | undefined) => { + if (!date) return undefined; + if (typeof date == 'string' && (date as string).length === 0) return undefined; + return new Date(date as string | number | Date); +};