diff --git a/client/.eslintrc b/client/.eslintrc index cc0f939b4..25e7bdb80 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -1,5 +1,8 @@ { "extends": [ "react-app" - ] + ], + "rules": { + "no-useless-rename": "off" + } } diff --git a/client/src/components/bills-list-table/bills-list-table.component.jsx b/client/src/components/bills-list-table/bills-list-table.component.jsx index 03541a5ca..5329c43b0 100644 --- a/client/src/components/bills-list-table/bills-list-table.component.jsx +++ b/client/src/components/bills-list-table/bills-list-table.component.jsx @@ -14,6 +14,7 @@ import { TemplateList } from "../../utils/TemplateConstants"; import BillDeleteButton from "../bill-delete-button/bill-delete-button.component"; import BillDetailEditReturnComponent from "../bill-detail-edit/bill-detail-edit-return.component"; import PrintWrapperComponent from "../print-wrapper/print-wrapper.component"; +import { FaTasks } from "react-icons/fa"; const mapStateToProps = createStructuredSelector({ jobRO: selectJobReadOnly, @@ -21,9 +22,21 @@ const mapStateToProps = createStructuredSelector({ }); const mapDispatchToProps = (dispatch) => ({ - setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })), - setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })), - setReconciliationContext: (context) => dispatch(setModalContext({ context: context, modal: "reconciliation" })) + setBillEnterContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "billEnter" + }) + ), + setReconciliationContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "reconciliation" + }) + ), + setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) }); export function BillsListTableComponent({ @@ -32,9 +45,9 @@ export function BillsListTableComponent({ job, billsQuery, handleOnRowClick, - setPartsOrderContext, setBillEnterContext, - setReconciliationContext + setReconciliationContext, + setTaskUpsertContext }) { const { t } = useTranslation(); @@ -48,6 +61,7 @@ export function BillsListTableComponent({ const Templates = TemplateList("bill"); const bills = billsQuery.data ? billsQuery.data.bills : []; const { refetch } = billsQuery; + const recordActions = (record, showView = false) => ( {showView && ( @@ -55,6 +69,19 @@ export function BillsListTableComponent({ )} + ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect(mapStateToProps, mapDispatchToProps)(FormDateTimePickerEnhanced); + +const dateFormat = "MM/DD/YYYY h:mm a"; + +export function FormDateTimePickerEnhanced({ + bodyshop, + value, + onBlur, + onlyFuture, + onlyToday, + isDateOnly = true, + ...restProps +}) { + const ref = useRef(); + return ( +
+ { + if (onlyToday) { + return !dayjs().isSame(d, "day"); + } else if (onlyFuture) { + return dayjs().subtract(1, "day").isAfter(d); + } + }} + {...restProps} + /> +
+ ); +} diff --git a/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx b/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx index 8c11ece9d..23af7008a 100644 --- a/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx +++ b/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx @@ -1,7 +1,7 @@ import React, { forwardRef } from "react"; //import DatePicker from "react-datepicker"; //import "react-datepicker/src/stylesheets/datepicker.scss"; -import { TimePicker } from "antd"; +import { Space, TimePicker } from "antd"; import dayjs from "../../utils/day"; import FormDatePicker from "../form-date-picker/form-date-picker.component"; //To be used as a form element only. @@ -14,7 +14,7 @@ const DateTimePicker = ({ value, onChange, onBlur, id, onlyFuture, ...restProps // }; return ( -
+ -
+
); }; diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index d0683154e..d795e6f54 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -17,6 +17,7 @@ import Icon, { LineChartOutlined, PaperClipOutlined, PhoneOutlined, + PlusCircleOutlined, QuestionCircleFilled, ScheduleOutlined, SettingOutlined, @@ -30,7 +31,7 @@ import { Layout, Menu, Switch, Tooltip } from "antd"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsKanban } from "react-icons/bs"; -import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar } from "react-icons/fa"; +import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa"; import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi"; import { IoBusinessOutline } from "react-icons/io5"; import { RiSurveyLine } from "react-icons/ri"; @@ -54,12 +55,43 @@ const mapStateToProps = createStructuredSelector({ }); const mapDispatchToProps = (dispatch) => ({ - setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })), - setTimeTicketContext: (context) => dispatch(setModalContext({ context: context, modal: "timeTicket" })), + setBillEnterContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "billEnter" + }) + ), + setTimeTicketContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "timeTicket" + }) + ), setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })), - setReportCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "reportCenter" })), + setReportCenterContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "reportCenter" + }) + ), signOutStart: () => dispatch(signOutStart()), - setCardPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "cardPayment" })) + setCardPaymentContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "cardPayment" + }) + ), + setTaskUpsertContext: (context) => + dispatch( + setModalContext({ + context: context, + modal: "taskUpsert" + }) + ) }); function Header({ @@ -73,7 +105,8 @@ function Header({ setPaymentContext, setReportCenterContext, recentItems, - setCardPaymentContext + setCardPaymentContext, + setTaskUpsertContext }) { const { treatments: { ImEXPay, DmsAp, Simple_Inventory } @@ -441,6 +474,35 @@ function Header({ } ] : []), + { + key: "tasks", + id: "tasks", + icon: , + label: t("menus.header.tasks"), + children: [ + { + key: "createTask", + icon: , + label: t("menus.header.create_task"), + onClick: () => { + setTaskUpsertContext({ + actions: {}, + context: {} + }); + } + }, + { + key: "mytasks", + icon: , + label: {t("menus.header.my_tasks")} + }, + { + key: "all_tasks", + icon: , + label: {t("menus.header.all_tasks")} + } + ] + }, { key: "shopsubmenu", icon: , diff --git a/client/src/components/job-detail-lines/job-lines-expander.component.jsx b/client/src/components/job-detail-lines/job-lines-expander.component.jsx index 16d2d6fa5..53ca81457 100644 --- a/client/src/components/job-detail-lines/job-lines-expander.component.jsx +++ b/client/src/components/job-detail-lines/job-lines-expander.component.jsx @@ -7,17 +7,18 @@ import { GET_JOB_LINE_ORDERS } from "../../graphql/jobs.queries"; import CurrencyFormatter from "../../utils/CurrencyFormatter"; import { DateFormatter } from "../../utils/DateFormatter"; import AlertComponent from "../alert/alert.component"; - import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; +import { QUERY_JOBLINE_TASKS_PAGINATED } from "../../graphql/tasks.queries.js"; +import TaskListContainer from "../task-list/task-list.container.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop }); -const mapDispatchToProps = (dispatch) => ({ - //setUserLanguage: language => dispatch(setUserLanguage(language)) -}); + +const mapDispatchToProps = (dispatch) => ({}); + export default connect(mapStateToProps, mapDispatchToProps)(JobLinesExpander); export function JobLinesExpander({ jobline, jobid, bodyshop }) { @@ -146,6 +147,15 @@ export function JobLinesExpander({ jobline, jobid, bodyshop }) { } /> + + + ); } diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx index 291c02305..ff963337c 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -42,6 +42,7 @@ import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job- import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container"; import JobLinesExpander from "./job-lines-expander.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; +import { FaTasks } from "react-icons/fa"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -52,7 +53,8 @@ const mapStateToProps = createStructuredSelector({ const mapDispatchToProps = (dispatch) => ({ setJobLineEditContext: (context) => dispatch(setModalContext({ context: context, modal: "jobLineEdit" })), setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })), - setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })) + setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })), + setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) }); export function JobLinesComponent({ @@ -67,7 +69,8 @@ export function JobLinesComponent({ job, setJobLineEditContext, form, - setBillEnterContext + setBillEnterContext, + setTaskUpsertContext }) { const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK); const { @@ -331,6 +334,24 @@ export function JobLinesComponent({ > + + )} + + + {(record.manual_line || jobIsPrivate) && ( + <> - + { return { diff --git a/client/src/components/task-list/task-list.component.jsx b/client/src/components/task-list/task-list.component.jsx new file mode 100644 index 000000000..60cd9dbe4 --- /dev/null +++ b/client/src/components/task-list/task-list.component.jsx @@ -0,0 +1,382 @@ +import { Button, Card, Space, Switch, Table } from "antd"; +import queryString from "query-string"; +import React, { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { pageLimit } from "../../utils/config"; +import dayjs from "../../utils/day"; +import { + CheckCircleFilled, + CheckCircleOutlined, + DeleteFilled, + DeleteOutlined, + EditFilled, + ExclamationCircleFilled, + PlusCircleFilled, + SyncOutlined +} from "@ant-design/icons"; +import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter.jsx"; +import { connect } from "react-redux"; +import { setModalContext } from "../../redux/modals/modals.actions"; + +/** + * Task List Component + * @param dueDate + * @returns {Element} + * @constructor + */ +const DueDateRecord = ({ dueDate }) => { + if (!dueDate) return <>; + + const dueDateDayjs = dayjs(dueDate); + const relativeDueDate = dueDateDayjs.fromNow(); + const isBeforeToday = dueDateDayjs.isBefore(dayjs()); + + return ( +
+ {dueDate} +
+ ); +}; + +const RemindAtRecord = ({ remindAt }) => { + if (!remindAt) return <>; + + const remindAtDayjs = dayjs(remindAt); + const relativeRemindAtDate = remindAtDayjs.fromNow(); + const isBeforeToday = remindAtDayjs.isBefore(dayjs()); + + return ( +
+ {remindAt} +
+ ); +}; + +/** + * Priority Label Component + * @param priority + * @returns {Element} + * @constructor + */ +const PriorityLabel = ({ priority }) => { + switch (priority) { + case 1: + return ( +
+ High +
+ ); + case 2: + return ( +
+ Medium +
+ ); + case 3: + return ( +
+ Low +
+ ); + default: + return ( +
+ None +
+ ); + } +}; + +const mapDispatchToProps = (dispatch) => ({ + // Existing dispatch props... + setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) +}); + +const mapStateToProps = (state) => ({}); + +export default connect(mapStateToProps, mapDispatchToProps)(TaskListComponent); + +function TaskListComponent({ + bodyshop, + loading, + tasks, + total, + titleTranslation, + refetch, + toggleCompletedStatus, + setTaskUpsertContext, + toggleDeletedStatus, + relationshipType, + relationshipId, + onlyMine, + parentJobId, + query, + showRo = true +}) { + const { t } = useTranslation(); + const location = useLocation(); + + const search = queryString.parse(useLocation().search); + + // Extract Query Params + const { page, sortcolumn, sortorder, deleted, completed, mine } = search; + + const history = useNavigate(); + const columns = []; + + useEffect(() => { + // This is a hack to force the page to change if the query params change (partssublet for example) + }, [location]); + + columns.push({ + title: t("tasks.fields.created_at"), + dataIndex: "created_at", + key: "created_at", + width: "10%", + defaultSortOrder: "descend", + sorter: true, + sortOrder: sortcolumn === "created_at" && sortorder, + render: (text, record) => {record.created_at} + }); + + if (!onlyMine) { + columns.push({ + title: t("tasks.fields.assigned_to"), + dataIndex: "assigned_to", + key: "assigned_to", + width: "8%", + sorter: true, + sortOrder: sortcolumn === "assigned_to" && sortorder, + render: (text, record) => { + const employee = bodyshop?.employees?.find((e) => e.user_email === record.assigned_to); + return employee ? `${employee.first_name} ${employee.last_name}` : t("general.labels.na"); + } + }); + } + + if (showRo) { + columns.push({ + title: t("tasks.fields.job.ro_number"), + dataIndex: ["job", "ro_number"], + key: "job.ro_number", + width: "8%", + render: (text, record) => + record.job ? ( + {record.job.ro_number || t("general.labels.na")} + ) : ( + t("general.labels.na") + ) + }); + } + + columns.push( + { + title: t("tasks.fields.jobline"), + dataIndex: ["jobline", "id"], + key: "jobline.id", + width: "8%", + render: (text, record) => record?.jobline?.line_desc || "" + }, + { + title: t("tasks.fields.parts_order"), + dataIndex: ["parts_order", "id"], + key: "part_order.id", + width: "8%", + render: (text, record) => + record.parts_order ? ( + + {record.parts_order.order_number && record.parts_order.vendor && record.parts_order.vendor.name + ? `${record.parts_order.order_number} - ${record.parts_order.vendor.name}` + : t("general.labels.na")} + + ) : ( + "" + ) + }, + { + title: t("tasks.fields.bill"), + dataIndex: ["bill", "id"], + key: "bill.id", + width: "10%", + render: (text, record) => + record.bill ? ( + + {record.bill.invoice_number && record.bill.vendor && record.bill.vendor.name + ? `${record.bill.invoice_number} - ${record.bill.vendor.name}` + : t("general.labels.na")} + + ) : ( + "" + ) + }, + { + title: t("tasks.fields.title"), + dataIndex: "title", + key: "title", + sorter: true, + sortOrder: sortcolumn === "title" && sortorder + }, + { + title: t("tasks.fields.due_date"), + dataIndex: "due_date", + key: "due_date", + sorter: true, + sortOrder: sortcolumn === "due_date" && sortorder, + width: "8%", + render: (text, record) => + }, + { + title: t("tasks.fields.remind_at"), + dataIndex: "remind_at", + key: "remind_at", + sorter: true, + sortOrder: sortcolumn === "remind_at" && sortorder, + width: "10%", + render: (text, record) => + }, + { + title: t("tasks.fields.priority"), + dataIndex: "priority", + key: "priority", + sorter: true, + sortOrder: sortcolumn === "priority" && sortorder, + width: "8%", + render: (text, record) => + }, + { + title: t("tasks.fields.actions"), + key: "toggleCompleted", + width: "5%", + render: (text, record) => ( + + + + + + ) + } + ); + + const handleCreateTask = useCallback(() => { + setTaskUpsertContext({ + actions: {}, + context: { + jobid: parentJobId, + [relationshipType]: relationshipId, + query + } + }); + }, [parentJobId, relationshipId, relationshipType, setTaskUpsertContext, query]); + + const handleTableChange = (pagination, filters, sorter) => { + search.page = pagination.current; + search.sortcolumn = sorter.columnKey; + search.sortorder = sorter.order; + history({ search: queryString.stringify(search) }); + }; + + const handleSwitchChange = useCallback( + (param, value) => { + if (value) { + search[param] = "true"; + } else { + delete search[param]; + } + history({ search: queryString.stringify(search) }); + }, + [history, search] + ); + + const expandableRow = (record) => { + return ( + + {record.description} + + ); + }; + + /** + * Extra actions for the tasks + * @type {Function} + */ + const tasksExtra = useCallback(() => { + return ( + + {!onlyMine && ( + handleSwitchChange("mine", value)} + /> + )} + } + unCheckedChildren={} + title={t("tasks.titles.completed")} + checked={completed === "true"} + onChange={(value) => handleSwitchChange("completed", value)} + /> + } + unCheckedChildren={} + title={t("tasks.titles.deleted")} + checked={deleted === "true"} + onChange={(value) => handleSwitchChange("deleted", value)} + /> + + + + ); + }, [refetch, deleted, completed, mine, onlyMine, t, handleSwitchChange, handleCreateTask]); + + return ( + + record.description + }} + /> + + ); +} diff --git a/client/src/components/task-list/task-list.container.jsx b/client/src/components/task-list/task-list.container.jsx new file mode 100644 index 000000000..1bc801fff --- /dev/null +++ b/client/src/components/task-list/task-list.container.jsx @@ -0,0 +1,187 @@ +import queryString from "query-string"; +import { useLocation } from "react-router-dom"; +import { useMutation, useQuery } from "@apollo/client"; +import { MUTATION_TOGGLE_TASK_COMPLETED, MUTATION_TOGGLE_TASK_DELETED } from "../../graphql/tasks.queries.js"; +import { pageLimit } from "../../utils/config.js"; +import AlertComponent from "../alert/alert.component.jsx"; +import React from "react"; +import TaskListComponent from "./task-list.component.jsx"; +import { notification } from "antd"; +import { useTranslation } from "react-i18next"; +import { connect, useDispatch } from "react-redux"; +import { insertAuditTrail } from "../../redux/application/application.actions.js"; +import AuditTrailMapping from "../../utils/AuditTrailMappings.js"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; +import dayjs from "../../utils/day"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + currentUser: selectCurrentUser +}); + +const mapDispatchToProps = (dispatch) => ({}); + +export default connect(mapStateToProps, mapDispatchToProps)(TaskListContainer); + +export function TaskListContainer({ + bodyshop, + titleTranslation, + query, + relationshipType, + relationshipId, + currentUser, + onlyMine, + parentJobId, + showRo = true, + disableJobRefetch = false +}) { + const { t } = useTranslation(); + const searchParams = queryString.parse(useLocation().search); + const { page, sortcolumn, sortorder, deleted, completed, mine } = searchParams; + const dispatch = useDispatch(); + + const { loading, error, data, refetch } = useQuery(query[Object.keys(query)[0]], { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + variables: { + bodyshop: bodyshop.id, + [relationshipType]: relationshipId, + deleted: deleted === "true", + completed: completed === "true", + assigned_to: mine === "true" ? currentUser.email : undefined, // replace currentUserID with the actual ID of the current user + offset: page ? (page - 1) * pageLimit : 0, + limit: pageLimit, + order: [ + { + [sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc" + } + ] + } + }); + + /** + * Toggle task completed mutation + */ + const [toggleTaskCompleted] = useMutation(MUTATION_TOGGLE_TASK_COMPLETED); + + /** + * Toggle task completed status + * @param id + * @param currentStatus + * @returns {Promise} + */ + const toggleCompletedStatus = async (id, currentStatus) => { + const completed_at = !currentStatus ? dayjs().toISOString() : null; + + try { + const toggledTaskObject = { + variables: { + id: id, + completed: !currentStatus, + completed_at: completed_at + }, + refetchQueries: [Object.keys(query)[0]] + }; + + if (!disableJobRefetch) { + toggledTaskObject.refetchQueries.push("GET_JOB_BY_PK"); + } + + const toggledTask = await toggleTaskCompleted(toggledTaskObject); + + if (!toggledTask.errors) { + dispatch( + insertAuditTrail({ + jobid: toggledTask.data.update_tasks_by_pk.jobid, + operation: toggledTask?.data?.update_tasks_by_pk?.completed + ? AuditTrailMapping.tasksCompleted(toggledTask.data.update_tasks_by_pk.title, currentUser.email) + : AuditTrailMapping.tasksUncompleted(toggledTask.data.update_tasks_by_pk.title, currentUser.email), + type: toggledTask?.data?.update_tasks_by_pk?.completed ? "tasksCompleted" : "tasksUncompleted" + }) + ); + } + + notification["success"]({ + message: t("tasks.successes.completed") + }); + } catch (err) { + notification["error"]({ + message: t("tasks.failures.completed") + }); + } + }; + + /** + * Toggle task deleted mutation + */ + const [toggleTaskDeleted] = useMutation(MUTATION_TOGGLE_TASK_DELETED); + + /** + * Toggle task deleted status + * @param id + * @param currentStatus + * @returns {Promise} + */ + + const toggleDeletedStatus = async (id, currentStatus) => { + const deleted_at = !currentStatus ? dayjs().toISOString() : null; + try { + const toggledTaskObject = { + variables: { + id: id, + deleted: !currentStatus, + deleted_at: deleted_at + }, + refetchQueries: [Object.keys(query)[0]] + }; + + if (!disableJobRefetch) { + toggledTaskObject.refetchQueries.push("GET_JOB_BY_PK"); + } + + const toggledTask = await toggleTaskDeleted(toggledTaskObject); + + if (!toggledTask.errors) { + dispatch( + insertAuditTrail({ + jobid: toggledTask.data.update_tasks_by_pk.jobid, + operation: toggledTask?.data?.update_tasks_by_pk?.deleted + ? AuditTrailMapping.tasksDeleted(toggledTask.data.update_tasks_by_pk.title, currentUser.email) + : AuditTrailMapping.tasksUndeleted(toggledTask.data.update_tasks_by_pk.title, currentUser.email), + type: toggledTask?.data?.update_tasks_by_pk?.deleted ? "tasksDeleted" : "tasksUndeleted" + }) + ); + } + + notification["success"]({ + message: t("tasks.successes.deleted") + }); + } catch (err) { + notification["error"]({ + message: t("tasks.failures.deleted") + }); + } + }; + + if (error) return ; + + return ( + + ); +} diff --git a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx new file mode 100644 index 000000000..2c5eff0a7 --- /dev/null +++ b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx @@ -0,0 +1,301 @@ +import { Col, Form, Input, Row, Select, Switch } from "antd"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { FormDatePicker } from "../form-date-picker/form-date-picker.component.jsx"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; +import dayjs from "../../utils/day"; +import { connect } from "react-redux"; +import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx"; +import JobSearchSelectComponent from "../job-search-select/job-search-select.component.jsx"; +import { FormDateTimePickerEnhanced } from "../form-date-time-picker-enhanced/form-date-time-picker-enhanced.component.jsx"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + currentUser: selectCurrentUser +}); + +const mapDispatchToProps = () => ({}); + +export default connect(mapStateToProps, mapDispatchToProps)(TaskUpsertModalComponent); + +export function TaskUpsertModalComponent({ + form, + bodyshop, + currentUser, + selectedJobId, + setSelectedJobId, + selectedJobDetails, + existingTask, + loading, + error +}) { + const { t } = useTranslation(); + + const datePickerPresets = [ + { label: t("tasks.date_presets.today"), value: dayjs().add(1, "hour") }, + { label: t("tasks.date_presets.tomorrow"), value: dayjs().add(1, "day").startOf("day") }, + { label: t("tasks.date_presets.next_week"), value: dayjs().add(1, "week").startOf("day") }, + { label: t("tasks.date_presets.two_weeks"), value: dayjs().add(2, "weeks").startOf("day") }, + { label: t("tasks.date_presets.three_weeks"), value: dayjs().add(3, "weeks").startOf("day") }, + { label: t("tasks.date_presets.one_month"), value: dayjs().add(1, "month").startOf("day") }, + { label: t("tasks.date_presets.three_months"), value: dayjs().add(3, "month").startOf("day") } + ]; + + const generatePresets = (job) => { + if (!job || !selectedJobDetails) return datePickerPresets; // return default presets if no job selected + const relativePresets = []; + + if (selectedJobDetails?.scheduled_completion) { + const scheduledCompletion = dayjs(selectedJobDetails.scheduled_completion); + + if (scheduledCompletion.isAfter(dayjs())) { + relativePresets.push( + { + label: `${t("tasks.date_presets.completion")} -1 ${t("tasks.date_presets.day")}`, + value: scheduledCompletion.subtract(1, "day").startOf("day") + }, + { + label: `${t("tasks.date_presets.completion")} -2 ${t("tasks.date_presets.days")}`, + value: scheduledCompletion.subtract(2, "day").startOf("day") + }, + { + label: `${t("tasks.date_presets.completion")} -3 ${t("tasks.date_presets.days")}`, + value: scheduledCompletion.subtract(3, "day").startOf("day") + } + ); + } + } + + if (selectedJobDetails?.scheduled_delivery) { + const scheduledDelivery = dayjs(selectedJobDetails.scheduled_delivery); + if (scheduledDelivery.isAfter(dayjs())) { + relativePresets.push( + { + label: `${t("tasks.date_presets.delivery")} -1 ${t("tasks.date_presets.day")}`, + value: scheduledDelivery.subtract(1, "day").startOf("day") + }, + { + label: `${t("tasks.date_presets.delivery")} -2 ${t("tasks.date_presets.days")}`, + value: scheduledDelivery.subtract(2, "day").startOf("day") + }, + { + label: `${t("tasks.date_presets.delivery")} -3 ${t("tasks.date_presets.days")}`, + value: scheduledDelivery.subtract(3, "day").startOf("day") + } + ); + } + } + + return [...relativePresets, ...datePickerPresets]; + }; + + const clearRelations = () => { + form.setFieldsValue({ + billid: null, + partsorderid: null, + joblineid: null + }); + }; + + /** + * Change the selected job id + * @param jobId + */ + const changeJobId = (jobId) => { + setSelectedJobId(jobId || null); + // Reset the form fields when selectedJobId changes + clearRelations(); + }; + + if (loading || error) return ; + + return ( + <> + + + + + + + + + ({ + key: jobline.id, + value: jobline.id, + label: jobline.line_desc + }))} + /> + + + + + ({ + key: bill.id, + value: bill.id, + label: `${bill.invoice_number} - ${bill.vendor.name}` + }))} + /> + + + + + + employee?.user_email === currentUser.email && employee.active) + ? currentUser.email + : undefined + } + rules={[ + { + required: true + } + ]} + > +
+ + + + + +
+ + + + + + + + + +
+
${strings.header}
+
+

${strings.subHeader}

+
+
+ + + + + + + + +
+ + + + + + +
${strings.body}
+
+ + + + + + + + + + +` + + end + ); +}; + +module.exports = generateEmailTemplate; diff --git a/server/email/html.js b/server/email/html.js new file mode 100644 index 000000000..ec96c6ff9 --- /dev/null +++ b/server/email/html.js @@ -0,0 +1,2614 @@ +const header = ` + + + + + + +`; +const start = `
 
`; +const end = `
 
`; + +module.exports = { + header, + start, + end +}; diff --git a/server/email/sendemail.js b/server/email/sendemail.js index cface107f..85534b815 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -10,6 +10,9 @@ const InstanceManager = require("../utils/instanceMgr").default; const logger = require("../utils/logger"); const client = require("../graphql-client/graphql-client").client; const queries = require("../graphql-client/queries"); +const { isObject } = require("lodash"); +const generateEmailTemplate = require("./generateTemplate"); +const moment = require("moment"); const ses = new aws.SES({ // The key apiVersion is no longer supported in v3, and can be removed. @@ -21,12 +24,46 @@ const ses = new aws.SES({ rome: "us-east-2" }) }); - let transporter = nodemailer.createTransport({ SES: { ses, aws } }); -exports.sendServerEmail = async function ({ subject, text }) { +// Get the image from the URL and return it as a base64 string +const getImage = async (imageUrl) => { + let image = await axios.get(imageUrl, { responseType: "arraybuffer" }); + let raw = Buffer.from(image.data).toString("base64"); + return "data:" + image.headers["content-type"] + ";base64," + raw; +}; + +// Log the email in the database +const logEmail = async (req, email) => { + try { + const insertresult = await client.request(queries.INSERT_EMAIL_AUDIT, { + email: { + to: email.to, + cc: email.cc, + subject: email.subject, + bodyshopid: req.body.bodyshopid, + useremail: req.user.email, + contents: req.body.html, + jobid: req.body.jobid, + sesmessageid: email.messageId, + status: "Sent" + } + }); + console.log(insertresult); + } catch (error) { + logger.log("email-log-error", "error", req.user.email, null, { + from: `${req.body.from.name} <${req.body.from.address}>`, + to: req.body.to, + cc: req.body.cc, + subject: req.body.subject + // info, + }); + } +}; + +const sendServerEmail = async ({ subject, text }) => { if (process.env.NODE_ENV === undefined) return; try { transporter.sendMail( @@ -58,7 +95,8 @@ exports.sendServerEmail = async function ({ subject, text }) { logger.log("server-email-failure", "error", null, null, error); } }; -exports.sendTaskEmail = async function ({ to, subject, text, attachments }) { + +const sendTaskEmail = async ({ to, subject, text, attachments }) => { try { transporter.sendMail( { @@ -82,13 +120,15 @@ exports.sendTaskEmail = async function ({ to, subject, text, attachments }) { } }; -exports.sendEmail = async (req, res) => { +// Send an email +const sendEmail = async (req, res) => { logger.log("send-email", "DEBUG", req.user.email, null, { from: `${req.body.from.name} <${req.body.from.address}>`, replyTo: req.body.ReplyTo.Email, to: req.body.to, cc: req.body.cc, - subject: req.body.subject + subject: req.body.subject, + templateStrings: req.body.templateStrings }); let downloadedMedia = []; @@ -104,6 +144,7 @@ exports.sendEmail = async (req, res) => { to: req.body.to, cc: req.body.cc, subject: req.body.subject, + templateStrings: req.body.templateStrings, error }); } @@ -134,7 +175,7 @@ exports.sendEmail = async (req, res) => { }; }) ] || null, - html: req.body.html, + html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html, ses: { // optional extra arguments for SendRawEmail Tags: [ @@ -153,7 +194,8 @@ exports.sendEmail = async (req, res) => { replyTo: req.body.ReplyTo.Email, to: req.body.to, cc: req.body.cc, - subject: req.body.subject + subject: req.body.subject, + templateStrings: req.body.templateStrings // info, }); logEmail(req, { @@ -172,6 +214,7 @@ exports.sendEmail = async (req, res) => { to: req.body.to, cc: req.body.cc, subject: req.body.subject, + templateStrings: req.body.templateStrings, error: err }); logEmail(req, { @@ -186,40 +229,8 @@ exports.sendEmail = async (req, res) => { ); }; -async function getImage(imageUrl) { - let image = await axios.get(imageUrl, { responseType: "arraybuffer" }); - let raw = Buffer.from(image.data).toString("base64"); - return "data:" + image.headers["content-type"] + ";base64," + raw; -} - -async function logEmail(req, email) { - try { - const insertresult = await client.request(queries.INSERT_EMAIL_AUDIT, { - email: { - to: email.to, - cc: email.cc, - subject: email.subject, - bodyshopid: req.body.bodyshopid, - useremail: req.user.email, - contents: req.body.html, - jobid: req.body.jobid, - sesmessageid: email.messageId, - status: "Sent" - } - }); - console.log(insertresult); - } catch (error) { - logger.log("email-log-error", "error", req.user.email, null, { - from: `${req.body.from.name} <${req.body.from.address}>`, - to: req.body.to, - cc: req.body.cc, - subject: req.body.subject - // info, - }); - } -} - -exports.emailBounce = async function (req, res) { +// This will be called by an SNS event trigger +const emailBounce = async (req, res) => { try { const body = JSON.parse(req.body); if (body.Type === "SubscriptionConfirmation") { @@ -293,3 +304,10 @@ ${body.bounce?.bouncedRecipients.map( } res.sendStatus(200); }; + +module.exports = { + sendEmail, + sendServerEmail, + sendTaskEmail, + emailBounce +}; diff --git a/server/email/tasksEmails.js b/server/email/tasksEmails.js new file mode 100644 index 000000000..74b4e0487 --- /dev/null +++ b/server/email/tasksEmails.js @@ -0,0 +1,230 @@ +const path = require("path"); +require("dotenv").config({ + path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) +}); +let nodemailer = require("nodemailer"); +let aws = require("@aws-sdk/client-ses"); +let { defaultProvider } = require("@aws-sdk/credential-provider-node"); +const InstanceManager = require("../utils/instanceMgr").default; +const logger = require("../utils/logger"); +const client = require("../graphql-client/graphql-client").client; +const queries = require("../graphql-client/queries"); +const generateEmailTemplate = require("./generateTemplate"); +const moment = require("moment"); +const { UPDATE_TASKS_REMIND_AT_SENT } = require("../graphql-client/queries"); + +const ses = new aws.SES({ + apiVersion: "latest", + defaultProvider, + region: InstanceManager({ + imex: "ca-central-1", + rome: "us-east-2" + }) +}); + +const transporter = nodemailer.createTransport({ + SES: { ses, aws }, + sendingRate: 40 // 40 emails per second. +}); + +const fromEmails = InstanceManager({ + imex: "ImEX Online ", + rome: "Rome Online ", + promanager: "ProManager " +}); + +const endPoints = InstanceManager({ + imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online", + rome: process.env?.NODE_ENV === "test" ? "https//test.romeonline.io" : "https://romeonline.io", + promanager: process.env?.NODE_ENV === "test" ? "https//test.promanager.web-est.com" : "https://promanager.web-est.com" +}); + +/** + * Format the date for the email. + * @param date + * @returns {string|string} + */ +const formatDate = (date) => { + return date ? `| Due on ${moment(date).format("MM/DD/YYYY")}` : ""; +}; + +/** + * Generate the email template arguments. + * @param title + * @param createdBy + * @param dueDate + * @param taskId + * @returns {{header, body: string, subHeader: string}} + */ +const generateTemplateArgs = (title, createdBy, dueDate, taskId) => { + return { + header: title, + subHeader: `Assigned by ${createdBy} ${formatDate(dueDate)}`, + body: `Please sign in to your account to view the Task details.` + }; +}; + +/** + * Send the email. + * @param type + * @param to + * @param subject + * @param html + * @param taskIds + * @param successCallback + */ +const sendMail = (type, to, subject, html, taskIds, successCallback) => { + // Push next messages to Nodemailer + transporter.once("idle", () => { + if (transporter.isIdle()) { + transporter.sendMail( + { + from: fromEmails, + to, + subject, + html + }, + (error, info) => { + if (info) { + if (typeof successCallback === "function" && taskIds && taskIds.length) { + successCallback(taskIds); + } + } else { + logger.log(`task-${type}-email-failure`, "error", null, null, error); + } + } + ); + } + }); +}; + +/** + * Send an email to the assigned user. + * @param req + * @param res + * @returns {Promise<*>} + */ +const taskAssignedEmail = async (req, res) => { + // We have no event Data, bail + if (!req?.payload?.event?.data?.new) { + return res.status(400).json({ message: "No data in the event payload" }); + } + + const { new: newTask } = req.payload.event.data; + + // This is not a new task, but a reassignment. + const dirty = req.payload.event.data?.old && req.payload.event.data?.old?.assigned_to; + + sendMail( + "assigned", + newTask.assigned_to, + `A Task has been ${dirty ? "reassigned" : "created"} for you - ${newTask.title}`, + generateEmailTemplate(generateTemplateArgs(newTask.title, newTask.created_by, newTask.due_date, newTask.id)) + ); + + // We return success regardless because we don't want to block the event trigger. + res.status(200).json({ success: true }); +}; + +/** + * Send an email to remind the user of their tasks. + * @param req + * @param res + * @returns {Promise<*>} + */ +const tasksRemindEmail = async (req, res) => { + try { + const tasksRequest = await client.request(queries.QUERY_REMIND_TASKS, { + time: moment().add(1, "minutes").toISOString() + }); + + // No tasks present in the database, bail. + if (!tasksRequest?.tasks || !tasksRequest?.tasks.length) { + return res.status(200).json({ message: "No tasks to remind" }); + } + + // Group tasks by assigned_to, to avoid sending multiple emails to the same recipient. + const groupedTasks = tasksRequest.tasks.reduce((acc, task) => { + const key = task.assigned_to; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(task); + return acc; + }, {}); + + // No grouped tasks, bail. + if (Object.keys(groupedTasks).length === 0) { + return res.status(200).json({ message: "No tasks to remind" }); + } + + // Build an aggregate data object containing email and the count of tasks assigned to them. + const recipientCounts = Object.keys(groupedTasks).map((key) => { + return { + email: key, + count: groupedTasks[key].length + }; + }); + + // Iterate over all recipients and send the email. + recipientCounts.forEach((recipient) => { + const emailData = { + from: fromEmails, + to: recipient.email + }; + + const taskIds = groupedTasks[recipient.email].map((task) => task.id); + + // There is only the one email to send to this author. + if (recipient.count === 1) { + const onlyTask = groupedTasks[recipient.email][0]; + + emailData.subject = `New Task Reminder - ${onlyTask.title} - ${formatDate(onlyTask.due_date)}`; + + emailData.html = generateEmailTemplate( + generateTemplateArgs(onlyTask.title, onlyTask.created_by, onlyTask.due_date, onlyTask.id) + ); + } + // There are multiple emails to send to this author. + else { + const allTasks = groupedTasks[recipient.email]; + emailData.subject = `New Task Reminder - ${allTasks.length} Tasks require your attention`; + emailData.html = generateEmailTemplate({ + header: `${allTasks.length} Tasks require your attention`, + subHeader: `Please sign in to your account to view the Task details.`, + body: `` + }); + } + + if (emailData?.subject && emailData?.html) { + // Send Email + sendMail("remind", emailData.to, emailData.subject, emailData.html, taskIds, (taskIds) => { + client.request(UPDATE_TASKS_REMIND_AT_SENT, { + taskIds, + now: moment().toISOString() + }); + }); + } + }); + + // Sixth step would be to set the remind_at_sent to the current time. + res.status(200).json({ status: "success" }); + } catch (err) { + res.status(500).json({ + status: "error", + message: `Something went wrong sending Task Reminders: ${err.message || "An error occurred"}` + }); + } +}; + +module.exports = { + taskAssignedEmail, + tasksRemindEmail +}; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index f5cbdb3b1..1ffe8c858 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2393,3 +2393,37 @@ exports.COMPLETE_SURVEY = `mutation COMPLETE_SURVEY($surveyId: uuid!, $survey: c affected_rows } }`; + +exports.QUERY_REMIND_TASKS = ` + query QUERY_REMIND_TASKS($time: timestamptz!) { + tasks( + where: { + _and: [ + { remind_at: { _is_null: false } } + { remind_at: { _lte: $time } } + { remind_at_sent: { _is_null: true } } + ] + } + ) { + id + title + due_date + created_by + assigned_to + remind_at + remind_at_sent + priority + job { + id + ro_number + } + jobid + } + } +`; + +exports.UPDATE_TASKS_REMIND_AT_SENT = `mutation UPDATE_TASK_REMIND_AT_SENT($taskIds: [uuid!]!, $now: timestamptz!) { + update_tasks_many(updates: {where: {id: {_in: $taskIds}}, _set: {remind_at_sent: $now}}) { + affected_rows + } +}`; diff --git a/server/routes/miscellaneousRoutes.js b/server/routes/miscellaneousRoutes.js index 9cf24ba99..b2dd80b26 100644 --- a/server/routes/miscellaneousRoutes.js +++ b/server/routes/miscellaneousRoutes.js @@ -10,6 +10,7 @@ const os = require("../opensearch/os-handler"); const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware"); +const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails"); //Test route to ensure Express is responding. router.get("/test", async function (req, res) { @@ -40,6 +41,10 @@ router.post("/ioevent", ioevent.default); router.post("/sendemail", validateFirebaseIdTokenMiddleware, sendEmail.sendEmail); router.post("/emailbounce", bodyParser.text(), sendEmail.emailBounce); +// Tasks Email Handler +router.post("/tasks-assigned-handler", eventAuthorizationMiddleware, taskAssignedEmail); +router.post("/tasks-remind-handler", eventAuthorizationMiddleware, tasksRemindEmail); + // Handlers router.post("/record-handler/arms", data.arms); router.post("/taskHandler", validateFirebaseIdTokenMiddleware, taskHandler.taskHandler);