diff --git a/frontend/package.json b/frontend/package.json index 10274939..ed4a5e88 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "bootstrap": "^4.6.0", "date-fns": "^2.30.0", "dayzed": "^3.2.3", + "docx": "8.0.0", "framer-motion": "^4.1.17", "graphql": "^15.5.0", "humps": "^2.0.1", diff --git a/frontend/src/components/forms/ExportToCSV.tsx b/frontend/src/components/forms/ExportLogs.tsx similarity index 69% rename from frontend/src/components/forms/ExportToCSV.tsx rename to frontend/src/components/forms/ExportLogs.tsx index 674b8018..8bd5d770 100644 --- a/frontend/src/components/forms/ExportToCSV.tsx +++ b/frontend/src/components/forms/ExportLogs.tsx @@ -20,16 +20,21 @@ import { ModalCloseButton, Spinner, FormLabel, + Flex, } from "@chakra-ui/react"; +import { ArrowDownIcon, ArrowUpIcon } from "@chakra-ui/icons"; import { TiExport } from "react-icons/ti"; import LogRecordAPIClient from "../../APIClients/LogRecordAPIClient"; import { singleDatePickerStyle } from "../../theme/forms/datePickerStyles"; -import convertLogsToCSV from "../../helper/csvHelpers"; +import { + convertLogsToDOCX, + convertLogsToCSV, +} from "../../helper/exportHelpers"; import CreateToast from "../common/Toasts"; import { getFormattedDateAndTime } from "../../helper/dateHelpers"; import { SingleDatepicker } from "../common/Datepicker"; -const ExportToCSV = (): React.ReactElement => { +const ExportLogs = (): React.ReactElement => { const [startDate, setStartDate] = useState(); const [isStartDateEmpty, setIsStartDateEmpty] = useState(true); const [endDate, setEndDate] = useState(); @@ -39,6 +44,8 @@ const ExportToCSV = (): React.ReactElement => { const [endDateError, setEndDateError] = useState(false); const [dateError, setDateError] = useState(false); + const [sortDirection, setSortDirection] = useState("desc"); + const [isOpen, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -47,6 +54,7 @@ const ExportToCSV = (): React.ReactElement => { const handleClear = () => { setStartDate(undefined); setEndDate(undefined); + setSortDirection("desc"); setDateError(false); setStartDateError(false); setEndDateError(false); @@ -93,20 +101,23 @@ const ExportToCSV = (): React.ReactElement => { setOpen(false); }; - const handleSubmit = async () => { + const validateDates = (): boolean => { if (!startDate && !isStartDateEmpty) { setStartDateError(true); - return; + return false; } if (!endDate && !isEndDateEmpty) { setEndDateError(true); - return; + return false; } if (startDate && endDate && startDate > endDate) { setDateError(true); - return; + return false; } + return true; + }; + const constructDateRange = (): (string | null)[] | undefined => { let dateRange; if (startDate || endDate) { startDate?.setHours(0, 0, 0, 0); @@ -117,10 +128,57 @@ const ExportToCSV = (): React.ReactElement => { endDate ? endDate.toISOString() : null, ]; } + + return dateRange; + }; + + const handleDocxExport = async () => { + if (!validateDates()) { + return; + } + setLoading(true); const data = await LogRecordAPIClient.filterLogRecords({ - dateRange, - returnAll: true, // return all data + dateRange: constructDateRange(), + sortDirection, + returnAll: true, + }); + + if (!data || data.logRecords.length === 0) { + newToast( + "Error downloading DOCX", + "No records found in the provided date range.", + "error", + ); + } else { + const formattedLogRecords = data.logRecords.map((logRecord) => { + const { date, time } = getFormattedDateAndTime( + new Date(logRecord.datetime), + true, + ); + return { ...logRecord, datetime: `${date}, ${time}` }; + }); + const success = await convertLogsToDOCX(formattedLogRecords); + if (success) { + newToast("DOCX downloaded", "Successfully downloaded DOCX.", "success"); + handleClose(); + } else { + newToast("Error downloading DOCX", "Unable to download DOCX.", "error"); + } + } + setLoading(false); + }; + + const handleCsvExport = async () => { + if (!validateDates()) { + return; + } + + setLoading(true); + const data = await LogRecordAPIClient.filterLogRecords({ + dateRange: constructDateRange(), + sortDirection, + returnAll: true, }); if (!data || data.logRecords.length === 0) { @@ -150,9 +208,9 @@ const ExportToCSV = (): React.ReactElement => { return ( <> - + } variant="tertiary" onClick={handleOpen} @@ -163,7 +221,7 @@ const ExportToCSV = (): React.ReactElement => { - Export to CSV File + Export Logs @@ -225,6 +283,32 @@ const ExportToCSV = (): React.ReactElement => { Note: If a range is not selected, all records will be printed. + + Sort Direction + + + ) : ( + + ) + } + onClick={() => + setSortDirection( + sortDirection === "desc" ? "asc" : "desc", + ) + } + /> + + {sortDirection === "desc" ? "Descending" : "Ascending"} + + + @@ -237,8 +321,16 @@ const ExportToCSV = (): React.ReactElement => { marginRight="10px" /> )} - + @@ -248,4 +340,4 @@ const ExportToCSV = (): React.ReactElement => { ); }; -export default ExportToCSV; +export default ExportLogs; diff --git a/frontend/src/components/pages/HomePage/HomePage.tsx b/frontend/src/components/pages/HomePage/HomePage.tsx index 70bc0925..a9125f12 100644 --- a/frontend/src/components/pages/HomePage/HomePage.tsx +++ b/frontend/src/components/pages/HomePage/HomePage.tsx @@ -7,7 +7,7 @@ import CreateLog from "../../forms/CreateLog"; import { LogRecord } from "../../../types/LogRecordTypes"; import LogRecordsTable from "./LogRecordsTable"; import HomePageFilters from "./HomePageFilters"; -import ExportToCSV from "../../forms/ExportToCSV"; +import ExportLogs from "../../forms/ExportLogs"; import LogRecordAPIClient from "../../../APIClients/LogRecordAPIClient"; import { SelectLabel } from "../../../types/SharedTypes"; @@ -219,7 +219,7 @@ const HomePage = (): React.ReactElement => { countRecords={countLogRecords} setUserPageNum={setUserPageNum} /> - + diff --git a/frontend/src/components/pages/HomePage/LogRecordsTable.tsx b/frontend/src/components/pages/HomePage/LogRecordsTable.tsx index 1fda8fc4..e2505344 100644 --- a/frontend/src/components/pages/HomePage/LogRecordsTable.tsx +++ b/frontend/src/components/pages/HomePage/LogRecordsTable.tsx @@ -238,7 +238,7 @@ const LogRecordsTable = ({ Tenants Note Employee - Attn To + Attn Tos Tags diff --git a/frontend/src/helper/csvHelpers.ts b/frontend/src/helper/csvHelpers.ts deleted file mode 100644 index aadaa8a0..00000000 --- a/frontend/src/helper/csvHelpers.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { LogRecord } from "../types/LogRecordTypes"; -import { CSVLog } from "../types/CSVLogTypes"; - -const convertToCSVLog = (logRecord: LogRecord): CSVLog => { - return { - attnTos: - logRecord.attnTos != null ? `"${logRecord.attnTos.join(", ")}"` : "", - building: `"${logRecord.building.name}"`, - datetime: `"${logRecord.datetime}"`, - employee: `"${logRecord.employee.firstName} ${logRecord.employee.lastName}"`, - flagged: logRecord.flagged, - note: `"${logRecord.note.replace(/"/g, '""')}"`, - residents: `"${logRecord.residents.join(", ")}"`, - tags: logRecord.tags != null ? `"${logRecord.tags.join(", ")}"` : "", - }; -}; - -const convertLogsToCSV = (data: LogRecord[]): boolean => { - // Convert JSON to CSV - try { - const csvRows = []; - - const headers = [ - "attnTos", - "building", - "datetime", - "employee", - "flagged", - "note", - "tenants", - "tags", - ]; - csvRows.push(headers.join(",")); - data.forEach((log: LogRecord) => { - const logCSV = convertToCSVLog(log); - const values = Object.values(logCSV).join(","); - csvRows.push(values); - }); - const csvContent = csvRows.join("\n"); - - // Download CSV file - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - - // Get date for file name - // "fr-CA" formats the date into YYYY-MM-DD - const dateToday = new Date(); - const dateTodayString = dateToday - .toLocaleString("fr-CA", { timeZone: "America/Toronto" }) - .substring(0, 10); - - link.setAttribute("download", `log_records ${dateTodayString}.csv`); - document.body.appendChild(link); - link.click(); - - // Cleanup created object - document.body.removeChild(link); - URL.revokeObjectURL(url); - - return true; - } catch { - return false; - } -}; - -export default convertLogsToCSV; diff --git a/frontend/src/helper/exportHelpers.ts b/frontend/src/helper/exportHelpers.ts new file mode 100644 index 00000000..015ec0a1 --- /dev/null +++ b/frontend/src/helper/exportHelpers.ts @@ -0,0 +1,139 @@ +import { Document, Packer, Paragraph, Table, TableCell, TableRow } from "docx"; +import { LogRecord } from "../types/LogRecordTypes"; + +export enum DocType { + DOCX = "docx", + CSV = "csv", +} + +const downloadBlob = (blob: Blob, type: DocType): void => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + + // Get date for file name + // "fr-CA" formats the date into YYYY-MM-DD + const dateToday = new Date(); + const dateTodayString = dateToday + .toLocaleString("fr-CA", { timeZone: "America/Toronto" }) + .substring(0, 10); + + link.setAttribute("download", `log_records_${dateTodayString}.${type}`); + document.body.appendChild(link); + link.click(); + + // Cleanup created object + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +export const convertLogsToDOCX = async ( + data: LogRecord[], +): Promise => { + try { + // Create a table to display log records + const table = new Table({ + rows: [ + new TableRow({ + children: [ + new TableCell({ children: [new Paragraph("Datetime")] }), + new TableCell({ children: [new Paragraph("Tenants")] }), + new TableCell({ children: [new Paragraph("Note")] }), + new TableCell({ children: [new Paragraph("Employee")] }), + new TableCell({ children: [new Paragraph("Attn Tos")] }), + new TableCell({ children: [new Paragraph("Tags")] }), + new TableCell({ children: [new Paragraph("Flagged")] }), + new TableCell({ children: [new Paragraph("Building")] }), + ], + }), + ...data.map( + (record) => + new TableRow({ + children: [ + new TableCell({ children: [new Paragraph(record.datetime)] }), + new TableCell({ + children: [new Paragraph(record.residents.join(", "))], + }), + new TableCell({ children: [new Paragraph(record.note)] }), + new TableCell({ + children: [ + new Paragraph( + `${record.employee.firstName} ${record.employee.lastName}`, + ), + ], + }), + new TableCell({ + children: [new Paragraph(record.attnTos.join(", "))], + }), + new TableCell({ + children: [new Paragraph(record.tags.join(", "))], + }), + new TableCell({ + children: [new Paragraph(record.flagged ? "Yes" : "No")], + }), + new TableCell({ + children: [new Paragraph(record.building.name)], + }), + ], + }), + ), + ], + }); + + // Create a new document with the table + const doc = new Document({ + sections: [ + { + children: [table], + }, + ], + }); + + const blob = await Packer.toBlob(doc); + downloadBlob(blob, DocType.DOCX); + + return true; + } catch (error) { + return false; + } +}; + +export const convertLogsToCSV = (data: LogRecord[]): boolean => { + try { + const csvRows = []; + + const headers = [ + "Datetime", + "Tenants", + "Note", + "Employee", + "Attn Tos", + "Tags", + "Flagged", + "Building", + ]; + csvRows.push(headers.join(",")); + data.forEach((log: LogRecord) => { + const logCSV = { + datetime: `"${log.datetime}"`, + tenants: `"${log.residents.join(", ")}"`, + note: `"${log.note.replace(/"/g, '""')}"`, + employee: `"${log.employee.firstName} ${log.employee.lastName}"`, + attnTos: log.attnTos != null ? `"${log.attnTos.join(", ")}"` : "", + tags: log.tags != null ? `"${log.tags.join(", ")}"` : "", + flagged: log.flagged, + building: `"${log.building.name}"`, + }; + const values = Object.values(logCSV).join(","); + csvRows.push(values); + }); + const csvContent = csvRows.join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + downloadBlob(blob, DocType.CSV); + + return true; + } catch { + return false; + } +}; diff --git a/frontend/src/types/CSVLogTypes.ts b/frontend/src/types/CSVLogTypes.ts deleted file mode 100644 index 91327fa6..00000000 --- a/frontend/src/types/CSVLogTypes.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type CSVLog = { - attnTos: string; - building: string; - datetime: string; - employee: string; - flagged: boolean; - note: string; - residents: string; - tags: string; -}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 57f7b8fb..f9888c0e 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2447,6 +2447,13 @@ version "12.20.4" resolved "https://registry.npmjs.org/@types/node/-/node-12.20.4.tgz" +"@types/node@^18.0.0": + version "18.19.31" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.31.tgz#b7d4a00f7cb826b60a543cebdbda5d189aaecdcd" + integrity sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA== + dependencies: + undici-types "~5.26.4" + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz" @@ -4738,6 +4745,17 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +docx@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/docx/-/docx-8.0.0.tgz#2690097f6b755e82b8e83a1df6e7a2c89d274bdc" + integrity sha512-KP+sH/e6InD+gzcA9axumAukQ5Ng2kzNVEnKWAuSzX9xfghSFi4oe9SiEE9NBSguao98oglpVau6j0vfYgHCpA== + dependencies: + "@types/node" "^18.0.0" + jszip "^3.1.5" + nanoid "^3.3.4" + xml "^1.0.1" + xml-js "^1.6.8" + dom-accessibility-api@^0.5.4: version "0.5.4" resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz" @@ -6237,6 +6255,11 @@ ignore@^5.1.4: version "5.1.8" resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz" +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immer@8.0.1: version "8.0.1" resolved "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz" @@ -7312,6 +7335,16 @@ jsprim@^1.2.2: array-includes "^3.1.2" object.assign "^4.1.2" +jszip@^3.1.5: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz" @@ -7398,6 +7431,13 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz" @@ -7866,6 +7906,11 @@ nanoid@^3.1.22: version "3.1.22" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz" +nanoid@^3.3.4: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz" @@ -8291,7 +8336,7 @@ p-try@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" -pako@~1.0.5: +pako@~1.0.2, pako@~1.0.5: version "1.0.11" resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz" @@ -10160,6 +10205,11 @@ sass-loader@^10.0.5: schema-utils "^3.0.0" semver "^7.3.2" +sax@^1.2.4: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" + integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA== + sax@~1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz" @@ -10300,7 +10350,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" integrity "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" @@ -11206,6 +11256,11 @@ uncontrollable@^7.2.1: invariant "^2.2.4" react-lifecycles-compat "^3.0.4" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz" @@ -11878,10 +11933,22 @@ ws@^7.4.4: version "7.4.5" resolved "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz" +xml-js@^1.6.8: + version "1.6.11" + resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" + integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== + dependencies: + sax "^1.2.4" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz" +xml@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" + integrity sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw== + xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz"