Merged in feature/IO-2667 -Tasks (pull request #1389)
Feature/IO-2667 Tasks Approved-by: Patrick Fic
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
],
|
||||
"rules": {
|
||||
"no-useless-rename": "off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
<Space wrap>
|
||||
{showView && (
|
||||
@@ -55,6 +69,19 @@ export function BillsListTableComponent({
|
||||
<EditFilled />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
title={t("tasks.buttons.create")}
|
||||
onClick={() => {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
jobid: job.id,
|
||||
billid: record.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaTasks />
|
||||
</Button>
|
||||
<BillDeleteButton bill={record} jobid={job.id} />
|
||||
<BillDetailEditReturnComponent
|
||||
data={{ bills_by_pk: { ...record, jobid: job.id, job: job } }}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DatePicker } from "antd";
|
||||
import dayjs from "../../utils/day.js";
|
||||
import React, { useRef } from "react";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//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 (
|
||||
<div>
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
value={value ? dayjs(value) : null}
|
||||
format={dateFormat}
|
||||
onBlur={onBlur}
|
||||
showToday={false}
|
||||
disabledDate={(d) => {
|
||||
if (onlyToday) {
|
||||
return !dayjs().isSame(d, "day");
|
||||
} else if (onlyFuture) {
|
||||
return dayjs().subtract(1, "day").isAfter(d);
|
||||
}
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div id={id}>
|
||||
<Space direction="vertical" style={{ width: "100%" }} id={id}>
|
||||
<FormDatePicker
|
||||
{...restProps}
|
||||
{...(onlyFuture && {
|
||||
@@ -39,7 +39,7 @@ const DateTimePicker = ({ value, onChange, onBlur, id, onlyFuture, ...restProps
|
||||
format="hh:mm a"
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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: <FaTasks />,
|
||||
label: t("menus.header.tasks"),
|
||||
children: [
|
||||
{
|
||||
key: "createTask",
|
||||
icon: <PlusCircleOutlined />,
|
||||
label: t("menus.header.create_task"),
|
||||
onClick: () => {
|
||||
setTaskUpsertContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "mytasks",
|
||||
icon: <FaTasks />,
|
||||
label: <Link to="/manage/tasks/mytasks">{t("menus.header.my_tasks")}</Link>
|
||||
},
|
||||
{
|
||||
key: "all_tasks",
|
||||
icon: <FaTasks />,
|
||||
label: <Link to="/manage/tasks/alltasks">{t("menus.header.all_tasks")}</Link>
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "shopsubmenu",
|
||||
icon: <SettingOutlined />,
|
||||
|
||||
@@ -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 }) {
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={24} lg={24}>
|
||||
<TaskListContainer
|
||||
parentJobId={jobid}
|
||||
relationshipType={"joblineid"}
|
||||
relationshipId={jobline.id}
|
||||
query={{ QUERY_JOBLINE_TASKS_PAGINATED }}
|
||||
titleTranslation="tasks.titles.job_tasks"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<EditFilled />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title={t("tasks.buttons.create")}
|
||||
onClick={() => {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
jobid: job.id,
|
||||
joblineid: record.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaTasks />
|
||||
</Button>
|
||||
{(record.manual_line || jobIsPrivate) && (
|
||||
<>
|
||||
<Button
|
||||
disabled={jobRO}
|
||||
onClick={async () => {
|
||||
@@ -452,7 +473,7 @@ export function JobLinesComponent({
|
||||
vendorid: bodyshop.inhousevendorid,
|
||||
invoice_number: "ih",
|
||||
isinhouse: true,
|
||||
date: new dayjs(),
|
||||
date: dayjs(),
|
||||
total: 0,
|
||||
billlines: selectedLines.map((p) => {
|
||||
return {
|
||||
|
||||
@@ -154,7 +154,7 @@ export function JobLineConvertToLabor({ children, jobline, job, insertAuditTrail
|
||||
setLoading(true);
|
||||
|
||||
form.setFieldsValue({
|
||||
// date: new dayjs(),
|
||||
// date: dayjs(),
|
||||
// bodyhrs: Math.round(v.bodyhrs * 10) / 10,
|
||||
// painthrs: Math.round(v.painthrs * 10) / 10,
|
||||
});
|
||||
|
||||
@@ -18,14 +18,9 @@ const JobSearchSelect = (
|
||||
const [theOptions, setTheOptions] = useState([]);
|
||||
const [callSearch, { loading, error, data }] = useLazyQuery(SEARCH_JOBS_FOR_AUTOCOMPLETE, {});
|
||||
|
||||
const [
|
||||
callIdSearch,
|
||||
{
|
||||
//loading: idLoading,
|
||||
error: idError,
|
||||
data: idData
|
||||
}
|
||||
] = useLazyQuery(SEARCH_JOBS_BY_ID_FOR_AUTOCOMPLETE);
|
||||
const [callIdSearch, { loading: idLoading, error: idError, data: idData }] = useLazyQuery(
|
||||
SEARCH_JOBS_BY_ID_FOR_AUTOCOMPLETE
|
||||
);
|
||||
|
||||
const executeSearch = (v) => {
|
||||
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch(v);
|
||||
@@ -79,7 +74,7 @@ const JobSearchSelect = (
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
//loading={loading || idLoading}
|
||||
suffixIcon={loading && <Spin />}
|
||||
suffixIcon={(loading || idLoading) && <Spin />}
|
||||
notFoundContent={loading ? <LoadingOutlined /> : null}
|
||||
{...restProps}
|
||||
>
|
||||
|
||||
@@ -165,6 +165,8 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
|
||||
bold: true
|
||||
}
|
||||
];
|
||||
// TODO: was removed by Patrick during a CI bug fix.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [job.job_totals, job.cieca_pft, t, bodyshop.md_responsibility_centers]);
|
||||
|
||||
const columns = [
|
||||
|
||||
@@ -39,13 +39,50 @@ const mapStateToProps = createStructuredSelector({
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })),
|
||||
setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })),
|
||||
setBillEnterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "billEnter"
|
||||
})
|
||||
),
|
||||
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
|
||||
setJobCostingContext: (context) => dispatch(setModalContext({ context: context, modal: "jobCosting" })),
|
||||
setTimeTicketContext: (context) => dispatch(setModalContext({ context: context, modal: "timeTicket" })),
|
||||
setCardPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "cardPayment" })),
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })),
|
||||
setTimeTicketTaskContext: (context) => dispatch(setModalContext({ context: context, modal: "timeTicketTask" })),
|
||||
setJobCostingContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "jobCosting"
|
||||
})
|
||||
),
|
||||
setTimeTicketContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "timeTicket"
|
||||
})
|
||||
),
|
||||
setCardPaymentContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "cardPayment"
|
||||
})
|
||||
),
|
||||
insertAuditTrail: ({ jobid, operation, type }) =>
|
||||
dispatch(
|
||||
insertAuditTrail({
|
||||
jobid,
|
||||
operation,
|
||||
type
|
||||
})
|
||||
),
|
||||
setTimeTicketTaskContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "timeTicketTask"
|
||||
})
|
||||
),
|
||||
setEmailOptions: (e) => dispatch(setEmailOptions(e)),
|
||||
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
|
||||
setMessage: (text) => dispatch(setMessage(text))
|
||||
@@ -744,7 +781,7 @@ export function JobsDetailHeaderActions({
|
||||
menuItems.push(
|
||||
job.inproduction
|
||||
? {
|
||||
key: "addtoproduction",
|
||||
key: "removefromproduction",
|
||||
disabled: !job.converted,
|
||||
label: t("jobs.actions.removefromproduction"),
|
||||
onClick: () => AddToProduction(client, job.id, refetch, true)
|
||||
|
||||
@@ -96,7 +96,6 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
|
||||
}),
|
||||
onFilter: (value, record) => value.includes(record.status)
|
||||
},
|
||||
|
||||
{
|
||||
title: t("jobs.fields.vehicle"),
|
||||
dataIndex: "vehicle",
|
||||
|
||||
@@ -176,7 +176,6 @@ export function JobsList({ bodyshop, setJoyRideSteps }) {
|
||||
[],
|
||||
onFilter: (value, record) => value.includes(record.status)
|
||||
},
|
||||
|
||||
{
|
||||
title: t("jobs.fields.vehicle"),
|
||||
dataIndex: "vehicle",
|
||||
|
||||
@@ -26,6 +26,7 @@ import PartsOrderLineBackorderButton from "../parts-order-line-backorder-button/
|
||||
import PartsReceiveModalContainer from "../parts-receive-modal/parts-receive-modal.container";
|
||||
import PrintWrapper from "../print-wrapper/print-wrapper.component";
|
||||
import FeatureWrapperComponent from "../feature-wrapper/feature-wrapper.component";
|
||||
import { FaTasks } from "react-icons/fa";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
jobRO: selectJobReadOnly,
|
||||
@@ -33,8 +34,21 @@ const mapStateToProps = createStructuredSelector({
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })),
|
||||
setPartsReceiveContext: (context) => dispatch(setModalContext({ context: context, modal: "partsReceive" }))
|
||||
setBillEnterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "billEnter"
|
||||
})
|
||||
),
|
||||
setPartsReceiveContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "partsReceive"
|
||||
})
|
||||
),
|
||||
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
|
||||
});
|
||||
|
||||
export function PartsOrderListTableComponent({
|
||||
@@ -44,7 +58,8 @@ export function PartsOrderListTableComponent({
|
||||
job,
|
||||
billsQuery,
|
||||
handleOnRowClick,
|
||||
setPartsReceiveContext
|
||||
setPartsReceiveContext,
|
||||
setTaskUpsertContext
|
||||
}) {
|
||||
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
|
||||
.filter((screen) => !!screen[1])
|
||||
@@ -76,7 +91,7 @@ export function PartsOrderListTableComponent({
|
||||
const { refetch } = billsQuery;
|
||||
|
||||
const recordActions = (record, showView = false) => (
|
||||
<Space wrap>
|
||||
<Space direction="horizontal" wrap>
|
||||
{showView && (
|
||||
<Button onClick={() => handleOnRowClick(record)}>
|
||||
<EyeFilled />
|
||||
@@ -108,7 +123,19 @@ export function PartsOrderListTableComponent({
|
||||
>
|
||||
{t("parts_orders.actions.receive")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title={t("tasks.buttons.create")}
|
||||
onClick={() => {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
jobid: job.id,
|
||||
partsorderid: record.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaTasks />
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t("parts_orders.labels.confirmdelete")}
|
||||
disabled={jobRO}
|
||||
|
||||
@@ -158,7 +158,7 @@ export function PartsOrderModalContainer({
|
||||
vendorid: bodyshop.inhousevendorid,
|
||||
invoice_number: "ih",
|
||||
isinhouse: true,
|
||||
date: new dayjs(),
|
||||
date: dayjs(),
|
||||
total: 0,
|
||||
billlines: values.parts_order_lines.data.map((p) => {
|
||||
return {
|
||||
|
||||
382
client/src/components/task-list/task-list.component.jsx
Normal file
382
client/src/components/task-list/task-list.component.jsx
Normal file
@@ -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 (
|
||||
<div title={relativeDueDate} style={isBeforeToday ? { color: "red" } : {}}>
|
||||
<DateFormatter>{dueDate}</DateFormatter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RemindAtRecord = ({ remindAt }) => {
|
||||
if (!remindAt) return <></>;
|
||||
|
||||
const remindAtDayjs = dayjs(remindAt);
|
||||
const relativeRemindAtDate = remindAtDayjs.fromNow();
|
||||
const isBeforeToday = remindAtDayjs.isBefore(dayjs());
|
||||
|
||||
return (
|
||||
<div title={relativeRemindAtDate} style={isBeforeToday ? { color: "red" } : {}}>
|
||||
<DateTimeFormatter>{remindAt}</DateTimeFormatter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Priority Label Component
|
||||
* @param priority
|
||||
* @returns {Element}
|
||||
* @constructor
|
||||
*/
|
||||
const PriorityLabel = ({ priority }) => {
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return (
|
||||
<div>
|
||||
High <ExclamationCircleFilled style={{ marginLeft: "5px", color: "red" }} />
|
||||
</div>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<div>
|
||||
Medium <ExclamationCircleFilled style={{ marginLeft: "5px", color: "yellow" }} />
|
||||
</div>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<div>
|
||||
Low <ExclamationCircleFilled style={{ marginLeft: "5px", color: "green" }} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
None <ExclamationCircleFilled style={{ marginLeft: "5px" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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) => <DateTimeFormatter>{record.created_at}</DateTimeFormatter>
|
||||
});
|
||||
|
||||
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 ? (
|
||||
<Link to={`/manage/jobs/${record.job.id}?tab=tasks`}>{record.job.ro_number || t("general.labels.na")}</Link>
|
||||
) : (
|
||||
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 ? (
|
||||
<Link to={`/manage/jobs/${record.job.id}?partsorderid=${record.parts_order.id}&tab=partssublet`}>
|
||||
{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")}
|
||||
</Link>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("tasks.fields.bill"),
|
||||
dataIndex: ["bill", "id"],
|
||||
key: "bill.id",
|
||||
width: "10%",
|
||||
render: (text, record) =>
|
||||
record.bill ? (
|
||||
<Link to={`/manage/jobs/${record.job.id}?billid=${record.bill.id}&tab=partssublet`}>
|
||||
{record.bill.invoice_number && record.bill.vendor && record.bill.vendor.name
|
||||
? `${record.bill.invoice_number} - ${record.bill.vendor.name}`
|
||||
: t("general.labels.na")}
|
||||
</Link>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
},
|
||||
{
|
||||
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) => <DueDateRecord dueDate={record.due_date} />
|
||||
},
|
||||
{
|
||||
title: t("tasks.fields.remind_at"),
|
||||
dataIndex: "remind_at",
|
||||
key: "remind_at",
|
||||
sorter: true,
|
||||
sortOrder: sortcolumn === "remind_at" && sortorder,
|
||||
width: "10%",
|
||||
render: (text, record) => <RemindAtRecord remindAt={record.remind_at} />
|
||||
},
|
||||
{
|
||||
title: t("tasks.fields.priority"),
|
||||
dataIndex: "priority",
|
||||
key: "priority",
|
||||
sorter: true,
|
||||
sortOrder: sortcolumn === "priority" && sortorder,
|
||||
width: "8%",
|
||||
render: (text, record) => <PriorityLabel priority={record.priority} />
|
||||
},
|
||||
{
|
||||
title: t("tasks.fields.actions"),
|
||||
key: "toggleCompleted",
|
||||
width: "5%",
|
||||
render: (text, record) => (
|
||||
<Space direction="horizontal">
|
||||
<Button
|
||||
title={t("tasks.buttons.edit")}
|
||||
onClick={() => {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
existingTask: record,
|
||||
query
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EditFilled />
|
||||
</Button>
|
||||
<Button
|
||||
title={t("tasks.buttons.complete")}
|
||||
onClick={() => toggleCompletedStatus(record.id, record.completed)}
|
||||
>
|
||||
{record.completed ? <CheckCircleOutlined /> : <CheckCircleFilled />}
|
||||
</Button>
|
||||
<Button title={t("tasks.buttons.delete")} onClick={() => toggleDeletedStatus(record.id, record.deleted)}>
|
||||
{record.deleted ? <DeleteOutlined /> : <DeleteFilled />}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
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 (
|
||||
<Card title={t("tasks.fields.description")} size="small">
|
||||
{record.description}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra actions for the tasks
|
||||
* @type {Function}
|
||||
*/
|
||||
const tasksExtra = useCallback(() => {
|
||||
return (
|
||||
<Space direction="horizontal">
|
||||
{!onlyMine && (
|
||||
<Switch
|
||||
checkedChildren={t("tasks.buttons.myTasks")}
|
||||
unCheckedChildren={t("tasks.buttons.allTasks")}
|
||||
title={t("tasks.titles.mine")}
|
||||
checked={mine === "true"}
|
||||
onChange={(value) => handleSwitchChange("mine", value)}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
checkedChildren={<CheckCircleFilled />}
|
||||
unCheckedChildren={<CheckCircleOutlined />}
|
||||
title={t("tasks.titles.completed")}
|
||||
checked={completed === "true"}
|
||||
onChange={(value) => handleSwitchChange("completed", value)}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren={<DeleteFilled />}
|
||||
unCheckedChildren={<DeleteOutlined />}
|
||||
title={t("tasks.titles.deleted")}
|
||||
checked={deleted === "true"}
|
||||
onChange={(value) => handleSwitchChange("deleted", value)}
|
||||
/>
|
||||
<Button title={t("tasks.buttons.refresh")} onClick={() => refetch()}>
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
<Button title={t("tasks.buttons.create")} onClick={handleCreateTask}>
|
||||
<PlusCircleFilled />
|
||||
{t("tasks.buttons.create")}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}, [refetch, deleted, completed, mine, onlyMine, t, handleSwitchChange, handleCreateTask]);
|
||||
|
||||
return (
|
||||
<Card title={titleTranslation} extra={tasksExtra()}>
|
||||
<Table
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: pageLimit,
|
||||
current: parseInt(page || 1),
|
||||
total: total,
|
||||
responsive: true,
|
||||
showQuickJumper: true
|
||||
}}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
scroll={{ x: true }}
|
||||
dataSource={tasks}
|
||||
onChange={handleTableChange}
|
||||
expandable={{
|
||||
expandedRowRender: expandableRow,
|
||||
rowExpandable: (record) => record.description
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
187
client/src/components/task-list/task-list.container.jsx
Normal file
187
client/src/components/task-list/task-list.container.jsx
Normal file
@@ -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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
|
||||
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 <AlertComponent message={error.message} type="error" />;
|
||||
|
||||
return (
|
||||
<TaskListComponent
|
||||
loading={loading}
|
||||
tasks={data ? data.tasks : null}
|
||||
total={data ? data.tasks_aggregate.aggregate.count : 0}
|
||||
titleTranslation={t(titleTranslation || "tasks.title")}
|
||||
refetch={refetch}
|
||||
toggleCompletedStatus={toggleCompletedStatus}
|
||||
toggleDeletedStatus={toggleDeletedStatus}
|
||||
relationshipType={relationshipType}
|
||||
relationshipId={relationshipId}
|
||||
onlyMine={onlyMine}
|
||||
showRo={showRo}
|
||||
parentJobId={parentJobId}
|
||||
bodyshop={bodyshop}
|
||||
query={query}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <LoadingSkeleton active />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={16}>
|
||||
<Form.Item
|
||||
label={t("tasks.fields.title")}
|
||||
name="title"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t("tasks.fields.title")} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label={t("tasks.fields.priority")} name="priority" initialValue={2}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 3, label: t("tasks.fields.priorities.low") },
|
||||
{ value: 2, label: t("tasks.fields.priorities.medium") },
|
||||
{ value: 1, label: t("tasks.fields.priorities.high") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item
|
||||
label={t("tasks.fields.completed")}
|
||||
name="completed"
|
||||
valuePropName="checked"
|
||||
initialValue={false}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="jobid"
|
||||
initialValue={selectedJobId}
|
||||
label={t("tasks.fields.jobid")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<JobSearchSelectComponent
|
||||
placeholder={t("tasks.placeholders.jobid")}
|
||||
onSelect={changeJobId}
|
||||
onClear={changeJobId}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
<Form.Item label={t("tasks.fields.joblineid")} name="joblineid">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t("tasks.placeholders.joblineid")}
|
||||
disabled={!selectedJobDetails || !selectedJobId}
|
||||
options={selectedJobDetails?.joblines?.map((jobline) => ({
|
||||
key: jobline.id,
|
||||
value: jobline.id,
|
||||
label: jobline.line_desc
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label={t("tasks.fields.partsorderid")} name="partsorderid">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t("tasks.placeholders.partsorderid")}
|
||||
disabled={!selectedJobDetails || !selectedJobId}
|
||||
options={selectedJobDetails?.parts_orders?.map((partsOrder) => ({
|
||||
key: partsOrder.id,
|
||||
value: partsOrder.id,
|
||||
label: `${partsOrder.order_number} - ${partsOrder.vendor.name}`
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label={t("tasks.fields.billid")} name="billid">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t("tasks.placeholders.billid")}
|
||||
disabled={!selectedJobDetails || !selectedJobId}
|
||||
options={selectedJobDetails?.bills?.map((bill) => ({
|
||||
key: bill.id,
|
||||
value: bill.id,
|
||||
label: `${bill.invoice_number} - ${bill.vendor.name}`
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
label={t("tasks.fields.assigned_to")}
|
||||
name="assigned_to"
|
||||
initialValue={
|
||||
bodyshop.employees.find((employee) => employee?.user_email === currentUser.email && employee.active)
|
||||
? currentUser.email
|
||||
: undefined
|
||||
}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder={t("tasks.placeholders.assigned_to")}
|
||||
options={bodyshop.employees
|
||||
.filter((x) => x.active && x.user_email)
|
||||
.map((employee) => ({
|
||||
key: employee.id,
|
||||
value: employee.user_email,
|
||||
label: `${employee.first_name} ${employee.last_name}`
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label={t("tasks.fields.due_date")} name="due_date">
|
||||
<FormDatePicker
|
||||
onlyFuture
|
||||
format="MM/DD/YYYY"
|
||||
presets={generatePresets(selectedJobDetails)}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value || existingTask?.due_date === value || dayjs(value).isAfter(dayjs())) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t("tasks.validation.due_at_error_message")));
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
label={t("tasks.fields.remind_at")}
|
||||
name="remind_at"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value || existingTask?.remind_at === value || dayjs(value).isAfter(dayjs().add(30, "minute"))) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t("tasks.validation.remind_at_error_message")));
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerEnhanced
|
||||
onlyFuture
|
||||
showTime
|
||||
minuteStep={15}
|
||||
presets={generatePresets(selectedJobDetails)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Form.Item label={t("tasks.fields.description")} name="description">
|
||||
<Input.TextArea rows={8} placeholder={t("tasks.fields.description")} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { Form, Modal, notification } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { MUTATION_INSERT_NEW_TASK, MUTATION_UPDATE_TASK, QUERY_GET_TASK_BY_ID } from "../../graphql/tasks.queries";
|
||||
import { GET_JOB_BY_PK, QUERY_GET_TASKS_JOB_DETAILS_BY_ID } from "../../graphql/jobs.queries.js";
|
||||
import { toggleModalVisible } from "../../redux/modals/modals.actions";
|
||||
import { selectTaskUpsert } from "../../redux/modals/modals.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import TaskUpsertModalComponent from "./task-upsert-modal.component";
|
||||
import { replaceUndefinedWithNull } from "../../utils/undefinedtonull.js";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||
import { isEqual } from "lodash";
|
||||
import refetchRouteMappings from "./task-upsert-modal.route.mappings";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
bodyshop: selectBodyshop,
|
||||
taskUpsert: selectTaskUpsert
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
toggleModalVisible: () => dispatch(toggleModalVisible("taskUpsert")),
|
||||
insertAuditTrail: ({ jobid, billid, operation, type }) =>
|
||||
dispatch(insertAuditTrail({ jobid, billid, operation, type }))
|
||||
});
|
||||
|
||||
export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, toggleModalVisible, insertAuditTrail }) {
|
||||
const { t } = useTranslation();
|
||||
const history = useNavigate();
|
||||
const [insertTask] = useMutation(MUTATION_INSERT_NEW_TASK);
|
||||
const [updateTask] = useMutation(MUTATION_UPDATE_TASK);
|
||||
const { open, context } = taskUpsert;
|
||||
const { jobid, joblineid, billid, partsorderid, taskId, existingTask, query } = context;
|
||||
const [form] = Form.useForm();
|
||||
const [selectedJobId, setSelectedJobId] = useState(null);
|
||||
const [selectedJobDetails, setSelectedJobDetails] = useState(null);
|
||||
const [jobIdState, setJobIdState] = useState(null);
|
||||
const [isTouched, setIsTouched] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const { loading, error, data } = useQuery(QUERY_GET_TASKS_JOB_DETAILS_BY_ID, {
|
||||
variables: { id: jobIdState },
|
||||
skip: !jobIdState
|
||||
});
|
||||
|
||||
const {
|
||||
loading: taskLoading,
|
||||
error: taskError,
|
||||
data: taskData
|
||||
} = useQuery(QUERY_GET_TASK_BY_ID, {
|
||||
variables: { id: taskId },
|
||||
skip: !taskId
|
||||
});
|
||||
// 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);
|
||||
setSelectedJobId(taskData.tasks_by_pk.jobid);
|
||||
}
|
||||
}, [taskLoading, taskError, taskData, form]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (existingTask && open) {
|
||||
form.setFieldsValue(existingTask);
|
||||
} else if (!existingTask && open) {
|
||||
form.resetFields();
|
||||
if (joblineid) form.setFieldsValue({ joblineid });
|
||||
if (billid) form.setFieldsValue({ billid });
|
||||
if (partsorderid) form.setFieldsValue({ partsorderid });
|
||||
}
|
||||
return () => {
|
||||
setSelectedJobId(null);
|
||||
setIsTouched(false);
|
||||
};
|
||||
}, [jobid, existingTask, form, open, joblineid, billid, partsorderid]);
|
||||
|
||||
/**
|
||||
* Remove the taskid from the URL
|
||||
*/
|
||||
const removeTaskIdFromUrl = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (!urlParams.has("taskid")) return;
|
||||
urlParams.delete("taskid");
|
||||
history(`${window.location.pathname}?${urlParams}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate refetch queries
|
||||
* @param jobId
|
||||
* @returns {*[]}
|
||||
*/
|
||||
const generateRefetchQueries = (jobId) => {
|
||||
const refetchQueries = [];
|
||||
|
||||
if (location.pathname.includes("/manage/jobs") && jobId) {
|
||||
refetchQueries.push({
|
||||
query: GET_JOB_BY_PK,
|
||||
variables: {
|
||||
id: jobId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// We have a specified query
|
||||
if (query && Object.keys(query).length) {
|
||||
refetchQueries.push(Object.keys(query)[0]);
|
||||
}
|
||||
// We don't have a specified query, check the page
|
||||
else {
|
||||
refetchRouteMappings.forEach((mapping) => {
|
||||
if (location.pathname.includes(mapping.route)) {
|
||||
refetchQueries.push(mapping.query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return refetchQueries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle existing task
|
||||
* @param id
|
||||
* @param jobId
|
||||
* @param values
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleExistingTask = async (id, jobId, values) => {
|
||||
const task = replaceUndefinedWithNull(values);
|
||||
|
||||
// Remind at is dirty so lets clear remind_at_sent
|
||||
if (task?.remind_at) {
|
||||
task.remind_at_sent = null;
|
||||
}
|
||||
|
||||
const taskObject = {
|
||||
variables: {
|
||||
taskId: id,
|
||||
task
|
||||
}
|
||||
};
|
||||
|
||||
taskObject.refetchQueries = generateRefetchQueries(jobId);
|
||||
|
||||
const taskData = await updateTask(taskObject);
|
||||
|
||||
if (!taskData.errors) {
|
||||
const oldTask = taskData?.data?.update_tasks?.returning[0];
|
||||
|
||||
insertAuditTrail({
|
||||
jobid: oldTask.jobid,
|
||||
operation: AuditTrailMapping.tasksUpdated(oldTask.title, currentUser.email),
|
||||
type: "tasksUpdated"
|
||||
});
|
||||
}
|
||||
|
||||
notification["success"]({
|
||||
message: t("tasks.successes.updated")
|
||||
});
|
||||
|
||||
toggleModalVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle new task
|
||||
* @param values
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleNewTask = async (values) => {
|
||||
const taskObject = {
|
||||
variables: {
|
||||
taskInput: [
|
||||
{
|
||||
...values,
|
||||
created_by: currentUser.email,
|
||||
bodyshopid: bodyshop.id
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
taskObject.refetchQueries = generateRefetchQueries(values.jobid);
|
||||
|
||||
const newTaskData = await insertTask(taskObject);
|
||||
const newTask = newTaskData?.data?.insert_tasks?.returning[0];
|
||||
|
||||
if (!newTaskData.errors) {
|
||||
insertAuditTrail({
|
||||
jobid: newTask.jobid,
|
||||
operation: AuditTrailMapping.tasksCreated(newTask.title, currentUser.email),
|
||||
type: "tasksCreated"
|
||||
});
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
toggleModalVisible();
|
||||
|
||||
notification["success"]({
|
||||
message: t("tasks.successes.created")
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the form submit
|
||||
* @param formValues
|
||||
* @returns {Promise<[{jobid, bodyshopid, created_by},...*]>}
|
||||
*/
|
||||
const handleFinish = async (formValues) => {
|
||||
if (existingTask || taskData?.tasks_by_pk) {
|
||||
const taskSource = existingTask || taskData?.tasks_by_pk;
|
||||
const dirtyValues = Object.keys(formValues).reduce((acc, key) => {
|
||||
if (!isEqual(formValues[key], taskSource[key])) {
|
||||
acc[key] = formValues[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
try {
|
||||
await handleExistingTask(taskSource.id, taskSource.jobid, dirtyValues);
|
||||
} catch (e) {
|
||||
notification["error"]({
|
||||
message: t("tasks.failures.updated")
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await handleNewTask(formValues);
|
||||
} catch (e) {
|
||||
notification["error"]({
|
||||
message: t("tasks.failures.created")
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={existingTask ? t("tasks.actions.edit") : t("tasks.actions.new")}
|
||||
open={open}
|
||||
okText={t("general.actions.save")}
|
||||
width="50%"
|
||||
onOk={() => {
|
||||
removeTaskIdFromUrl();
|
||||
form.submit();
|
||||
}}
|
||||
onCancel={() => {
|
||||
removeTaskIdFromUrl();
|
||||
toggleModalVisible();
|
||||
}}
|
||||
okButtonProps={{ disabled: !isTouched }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleFinish}
|
||||
layout="vertical"
|
||||
onFieldsChange={() => {
|
||||
setIsTouched(true);
|
||||
}}
|
||||
>
|
||||
<TaskUpsertModalComponent
|
||||
form={form}
|
||||
loading={loading || (taskId && taskLoading)}
|
||||
error={error}
|
||||
data={data}
|
||||
existingTask={existingTask || taskData?.tasks_by_pk}
|
||||
selectedJobId={selectedJobId}
|
||||
setSelectedJobId={setSelectedJobId}
|
||||
selectedJobDetails={selectedJobDetails}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TaskUpsertModalContainer);
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
QUERY_ALL_TASKS_PAGINATED,
|
||||
QUERY_JOB_TASKS_PAGINATED,
|
||||
QUERY_MY_TASKS_PAGINATED
|
||||
} from "../../graphql/tasks.queries.js";
|
||||
|
||||
const getQueryName = (query) => Object.keys(query)[0];
|
||||
|
||||
const refetchRouteMappings = [
|
||||
{query: getQueryName({QUERY_MY_TASKS_PAGINATED}), route: "/manage/tasks/mytasks"},
|
||||
{query: getQueryName({QUERY_ALL_TASKS_PAGINATED}), route: "/manage/tasks/alltasks"},
|
||||
{query: getQueryName({QUERY_JOB_TASKS_PAGINATED}), route: "/manage/jobs"}
|
||||
];
|
||||
|
||||
export default refetchRouteMappings;
|
||||
@@ -59,6 +59,10 @@ export const QUERY_BILLS_BY_JOBID = gql`
|
||||
name
|
||||
email
|
||||
}
|
||||
tasks {
|
||||
id
|
||||
due_date
|
||||
}
|
||||
order_date
|
||||
deliver_by
|
||||
return
|
||||
|
||||
@@ -499,6 +499,11 @@ export const QUERY_JOB_COSTING_DETAILS = gql`
|
||||
export const GET_JOB_BY_PK = gql`
|
||||
query GET_JOB_BY_PK($id: uuid!) {
|
||||
jobs_by_pk(id: $id) {
|
||||
tasks_aggregate(where: { completed: { _eq: false }, deleted: { _eq: false } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
actual_completion
|
||||
actual_delivery
|
||||
actual_in
|
||||
@@ -1969,19 +1974,19 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
|
||||
kmout
|
||||
qb_multiple_payers
|
||||
lbr_adjustments
|
||||
payments {
|
||||
amount
|
||||
created_at
|
||||
date
|
||||
exportedat
|
||||
id
|
||||
jobid
|
||||
memo
|
||||
payer
|
||||
paymentnum
|
||||
transactionid
|
||||
type
|
||||
}
|
||||
payments {
|
||||
amount
|
||||
created_at
|
||||
date
|
||||
exportedat
|
||||
id
|
||||
jobid
|
||||
memo
|
||||
payer
|
||||
paymentnum
|
||||
transactionid
|
||||
type
|
||||
}
|
||||
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||
id
|
||||
removed
|
||||
@@ -1994,7 +1999,7 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
|
||||
db_price
|
||||
act_price
|
||||
part_qty
|
||||
notes
|
||||
notes
|
||||
mod_lbr_ty
|
||||
db_hrs
|
||||
mod_lb_hrs
|
||||
@@ -2006,9 +2011,9 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
|
||||
prt_dsmk_p
|
||||
convertedtolbr
|
||||
convertedtolbr_data
|
||||
act_price_before_ppc
|
||||
sublet_ignored
|
||||
sublet_completed
|
||||
act_price_before_ppc
|
||||
sublet_ignored
|
||||
sublet_completed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2056,12 +2061,12 @@ export const generate_UPDATE_JOB_KANBAN = (
|
||||
}`;
|
||||
|
||||
return gql`
|
||||
mutation UPDATE_JOB_KANBAN {
|
||||
${oldChildId ? oldChildQuery : ""}
|
||||
${movedId ? movedQuery : ""}
|
||||
${newChildId ? newChildQuery : ""}
|
||||
}
|
||||
`;
|
||||
mutation UPDATE_JOB_KANBAN {
|
||||
${oldChildId ? oldChildQuery : ""}
|
||||
${movedId ? movedQuery : ""}
|
||||
${newChildId ? newChildQuery : ""}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
export const QUERY_JOB_LBR_ADJUSTMENTS = gql`
|
||||
@@ -2081,6 +2086,34 @@ export const DELETE_JOB = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_GET_TASKS_JOB_DETAILS_BY_ID = gql`
|
||||
query QUERY_GET_TASKS_JOB_DETAILS_BY_ID($id: uuid!) {
|
||||
jobs_by_pk(id: $id) {
|
||||
id
|
||||
scheduled_delivery
|
||||
scheduled_completion
|
||||
joblines {
|
||||
id
|
||||
line_desc
|
||||
}
|
||||
bills {
|
||||
id
|
||||
vendor {
|
||||
name
|
||||
}
|
||||
invoice_number
|
||||
}
|
||||
parts_orders {
|
||||
id
|
||||
vendor {
|
||||
name
|
||||
}
|
||||
order_number
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_JOB_FOR_CC_CONTRACT = gql`
|
||||
query GET_JOB_FOR_CC_CONTRACT($id: uuid!) {
|
||||
jobs_by_pk(id: $id) {
|
||||
|
||||
379
client/src/graphql/tasks.queries.js
Normal file
379
client/src/graphql/tasks.queries.js
Normal file
@@ -0,0 +1,379 @@
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
export const PARTIAL_TASK_FIELDS = gql`
|
||||
fragment TaskFields on tasks {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
title
|
||||
description
|
||||
deleted
|
||||
deleted_at
|
||||
due_date
|
||||
created_by
|
||||
assigned_to
|
||||
completed
|
||||
completed_at
|
||||
remind_at
|
||||
priority
|
||||
job {
|
||||
id
|
||||
ro_number
|
||||
joblines {
|
||||
id
|
||||
line_desc
|
||||
}
|
||||
bills {
|
||||
id
|
||||
vendor {
|
||||
name
|
||||
}
|
||||
invoice_number
|
||||
}
|
||||
parts_orders {
|
||||
id
|
||||
vendor {
|
||||
name
|
||||
}
|
||||
order_number
|
||||
}
|
||||
}
|
||||
jobid
|
||||
jobline {
|
||||
id
|
||||
line_desc
|
||||
}
|
||||
joblineid
|
||||
parts_order {
|
||||
id
|
||||
vendor {
|
||||
name
|
||||
}
|
||||
order_number
|
||||
}
|
||||
partsorderid
|
||||
bill {
|
||||
id
|
||||
vendor {
|
||||
name
|
||||
}
|
||||
invoice_number
|
||||
}
|
||||
billid
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_GET_TASK_BY_ID = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
query QUERY_GET_TASK_BY_ID($id: uuid!) {
|
||||
tasks_by_pk(id: $id) {
|
||||
...TaskFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_ALL_TASKS_PAGINATED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
query QUERY_ALL_TASKS_PAGINATED(
|
||||
$offset: Int
|
||||
$limit: Int
|
||||
$bodyshop: uuid!
|
||||
$deleted: Boolean
|
||||
$completed: Boolean
|
||||
$assigned_to: String
|
||||
$order: [tasks_order_by!]!
|
||||
) {
|
||||
tasks(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: $order
|
||||
where: {
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
...TaskFields
|
||||
}
|
||||
tasks_aggregate(
|
||||
where: {
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Query for joblineid
|
||||
export const QUERY_JOBLINE_TASKS_PAGINATED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
query QUERY_JOBLINE_TASKS_PAGINATED(
|
||||
$offset: Int
|
||||
$limit: Int
|
||||
$joblineid: uuid!
|
||||
$bodyshop: uuid!
|
||||
$deleted: Boolean
|
||||
$completed: Boolean
|
||||
$assigned_to: String
|
||||
$order: [tasks_order_by!]!
|
||||
) {
|
||||
tasks(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: $order
|
||||
where: {
|
||||
joblineid: { _eq: $joblineid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
...TaskFields
|
||||
}
|
||||
tasks_aggregate(
|
||||
where: {
|
||||
joblineid: { _eq: $joblineid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Query for partsorderid
|
||||
export const QUERY_PARTSORDER_TASKS_PAGINATED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
query QUERY_PARTSORDER_TASKS_PAGINATED(
|
||||
$offset: Int
|
||||
$limit: Int
|
||||
$partsorderid: uuid!
|
||||
$bodyshop: uuid!
|
||||
$deleted: Boolean
|
||||
$completed: Boolean
|
||||
$assigned_to: String
|
||||
$order: [tasks_order_by!]!
|
||||
) {
|
||||
tasks(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: $order
|
||||
where: {
|
||||
partsorderid: { _eq: $partsorderid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
...TaskFields
|
||||
}
|
||||
tasks_aggregate(
|
||||
where: {
|
||||
partsorderid: { _eq: $partsorderid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Query for billid
|
||||
export const QUERY_BILL_TASKS_PAGINATED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
query QUERY_BILL_TASKS_PAGINATED(
|
||||
$offset: Int
|
||||
$limit: Int
|
||||
$billid: uuid!
|
||||
$bodyshop: uuid!
|
||||
$deleted: Boolean
|
||||
$completed: Boolean
|
||||
$assigned_to: String
|
||||
$order: [tasks_order_by!]!
|
||||
) {
|
||||
tasks(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: $order
|
||||
where: {
|
||||
billid: { _eq: $billid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
...TaskFields
|
||||
}
|
||||
tasks_aggregate(
|
||||
where: {
|
||||
billid: { _eq: $billid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Use the fragment in your queries
|
||||
export const QUERY_JOB_TASKS_PAGINATED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
query QUERY_JOB_TASKS_PAGINATED(
|
||||
$offset: Int
|
||||
$limit: Int
|
||||
$jobid: uuid!
|
||||
$bodyshop: uuid!
|
||||
$deleted: Boolean
|
||||
$completed: Boolean
|
||||
$assigned_to: String
|
||||
$order: [tasks_order_by!]!
|
||||
) {
|
||||
tasks(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: $order
|
||||
where: {
|
||||
jobid: { _eq: $jobid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
...TaskFields
|
||||
}
|
||||
tasks_aggregate(
|
||||
where: {
|
||||
jobid: { _eq: $jobid }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_MY_TASKS_PAGINATED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
query QUERY_MY_TASKS_PAGINATED(
|
||||
$offset: Int
|
||||
$limit: Int
|
||||
$user: String!
|
||||
$bodyshop: uuid!
|
||||
$deleted: Boolean
|
||||
$completed: Boolean
|
||||
$order: [tasks_order_by!]!
|
||||
) {
|
||||
tasks(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: $order
|
||||
where: {
|
||||
assigned_to: { _eq: $user }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
...TaskFields
|
||||
}
|
||||
tasks_aggregate(
|
||||
where: {
|
||||
assigned_to: { _eq: $user }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Toggle task completed mutation
|
||||
* @type {DocumentNode}
|
||||
*/
|
||||
export const MUTATION_TOGGLE_TASK_COMPLETED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
mutation MUTATION_TOGGLE_TASK_COMPLETED($id: uuid!, $completed: Boolean!, $completed_at: timestamptz) {
|
||||
update_tasks_by_pk(pk_columns: { id: $id }, _set: { completed: $completed, completed_at: $completed_at }) {
|
||||
...TaskFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Toggle task deleted mutation
|
||||
* @type {DocumentNode}
|
||||
*/
|
||||
export const MUTATION_TOGGLE_TASK_DELETED = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
mutation MUTATION_TOGGLE_TASK_DELETED($id: uuid!, $deleted: Boolean!, $deleted_at: timestamptz) {
|
||||
update_tasks_by_pk(pk_columns: { id: $id }, _set: { deleted: $deleted, deleted_at: $deleted_at }) {
|
||||
...TaskFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Insert new task mutation
|
||||
* @type {DocumentNode}
|
||||
*/
|
||||
export const MUTATION_INSERT_NEW_TASK = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
mutation MUTATION_INSERT_NEW_TASK($taskInput: [tasks_insert_input!]!) {
|
||||
insert_tasks(objects: $taskInput) {
|
||||
returning {
|
||||
...TaskFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Update task mutation
|
||||
* @type {DocumentNode}
|
||||
*/
|
||||
export const MUTATION_UPDATE_TASK = gql`
|
||||
${PARTIAL_TASK_FIELDS}
|
||||
mutation MUTATION_UPDATE_TASK($taskId: uuid!, $task: tasks_set_input!) {
|
||||
update_tasks(where: { id: { _eq: $taskId } }, _set: $task) {
|
||||
returning {
|
||||
...TaskFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -8,7 +8,7 @@ import Icon, {
|
||||
SyncOutlined,
|
||||
ToolFilled
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Divider, Form, notification, Space, Tabs } from "antd";
|
||||
import { Badge, Button, Divider, Form, notification, Space, Tabs } from "antd";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
|
||||
import Axios from "axios";
|
||||
@@ -16,7 +16,7 @@ import dayjs from "../../utils/day";
|
||||
import queryString from "query-string";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaHardHat, FaRegStickyNote, FaShieldAlt } from "react-icons/fa";
|
||||
import { FaHardHat, FaRegStickyNote, FaShieldAlt, FaTasks } from "react-icons/fa";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -53,14 +53,29 @@ import JobProfileDataWarning from "../../components/job-profile-data-warning/job
|
||||
import { DateTimeFormat } from "../../utils/DateFormatter";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
|
||||
import TaskListContainer from "../../components/task-list/task-list.container.jsx";
|
||||
import { QUERY_JOB_TASKS_PAGINATED } from "../../graphql/tasks.queries.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
jobRO: selectJobReadOnly
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })),
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
setPrintCenterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "printCenter"
|
||||
})
|
||||
),
|
||||
insertAuditTrail: ({ jobid, operation, type }) =>
|
||||
dispatch(
|
||||
insertAuditTrail({
|
||||
jobid,
|
||||
operation,
|
||||
type
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export function JobsDetailPage({
|
||||
@@ -69,7 +84,6 @@ export function JobsDetailPage({
|
||||
jobRO,
|
||||
job,
|
||||
mutationUpdateJob,
|
||||
handleSubmit,
|
||||
insertAuditTrail,
|
||||
refetch
|
||||
}) {
|
||||
@@ -365,6 +379,25 @@ export function JobsDetailPage({
|
||||
icon: <HistoryOutlined />,
|
||||
label: t("jobs.labels.audit"),
|
||||
children: <JobAuditTrail jobId={job.id} />
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
icon: <FaTasks />,
|
||||
label: (
|
||||
<Space direction="horizontal">
|
||||
{t("jobs.labels.tasks")}
|
||||
{job.tasks_aggregate.aggregate.count > 0 && <Badge count={job.tasks_aggregate.aggregate.count} />}
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<TaskListContainer
|
||||
relationshipType={"jobid"}
|
||||
relationshipId={job.id}
|
||||
query={{ QUERY_JOB_TASKS_PAGINATED }}
|
||||
titleTranslation="tasks.titles.job_tasks"
|
||||
showRo={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FloatButton, Layout, Spin, Collapse, Button, Space, Tag } from "antd";
|
||||
import { Button, Collapse, FloatButton, Layout, Space, Spin, Tag } from "antd";
|
||||
// import preval from "preval.macro";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -30,8 +30,8 @@ import "./manage.page.styles.scss";
|
||||
|
||||
const JobsPage = lazy(() => import("../jobs/jobs.page"));
|
||||
|
||||
const CardPaymentModalContainer = lazy(() =>
|
||||
import("../../components/card-payment-modal/card-payment-modal.container.")
|
||||
const CardPaymentModalContainer = lazy(
|
||||
() => import("../../components/card-payment-modal/card-payment-modal.container.")
|
||||
);
|
||||
|
||||
const JobsDetailPage = lazy(() => import("../jobs-detail/jobs-detail.page.container"));
|
||||
@@ -59,8 +59,8 @@ const JobCostingModal = lazy(() => import("../../components/job-costing-modal/jo
|
||||
const ReportCenterModal = lazy(() => import("../../components/report-center-modal/report-center-modal.container"));
|
||||
const BillEnterModalContainer = lazy(() => import("../../components/bill-enter-modal/bill-enter-modal.container"));
|
||||
const TimeTicketModalContainer = lazy(() => import("../../components/time-ticket-modal/time-ticket-modal.container"));
|
||||
const TimeTicketModalTask = lazy(() =>
|
||||
import("../../components/time-ticket-task-modal/time-ticket-task-modal.container")
|
||||
const TimeTicketModalTask = lazy(
|
||||
() => import("../../components/time-ticket-task-modal/time-ticket-task-modal.container")
|
||||
);
|
||||
const PaymentModalContainer = lazy(() => import("../../components/payment-modal/payment-modal.container"));
|
||||
const ProductionListPage = lazy(() => import("../production-list/production-list.container"));
|
||||
@@ -97,7 +97,10 @@ const Dms = lazy(() => import("../dms/dms.container"));
|
||||
const DmsPayables = lazy(() => import("../dms-payables/dms-payables.container"));
|
||||
const ManageRootPage = lazy(() => import("../manage-root/manage-root.page.container"));
|
||||
const TtApprovals = lazy(() => import("../tt-approvals/tt-approvals.page.container"));
|
||||
const MyTasksPage = lazy(() => import("../tasks/myTasksPageContainer.jsx"));
|
||||
const AllTasksPage = lazy(() => import("../tasks/allTasksPageContainer.jsx"));
|
||||
|
||||
const TaskUpsertModalContainer = lazy(() => import("../../components/task-upsert-modal/task-upsert-modal.container"));
|
||||
const { Content, Footer } = Layout;
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
@@ -147,12 +150,10 @@ export function Manage({ conflict, bodyshop, enableJoyRide, joyRideSteps, setJoy
|
||||
})}
|
||||
/>
|
||||
}
|
||||
This
|
||||
>
|
||||
<PaymentModalContainer />
|
||||
|
||||
<CardPaymentModalContainer />
|
||||
|
||||
<TaskUpsertModalContainer />
|
||||
<BreadCrumbs />
|
||||
<BillEnterModalContainer />
|
||||
<JobCostingModal />
|
||||
@@ -252,6 +253,22 @@ export function Manage({ conflict, bodyshop, enableJoyRide, joyRideSteps, setJoy
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tasks/mytasks"
|
||||
element={
|
||||
<Suspense fallback={<Spin />}>
|
||||
<MyTasksPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tasks/alltasks"
|
||||
element={
|
||||
<Suspense fallback={<Spin />}>
|
||||
<AllTasksPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/"
|
||||
element={
|
||||
|
||||
60
client/src/pages/tasks/allTasksPageContainer.jsx
Normal file
60
client/src/pages/tasks/allTasksPageContainer.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TasksPageComponent from "./tasks.page.component";
|
||||
import queryString from "query-string";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
import TaskPageTypes from "./taskPageTypes.jsx";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions.js";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
|
||||
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
|
||||
});
|
||||
|
||||
export function AllTasksPageContainer({ setBreadcrumbs, setSelectedHeader, setTaskUpsertContext }) {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
useEffect(() => {
|
||||
document.title = t("titles.all_tasks", {
|
||||
app: InstanceRenderManager({
|
||||
imex: "$t(titles.imexonline)",
|
||||
rome: "$t(titles.romeonline)",
|
||||
promanager: "$t(titles.promanager)"
|
||||
})
|
||||
});
|
||||
setSelectedHeader("all_tasks");
|
||||
setBreadcrumbs([
|
||||
{
|
||||
link: "/manage/tasks/alltasks",
|
||||
label: t("titles.bc.all_tasks")
|
||||
}
|
||||
]);
|
||||
}, [t, setBreadcrumbs, setSelectedHeader]);
|
||||
|
||||
// This takes care of the ability to deep link a task from the URL (Dispatches the modal)
|
||||
useEffect(() => {
|
||||
// Check for a query string in the URL
|
||||
const urlParams = new URLSearchParams(searchParams);
|
||||
const taskId = urlParams.get("taskid");
|
||||
if (taskId) {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
taskId
|
||||
}
|
||||
});
|
||||
urlParams.delete("taskid");
|
||||
}
|
||||
}, [setTaskUpsertContext, searchParams]);
|
||||
|
||||
return <TasksPageComponent type={TaskPageTypes.ALL_TASKS} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AllTasksPageContainer);
|
||||
40
client/src/pages/tasks/myTasksPageContainer.jsx
Normal file
40
client/src/pages/tasks/myTasksPageContainer.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TasksPageComponent from "./tasks.page.component";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
import TaskPageTypes from "./taskPageTypes.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
||||
});
|
||||
|
||||
export function MyTasksPageContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
document.title = t("titles.my_tasks", {
|
||||
app: InstanceRenderManager({
|
||||
imex: "$t(titles.imexonline)",
|
||||
rome: "$t(titles.romeonline)",
|
||||
promanager: "$t(titles.promanager)"
|
||||
})
|
||||
});
|
||||
setSelectedHeader("my_tasks");
|
||||
setBreadcrumbs([
|
||||
{
|
||||
link: "/manage/tasks/mytasks",
|
||||
label: t("titles.bc.my_tasks")
|
||||
}
|
||||
]);
|
||||
}, [t, setBreadcrumbs, setSelectedHeader]);
|
||||
|
||||
return <TasksPageComponent type={TaskPageTypes.MY_TASKS} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(MyTasksPageContainer);
|
||||
6
client/src/pages/tasks/taskPageTypes.jsx
Normal file
6
client/src/pages/tasks/taskPageTypes.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export const TaskPageTypes = {
|
||||
MY_TASKS: "myTasks",
|
||||
ALL_TASKS: "allTasks"
|
||||
};
|
||||
|
||||
export default TaskPageTypes;
|
||||
40
client/src/pages/tasks/tasks.page.component.jsx
Normal file
40
client/src/pages/tasks/tasks.page.component.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import TaskListContainer from "../../components/task-list/task-list.container.jsx";
|
||||
import { QUERY_ALL_TASKS_PAGINATED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries.js";
|
||||
import taskPageTypes from "./taskPageTypes.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TasksPageComponent);
|
||||
|
||||
export function TasksPageComponent({ currentUser, type }) {
|
||||
switch (type) {
|
||||
case taskPageTypes.MY_TASKS:
|
||||
return (
|
||||
<TaskListContainer
|
||||
onlyMine={true}
|
||||
relationshipId={currentUser.email}
|
||||
relationshipType={"user"}
|
||||
query={{ QUERY_MY_TASKS_PAGINATED }}
|
||||
titleTranslation={"tasks.titles.my_tasks"}
|
||||
disableJobRefetch={true}
|
||||
/>
|
||||
);
|
||||
case taskPageTypes.ALL_TASKS:
|
||||
return (
|
||||
<TaskListContainer
|
||||
query={{ QUERY_ALL_TASKS_PAGINATED }}
|
||||
titleTranslation={"tasks.titles.all_tasks"}
|
||||
disableJobRefetch={true}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const INITIAL_STATE = {
|
||||
billEnter: { ...baseModal },
|
||||
courtesyCarReturn: { ...baseModal },
|
||||
noteUpsert: { ...baseModal },
|
||||
taskUpsert: { ...baseModal },
|
||||
schedule: { ...baseModal },
|
||||
partsOrder: { ...baseModal },
|
||||
timeTicket: { ...baseModal },
|
||||
|
||||
@@ -10,6 +10,8 @@ export const selectCourtesyCarReturn = createSelector([selectModals], (modals) =
|
||||
|
||||
export const selectNoteUpsert = createSelector([selectModals], (modals) => modals.noteUpsert);
|
||||
|
||||
export const selectTaskUpsert = createSelector([selectModals], (modals) => modals.taskUpsert);
|
||||
|
||||
export const selectSchedule = createSelector([selectModals], (modals) => modals.schedule);
|
||||
|
||||
export const selectPartsOrder = createSelector([selectModals], (modals) => modals.partsOrder);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,38 @@ const AuditTrailMapping = {
|
||||
jobstatuschange: (status) => i18n.t("audit_trail.messages.jobstatuschange", { status }),
|
||||
jobsupplement: () => i18n.t("audit_trail.messages.jobsupplement"),
|
||||
jobsuspend: (status) => i18n.t("audit_trail.messages.jobsuspend", { status }),
|
||||
jobvoid: () => i18n.t("audit_trail.messages.jobvoid")
|
||||
jobvoid: () => i18n.t("audit_trail.messages.jobvoid"),
|
||||
// Tasks Entries
|
||||
tasksCreated: (title, createdBy) =>
|
||||
i18n.t("audit_trail.messages.tasks_created", {
|
||||
title,
|
||||
createdBy
|
||||
}),
|
||||
tasksUpdated: (title, updatedBy) =>
|
||||
i18n.t("audit_trail.messages.tasks_updated", {
|
||||
title,
|
||||
updatedBy
|
||||
}),
|
||||
tasksDeleted: (title, deletedBy) =>
|
||||
i18n.t("audit_trail.messages.tasks_deleted", {
|
||||
title,
|
||||
deletedBy
|
||||
}),
|
||||
tasksUndeleted: (title, undeletedBy) =>
|
||||
i18n.t("audit_trail.messages.tasks_undeleted", {
|
||||
title,
|
||||
undeletedBy
|
||||
}),
|
||||
tasksCompleted: (title, completedBy) =>
|
||||
i18n.t("audit_trail.messages.tasks_completed", {
|
||||
title,
|
||||
completedBy
|
||||
}),
|
||||
tasksUncompleted: (title, uncompletedBy) =>
|
||||
i18n.t("audit_trail.messages.tasks_uncompleted", {
|
||||
title,
|
||||
uncompletedBy
|
||||
})
|
||||
};
|
||||
|
||||
export default AuditTrailMapping;
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* Replaces undefined values with null in an object.
|
||||
* Optionally, you can specify keys to replace.
|
||||
* If keys are specified, only those keys will be replaced.
|
||||
* If no keys are specified, all undefined values will be replaced.
|
||||
* @param obj
|
||||
* @param keys
|
||||
* @returns {*}
|
||||
* @constructor
|
||||
*/
|
||||
export default function UndefinedToNull(obj, keys) {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (keys && keys.indexOf(key) >= 0) {
|
||||
@@ -8,3 +18,21 @@ export default function UndefinedToNull(obj, keys) {
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces undefined values with null in an object. Optionally, you can specify keys to replace. If keys are specified, only those keys will be replaced. If no keys are specified, all undefined values will be replaced.
|
||||
* @param obj
|
||||
* @param keys
|
||||
* @returns {{[p: string]: unknown}}
|
||||
*/
|
||||
export function replaceUndefinedWithNull(obj, keys) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => {
|
||||
if (keys) {
|
||||
return [key, keys.includes(key) && value === undefined ? null : value];
|
||||
} else {
|
||||
return [key, value === undefined ? null : value];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
actions: [ ]
|
||||
actions: []
|
||||
custom_types:
|
||||
enums: [ ]
|
||||
input_objects: [ ]
|
||||
objects: [ ]
|
||||
scalars: [ ]
|
||||
enums: []
|
||||
input_objects: []
|
||||
objects: []
|
||||
scalars: []
|
||||
|
||||
@@ -1 +1 @@
|
||||
[ ]
|
||||
[]
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ }
|
||||
{}
|
||||
|
||||
@@ -1 +1 @@
|
||||
[ ]
|
||||
[]
|
||||
|
||||
@@ -1 +1 @@
|
||||
[ ]
|
||||
[]
|
||||
|
||||
109
server/email/generateTemplate.js
Normal file
109
server/email/generateTemplate.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const moment = require("moment");
|
||||
const { default: RenderInstanceManager } = require("../utils/instanceMgr");
|
||||
const { header, end, start } = require("./html");
|
||||
|
||||
// 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 defaultFooter = () => {
|
||||
return RenderInstanceManager({
|
||||
imex: "ImEX Online Collision Repair Management System",
|
||||
rome: "Rome Technologies",
|
||||
promanager: "ProManager"
|
||||
});
|
||||
};
|
||||
|
||||
const now = () => moment().format("MM/DD/YYYY @ hh:mm a");
|
||||
|
||||
const generateEmailTemplate = (strings) => {
|
||||
return (
|
||||
`
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US">` +
|
||||
header +
|
||||
start +
|
||||
`
|
||||
<table class="row">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small-12 large-12 columns first last">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<h6 style="text-align:left"><strong>${strings.header}</strong></h6>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p style="font-size:90%">${strings.subHeader}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- End Report Title -->
|
||||
<!-- Task Detail -->
|
||||
<table class="row">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small-12 large-12 columns first last">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>${strings.body}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- End Task Detail -->
|
||||
<!-- Footer -->
|
||||
<table class="row collapsed footer" id="non-printable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small-3 large-3 columns first">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size:70%;">${strings?.dateLine || now()}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
<th class="small-6 large-6 columns">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size:70%; text-align:center">${strings?.footer || defaultFooter()}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
<th class="small-3 large-3 columns last">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size:70%"> </p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>` +
|
||||
end
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = generateEmailTemplate;
|
||||
2614
server/email/html.js
Normal file
2614
server/email/html.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
};
|
||||
|
||||
230
server/email/tasksEmails.js
Normal file
230
server/email/tasksEmails.js
Normal file
@@ -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 <noreply@imex.online>",
|
||||
rome: "Rome Online <noreply@romeonline.io>",
|
||||
promanager: "ProManager <noreply@promanager.web-est.com>"
|
||||
});
|
||||
|
||||
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: `<a href="${endPoints}/manage/tasks/alltasks?taskid=${taskId}">Please sign in to your account to view the Task details.</a>`
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: `<ul>
|
||||
${allTasks
|
||||
.map(
|
||||
(task) =>
|
||||
`<li><a href="${endPoints}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - ${formatDate(task.due_date)}</a></li>`
|
||||
)
|
||||
.join("")}
|
||||
</ul>`
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user