Compare commits

...

38 Commits

Author SHA1 Message Date
Dave Richer
bbd52091d8 IO-2932-Scheduling-Lag-on-AIO:
profiler

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-17 14:06:22 -04:00
Dave Richer
873eb65e75 IO-2932-Scheduling-Lag-on-AIO:
null collaesnce

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-17 13:36:33 -04:00
Dave Richer
4b6e140e3e IO-2932-Scheduling-Lag-on-AIO:
Bump React-Big-Calendar

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-17 13:29:10 -04:00
Dave Richer
8f118937f3 IO-2932-Scheduling-Lag-on-AIO:
Bump React-Big-Calendar

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-17 12:54:19 -04:00
Dave Richer
cd0a08a7be IO-2932-Scheduling-Lag-on-AIO:
Bump React-Big-Calendar

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-17 12:34:11 -04:00
Dave Richer
b0ea516fd6 IO-2932-Scheduling-Lag-on-AIO:
Bump React-Big-Calendar

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-17 12:00:37 -04:00
Dave Richer
10ba19f0d2 IO-2932-Scheduling-Lag-on-AIO:
Full Optimization of all Schedule related components.

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-16 23:02:20 -04:00
Dave Richer
449330441a Merged in release/2024-09-13 (pull request #1725)
Release/2024 09 13 - IO-2913 - IO-2915 - IO-2733 - IO-2923 - IO-2925 - IO-2928 - IO-2927 - IO-2928 - IO-2913 - IO-2733 - IO-2926
2024-09-16 16:28:40 +00:00
Dave Richer
fcab5e6ef2 Merged in feature/IO-2926-Vendor-Discount-Wrapping-In-Parts-Order (pull request #1723)
feature/IO-2926-Vendor-Discount-Wrapping-In-Parts-Order - Fix Wrapping in Vendor Search Select
2024-09-16 16:18:05 +00:00
Dave Richer
0212b837ea feature/IO-2926-Vendor-Discount-Wrapping-In-Parts-Order - Fix Wrapping in Vendor Search Select
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-16 12:16:29 -04:00
Patrick Fic
e7438a099e Merged in feature/IO-2733-pwa-timer (pull request #1721)
IO-2733 Add loading state and further delay reload.
2024-09-13 18:24:11 +00:00
Patrick Fic
c69c86d193 Merged in feature/IO-2733-pwa-timer (pull request #1719)
IO-2733 Resolve notification showing incorrect time.
2024-09-13 17:51:33 +00:00
Patrick Fic
af09796df8 Merged in feature/IO-2733-pwa-timer (pull request #1717)
IO-2733 Add Timer Started check to prevent auto refresh early.
2024-09-13 16:59:58 +00:00
Allan Carr
0aba040338 Merged in feature/IO-2928-QBO-CAUSA-Payable-TAX (pull request #1714)
IO-2928 QBO CA US Tax  Accumulator

Approved-by: Patrick Fic
2024-09-13 14:43:58 +00:00
Allan Carr
c3bfe87674 Merge branch 'feature/IO-2913-ADP-Payroll' into release/2024-09-13
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>

# Conflicts:
#	client/src/translations/en_us/common.json
#	client/src/translations/es/common.json
#	client/src/translations/fr/common.json
2024-09-12 19:59:47 -07:00
Allan Carr
9aa1279144 IO-2913 Add in Translations
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-12 19:53:50 -07:00
Allan Carr
4e6c45b195 IO-2928 Null coalesce billline amount
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-12 17:13:29 -07:00
Allan Carr
4fdb939bd2 Merged in feature/IO-2927-qbo-usa-gst-itc (pull request #1715)
IO-2927 Extend Logging passthru

Approved-by: Patrick Fic
2024-09-12 23:58:24 +00:00
Allan Carr
062a1dcc72 IO-2927 Extend Logging passthru
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-12 16:29:37 -07:00
Allan Carr
7b420b1855 IO-2928 QBO CA US Tax Accumulator
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-12 16:27:43 -07:00
Allan Carr
40f61bbc8f Merged in feature/IO-2927-qbo-usa-gst-itc (pull request #1713)
IO-2927 Correct accountmeta
2024-09-12 22:23:54 +00:00
Allan Carr
f5d821c394 Merged in feature/IO-2927-qbo-usa-gst-itc (pull request #1712)
IO-2927 Correct accountmeta
2024-09-12 22:12:32 +00:00
Allan Carr
3958ec9189 IO-2927 Correct accountmeta
accounts doesn't exist in recievables, switch to items

Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-12 15:11:06 -07:00
Allan Carr
1e4f52e541 Merged in feature/IO-2927-qbo-usa-gst-itc (pull request #1710)
Feature/IO-2927 qbo usa gst itc

IO-2927
2024-09-12 20:33:27 +00:00
Patrick Fic
5cc5cb444e Merged in feature/IO-2927-qbo-usa-gst-itc (pull request #1709)
IO-2997 Add better error handling for 400 requests.

Approved-by: Allan Carr
2024-09-12 20:26:16 +00:00
Patrick Fic
4acf0c59ca IO-2997 Remove unnecessary comment. 2024-09-12 13:25:14 -07:00
Patrick Fic
2858a5e871 IO-2997 Add better error handling for 400 requests. 2024-09-12 13:23:53 -07:00
Patrick Fic
24496d3ee1 Merged in feature/IO-2927-qbo-usa-gst-itc (pull request #1707)
IO-2927 Update QBO Payable to use ITC.

Approved-by: Allan Carr
2024-09-12 20:04:33 +00:00
Patrick Fic
0a5df69b12 IO-2927 Update QBO Payable to use ITC. 2024-09-12 13:03:23 -07:00
Patrick Fic
80efea02c6 Merged in feature/IO-2925-ppc-40-points (pull request #1704)
IO-2925 Add 40% as PPC choice.

Approved-by: Allan Carr
2024-09-12 17:15:03 +00:00
Patrick Fic
9f5c282b41 IO-2925 Add 40% as PPC choice. 2024-09-12 09:19:49 -07:00
Allan Carr
b2602c3385 Merged in feature/IO-2923-Edit-Bill-Line-original_actual_price (pull request #1702)
IO-2923 Edit Bill Line original_actual_price

Approved-by: Dave Richer
2024-09-12 15:54:58 +00:00
Allan Carr
0e584af424 IO-2923 Edit Bill Line original_actual_price
IO-2923 Edit Bill Line original_actual_price

Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-11 16:36:28 -07:00
Patrick Fic
cdc3de2a33 Merge branch 'feature/IO-2733-pwa-timer' into release/2024-09-13 2024-09-10 15:58:59 -07:00
Allan Carr
44cb7577e2 Merged in feature/IO-2913-ADP-Payroll (pull request #1698)
IO-2913 ADP Payroll Reports

Approved-by: Dave Richer
2024-09-10 20:24:51 +00:00
Allan Carr
46d2b08477 Merged in feature/IO-2915-Customer-Portion-Totals-Federal-Tax (pull request #1699)
IO-2915 Customer Portion Totals - Federal Tax

Approved-by: Dave Richer
2024-09-10 20:24:13 +00:00
Allan Carr
0193ff9e65 IO-2915 Customer Portion Totals - Federal Tax
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-10 11:55:42 -07:00
Allan Carr
fd9a51209f IO-2913 ADP Payroll Reports
Uses ADPPayroll Split

Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-09-10 11:21:03 -07:00
34 changed files with 12234 additions and 11963 deletions

View File

@@ -47,7 +47,7 @@
"query-string": "^9.1.0",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.13.2",
"react-big-calendar": "^1.14.1",
"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.13.2",
"resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.13.2.tgz",
"integrity": "sha512-yzeVRM1I+JloeJXytrZx2lJWKUfLAi5bsgGuBjh3aFSHZrdFcGnfA7LE6pBacdyOG+NGP+332m2MziszkmQWcw==",
"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==",
"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.13.2",
"react-big-calendar": "^1.14.1",
"react-color": "^2.19.3",
"react-cookie": "^7.2.0",
"react-dom": "^18.3.1",

View File

@@ -98,7 +98,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
});
billlines.forEach((billline) => {
const { deductedfromlbr, inventories, jobline, ...il } = billline;
const { deductedfromlbr, inventories, jobline, original_actual_price, create_ppc, ...il } = billline;
delete il.__typename;
if (il.id) {

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback, useMemo } from "react";
import { useMutation } from "@apollo/client";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { useTranslation } from "react-i18next";
@@ -11,55 +11,61 @@ 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 = 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 = 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)
})
});
}
},
[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;
}
};
return null;
}, [event.color, bodyshop.appt_colors]);
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") }
]
};
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]
);
return (
<Dropdown menu={menu}>
<a href=" #" onClick={(e) => e.preventDefault()}>
<a href="#" onClick={(e) => e.preventDefault()}>
{selectedColor}
<DownOutlined />
</a>
@@ -67,4 +73,4 @@ export function ScheduleEventColor({ bodyshop, event }) {
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleEventColor);
export default connect(mapStateToProps)(React.memo(ScheduleEventColor));

View File

@@ -2,11 +2,10 @@ 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 queryString from "query-string";
import React, { useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
@@ -27,6 +26,7 @@ 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,301 +44,319 @@ export function ScheduleEventComponent({
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const history = useNavigate();
const searchParams = queryString.parse(useLocation().search);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [title, setTitle] = useState(event.title);
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
}
},
optimisticResponse: {
update_appointments: {
__typename: "appointments_mutation_response",
returning: [
{
...event,
title: title,
__typename: "appointments"
}
]
}
}
});
}}
/>
<Button onClick={() => handleCancel({ id: event.id })} disabled={event.arrived}>
{t("appointments.actions.unblock")}
</Button>
</Space>
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 popoverContent = (
<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>
)}
const reminderMenu = useMemo(() => ({ items: reminderMenuItems }), [reminderMenuItems]);
{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={() => {
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>
)}
const handleCancelFormFinish = useCallback(
({ lost_sale_reason }) => {
handleCancel({ id: event.id, lost_sale_reason });
},
[handleCancel, event.id]
);
{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}
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>
</div>
),
[title, handleTitleBlur, handleUnblock, event.arrived, t]
);
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>
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>
)}
<OwnerNameDisplay ownerObject={event.job} />
{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>
}
>
<Button disabled={event.arrived}>{t("appointments.actions.cancel")}</Button>
</Popover>
)}
{`${(event.job && event.job.v_model_yr) || ""} ${
(event.job && event.job.v_make_desc) || ""
} ${(event.job && event.job.v_model_desc) || ""}`}
{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
]);
{`(${(event.job && event.job.labhrs.aggregate.sum.mod_lb_hrs) || "0"} / ${
(event.job && event.job.larhrs.aggregate.sum.mod_lb_hrs) || "0"
})`}
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>
{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>
<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>
),
[event, t]
);
return (
<Popover
open={open}
onOpenChange={(vis) => !event.vacation && setOpen(vis)}
onOpenChange={handleOpenChange}
trigger="click"
content={event.block ? blockContent : popoverContent}
style={{
height: "100%",
width: "100%",
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
}}
>
@@ -347,4 +365,4 @@ export function ScheduleEventComponent({
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleEventComponent);
export default connect(mapStateToProps, mapDispatchToProps)(React.memo(ScheduleEventComponent));

View File

@@ -1,6 +1,6 @@
import { useMutation } from "@apollo/client";
import { notification } from "antd";
import React from "react";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { logImEXEvent } from "../../firebase/firebase.utils";
@@ -10,64 +10,70 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import ScheduleEventComponent from "./schedule-event.component";
export default function ScheduleEventContainer({ bodyshop, event, refetch }) {
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 cancelAppt = await cancelAppointment({
variables: { appid: event.id }
});
notification["success"]({
message: t("appointments.successes.canceled")
});
const handleCancel = useCallback(
async ({ id, lost_sale_reason }) => {
logImEXEvent("schedule_cancel_appt");
if (!!cancelAppt.errors) {
notification["error"]({
message: t("appointments.errors.canceling", {
message: JSON.stringify(cancelAppt.errors)
})
const cancelAppt = await cancelAppointment({
variables: { appid: event.id }
});
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"
})
);
}
if (!!jobUpdate.errors) {
notification["error"]({
message: t("jobs.errors.updating", {
message: JSON.stringify(jobUpdate.errors)
if (!cancelAppt.errors) {
notification.success({
message: t("appointments.successes.canceled")
});
} else {
notification.error({
message: t("appointments.errors.canceling", {
message: JSON.stringify(cancelAppt.errors)
})
});
return;
}
}
if (refetch) refetch();
};
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]
);
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 } from "react";
import React, { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -12,9 +12,6 @@ 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);
@@ -23,9 +20,9 @@ export function ScheduleEventNote({ event }) {
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const { t } = useTranslation();
const toggleEdit = async () => {
const toggleEdit = useCallback(async () => {
if (editing) {
//Await the update
// Await the update
setLoading(true);
const result = await updateAppointment({
variables: {
@@ -34,10 +31,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)
})
@@ -45,11 +42,15 @@ export function ScheduleEventNote({ event }) {
}
setEditing(false);
setLoading(false);
} else {
setEditing(true);
}
setLoading(false);
};
}, [editing, note, updateAppointment, event.id, t]);
const handleNoteChange = useCallback((e) => {
setNote(e.target.value);
}, []);
return (
<DataLabel label={t("appointments.fields.note")}>
@@ -57,7 +58,7 @@ export function ScheduleEventNote({ event }) {
{!editing ? (
event.note || ""
) : (
<Input.TextArea rows={3} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: "8vw" }} />
<Input.TextArea rows={3} value={note} onChange={handleNoteChange} style={{ maxWidth: "8vw" }} />
)}
<Button onClick={toggleEdit} loading={loading}>
{editing ? <SaveFilled /> : <EditFilled />}
@@ -67,4 +68,4 @@ export function ScheduleEventNote({ event }) {
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleEventNote);
export default connect(mapStateToProps)(React.memo(ScheduleEventNote));

View File

@@ -141,10 +141,14 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
key: t("jobs.fields.ded_amt"),
total: job.job_totals.totals.custPayable.deductible
},
// {
// key: t("jobs.fields.federal_tax_payable"),
// total: job.job_totals.totals.custPayable.federal_tax,
// },
...(InstanceRenderManager({
imex: [{
key: t("jobs.fields.federal_tax_payable"),
total: job.job_totals.totals.custPayable.federal_tax
}],
rome: [],
promanager: "USE_ROME"
})),
{
key: t("jobs.fields.other_amount_payable"),
total: job.job_totals.totals.custPayable.other_customer_amount

View File

@@ -27,6 +27,10 @@ export default function PartsOrderModalPriceChange({ form, field }) {
key: "25",
label: t("parts_orders.labels.discount", { percent: "25%" })
},
{
key: "40",
label: t("parts_orders.labels.discount", { percent: "40%" })
},
{
key: "custom",
label: (

View File

@@ -34,28 +34,34 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
const [form] = Form.useForm();
const [search, setSearch] = useState("");
const {
treatments: { Enhanced_Payroll }
treatments: { Enhanced_Payroll, ADPPayroll }
} = useSplitTreatments({
attributes: {},
names: ["Enhanced_Payroll"],
names: ["Enhanced_Payroll", "ADPPayroll"],
splitKey: bodyshop.imexshopid
});
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const Templates = TemplateList("report_center");
const ReportsList =
Enhanced_Payroll.treatment === "on"
? Object.keys(Templates)
.map((key) => {
return Templates[key];
})
.filter((temp) => temp.enhanced_payroll === undefined || temp.enhanced_payroll === true)
: Object.keys(Templates)
.map((key) => {
return Templates[key];
})
.filter((temp) => temp.enhanced_payroll === undefined || temp.enhanced_payroll === false);
const ReportsList = Object.keys(Templates)
.map((key) => Templates[key])
.filter((temp) => {
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
const adpPayrollOn = ADPPayroll.treatment === "on";
if (enhancedPayrollOn && adpPayrollOn) {
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
}
if (enhancedPayrollOn) {
return temp.enhanced_payroll !== false && temp.adp_payroll !== true;
}
if (adpPayrollOn) {
return temp.adp_payroll !== false && temp.enhanced_payroll !== true;
}
return temp.enhanced_payroll !== true && temp.adp_payroll !== true;
});
const { open } = reportCenterModal;
@@ -104,7 +110,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
to: values.to,
subject: Templates[values.key]?.subject
},
values.sendbyexcel === "excel" ? "x" : values.sendby === "email" ? "e" : "p",
values.sendbytext === "text" ? values.sendbytext : values.sendbyexcel === "excel" ? "x" : values.sendby === "email" ? "e" : "p",
id
);
setLoading(false);
@@ -291,7 +297,15 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
</Radio.Group>
</Form.Item>
);
if (reporttype !== "excel")
if (reporttype === "text")
return (
<Form.Item label={t("general.labels.sendby")} name="sendbytext" initialValue="text">
<Radio.Group>
<Radio value="text">{t("general.labels.text")}</Radio>
</Radio.Group>
</Form.Item>
);
if (reporttype !== "excel" || reporttype !== "text")
return (
<Form.Item label={t("general.labels.sendby")} name="sendby" initialValue="print">
<Radio.Group>

View File

@@ -1,50 +1,36 @@
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 mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
scheduleLoad: selectScheduleLoad
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function ScheduleAtsSummary({ scheduleLoad, appointments }) {
const ScheduleAtsSummary = React.memo(function ScheduleAtsSummary({ appointments }) {
const { t } = useTranslation();
const atsSummary = useMemo(() => {
let atsSummary = {};
if (!appointments || appointments.length === 0) {
return {};
}
const summary = {};
appointments
.filter((a) => a.isintake)
.filter((a) => a.isintake && a.job?.alt_transport)
.forEach((a) => {
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;
}
const key = a.job.alt_transport;
summary[key] = (summary[key] || 0) + 1;
});
return atsSummary;
return summary;
}, [appointments]);
if (Object.keys(atsSummary).length > 0)
if (Object.keys(atsSummary).length > 0) {
return (
<Space wrap>
{t("schedule.labels.atssummary")}
{Object.keys(atsSummary).map((key) => (
<span key={key}>{`${key}: ${atsSummary[key]}`}</span>
{Object.entries(atsSummary).map(([key, value]) => (
<span key={key}>{`${key}: ${value}`}</span>
))}
</Space>
);
}
return null;
}
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleAtsSummary);
export default ScheduleAtsSummary;

View File

@@ -1,7 +1,7 @@
import { useMutation } from "@apollo/client";
import { Dropdown, notification } from "antd";
import dayjs from "../../utils/day";
import React from "react";
import React, { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -13,57 +13,61 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
export function ScheduleBlockDay({ date, children, refetch, bodyshop, alreadyBlocked }) {
const ScheduleBlockDay = React.memo(function ScheduleBlockDay({ date, children, refetch, bodyshop, alreadyBlocked }) {
const { t } = useTranslation();
const [insertBlock] = useMutation(INSERT_APPOINTMENT_BLOCK);
const handleMenu = async (e) => {
e.domEvent.stopPropagation();
const handleMenu = useCallback(
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] }
});
if (!!result.errors) {
notification["error"]({
message: t("appointments.errors.blocking", {
message: JSON.stringify(result.errors)
})
const result = await insertBlock({
variables: { app: [blockAppt] }
});
}
if (!!refetch) refetch();
}
};
if (result.errors) {
notification.error({
message: t("appointments.errors.blocking", {
message: JSON.stringify(result.errors)
})
});
}
const menu = {
items: [
{
key: "block",
label: t("appointments.actions.block")
if (refetch) refetch();
}
],
onClick: handleMenu
};
},
[t, bodyshop.id, date, insertBlock, refetch]
);
const menu = useMemo(
() => ({
items: [
{
key: "block",
label: t("appointments.actions.block")
}
],
onClick: handleMenu
}),
[t, handleMenu]
);
return (
<Dropdown menu={menu} disabled={alreadyBlocked} trigger={["contextMenu"]}>
{children}
</Dropdown>
);
}
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleBlockDay);
export default connect(mapStateToProps)(ScheduleBlockDay);

View File

@@ -10,55 +10,48 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function ScheduleCalendarHeaderGraph({ bodyshop, loadData }) {
const mapDispatchToProps = () => ({});
const ScheduleCalendarHeaderGraph = React.memo(function ScheduleCalendarHeaderGraph({ bodyshop, loadData }) {
const { ssbuckets } = bodyshop;
const { t } = useTranslation();
const data = useMemo(() => {
return (
(loadData &&
loadData.expectedLoad &&
Object.keys(loadData.expectedLoad).map((key) => {
const metadataBucket = ssbuckets.filter((b) => b.id === key)[0];
return {
bucket: loadData.expectedLoad[key].label,
current: loadData.expectedLoad[key].count,
target: metadataBucket && metadataBucket.target
};
})) ||
[]
);
const data = useMemo(() => {
if (!loadData || !loadData.expectedLoad || !ssbuckets) return [];
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
};
});
}, [loadData, ssbuckets]);
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>
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]
);
return (
@@ -66,6 +59,6 @@ export function ScheduleCalendarHeaderGraph({ bodyshop, loadData }) {
<RadarChartOutlined />
</Popover>
);
}
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarHeaderGraph);

View File

@@ -1,9 +1,8 @@
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";
@@ -24,115 +23,114 @@ const mapStateToProps = createStructuredSelector({
calculating: selectScheduleLoadCalculating
});
const mapDispatchToProps = (dispatch) => ({});
const mapDispatchToProps = () => ({});
export function ScheduleCalendarHeaderComponent({
export const ScheduleCalendarHeaderComponent = React.memo(function ScheduleCalendarHeaderComponent({
bodyshop,
label,
refetch,
date,
load,
calculating,
events,
...otherProps
events
}) {
const dayjsDate = useMemo(() => dayjs(date), [date]);
const { t } = useTranslation();
const ATSToday = useMemo(() => {
if (!events) return [];
return _.groupBy(
events.filter((e) => !e.vacation && e.isintake && dayjs(date).isSame(dayjs(e.start), "day")),
"job.alt_transport"
);
}, [events, date]);
const filteredEvents = events.filter((e) => !e.vacation && e.isintake && dayjsDate.isSame(dayjs(e.start), "day"));
return _.groupBy(filteredEvents, "job.alt_transport");
}, [events, dayjsDate]);
const isDayBlocked = useMemo(() => {
if (!events) return [];
return events && events.filter((e) => dayjs(date).isSame(dayjs(e.start), "day") && e.block);
}, [events, date]);
return events.filter((e) => dayjsDate.isSame(dayjs(e.start), "day") && e.block);
}, [events, dayjsDate]);
const { t } = useTranslation();
const loadData = load[date.toISOString().substr(0, 10)];
const dateString = dayjsDate.format("YYYY-MM-DD");
const loadData = load[dateString];
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>
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>
</tr>
))
) : (
<tr>
<td style={{ padding: "2.5px" }}>
{t("appointments.labels.nocompletingjobs")}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</tbody>
</table>
</div>
),
[loadData, t]
);
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>
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>
</tr>
))
) : (
<tr>
<td style={{ padding: "2.5px" }}>
{t("appointments.labels.noarrivingjobs")}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</tbody>
</table>
</div>
),
[loadData, t]
);
const LoadComponent = loadData ? (
<div>
const LoadComponent = useMemo(() => {
if (!loadData) return null;
return (
<div>
<Space align="center">
<Popover
placement={"bottom"}
@@ -141,12 +139,8 @@ export function ScheduleCalendarHeaderComponent({
title={t("appointments.labels.arrivingjobs")}
>
<Icon component={MdFileDownload} style={{ color: "green" }} />
{(loadData.allHoursInBody || 0) &&
loadData.allHoursInBody.toFixed(1)}
/
{(loadData.allHoursInRefinish || 0) &&
loadData.allHoursInRefinish.toFixed(1)}
/{(loadData.allHoursIn || 0) && loadData.allHoursIn.toFixed(1)}
{(loadData.allHoursInBody || 0).toFixed(1)}/{(loadData.allHoursInRefinish || 0).toFixed(1)}/
{(loadData.allHoursIn || 0).toFixed(1)}
</Popover>
<Popover
placement={"bottom"}
@@ -155,57 +149,31 @@ export function ScheduleCalendarHeaderComponent({
title={t("appointments.labels.completingjobs")}
>
<Icon component={MdFileUpload} style={{ color: "red" }} />
{(loadData.allHoursOut || 0) && loadData.allHoursOut.toFixed(1)}
{(loadData.allHoursOut || 0).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>
<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>
</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;
}
);
}, [loadData, jobsInPopup, jobsOutPopup, t, ATSToday]);
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(date) ? "" : "tomato" }}>
<div style={{ color: isShopOpen() ? "" : "tomato" }}>
{label}
{InstanceRenderMgr({
imex: calculating ? <LoadingSkeleton /> : LoadComponent,
@@ -216,6 +184,6 @@ export function ScheduleCalendarHeaderComponent({
</ScheduleBlockDay>
</div>
);
}
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarHeaderComponent);

View File

@@ -1,29 +1,28 @@
import dayjs from "../../utils/day";
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");
}
// 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")
})
};
return { start, end };
export function getRange(dateParam = new Date(), viewParam = "week") {
const date = dayjs(dateParam);
const view = viewRanges[viewParam] ? viewParam : "week";
return viewRanges[view](date);
}

View File

@@ -1,6 +1,6 @@
import dayjs from "../../utils/day";
import queryString from "query-string";
import React from "react";
import React, { useCallback, useMemo } from "react";
import { Calendar, dayjsLocalizer } from "react-big-calendar";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -19,9 +19,10 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
problemJobs: selectProblemJobs
});
const localizer = dayjsLocalizer(dayjs);
export function ScheduleCalendarWrapperComponent({
export const ScheduleCalendarWrapperComponent = React.memo(function ScheduleCalendarWrapperComponent({
bodyshop,
problemJobs,
data,
@@ -31,23 +32,79 @@ export function ScheduleCalendarWrapperComponent({
date,
...otherProps
}) {
const search = queryString.parse(useLocation().search);
const history = useNavigate();
const location = useLocation();
const search = useMemo(() => queryString.parse(location.search), [location.search]);
const navigate = useNavigate();
const { t } = useTranslation();
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" : ""}`
};
};
const selectedDate = new Date(date || dayjs(search.date) || Date.now());
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
}
}
: {}),
className: `${event.arrived ? "imex-event-arrived" : ""} ${event.block ? "imex-event-block" : ""}`
};
},
[search.view, defaultView]
);
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]
);
return (
<>
@@ -109,32 +166,20 @@ export function ScheduleCalendarWrapperComponent({
events={data}
defaultView={search.view || defaultView || "week"}
date={selectedDate}
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) });
}}
onNavigate={onNavigate}
onRangeChange={onRangeChange}
onView={onView}
step={15}
// timeslots={1}
showMultiDayTimes
localizer={localizer}
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")}
min={minTime}
max={maxTime}
eventPropGetter={handleEventPropStyles}
components={{
event: (e) => Event({ bodyshop: bodyshop, event: e.event, refetch: refetch }),
header: (p) => <HeaderComponent {...p} events={data} refetch={refetch} />
}}
components={calendarComponents}
{...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 { t } from "i18next";
import React, { useMemo } from "react";
import React, { Profiler, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
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,19 +18,17 @@ import _ from "lodash";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarComponent);
export function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
const ScheduleCalendarComponent = React.memo(function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
const { t } = useTranslation();
const [filter, setFilter] = useLocalStorage("filter_events", {
intake: true,
manual: true,
employeevacation: true,
ins_co_nm: null
});
const [estimatorsFilter, setEstimatiorsFilter] = useLocalStorage("estimators", []);
const [estimatorsFilter, setEstimatorsFilter] = useLocalStorage("estimators", []);
const estimators = useMemo(() => {
return _.uniq([
@@ -48,7 +46,7 @@ export function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
d.__typename === "appointments"
? estimatorsFilter.length === 0
? true
: !!estimatorsFilter.find((e) => e === `${d.job?.est_ct_fn || ""} ${d.job?.est_ct_ln || ""}`.trim())
: estimatorsFilter.includes(`${d.job?.est_ct_fn || ""} ${d.job?.est_ct_ln || ""}`.trim())
: true;
return (
@@ -62,7 +60,85 @@ export function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
});
}, [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 />
@@ -76,65 +152,35 @@ export function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
mode="multiple"
placeholder={t("schedule.labels.estimators")}
allowClear
onClear={() => setEstimatiorsFilter([])}
value={[...estimatorsFilter]}
onChange={(e) => {
setEstimatiorsFilter(e);
}}
options={estimators.map((e) => ({
label: e,
value: e
}))}
onClear={handleEstimatorsFilterClear}
value={estimatorsFilter}
onChange={handleEstimatorsFilterChange}
options={estimatorsOptions}
/>
<Select
style={{ minWidth: "15rem" }}
mode="multiple"
placeholder={t("schedule.labels.ins_co_nm_filter")}
allowClear
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
}))}
onClear={handleInsCoNmFilterClear}
value={filter.ins_co_nm || []}
onChange={handleInsCoNmFilterChange}
options={insCoNmOptions}
/>
<Checkbox
checked={filter?.intake}
onChange={(e) => {
setFilter({ ...filter, intake: e.target.checked });
}}
>
<Checkbox checked={filter.intake} onChange={handleIntakeFilterChange}>
{t("schedule.labels.intake")}
</Checkbox>
<Checkbox
checked={filter?.manual}
onChange={(e) => {
setFilter({ ...filter, manual: e.target.checked });
}}
>
<Checkbox checked={filter.manual} onChange={handleManualFilterChange}>
{t("schedule.labels.manual")}
</Checkbox>
<Checkbox
checked={filter?.employeevacation}
onChange={(e) => {
setFilter({ ...filter, employeevacation: e.target.checked });
}}
>
<Checkbox checked={filter.employeevacation} onChange={handleEmployeeVacationFilterChange}>
{t("schedule.labels.employeevacation")}
</Checkbox>
<ScheduleVerifyIntegrity />
<Button
onClick={() => {
refetch();
}}
>
<Button onClick={handleRefetch}>
<SyncOutlined />
</Button>
<ScheduleProductionList />
<ScheduleManualEvent />
</Space>
}
@@ -147,5 +193,9 @@ export function ScheduleCalendarComponent({ data, refetch, bodyshop }) {
</Card>
</Col>
</Row>
// TODO Remove when done
// </Profiler>
);
}
});
export default connect(mapStateToProps)(ScheduleCalendarComponent);

View File

@@ -15,56 +15,65 @@ import dayjs from "../../utils/day";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
calculateScheduleLoad: (endDate) => dispatch(calculateScheduleLoad(endDate))
});
export function ScheduleCalendarContainer({ calculateScheduleLoad }) {
const search = queryString.parse(useLocation().search);
const ScheduleCalendarContainer = React.memo(function ScheduleCalendarContainer({ calculateScheduleLoad }) {
const location = useLocation();
const search = useMemo(() => queryString.parse(location.search), [location.search]);
const { date, view } = search;
const range = useMemo(() => getRange(date, view), [date, view]);
const { loading, error, data, refetch } = useQuery(QUERY_ALL_ACTIVE_APPOINTMENTS, {
variables: {
const queryVariables = useMemo(
() => ({
start: range.start.toDate(),
end: range.end.toDate(),
startd: range.start,
endd: range.end
},
skip: !!!range.start || !!!range.end,
}),
[range]
);
const { loading, error, data, refetch } = useQuery(QUERY_ALL_ACTIVE_APPOINTMENTS, {
variables: queryVariables,
skip: !range.start || !range.end,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
useEffect(() => {
if (data && range.end) calculateScheduleLoad(range.end);
}, [data, range, calculateScheduleLoad]);
if (data && range.end) {
calculateScheduleLoad(range.end);
}
}, [data, range.end, calculateScheduleLoad]);
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 {
const normalizedData = useMemo(() => {
if (!data) return [];
return [
...data.appointments.map((e) => ({
...e,
title: `${
(e.employee.first_name && e.employee.first_name.substr(0, 1)) || ""
} ${e.employee.last_name || ""} OUT`,
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`,
color: "red",
start: dayjs(e.start).startOf("day").toDate(),
end: dayjs(e.end).startOf("day").toDate(),
allDay: true,
vacation: true
};
})
];
}))
];
}, [data]);
return <ScheduleCalendarComponent refetch={refetch} data={data ? normalizedData : []} />;
}
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent message={error.message} type="error" />;
return <ScheduleCalendarComponent refetch={refetch} data={normalizedData} />;
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleCalendarContainer);

View File

@@ -2,9 +2,14 @@ import React from "react";
import { useTranslation } from "react-i18next";
import ScheduleCalendarWrapperComponent from "../schedule-calendar-wrapper/scheduler-calendar-wrapper.component";
export default function ScheduleDayViewComponent({ data, day }) {
const ScheduleDayViewComponent = React.memo(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>;
}
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;

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import ScheduleDayViewComponent from "./schedule-day-view.component";
import { useQuery } from "@apollo/client";
import { QUERY_APPOINTMENT_BY_DATE } from "../../graphql/appointments.queries";
@@ -6,45 +6,59 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import dayjs from "../../utils/day";
import { useTranslation } from "react-i18next";
export default function ScheduleDayViewContainer({ day }) {
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
const { loading, error, data } = useQuery(QUERY_APPOINTMENT_BY_DATE, {
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(),
variables: queryVariables,
skip: !dayjsDay.isValid(),
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const { t } = useTranslation();
// 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
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;
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
};
})
];
}
return <ScheduleDayViewComponent data={normalizedData} day={day} />;
});
return <ScheduleDayViewComponent data={data ? normalizedData : []} day={day} />;
}
export default ScheduleDayViewContainer;

View File

@@ -1,38 +1,43 @@
import React from "react";
import React, { useMemo } 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";
export default function ScheduleExistingAppointmentsList({ existingAppointments }) {
const ScheduleExistingAppointmentsList = React.memo(function ScheduleExistingAppointmentsList({
existingAppointments
}) {
const { t } = useTranslation();
if (existingAppointments.loading) return <LoadingSpinner />;
if (existingAppointments.error) return <AlertComponent message={existingAppointments.error.message} type="error" />;
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" />;
return (
<div>
{t("appointments.labels.priorappointments")}
<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>
</>
)
}))
: []
}
/>
<Timeline items={items} />
</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, { useState } from "react";
import React, { useCallback, useMemo, 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))
});
export function ScheduleJobModalComponent({
const ScheduleJobModalComponent = React.memo(function ScheduleJobModalComponent({
bodyshop,
form,
existingAppointments,
@@ -36,7 +36,7 @@ export function ScheduleJobModalComponent({
const [loading, setLoading] = useState(false);
const [smartOptions, setSmartOptions] = useState([]);
const handleSmartScheduling = async () => {
const handleSmartScheduling = useCallback(async () => {
setLoading(true);
try {
const response = await axios.post("/scheduling/job", {
@@ -48,21 +48,66 @@ export function ScheduleJobModalComponent({
} finally {
setLoading(false);
}
};
}, [jobId]);
const handleDateBlur = () => {
const handleDateBlur = useCallback(() => {
const values = form.getFieldsValue();
if (lbrHrsData) {
const totalHours =
lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs + lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs;
(lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs || 0) +
(lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs || 0);
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]}>
@@ -80,7 +125,6 @@ export function ScheduleJobModalComponent({
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -92,7 +136,6 @@ export function ScheduleJobModalComponent({
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -107,25 +150,7 @@ export function ScheduleJobModalComponent({
<Button onClick={handleSmartScheduling} loading={loading}>
{t("appointments.actions.calculate")}
</Button>
{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>
))}
{smartOptionsButtons}
</Space>
</>
),
@@ -144,20 +169,10 @@ export function ScheduleJobModalComponent({
</LayoutFormRow>
<LayoutFormRow grow>
<Form.Item name="color" label={t("appointments.fields.color")}>
<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>
<Select allowClear>{colorOptions}</Select>
</Form.Item>
<Form.Item name={"alt_transport"} label={t("jobs.fields.alt_transport")}>
<Select allowClear>
{bodyshop.appt_alt_transport &&
bodyshop.appt_alt_transport.map((alt) => <Select.Option key={alt}>{alt}</Select.Option>)}
</Select>
<Select allowClear>{altTransportOptions}</Select>
</Form.Item>
<Form.Item name={"note"} label={t("appointments.fields.note")}>
<Input />
@@ -183,6 +198,6 @@ export 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, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -27,13 +27,21 @@ 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
})
)
});
export function ScheduleJobModalContainer({
const ScheduleJobModalContainer = React.memo(function ScheduleJobModalContainer({
scheduleModal,
bodyshop,
toggleModalVisible,
@@ -43,168 +51,186 @@ export 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 && job.id },
skip: !job || !job.id,
variables: { id: job?.id },
skip: !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);
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
skip: !open || !jobId
});
useEffect(() => {
if (
existingAppointments.data &&
existingAppointments.data.appointments.length > 0 &&
!existingAppointments.data.appointments[0].canceled
) {
form.setFieldsValue({
color: existingAppointments.data.appointments[0].color,
if (job) form.resetFields();
}, [job, form]);
note: existingAppointments.data.appointments[0].note
useEffect(() => {
const appointments = existingAppointments.data?.appointments;
if (appointments?.length && !appointments[0].canceled) {
form.setFieldsValue({
color: appointments[0].color,
note: appointments[0].note
});
}
}, [existingAppointments.data, form]);
const handleFinish = async (values) => {
logImEXEvent("schedule_new_appointment");
const handleFinish = useCallback(
async (values) => {
logImEXEvent("schedule_new_appointment");
setLoading(true);
setLoading(true);
if (!!previousEvent) {
const cancelAppt = await cancelAppointment({
variables: { appid: previousEvent }
});
if (!!cancelAppt.errors) {
notification["error"]({
message: t("appointments.errors.canceling", {
message: JSON.stringify(cancelAppt.errors)
})
if (previousEvent) {
const cancelAppt = await cancelAppointment({
variables: { appid: previousEvent }
});
return;
}
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 }
if (cancelAppt.errors) {
notification.error({
message: t("appointments.errors.canceling", {
message: JSON.stringify(cancelAppt.errors)
})
});
})
);
}
return;
}
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
notification.success({
message: t("appointments.successes.canceled")
});
}
});
if (!appt.errors) {
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.appointmentinsert(DateTimeFormat(values.start)),
type: "appointmentinsert"
});
}
const existingApps = existingAppointments.data?.appointments || [];
if (existingApps.length > 0) {
await Promise.all(
existingApps.map((app) =>
cancelAppointment({
variables: { appid: app.id }
})
)
);
}
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({
const appt = await insertAppointment({
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
}
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 (!!jobUpdate.errors) {
notification["error"]({
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(jobUpdate.errors)
message: JSON.stringify(appt.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: {
id: appt.data.insert_appointments.returning[0].id
}
}
notification.success({
message: t("appointments.successes.created")
});
}
if (refetch) refetch();
};
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 (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
]
);
return (
<Modal
open={open}
onCancel={() => toggleModalVisible()}
onCancel={toggleModalVisible}
onOk={() => form.submit()}
width={"90%"}
width="90%"
maskClosable={false}
destroyOnClose
okButtonProps={{
@@ -217,10 +243,9 @@ export function ScheduleJobModalContainer({
layout="vertical"
onFinish={handleFinish}
initialValues={{
notifyCustomer: !!(job && job.ownr_ea),
email: (job && job.ownr_ea) || "",
notifyCustomer: !!job?.ownr_ea,
email: job?.ownr_ea || "",
start: null,
// smartDates: [],
scheduled_completion: null,
color: context.color,
alt_transport: context.alt_transport,
@@ -236,6 +261,6 @@ export 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, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -13,142 +13,143 @@ 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);
export function ScheduleManualEvent({ bodyshop, event }) {
const ScheduleManualEvent = React.memo(function ScheduleManualEvent({ bodyshop, event }) {
const { t } = useTranslation();
const [insertAppointment] = useMutation(INSERT_MANUAL_APPT);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [insertAppointment] = useMutation(INSERT_MANUAL_APPT, {
refetchQueries: ["QUERY_ALL_ACTIVE_APPOINTMENTS"]
});
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT, {
refetchQueries: ["QUERY_ALL_ACTIVE_APPOINTMENTS"]
});
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const [visibility, setVisibility] = useState(false);
// const [callQuery, { loading: entryLoading, data: entryData }] = useLazyQuery(
// QUERY_SCOREBOARD_ENTRY
// );
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);
}, []);
useEffect(() => {
if (visibility && event) {
form.setFieldsValue(event);
} else if (!visibility) {
form.resetFields();
}
}, [visibility, form, event]);
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 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 overlay = (
<Card>
<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();
}
<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"));
} else {
return Promise.resolve();
}
} else {
return Promise.resolve();
}
})
]}
>
<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>
}
})
]}
>
<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>
</Card>
);
const handleClick = (e) => {
setVisibility(true);
};
return (
<Popover content={overlay} open={visibility}>
<Button loading={loading} onClick={handleClick}>
<Button 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 from "react";
import React, { useCallback } 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";
export default function ScheduleProductionList() {
const ScheduleProductionList = React.memo(function ScheduleProductionList() {
const { t } = useTranslation();
const [callQuery, { loading, error, data }] = useLazyQuery(QUERY_JOBS_IN_PRODUCTION);
const content = () => {
const content = useCallback(() => {
return (
<Card>
<div onClick={(e) => e.stopPropagation()} className="jobs-in-production-table">
{loading ? <LoadingSkeleton /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? (
{loading && <LoadingSkeleton />}
{error && <AlertComponent message={error.message} type="error" />}
{data && data.jobs && (
<table>
<tbody>
{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}
{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>
))}
</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, { useState } from "react";
import React, { useCallback, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_SCHEDULE_LOAD_DATA } from "../../graphql/appointments.queries";
@@ -10,49 +10,46 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleVerifyIntegrity);
export function ScheduleVerifyIntegrity({ currentUser }) {
const ScheduleVerifyIntegrity = React.memo(function ScheduleVerifyIntegrity({ currentUser }) {
const [loading, setLoading] = useState(false);
const client = useApolloClient();
const handleVerify = async () => {
const handleVerify = useCallback(async () => {
setLoading(true);
const {
data: { arrJobs, compJobs, prodJobs }
} = await client.query({
query: QUERY_SCHEDULE_LOAD_DATA,
variables: { start: dayjs(), end: dayjs().add(180, "day") }
});
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 leaving jobs are either in the arriving list, or in production.
const issues = [];
// 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);
});
compJobs.forEach((j) => {
const inProdJobs = prodJobs.find((p) => p.id === j.id);
const inArrJobs = arrJobs.find((p) => p.id === j.id);
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]);
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
);
// TODO: A Global helper with developer emails
if (currentUser.email !== "patrick@imex.prod") {
return null;
}
setLoading(false);
};
return (
<Button loading={loading} onClick={handleVerify}>
Developer Use Only - Verify Schedule Integrity
</Button>
);
});
if (currentUser.email === "patrick@imex.prod")
return (
<Button loading={loading} onClick={handleVerify}>
Developer Use Only - Verify Schedule Integrity
</Button>
);
else return null;
}
export default connect(mapStateToProps)(ScheduleVerifyIntegrity);

View File

@@ -7,13 +7,13 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DatePickerRanges from "../../utils/DatePickerRanges";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import FeatureWrapper, { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import FeatureWrapper, { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
// TODO: Client Update, this might break
const timeZonesList = Intl.supportedValuesOf("timeZone");
const mapStateToProps = createStructuredSelector({
@@ -28,10 +28,10 @@ export function ShopInfoGeneral({ form, bodyshop }) {
const { t } = useTranslation();
const {
treatments: { ClosingPeriod }
treatments: { ClosingPeriod, ADPPayroll }
} = useSplitTreatments({
attributes: {},
names: ["ClosingPeriod"],
names: ["ClosingPeriod", "ADPPayroll"],
splitKey: bodyshop && bodyshop.imexshopid
});
@@ -98,7 +98,6 @@ export function ShopInfoGeneral({ form, bodyshop }) {
<Form.Item label={t("bodyshop.fields.email")} name="email">
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.phone")}
name="phone"
@@ -356,14 +355,22 @@ export function ShopInfoGeneral({ form, bodyshop }) {
<Select mode="tags" />
</Form.Item>
{ClosingPeriod.treatment === "on" && (
<>
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
</>
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "companyCode"]} label={t("bodyshop.fields.companycode")}>
<Input />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input />
</Form.Item>
)}
</LayoutFormRow>
</FeatureWrapper>

View File

@@ -5,7 +5,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
const { Option } = Select;
//To be used as a form element only.
// To be used as a form element only.
const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, preferredMake, showPhone }, ref) => {
const [option, setOption] = useState(value);
@@ -33,9 +33,25 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
if (!value || !options) return label;
const discount = options?.find((o) => o.id === value)?.discount;
return (
<div className="imex-flex-row" style={{ width: "100%" }}>
<div style={{ flex: 1 }}>{label}</div>
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "nowrap",
width: "100%"
}}
>
<div
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{label}
</div>
{discount && discount !== 0 ? <Tag color="green">{`${discount * 100}%`}</Tag> : null}
</div>
);
@@ -45,36 +61,67 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
optionFilterProp="name"
onSelect={onSelect}
disabled={disabled || false}
optionLabelProp={"name"}
optionLabelProp="name"
>
{favorites
? favorites.map((o) => (
<Option key={`favorite-${o.id}`} value={o.id} name={o.name} discount={o.discount}>
<div className="imex-flex-row">
<div style={{ flex: 1 }}>{o.name}</div>
<Space style={{ marginLeft: "1rem" }}>
<HeartOutlined style={{ color: "red" }} />
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>
{favorites &&
favorites.map((o) => (
<Option key={`favorite-${o.id}`} value={o.id} name={o.name} discount={o.discount}>
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "nowrap",
width: "100%"
}}
>
<div
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{o.name}
</div>
</Option>
))
: null}
{options
? options.map((o) => (
<Option key={o.id} value={o.id} name={o.name} discount={o.discount}>
<div className="imex-flex-row" style={{ width: "100%" }}>
<div style={{ flex: 1 }}>{o.name}</div>
<Space style={{ marginLeft: "1rem" }}>
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>
<Space style={{ marginLeft: "1rem" }}>
<HeartOutlined style={{ color: "red" }} />
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>
</div>
</Option>
))}
{options &&
options.map((o) => (
<Option key={o.id} value={o.id} name={o.name} discount={o.discount}>
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "nowrap",
width: "100%"
}}
>
<div
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{o.name}
</div>
</Option>
))
: null}
<Space style={{ marginLeft: "1rem" }}>
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>
</div>
</Option>
))}
</Select>
);
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2158,6 +2158,32 @@ export const TemplateList = (type, context) => {
field: i18n.t("tasks.fields.created_at")
},
group: "jobs"
},
adp_payroll_flat: {
title: i18n.t("reportcenter.templates.adp_payroll_flat"),
subject: i18n.t("reportcenter.templates.adp_payroll_flat"),
key: "adp_payroll_flat",
reporttype: "text",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.timetickets"),
field: i18n.t("timetickets.fields.committed_at")
},
group: "payroll",
adp_payroll: true
},
adp_payroll_straight: {
title: i18n.t("reportcenter.templates.adp_payroll_straight"),
subject: i18n.t("reportcenter.templates.adp_payroll_straight"),
key: "adp_payroll_straight",
reporttype: "text",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.timetickets"),
field: i18n.t("timetickets.fields.date")
},
group: "payroll",
adp_payroll: true
}
}
: {}),

View File

@@ -94,7 +94,10 @@ exports.default = async (req, res) => {
ret.push({
billid: bill.id,
success: false,
errorMessage: (error && error.authResponse && error.authResponse.body) || (error && error.message)
errorMessage:
(error && error.authResponse && error.authResponse.body) ||
error.response?.data?.Fault?.Error.map((e) => e.Detail).join(", ") ||
(error && error.message)
});
//Add the export log error.
@@ -209,14 +212,14 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
AccountBasedExpenseLineDetail: {
...(bill.job.class ? { ClassRef: { value: classes[bill.job.class] } } : {}),
AccountRef: {
value: accounts[bodyshop.md_responsibility_centers.taxes.federal.accountdesc]
value: accounts[bodyshop.md_responsibility_centers.taxes.federal_itc.accountdesc]
}
},
Amount: Dinero({
amount: Math.round(
bill.billlines.reduce((acc, val) => {
return acc + val.actual_cost * val.quantity;
return acc + val.applicable_taxes?.federal ? (val.actual_cost * val.quantity ?? 0) : 0;
}, 0) * 100
)
})
@@ -274,6 +277,8 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
} catch (error) {
logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, {
error: error, //(error && error.authResponse && error.authResponse.body) || (error && error.message),
validationError: JSON.stringify(error?.response?.data),
accountmeta: JSON.stringify({ accounts, taxCodes, classes }),
method: "InsertBill"
});
throw error;

View File

@@ -179,7 +179,11 @@ exports.default = async (req, res) => {
ret.push({
jobid: job.id,
success: false,
errorMessage: (error && error.authResponse && error.authResponse.body) || (error && error.message)
errorMessage:
error?.authResponse?.body ||
error?.response?.data?.Fault?.Error.map((e) => e.Detail).join(", ") ||
error?.response?.data ||
error?.message
});
console.log(error);
logger.log("qbo-receivable-create-error", "ERROR", req.user.email, {
@@ -254,7 +258,6 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
throw new Error(
`Insurance Company '${job.ins_co_nm}' not found in shop configuration. Please make sure it exists or change the insurance company name on the job to one that exists.`
);
return;
}
const Customer = {
DisplayName: job.ins_co_nm.trim(),
@@ -575,7 +578,9 @@ async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, paren
} catch (error) {
logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, {
error,
method: "InsertOwner"
method: "InsertInvoice",
validationError: JSON.stringify(error?.response?.data),
accountmeta: JSON.stringify({ items, taxCodes, classes })
});
throw error;
}