Compare commits

..

14 Commits

Author SHA1 Message Date
Allan Carr
646754732d IO-2782 Adjust to Object for items
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-20 16:45:48 -07:00
Dave Richer
efc1157653 IO-2782 - Fix query
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-20 18:53:33 -04:00
Dave Richer
a5d3f2caf1 IO-2782-Send-Promanager-Welcome-Email - Update for merge conflict
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-19 13:11:02 -04:00
Dave Richer
4ad87a522c IO-2782-Send-Promanager-Welcome-Email - Update for merge conflict
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-19 13:08:23 -04:00
Dave Richer
145cf7cc93 IO-2782-Send-Promanager-Welcome-Email - Finalize cleanup
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-19 12:54:26 -04:00
Dave Richer
cdb2d4d2d6 IO-2782-Send-Promanager-Welcome-Email - Cleanup of adminRoutes / firebase-handler.js
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-19 11:29:13 -04:00
Dave Richer
29f0031c1e IO-2782-Send-Promanager-Welcome-Email - Send ProManager welcome email
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-18 18:30:02 -04:00
Patrick Fic
e3059b41ae IO-2933 Resolve PR comments. 2024-09-18 11:19:43 -07:00
Patrick Fic
2a33f462a3 IO-2933 Add in email for succesful postback from Short URL. 2024-09-16 16:02:19 -07:00
Patrick Fic
cbc164dbeb IO-2933 Add in ability to text payments for multiple ROs. 2024-09-16 14:33:17 -07:00
Patrick Fic
6382fdf19c Merge branch 'feature/IO-2920-cash-discounting' into release/2024-09-20 2024-09-16 12:23:53 -07:00
Patrick Fic
9287e6608d IO-2920 Pretty JSON Translation 2024-09-16 12:23:29 -07:00
Patrick Fic
d221763064 Merge branch 'feature/IO-2920-cash-discounting' into release/2024-09-20 2024-09-16 12:13:13 -07:00
Patrick Fic
b39a5b755e IO-2920 Add hasura changes for cash discount & add config page. 2024-09-16 12:07:35 -07:00
41 changed files with 5492 additions and 5039 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "intellipay_config" jsonb
-- not null default jsonb_build_object();

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "intellipay_config" jsonb
not null default jsonb_build_object();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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