@@ -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,37 @@
|
||||
import { Select, Space, Tag } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
//To be used as a form element only.
|
||||
|
||||
const EmployeeSearchSelectEmail = ({ options, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
// value={option}
|
||||
style={{
|
||||
width: 400
|
||||
}}
|
||||
optionFilterProp="search"
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map((o) => (
|
||||
<Option key={o.id} value={o.user_email} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
|
||||
<Space>
|
||||
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
|
||||
|
||||
<Tag color="green">
|
||||
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Option>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
export default EmployeeSearchSelectEmail;
|
||||
@@ -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 }
|
||||
@@ -461,6 +494,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",
|
||||
id: "header-shopsubmenu",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,23 +2,24 @@ import { useLazyQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
|
||||
import _ from "lodash";
|
||||
import dayjs from "../../utils/day";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries";
|
||||
import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries";
|
||||
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
|
||||
import { selectReportCenter } from "../../redux/modals/modals.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import DatePickerRanges from "../../utils/DatePickerRanges";
|
||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import dayjs from "../../utils/day";
|
||||
import EmployeeSearchSelectEmail from "../employee-search-select/employee-search-select-email.component";
|
||||
import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component";
|
||||
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||
import "./report-center-modal.styles.scss";
|
||||
import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||
import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component";
|
||||
import "./report-center-modal.styles.scss";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
reportCenterModal: selectReportCenter,
|
||||
@@ -66,6 +67,13 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
skip: !(open && Templates[form.getFieldValue("key")] && Templates[form.getFieldValue("key")].idtype)
|
||||
});
|
||||
|
||||
const [callEmployeeWithEmailQuery, { data: employeeWithEmailData, called: employeeWithEmailCalled }] = useLazyQuery(
|
||||
QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL,
|
||||
{
|
||||
skip: !(open && Templates[form.getFieldValue("key")] && Templates[form.getFieldValue("key")].idtype)
|
||||
}
|
||||
);
|
||||
|
||||
const handleFinish = async (values) => {
|
||||
setLoading(true);
|
||||
const start = values.dates ? values.dates[0] : null;
|
||||
@@ -197,6 +205,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
}
|
||||
if (!vendorCalled && idtype === "vendor") callVendorQuery();
|
||||
if (!employeeCalled && idtype === "employee") callEmployeeQuery();
|
||||
if (!employeeWithEmailCalled && idtype === "employeeWithEmail") callEmployeeWithEmailQuery();
|
||||
if (idtype === "vendor")
|
||||
return (
|
||||
<Form.Item
|
||||
@@ -227,6 +236,22 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
<EmployeeSearchSelect options={employeeData ? employeeData.employees : []} />
|
||||
</Form.Item>
|
||||
);
|
||||
//This was introduced with tasks before assigned_to was shifted to UUID. Keeping in place for reference in the future if needed.
|
||||
if (idtype === "employeeWithEmail")
|
||||
return (
|
||||
<Form.Item
|
||||
name="id"
|
||||
label={t("reportcenter.labels.employee")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<EmployeeSearchSelectEmail options={employeeWithEmailData ? employeeWithEmailData.employees : []} />
|
||||
</Form.Item>
|
||||
);
|
||||
else return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
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.id === 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>
|
||||
);
|
||||
}
|
||||
193
client/src/components/task-list/task-list.container.jsx
Normal file
193
client/src/components/task-list/task-list.container.jsx
Normal file
@@ -0,0 +1,193 @@
|
||||
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", //TODO: Find where mine is set.
|
||||
assigned_to: onlyMine ? bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id : 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,299 @@
|
||||
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)?.id
|
||||
}
|
||||
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.id,
|
||||
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(15, "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
|
||||
|
||||
@@ -67,6 +67,24 @@ export const QUERY_ACTIVE_EMPLOYEES = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL = gql`
|
||||
query QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL {
|
||||
employees(where: {active: {_eq: true}, user_email: {_is_null: false}}) {
|
||||
last_name
|
||||
id
|
||||
first_name
|
||||
employee_number
|
||||
active
|
||||
termination_date
|
||||
hire_date
|
||||
flat_rate
|
||||
rates
|
||||
pin
|
||||
user_email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const INSERT_EMPLOYEES = gql`
|
||||
mutation INSERT_EMPLOYEES($employees: [employees_insert_input!]!) {
|
||||
insert_employees(objects: $employees) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
383
client/src/graphql/tasks.queries.js
Normal file
383
client/src/graphql/tasks.queries.js
Normal file
@@ -0,0 +1,383 @@
|
||||
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
|
||||
assigned_to_employee {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
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: uuid
|
||||
$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: uuid
|
||||
$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: uuid
|
||||
$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: uuid
|
||||
$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: uuid
|
||||
$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
|
||||
$assigned_to: uuid!
|
||||
$bodyshop: uuid!
|
||||
$deleted: Boolean
|
||||
$completed: Boolean
|
||||
$order: [tasks_order_by!]!
|
||||
) {
|
||||
tasks(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: $order
|
||||
where: {
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
bodyshopid: { _eq: $bodyshop }
|
||||
deleted: { _eq: $deleted }
|
||||
completed: { _eq: $completed }
|
||||
}
|
||||
) {
|
||||
...TaskFields
|
||||
}
|
||||
tasks_aggregate(
|
||||
where: {
|
||||
assigned_to: { _eq: $assigned_to }
|
||||
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;
|
||||
41
client/src/pages/tasks/tasks.page.component.jsx
Normal file
41
client/src/pages/tasks/tasks.page.component.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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 { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TasksPageComponent);
|
||||
|
||||
export function TasksPageComponent({ bodyshop, currentUser, type }) {
|
||||
switch (type) {
|
||||
case taskPageTypes.MY_TASKS:
|
||||
return (
|
||||
<TaskListContainer
|
||||
onlyMine={true}
|
||||
relationshipId={bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id}
|
||||
relationshipType={"assigned_to"}
|
||||
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;
|
||||
|
||||
@@ -587,6 +587,14 @@ export const TemplateList = (type, context) => {
|
||||
key: "job_lifecycle_ro",
|
||||
disabled: false,
|
||||
group: "post"
|
||||
},
|
||||
job_tasks: {
|
||||
title: i18n.t("printcenter.jobs.job_tasks"),
|
||||
description: "",
|
||||
subject: i18n.t("printcenter.jobs.job_tasks"),
|
||||
key: "job_tasks",
|
||||
disabled: false,
|
||||
group: "ro"
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
@@ -2089,6 +2097,30 @@ export const TemplateList = (type, context) => {
|
||||
field: i18n.t("jobs.fields.date_invoiced")
|
||||
},
|
||||
group: "jobs"
|
||||
},
|
||||
tasks_date: {
|
||||
title: i18n.t("reportcenter.templates.tasks_date"),
|
||||
subject: i18n.t("reportcenter.templates.tasks_date"),
|
||||
key: "tasks_date",
|
||||
//idtype: "vendor",
|
||||
disabled: false,
|
||||
rangeFilter: {
|
||||
object: i18n.t("reportcenter.labels.objects.tasks"),
|
||||
field: i18n.t("tasks.fields.created_at")
|
||||
},
|
||||
group: "jobs"
|
||||
},
|
||||
tasks_date_employee: {
|
||||
title: i18n.t("reportcenter.templates.tasks_date_employee"),
|
||||
subject: i18n.t("reportcenter.templates.tasks_date_employee"),
|
||||
key: "tasks_date_employee",
|
||||
idtype: "employee",
|
||||
disabled: false,
|
||||
rangeFilter: {
|
||||
object: i18n.t("reportcenter.labels.objects.tasks"),
|
||||
field: i18n.t("tasks.fields.created_at")
|
||||
},
|
||||
group: "jobs"
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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 @@
|
||||
[ ]
|
||||
[]
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
update_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns: [ ]
|
||||
columns: []
|
||||
filter:
|
||||
jobline:
|
||||
job:
|
||||
@@ -705,7 +705,7 @@
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: { }
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/opensearch'
|
||||
version: 2
|
||||
@@ -1676,7 +1676,7 @@
|
||||
columns:
|
||||
- config
|
||||
- id
|
||||
filter: { }
|
||||
filter: {}
|
||||
limit: 1
|
||||
- role: user
|
||||
permission:
|
||||
@@ -2335,6 +2335,13 @@
|
||||
table:
|
||||
name: jobs
|
||||
schema: public
|
||||
- name: tasks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: assigned_to
|
||||
table:
|
||||
name: tasks
|
||||
schema: public
|
||||
- name: timetickets
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -2514,7 +2521,7 @@
|
||||
- effective_date
|
||||
- end_date
|
||||
- content
|
||||
filter: { }
|
||||
filter: {}
|
||||
- table:
|
||||
name: exportlog
|
||||
schema: public
|
||||
@@ -4250,7 +4257,7 @@
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: { }
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/job/statustransition'
|
||||
version: 2
|
||||
@@ -4264,7 +4271,7 @@
|
||||
webhook_from_env: HASURA_API_URL
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: { }
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/record-handler/arms'
|
||||
version: 2
|
||||
@@ -4287,7 +4294,7 @@
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: { }
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/opensearch'
|
||||
version: 2
|
||||
@@ -4300,13 +4307,13 @@
|
||||
columns:
|
||||
- key
|
||||
- value
|
||||
filter: { }
|
||||
filter: {}
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- key
|
||||
- value
|
||||
filter: { }
|
||||
filter: {}
|
||||
- table:
|
||||
name: messages
|
||||
schema: public
|
||||
@@ -4738,7 +4745,7 @@
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: { }
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/opensearch'
|
||||
version: 2
|
||||
@@ -5353,7 +5360,7 @@
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: { }
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/opensearch'
|
||||
version: 2
|
||||
@@ -5678,6 +5685,9 @@
|
||||
name: tasks
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: assigned_to_employee
|
||||
using:
|
||||
foreign_key_constraint_on: assigned_to
|
||||
- name: bill
|
||||
using:
|
||||
foreign_key_constraint_on: billid
|
||||
@@ -5693,9 +5703,6 @@
|
||||
- name: parts_order
|
||||
using:
|
||||
foreign_key_constraint_on: partsorderid
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: assigned_to
|
||||
- name: userByCreatedBy
|
||||
using:
|
||||
foreign_key_constraint_on: created_by
|
||||
@@ -5712,48 +5719,50 @@
|
||||
- active:
|
||||
_eq: true
|
||||
columns:
|
||||
- completed
|
||||
- deleted
|
||||
- priority
|
||||
- assigned_to
|
||||
- created_by
|
||||
- description
|
||||
- title
|
||||
- completed_at
|
||||
- created_at
|
||||
- deleted_at
|
||||
- due_date
|
||||
- remind_at
|
||||
- updated_at
|
||||
- billid
|
||||
- bodyshopid
|
||||
- completed
|
||||
- completed_at
|
||||
- created_at
|
||||
- created_by
|
||||
- deleted
|
||||
- deleted_at
|
||||
- description
|
||||
- due_date
|
||||
- id
|
||||
- jobid
|
||||
- joblineid
|
||||
- partsorderid
|
||||
- priority
|
||||
- remind_at
|
||||
- remind_at_sent
|
||||
- title
|
||||
- updated_at
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- completed
|
||||
- deleted
|
||||
- priority
|
||||
- assigned_to
|
||||
- created_by
|
||||
- description
|
||||
- title
|
||||
- completed_at
|
||||
- created_at
|
||||
- deleted_at
|
||||
- due_date
|
||||
- remind_at
|
||||
- updated_at
|
||||
- billid
|
||||
- bodyshopid
|
||||
- completed
|
||||
- completed_at
|
||||
- created_at
|
||||
- created_by
|
||||
- deleted
|
||||
- deleted_at
|
||||
- description
|
||||
- due_date
|
||||
- id
|
||||
- jobid
|
||||
- joblineid
|
||||
- partsorderid
|
||||
- priority
|
||||
- remind_at
|
||||
- remind_at_sent
|
||||
- title
|
||||
- updated_at
|
||||
filter:
|
||||
bodyshop:
|
||||
associations:
|
||||
@@ -5768,25 +5777,26 @@
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- completed
|
||||
- deleted
|
||||
- priority
|
||||
- assigned_to
|
||||
- created_by
|
||||
- description
|
||||
- title
|
||||
- completed_at
|
||||
- created_at
|
||||
- deleted_at
|
||||
- due_date
|
||||
- remind_at
|
||||
- updated_at
|
||||
- billid
|
||||
- bodyshopid
|
||||
- completed
|
||||
- completed_at
|
||||
- created_at
|
||||
- created_by
|
||||
- deleted
|
||||
- deleted_at
|
||||
- description
|
||||
- due_date
|
||||
- id
|
||||
- jobid
|
||||
- joblineid
|
||||
- partsorderid
|
||||
- priority
|
||||
- remind_at
|
||||
- remind_at_sent
|
||||
- title
|
||||
- updated_at
|
||||
filter:
|
||||
bodyshop:
|
||||
associations:
|
||||
@@ -6040,7 +6050,7 @@
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: { }
|
||||
check: {}
|
||||
- table:
|
||||
name: tt_approval_queue
|
||||
schema: public
|
||||
@@ -6189,6 +6199,13 @@
|
||||
table:
|
||||
name: email_audit_trail
|
||||
schema: public
|
||||
- name: employees
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_email
|
||||
table:
|
||||
name: employees
|
||||
schema: public
|
||||
- name: eula_acceptances
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -6238,13 +6255,6 @@
|
||||
table:
|
||||
name: parts_orders
|
||||
schema: public
|
||||
- name: tasks
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: assigned_to
|
||||
table:
|
||||
name: tasks
|
||||
schema: public
|
||||
- name: tasksByCreatedBy
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -6269,7 +6279,7 @@
|
||||
insert_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
check: { }
|
||||
check: {}
|
||||
columns:
|
||||
- authid
|
||||
- email
|
||||
@@ -6471,7 +6481,7 @@
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: { }
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/opensearch'
|
||||
version: 2
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."employees" drop constraint "employees_user_email_shopid_key";
|
||||
alter table "public"."employees" add constraint "employees_user_email_key" unique ("user_email");
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."employees" drop constraint "employees_user_email_key";
|
||||
alter table "public"."employees" add constraint "employees_user_email_shopid_key" unique ("user_email", "shopid");
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."tasks" drop constraint "tasks_created_by_fkey2";
|
||||
@@ -0,0 +1,5 @@
|
||||
alter table "public"."tasks"
|
||||
add constraint "tasks_created_by_fkey2"
|
||||
foreign key ("created_by")
|
||||
references "public"."users"
|
||||
("email") on update restrict on delete restrict;
|
||||
@@ -0,0 +1,5 @@
|
||||
alter table "public"."tasks"
|
||||
add constraint "tasks_created_by_fkey2"
|
||||
foreign key ("created_by")
|
||||
references "public"."users"
|
||||
("email") on update restrict on delete restrict;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."tasks" drop constraint "tasks_created_by_fkey2";
|
||||
@@ -0,0 +1,5 @@
|
||||
alter table "public"."tasks"
|
||||
add constraint "tasks_assigned_to_fkey"
|
||||
foreign key ("assigned_to")
|
||||
references "public"."users"
|
||||
("email") on update restrict on delete restrict;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."tasks" drop constraint "tasks_assigned_to_fkey";
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."tasks" alter column "assigned_to" drop not null;
|
||||
alter table "public"."tasks" add column "assigned_to" text;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."tasks" drop column "assigned_to" cascade;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."tasks" add column "assigned_to" uuid
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."tasks" add column "assigned_to" uuid
|
||||
null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."tasks" drop constraint "tasks_assigned_to_fkey";
|
||||
@@ -0,0 +1,5 @@
|
||||
alter table "public"."tasks"
|
||||
add constraint "tasks_assigned_to_fkey"
|
||||
foreign key ("assigned_to")
|
||||
references "public"."employees"
|
||||
("id") on update restrict on delete restrict;
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."tasks_jobid";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "tasks_jobid" on
|
||||
"public"."tasks" using btree ("jobid");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."tasks_assigned-to";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "tasks_assigned-to" on
|
||||
"public"."tasks" using btree ("assigned_to");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."tasks_joblineid";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "tasks_joblineid" on
|
||||
"public"."tasks" using btree ("joblineid");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."tasks_partsorderid";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "tasks_partsorderid" on
|
||||
"public"."tasks" using btree ("partsorderid");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."tasks_remind_at";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "tasks_remind_at" on
|
||||
"public"."tasks" using btree ("remind_at");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."tasks_remind_at_sent";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "tasks_remind_at_sent" on
|
||||
"public"."tasks" using btree ("remind_at_sent");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."tasks_bodyshopid";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "tasks_bodyshopid" on
|
||||
"public"."tasks" using btree ("bodyshopid");
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@opensearch-project/opensearch": "^2.5.0",
|
||||
"aws4": "^1.12.0",
|
||||
"axios": "^1.6.5",
|
||||
"better-queue": "^3.8.12",
|
||||
"bluebird": "^3.7.2",
|
||||
"body-parser": "^1.20.2",
|
||||
"cloudinary": "^2.0.2",
|
||||
@@ -2860,6 +2861,21 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/better-queue": {
|
||||
"version": "3.8.12",
|
||||
"resolved": "https://registry.npmjs.org/better-queue/-/better-queue-3.8.12.tgz",
|
||||
"integrity": "sha512-D9KZ+Us+2AyaCz693/9AyjTg0s8hEmkiM/MB3i09cs4MdK1KgTSGJluXRYmOulR69oLZVo2XDFtqsExDt8oiLA==",
|
||||
"dependencies": {
|
||||
"better-queue-memory": "^1.0.1",
|
||||
"node-eta": "^0.9.0",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/better-queue-memory": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/better-queue-memory/-/better-queue-memory-1.0.4.tgz",
|
||||
"integrity": "sha512-SWg5wFIShYffEmJpI6LgbL8/3Dqhku7xI1oEiy6FroP9DbcZlG0ZDjxvPdP9t7hTGW40IpIcC6zVoGT1oxjOuA=="
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
|
||||
@@ -5493,6 +5509,11 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-eta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/node-eta/-/node-eta-0.9.0.tgz",
|
||||
"integrity": "sha512-mTCTZk29tmX1OGfVkPt63H3c3VqXrI2Kvua98S7iUIB/Gbp0MNw05YtUomxQIxnnKMyRIIuY9izPcFixzhSBrA=="
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@opensearch-project/opensearch": "^2.5.0",
|
||||
"aws4": "^1.12.0",
|
||||
"axios": "^1.6.5",
|
||||
"better-queue": "^3.8.12",
|
||||
"bluebird": "^3.7.2",
|
||||
"body-parser": "^1.20.2",
|
||||
"cloudinary": "^2.0.2",
|
||||
|
||||
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%; padding-right:10px">${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
|
||||
};
|
||||
|
||||
302
server/email/tasksEmails.js
Normal file
302
server/email/tasksEmails.js
Normal file
@@ -0,0 +1,302 @@
|
||||
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 { taskEmailQueue } = require("./tasksEmailsQueue");
|
||||
|
||||
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.
|
||||
});
|
||||
|
||||
// Initialize the Tasks Email Queue
|
||||
const tasksEmailQueue = taskEmailQueue();
|
||||
|
||||
// Cleanup function for the Tasks Email Queue
|
||||
const tasksEmailQueueCleanup = async () => {
|
||||
try {
|
||||
// Example async operation
|
||||
console.log("Performing Tasks Email Reminder process cleanup...");
|
||||
await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve()));
|
||||
} catch (err) {
|
||||
console.error("Tasks Email Reminder process cleanup failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handling SIGINT (e.g., Ctrl+C)
|
||||
process.on("SIGINT", async () => {
|
||||
await tasksEmailQueueCleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
// Handling SIGTERM (e.g., sent by system shutdown)
|
||||
process.on("SIGTERM", async () => {
|
||||
await tasksEmailQueueCleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
// Handling uncaught exceptions
|
||||
process.on("uncaughtException", async (err) => {
|
||||
await tasksEmailQueueCleanup();
|
||||
process.exit(1); // Exit with an 'error' code
|
||||
});
|
||||
// Handling unhandled promise rejections
|
||||
process.on("unhandledRejection", async (reason, promise) => {
|
||||
await tasksEmailQueueCleanup();
|
||||
process.exit(1); // Exit with an 'error' code
|
||||
});
|
||||
|
||||
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")}` : "";
|
||||
};
|
||||
|
||||
const formatPriority = (priority) => {
|
||||
if (priority === 1) {
|
||||
return "High";
|
||||
} else if (priority === 3) {
|
||||
return "Low";
|
||||
} else {
|
||||
return "Medium";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the email template arguments.
|
||||
* @param title
|
||||
* @param priority
|
||||
* @param description
|
||||
* @param dueDate
|
||||
* @param bodyshop
|
||||
* @param job
|
||||
* @param taskId
|
||||
* @returns {{header, body: string, subHeader: string}}
|
||||
*/
|
||||
const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId) => {
|
||||
return {
|
||||
header: title,
|
||||
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)}`,
|
||||
body: `Reference: ${job.ro_number || "N/A"} | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()}<br>${description ? description.concat("<br>") : ""}<a href="${endPoints}/manage/tasks/alltasks?taskid=${taskId}">View this task.</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", () => {
|
||||
// Note: This is commented out because despite being in the documentation, it does not work
|
||||
// and stackoverflow suggests it is not needed
|
||||
// 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?.body?.event?.data?.new) {
|
||||
return res.status(400).json({ message: "No data in the event body" });
|
||||
}
|
||||
|
||||
const { new: newTask } = req.body.event.data;
|
||||
|
||||
// This is not a new task, but a reassignment.
|
||||
const dirty = req.body.event.data?.old && req.body.event.data?.old?.assigned_to;
|
||||
|
||||
//Query to get the employee assigned currently.
|
||||
const { tasks_by_pk } = await client.request(queries.QUERY_TASK_BY_ID, {
|
||||
id: newTask.id
|
||||
});
|
||||
|
||||
sendMail(
|
||||
"assigned",
|
||||
tasks_by_pk.assigned_to_employee.user_email,
|
||||
`A ${formatPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
|
||||
generateEmailTemplate(
|
||||
generateTemplateArgs(
|
||||
newTask.title,
|
||||
newTask.priority,
|
||||
newTask.description,
|
||||
newTask.due_date,
|
||||
tasks_by_pk.bodyshop,
|
||||
tasks_by_pk.job,
|
||||
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_employee.user_email;
|
||||
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 ${formatPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
|
||||
|
||||
emailData.html = generateEmailTemplate(
|
||||
generateTemplateArgs(
|
||||
onlyTask.title,
|
||||
onlyTask.priority,
|
||||
onlyTask.description,
|
||||
onlyTask.due_date,
|
||||
onlyTask.bodyshop,
|
||||
onlyTask.job,
|
||||
onlyTask.id
|
||||
)
|
||||
);
|
||||
}
|
||||
// There are multiple emails to send to this author.
|
||||
else {
|
||||
const allTasks = groupedTasks[recipient.email];
|
||||
emailData.subject = `New Tasks Reminder - ${allTasks.length} Tasks require your attention`;
|
||||
emailData.html = generateEmailTemplate({
|
||||
header: `${allTasks.length} Tasks require your attention`,
|
||||
subHeader: `Please click on the Tasks below to view the Task.`,
|
||||
body: `<ul>
|
||||
${allTasks
|
||||
.map((task) =>
|
||||
`<li><a href="${endPoints}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
|
||||
)
|
||||
.join("")}
|
||||
</ul>`
|
||||
});
|
||||
}
|
||||
|
||||
if (emailData?.subject && emailData?.html) {
|
||||
// Send Email
|
||||
sendMail("remind", emailData.to, emailData.subject, emailData.html, taskIds, (taskIds) => {
|
||||
for (const taskId of taskIds) {
|
||||
tasksEmailQueue.push(taskId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
};
|
||||
37
server/email/tasksEmailsQueue.js
Normal file
37
server/email/tasksEmailsQueue.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const path = require("path");
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
const Queue = require("better-queue");
|
||||
const moment = require("moment");
|
||||
const { client } = require("../graphql-client/graphql-client");
|
||||
const { UPDATE_TASKS_REMIND_AT_SENT } = require("../graphql-client/queries");
|
||||
const logger = require("../utils/logger");
|
||||
const taskEmailQueue = () =>
|
||||
new Queue(
|
||||
(taskIds, cb) => {
|
||||
console.log("Processing reminds for taskIds: ", taskIds.join(", "));
|
||||
|
||||
// Set the remind_at_sent to the current time.
|
||||
const now = moment().toISOString();
|
||||
|
||||
client
|
||||
.request(UPDATE_TASKS_REMIND_AT_SENT, { taskIds, now })
|
||||
.then((taskResponse) => {
|
||||
logger.log("task-remind-email-queue", "info", null, null, taskResponse);
|
||||
cb(null, taskResponse);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.log("task-remind-email-queue", "error", null, null, err);
|
||||
cb(err);
|
||||
});
|
||||
},
|
||||
{
|
||||
batchSize: 50,
|
||||
batchDelay: 5000,
|
||||
// The lower this is, the more likely we are to hit the rate limit.
|
||||
batchDelayTimeout: 1000
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = { taskEmailQueue };
|
||||
@@ -2393,3 +2393,77 @@ 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
|
||||
description
|
||||
due_date
|
||||
created_by
|
||||
assigned_to
|
||||
assigned_to_employee {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
remind_at
|
||||
remind_at_sent
|
||||
priority
|
||||
job {
|
||||
id
|
||||
ownr_co_nm
|
||||
ownr_fn
|
||||
ownr_ln
|
||||
v_make_desc
|
||||
v_model_desc
|
||||
v_model_yr
|
||||
ro_number
|
||||
}
|
||||
jobid
|
||||
bodyshop {
|
||||
shopname
|
||||
}
|
||||
bodyshopid
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.QUERY_TASK_BY_ID = `
|
||||
query QUERY_TASK_BY_ID($id: uuid!) {
|
||||
tasks_by_pk(id: $id) {
|
||||
id
|
||||
assigned_to_employee{
|
||||
id
|
||||
user_email
|
||||
}
|
||||
bodyshop{
|
||||
shopname
|
||||
}
|
||||
job{
|
||||
ro_number
|
||||
ownr_fn
|
||||
ownr_ln
|
||||
ownr_co_nm
|
||||
v_make_desc
|
||||
v_model_desc
|
||||
v_model_yr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
@@ -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