Merged in test-AIO (pull request #1432)

Test AIO
This commit is contained in:
Patrick Fic
2024-04-19 19:43:56 +00:00
83 changed files with 16019 additions and 10239 deletions

View File

@@ -1,5 +1,8 @@
{
"extends": [
"react-app"
]
],
"rules": {
"no-useless-rename": "off"
}
}

View File

@@ -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 } }}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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,
});

View File

@@ -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}
>

View File

@@ -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 = [

View File

@@ -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)

View File

@@ -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",

View File

@@ -176,7 +176,6 @@ export function JobsList({ bodyshop, setJoyRideSteps }) {
[],
onFilter: (value, record) => value.includes(record.status)
},
{
title: t("jobs.fields.vehicle"),
dataIndex: "vehicle",

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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>

View 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>
);
}

View 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}
/>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -59,6 +59,10 @@ export const QUERY_BILLS_BY_JOBID = gql`
name
email
}
tasks {
id
due_date
}
order_date
deliver_by
return

View File

@@ -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) {

View File

@@ -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) {

View 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
}
}
}
`;

View File

@@ -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}
/>
)
}
]}
/>

View File

@@ -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={

View 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);

View 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);

View File

@@ -0,0 +1,6 @@
export const TaskPageTypes = {
MY_TASKS: "myTasks",
ALL_TASKS: "allTasks"
};
export default TaskPageTypes;

View 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 <></>;
}
}

View File

@@ -13,6 +13,7 @@ const INITIAL_STATE = {
billEnter: { ...baseModal },
courtesyCarReturn: { ...baseModal },
noteUpsert: { ...baseModal },
taskUpsert: { ...baseModal },
schedule: { ...baseModal },
partsOrder: { ...baseModal },
timeTicket: { ...baseModal },

View File

@@ -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

View File

@@ -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;

View File

@@ -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"
}
}
: {}),

View File

@@ -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];
}
})
);
}

View File

@@ -1,6 +1,6 @@
actions: [ ]
actions: []
custom_types:
enums: [ ]
input_objects: [ ]
objects: [ ]
scalars: [ ]
enums: []
input_objects: []
objects: []
scalars: []

View File

@@ -1 +1 @@
[ ]
[]

View File

@@ -1 +1 @@
{ }
{}

View File

@@ -1 +1 @@
[ ]
[]

View File

@@ -1 +1 @@
[ ]
[]

View File

@@ -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

View File

@@ -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");

View File

@@ -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");

View File

@@ -0,0 +1 @@
alter table "public"."tasks" drop constraint "tasks_created_by_fkey2";

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
alter table "public"."tasks" drop constraint "tasks_created_by_fkey2";

View File

@@ -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;

View File

@@ -0,0 +1 @@
alter table "public"."tasks" drop constraint "tasks_assigned_to_fkey";

View File

@@ -0,0 +1,2 @@
alter table "public"."tasks" alter column "assigned_to" drop not null;
alter table "public"."tasks" add column "assigned_to" text;

View File

@@ -0,0 +1 @@
alter table "public"."tasks" drop column "assigned_to" cascade;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."tasks" add column "assigned_to" uuid
null;

View File

@@ -0,0 +1 @@
alter table "public"."tasks" drop constraint "tasks_assigned_to_fkey";

View File

@@ -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;

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."tasks_jobid";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "tasks_jobid" on
"public"."tasks" using btree ("jobid");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."tasks_assigned-to";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "tasks_assigned-to" on
"public"."tasks" using btree ("assigned_to");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."tasks_joblineid";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "tasks_joblineid" on
"public"."tasks" using btree ("joblineid");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."tasks_partsorderid";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "tasks_partsorderid" on
"public"."tasks" using btree ("partsorderid");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."tasks_remind_at";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "tasks_remind_at" on
"public"."tasks" using btree ("remind_at");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."tasks_remind_at_sent";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "tasks_remind_at_sent" on
"public"."tasks" using btree ("remind_at_sent");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."tasks_bodyshopid";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "tasks_bodyshopid" on
"public"."tasks" using btree ("bodyshopid");

21
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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%">&nbsp;</p></td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>` +
end
);
};
module.exports = generateEmailTemplate;

2614
server/email/html.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
};

View 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 };

View File

@@ -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
}
}
}
`;

View File

@@ -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);