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 ad764f1e1..b7ac48060 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 @@ -19,11 +19,9 @@ import {FaTasks} from "react-icons/fa"; const mapStateToProps = createStructuredSelector({ jobRO: selectJobReadOnly, bodyshop: selectBodyshop, - currentUser: selectCurrentUser }); 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, @@ -33,17 +31,15 @@ const mapDispatchToProps = (dispatch) => ({ }); export function BillsListTableComponent({ - bodyshop, - jobRO, - job, - billsQuery, - handleOnRowClick, - setPartsOrderContext, - setBillEnterContext, + bodyshop, + jobRO, + job, + billsQuery, + handleOnRowClick, + setBillEnterContext, setReconciliationContext, setTaskUpsertContext, - currentUser, -}) { + }) { const { t } = useTranslation(); const [state, setState] = useState({ 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 index 04f3dbafc..642e18299 100644 --- a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx +++ b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx @@ -123,7 +123,7 @@ export function TaskUpsertModalComponent({ ]} > + onSelect={changeJobId} onClear={changeJobId} autoFocus={false}/> diff --git a/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx b/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx index ad3c6444c..cb7f03cf3 100644 --- a/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx +++ b/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx @@ -16,6 +16,9 @@ import {selectBodyshop, selectCurrentUser} from "../../redux/user/user.selectors import TaskUpsertModalComponent from "./task-upsert-modal.component"; import {replaceUndefinedWithNull} from "../../utils/undefinedtonull.js"; import {useNavigate} from "react-router-dom"; +import axios from "axios"; +import dayjs from '../../utils/day'; +import {insertAuditTrail} from "../../redux/application/application.actions.js"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, @@ -24,6 +27,7 @@ const mapStateToProps = createStructuredSelector({ }); const mapDispatchToProps = (dispatch) => ({ toggleModalVisible: () => dispatch(toggleModalVisible("taskUpsert")), + insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) }); export function TaskUpsertModalContainer({ @@ -31,6 +35,7 @@ export function TaskUpsertModalContainer({ currentUser, taskUpsert, toggleModalVisible, + insertAuditTrail }) { const {t} = useTranslation(); const history = useNavigate(); @@ -62,7 +67,7 @@ export function TaskUpsertModalContainer({ skip: !taskId, }); - // This takes care of the ability to deep link a task from the URL (Fills in form fields) + // Use Effect to hydrate existing task if only a taskid is provided useEffect(() => { if (!taskLoading && !taskError && taskData && taskData?.tasks_by_pk) { form.setFieldsValue(taskData.tasks_by_pk); @@ -70,22 +75,25 @@ export function TaskUpsertModalContainer({ } }, [taskLoading, taskError, taskData]); - /** - * Set the selected job id when the modal is opened and jobId is passed as a prop or when an existing task is passed as a prop - */ + // Use Effect to hydrate selected job details + useEffect(() => { + if (!loading && !error && data) { + setSelectedJobDetails(data.jobs_by_pk); + } + }, [loading, error, data]); + + // Use Effect to toggle to set jobid state + useEffect(() => { + if (selectedJobId) { + setJobIdState(selectedJobId); + } + }, [selectedJobId]); + + // Use Effect to hydrate form fields useEffect(() => { if (jobid || existingTask?.id) { setSelectedJobId(jobid || existingTask.jobid); } - return () => { - setSelectedJobId(null); - }; - }, [jobid, existingTask]); - - /** - * Set the form values when the modal is opened and an existing task is passed as a prop - */ - useEffect(() => { if (existingTask && open) { form.setFieldsValue(existingTask); } else if (!existingTask && open) { @@ -94,34 +102,131 @@ export function TaskUpsertModalContainer({ if (billid) form.setFieldsValue({billid}); if (partsorderid) form.setFieldsValue({partsorderid}); } - }, [existingTask, form, open, joblineid, billid, partsorderid]); + return () => { + setSelectedJobId(null); + }; + }, [jobid, existingTask, form, open, joblineid, billid, partsorderid]); + /** - * Set the job id state when the selected job id changes + * Remove the taskid from the URL */ - useEffect(() => { - if (selectedJobId) { - // Update the state variable instead of calling useQuery - setJobIdState(selectedJobId); - } - }, [selectedJobId]); - - /** - * Set the selected job details when the job details query is successful - */ - useEffect(() => { - if (!loading && !error && data) { - setSelectedJobDetails(data.jobs_by_pk); - } - }, [loading, error, data]); - - const removeTaskIdFromUrl = () => { const urlParams = new URLSearchParams(window.location.search); + if (!urlParams.has('taskid')) return; urlParams.delete('taskid'); history(`${window.location.pathname}?${urlParams}`); } - + + /** + * Handle existing task + * @param values + * @returns {Promise} + */ + const handleExistingTask = async (values) => { + const isAssignedToDirty = values.assigned_to !== existingTask.assigned_to; + + await updateTask({ + variables: { + taskId: existingTask.id, + task: replaceUndefinedWithNull(values) + }, + }); + + if (isAssignedToDirty) { + axios.post("/sendemail", { + from: { + name: bodyshop.shopname, + address: bodyshop.email, + }, + ReplyTo: { + Email: 'noreply@imex.online' + }, + to: values.assigned_to, + subject: `A Task has been re-assigned to you on ${bodyshop.shopname} - ${values.title}`, + templateStrings: { + header: values.title, + subHeader: `Assigned by ${currentUser.email} ${values.due_at ? `| Due on ${dayjs(values.due_at).format('MM/DD/YYYY')}` : ''}`, + body: `Please sing in to your account to view the task details.` + } + }).catch(e => console.error(`Something went wrong sending email to Assigned party on Task creation. ${e.message || ''}`)); + } + + window.dispatchEvent(new CustomEvent('taskUpdated', { + detail: {message: 'A task has been created or edited.'}, + })); + + notification["success"]({ + message: t("tasks.successes.updated"), + }); + + if (refetch) await refetch(); + + toggleModalVisible(); + }; + + const handleNewTask = async (values) => { + const newTaskID = (await insertTask({ + variables: { + taskInput: [ + { + ...values, + created_by: currentUser.email, + bodyshopid: bodyshop.id + }, + ], + }, + // TODO: Consult Patrick, because this fails on relationship data, and an event emitter is just much easier to use + // update(cache) { + // cache.modify({ + // fields: { + // tasks(existingTasks) { + // return [{ + // ...values, + // jobid: selectedJobId || values.jobid, + // created_by: currentUser.email, + // bodyshopid: bodyshop.id + // }, ...existingTasks] + // }, + // }, + // }); + // }, + })).data.insert_tasks.returning[0].id; + + if (refetch) await refetch(); + + form.resetFields(); + + toggleModalVisible(); + + // send notification to the assigned user + axios.post("/sendemail", { + from: { + name: bodyshop.shopname, + address: bodyshop.email, + }, + replyTo: { + Email: 'noreply@imex.online' + }, + to: values.assigned_to, + subject: `A new Task has been assigned to you on ${bodyshop.shopname} - ${values.title}`, + templateName: 'taskAssigned', + templateStrings: { + header: values.title, + subHeader: `Assigned by ${currentUser.email} ${values.due_at ? `| Due on ${dayjs(values.due_at).format('MM/DD/YYYY')}` : ''}`, + body: `Please sing in to your account to view the task details.` + } + }).catch(e => console.error(`Something went wrong sending email to Assigned party on Task edit. ${e.message || ''}`)); + + window.dispatchEvent(new CustomEvent('taskUpdated', { + detail: {message: 'A task has been created or edited.'}, + })); + + notification["success"]({ + message: t("tasks.successes.created"), + }); + }; + /** * Handle the form submit * @param formValues @@ -130,58 +235,9 @@ export function TaskUpsertModalContainer({ const handleFinish = async (formValues) => { const {...values} = formValues; if (existingTask) { - await updateTask({ - variables: { - taskId: existingTask.id, - task: replaceUndefinedWithNull(values) - }, - }); - window.dispatchEvent(new CustomEvent('taskUpdated', { - detail: {message: 'A task has been created or edited.'}, - })); - notification["success"]({ - message: t("tasks.successes.updated"), - }); - if (refetch) await refetch(); - toggleModalVisible(); + await handleExistingTask(values); } else { - await insertTask({ - variables: { - taskInput: [ - { - ...values, - created_by: currentUser.email, - bodyshopid: bodyshop.id - }, - ], - }, - - // TODO: Consult Patrick, because this fails on relationship data, and an event emitter is just much easier to use - // update(cache) { - // cache.modify({ - // fields: { - // tasks(existingTasks) { - // return [{ - // ...values, - // jobid: selectedJobId || values.jobid, - // created_by: currentUser.email, - // bodyshopid: bodyshop.id - // }, ...existingTasks] - // }, - // }, - // }); - // }, - - }); - if (refetch) await refetch(); - form.resetFields(); - toggleModalVisible(); - window.dispatchEvent(new CustomEvent('taskUpdated', { - detail: {message: 'A task has been created or edited.'}, - })); - notification["success"]({ - message: t("tasks.successes.created"), - }); + await handleNewTask(values); } }; @@ -199,7 +255,6 @@ export function TaskUpsertModalContainer({ removeTaskIdFromUrl(); toggleModalVisible(); }} - destroyOnClose >
diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index 319762891..d9b310470 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -155,7 +155,6 @@ export function Manage({ conflict, bodyshop, enableJoyRide, joyRideSteps, setJoy })} /> } - This > diff --git a/server/email/generateTemplate.js b/server/email/generateTemplate.js new file mode 100644 index 000000000..eac8a4a55 --- /dev/null +++ b/server/email/generateTemplate.js @@ -0,0 +1,2668 @@ +const moment = require("moment"); + +// Required Strings +// - header - The header of the email +// - subHeader - The subheader of the email +// - body - The body of the email + +// Optional Strings (Have default values) +// - footer - The footer of the email +// - dateLine - The date line of the email + + const generateEmailTemplate = (strings) => { + + let now = () =>moment().format('MM/DD/YYYY @ hh:mm a'); + + return ` + + + + + + + + + + + +
+
+ + +
 
+ + + +
+ + +
${strings.header}

${strings.subHeader}

+ + + + +
+ +
${strings.body}
+ + + + + + + + + + +
 
+
+
+ +` +} + +module.exports = generateEmailTemplate; diff --git a/server/email/sendemail.js b/server/email/sendemail.js index cface107f..a976cee7d 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -10,6 +10,8 @@ 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 ses = new aws.SES({ // The key apiVersion is no longer supported in v3, and can be removed. @@ -88,7 +90,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 }); let downloadedMedia = []; @@ -104,6 +107,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 +138,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 +157,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 +177,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, {