Compare commits
14 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
646754732d | ||
|
|
efc1157653 | ||
|
|
a5d3f2caf1 | ||
|
|
4ad87a522c | ||
|
|
145cf7cc93 | ||
|
|
cdb2d4d2d6 | ||
|
|
29f0031c1e | ||
|
|
e3059b41ae | ||
|
|
2a33f462a3 | ||
|
|
cbc164dbeb | ||
|
|
6382fdf19c | ||
|
|
9287e6608d | ||
|
|
d221763064 | ||
|
|
b39a5b755e |
@@ -4730,6 +4730,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>batchid</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>bill_allow_post_to_closed</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -4856,6 +4877,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>companycode</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>country</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -5564,6 +5606,53 @@
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
<name>intellipay_config</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>cash_discount_percentage</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>enable_cash_discount</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<concept_node>
|
||||
<name>invoice_federal_tax_rate</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -11109,6 +11198,48 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>intellipay</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>intellipay_cash_discount</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>jobstatuses</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -22230,6 +22361,27 @@
|
||||
<folder_node>
|
||||
<name>buttons</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>create_short_link</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>goback</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -49635,6 +49787,48 @@
|
||||
<folder_node>
|
||||
<name>templates</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>adp_payroll_flat</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>adp_payroll_straight</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>anticipated_revenue</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
|
||||
8
client/package-lock.json
generated
8
client/package-lock.json
generated
@@ -47,7 +47,7 @@
|
||||
"query-string": "^9.1.0",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.14.1",
|
||||
"react-big-calendar": "^1.13.2",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^7.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -14671,9 +14671,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-big-calendar": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.14.1.tgz",
|
||||
"integrity": "sha512-6Le0kV/4yiV/mlqv5YYBBS+FaBeYBPNGjcYitLoVdPCiXsc0xzSHyX8+2FRqX9AM16XZYIjjomouK3wcnq6+XQ==",
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.13.2.tgz",
|
||||
"integrity": "sha512-yzeVRM1I+JloeJXytrZx2lJWKUfLAi5bsgGuBjh3aFSHZrdFcGnfA7LE6pBacdyOG+NGP+332m2MziszkmQWcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.7",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"query-string": "^9.1.0",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.14.1",
|
||||
"react-big-calendar": "^1.13.2",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^7.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { DeleteFilled, CopyFilled } from "@ant-design/icons";
|
||||
import { useLazyQuery, useMutation } from "@apollo/client";
|
||||
import { Button, Card, Col, Form, Input, Row, Space, Spin, Statistic, notification } from "antd";
|
||||
import { Button, Card, Col, Form, Input, Row, Space, Spin, Statistic, message, notification } from "antd";
|
||||
import axios from "axios";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,10 +14,12 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
||||
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
|
||||
import { getCurrentUser } from "../../firebase/firebase.utils";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
cardPaymentModal: selectCardPayment,
|
||||
bodyshop: selectBodyshop
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: getCurrentUser
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
@@ -25,11 +27,17 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment"))
|
||||
});
|
||||
|
||||
const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisible, insertAuditTrail }) => {
|
||||
const CardPaymentModalComponent = ({
|
||||
bodyshop,
|
||||
currentUser,
|
||||
cardPaymentModal,
|
||||
toggleModalVisible,
|
||||
insertAuditTrail
|
||||
}) => {
|
||||
const { context, actions } = cardPaymentModal;
|
||||
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [paymentLink, setPaymentLink] = useState();
|
||||
const [loading, setLoading] = useState(false);
|
||||
// const [insertPayment] = useMutation(INSERT_NEW_PAYMENT);
|
||||
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
|
||||
@@ -51,8 +59,7 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
|
||||
//2024-04-25: Nothing is going to happen here anymore. We'll completely rely on the callback.
|
||||
//Add a slight delay to allow the refetch to properly get the data.
|
||||
setTimeout(() => {
|
||||
if (actions && actions.refetch && typeof actions.refetch === "function")
|
||||
actions.refetch();
|
||||
if (actions && actions.refetch && typeof actions.refetch === "function") actions.refetch();
|
||||
setLoading(false);
|
||||
toggleModalVisible();
|
||||
}, 750);
|
||||
@@ -86,7 +93,6 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleIntelliPayCharge = async () => {
|
||||
setLoading(true);
|
||||
//Validate
|
||||
@@ -101,7 +107,7 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
|
||||
const response = await axios.post("/intellipay/lightbox_credentials", {
|
||||
bodyshop,
|
||||
refresh: !!window.intellipay,
|
||||
paymentSplitMeta: form.getFieldsValue(),
|
||||
paymentSplitMeta: form.getFieldsValue()
|
||||
});
|
||||
|
||||
if (window.intellipay) {
|
||||
@@ -126,6 +132,42 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
|
||||
}
|
||||
};
|
||||
|
||||
const handleIntelliPayChargeShortLink = async () => {
|
||||
setLoading(true);
|
||||
//Validate
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { payments } = form.getFieldsValue();
|
||||
const response = await axios.post("/intellipay/generate_payment_url", {
|
||||
bodyshop,
|
||||
amount: payments?.reduce((acc, val) => {
|
||||
return acc + (val?.amount || 0);
|
||||
}, 0),
|
||||
account: payments && data && data.jobs.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null,
|
||||
comment: btoa(JSON.stringify({ payments, userEmail: currentUser.email })),
|
||||
paymentSplitMeta: form.getFieldsValue()
|
||||
});
|
||||
if (response.data) {
|
||||
setPaymentLink(response.data?.shorUrl);
|
||||
navigator.clipboard.writeText(response.data?.shorUrl);
|
||||
message.success(t("general.actions.copied"));
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
notification.open({
|
||||
type: "error",
|
||||
message: t("job_payments.notifications.error.openingip")
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Card Payment">
|
||||
<Spin spinning={loading}>
|
||||
@@ -208,10 +250,7 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
|
||||
{() => {
|
||||
//If all of the job ids have been fileld in, then query and update the IP field.
|
||||
const { payments } = form.getFieldsValue();
|
||||
if (
|
||||
payments?.length > 0 &&
|
||||
payments?.filter((p) => p?.jobid).length === payments?.length
|
||||
) {
|
||||
if (payments?.length > 0 && payments?.filter((p) => p?.jobid).length === payments?.length) {
|
||||
refetch({ jobids: payments.map((p) => p.jobid) });
|
||||
}
|
||||
return (
|
||||
@@ -246,7 +285,6 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
|
||||
const totalAmountToCharge = payments?.reduce((acc, val) => {
|
||||
return acc + (val?.amount || 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Space style={{ float: "right" }}>
|
||||
<Statistic title="Amount To Charge" value={totalAmountToCharge} precision={2} />
|
||||
@@ -273,11 +311,36 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
|
||||
>
|
||||
{t("job_payments.buttons.proceedtopayment")}
|
||||
</Button>
|
||||
<Space direction="vertical" align="center">
|
||||
<Button
|
||||
type="primary"
|
||||
// data-ipayname="submit"
|
||||
className="ipayfield"
|
||||
loading={queryLoading || loading}
|
||||
disabled={!(totalAmountToCharge > 0)}
|
||||
onClick={handleIntelliPayChargeShortLink}
|
||||
>
|
||||
{t("job_payments.buttons.create_short_link")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{paymentLink && (
|
||||
<Space
|
||||
style={{ cursor: "pointer", float: "right" }}
|
||||
align="end"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(paymentLink);
|
||||
message.success(t("general.actions.copied"));
|
||||
}}
|
||||
>
|
||||
<div>{paymentLink}</div>
|
||||
<CopyFilled />
|
||||
</Space>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React from "react";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,61 +11,55 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
export function ScheduleEventColor({ bodyshop, event }) {
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onClick = useCallback(
|
||||
async ({ key }) => {
|
||||
const result = await updateAppointment({
|
||||
variables: {
|
||||
appid: event.id,
|
||||
app: { color: key === "null" ? null : key }
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.errors) {
|
||||
notification.success({ message: t("appointments.successes.saved") });
|
||||
} else {
|
||||
notification.error({
|
||||
message: t("appointments.errors.saving", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
const onClick = async ({ key }) => {
|
||||
const result = await updateAppointment({
|
||||
variables: {
|
||||
appid: event.id,
|
||||
app: { color: key === "null" ? null : key }
|
||||
}
|
||||
},
|
||||
[event.id, t, updateAppointment]
|
||||
);
|
||||
});
|
||||
|
||||
const selectedColor = useMemo(() => {
|
||||
if (event.color && bodyshop.appt_colors) {
|
||||
const colorObj = bodyshop.appt_colors.find((color) => color.color.hex === event.color);
|
||||
return colorObj?.label;
|
||||
if (!!!result.errors) {
|
||||
notification["success"]({ message: t("appointments.successes.saved") });
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("appointments.errors.saving", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [event.color, bodyshop.appt_colors]);
|
||||
};
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
defaultSelectedKeys: [event.color],
|
||||
onClick: onClick,
|
||||
items: [
|
||||
...(bodyshop.appt_colors || []).map((color) => ({
|
||||
key: color.color.hex,
|
||||
label: color.label,
|
||||
style: { color: color.color.hex }
|
||||
})),
|
||||
{ type: "divider" },
|
||||
{ key: "null", label: t("general.actions.clear") }
|
||||
]
|
||||
}),
|
||||
[bodyshop.appt_colors, event.color, onClick, t]
|
||||
);
|
||||
const selectedColor =
|
||||
event.color &&
|
||||
bodyshop.appt_colors &&
|
||||
bodyshop.appt_colors.filter((color) => color.color.hex === event.color)[0]?.label;
|
||||
|
||||
const menu = {
|
||||
defaultSelectedKeys: [event.color],
|
||||
onClick: onClick,
|
||||
items: [
|
||||
...(bodyshop.appt_colors || []).map((color) => ({
|
||||
key: color.color.hex,
|
||||
label: color.label,
|
||||
style: { color: color.color.hex }
|
||||
})),
|
||||
{ type: "divider" },
|
||||
{ key: "null", label: t("general.actions.clear") }
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={menu}>
|
||||
<a href="#" onClick={(e) => e.preventDefault()}>
|
||||
<a href=" #" onClick={(e) => e.preventDefault()}>
|
||||
{selectedColor}
|
||||
<DownOutlined />
|
||||
</a>
|
||||
@@ -73,4 +67,4 @@ export function ScheduleEventColor({ bodyshop, event }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(React.memo(ScheduleEventColor));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleEventColor);
|
||||
|
||||
@@ -2,10 +2,11 @@ import { AlertFilled } from "@ant-design/icons";
|
||||
import { Button, Divider, Dropdown, Form, Input, notification, Popover, Select, Space } from "antd";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import dayjs from "../../utils/day";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import queryString from "query-string";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
@@ -26,7 +27,6 @@ import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })),
|
||||
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
|
||||
@@ -44,319 +44,301 @@ export function ScheduleEventComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const history = useNavigate();
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||
const [title, setTitle] = useState(event.title);
|
||||
|
||||
const handleTitleBlur = useCallback(async () => {
|
||||
await updateAppointment({
|
||||
variables: {
|
||||
appid: event.id,
|
||||
app: {
|
||||
title: title
|
||||
}
|
||||
},
|
||||
optimisticResponse: {
|
||||
update_appointments: {
|
||||
__typename: "appointments_mutation_response",
|
||||
returning: [
|
||||
{
|
||||
...event,
|
||||
title: title,
|
||||
__typename: "appointments"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [updateAppointment, event, title]);
|
||||
|
||||
const handleUnblock = useCallback(() => {
|
||||
handleCancel({ id: event.id });
|
||||
}, [handleCancel, event.id]);
|
||||
|
||||
const handlePreviewClick = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("selected", event.job?.id);
|
||||
navigate({ search: `?${params.toString()}` });
|
||||
}, [navigate, searchParams, event.job?.id]);
|
||||
|
||||
const handleSendEmailReminder = useCallback(() => {
|
||||
const Template = TemplateList("job").appointment_reminder;
|
||||
GenerateDocument(
|
||||
{
|
||||
name: Template.key,
|
||||
variables: { id: event.job.id }
|
||||
},
|
||||
{
|
||||
to: event.job && event.job.ownr_ea,
|
||||
subject: Template.subject
|
||||
},
|
||||
"e",
|
||||
event.job && event.job.id
|
||||
);
|
||||
}, [event.job]);
|
||||
|
||||
const handleSendSMSReminder = useCallback(() => {
|
||||
const p = parsePhoneNumber(event.job.ownr_ph1, "CA");
|
||||
if (p && p.isValid()) {
|
||||
openChatByPhone({
|
||||
phone_num: p.formatInternational(),
|
||||
jobid: event.job.id
|
||||
});
|
||||
setMessage(
|
||||
t("appointments.labels.reminder", {
|
||||
shopname: bodyshop.shopname,
|
||||
date: dayjs(event.start).format("MM/DD/YYYY"),
|
||||
time: dayjs(event.start).format("HH:mm a")
|
||||
})
|
||||
);
|
||||
setOpen(false);
|
||||
} else {
|
||||
notification.error({
|
||||
message: t("messaging.error.invalidphone")
|
||||
});
|
||||
}
|
||||
}, [event.job, openChatByPhone, setMessage, t, bodyshop.shopname, event.start, setOpen]);
|
||||
|
||||
const reminderMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "email",
|
||||
label: t("general.labels.email"),
|
||||
disabled: event.arrived,
|
||||
onClick: handleSendEmailReminder
|
||||
},
|
||||
{
|
||||
key: "sms",
|
||||
label: t("general.labels.sms"),
|
||||
disabled: event.arrived || !bodyshop.messagingservicesid,
|
||||
onClick: handleSendSMSReminder
|
||||
}
|
||||
],
|
||||
[t, event.arrived, handleSendEmailReminder, handleSendSMSReminder, bodyshop.messagingservicesid]
|
||||
);
|
||||
|
||||
const reminderMenu = useMemo(() => ({ items: reminderMenuItems }), [reminderMenuItems]);
|
||||
|
||||
const handleCancelFormFinish = useCallback(
|
||||
({ lost_sale_reason }) => {
|
||||
handleCancel({ id: event.id, lost_sale_reason });
|
||||
},
|
||||
[handleCancel, event.id]
|
||||
);
|
||||
|
||||
const handleRescheduleClick = useCallback(() => {
|
||||
setOpen(false);
|
||||
setScheduleContext({
|
||||
actions: { refetch: refetch },
|
||||
context: {
|
||||
jobId: event.job.id,
|
||||
job: event.job,
|
||||
previousEvent: event.id,
|
||||
color: event.color,
|
||||
alt_transport: event.job && event.job.alt_transport,
|
||||
note: event.note
|
||||
}
|
||||
});
|
||||
}, [setOpen, setScheduleContext, refetch, event]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(vis) => {
|
||||
if (!event.vacation) setOpen(vis);
|
||||
},
|
||||
[event.vacation]
|
||||
);
|
||||
|
||||
const blockContent = useMemo(
|
||||
() => (
|
||||
<Space direction="vertical" wrap>
|
||||
<Input value={title} onChange={(e) => setTitle(e.currentTarget.value)} onBlur={handleTitleBlur} />
|
||||
|
||||
<Button onClick={handleUnblock} disabled={event.arrived}>
|
||||
{t("appointments.actions.unblock")}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
[title, handleTitleBlur, handleUnblock, event.arrived, t]
|
||||
);
|
||||
|
||||
const popoverContent = useMemo(() => {
|
||||
console.log("hit");
|
||||
return (
|
||||
<div style={{ maxWidth: "40vw" }}>
|
||||
{!event.isintake ? (
|
||||
<Space>
|
||||
<strong>{event.title}</strong>
|
||||
<ScheduleEventColor event={event} />
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<strong>
|
||||
<OwnerNameDisplay ownerObject={event.job} />
|
||||
</strong>
|
||||
<span style={{ margin: 4 }}>
|
||||
{`${(event.job && event.job.v_model_yr) || ""} ${
|
||||
(event.job && event.job.v_make_desc) || ""
|
||||
} ${(event.job && event.job.v_model_desc) || ""}`}
|
||||
</span>
|
||||
<ScheduleEventColor event={event} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{event.job ? (
|
||||
<div>
|
||||
<DataLabel label={t("jobs.fields.ro_number")}>{(event.job && event.job.ro_number) || ""}</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.clm_total")}>
|
||||
<CurrencyFormatter>{(event.job && event.job.clm_total) || ""}</CurrencyFormatter>
|
||||
</DataLabel>
|
||||
<DataLabel hideIfNull label={t("jobs.fields.ins_co_nm")}>
|
||||
{(event.job && event.job.ins_co_nm) || ""}
|
||||
</DataLabel>
|
||||
<DataLabel hideIfNull label={t("jobs.fields.clm_no")}>
|
||||
{(event.job && event.job.clm_no) || ""}
|
||||
</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.ownr_ea")}>{(event.job && event.job.ownr_ea) || ""}</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.ownr_ph1")}>
|
||||
<ChatOpenButton phone={event.job && event.job.ownr_ph1} jobid={event.job.id} />
|
||||
</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.ownr_ph2")}>
|
||||
<ChatOpenButton phone={event.job && event.job.ownr_ph2} jobid={event.job.id} />
|
||||
</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.alt_transport")}>
|
||||
{(event.job && event.job.alt_transport) || ""}
|
||||
<ScheduleAtChange job={event && event.job} />
|
||||
</DataLabel>
|
||||
<ScheduleEventNote event={event} />
|
||||
</div>
|
||||
) : (
|
||||
<div>{event.note || ""}</div>
|
||||
)}
|
||||
<Divider />
|
||||
<Space wrap>
|
||||
{event.job ? (
|
||||
<Link to={`/manage/jobs/${event.job && event.job.id}`}>
|
||||
<Button>{t("appointments.actions.viewjob")}</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{event.job ? <Button onClick={handlePreviewClick}>{t("appointments.actions.preview")}</Button> : null}
|
||||
{event.job ? (
|
||||
<Dropdown menu={reminderMenu}>
|
||||
<Button>{t("appointments.actions.sendreminder")}</Button>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
{event.arrived ? (
|
||||
<Button disabled={event.arrived}>{t("appointments.actions.cancel")}</Button>
|
||||
) : (
|
||||
<Popover
|
||||
trigger="click"
|
||||
disabled={event.arrived}
|
||||
content={
|
||||
<Form layout="vertical" onFinish={handleCancelFormFinish}>
|
||||
<Form.Item
|
||||
name="lost_sale_reason"
|
||||
label={t("jobs.fields.lost_sale_reason")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
options={bodyshop.md_lost_sale_reasons.map((lsr) => ({
|
||||
label: lsr,
|
||||
value: lsr
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button htmlType="submit">{t("appointments.actions.cancel")}</Button>
|
||||
</Form>
|
||||
const blockContent = (
|
||||
<Space direction="vertical" wrap>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
onBlur={async () => {
|
||||
await updateAppointment({
|
||||
variables: {
|
||||
appid: event.id,
|
||||
app: {
|
||||
title: title
|
||||
}
|
||||
>
|
||||
<Button disabled={event.arrived}>{t("appointments.actions.cancel")}</Button>
|
||||
</Popover>
|
||||
)}
|
||||
},
|
||||
optimisticResponse: {
|
||||
update_appointments: {
|
||||
__typename: "appointments_mutation_response",
|
||||
returning: [
|
||||
{
|
||||
...event,
|
||||
title: title,
|
||||
__typename: "appointments"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{event.isintake ? (
|
||||
<Button disabled={event.arrived} onClick={handleRescheduleClick}>
|
||||
{t("appointments.actions.reschedule")}
|
||||
</Button>
|
||||
) : (
|
||||
<ScheduleManualEvent event={event} />
|
||||
)}
|
||||
{event.isintake ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
|
||||
search: `?appointmentId=${event.id}`
|
||||
}}
|
||||
>
|
||||
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
event,
|
||||
t,
|
||||
handlePreviewClick,
|
||||
reminderMenu,
|
||||
bodyshop.md_lost_sale_reasons,
|
||||
handleCancelFormFinish,
|
||||
handleRescheduleClick
|
||||
]);
|
||||
<Button onClick={() => handleCancel({ id: event.id })} disabled={event.arrived}>
|
||||
{t("appointments.actions.unblock")}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
const RegularEvent = useMemo(
|
||||
() =>
|
||||
event.isintake ? (
|
||||
<Space
|
||||
wrap
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
|
||||
}}
|
||||
>
|
||||
{event.note && <AlertFilled className="production-alert" />}
|
||||
<strong>{`${event.job.ro_number || t("general.labels.na")}`}</strong>
|
||||
|
||||
<OwnerNameDisplay ownerObject={event.job} />
|
||||
|
||||
{`${(event.job && event.job.v_model_yr) || ""} ${
|
||||
(event.job && event.job.v_make_desc) || ""
|
||||
} ${(event.job && event.job.v_model_desc) || ""}`}
|
||||
|
||||
{`(${(event.job && event.job.labhrs.aggregate.sum.mod_lb_hrs) || "0"} / ${
|
||||
(event.job && event.job.larhrs.aggregate.sum.mod_lb_hrs) || "0"
|
||||
})`}
|
||||
|
||||
{event.job && event.job.alt_transport && <div style={{ margin: ".1rem" }}>{event.job.alt_transport}</div>}
|
||||
const popoverContent = (
|
||||
<div style={{ maxWidth: "40vw" }}>
|
||||
{!event.isintake ? (
|
||||
<Space>
|
||||
<strong>{event.title}</strong>
|
||||
<ScheduleEventColor event={event} />
|
||||
</Space>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
|
||||
}}
|
||||
>
|
||||
<strong>{`${event.title || ""}`}</strong>
|
||||
<Space>
|
||||
<strong>
|
||||
<OwnerNameDisplay ownerObject={event.job} />
|
||||
</strong>
|
||||
<span style={{ margin: 4 }}>
|
||||
{`${(event.job && event.job.v_model_yr) || ""} ${
|
||||
(event.job && event.job.v_make_desc) || ""
|
||||
} ${(event.job && event.job.v_model_desc) || ""}`}
|
||||
</span>
|
||||
<ScheduleEventColor event={event} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{event.job ? (
|
||||
<div>
|
||||
<DataLabel label={t("jobs.fields.ro_number")}>{(event.job && event.job.ro_number) || ""}</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.clm_total")}>
|
||||
<CurrencyFormatter>{(event.job && event.job.clm_total) || ""}</CurrencyFormatter>
|
||||
</DataLabel>
|
||||
<DataLabel hideIfNull label={t("jobs.fields.ins_co_nm")}>
|
||||
{(event.job && event.job.ins_co_nm) || ""}
|
||||
</DataLabel>
|
||||
<DataLabel hideIfNull label={t("jobs.fields.clm_no")}>
|
||||
{(event.job && event.job.clm_no) || ""}
|
||||
</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.ownr_ea")}>{(event.job && event.job.ownr_ea) || ""}</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.ownr_ph1")}>
|
||||
<ChatOpenButton phone={event.job && event.job.ownr_ph1} jobid={event.job.id} />
|
||||
</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.ownr_ph2")}>
|
||||
<ChatOpenButton phone={event.job && event.job.ownr_ph2} jobid={event.job.id} />
|
||||
</DataLabel>
|
||||
<DataLabel label={t("jobs.fields.alt_transport")}>
|
||||
{(event.job && event.job.alt_transport) || ""}
|
||||
<ScheduleAtChange job={event && event.job} />
|
||||
</DataLabel>
|
||||
<ScheduleEventNote event={event} />
|
||||
</div>
|
||||
),
|
||||
[event, t]
|
||||
) : (
|
||||
<div>{event.note || ""}</div>
|
||||
)}
|
||||
<Divider />
|
||||
<Space wrap>
|
||||
{event.job ? (
|
||||
<Link to={`/manage/jobs/${event.job && event.job.id}`}>
|
||||
<Button>{t("appointments.actions.viewjob")}</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{event.job ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
history({
|
||||
search: queryString.stringify({
|
||||
...searchParams,
|
||||
selected: event.job.id
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("appointments.actions.preview")}
|
||||
</Button>
|
||||
) : null}
|
||||
{event.job ? (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "email",
|
||||
label: t("general.labels.email"),
|
||||
disabled: event.arrived,
|
||||
onClick: () => {
|
||||
const Template = TemplateList("job").appointment_reminder;
|
||||
GenerateDocument(
|
||||
{
|
||||
name: Template.key,
|
||||
variables: { id: event.job.id }
|
||||
},
|
||||
{
|
||||
to: event.job && event.job.ownr_ea,
|
||||
subject: Template.subject
|
||||
},
|
||||
"e",
|
||||
event.job && event.job.id
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "sms",
|
||||
label: t("general.labels.sms"),
|
||||
disabled: event.arrived || !bodyshop.messagingservicesid,
|
||||
onClick: () => {
|
||||
const p = parsePhoneNumber(event.job.ownr_ph1, "CA");
|
||||
if (p && p.isValid()) {
|
||||
openChatByPhone({
|
||||
phone_num: p.formatInternational(),
|
||||
jobid: event.job.id
|
||||
});
|
||||
setMessage(
|
||||
t("appointments.labels.reminder", {
|
||||
shopname: bodyshop.shopname,
|
||||
date: dayjs(event.start).format("MM/DD/YYYY"),
|
||||
time: dayjs(event.start).format("HH:mm a")
|
||||
})
|
||||
);
|
||||
setOpen(false);
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("messaging.error.invalidphone")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Button>{t("appointments.actions.sendreminder")}</Button>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
{event.arrived ? (
|
||||
<Button
|
||||
// onClick={() => handleCancel(event.id)}
|
||||
disabled={event.arrived}
|
||||
>
|
||||
{t("appointments.actions.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Popover
|
||||
trigger="click"
|
||||
disabled={event.arrived}
|
||||
content={
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={({ lost_sale_reason }) => {
|
||||
handleCancel({ id: event.id, lost_sale_reason });
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="lost_sale_reason"
|
||||
label={t("jobs.fields.lost_sale_reason")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
options={bodyshop.md_lost_sale_reasons.map((lsr) => ({
|
||||
label: lsr,
|
||||
value: lsr
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button htmlType="submit">{t("appointments.actions.cancel")}</Button>
|
||||
</Form>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
// onClick={() => handleCancel(event.id)}
|
||||
disabled={event.arrived}
|
||||
>
|
||||
{t("appointments.actions.cancel")}
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{event.isintake ? (
|
||||
<Button
|
||||
disabled={event.arrived}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setScheduleContext({
|
||||
actions: { refetch: refetch },
|
||||
context: {
|
||||
jobId: event.job.id,
|
||||
job: event.job,
|
||||
previousEvent: event.id,
|
||||
color: event.color,
|
||||
alt_transport: event.job && event.job.alt_transport,
|
||||
note: event.note
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("appointments.actions.reschedule")}
|
||||
</Button>
|
||||
) : (
|
||||
<ScheduleManualEvent event={event} />
|
||||
)}
|
||||
{event.isintake ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
|
||||
search: `?appointmentId=${event.id}`
|
||||
}}
|
||||
>
|
||||
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RegularEvent = event.isintake ? (
|
||||
<Space
|
||||
wrap
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
|
||||
}}
|
||||
>
|
||||
{event.note && <AlertFilled className="production-alert" />}
|
||||
<strong>{`${event.job.ro_number || t("general.labels.na")}`}</strong>
|
||||
|
||||
<OwnerNameDisplay ownerObject={event.job} />
|
||||
|
||||
{`${(event.job && event.job.v_model_yr) || ""} ${
|
||||
(event.job && event.job.v_make_desc) || ""
|
||||
} ${(event.job && event.job.v_model_desc) || ""}`}
|
||||
|
||||
{`(${(event.job && event.job.labhrs.aggregate.sum.mod_lb_hrs) || "0"} / ${
|
||||
(event.job && event.job.larhrs.aggregate.sum.mod_lb_hrs) || "0"
|
||||
})`}
|
||||
|
||||
{event.job && event.job.alt_transport && <div style={{ margin: ".1rem" }}>{event.job.alt_transport}</div>}
|
||||
</Space>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
|
||||
}}
|
||||
>
|
||||
<strong>{`${event.title || ""}`}</strong>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
onOpenChange={(vis) => !event.vacation && setOpen(vis)}
|
||||
trigger="click"
|
||||
content={event.block ? blockContent : popoverContent}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
|
||||
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
|
||||
}}
|
||||
>
|
||||
@@ -365,4 +347,4 @@ export function ScheduleEventComponent({
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(React.memo(ScheduleEventComponent));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleEventComponent);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { notification } from "antd";
|
||||
import React, { useCallback } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
@@ -10,70 +10,64 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import ScheduleEventComponent from "./schedule-event.component";
|
||||
|
||||
function ScheduleEventContainer({ bodyshop, event, refetch }) {
|
||||
export default function ScheduleEventContainer({ bodyshop, event, refetch }) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const [cancelAppointment] = useMutation(CANCEL_APPOINTMENT_BY_ID);
|
||||
const [updateJob] = useMutation(UPDATE_JOB);
|
||||
const handleCancel = async ({ id, lost_sale_reason }) => {
|
||||
logImEXEvent("schedule_cancel_appt");
|
||||
|
||||
const handleCancel = useCallback(
|
||||
async ({ id, lost_sale_reason }) => {
|
||||
logImEXEvent("schedule_cancel_appt");
|
||||
const cancelAppt = await cancelAppointment({
|
||||
variables: { appid: event.id }
|
||||
});
|
||||
notification["success"]({
|
||||
message: t("appointments.successes.canceled")
|
||||
});
|
||||
|
||||
const cancelAppt = await cancelAppointment({
|
||||
variables: { appid: event.id }
|
||||
if (!!cancelAppt.errors) {
|
||||
notification["error"]({
|
||||
message: t("appointments.errors.canceling", {
|
||||
message: JSON.stringify(cancelAppt.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelAppt.errors) {
|
||||
notification.success({
|
||||
message: t("appointments.successes.canceled")
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
message: t("appointments.errors.canceling", {
|
||||
message: JSON.stringify(cancelAppt.errors)
|
||||
if (event.job) {
|
||||
const jobUpdate = await updateJob({
|
||||
variables: {
|
||||
jobId: event.job.id,
|
||||
job: {
|
||||
date_scheduled: null,
|
||||
scheduled_in: null,
|
||||
scheduled_completion: null,
|
||||
lost_sale_reason,
|
||||
date_lost_sale: new Date(),
|
||||
status: bodyshop.md_ro_statuses.default_imported
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!jobUpdate.errors) {
|
||||
dispatch(
|
||||
insertAuditTrail({
|
||||
jobid: event.job.id,
|
||||
operation: AuditTrailMapping.appointmentcancel(lost_sale_reason),
|
||||
type: "appointmentcancel"
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!!jobUpdate.errors) {
|
||||
notification["error"]({
|
||||
message: t("jobs.errors.updating", {
|
||||
message: JSON.stringify(jobUpdate.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.job) {
|
||||
const jobUpdate = await updateJob({
|
||||
variables: {
|
||||
jobId: event.job.id,
|
||||
job: {
|
||||
date_scheduled: null,
|
||||
scheduled_in: null,
|
||||
scheduled_completion: null,
|
||||
lost_sale_reason,
|
||||
date_lost_sale: new Date(),
|
||||
status: bodyshop.md_ro_statuses.default_imported
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!jobUpdate.errors) {
|
||||
dispatch(
|
||||
insertAuditTrail({
|
||||
jobid: event.job.id,
|
||||
operation: AuditTrailMapping.appointmentcancel(lost_sale_reason),
|
||||
type: "appointmentcancel"
|
||||
})
|
||||
);
|
||||
} else {
|
||||
notification.error({
|
||||
message: t("jobs.errors.updating", {
|
||||
message: JSON.stringify(jobUpdate.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (refetch) refetch();
|
||||
},
|
||||
[cancelAppointment, event.id, event.job, updateJob, bodyshop.md_ro_statuses.default_imported, dispatch, t, refetch]
|
||||
);
|
||||
}
|
||||
if (refetch) refetch();
|
||||
};
|
||||
|
||||
return <ScheduleEventComponent event={event} refetch={refetch} handleCancel={handleCancel} />;
|
||||
}
|
||||
|
||||
export default React.memo(ScheduleEventContainer);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EditFilled, SaveFilled } from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Button, Input, notification, Space } from "antd";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -12,6 +12,9 @@ import DataLabel from "../data-label/data-label.component";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
export function ScheduleEventNote({ event }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -20,9 +23,9 @@ export function ScheduleEventNote({ event }) {
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleEdit = useCallback(async () => {
|
||||
const toggleEdit = async () => {
|
||||
if (editing) {
|
||||
// Await the update
|
||||
//Await the update
|
||||
setLoading(true);
|
||||
const result = await updateAppointment({
|
||||
variables: {
|
||||
@@ -31,10 +34,10 @@ export function ScheduleEventNote({ event }) {
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.errors) {
|
||||
// notification.success({ message: t("appointments.successes.saved") });
|
||||
if (!!!result.errors) {
|
||||
// notification["success"]({ message: t("appointments.successes.saved") });
|
||||
} else {
|
||||
notification.error({
|
||||
notification["error"]({
|
||||
message: t("jobs.errors.saving", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
@@ -42,15 +45,11 @@ export function ScheduleEventNote({ event }) {
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setEditing(true);
|
||||
}
|
||||
}, [editing, note, updateAppointment, event.id, t]);
|
||||
|
||||
const handleNoteChange = useCallback((e) => {
|
||||
setNote(e.target.value);
|
||||
}, []);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DataLabel label={t("appointments.fields.note")}>
|
||||
@@ -58,7 +57,7 @@ export function ScheduleEventNote({ event }) {
|
||||
{!editing ? (
|
||||
event.note || ""
|
||||
) : (
|
||||
<Input.TextArea rows={3} value={note} onChange={handleNoteChange} style={{ maxWidth: "8vw" }} />
|
||||
<Input.TextArea rows={3} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: "8vw" }} />
|
||||
)}
|
||||
<Button onClick={toggleEdit} loading={loading}>
|
||||
{editing ? <SaveFilled /> : <EditFilled />}
|
||||
@@ -68,4 +67,4 @@ export function ScheduleEventNote({ event }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(React.memo(ScheduleEventNote));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleEventNote);
|
||||
|
||||
@@ -8,11 +8,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
|
||||
@@ -20,7 +21,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PaymentsGenerateLink);
|
||||
|
||||
export function PaymentsGenerateLink({ bodyshop, callback, job, openChatByPhone, setMessage }) {
|
||||
export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, openChatByPhone, setMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
@@ -30,29 +31,35 @@ export function PaymentsGenerateLink({ bodyshop, callback, job, openChatByPhone,
|
||||
|
||||
const handleFinish = async ({ amount }) => {
|
||||
setLoading(true);
|
||||
|
||||
const p = parsePhoneNumber(job.ownr_ph1, "CA");
|
||||
let p;
|
||||
try {
|
||||
p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
|
||||
} catch (error) {
|
||||
console.log("Unable to parse phone number");
|
||||
}
|
||||
setLoading(true);
|
||||
const response = await axios.post("/intellipay/generate_payment_url", {
|
||||
bodyshop,
|
||||
amount: amount,
|
||||
account: job.ro_number,
|
||||
invoice: job.id
|
||||
comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
|
||||
});
|
||||
setLoading(false);
|
||||
setPaymentLink(response.data.shorUrl);
|
||||
|
||||
openChatByPhone({
|
||||
phone_num: p.formatInternational(),
|
||||
jobid: job.id
|
||||
});
|
||||
setMessage(
|
||||
t("payments.labels.smspaymentreminder", {
|
||||
shopname: bodyshop.shopname,
|
||||
amount: amount,
|
||||
payment_link: response.data.shorUrl
|
||||
})
|
||||
);
|
||||
if (p) {
|
||||
openChatByPhone({
|
||||
phone_num: p.formatInternational(),
|
||||
jobid: job.id
|
||||
});
|
||||
setMessage(
|
||||
t("payments.labels.smspaymentreminder", {
|
||||
shopname: bodyshop.shopname,
|
||||
amount: amount,
|
||||
payment_link: response.data.shorUrl
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
//Add in confirmation & errors.
|
||||
if (callback) callback();
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
import { Space } from "antd";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectScheduleLoad } from "../../redux/application/application.selectors";
|
||||
|
||||
const ScheduleAtsSummary = React.memo(function ScheduleAtsSummary({ appointments }) {
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
scheduleLoad: selectScheduleLoad
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
export function ScheduleAtsSummary({ scheduleLoad, appointments }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const atsSummary = useMemo(() => {
|
||||
let atsSummary = {};
|
||||
if (!appointments || appointments.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const summary = {};
|
||||
appointments
|
||||
.filter((a) => a.isintake && a.job?.alt_transport)
|
||||
.filter((a) => a.isintake)
|
||||
.forEach((a) => {
|
||||
const key = a.job.alt_transport;
|
||||
summary[key] = (summary[key] || 0) + 1;
|
||||
if (!a.job.alt_transport) return;
|
||||
if (!atsSummary[a.job.alt_transport]) {
|
||||
atsSummary[a.job.alt_transport] = 1;
|
||||
} else {
|
||||
atsSummary[a.job.alt_transport] = atsSummary[a.job.alt_transport] + 1;
|
||||
}
|
||||
});
|
||||
return summary;
|
||||
return atsSummary;
|
||||
}, [appointments]);
|
||||
|
||||
if (Object.keys(atsSummary).length > 0) {
|
||||
if (Object.keys(atsSummary).length > 0)
|
||||
return (
|
||||
<Space wrap>
|
||||
{t("schedule.labels.atssummary")}
|
||||
{Object.entries(atsSummary).map(([key, value]) => (
|
||||
<span key={key}>{`${key}: ${value}`}</span>
|
||||
{Object.keys(atsSummary).map((key) => (
|
||||
<span key={key}>{`${key}: ${atsSummary[key]}`}</span>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export default ScheduleAtsSummary;
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleAtsSummary);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Dropdown, notification } from "antd";
|
||||
import dayjs from "../../utils/day";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -13,61 +13,57 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const ScheduleBlockDay = React.memo(function ScheduleBlockDay({ date, children, refetch, bodyshop, alreadyBlocked }) {
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
|
||||
export function ScheduleBlockDay({ date, children, refetch, bodyshop, alreadyBlocked }) {
|
||||
const { t } = useTranslation();
|
||||
const [insertBlock] = useMutation(INSERT_APPOINTMENT_BLOCK);
|
||||
|
||||
const handleMenu = useCallback(
|
||||
async (e) => {
|
||||
e.domEvent.stopPropagation();
|
||||
const handleMenu = async (e) => {
|
||||
e.domEvent.stopPropagation();
|
||||
|
||||
if (e.key === "block") {
|
||||
const blockAppt = {
|
||||
title: t("appointments.labels.blocked"),
|
||||
block: true,
|
||||
isintake: false,
|
||||
bodyshopid: bodyshop.id,
|
||||
start: dayjs(date).startOf("day"),
|
||||
end: dayjs(date).endOf("day")
|
||||
};
|
||||
logImEXEvent("dashboard_change_layout");
|
||||
if (e.key === "block") {
|
||||
const blockAppt = {
|
||||
title: t("appointments.labels.blocked"),
|
||||
block: true,
|
||||
isintake: false,
|
||||
bodyshopid: bodyshop.id,
|
||||
start: dayjs(date).startOf("day"),
|
||||
end: dayjs(date).endOf("day")
|
||||
};
|
||||
logImEXEvent("dashboard_change_layout");
|
||||
|
||||
const result = await insertBlock({
|
||||
variables: { app: [blockAppt] }
|
||||
const result = await insertBlock({
|
||||
variables: { app: [blockAppt] }
|
||||
});
|
||||
|
||||
if (!!result.errors) {
|
||||
notification["error"]({
|
||||
message: t("appointments.errors.blocking", {
|
||||
message: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
|
||||
if (result.errors) {
|
||||
notification.error({
|
||||
message: t("appointments.errors.blocking", {
|
||||
message: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (refetch) refetch();
|
||||
}
|
||||
},
|
||||
[t, bodyshop.id, date, insertBlock, refetch]
|
||||
);
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
items: [
|
||||
{
|
||||
key: "block",
|
||||
label: t("appointments.actions.block")
|
||||
}
|
||||
],
|
||||
onClick: handleMenu
|
||||
}),
|
||||
[t, handleMenu]
|
||||
);
|
||||
if (!!refetch) refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const menu = {
|
||||
items: [
|
||||
{
|
||||
key: "block",
|
||||
label: t("appointments.actions.block")
|
||||
}
|
||||
],
|
||||
onClick: handleMenu
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={menu} disabled={alreadyBlocked} trigger={["contextMenu"]}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ScheduleBlockDay);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleBlockDay);
|
||||
|
||||
@@ -10,48 +10,55 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
const ScheduleCalendarHeaderGraph = React.memo(function ScheduleCalendarHeaderGraph({ bodyshop, loadData }) {
|
||||
export function ScheduleCalendarHeaderGraph({ bodyshop, loadData }) {
|
||||
const { ssbuckets } = bodyshop;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!loadData || !loadData.expectedLoad || !ssbuckets) return [];
|
||||
return (
|
||||
(loadData &&
|
||||
loadData.expectedLoad &&
|
||||
Object.keys(loadData.expectedLoad).map((key) => {
|
||||
const metadataBucket = ssbuckets.filter((b) => b.id === key)[0];
|
||||
|
||||
return Object.keys(loadData.expectedLoad).map((key) => {
|
||||
const metadataBucket = ssbuckets.find((b) => b.id === key);
|
||||
|
||||
return {
|
||||
bucket: loadData.expectedLoad[key].label,
|
||||
current: loadData.expectedLoad[key].count,
|
||||
target: metadataBucket?.target || 0
|
||||
};
|
||||
});
|
||||
return {
|
||||
bucket: loadData.expectedLoad[key].label,
|
||||
current: loadData.expectedLoad[key].count,
|
||||
target: metadataBucket && metadataBucket.target
|
||||
};
|
||||
})) ||
|
||||
[]
|
||||
);
|
||||
}, [loadData, ssbuckets]);
|
||||
|
||||
const popContent = useMemo(
|
||||
() => (
|
||||
<div>
|
||||
<Space>
|
||||
{t("appointments.labels.expectedprodhrs")}
|
||||
<strong>{loadData?.expectedHours?.toFixed(1) || 0}</strong>
|
||||
{t("appointments.labels.expectedjobs")}
|
||||
<strong>{loadData?.expectedJobCount || 0}</strong>
|
||||
</Space>
|
||||
<RadarChart width={300} height={250} data={data}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="bucket" />
|
||||
<PolarRadiusAxis angle={90} />
|
||||
<Radar name="Ideal Load" dataKey="target" stroke="darkgreen" fill="white" fillOpacity={0} />
|
||||
<Radar name="EOD Load" dataKey="current" stroke="dodgerblue" fill="dodgerblue" fillOpacity={0.6} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</RadarChart>
|
||||
</div>
|
||||
),
|
||||
[t, loadData, data]
|
||||
const popContent = (
|
||||
<div>
|
||||
<Space>
|
||||
{t("appointments.labels.expectedprodhrs")}
|
||||
<strong>{loadData?.expectedHours?.toFixed(1)}</strong>
|
||||
{t("appointments.labels.expectedjobs")}
|
||||
<strong>{loadData?.expectedJobCount}</strong>
|
||||
</Space>
|
||||
<RadarChart
|
||||
// cx={300}
|
||||
// cy={250}
|
||||
// outerRadius={150}
|
||||
width={800}
|
||||
height={600}
|
||||
data={data}
|
||||
>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="bucket" />
|
||||
<PolarRadiusAxis angle={90} />
|
||||
<Radar name="Ideal Load" dataKey="target" stroke="darkgreen" fill="white" fillOpacity={0} />
|
||||
<Radar name="EOD Load" dataKey="current" stroke="dodgerblue" fill="dodgerblue" fillOpacity={0.6} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</RadarChart>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -59,6 +66,6 @@ const ScheduleCalendarHeaderGraph = React.memo(function ScheduleCalendarHeaderGr
|
||||
<RadarChartOutlined />
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarHeaderGraph);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import Icon from "@ant-design/icons";
|
||||
import { Popover, Space } from "antd";
|
||||
import _ from "lodash";
|
||||
import dayjs from "../../utils/day";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MdFileDownload, MdFileUpload } from "react-icons/md";
|
||||
import { connect } from "react-redux";
|
||||
@@ -23,114 +24,115 @@ const mapStateToProps = createStructuredSelector({
|
||||
calculating: selectScheduleLoadCalculating
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
|
||||
export const ScheduleCalendarHeaderComponent = React.memo(function ScheduleCalendarHeaderComponent({
|
||||
export function ScheduleCalendarHeaderComponent({
|
||||
bodyshop,
|
||||
label,
|
||||
refetch,
|
||||
date,
|
||||
load,
|
||||
calculating,
|
||||
events
|
||||
events,
|
||||
...otherProps
|
||||
}) {
|
||||
const dayjsDate = useMemo(() => dayjs(date), [date]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ATSToday = useMemo(() => {
|
||||
if (!events) return [];
|
||||
const filteredEvents = events.filter((e) => !e.vacation && e.isintake && dayjsDate.isSame(dayjs(e.start), "day"));
|
||||
return _.groupBy(filteredEvents, "job.alt_transport");
|
||||
}, [events, dayjsDate]);
|
||||
return _.groupBy(
|
||||
events.filter((e) => !e.vacation && e.isintake && dayjs(date).isSame(dayjs(e.start), "day")),
|
||||
"job.alt_transport"
|
||||
);
|
||||
}, [events, date]);
|
||||
|
||||
const isDayBlocked = useMemo(() => {
|
||||
if (!events) return [];
|
||||
return events.filter((e) => dayjsDate.isSame(dayjs(e.start), "day") && e.block);
|
||||
}, [events, dayjsDate]);
|
||||
return events && events.filter((e) => dayjs(date).isSame(dayjs(e.start), "day") && e.block);
|
||||
}, [events, date]);
|
||||
|
||||
const dateString = dayjsDate.format("YYYY-MM-DD");
|
||||
const loadData = load[dateString];
|
||||
const { t } = useTranslation();
|
||||
const loadData = load[date.toISOString().substr(0, 10)];
|
||||
|
||||
const jobsOutPopup = useCallback(
|
||||
() => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<table>
|
||||
<tbody>
|
||||
{loadData && loadData.allJobsOut && loadData.allJobsOut.length > 0 ? (
|
||||
loadData.allJobsOut.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<Link to={`/manage/jobs/${j.id}`}>{j.ro_number}</Link> ({j.status})
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<OwnerNameDisplay ownerObject={j} />
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
{`(${j.labhrs?.aggregate?.sum?.mod_lb_hrs?.toFixed(1) || 0}/${
|
||||
j.larhrs?.aggregate?.sum?.mod_lb_hrs?.toFixed(1) || 0
|
||||
}/${(
|
||||
(j.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) + (j.larhrs?.aggregate?.sum?.mod_lb_hrs || 0)
|
||||
).toFixed(1)} ${t("general.labels.hours")})`}
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<DateTimeFormatter>{j.scheduled_completion}</DateTimeFormatter>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td style={{ padding: "2.5px" }}>{t("appointments.labels.nocompletingjobs")}</td>
|
||||
const jobsOutPopup = () => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<table>
|
||||
<tbody>
|
||||
{loadData && loadData.allJobsOut ? (
|
||||
loadData.allJobsOut.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<Link to={`/manage/jobs/${j.id}`}>{j.ro_number}</Link> (
|
||||
{j.status})
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<OwnerNameDisplay ownerObject={j} />
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
{`(${j.labhrs?.aggregate?.sum?.mod_lb_hrs?.toFixed(1) || 0}/${
|
||||
j.larhrs?.aggregate?.sum?.mod_lb_hrs?.toFixed(1) || 0
|
||||
}/${(
|
||||
j.labhrs.aggregate?.sum?.mod_lb_hrs +
|
||||
j.larhrs.aggregate?.sum?.mod_lb_hrs
|
||||
).toFixed(1)} ${t("general.labels.hours")})`}
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<DateTimeFormatter>
|
||||
{j.scheduled_completion}
|
||||
</DateTimeFormatter>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
[loadData, t]
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
{t("appointments.labels.nocompletingjobs")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const jobsInPopup = useCallback(
|
||||
() => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<table>
|
||||
<tbody>
|
||||
{loadData && loadData.allJobsIn && loadData.allJobsIn.length > 0 ? (
|
||||
loadData.allJobsIn.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<Link to={`/manage/jobs/${j.id}`}>{j.ro_number}</Link>
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<OwnerNameDisplay ownerObject={j} />
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
{`(${j.labhrs?.aggregate?.sum?.mod_lb_hrs?.toFixed(1) || 0}/${
|
||||
j.larhrs?.aggregate?.sum?.mod_lb_hrs?.toFixed(1) || 0
|
||||
}/${(
|
||||
(j.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) + (j.larhrs?.aggregate?.sum?.mod_lb_hrs || 0)
|
||||
).toFixed(1)} ${t("general.labels.hours")})`}
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<DateTimeFormatter>{j.scheduled_in}</DateTimeFormatter>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td style={{ padding: "2.5px" }}>{t("appointments.labels.noarrivingjobs")}</td>
|
||||
const jobsInPopup = () => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<table>
|
||||
<tbody>
|
||||
{loadData && loadData.allJobsIn ? (
|
||||
loadData.allJobsIn.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<Link to={`/manage/jobs/${j.id}`}>{j.ro_number}</Link>
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<OwnerNameDisplay ownerObject={j} />
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
{`(${j.labhrs?.aggregate?.sum.mod_lb_hrs?.toFixed(1) || 0}/${
|
||||
j.larhrs?.aggregate?.sum?.mod_lb_hrs?.toFixed(1) || 0
|
||||
}/${(
|
||||
j.labhrs?.aggregate?.sum?.mod_lb_hrs +
|
||||
j.larhrs?.aggregate?.sum?.mod_lb_hrs
|
||||
).toFixed(1)} ${t("general.labels.hours")})`}
|
||||
</td>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
<DateTimeFormatter>{j.scheduled_in}</DateTimeFormatter>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
[loadData, t]
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td style={{ padding: "2.5px" }}>
|
||||
{t("appointments.labels.noarrivingjobs")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoadComponent = useMemo(() => {
|
||||
if (!loadData) return null;
|
||||
return (
|
||||
<div>
|
||||
const LoadComponent = loadData ? (
|
||||
<div>
|
||||
<Space align="center">
|
||||
<Popover
|
||||
placement={"bottom"}
|
||||
@@ -139,8 +141,12 @@ export const ScheduleCalendarHeaderComponent = React.memo(function ScheduleCalen
|
||||
title={t("appointments.labels.arrivingjobs")}
|
||||
>
|
||||
<Icon component={MdFileDownload} style={{ color: "green" }} />
|
||||
{(loadData.allHoursInBody || 0).toFixed(1)}/{(loadData.allHoursInRefinish || 0).toFixed(1)}/
|
||||
{(loadData.allHoursIn || 0).toFixed(1)}
|
||||
{(loadData.allHoursInBody || 0) &&
|
||||
loadData.allHoursInBody.toFixed(1)}
|
||||
/
|
||||
{(loadData.allHoursInRefinish || 0) &&
|
||||
loadData.allHoursInRefinish.toFixed(1)}
|
||||
/{(loadData.allHoursIn || 0) && loadData.allHoursIn.toFixed(1)}
|
||||
</Popover>
|
||||
<Popover
|
||||
placement={"bottom"}
|
||||
@@ -149,31 +155,57 @@ export const ScheduleCalendarHeaderComponent = React.memo(function ScheduleCalen
|
||||
title={t("appointments.labels.completingjobs")}
|
||||
>
|
||||
<Icon component={MdFileUpload} style={{ color: "red" }} />
|
||||
{(loadData.allHoursOut || 0).toFixed(1)}
|
||||
{(loadData.allHoursOut || 0) && loadData.allHoursOut.toFixed(1)}
|
||||
</Popover>
|
||||
<ScheduleCalendarHeaderGraph loadData={loadData} />
|
||||
</Space>
|
||||
<div>
|
||||
<ul style={{ listStyleType: "none", columns: "2 auto", padding: 0 }}>
|
||||
{Object.keys(ATSToday).map((key, idx) => (
|
||||
<li key={idx}>{`${key === "null" || key === "undefined" ? "N/A" : key}: ${ATSToday[key].length}`}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul style={{ listStyleType: "none", columns: "2 auto", padding: 0 }}>
|
||||
{Object.keys(ATSToday).map((key, idx) => (
|
||||
<li key={idx}>{`${key === "null" || key === "undefined" ? "N/A" : key}: ${ATSToday[key].length}`}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}, [loadData, jobsInPopup, jobsOutPopup, t, ATSToday]);
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const isShopOpen = (date) => {
|
||||
let day;
|
||||
switch (dayjs(date).day()) {
|
||||
case 0:
|
||||
day = "sunday";
|
||||
break;
|
||||
case 1:
|
||||
day = "monday";
|
||||
break;
|
||||
case 2:
|
||||
day = "tuesday";
|
||||
break;
|
||||
case 3:
|
||||
day = "wednesday";
|
||||
break;
|
||||
case 4:
|
||||
day = "thursday";
|
||||
break;
|
||||
case 5:
|
||||
day = "friday";
|
||||
break;
|
||||
case 6:
|
||||
day = "saturday";
|
||||
break;
|
||||
default:
|
||||
day = "sunday";
|
||||
break;
|
||||
}
|
||||
|
||||
const isShopOpen = useCallback(() => {
|
||||
const days = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
||||
const day = days[dayjsDate.day()];
|
||||
return bodyshop.workingdays[day];
|
||||
}, [bodyshop, dayjsDate]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="imex-calendar-load">
|
||||
<ScheduleBlockDay alreadyBlocked={isDayBlocked.length > 0} date={date} refetch={refetch}>
|
||||
<div style={{ color: isShopOpen() ? "" : "tomato" }}>
|
||||
<div style={{ color: isShopOpen(date) ? "" : "tomato" }}>
|
||||
{label}
|
||||
{InstanceRenderMgr({
|
||||
imex: calculating ? <LoadingSkeleton /> : LoadComponent,
|
||||
@@ -184,6 +216,6 @@ export const ScheduleCalendarHeaderComponent = React.memo(function ScheduleCalen
|
||||
</ScheduleBlockDay>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarHeaderComponent);
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import dayjs from "../../utils/day";
|
||||
|
||||
// Predefine range calculation functions for each view
|
||||
const viewRanges = {
|
||||
day: (date) => ({
|
||||
start: date.startOf("day"),
|
||||
end: date.endOf("day")
|
||||
}),
|
||||
week: (date) => ({
|
||||
start: date.startOf("week"),
|
||||
end: date.endOf("week")
|
||||
}),
|
||||
month: (date) => ({
|
||||
// Adjusting for adjacent weeks
|
||||
start: date.startOf("month").subtract(7, "day"),
|
||||
end: date.endOf("month").add(7, "day")
|
||||
}),
|
||||
agenda: (date) => ({
|
||||
start: date.startOf("day"),
|
||||
end: date.endOf("day").add(1, "month")
|
||||
})
|
||||
};
|
||||
export function getRange(dateParam, viewParam) {
|
||||
let start, end;
|
||||
let date = dateParam || new Date();
|
||||
let view = viewParam || "week";
|
||||
// if view is day: from dayjs(date).startOf('day') to dayjs(date).endOf('day');
|
||||
if (view === "day") {
|
||||
start = dayjs(date).startOf("day");
|
||||
end = dayjs(date).endOf("day");
|
||||
}
|
||||
// if view is week: from dayjs(date).startOf('isoWeek') to dayjs(date).endOf('isoWeek');
|
||||
else if (view === "week") {
|
||||
start = dayjs(date).startOf("week");
|
||||
end = dayjs(date).endOf("week");
|
||||
}
|
||||
//if view is month: from dayjs(date).startOf('month').subtract(7, 'day') to dayjs(date).endOf('month').add(7, 'day'); i do additional 7 days math because you can see adjacent weeks on month view (that is the way how i generate my recurrent events for the Big Calendar, but if you need only start-end of month - just remove that math);
|
||||
else if (view === "month") {
|
||||
start = dayjs(date).startOf("month").subtract(7, "day");
|
||||
end = dayjs(date).endOf("month").add(7, "day");
|
||||
}
|
||||
// if view is agenda: from dayjs(date).startOf('day') to dayjs(date).endOf('day').add(1, 'month');
|
||||
else if (view === "agenda") {
|
||||
start = dayjs(date).startOf("day");
|
||||
end = dayjs(date).endOf("day").add(1, "month");
|
||||
}
|
||||
|
||||
export function getRange(dateParam = new Date(), viewParam = "week") {
|
||||
const date = dayjs(dateParam);
|
||||
const view = viewRanges[viewParam] ? viewParam : "week";
|
||||
return viewRanges[view](date);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dayjs from "../../utils/day";
|
||||
import queryString from "query-string";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React from "react";
|
||||
import { Calendar, dayjsLocalizer } from "react-big-calendar";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -19,10 +19,9 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
problemJobs: selectProblemJobs
|
||||
});
|
||||
|
||||
const localizer = dayjsLocalizer(dayjs);
|
||||
|
||||
export const ScheduleCalendarWrapperComponent = React.memo(function ScheduleCalendarWrapperComponent({
|
||||
export function ScheduleCalendarWrapperComponent({
|
||||
bodyshop,
|
||||
problemJobs,
|
||||
data,
|
||||
@@ -32,79 +31,23 @@ export const ScheduleCalendarWrapperComponent = React.memo(function ScheduleCale
|
||||
date,
|
||||
...otherProps
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const search = useMemo(() => queryString.parse(location.search), [location.search]);
|
||||
const navigate = useNavigate();
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const history = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
return new Date(date || dayjs(search.date).toDate() || Date.now());
|
||||
}, [date, search.date]);
|
||||
|
||||
const minTime = useMemo(() => {
|
||||
return bodyshop.schedule_start_time ? new Date(bodyshop.schedule_start_time) : new Date("2020-01-01T06:00:00");
|
||||
}, [bodyshop.schedule_start_time]);
|
||||
|
||||
const maxTime = useMemo(() => {
|
||||
return bodyshop.schedule_end_time ? new Date(bodyshop.schedule_end_time) : new Date("2020-01-01T20:00:00");
|
||||
}, [bodyshop.schedule_end_time]);
|
||||
|
||||
const handleEventPropStyles = useCallback(
|
||||
(event, start, end, isSelected) => {
|
||||
return {
|
||||
...(event.color && !((search.view || defaultView) === "agenda")
|
||||
? {
|
||||
style: {
|
||||
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
|
||||
}
|
||||
const handleEventPropStyles = (event, start, end, isSelected) => {
|
||||
return {
|
||||
...(event.color && !((search.view || defaultView) === "agenda")
|
||||
? {
|
||||
style: {
|
||||
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
|
||||
}
|
||||
: {}),
|
||||
className: `${event.arrived ? "imex-event-arrived" : ""} ${event.block ? "imex-event-block" : ""}`
|
||||
};
|
||||
},
|
||||
[search.view, defaultView]
|
||||
);
|
||||
}
|
||||
: {}),
|
||||
className: `${event.arrived ? "imex-event-arrived" : ""} ${event.block ? "imex-event-block" : ""}`
|
||||
};
|
||||
};
|
||||
|
||||
const eventComponent = useCallback(
|
||||
(e) => <Event bodyshop={bodyshop} event={e.event} refetch={refetch} />,
|
||||
[bodyshop, refetch]
|
||||
);
|
||||
|
||||
const headerComponent = useCallback(
|
||||
(p) => <HeaderComponent {...p} events={data} refetch={refetch} />,
|
||||
[data, refetch]
|
||||
);
|
||||
|
||||
const calendarComponents = useMemo(
|
||||
() => ({
|
||||
event: eventComponent,
|
||||
header: headerComponent
|
||||
}),
|
||||
[eventComponent, headerComponent]
|
||||
);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(date, view, action) => {
|
||||
const newSearch = { ...search, date: date.toISOString().substr(0, 10) };
|
||||
navigate({ search: queryString.stringify(newSearch) });
|
||||
},
|
||||
[search, navigate]
|
||||
);
|
||||
|
||||
const onView = useCallback(
|
||||
(view) => {
|
||||
const newSearch = { ...search, view };
|
||||
navigate({ search: queryString.stringify(newSearch) });
|
||||
},
|
||||
[search, navigate]
|
||||
);
|
||||
|
||||
const onRangeChange = useCallback(
|
||||
(range) => {
|
||||
if (setDateRangeCallback) setDateRangeCallback(range);
|
||||
},
|
||||
[setDateRangeCallback]
|
||||
);
|
||||
const selectedDate = new Date(date || dayjs(search.date) || Date.now());
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -166,20 +109,32 @@ export const ScheduleCalendarWrapperComponent = React.memo(function ScheduleCale
|
||||
events={data}
|
||||
defaultView={search.view || defaultView || "week"}
|
||||
date={selectedDate}
|
||||
onNavigate={onNavigate}
|
||||
onRangeChange={onRangeChange}
|
||||
onView={onView}
|
||||
onNavigate={(date, view, action) => {
|
||||
search.date = date.toISOString().substr(0, 10);
|
||||
history({ search: queryString.stringify(search) });
|
||||
}}
|
||||
onRangeChange={(start, end) => {
|
||||
if (setDateRangeCallback) setDateRangeCallback({ start, end });
|
||||
}}
|
||||
onView={(view) => {
|
||||
search.view = view;
|
||||
history({ search: queryString.stringify(search) });
|
||||
}}
|
||||
step={15}
|
||||
// timeslots={1}
|
||||
showMultiDayTimes
|
||||
localizer={localizer}
|
||||
min={minTime}
|
||||
max={maxTime}
|
||||
min={bodyshop.schedule_start_time ? new Date(bodyshop.schedule_start_time) : new Date("2020-01-01T06:00:00")}
|
||||
max={bodyshop.schedule_end_time ? new Date(bodyshop.schedule_end_time) : new Date("2020-01-01T20:00:00")}
|
||||
eventPropGetter={handleEventPropStyles}
|
||||
components={calendarComponents}
|
||||
components={{
|
||||
event: (e) => Event({ bodyshop: bodyshop, event: e.event, refetch: refetch }),
|
||||
header: (p) => <HeaderComponent {...p} events={data} refetch={refetch} />
|
||||
}}
|
||||
{...otherProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(ScheduleCalendarWrapperComponent);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Checkbox, Col, Row, Select, Space } from "antd";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import React, { Profiler, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "i18next";
|
||||
import React, { useMemo } from "react";
|
||||
import useLocalStorage from "../../utils/useLocalStorage";
|
||||
import ScheduleAtsSummary from "../schedule-ats-summary/schedule-ats-summary.component";
|
||||
import ScheduleCalendarWrapperComponent from "../schedule-calendar-wrapper/scheduler-calendar-wrapper.component";
|
||||
@@ -18,17 +18,19 @@ import _ from "lodash";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarComponent);
|
||||
|
||||
const ScheduleCalendarComponent = React.memo(function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
|
||||
const [filter, setFilter] = useLocalStorage("filter_events", {
|
||||
intake: true,
|
||||
manual: true,
|
||||
employeevacation: true,
|
||||
ins_co_nm: null
|
||||
});
|
||||
const [estimatorsFilter, setEstimatorsFilter] = useLocalStorage("estimators", []);
|
||||
const [estimatorsFilter, setEstimatiorsFilter] = useLocalStorage("estimators", []);
|
||||
|
||||
const estimators = useMemo(() => {
|
||||
return _.uniq([
|
||||
@@ -46,7 +48,7 @@ const ScheduleCalendarComponent = React.memo(function ScheduleCalendarComponent(
|
||||
d.__typename === "appointments"
|
||||
? estimatorsFilter.length === 0
|
||||
? true
|
||||
: estimatorsFilter.includes(`${d.job?.est_ct_fn || ""} ${d.job?.est_ct_ln || ""}`.trim())
|
||||
: !!estimatorsFilter.find((e) => e === `${d.job?.est_ct_fn || ""} ${d.job?.est_ct_ln || ""}`.trim())
|
||||
: true;
|
||||
|
||||
return (
|
||||
@@ -60,85 +62,7 @@ const ScheduleCalendarComponent = React.memo(function ScheduleCalendarComponent(
|
||||
});
|
||||
}, [data, filter, estimatorsFilter]);
|
||||
|
||||
const estimatorsOptions = useMemo(() => {
|
||||
return estimators.map((e) => ({
|
||||
label: e,
|
||||
value: e
|
||||
}));
|
||||
}, [estimators]);
|
||||
|
||||
const insCoNmOptions = useMemo(() => {
|
||||
return bodyshop.md_ins_cos.map((i) => ({
|
||||
label: i.name,
|
||||
value: i.name
|
||||
}));
|
||||
}, [bodyshop.md_ins_cos]);
|
||||
|
||||
const handleEstimatorsFilterChange = useCallback(
|
||||
(e) => {
|
||||
setEstimatorsFilter(e);
|
||||
},
|
||||
[setEstimatorsFilter]
|
||||
);
|
||||
|
||||
const handleEstimatorsFilterClear = useCallback(() => {
|
||||
setEstimatorsFilter([]);
|
||||
}, [setEstimatorsFilter]);
|
||||
|
||||
const handleInsCoNmFilterChange = useCallback(
|
||||
(e) => {
|
||||
setFilter((prevFilter) => ({ ...prevFilter, ins_co_nm: e }));
|
||||
},
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
const handleInsCoNmFilterClear = useCallback(() => {
|
||||
setFilter((prevFilter) => ({ ...prevFilter, ins_co_nm: [] }));
|
||||
}, [setFilter]);
|
||||
|
||||
const handleIntakeFilterChange = useCallback(
|
||||
(e) => {
|
||||
const checked = e.target.checked;
|
||||
setFilter((prevFilter) => ({ ...prevFilter, intake: checked }));
|
||||
},
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
const handleManualFilterChange = useCallback(
|
||||
(e) => {
|
||||
const checked = e.target.checked;
|
||||
setFilter((prevFilter) => ({ ...prevFilter, manual: checked }));
|
||||
},
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
const handleEmployeeVacationFilterChange = useCallback(
|
||||
(e) => {
|
||||
const checked = e.target.checked;
|
||||
setFilter((prevFilter) => ({ ...prevFilter, employeevacation: checked }));
|
||||
},
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
const handleRefetch = useCallback(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
// TODO Remove when done
|
||||
// <Profiler
|
||||
// id="cal"
|
||||
// onRender={(id, phase, actualDuration, baseDuration, startTime, commitTime) => {
|
||||
// console.dir({
|
||||
// id,
|
||||
// phase,
|
||||
// actualDuration,
|
||||
// baseDuration,
|
||||
// startTime,
|
||||
// commitTime
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
<Row gutter={[16, 16]}>
|
||||
<ScheduleModal />
|
||||
|
||||
@@ -152,35 +76,65 @@ const ScheduleCalendarComponent = React.memo(function ScheduleCalendarComponent(
|
||||
mode="multiple"
|
||||
placeholder={t("schedule.labels.estimators")}
|
||||
allowClear
|
||||
onClear={handleEstimatorsFilterClear}
|
||||
value={estimatorsFilter}
|
||||
onChange={handleEstimatorsFilterChange}
|
||||
options={estimatorsOptions}
|
||||
onClear={() => setEstimatiorsFilter([])}
|
||||
value={[...estimatorsFilter]}
|
||||
onChange={(e) => {
|
||||
setEstimatiorsFilter(e);
|
||||
}}
|
||||
options={estimators.map((e) => ({
|
||||
label: e,
|
||||
value: e
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
style={{ minWidth: "15rem" }}
|
||||
mode="multiple"
|
||||
placeholder={t("schedule.labels.ins_co_nm_filter")}
|
||||
allowClear
|
||||
onClear={handleInsCoNmFilterClear}
|
||||
value={filter.ins_co_nm || []}
|
||||
onChange={handleInsCoNmFilterChange}
|
||||
options={insCoNmOptions}
|
||||
onClear={() => setFilter({ ...filter, ins_co_nm: [] })}
|
||||
value={filter?.ins_co_nm ? filter.ins_co_nm : []}
|
||||
onChange={(e) => {
|
||||
setFilter({ ...filter, ins_co_nm: e });
|
||||
}}
|
||||
options={bodyshop.md_ins_cos.map((i) => ({
|
||||
label: i.name,
|
||||
value: i.name
|
||||
}))}
|
||||
/>
|
||||
<Checkbox checked={filter.intake} onChange={handleIntakeFilterChange}>
|
||||
<Checkbox
|
||||
checked={filter?.intake}
|
||||
onChange={(e) => {
|
||||
setFilter({ ...filter, intake: e.target.checked });
|
||||
}}
|
||||
>
|
||||
{t("schedule.labels.intake")}
|
||||
</Checkbox>
|
||||
<Checkbox checked={filter.manual} onChange={handleManualFilterChange}>
|
||||
<Checkbox
|
||||
checked={filter?.manual}
|
||||
onChange={(e) => {
|
||||
setFilter({ ...filter, manual: e.target.checked });
|
||||
}}
|
||||
>
|
||||
{t("schedule.labels.manual")}
|
||||
</Checkbox>
|
||||
<Checkbox checked={filter.employeevacation} onChange={handleEmployeeVacationFilterChange}>
|
||||
<Checkbox
|
||||
checked={filter?.employeevacation}
|
||||
onChange={(e) => {
|
||||
setFilter({ ...filter, employeevacation: e.target.checked });
|
||||
}}
|
||||
>
|
||||
{t("schedule.labels.employeevacation")}
|
||||
</Checkbox>
|
||||
<ScheduleVerifyIntegrity />
|
||||
<Button onClick={handleRefetch}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
<ScheduleProductionList />
|
||||
|
||||
<ScheduleManualEvent />
|
||||
</Space>
|
||||
}
|
||||
@@ -193,9 +147,5 @@ const ScheduleCalendarComponent = React.memo(function ScheduleCalendarComponent(
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
// TODO Remove when done
|
||||
// </Profiler>
|
||||
);
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ScheduleCalendarComponent);
|
||||
}
|
||||
|
||||
@@ -15,65 +15,56 @@ import dayjs from "../../utils/day";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
calculateScheduleLoad: (endDate) => dispatch(calculateScheduleLoad(endDate))
|
||||
});
|
||||
|
||||
const ScheduleCalendarContainer = React.memo(function ScheduleCalendarContainer({ calculateScheduleLoad }) {
|
||||
const location = useLocation();
|
||||
const search = useMemo(() => queryString.parse(location.search), [location.search]);
|
||||
export function ScheduleCalendarContainer({ calculateScheduleLoad }) {
|
||||
const search = queryString.parse(useLocation().search);
|
||||
|
||||
const { date, view } = search;
|
||||
const range = useMemo(() => getRange(date, view), [date, view]);
|
||||
|
||||
const queryVariables = useMemo(
|
||||
() => ({
|
||||
const { loading, error, data, refetch } = useQuery(QUERY_ALL_ACTIVE_APPOINTMENTS, {
|
||||
variables: {
|
||||
start: range.start.toDate(),
|
||||
end: range.end.toDate(),
|
||||
startd: range.start,
|
||||
endd: range.end
|
||||
}),
|
||||
[range]
|
||||
);
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(QUERY_ALL_ACTIVE_APPOINTMENTS, {
|
||||
variables: queryVariables,
|
||||
skip: !range.start || !range.end,
|
||||
},
|
||||
skip: !!!range.start || !!!range.end,
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && range.end) {
|
||||
calculateScheduleLoad(range.end);
|
||||
}
|
||||
}, [data, range.end, calculateScheduleLoad]);
|
||||
if (data && range.end) calculateScheduleLoad(range.end);
|
||||
}, [data, range, calculateScheduleLoad]);
|
||||
|
||||
const normalizedData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [
|
||||
...data.appointments.map((e) => ({
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
let normalizedData = [
|
||||
...data.appointments.map((e) => {
|
||||
//Required because Hasura returns a string instead of a date object.
|
||||
return Object.assign({}, e, { start: new Date(e.start) }, { end: new Date(e.end) });
|
||||
}),
|
||||
...data.employee_vacation.map((e) => {
|
||||
//Required because Hasura returns a string instead of a date object.
|
||||
return {
|
||||
...e,
|
||||
start: new Date(e.start),
|
||||
end: new Date(e.end)
|
||||
})),
|
||||
...data.employee_vacation.map((e) => ({
|
||||
...e,
|
||||
title: `${e.employee.first_name?.[0] || ""} ${e.employee.last_name || ""} OUT`,
|
||||
title: `${
|
||||
(e.employee.first_name && e.employee.first_name.substr(0, 1)) || ""
|
||||
} ${e.employee.last_name || ""} OUT`,
|
||||
color: "red",
|
||||
start: dayjs(e.start).startOf("day").toDate(),
|
||||
end: dayjs(e.end).startOf("day").toDate(),
|
||||
allDay: true,
|
||||
vacation: true
|
||||
}))
|
||||
];
|
||||
}, [data]);
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
|
||||
return <ScheduleCalendarComponent refetch={refetch} data={normalizedData} />;
|
||||
});
|
||||
return <ScheduleCalendarComponent refetch={refetch} data={data ? normalizedData : []} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarContainer);
|
||||
|
||||
@@ -2,14 +2,9 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ScheduleCalendarWrapperComponent from "../schedule-calendar-wrapper/scheduler-calendar-wrapper.component";
|
||||
|
||||
const ScheduleDayViewComponent = React.memo(function ScheduleDayViewComponent({ data, day }) {
|
||||
export default function ScheduleDayViewComponent({ data, day }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data) {
|
||||
return <ScheduleCalendarWrapperComponent events={data} defaultView="day" view="day" views={["day"]} date={day} />;
|
||||
} else {
|
||||
return <div>{t("appointments.labels.nodateselected")}</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default ScheduleDayViewComponent;
|
||||
if (data)
|
||||
return <ScheduleCalendarWrapperComponent events={data} defaultView="day" view={"day"} views={["day"]} date={day} />;
|
||||
else return <div>{t("appointments.labels.nodateselected")}</div>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import ScheduleDayViewComponent from "./schedule-day-view.component";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { QUERY_APPOINTMENT_BY_DATE } from "../../graphql/appointments.queries";
|
||||
@@ -6,59 +6,45 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||
import dayjs from "../../utils/day";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ScheduleDayViewContainer = React.memo(function ScheduleDayViewContainer({ day }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Memoize dayjs computations
|
||||
const dayjsDay = useMemo(() => dayjs(day), [day]);
|
||||
|
||||
// Memoize query variables
|
||||
const queryVariables = useMemo(
|
||||
() => ({
|
||||
start: dayjsDay.startOf("day").toISOString(),
|
||||
end: dayjsDay.endOf("day").toISOString(),
|
||||
startd: dayjsDay.startOf("day").format("YYYY-MM-DD"),
|
||||
endd: dayjsDay.add(1, "day").format("YYYY-MM-DD")
|
||||
}),
|
||||
[dayjsDay]
|
||||
);
|
||||
|
||||
// Use the useQuery hook
|
||||
export default function ScheduleDayViewContainer({ day }) {
|
||||
const { loading, error, data } = useQuery(QUERY_APPOINTMENT_BY_DATE, {
|
||||
variables: queryVariables,
|
||||
skip: !dayjsDay.isValid(),
|
||||
variables: {
|
||||
start: dayjs(day).startOf("day"),
|
||||
end: dayjs(day).endOf("day"),
|
||||
startd: dayjs(day).startOf("day").format("YYYY-MM-DD"),
|
||||
endd: dayjs(day).add(1, "day").format("YYYY-MM-DD")
|
||||
},
|
||||
skip: !dayjs(day).isValid(),
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
|
||||
// Memoize normalizedData
|
||||
const normalizedData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const appointments = data.appointments.map((e) => ({
|
||||
...e,
|
||||
start: new Date(e.start),
|
||||
end: new Date(e.end)
|
||||
}));
|
||||
|
||||
const vacations = data.employee_vacation.map((e) => ({
|
||||
...e,
|
||||
title: `${e.employee.first_name?.[0] || ""} ${e.employee.last_name || ""} OUT`,
|
||||
color: "red",
|
||||
start: dayjs(e.start).startOf("day").toDate(),
|
||||
end: dayjs(e.end).startOf("day").toDate(),
|
||||
vacation: true
|
||||
}));
|
||||
|
||||
return [...appointments, ...vacations];
|
||||
}, [data]);
|
||||
|
||||
// Handle conditional rendering
|
||||
const { t } = useTranslation();
|
||||
if (!day) return <div>{t("appointments.labels.nodateselected")}</div>;
|
||||
if (loading) return <LoadingSkeleton paragraph={{ rows: 4 }} />;
|
||||
if (error) return <div>{error.message}</div>;
|
||||
let normalizedData;
|
||||
|
||||
return <ScheduleDayViewComponent data={normalizedData} day={day} />;
|
||||
});
|
||||
if (data) {
|
||||
normalizedData = [
|
||||
...data.appointments.map((e) => {
|
||||
//Required becuase Hasura returns a string instead of a date object.
|
||||
return Object.assign({}, e, { start: new Date(e.start) }, { end: new Date(e.end) });
|
||||
}),
|
||||
...data.employee_vacation.map((e) => {
|
||||
//Required becuase Hasura returns a string instead of a date object.
|
||||
return {
|
||||
...e,
|
||||
title: `${
|
||||
(e.employee.first_name && e.employee.first_name.substr(0, 1)) || ""
|
||||
} ${e.employee.last_name || ""} OUT`,
|
||||
color: "red",
|
||||
start: dayjs(e.start).startOf("day").toDate(),
|
||||
end: dayjs(e.end).startOf("day").toDate(),
|
||||
vacation: true
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
export default ScheduleDayViewContainer;
|
||||
return <ScheduleDayViewComponent data={data ? normalizedData : []} day={day} />;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,38 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import { Timeline } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DateTimeFormatter } from "../../utils/DateFormatter";
|
||||
|
||||
const ScheduleExistingAppointmentsList = React.memo(function ScheduleExistingAppointmentsList({
|
||||
existingAppointments
|
||||
}) {
|
||||
export default function ScheduleExistingAppointmentsList({ existingAppointments }) {
|
||||
const { t } = useTranslation();
|
||||
const { loading, error, data } = existingAppointments;
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.appointments.map((item) => ({
|
||||
key: item.id,
|
||||
color: item.canceled ? "red" : item.arrived ? "green" : "blue",
|
||||
children: (
|
||||
<>
|
||||
{item.canceled
|
||||
? t("appointments.labels.cancelledappointment")
|
||||
: item.arrived
|
||||
? t("appointments.labels.arrivedon")
|
||||
: t("appointments.labels.scheduledfor")}
|
||||
<DateTimeFormatter>{item.start}</DateTimeFormatter>
|
||||
</>
|
||||
)
|
||||
}));
|
||||
}, [data, t]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
if (existingAppointments.loading) return <LoadingSpinner />;
|
||||
if (existingAppointments.error) return <AlertComponent message={existingAppointments.error.message} type="error" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{t("appointments.labels.priorappointments")}
|
||||
<Timeline items={items} />
|
||||
<Timeline
|
||||
items={
|
||||
existingAppointments.data
|
||||
? existingAppointments.data.appointments.map((item) => ({
|
||||
key: item.id,
|
||||
color: item.canceled ? "red" : item.arrived ? "green" : "blue",
|
||||
children: (
|
||||
<>
|
||||
{item.canceled
|
||||
? t("appointments.labels.cancelledappointment")
|
||||
: item.arrived
|
||||
? t("appointments.labels.arrivedon")
|
||||
: t("appointments.labels.scheduledfor")}
|
||||
<DateTimeFormatter>{item.start}</DateTimeFormatter>
|
||||
</>
|
||||
)
|
||||
}))
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScheduleExistingAppointmentsList;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Col, Form, Input, Row, Select, Space, Switch, Typography } from "antd";
|
||||
import axios from "axios";
|
||||
import dayjs from "../../utils/day";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -19,12 +19,12 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
calculateScheduleLoad: (endDate) => dispatch(calculateScheduleLoad(endDate))
|
||||
});
|
||||
|
||||
const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent({
|
||||
export function ScheduleJobModalComponent({
|
||||
bodyshop,
|
||||
form,
|
||||
existingAppointments,
|
||||
@@ -36,7 +36,7 @@ const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [smartOptions, setSmartOptions] = useState([]);
|
||||
|
||||
const handleSmartScheduling = useCallback(async () => {
|
||||
const handleSmartScheduling = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.post("/scheduling/job", {
|
||||
@@ -48,66 +48,21 @@ const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
};
|
||||
|
||||
const handleDateBlur = useCallback(() => {
|
||||
const handleDateBlur = () => {
|
||||
const values = form.getFieldsValue();
|
||||
|
||||
if (lbrHrsData) {
|
||||
const totalHours =
|
||||
(lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs || 0) +
|
||||
(lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs || 0);
|
||||
lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs + lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs;
|
||||
|
||||
if (values.start && !values.scheduled_completion)
|
||||
form.setFieldsValue({
|
||||
scheduled_completion: dayjs(values.start).businessDaysAdd(totalHours / bodyshop.target_touchtime, "day")
|
||||
});
|
||||
}
|
||||
}, [form, lbrHrsData, bodyshop.target_touchtime]);
|
||||
|
||||
const colorOptions = useMemo(() => {
|
||||
return (
|
||||
bodyshop.appt_colors &&
|
||||
bodyshop.appt_colors.map((color) => (
|
||||
<Select.Option style={{ color: color.color.hex }} key={color.color.hex} value={color.color.hex}>
|
||||
{color.label}
|
||||
</Select.Option>
|
||||
))
|
||||
);
|
||||
}, [bodyshop.appt_colors]);
|
||||
|
||||
const altTransportOptions = useMemo(() => {
|
||||
return (
|
||||
bodyshop.appt_alt_transport &&
|
||||
bodyshop.appt_alt_transport.map((alt) => (
|
||||
<Select.Option key={alt} value={alt}>
|
||||
{alt}
|
||||
</Select.Option>
|
||||
))
|
||||
);
|
||||
}, [bodyshop.appt_alt_transport]);
|
||||
|
||||
const smartOptionsButtons = useMemo(() => {
|
||||
return smartOptions.map((d, idx) => (
|
||||
<Button
|
||||
className="imex-flex-row__margin"
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
const ssDate = dayjs(d);
|
||||
if (ssDate.isBefore(dayjs())) {
|
||||
form.setFieldsValue({ start: dayjs() });
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
start: dayjs(d).add(8, "hour")
|
||||
});
|
||||
}
|
||||
handleDateBlur();
|
||||
}}
|
||||
>
|
||||
<DateFormatter includeDay>{d}</DateFormatter>
|
||||
</Button>
|
||||
));
|
||||
}, [smartOptions, form, handleDateBlur]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -125,6 +80,7 @@ const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent(
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
@@ -136,6 +92,7 @@ const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent(
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
@@ -150,7 +107,25 @@ const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent(
|
||||
<Button onClick={handleSmartScheduling} loading={loading}>
|
||||
{t("appointments.actions.calculate")}
|
||||
</Button>
|
||||
{smartOptionsButtons}
|
||||
{smartOptions.map((d, idx) => (
|
||||
<Button
|
||||
className="imex-flex-row__margin"
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
const ssDate = dayjs(d);
|
||||
if (ssDate.isBefore(dayjs())) {
|
||||
form.setFieldsValue({ start: dayjs() });
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
start: dayjs(d).add(8, "hour")
|
||||
});
|
||||
}
|
||||
handleDateBlur();
|
||||
}}
|
||||
>
|
||||
<DateFormatter includeDay>{d}</DateFormatter>
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</>
|
||||
),
|
||||
@@ -169,10 +144,20 @@ const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent(
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow grow>
|
||||
<Form.Item name="color" label={t("appointments.fields.color")}>
|
||||
<Select allowClear>{colorOptions}</Select>
|
||||
<Select allowClear>
|
||||
{bodyshop.appt_colors &&
|
||||
bodyshop.appt_colors.map((color) => (
|
||||
<Select.Option style={{ color: color.color.hex }} key={color.color.hex} value={color.color.hex}>
|
||||
{color.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name={"alt_transport"} label={t("jobs.fields.alt_transport")}>
|
||||
<Select allowClear>{altTransportOptions}</Select>
|
||||
<Select allowClear>
|
||||
{bodyshop.appt_alt_transport &&
|
||||
bodyshop.appt_alt_transport.map((alt) => <Select.Option key={alt}>{alt}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name={"note"} label={t("appointments.fields.note")}>
|
||||
<Input />
|
||||
@@ -198,6 +183,6 @@ const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent(
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleJobModalComponent);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { Form, Modal, notification } from "antd";
|
||||
import dayjs from "../../utils/day";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -27,21 +27,13 @@ const mapStateToProps = createStructuredSelector({
|
||||
scheduleModal: selectSchedule,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
toggleModalVisible: () => dispatch(toggleModalVisible("schedule")),
|
||||
setEmailOptions: (e) => dispatch(setEmailOptions(e)),
|
||||
insertAuditTrail: ({ jobid, operation, type }) =>
|
||||
dispatch(
|
||||
insertAuditTrail({
|
||||
jobid,
|
||||
operation,
|
||||
type
|
||||
})
|
||||
)
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
});
|
||||
|
||||
const ScheduleJobModalContainer = React.memo(function ScheduleJobModalContainer({
|
||||
export function ScheduleJobModalContainer({
|
||||
scheduleModal,
|
||||
bodyshop,
|
||||
toggleModalVisible,
|
||||
@@ -51,186 +43,168 @@ const ScheduleJobModalContainer = React.memo(function ScheduleJobModalContainer(
|
||||
}) {
|
||||
const { open, context, actions } = scheduleModal;
|
||||
const { jobId, job, previousEvent } = context;
|
||||
|
||||
const { refetch } = actions;
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data: lbrHrsData } = useQuery(QUERY_LBR_HRS_BY_PK, {
|
||||
variables: { id: job?.id },
|
||||
skip: !job?.id,
|
||||
variables: { id: job && job.id },
|
||||
skip: !job || !job.id,
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cancelAppointment] = useMutation(CANCEL_APPOINTMENT_BY_ID);
|
||||
const [insertAppointment] = useMutation(INSERT_APPOINTMENT);
|
||||
const [updateJobStatus] = useMutation(UPDATE_JOBS);
|
||||
|
||||
const existingAppointments = useQuery(QUERY_APPOINTMENTS_BY_JOBID, {
|
||||
variables: { jobid: jobId },
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: !open || !jobId
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (job) form.resetFields();
|
||||
}, [job, form]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const existingAppointments = useQuery(QUERY_APPOINTMENTS_BY_JOBID, {
|
||||
variables: { jobid: jobId },
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: !open || !!!jobId
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const appointments = existingAppointments.data?.appointments;
|
||||
if (appointments?.length && !appointments[0].canceled) {
|
||||
if (
|
||||
existingAppointments.data &&
|
||||
existingAppointments.data.appointments.length > 0 &&
|
||||
!existingAppointments.data.appointments[0].canceled
|
||||
) {
|
||||
form.setFieldsValue({
|
||||
color: appointments[0].color,
|
||||
note: appointments[0].note
|
||||
color: existingAppointments.data.appointments[0].color,
|
||||
|
||||
note: existingAppointments.data.appointments[0].note
|
||||
});
|
||||
}
|
||||
}, [existingAppointments.data, form]);
|
||||
|
||||
const handleFinish = useCallback(
|
||||
async (values) => {
|
||||
logImEXEvent("schedule_new_appointment");
|
||||
setLoading(true);
|
||||
const handleFinish = async (values) => {
|
||||
logImEXEvent("schedule_new_appointment");
|
||||
|
||||
if (previousEvent) {
|
||||
const cancelAppt = await cancelAppointment({
|
||||
variables: { appid: previousEvent }
|
||||
});
|
||||
|
||||
if (cancelAppt.errors) {
|
||||
notification.error({
|
||||
message: t("appointments.errors.canceling", {
|
||||
message: JSON.stringify(cancelAppt.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
notification.success({
|
||||
message: t("appointments.successes.canceled")
|
||||
});
|
||||
}
|
||||
|
||||
const existingApps = existingAppointments.data?.appointments || [];
|
||||
if (existingApps.length > 0) {
|
||||
await Promise.all(
|
||||
existingApps.map((app) =>
|
||||
cancelAppointment({
|
||||
variables: { appid: app.id }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const appt = await insertAppointment({
|
||||
variables: {
|
||||
app: {
|
||||
jobid: jobId,
|
||||
bodyshopid: bodyshop.id,
|
||||
start: dayjs(values.start),
|
||||
end: dayjs(values.start).add(bodyshop.appt_length || 60, "minute"),
|
||||
color: values.color,
|
||||
note: values.note,
|
||||
created_by: currentUser.email
|
||||
},
|
||||
jobId: jobId,
|
||||
altTransport: values.alt_transport
|
||||
}
|
||||
setLoading(true);
|
||||
if (!!previousEvent) {
|
||||
const cancelAppt = await cancelAppointment({
|
||||
variables: { appid: previousEvent }
|
||||
});
|
||||
|
||||
if (!appt.errors) {
|
||||
insertAuditTrail({
|
||||
jobid: job.id,
|
||||
operation: AuditTrailMapping.appointmentinsert(DateTimeFormat(values.start)),
|
||||
type: "appointmentinsert"
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
message: t("appointments.errors.saving", {
|
||||
message: JSON.stringify(appt.errors)
|
||||
if (!!cancelAppt.errors) {
|
||||
notification["error"]({
|
||||
message: t("appointments.errors.canceling", {
|
||||
message: JSON.stringify(cancelAppt.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
notification.success({
|
||||
message: t("appointments.successes.created")
|
||||
notification["success"]({
|
||||
message: t("appointments.successes.canceled")
|
||||
});
|
||||
}
|
||||
|
||||
if (existingAppointments.data.appointments.length > 0) {
|
||||
await Promise.all(
|
||||
existingAppointments.data.appointments.map((app) => {
|
||||
return cancelAppointment({
|
||||
variables: { appid: app.id }
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const appt = await insertAppointment({
|
||||
variables: {
|
||||
app: {
|
||||
jobid: jobId,
|
||||
bodyshopid: bodyshop.id,
|
||||
start: dayjs(values.start),
|
||||
end: dayjs(values.start).add(bodyshop.appt_length || 60, "minute"),
|
||||
color: values.color,
|
||||
note: values.note,
|
||||
created_by: currentUser.email
|
||||
},
|
||||
jobId: jobId,
|
||||
altTransport: values.alt_transport
|
||||
}
|
||||
});
|
||||
|
||||
if (!appt.errors) {
|
||||
insertAuditTrail({
|
||||
jobid: job.id,
|
||||
operation: AuditTrailMapping.appointmentinsert(DateTimeFormat(values.start)),
|
||||
type: "appointmentinsert"
|
||||
});
|
||||
}
|
||||
|
||||
if (!!appt.errors) {
|
||||
notification["error"]({
|
||||
message: t("appointments.errors.saving", {
|
||||
message: JSON.stringify(appt.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
notification["success"]({
|
||||
message: t("appointments.successes.created")
|
||||
});
|
||||
if (jobId) {
|
||||
const jobUpdate = await updateJobStatus({
|
||||
variables: {
|
||||
jobIds: [jobId],
|
||||
fields: {
|
||||
status: bodyshop.md_ro_statuses.default_scheduled,
|
||||
date_scheduled: new Date(),
|
||||
scheduled_in: values.start,
|
||||
scheduled_completion: values.scheduled_completion,
|
||||
lost_sale_reason: null,
|
||||
date_lost_sale: null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (jobId) {
|
||||
const jobUpdate = await updateJobStatus({
|
||||
if (!!jobUpdate.errors) {
|
||||
notification["error"]({
|
||||
message: t("appointments.errors.saving", {
|
||||
message: JSON.stringify(jobUpdate.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
toggleModalVisible();
|
||||
if (values.notifyCustomer) {
|
||||
setEmailOptions({
|
||||
jobid: jobId,
|
||||
messageOptions: {
|
||||
to: [values.email],
|
||||
replyTo: bodyshop.email,
|
||||
subject: TemplateList("appointment").appointment_confirmation.subject
|
||||
},
|
||||
template: {
|
||||
name: TemplateList("appointment").appointment_confirmation.key,
|
||||
variables: {
|
||||
jobIds: [jobId],
|
||||
fields: {
|
||||
status: bodyshop.md_ro_statuses.default_scheduled,
|
||||
date_scheduled: new Date(),
|
||||
scheduled_in: values.start,
|
||||
scheduled_completion: values.scheduled_completion,
|
||||
lost_sale_reason: null,
|
||||
date_lost_sale: null
|
||||
}
|
||||
id: appt.data.insert_appointments.returning[0].id
|
||||
}
|
||||
});
|
||||
|
||||
if (jobUpdate.errors) {
|
||||
notification.error({
|
||||
message: t("appointments.errors.saving", {
|
||||
message: JSON.stringify(jobUpdate.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (values.notifyCustomer) {
|
||||
setEmailOptions({
|
||||
jobid: jobId,
|
||||
messageOptions: {
|
||||
to: [values.email],
|
||||
replyTo: bodyshop.email,
|
||||
subject: TemplateList("appointment").appointment_confirmation.subject
|
||||
},
|
||||
template: {
|
||||
name: TemplateList("appointment").appointment_confirmation.key,
|
||||
variables: {
|
||||
id: appt.data.insert_appointments.returning[0].id
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (refetch) refetch();
|
||||
toggleModalVisible();
|
||||
setLoading(false);
|
||||
},
|
||||
[
|
||||
t,
|
||||
previousEvent,
|
||||
cancelAppointment,
|
||||
existingAppointments.data,
|
||||
insertAppointment,
|
||||
jobId,
|
||||
bodyshop.id,
|
||||
bodyshop.appt_length,
|
||||
currentUser.email,
|
||||
insertAuditTrail,
|
||||
job,
|
||||
updateJobStatus,
|
||||
bodyshop.md_ro_statuses.default_scheduled,
|
||||
setEmailOptions,
|
||||
refetch,
|
||||
toggleModalVisible,
|
||||
bodyshop.email
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
if (refetch) refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={toggleModalVisible}
|
||||
onCancel={() => toggleModalVisible()}
|
||||
onOk={() => form.submit()}
|
||||
width="90%"
|
||||
width={"90%"}
|
||||
maskClosable={false}
|
||||
destroyOnClose
|
||||
okButtonProps={{
|
||||
@@ -243,9 +217,10 @@ const ScheduleJobModalContainer = React.memo(function ScheduleJobModalContainer(
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
initialValues={{
|
||||
notifyCustomer: !!job?.ownr_ea,
|
||||
email: job?.ownr_ea || "",
|
||||
notifyCustomer: !!(job && job.ownr_ea),
|
||||
email: (job && job.ownr_ea) || "",
|
||||
start: null,
|
||||
// smartDates: [],
|
||||
scheduled_completion: null,
|
||||
color: context.color,
|
||||
alt_transport: context.alt_transport,
|
||||
@@ -261,6 +236,6 @@ const ScheduleJobModalContainer = React.memo(function ScheduleJobModalContainer(
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleJobModalContainer);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Button, Card, Form, Input, Popover, Select, Space } from "antd";
|
||||
import dayjs from "../../utils/day";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -13,143 +13,142 @@ import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleManualEvent);
|
||||
|
||||
const ScheduleManualEvent = React.memo(function ScheduleManualEvent({ bodyshop, event }) {
|
||||
export function ScheduleManualEvent({ bodyshop, event }) {
|
||||
const { t } = useTranslation();
|
||||
const [insertAppointment] = useMutation(INSERT_MANUAL_APPT, {
|
||||
refetchQueries: ["QUERY_ALL_ACTIVE_APPOINTMENTS"]
|
||||
});
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT, {
|
||||
refetchQueries: ["QUERY_ALL_ACTIVE_APPOINTMENTS"]
|
||||
});
|
||||
const [insertAppointment] = useMutation(INSERT_MANUAL_APPT);
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [visibility, setVisibility] = useState(false);
|
||||
|
||||
const handleFinish = useCallback(
|
||||
async (values) => {
|
||||
logImEXEvent("schedule_manual_event");
|
||||
setLoading(true);
|
||||
try {
|
||||
if (event && event.id) {
|
||||
await updateAppointment({
|
||||
variables: { appid: event.id, app: values }
|
||||
});
|
||||
} else {
|
||||
await insertAppointment({
|
||||
variables: {
|
||||
apt: {
|
||||
...values,
|
||||
isintake: false,
|
||||
bodyshopid: bodyshop.id
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
form.resetFields();
|
||||
setVisibility(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[event, updateAppointment, insertAppointment, bodyshop.id, form]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setVisibility(true);
|
||||
}, []);
|
||||
// const [callQuery, { loading: entryLoading, data: entryData }] = useLazyQuery(
|
||||
// QUERY_SCOREBOARD_ENTRY
|
||||
// );
|
||||
|
||||
useEffect(() => {
|
||||
if (visibility && event) {
|
||||
form.setFieldsValue(event);
|
||||
} else if (!visibility) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [visibility, form, event]);
|
||||
|
||||
const colorOptions = useMemo(() => {
|
||||
return bodyshop.appt_colors.map((col, idx) => (
|
||||
<Select.Option key={idx} value={col.color.hex}>
|
||||
{col.label}
|
||||
</Select.Option>
|
||||
));
|
||||
}, [bodyshop.appt_colors]);
|
||||
const handleFinish = async (values) => {
|
||||
logImEXEvent("schedule_manual_event");
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (event && event.id) {
|
||||
updateAppointment({
|
||||
variables: { appid: event.id, app: values },
|
||||
refetchQueries: ["QUERY_ALL_ACTIVE_APPOINTMENTS"]
|
||||
});
|
||||
} else {
|
||||
insertAppointment({
|
||||
variables: {
|
||||
apt: { ...values, isintake: false, bodyshopid: bodyshop.id }
|
||||
},
|
||||
refetchQueries: ["QUERY_ALL_ACTIVE_APPOINTMENTS"]
|
||||
});
|
||||
}
|
||||
form.resetFields();
|
||||
setVisibility(false);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const overlay = (
|
||||
<Card>
|
||||
<Form form={form} layout="vertical" onFinish={handleFinish}>
|
||||
<Form.Item
|
||||
label={t("appointments.fields.title")}
|
||||
name="title"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("appointments.fields.note")} name="note">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("appointments.fields.start")}
|
||||
name="start"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("appointments.fields.end")}
|
||||
name="end"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(rule, value) {
|
||||
if (value) {
|
||||
const start = form.getFieldValue("start");
|
||||
if (dayjs(start).isAfter(dayjs(value))) {
|
||||
return Promise.reject(t("employees.labels.endmustbeafterstart"));
|
||||
<div>
|
||||
<Form form={form} layout="vertical" onFinish={handleFinish}>
|
||||
<Form.Item
|
||||
label={t("appointments.fields.title")}
|
||||
name="title"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("appointments.fields.note")} name="note">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("appointments.fields.start")}
|
||||
name="start"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("appointments.fields.end")}
|
||||
name="end"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
async validator(rule, value) {
|
||||
if (value) {
|
||||
const { start } = form.getFieldsValue();
|
||||
if (dayjs(start).isAfter(dayjs(value))) {
|
||||
return Promise.reject(t("employees.labels.endmustbeafterstart"));
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("appointments.fields.color")} name="color">
|
||||
<Select>{colorOptions}</Select>
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
<Button onClick={() => setVisibility(false)}>{t("general.actions.cancel")}</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
})
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("appointments.fields.color")} name="color">
|
||||
<Select>
|
||||
{bodyshop.appt_colors.map((col, idx) => (
|
||||
<Select.Option key={idx} value={col.color.hex}>
|
||||
{col.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
<Button onClick={() => setVisibility(false)}>{t("general.actions.cancel")}</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const handleClick = (e) => {
|
||||
setVisibility(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover content={overlay} open={visibility}>
|
||||
<Button onClick={handleClick}>
|
||||
<Button loading={loading} onClick={handleClick}>
|
||||
{event ? t("appointments.actions.reschedule") : t("appointments.labels.manualevent")}
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ScheduleManualEvent);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DownOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Popover } from "antd";
|
||||
import React, { useCallback } from "react";
|
||||
import React from "react";
|
||||
import { useLazyQuery } from "@apollo/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -11,52 +11,52 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||
import "./schedule-production-list.styles.scss";
|
||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||
|
||||
const ScheduleProductionList = React.memo(function ScheduleProductionList() {
|
||||
export default function ScheduleProductionList() {
|
||||
const { t } = useTranslation();
|
||||
const [callQuery, { loading, error, data }] = useLazyQuery(QUERY_JOBS_IN_PRODUCTION);
|
||||
|
||||
const content = useCallback(() => {
|
||||
const content = () => {
|
||||
return (
|
||||
<Card>
|
||||
<div onClick={(e) => e.stopPropagation()} className="jobs-in-production-table">
|
||||
{loading && <LoadingSkeleton />}
|
||||
{error && <AlertComponent message={error.message} type="error" />}
|
||||
{data && data.jobs && (
|
||||
{loading ? <LoadingSkeleton /> : null}
|
||||
{error ? <AlertComponent message={error.message} type="error" /> : null}
|
||||
{data ? (
|
||||
<table>
|
||||
<tbody>
|
||||
{data.jobs.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td>
|
||||
<Link to={`/manage/jobs/${j.id}`}>{j.ro_number}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<OwnerNameDisplay ownerObject={j} />
|
||||
</td>
|
||||
<td>{`${j.v_model_yr || ""} ${j.v_make_desc || ""} ${j.v_model_desc || ""}`}</td>
|
||||
<td>{`${j.labhrs.aggregate.sum.mod_lb_hrs || "0"} / ${
|
||||
j.larhrs.aggregate.sum.mod_lb_hrs || "0"
|
||||
}`}</td>
|
||||
<td>
|
||||
<DateTimeFormatter>{j.scheduled_completion}</DateTimeFormatter>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data && data.jobs
|
||||
? data.jobs.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td>
|
||||
<Link to={`/manage/jobs/${j.id}`}>{j.ro_number}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<OwnerNameDisplay ownerObject={j} />
|
||||
</td>
|
||||
<td>{`${j.v_model_yr || ""} ${j.v_make_desc || ""} ${j.v_model_desc || ""}`}</td>
|
||||
<td>{`${j.labhrs.aggregate.sum.mod_lb_hrs || "0"} / ${
|
||||
j.larhrs.aggregate.sum.mod_lb_hrs || "0"
|
||||
}`}</td>
|
||||
<td>
|
||||
<DateTimeFormatter>{j.scheduled_completion}</DateTimeFormatter>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}, [loading, error, data]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover content={content} trigger="click" placement="bottomRight">
|
||||
<Button onClick={callQuery}>
|
||||
<Button onClick={() => callQuery()}>
|
||||
{t("appointments.labels.inproduction")}
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScheduleProductionList;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { Button } from "antd";
|
||||
import dayjs from "../../utils/day";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_SCHEDULE_LOAD_DATA } from "../../graphql/appointments.queries";
|
||||
@@ -10,46 +10,49 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const ScheduleVerifyIntegrity = React.memo(function ScheduleVerifyIntegrity({ currentUser }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const client = useApolloClient();
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const {
|
||||
data: { arrJobs, compJobs, prodJobs }
|
||||
} = await client.query({
|
||||
query: QUERY_SCHEDULE_LOAD_DATA,
|
||||
variables: { start: dayjs(), end: dayjs().add(180, "day") }
|
||||
});
|
||||
|
||||
// Check that the completing jobs are either in production or arriving within the next 180 days.
|
||||
const issues = compJobs.filter((j) => {
|
||||
const inProdJobs = prodJobs.some((p) => p.id === j.id);
|
||||
const inArrJobs = arrJobs.some((p) => p.id === j.id);
|
||||
return !(inProdJobs || inArrJobs);
|
||||
});
|
||||
|
||||
console.log("The following completing jobs are not in production or arriving within the next 180 days:", issues);
|
||||
} catch (error) {
|
||||
console.error("Error verifying schedule integrity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
// TODO: A Global helper with developer emails
|
||||
if (currentUser.email !== "patrick@imex.prod") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button loading={loading} onClick={handleVerify}>
|
||||
Developer Use Only - Verify Schedule Integrity
|
||||
</Button>
|
||||
);
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleVerifyIntegrity);
|
||||
|
||||
export default connect(mapStateToProps)(ScheduleVerifyIntegrity);
|
||||
export function ScheduleVerifyIntegrity({ currentUser }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const client = useApolloClient();
|
||||
const handleVerify = async () => {
|
||||
setLoading(true);
|
||||
const {
|
||||
data: { arrJobs, compJobs, prodJobs }
|
||||
} = await client.query({
|
||||
query: QUERY_SCHEDULE_LOAD_DATA,
|
||||
variables: { start: dayjs(), end: dayjs().add(180, "day") }
|
||||
});
|
||||
|
||||
//check that the leaving jobs are either in the arriving list, or in production.
|
||||
const issues = [];
|
||||
|
||||
compJobs.forEach((j) => {
|
||||
const inProdJobs = prodJobs.find((p) => p.id === j.id);
|
||||
const inArrJobs = arrJobs.find((p) => p.id === j.id);
|
||||
|
||||
if (!(inProdJobs || inArrJobs)) {
|
||||
// NOT FOUND!
|
||||
issues.push(j);
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
"The following completing jobs are not in production, or are arriving within the next 180 days. ",
|
||||
issues
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (currentUser.email === "patrick@imex.prod")
|
||||
return (
|
||||
<Button loading={loading} onClick={handleVerify}>
|
||||
Developer Use Only - Verify Schedule Integrity
|
||||
</Button>
|
||||
);
|
||||
else return null;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import ShopInfoTaskPresets from "./shop-info.task-presets.component";
|
||||
import queryString from "query-string";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import ShopInfoRoGuard from "./shop-info.roguard.component";
|
||||
import ShopInfoIntellipay from "./shop-intellipay-config.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -135,6 +136,17 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
|
||||
],
|
||||
rome: "USE_IMEX",
|
||||
promanager: []
|
||||
}),
|
||||
...InstanceRenderManager({
|
||||
imex: [],
|
||||
rome: [
|
||||
{
|
||||
key: "intellipay",
|
||||
label: t("bodyshop.labels.intellipay"),
|
||||
children: <ShopInfoIntellipay form={form} />
|
||||
}
|
||||
],
|
||||
promanager: []
|
||||
})
|
||||
];
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Alert, Form, InputNumber, Switch } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay);
|
||||
|
||||
export function ShopInfoIntellipay({ bodyshop, form }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}>
|
||||
{() => {
|
||||
const { intellipay_config } = form.getFieldsValue();
|
||||
|
||||
if (intellipay_config?.enable_cash_discount)
|
||||
return <Alert message={t("bodyshop.labels.intellipay_cash_discount")} />;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<LayoutFormRow noDivider>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.enable_cash_discount")}
|
||||
valuePropName="checked"
|
||||
name={["intellipay_config", "enable_cash_discount"]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.cash_discount_percentage")}
|
||||
valuePropName="checked"
|
||||
dependencies={[["intellipay_config", "enable_cash_discount"]]}
|
||||
name={["intellipay_config", "cash_discount_percentage"]}
|
||||
rules={[
|
||||
({ getFieldsValue }) => ({ required: form.getFieldValue(["intellipay_config", "enable_cash_discount"]) })
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={1} suffix='%'/>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,8 @@ export const QUERY_BODYSHOP = gql`
|
||||
tt_enforce_hours_for_tech_console
|
||||
md_tasks_presets
|
||||
use_paint_scale_data
|
||||
md_ro_guard
|
||||
intellipay_config
|
||||
md_ro_guard
|
||||
employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) {
|
||||
id
|
||||
name
|
||||
@@ -266,7 +267,8 @@ export const UPDATE_SHOP = gql`
|
||||
enforce_conversion_category
|
||||
tt_enforce_hours_for_tech_console
|
||||
md_tasks_presets
|
||||
md_ro_guard
|
||||
intellipay_config
|
||||
md_ro_guard
|
||||
employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) {
|
||||
id
|
||||
name
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Tabs } from "antd";
|
||||
import React, { useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import queryString from "query-string";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.component";
|
||||
import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container";
|
||||
import ShopInfoContainer from "../../components/shop-info/shop-info.container";
|
||||
import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.component";
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
|
||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
|
||||
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
|
||||
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
|
||||
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -332,6 +332,10 @@
|
||||
"next_contact_hours": "Automatic Next Contact Date - Hours from Intake",
|
||||
"templates": "Intake Templates"
|
||||
},
|
||||
"intellipay_config": {
|
||||
"cash_discount_percentage": "Cash Discount %",
|
||||
"enable_cash_discount": "Enable Cash Discounting"
|
||||
},
|
||||
"invoice_federal_tax_rate": "Invoices - Federal Tax Rate",
|
||||
"invoice_local_tax_rate": "Invoices - Local Tax Rate",
|
||||
"invoice_state_tax_rate": "Invoices - State Tax Rate",
|
||||
@@ -663,6 +667,8 @@
|
||||
"filehandlers": "Adjusters",
|
||||
"insurancecos": "Insurance Companies",
|
||||
"intakechecklist": "Intake Checklist",
|
||||
"intellipay": "IntelliPay",
|
||||
"intellipay_cash_discount": "Please ensure that cash discounting has been enabled on your merchant account. Reach out to IntelliPay Support if you need assistance. ",
|
||||
"jobstatuses": "Job Statuses",
|
||||
"laborrates": "Labor Rates",
|
||||
"licensing": "Licensing",
|
||||
@@ -1367,6 +1373,7 @@
|
||||
},
|
||||
"job_payments": {
|
||||
"buttons": {
|
||||
"create_short_link": "Generate Short Link",
|
||||
"goback": "Go Back",
|
||||
"proceedtopayment": "Proceed to Payment",
|
||||
"refundpayment": "Refund Payment"
|
||||
|
||||
@@ -332,6 +332,10 @@
|
||||
"next_contact_hours": "",
|
||||
"templates": ""
|
||||
},
|
||||
"intellipay_config": {
|
||||
"cash_discount_percentage": "",
|
||||
"enable_cash_discount": ""
|
||||
},
|
||||
"invoice_federal_tax_rate": "",
|
||||
"invoice_local_tax_rate": "",
|
||||
"invoice_state_tax_rate": "",
|
||||
@@ -663,6 +667,8 @@
|
||||
"filehandlers": "",
|
||||
"insurancecos": "",
|
||||
"intakechecklist": "",
|
||||
"intellipay": "",
|
||||
"intellipay_cash_discount": "",
|
||||
"jobstatuses": "",
|
||||
"laborrates": "",
|
||||
"licensing": "",
|
||||
@@ -1367,6 +1373,7 @@
|
||||
},
|
||||
"job_payments": {
|
||||
"buttons": {
|
||||
"create_short_link": "",
|
||||
"goback": "",
|
||||
"proceedtopayment": "",
|
||||
"refundpayment": ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -939,6 +939,7 @@
|
||||
- inhousevendorid
|
||||
- insurance_vendor_id
|
||||
- intakechecklist
|
||||
- intellipay_config
|
||||
- jc_hourly_rates
|
||||
- jobsizelimit
|
||||
- last_name_first
|
||||
@@ -1040,6 +1041,7 @@
|
||||
- inhousevendorid
|
||||
- insurance_vendor_id
|
||||
- intakechecklist
|
||||
- intellipay_config
|
||||
- jc_hourly_rates
|
||||
- last_name_first
|
||||
- localmediaserverhttp
|
||||
@@ -4240,6 +4242,63 @@
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: job_modified
|
||||
definition:
|
||||
enable_manual: false
|
||||
update:
|
||||
columns:
|
||||
- clm_no
|
||||
- v_make_desc
|
||||
- date_next_contact
|
||||
- status
|
||||
- employee_csr
|
||||
- employee_prep
|
||||
- clm_total
|
||||
- suspended
|
||||
- employee_body
|
||||
- ro_number
|
||||
- actual_in
|
||||
- ownr_co_nm
|
||||
- v_model_yr
|
||||
- comment
|
||||
- job_totals
|
||||
- v_vin
|
||||
- ownr_fn
|
||||
- scheduled_completion
|
||||
- special_coverage_policy
|
||||
- v_color
|
||||
- ca_gst_registrant
|
||||
- scheduled_delivery
|
||||
- actual_delivery
|
||||
- actual_completion
|
||||
- kanbanparent
|
||||
- est_ct_fn
|
||||
- employee_refinish
|
||||
- ownr_ph1
|
||||
- date_last_contacted
|
||||
- alt_transport
|
||||
- inproduction
|
||||
- est_ct_ln
|
||||
- production_vars
|
||||
- category
|
||||
- v_model_desc
|
||||
- date_invoiced
|
||||
- est_co_nm
|
||||
- ownr_ln
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/job/job-updated'
|
||||
version: 2
|
||||
- name: job_status_transition
|
||||
definition:
|
||||
enable_manual: true
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."bodyshops" add column "intellipay_config" jsonb
|
||||
-- not null default jsonb_build_object();
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."bodyshops" add column "intellipay_config" jsonb
|
||||
not null default jsonb_build_object();
|
||||
@@ -96,7 +96,21 @@ const sendServerEmail = async ({ subject, text }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendTaskEmail = async ({ to, subject, text, attachments }) => {
|
||||
const sendProManagerWelcomeEmail = async ({to, subject, html}) => {
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: `ProManager <noreply@promanager.web-est.com>`,
|
||||
to,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
logger.log("server-email-failure", "error", null, null, error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => {
|
||||
try {
|
||||
transporter.sendMail(
|
||||
{
|
||||
@@ -107,7 +121,7 @@ const sendTaskEmail = async ({ to, subject, text, attachments }) => {
|
||||
}),
|
||||
to: to,
|
||||
subject: subject,
|
||||
text: text,
|
||||
...(type === "text" ? { text } : { html }),
|
||||
attachments: attachments || null
|
||||
},
|
||||
(err, info) => {
|
||||
@@ -309,5 +323,6 @@ module.exports = {
|
||||
sendEmail,
|
||||
sendServerEmail,
|
||||
sendTaskEmail,
|
||||
sendProManagerWelcomeEmail,
|
||||
emailBounce
|
||||
};
|
||||
|
||||
@@ -94,8 +94,9 @@ const formatPriority = (priority) => {
|
||||
* @param taskId
|
||||
* @returns {{header, body: string, subHeader: string}}
|
||||
*/
|
||||
const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId) => {
|
||||
const endPoints = InstanceManager({
|
||||
|
||||
const getEndpoints = () =>
|
||||
InstanceManager({
|
||||
imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online",
|
||||
rome:
|
||||
bodyshop.convenient_company === "promanager"
|
||||
@@ -106,6 +107,9 @@ const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, j
|
||||
? "https//test.romeonline.io"
|
||||
: "https://romeonline.io"
|
||||
});
|
||||
|
||||
const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId) => {
|
||||
const endPoints = getEndpoints();
|
||||
return {
|
||||
header: title,
|
||||
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)}`,
|
||||
@@ -333,5 +337,6 @@ const tasksRemindEmail = async (req, res) => {
|
||||
|
||||
module.exports = {
|
||||
taskAssignedEmail,
|
||||
tasksRemindEmail
|
||||
tasksRemindEmail,
|
||||
getEndpoints
|
||||
};
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("../utils/logger");
|
||||
const path = require("path");
|
||||
const { auth } = require("firebase-admin");
|
||||
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("../utils/logger");
|
||||
const { sendProManagerWelcomeEmail } = require("../email/sendemail");
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON);
|
||||
const adminEmail = require("../utils/adminEmail");
|
||||
const generateEmailTemplate = require("../email/generateTemplate");
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
databaseURL: process.env.FIREBASE_DATABASE_URL
|
||||
});
|
||||
|
||||
exports.admin = admin;
|
||||
|
||||
exports.createUser = async (req, res) => {
|
||||
const createUser = async (req, res) => {
|
||||
logger.log("admin-create-user", "ADMIN", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
const { email, displayName, password, shopid, authlevel } = req.body;
|
||||
const { email, displayName, password, shopid, authlevel, validemail } = req.body;
|
||||
|
||||
try {
|
||||
const userRecord = await admin.auth().createUser({ email, displayName, password });
|
||||
|
||||
@@ -42,6 +40,7 @@ exports.createUser = async (req, res) => {
|
||||
user: {
|
||||
email: email.toLowerCase(),
|
||||
authid: userRecord.uid,
|
||||
validemail,
|
||||
associations: {
|
||||
data: [{ shopid, authlevel, active: true }]
|
||||
}
|
||||
@@ -58,21 +57,115 @@ exports.createUser = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateUser = (req, res) => {
|
||||
const sendPromanagerWelcomeEmail = (req, res) => {
|
||||
const { authid, email } = req.body;
|
||||
|
||||
// Fetch user from Firebase
|
||||
admin
|
||||
.auth()
|
||||
.getUser(authid)
|
||||
.then((userRecord) => {
|
||||
if (!userRecord) {
|
||||
return Promise.reject({ status: 404, message: "User not found in Firebase." });
|
||||
}
|
||||
|
||||
// Fetch user data from the database using GraphQL
|
||||
return client.request(
|
||||
`
|
||||
query GET_USER_BY_EMAIL($email: String!) {
|
||||
users(where: { email: { _eq: $email } }) {
|
||||
email
|
||||
validemail
|
||||
associations {
|
||||
id
|
||||
shopid
|
||||
bodyshop {
|
||||
id
|
||||
convenient_company
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ email: email.toLowerCase() }
|
||||
);
|
||||
})
|
||||
.then((dbUserResult) => {
|
||||
const dbUser = dbUserResult?.users?.[0];
|
||||
if (!dbUser) {
|
||||
return Promise.reject({ status: 404, message: "User not found in database." });
|
||||
}
|
||||
|
||||
// Validate email before proceeding
|
||||
if (!dbUser.validemail) {
|
||||
logger.log("admin-send-welcome-email-skip", "ADMIN", req.user.email, null, {
|
||||
message: "User email is not valid, skipping email.",
|
||||
email
|
||||
});
|
||||
return res.status(200).json({ message: "User email is not valid, email not sent." });
|
||||
}
|
||||
|
||||
// Check if the user's company is ProManager
|
||||
const convenientCompany = dbUser.associations?.[0]?.bodyshop?.convenient_company;
|
||||
if (convenientCompany !== "promanager") {
|
||||
logger.log("admin-send-welcome-email-skip", "ADMIN", req.user.email, null, {
|
||||
message: 'convenient_company is not "promanager", skipping email.',
|
||||
convenientCompany
|
||||
});
|
||||
return res.status(200).json({ message: `convenient_company is not "promanager", email not sent.` });
|
||||
}
|
||||
|
||||
// Generate password reset link
|
||||
return admin
|
||||
.auth()
|
||||
.generatePasswordResetLink(dbUser.email)
|
||||
.then((resetLink) => ({ dbUser, resetLink }));
|
||||
})
|
||||
.then(({ dbUser, resetLink }) => {
|
||||
// Send welcome email (replace with your actual email-sending service)
|
||||
return sendProManagerWelcomeEmail({
|
||||
to: dbUser.email,
|
||||
subject: "Welcome to the ProManager platform.",
|
||||
html: generateEmailTemplate({
|
||||
header: "",
|
||||
subHeader: "",
|
||||
body: `
|
||||
<p>Welcome to the ProManager platform. Please click the link below to reset your password:</p>
|
||||
<p><a href="${resetLink}">Reset your password</a></p>
|
||||
<p>User Details:</p>
|
||||
<ul>
|
||||
<li>Email: ${dbUser.email}</li>
|
||||
</ul>
|
||||
`
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// Log success and return response
|
||||
logger.log("admin-send-welcome-email", "ADMIN", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true,
|
||||
emailSentTo: email
|
||||
});
|
||||
res.status(200).json({ message: "Welcome email sent successfully." });
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error });
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(error.status || 500).json({
|
||||
message: error.message || "Error sending welcome email.",
|
||||
error
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateUser = (req, res) => {
|
||||
logger.log("admin-update-user", "ADMIN", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
if (!adminEmail.includes(req.user.email) && !req.user.ioadmin) {
|
||||
logger.log("admin-update-user-unauthorized", "ERROR", req.user.email, null, {
|
||||
request: req.body,
|
||||
user: req.user
|
||||
});
|
||||
res.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
|
||||
admin
|
||||
.auth()
|
||||
.updateUser(
|
||||
@@ -105,26 +198,45 @@ exports.updateUser = (req, res) => {
|
||||
});
|
||||
};
|
||||
|
||||
exports.getUser = (req, res) => {
|
||||
const getUser = (req, res) => {
|
||||
logger.log("admin-get-user", "ADMIN", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
if (!adminEmail.includes(req.user.email) && !req.user.ioadmin) {
|
||||
logger.log("admin-update-user-unauthorized", "ERROR", req.user.email, null, {
|
||||
request: req.body,
|
||||
user: req.user
|
||||
});
|
||||
res.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
|
||||
admin
|
||||
.auth()
|
||||
.getUser(req.body.uid)
|
||||
.then((userRecord) => {
|
||||
res.json(userRecord);
|
||||
return client
|
||||
.request(
|
||||
`
|
||||
query GET_USER_BY_AUTHID($authid: String!) {
|
||||
users(where: { authid: { _eq: $authid } }) {
|
||||
email
|
||||
validemail
|
||||
associations {
|
||||
id
|
||||
shopid
|
||||
bodyshop {
|
||||
id
|
||||
convenient_company
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ authid: req.body.uid }
|
||||
)
|
||||
.then((dbUserResult) => {
|
||||
res.json({
|
||||
...userRecord,
|
||||
db: {
|
||||
validemail: dbUserResult?.users?.[0]?.validemail,
|
||||
company: dbUserResult?.users?.[0]?.associations?.[0]?.bodyshop?.convenient_company
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.log("admin-get-user-error", "ERROR", req.user.email, null, {
|
||||
@@ -134,7 +246,7 @@ exports.getUser = (req, res) => {
|
||||
});
|
||||
};
|
||||
|
||||
exports.sendNotification = async (req, res) => {
|
||||
const sendNotification = async (req, res) => {
|
||||
setTimeout(() => {
|
||||
// Send a message to the device corresponding to the provided
|
||||
// registration token.
|
||||
@@ -167,7 +279,7 @@ exports.sendNotification = async (req, res) => {
|
||||
}, 500);
|
||||
};
|
||||
|
||||
exports.subscribe = async (req, res) => {
|
||||
const subscribe = async (req, res) => {
|
||||
const result = await admin
|
||||
.messaging()
|
||||
.subscribeToTopic(req.body.fcm_tokens, `${req.body.imexshopid}-${req.body.type}`);
|
||||
@@ -175,7 +287,7 @@ exports.subscribe = async (req, res) => {
|
||||
res.json(result);
|
||||
};
|
||||
|
||||
exports.unsubscribe = async (req, res) => {
|
||||
const unsubscribe = async (req, res) => {
|
||||
try {
|
||||
const result = await admin
|
||||
.messaging()
|
||||
@@ -187,6 +299,17 @@ exports.unsubscribe = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
admin,
|
||||
createUser,
|
||||
updateUser,
|
||||
getUser,
|
||||
sendPromanagerWelcomeEmail,
|
||||
sendNotification,
|
||||
subscribe,
|
||||
unsubscribe
|
||||
};
|
||||
|
||||
//Admin claims code.
|
||||
// const uid = "JEqqYlsadwPEXIiyRBR55fflfko1";
|
||||
|
||||
|
||||
@@ -2502,6 +2502,13 @@ exports.GET_JOBS_BY_PKS = `query GET_JOBS_BY_PKS($ids: [uuid!]!) {
|
||||
jobs(where: {id: {_in: $ids}}) {
|
||||
id
|
||||
shopid
|
||||
ro_number
|
||||
ownr_co_nm
|
||||
ownr_fn
|
||||
ownr_ln
|
||||
v_make_desc
|
||||
v_model_yr
|
||||
v_model_desc
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -7,7 +7,9 @@ const axios = require("axios");
|
||||
const moment = require("moment");
|
||||
const logger = require("../utils/logger");
|
||||
const InstanceManager = require("../utils/instanceMgr").default;
|
||||
|
||||
const { sendTaskEmail } = require("../email/sendemail");
|
||||
const generateEmailTemplate = require("../email/generateTemplate");
|
||||
const { getEndpoints } = require("../email/tasksEmails");
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
@@ -129,6 +131,7 @@ exports.generate_payment_url = async (req, res) => {
|
||||
//...req.body,
|
||||
amount: Dinero({ amount: Math.round(req.body.amount * 100) }).toFormat("0.00"),
|
||||
account: req.body.account,
|
||||
comment: req.body.comment,
|
||||
invoice: req.body.invoice,
|
||||
createshorturl: true
|
||||
//The postback URL is set at the CP teller global terminal settings page.
|
||||
@@ -162,7 +165,67 @@ exports.postback = async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.invoice) {
|
||||
if (comment) {
|
||||
//Shifted the order to have this first to retain backwards compatibility for the old style of short link.
|
||||
//This has been triggered by IO and may have multiple jobs.
|
||||
const parsedComment = JSON.parse(comment);
|
||||
|
||||
//Adding in the user email to the short pay email.
|
||||
//Need to check this to ensure backwards compatibility for clients that don't update.
|
||||
|
||||
const partialPayments = Array.isArray(parsedComment) ? parsedComment : parsedComment.payments;
|
||||
|
||||
const jobs = await gqlClient.request(queries.GET_JOBS_BY_PKS, {
|
||||
ids: partialPayments.map((p) => p.jobid)
|
||||
});
|
||||
|
||||
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
|
||||
paymentInput: partialPayments.map((p) => ({
|
||||
amount: p.amount,
|
||||
transactionid: values.authcode,
|
||||
payer: "Customer",
|
||||
type: values.cardtype,
|
||||
jobid: p.jobid,
|
||||
date: moment(Date.now()),
|
||||
payment_responses: {
|
||||
data: {
|
||||
amount: values.total,
|
||||
bodyshopid: jobs.jobs[0].shopid,
|
||||
jobid: p.jobid,
|
||||
declinereason: "Approved",
|
||||
ext_paymentid: values.paymentid,
|
||||
successful: true,
|
||||
response: values
|
||||
}
|
||||
}
|
||||
}))
|
||||
});
|
||||
logger.log("intellipay-postback-app-success", "DEBUG", req.user?.email, null, {
|
||||
iprequest: values,
|
||||
paymentResult
|
||||
});
|
||||
|
||||
if (values.origin === "OneLink" && parsedComment.userEmail) {
|
||||
//Send an email, it was a text to pay link.
|
||||
const endPoints = getEndpoints();
|
||||
sendTaskEmail({
|
||||
to: parsedComment.userEmail,
|
||||
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
|
||||
type: "html",
|
||||
html: generateEmailTemplate({
|
||||
header: "New Payment(s) Received",
|
||||
subHeader: "",
|
||||
body: jobs.jobs
|
||||
.map(
|
||||
(job) =>
|
||||
`Reference: <a href="${endPoints}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</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()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
|
||||
)
|
||||
.join("<br/>")
|
||||
})
|
||||
});
|
||||
res.sendStatus(200);
|
||||
}
|
||||
} else if (values.invoice) {
|
||||
//This is a link email that's been sent out.
|
||||
const job = await gqlClient.request(queries.GET_JOB_BY_PK, {
|
||||
id: values.invoice
|
||||
@@ -198,39 +261,6 @@ exports.postback = async (req, res) => {
|
||||
paymentResult
|
||||
});
|
||||
res.sendStatus(200);
|
||||
} else if (comment) {
|
||||
//This has been triggered by IO and may have multiple jobs.
|
||||
const partialPayments = JSON.parse(comment);
|
||||
const jobs = await gqlClient.request(queries.GET_JOBS_BY_PKS, {
|
||||
ids: partialPayments.map((p) => p.jobid)
|
||||
});
|
||||
|
||||
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
|
||||
paymentInput: partialPayments.map((p) => ({
|
||||
amount: p.amount,
|
||||
transactionid: values.authcode,
|
||||
payer: "Customer",
|
||||
type: values.cardtype,
|
||||
jobid: p.jobid,
|
||||
date: moment(Date.now()),
|
||||
payment_responses: {
|
||||
data: {
|
||||
amount: values.total,
|
||||
bodyshopid: jobs.jobs[0].shopid,
|
||||
jobid: p.jobid,
|
||||
declinereason: "Approved",
|
||||
ext_paymentid: values.paymentid,
|
||||
successful: true,
|
||||
response: values
|
||||
}
|
||||
}
|
||||
}))
|
||||
});
|
||||
logger.log("intellipay-postback-app-success", "DEBUG", req.user?.email, null, {
|
||||
iprequest: values,
|
||||
paymentResult
|
||||
});
|
||||
res.sendStatus(200);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("intellipay-postback-error", "ERROR", req.user?.email, null, {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const fb = require("../firebase/firebase-handler");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops");
|
||||
const { updateUser, getUser, createUser, sendPromanagerWelcomeEmail } = require("../firebase/firebase-handler");
|
||||
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
router.use(validateAdminMiddleware);
|
||||
|
||||
router.post("/createassociation", validateAdminMiddleware, createAssociation);
|
||||
router.post("/createshop", validateAdminMiddleware, createShop);
|
||||
router.post("/updateshop", validateAdminMiddleware, updateShop);
|
||||
router.post("/updatecounter", validateAdminMiddleware, updateCounter);
|
||||
router.post("/updateuser", fb.updateUser);
|
||||
router.post("/getuser", fb.getUser);
|
||||
router.post("/createuser", fb.createUser);
|
||||
router.post("/createassociation", createAssociation);
|
||||
router.post("/createshop", createShop);
|
||||
router.post("/updateshop", updateShop);
|
||||
router.post("/updatecounter", updateCounter);
|
||||
router.post("/updateuser", updateUser);
|
||||
router.post("/getuser", getUser);
|
||||
router.post("/createuser", createUser);
|
||||
router.post("/promanagerwelcome", sendPromanagerWelcomeEmail);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user