From 8776b98a2aeb28928756dc7c3033c481a41b7cf7 Mon Sep 17 00:00:00 2001 From: Kelly Downes Date: Tue, 27 Aug 2024 15:04:05 -0700 Subject: [PATCH] Add export button and notification dialogs --- assets/close.svg | 3 + assets/download-circle.svg | 4 + assets/loading dots.svg | 5 + assets/logo_downloading.svg | 3 + assets/round-check-circle.svg | 4 + assets/typcn_export.svg | 3 + assets/warning-error.svg | 3 + assets/yellow_tips.svg | 9 + components/common/ChipList/StyledChip.jsx | 9 +- .../main/Desktop/Export/ExportButton.jsx | 320 ++++++++++++++++++ .../Desktop/Export/ExportConfirmation.jsx | 104 ++++++ .../main/Desktop/Export/ExportDialog.jsx | 58 ++++ .../main/Desktop/Export/ExportDownload.jsx | 71 ++++ .../main/Desktop/Export/ExportFailure.jsx | 67 ++++ .../main/Desktop/Export/ExportWarning.jsx | 72 ++++ components/main/Desktop/Export/useStyles.js | 56 +++ components/main/Desktop/ExportButton.jsx | 199 ----------- components/main/Desktop/FilterMenu.js | 10 +- 18 files changed, 797 insertions(+), 203 deletions(-) create mode 100644 assets/close.svg create mode 100644 assets/download-circle.svg create mode 100644 assets/loading dots.svg create mode 100644 assets/logo_downloading.svg create mode 100644 assets/round-check-circle.svg create mode 100644 assets/typcn_export.svg create mode 100644 assets/warning-error.svg create mode 100644 assets/yellow_tips.svg create mode 100644 components/main/Desktop/Export/ExportButton.jsx create mode 100644 components/main/Desktop/Export/ExportConfirmation.jsx create mode 100644 components/main/Desktop/Export/ExportDialog.jsx create mode 100644 components/main/Desktop/Export/ExportDownload.jsx create mode 100644 components/main/Desktop/Export/ExportFailure.jsx create mode 100644 components/main/Desktop/Export/ExportWarning.jsx create mode 100644 components/main/Desktop/Export/useStyles.js delete mode 100644 components/main/Desktop/ExportButton.jsx diff --git a/assets/close.svg b/assets/close.svg new file mode 100644 index 000000000..284956632 --- /dev/null +++ b/assets/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/download-circle.svg b/assets/download-circle.svg new file mode 100644 index 000000000..a0c51e419 --- /dev/null +++ b/assets/download-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/loading dots.svg b/assets/loading dots.svg new file mode 100644 index 000000000..528d62b4e --- /dev/null +++ b/assets/loading dots.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/logo_downloading.svg b/assets/logo_downloading.svg new file mode 100644 index 000000000..c4a62b051 --- /dev/null +++ b/assets/logo_downloading.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/round-check-circle.svg b/assets/round-check-circle.svg new file mode 100644 index 000000000..7e0c45e4b --- /dev/null +++ b/assets/round-check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/typcn_export.svg b/assets/typcn_export.svg new file mode 100644 index 000000000..23bdcbc15 --- /dev/null +++ b/assets/typcn_export.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/warning-error.svg b/assets/warning-error.svg new file mode 100644 index 000000000..bfcd62eac --- /dev/null +++ b/assets/warning-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/yellow_tips.svg b/assets/yellow_tips.svg new file mode 100644 index 000000000..5902f7225 --- /dev/null +++ b/assets/yellow_tips.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/components/common/ChipList/StyledChip.jsx b/components/common/ChipList/StyledChip.jsx index 6546e243c..10c73f99e 100644 --- a/components/common/ChipList/StyledChip.jsx +++ b/components/common/ChipList/StyledChip.jsx @@ -51,6 +51,7 @@ function StyledChip({ color, onDelete, outlined, + sx, }) { const theme = useTheme(); const classesSolid = useStylesSolid({ color }); @@ -72,6 +73,7 @@ function StyledChip({ size="small" variant={outlined ? 'outlined' : 'default'} clickable={false} + sx={sx} /> ); } @@ -79,7 +81,10 @@ function StyledChip({ export default StyledChip; StyledChip.propTypes = { - label: PropTypes.string.isRequired, + label: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + ]).isRequired, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, @@ -87,6 +92,7 @@ StyledChip.propTypes = { color: PropTypes.string, onDelete: PropTypes.func, outlined: PropTypes.bool, + sx: PropTypes.shape({}), }; StyledChip.defaultProps = { @@ -94,4 +100,5 @@ StyledChip.defaultProps = { color: undefined, onDelete: undefined, outlined: false, + sx: undefined, }; diff --git a/components/main/Desktop/Export/ExportButton.jsx b/components/main/Desktop/Export/ExportButton.jsx new file mode 100644 index 000000000..b7816f5ea --- /dev/null +++ b/components/main/Desktop/Export/ExportButton.jsx @@ -0,0 +1,320 @@ +/* eslint-disable quotes */ +import React, { useContext, useEffect, useState } from 'react'; +import { + Portal, + Button, + Icon, +} from '@mui/material'; +import PropTypes from 'proptypes'; +import { connect } from 'react-redux'; +import moment from 'moment'; +import JSZip from 'jszip'; +import Papa from 'papaparse'; +import { saveAs } from 'file-saver'; +import DbContext from '@db/DbContext'; +import ddbh from '@utils/duckDbHelpers.js'; +import { isEmpty } from '@utils'; +import ExportIcon from '@assets/typcn_export.svg'; +import requestTypes from '@data/requestTypes'; +import useStyles from './useStyles'; +import ExportDialog from './ExportDialog'; + +function ExportButton({ filters }) { + const { conn } = useContext(DbContext); + const classes = useStyles(); + const [showDialog, setShowDialog] = useState(false); + const [errorType, setErrorType] = useState(''); + const [requestType, setRequestType] = useState(''); + const [selectionValidated, setSelectionValidated] = useState(false); + const [exportConfirmed, setExportConfirmed] = useState(false); + const [dialogType, setDialogType] = useState(''); + const [fileSize, setFileSize] = useState(0); + + const formattedRequestTypes = requestTypes + .filter(item => filters.requestTypes[item.typeId]) + .map(reqType => `'${reqType.socrataNames[0]}'`) + .join(', '); + + useEffect(() => { + const downloadZip = async (neighborhoodCsvContent, srCsvContent) => { + const zip = new JSZip(); + zip.file('NeighborhoodData.csv', neighborhoodCsvContent); + + // Only add SR count csv if it was generated + if (srCsvContent) { + zip.file('ServiceRequestCount.csv', srCsvContent); + } + + const content = await zip.generateAsync({ type: 'blob' }); + setFileSize(content.size); + setShowDialog(true); + setDialogType('confirmation'); + + if (exportConfirmed) { + setDialogType('downloading'); + try { + saveAs(content, '311Data.zip'); + setDialogType('success'); + } catch { + setDialogType('failed'); + } finally { + setExportConfirmed(false); + setSelectionValidated(false); + } + } + }; + + const getDataToExport = async () => { + let requestStatusFilter = ''; + + if (filters.requestStatus.open === true && filters.requestStatus.closed === false) { + // Only open is true, get all open requests + // query = `SELECT * FROM requests WHERE Status='Open';`; + requestStatusFilter = 'Open'; + } else if (filters.requestStatus.closed === true && filters.requestStatus.open === false) { + // Only closed is true, get all closed requests + // query = `SELECT * FROM requests WHERE Status='Closed';`; + requestStatusFilter = 'Closed'; + } + + const startYear = moment(filters.startDate).year(); + const endYear = moment(filters.endDate).year(); + + const getAllRequests = (year, startDate, endDate, councilId = '', status = '') => ` + SELECT * FROM requests_${year} + WHERE CreatedDate >= '${startDate}' + AND CreatedDate <= '${endDate}' + ${status === 'Open' ? " AND (Status = 'Open' OR Status = 'Pending')" : ''} + ${status === 'Closed' ? " AND (Status = 'Closed')" : ''} + ${councilId !== null ? ` AND NC='${councilId}'` : ''} + AND RequestType IN (${formattedRequestTypes})`; + + // Note: this logic will only generate the SR count CSV if it meets the following conditions: + // exactly one SR type is selected, a NC is selected, and status is Open or Pending. + const groupRequestsByAddress = (year, startDate, endDate, councilId) => ` + SELECT Address, COUNT(*) AS NumberOfRequests FROM requests_${year} + WHERE CreatedDate >= '${startDate}' + AND CreatedDate <= '${endDate}' + AND (Status = 'Open' OR Status = 'Pending') + AND NC = '${councilId}' + AND RequestType IN (${formattedRequestTypes}) + GROUP BY Address`; + + const generateQuery = (grouped = false) => { + if (startYear === endYear) { + if (grouped) { + // SRs grouped by address from same year + return groupRequestsByAddress( + startYear, + filters.startDate, + filters.endDate, + filters.councilId, + ); + } + // data comes from same year and includes all columns matching filters + return getAllRequests( + startYear, + filters.startDate, + filters.endDate, + filters.councilId, + requestStatusFilter, + ); + } + + const endOfStartYear = moment(filters.startDate).endOf('year').format('YYYY-MM-DD'); + const startOfEndYear = moment(filters.endDate).startOf('year').format('YYYY-MM-DD'); + + // SRs grouped by address with different start and end years + if (grouped) { + return `(${groupRequestsByAddress( + startYear, + filters.startDate, + endOfStartYear, + filters.councilId, + )}) UNION ALL (${groupRequestsByAddress( + endYear, + startOfEndYear, + filters.endDate, + filters.councilId, + )})`; + } + + // data with different start and end years and includes all columns matching filters + return `(${getAllRequests( + startYear, + filters.startDate, + endOfStartYear, + filters.councilId, + requestStatusFilter, + )}) UNION ALL (${getAllRequests( + endYear, + startOfEndYear, + filters.endDate, + filters.councilId, + requestStatusFilter, + )})`; + }; + + const exportData = async () => { + if (selectionValidated && dialogType === '') { + try { + const neighborhoodDataQuery = generateQuery(); + const neighborhoodDataToExport = await conn.query(neighborhoodDataQuery); + const neighborhoodResults = ddbh.getTableData(neighborhoodDataToExport); + const formattedResults = neighborhoodResults.map(row => ({ + ...row, + CreatedDate: row.CreatedDate ? moment(row.CreatedDate).format('YYYY-MM-DD HH:mm:ss') : null, + UpdatedDate: row.UpdatedDate ? moment(row.UpdatedDate).format('YYYY-MM-DD HH:mm:ss') : null, + ServiceDate: row.ServiceDate ? moment(row.ServiceDate).format('YYYY-MM-DD HH:mm:ss') : null, + ClosedDate: row.ClosedDate ? moment(row.ClosedDate).format('YYYY-MM-DD HH:mm:ss') : null, + })); + + if (!isEmpty(formattedResults)) { + const neighborhoodCsvContent = Papa.unparse(formattedResults); + let groupedAddressesToExport; + let srCountResults; + let srCsvContent; + + // SR count csv data only generated if status is open + if (requestStatusFilter === 'Open') { + const groupedAddressQuery = generateQuery(true); + groupedAddressesToExport = await conn.query(groupedAddressQuery); + srCountResults = ddbh.getTableData(groupedAddressesToExport); + + if (!isEmpty(srCountResults)) { + srCsvContent = Papa.unparse(srCountResults); + } + } + downloadZip(neighborhoodCsvContent, srCsvContent); + } else { + window.alert('No 311 data available within the selected filters. Please adjust your filters and try again.'); + setSelectionValidated(false); + } + } catch { + setDialogType('failed'); + } + } + }; + + if (selectionValidated) { + exportData(); + } + }; + + getDataToExport(); + }, [conn, + dialogType, + exportConfirmed, + filters.councilId, + filters.endDate, + filters.requestStatus.closed, + filters.requestStatus.open, + filters.startDate, + formattedRequestTypes, + selectionValidated, + showDialog]); + + const validateSelection = async () => { + const multiSrTypesError = "Please select only \n one Request Type to \n export the file."; + const noNCError = "Please select a \n Neighborhood District."; + const noSrError = "Please select one \n Request Type."; + const multiSrTypesAndNoNCError = "Please select a \n Neighborhood District \n & ONE Request Type."; + const noSrTypeAndNoNCError = "Please select a \n Neighborhood District & \n one Request Type."; + + setSelectionValidated(false); + setDialogType(''); + + const srTypeCount = Object.values(filters.requestTypes).reduce( + (acc, cur) => (cur === true ? acc + 1 : acc), + 0, + ); + setRequestType(` ${formattedRequestTypes.slice(1, -1)}`); + + if (srTypeCount > 1 && filters.councilId === null) { + setErrorType(multiSrTypesAndNoNCError); + } else if (srTypeCount === 0 && filters.councilId === null) { + setErrorType(noSrTypeAndNoNCError); + } else if (srTypeCount > 1) { + setErrorType(multiSrTypesError); + } else if (srTypeCount === 0) { + setErrorType(noSrError); + } else if (filters.councilId === null) { + setErrorType(noNCError); + } + + setShowDialog(true); + + if (srTypeCount === 1 && filters.councilId) { + setSelectionValidated(true); + } + }; + + const handleExport = async () => { + validateSelection(); + }; + + const onClose = () => { + setShowDialog(false); + setErrorType(''); + setDialogType(''); + setExportConfirmed(false); + }; + + const onConfirmationClose = () => { + setShowDialog(false); + setExportConfirmed(false); + setDialogType('canceled'); + }; + + const onConfirm = () => { + setExportConfirmed(true); + setShowDialog(false); + setDialogType(''); + }; + + return ( + <> + + + {showDialog && ( + + + + )} + + ); +} + +const mapStateToProps = state => ({ + filters: state.filters, +}); + +export default connect(mapStateToProps)(ExportButton); + +ExportButton.propTypes = { + filters: PropTypes.shape({ + startDate: PropTypes.string, + endDate: PropTypes.string, + councilId: PropTypes.number, + requestStatus: PropTypes.shape({ + open: PropTypes.bool, + closed: PropTypes.bool, + }), + requestTypes: PropTypes.objectOf(PropTypes.bool), + }).isRequired, +}; diff --git a/components/main/Desktop/Export/ExportConfirmation.jsx b/components/main/Desktop/Export/ExportConfirmation.jsx new file mode 100644 index 000000000..54037778c --- /dev/null +++ b/components/main/Desktop/Export/ExportConfirmation.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { + styled, + Box, + Button, + Icon, + Typography, +} from '@mui/material'; +import moment from 'moment'; +import DocumentDownload from '@assets/logo_downloading.svg'; +import { StyledChip } from '@components/common/ChipList'; +import sharedLayout from '@theme/layout'; +import useStyles from './useStyles'; + +const StyledBox = styled(Box)(({ theme }) => ({ + position: 'absolute', + backgroundColor: theme.palette.primary.main, + padding: theme.spacing(4), + boxShadow: theme.shadows[5], + textAlign: 'center', + maxWidth: '280px', + maxHeight: '400px', + borderRadius: '10px', +})); + +function ExportConfirmation({ + onClose, onConfirm, requestType, filters, fileSize, +}) { + const classes = { ...useStyles(), ...sharedLayout() }; + const { + confirmationButton, confirmationCancel, confirmationOk, imageIcon, + } = classes; + + return ( + + + + document download icon + + + 311Data + {` ‧ zip ‧ ${(fileSize / 1000).toFixed(2)}MB`} + + )} + color="#0e251d" + sx={{ m: '6px', p: '6px' }} + /> + + Request Type: + {requestType} + + + For the dates: + + + {`${moment(filters.startDate).format('MM/DD/YYYY')} - ${moment(filters.endDate).format('MM/DD/YYYY')}`} + + + Would you like to proceed the + + + download? + + + + + + ); +} + +const mapStateToProps = state => ({ + filters: state.filters, +}); + +export default connect(mapStateToProps)(ExportConfirmation); + +ExportConfirmation.propTypes = { + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func, + requestType: PropTypes.string.isRequired, + fileSize: PropTypes.number.isRequired, + filters: PropTypes.shape({ + startDate: PropTypes.string, + endDate: PropTypes.string, + councilId: PropTypes.number, + requestStatus: PropTypes.shape({ + open: PropTypes.bool, + closed: PropTypes.bool, + }), + requestTypes: PropTypes.objectOf(PropTypes.bool), + }).isRequired, +}; + +ExportConfirmation.defaultProps = { + onConfirm: undefined, +}; diff --git a/components/main/Desktop/Export/ExportDialog.jsx b/components/main/Desktop/Export/ExportDialog.jsx new file mode 100644 index 000000000..b0f2af0b0 --- /dev/null +++ b/components/main/Desktop/Export/ExportDialog.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + styled, + Modal as BaseModal, +} from '@mui/material'; +import ExportConfirmation from './ExportConfirmation'; +import ExportDownload from './ExportDownload'; +import ExportWarning from './ExportWarning'; +import ExportFailure from './ExportFailure'; + +const StyledModal = styled(BaseModal)({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +function ExportDialog({ + open, onClose, onConfirm, onConfirmationClose, errorType, requestType, dialogType, fileSize, +}) { + return ( + + <> + {errorType && } + {(dialogType === 'confirmation') && } + {(dialogType === 'downloading') && } + {(dialogType === 'success') && } + {(dialogType === 'failed') && } + + + ); +} + +export default ExportDialog; + +ExportDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func, + onConfirm: PropTypes.func, + onConfirmationClose: PropTypes.func, + errorType: PropTypes.string, + requestType: PropTypes.string, + dialogType: PropTypes.string, + fileSize: PropTypes.number, +}; + +ExportDialog.defaultProps = { + onClose: undefined, + onConfirm: undefined, + onConfirmationClose: undefined, + errorType: undefined, + requestType: undefined, + dialogType: undefined, + fileSize: undefined, +}; diff --git a/components/main/Desktop/Export/ExportDownload.jsx b/components/main/Desktop/Export/ExportDownload.jsx new file mode 100644 index 000000000..36dcccdfa --- /dev/null +++ b/components/main/Desktop/Export/ExportDownload.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + styled, + Box, + Icon, + Stack, + Typography, +} from '@mui/material'; +import DownloadCircle from '@assets/download-circle.svg'; +import CheckCircle from '@assets/round-check-circle.svg'; +import LoadingDots from '@assets/loading dots.svg'; +import useStyles from './useStyles'; + +const StyledBox = styled(Box)(({ theme }) => ({ + position: 'absolute', + top: '80px', + backgroundColor: theme.palette.primary.main, + boxShadow: theme.shadows[5], + textAlign: 'center', + maxWidth: '450px', + maxHeight: '65px', + borderRadius: '8px', +})); + +function ExportDownload({ dialogType }) { + const downloadingMessage = 'Data downloading'; + const downloadSuccessMessage = 'Data exported successfully!'; + const classes = useStyles(); + const { imageIcon } = classes; + + return ( + + + {(dialogType === 'downloading') && ( + <> + + download circle icon + + + {downloadingMessage} + + + loading dots + + + )} + {(dialogType === 'success') && ( + <> + + download success icon + + + {downloadSuccessMessage} + + + )} + + + ); +} + +export default ExportDownload; + +ExportDownload.propTypes = { + dialogType: PropTypes.string.isRequired, +}; diff --git a/components/main/Desktop/Export/ExportFailure.jsx b/components/main/Desktop/Export/ExportFailure.jsx new file mode 100644 index 000000000..83347a977 --- /dev/null +++ b/components/main/Desktop/Export/ExportFailure.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + styled, + Box, + Icon, + IconButton, + Stack, + Typography, +} from '@mui/material'; +import Close from '@assets/close.svg'; +import Warning from '@assets/warning-error.svg'; +import useStyles from './useStyles'; + +const StyledBox = styled(Box)(({ theme }) => ({ + position: 'absolute', + top: '30%', + backgroundColor: '#29404F', + padding: theme.spacing(3), + boxShadow: theme.shadows[5], + textAlign: 'center', + maxWidth: '317px', + maxHeight: '220px', + borderRadius: '10px', +})); + +function ExportFailure({ onClose }) { + const classes = useStyles(); + + return ( + + + + + + close icon + + + + + + warning icon + + + Oops! Something +
+ went wrong. Please try +
+ again later. +
+
+
+
+ ); +} + +export default ExportFailure; + +ExportFailure.propTypes = { + onClose: PropTypes.func.isRequired, +}; diff --git a/components/main/Desktop/Export/ExportWarning.jsx b/components/main/Desktop/Export/ExportWarning.jsx new file mode 100644 index 000000000..882a916de --- /dev/null +++ b/components/main/Desktop/Export/ExportWarning.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + styled, + Box, + Button, + Icon, + IconButton, + Stack, + Typography, +} from '@mui/material'; +import Close from '@assets/close.svg'; +import InfoAlert from '@assets/yellow_tips.svg'; +import useStyles from './useStyles'; + +const StyledBox = styled(Box)(({ theme }) => ({ + position: 'absolute', + top: '30%', + left: '45%', + backgroundColor: theme.palette.primary.main, + padding: theme.spacing(4), + boxShadow: theme.shadows[5], + textAlign: 'center', + maxWidth: '280px', + maxHeight: '418px', + borderRadius: '8px', + whiteSpace: 'pre-wrap', +})); + +function ExportWarning({ onClose, errorType }) { + const classes = useStyles(); + + return ( + + + + + + close icon + + + + + + info alert tooltip + + + {errorType} + + + + + + ); +} + +export default ExportWarning; + +ExportWarning.propTypes = { + onClose: PropTypes.func.isRequired, + errorType: PropTypes.string, +}; + +ExportWarning.defaultProps = { + errorType: undefined, +}; diff --git a/components/main/Desktop/Export/useStyles.js b/components/main/Desktop/Export/useStyles.js new file mode 100644 index 000000000..9f0837acf --- /dev/null +++ b/components/main/Desktop/Export/useStyles.js @@ -0,0 +1,56 @@ +import makeStyles from '@mui/styles/makeStyles'; + +const useStyles = makeStyles(theme => ({ + exportButton: { + color: theme.palette.text.secondaryLight, + textDecoration: 'underline', + '&:hover': { + textDecoration: 'underline', + }, + padding: 0, + }, + confirmationButton: { + width: '229px', + height: '30px', + borderRadius: '5px', + border: '1px solid #ECECEC', + '&:hover': { + backgroundColor: '#DADADA', + borderColor: '#DADADA', + }, + fontWeight: '500', + fontSize: '18px', + marginTop: '10px', + }, + confirmationOk: { + backgroundColor: theme.palette.secondary.light, + color: '#29404F', + }, + confirmationCancel: { + backgroundColor: '#29404F', + color: theme.palette.text.secondaryLight, + }, + warningButton: { + width: '169px', + height: '25px', + borderRadius: '5px', + backgroundColor: theme.palette.secondary.light, + border: '1px solid #ECECEC', + '&:hover': { + backgroundColor: '#DADADA', + borderColor: '#DADADA', + }, + color: '#29404F', + fontWeight: '500', + fontSize: '18px', + marginTop: '10px', + paddingTop: '8px', + }, + imageIcon: { + display: 'flex', + height: 'inherit', + width: 'inherit', + }, +})); + +export default useStyles; diff --git a/components/main/Desktop/ExportButton.jsx b/components/main/Desktop/ExportButton.jsx deleted file mode 100644 index 4cb0ff016..000000000 --- a/components/main/Desktop/ExportButton.jsx +++ /dev/null @@ -1,199 +0,0 @@ -import React, { useContext } from 'react'; -import Button from '@mui/material/Button'; -import PropTypes from 'proptypes'; -import { connect } from 'react-redux'; -import moment from 'moment'; -import JSZip from 'jszip'; -import Papa from 'papaparse'; -import { saveAs } from 'file-saver'; -import DbContext from '@db/DbContext'; -import ddbh from '@utils/duckDbHelpers.js'; -import { isEmpty } from '@utils'; -import requestTypes from '../../../data/requestTypes'; - -// export button main function -function ExportButton({ filters }) { - const { conn } = useContext(DbContext); - - // creation zip file - const downloadZip = async (neighborhoodCsvContent, srCsvContent) => { - const zip = new JSZip(); - zip.file('NeighborhoodData.csv', neighborhoodCsvContent); - - // Only add SR count csv if it was generated - if (srCsvContent) { - zip.file('ServiceRequestCount.csv', srCsvContent); - } - - const content = await zip.generateAsync({ type: 'blob' }); - saveAs(content, '311Data.zip'); - }; - - // data to add into zip file, queries then add results - const getDataToExport = async () => { - // define request status filter variable to reuse variable - let requestStatusFilter = ''; - - if (filters.requestStatus.open === true && filters.requestStatus.closed === false) { - // Only open is true, get all open requests - // query = `SELECT * FROM requests WHERE Status='Open';`; - requestStatusFilter = 'Open'; - } else if (filters.requestStatus.closed === true && filters.requestStatus.open === false) { - // Only closed is true, get all closed requests - // query = `SELECT * FROM requests WHERE Status='Closed';`; - requestStatusFilter = 'Closed'; - } - - const formattedRequestTypes = requestTypes - .filter(item => filters.requestTypes[item.typeId]) - .map(reqType => `'${reqType.socrataNames[0]}'`) - .join(', '); - - const startYear = moment(filters.startDate).year(); - const endYear = moment(filters.endDate).year(); - - const getAllRequests = (year, startDate, endDate, councilId = '', status = '') => ` - SELECT * FROM requests_${year} - WHERE CreatedDate >= '${startDate}' - AND CreatedDate <= '${endDate}' - ${status === 'Open' ? " AND (Status = 'Open' OR Status = 'Pending')" : ''} - ${status === 'Closed' ? " AND (Status = 'Closed')" : ''} - ${councilId !== null ? ` AND NC='${councilId}'` : ''} - AND RequestType IN (${formattedRequestTypes})`; - - // Note: this logic will only generate the SR count CSV if it meets the following conditions: - // exactly one SR type is selected, a NC is selected, and status is Open or Pending. - const groupRequestsByAddress = (year, startDate, endDate, councilId) => ` - SELECT Address, COUNT(*) AS NumberOfRequests FROM requests_${year} - WHERE CreatedDate >= '${startDate}' - AND CreatedDate <= '${endDate}' - AND (Status = 'Open' OR Status = 'Pending') - AND NC = '${councilId}' - AND RequestType IN (${formattedRequestTypes}) - GROUP BY Address`; - - const generateQuery = (grouped = false) => { - if (startYear === endYear) { - if (grouped) { - // SRs grouped by address from same year - return groupRequestsByAddress( - startYear, - filters.startDate, - filters.endDate, - filters.councilId, - ); - } - // data comes from same year and includes all columns matching filters - return getAllRequests( - startYear, - filters.startDate, - filters.endDate, - filters.councilId, - requestStatusFilter, - ); - } - - const endOfStartYear = moment(filters.startDate).endOf('year').format('YYYY-MM-DD'); - const startOfEndYear = moment(filters.endDate).startOf('year').format('YYYY-MM-DD'); - - // SRs grouped by address with different start and end years - if (grouped) { - return `(${groupRequestsByAddress( - startYear, - filters.startDate, - endOfStartYear, - filters.councilId, - )}) UNION ALL (${groupRequestsByAddress( - endYear, - startOfEndYear, - filters.endDate, - filters.councilId, - )})`; - } - - // data with different start and end years and includes all columns matching filters - return `(${getAllRequests( - startYear, - filters.startDate, - endOfStartYear, - filters.councilId, - requestStatusFilter, - )}) UNION ALL (${getAllRequests( - endYear, - startOfEndYear, - filters.endDate, - filters.councilId, - requestStatusFilter, - )})`; - }; - - const neighborhoodDataQuery = generateQuery(); - const neighborhoodDataToExport = await conn.query(neighborhoodDataQuery); - const neighborhoodResults = ddbh.getTableData(neighborhoodDataToExport); - const formattedResults = neighborhoodResults.map(row => ({ - ...row, - CreatedDate: row.CreatedDate ? moment(row.CreatedDate).format('YYYY-MM-DD HH:mm:ss') : null, - UpdatedDate: row.UpdatedDate ? moment(row.UpdatedDate).format('YYYY-MM-DD HH:mm:ss') : null, - ServiceDate: row.ServiceDate ? moment(row.ServiceDate).format('YYYY-MM-DD HH:mm:ss') : null, - ClosedDate: row.ClosedDate ? moment(row.ClosedDate).format('YYYY-MM-DD HH:mm:ss') : null, - })); - - if (!isEmpty(formattedResults)) { - const neighborhoodCsvContent = Papa.unparse(formattedResults); - let groupedAddressesToExport; - let srCountResults; - let srCsvContent; - - const srTypeCount = Object.values(filters.requestTypes).reduce( - (acc, cur) => (cur === true ? acc + 1 : acc), - 0, - ); - - // SR count csv data only generated if: - // exactly one SR type is selected, NC selected, and status is open - if (srTypeCount === 1 && filters.councilId && requestStatusFilter === 'Open') { - const groupedAddressQuery = generateQuery(true); - groupedAddressesToExport = await conn.query(groupedAddressQuery); - srCountResults = ddbh.getTableData(groupedAddressesToExport); - - if (!isEmpty(srCountResults)) { - srCsvContent = Papa.unparse(srCountResults); - } - } - downloadZip(neighborhoodCsvContent, srCsvContent); - } else { - window.alert('No 311 data available within the selected filters. Please adjust your filters and try again.'); - } - }; - - // action upon clicking - const handleExport = () => { - getDataToExport(filters); - }; - - return ( - - - ); -} - -const mapStateToProps = state => ({ - filters: state.filters, -}); - -export default connect(mapStateToProps)(ExportButton); - -ExportButton.propTypes = { - filters: PropTypes.shape({ - startDate: PropTypes.string, - endDate: PropTypes.string, - councilId: PropTypes.number, - requestStatus: PropTypes.shape({ - open: PropTypes.bool, - closed: PropTypes.bool, - }), - requestTypes: PropTypes.objectOf(PropTypes.bool), - }).isRequired, -}; diff --git a/components/main/Desktop/FilterMenu.js b/components/main/Desktop/FilterMenu.js index 539e959dd..38e0a0573 100644 --- a/components/main/Desktop/FilterMenu.js +++ b/components/main/Desktop/FilterMenu.js @@ -16,7 +16,7 @@ import TypeSelector from '@components/main/Desktop/TypeSelector'; import StatusSelector from '@components/main/Desktop/StatusSelector'; import CouncilSelector from '@components/main/Desktop/CouncilSelector'; // import ShareableLinkCreator from '@components/main/Desktop/ShareableLinkCreator'; -// import ExportButton from '@components/main/Desktop/ExportButton'; +import ExportButton from '@components/main/Desktop/Export/ExportButton'; // import GearButton from '@components/common/GearButton'; // import clsx from 'clsx'; @@ -61,6 +61,10 @@ const useStyles = makeStyles(theme => ({ selectorWrapper: { marginBottom: theme.gaps.md, }, + export: { + display: 'grid', + justifyContent: 'flex-end', + }, content: { padding: '6px 14px', }, @@ -124,9 +128,9 @@ function FilterMenu({ resetMap, resetAddressSearch }) { {/*
*/} - {/*
+
-
*/} +