Merge branch 'master-AIO' into feature/IO-2776-cdk-fortellis

This commit is contained in:
Patrick Fic
2025-04-14 12:51:43 -07:00
170 changed files with 20783 additions and 19308 deletions

View File

@@ -1,53 +1,66 @@
import { ApolloProvider } from "@apollo/client";
import { SplitFactoryProvider, SplitSdk } from "@splitsoftware/splitio-react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import client from "../utils/GraphQLClient";
import App from "./App";
import * as Sentry from "@sentry/react";
import themeProvider from "./themeProvider";
import { Userpilot } from "userpilot";
// Initialize Userpilot
if (import.meta.env.DEV) {
Userpilot.initialize("NX-69145f08");
}
import { CookiesProvider } from "react-cookie";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
key: "anon" // Default key, overridden dynamically by SplitClientProvider
}
};
export const factory = SplitSdk(config);
// Custom provider to manage the Split client key based on imexshopid from Redux
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid); // Access imexshopid from Redux store
const splitClient = useSplitClient({ key: imexshopid || "anon" }); // Use imexshopid or fallback to "anon"
useEffect(() => {
if (splitClient && imexshopid) {
// Log readiness for debugging; no need for ready() since isReady is available
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const { t } = useTranslation();
return (
<ApolloProvider client={client}>
<ConfigProvider
//componentSize="small"
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={themeProvider}
form={{
validateMessages: {
// eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar />
<SplitFactoryProvider factory={factory}>
<App />
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={themeProvider}
form={{
validateMessages: {
// eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}

View File

@@ -21,8 +21,8 @@ import "./App.styles.scss";
import Eula from "../components/eula/eula.component";
import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
@@ -142,11 +142,10 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
>
<ProductFruitsWrapper
currentUser={currentUser}
workspaceCode={InstanceRenderMgr({
imex: null,
rome: "9BkbEseqNqxw8jUH"
})}
bodyshop={bodyshop}
workspaceCode={bodyshop?.tours_enabled ? "9BkbEseqNqxw8jUH" : ""}
/>
<NotificationProvider>
<Routes>
<Route

View File

@@ -1,8 +1,16 @@
import React from "react";
import { ProductFruits } from "react-product-fruits";
import PropTypes from "prop-types";
import { ProductFruits } from "react-product-fruits";
import dayjs from "dayjs";
const ProductFruitsWrapper = React.memo(({ currentUser, bodyshop, workspaceCode }) => {
const featureProps = bodyshop?.features
? Object.entries(bodyshop.features).reduce((acc, [key, value]) => {
acc[key] = value === true || (typeof value === "string" && dayjs(value).isAfter(dayjs()));
return acc;
}, {})
: {};
const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
return (
workspaceCode &&
currentUser?.authorized === true &&
@@ -14,7 +22,8 @@ const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
language="en"
user={{
email: currentUser.email,
username: currentUser.email
username: currentUser.email,
props: featureProps
}}
/>
)
@@ -28,5 +37,6 @@ ProductFruitsWrapper.propTypes = {
authorized: PropTypes.bool,
email: PropTypes.string
}),
workspaceCode: PropTypes.string
workspaceCode: PropTypes.string,
bodyshop: PropTypes.object
};

View File

@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alert component should render Alert component 1`] = `ShallowWrapper {}`;

View File

@@ -1,19 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import Alert from "./alert.component";
describe("Alert component", () => {
let wrapper;
beforeEach(() => {
const mockProps = {
type: "error",
message: "Test error message."
};
wrapper = shallow(<Alert {...mockProps} />);
});
it("should render Alert component", () => {
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import AlertComponent from "./alert.component";
describe("AlertComponent", () => {
it("renders with default props", () => {
render(<AlertComponent message="Default Alert" />);
expect(screen.getByText("Default Alert")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveClass("ant-alert");
});
it("applies type prop correctly", () => {
render(<AlertComponent message="Success Alert" type="success" />);
const alert = screen.getByRole("alert");
expect(screen.getByText("Success Alert")).toBeInTheDocument();
expect(alert).toHaveClass("ant-alert-success");
});
it("displays description when provided", () => {
render(<AlertComponent message="Error Alert" description="Something went wrong" type="error" />);
expect(screen.getByText("Error Alert")).toBeInTheDocument();
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveClass("ant-alert-error");
});
it("is closable and shows icon when props are set", () => {
render(<AlertComponent message="Warning Alert" type="warning" showIcon closable />);
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); // Close button
});
});

View File

@@ -1,5 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AllocationsAssignmentComponent component should create an allocation on save 1`] = `ReactWrapper {}`;
exports[`AllocationsAssignmentComponent component should render AllocationsAssignmentComponent component 1`] = `ReactWrapper {}`;

View File

@@ -1,35 +0,0 @@
import { mount } from "enzyme";
import React from "react";
import { MockBodyshop } from "../../utils/TestingHelpers";
import { AllocationsAssignmentComponent } from "./allocations-assignment.component";
describe("AllocationsAssignmentComponent component", () => {
let wrapper;
beforeEach(() => {
const mockProps = {
bodyshop: MockBodyshop,
handleAssignment: jest.fn(),
assignment: {},
setAssignment: jest.fn(),
visibilityState: [false, jest.fn()],
maxHours: 4
};
wrapper = mount(<AllocationsAssignmentComponent {...mockProps} />);
});
it("should render AllocationsAssignmentComponent component", () => {
expect(wrapper).toMatchSnapshot();
});
it("should render a list of employees", () => {
const empList = wrapper.find("#employeeSelector");
expect(empList.children()).to.have.lengthOf(2);
});
it("should create an allocation on save", () => {
wrapper.find("Button").simulate("click");
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -3,11 +3,11 @@ import { getToken } from "@firebase/messaging";
import axios from "axios";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();

View File

@@ -3,10 +3,10 @@ import { Button } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -4,10 +4,10 @@ import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -3,11 +3,11 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,

View File

@@ -4,11 +4,11 @@ import { Input, Spin, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -1,7 +1,8 @@
import { PictureFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Popover } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -9,6 +10,7 @@ import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
@@ -23,6 +25,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const {
treatments: { Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Imgproxy"],
splitKey: bodyshop && bodyshop.imexshopid
});
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, {
fetchPolicy: "network-only",
@@ -42,6 +51,10 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
setSelectedMedia([]);
}, [setSelectedMedia, conversation]);
//Knowingly taking on the technical debt of poor implementation below. Done this way to avoid an edge case where no component may be displayed.
//Cloudinary will be removed once the migration is completed.
//If Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods.
const content = (
<div>
{loading && <LoadingSpinner />}
@@ -49,17 +62,37 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
) : null}
{!bodyshop.uselocalmediaserver && data && (
<JobDocumentsGalleryExternal
data={data ? data.documents : []}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
/>
{Imgproxy.treatment === "on" ? (
<>
{!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
/>
)}
</>
) : (
<>
{!bodyshop.uselocalmediaserver && data && (
<JobDocumentsGalleryExternal
data={data ? data.documents : []}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
/>
)}
</>
)}
</div>
);

View File

@@ -5,7 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -7,7 +7,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -12,9 +12,9 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import "./chat-popup.styles.scss";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,

View File

@@ -8,10 +8,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -0,0 +1,132 @@
import i18next from "i18next";
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx";
import {
DashboardTotalProductionHours,
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
import DashboardProjectedMonthlySales, {
DashboardProjectedMonthlySalesGql
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component.jsx";
import DashboardMonthlyRevenueGraph, {
DashboardMonthlyRevenueGraphGql
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component.jsx";
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component.jsx";
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component.jsx";
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component.jsx";
import DashboardMonthlyEmployeeEfficiency, {
DashboardMonthlyEmployeeEfficiencyGql
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component.jsx";
import DashboardScheduledInToday, {
DashboardScheduledInTodayGql
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx";
import DashboardScheduledOutToday, {
DashboardScheduledOutTodayGql
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx";
import JobLifecycleDashboardComponent, {
JobLifecycleDashboardGQL
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx";
const componentList = {
ProductionDollars: {
label: i18next.t("dashboard.titles.productiondollars"),
component: DashboardTotalProductionDollars,
gqlFragment: null,
w: 1,
h: 1,
minW: 2,
minH: 1
},
ProductionHours: {
label: i18next.t("dashboard.titles.productionhours"),
component: DashboardTotalProductionHours,
gqlFragment: DashboardTotalProductionHoursGql,
w: 3,
h: 1,
minW: 3,
minH: 1
},
ProjectedMonthlySales: {
label: i18next.t("dashboard.titles.projectedmonthlysales"),
component: DashboardProjectedMonthlySales,
gqlFragment: DashboardProjectedMonthlySalesGql,
w: 2,
h: 1,
minW: 2,
minH: 1
},
MonthlyRevenueGraph: {
label: i18next.t("dashboard.titles.monthlyrevenuegraph"),
component: DashboardMonthlyRevenueGraph,
gqlFragment: DashboardMonthlyRevenueGraphGql,
w: 4,
h: 2,
minW: 4,
minH: 2
},
MonthlyJobCosting: {
label: i18next.t("dashboard.titles.monthlyjobcosting"),
component: DashboardMonthlyJobCosting,
gqlFragment: null,
minW: 6,
minH: 3,
w: 6,
h: 3
},
MonthlyPartsSales: {
label: i18next.t("dashboard.titles.monthlypartssales"),
component: DashboardMonthlyPartsSales,
gqlFragment: null,
minW: 2,
minH: 2,
w: 2,
h: 2
},
MonthlyLaborSales: {
label: i18next.t("dashboard.titles.monthlylaborsales"),
component: DashboardMonthlyLaborSales,
gqlFragment: null,
minW: 2,
minH: 2,
w: 2,
h: 2
},
// Typo in Efficency should be Efficiency, but changing it would reset users dashboard settings
MonthlyEmployeeEfficency: {
label: i18next.t("dashboard.titles.monthlyemployeeefficiency"),
component: DashboardMonthlyEmployeeEfficiency,
gqlFragment: DashboardMonthlyEmployeeEfficiencyGql,
minW: 2,
minH: 2,
w: 2,
h: 2
},
ScheduleInToday: {
label: i18next.t("dashboard.titles.scheduledintoday"),
component: DashboardScheduledInToday,
gqlFragment: DashboardScheduledInTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
ScheduleOutToday: {
label: i18next.t("dashboard.titles.scheduledouttoday"),
component: DashboardScheduledOutToday,
gqlFragment: DashboardScheduledOutTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
JobLifecycle: {
label: i18next.t("dashboard.titles.joblifecycle"),
component: JobLifecycleDashboardComponent,
gqlFragment: JobLifecycleDashboardGQL,
minW: 6,
minH: 3,
w: 6,
h: 3
}
};
export default componentList;

View File

@@ -0,0 +1,85 @@
import { gql } from "@apollo/client";
import dayjs from "../../utils/day.js";
import componentList from "./componentList.js";
const createDashboardQuery = (state) => {
const componentBasedAdditions =
state &&
Array.isArray(state.layout) &&
state.layout.map((item, index) => componentList[item.i].gqlFragment || "").join("");
return gql`
query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""}
monthly_sales: jobs(where: {_and: [
{ voided: {_eq: false}},
{date_invoiced: {_gte: "${dayjs()
.startOf("month")
.startOf("day")
.toISOString()}"}}, {date_invoiced: {_lte: "${dayjs().endOf("month").endOf("day").toISOString()}"}}]}) {
id
ro_number
date_invoiced
job_totals
rate_la1
rate_la2
rate_la3
rate_la4
rate_laa
rate_lab
rate_lad
rate_lae
rate_laf
rate_lag
rate_lam
rate_lar
rate_las
rate_lau
rate_ma2s
rate_ma2t
rate_ma3s
rate_mabl
rate_macs
rate_mahw
rate_mapa
rate_mash
rate_matd
joblines(where: { removed: { _eq: false } }) {
id
mod_lbr_ty
mod_lb_hrs
act_price
part_qty
part_type
}
}
production_jobs: jobs(where: { inproduction: { _eq: true } }) {
id
ro_number
ins_co_nm
job_totals
joblines(where: { removed: { _eq: false } }) {
id
mod_lbr_ty
mod_lb_hrs
act_price
part_qty
part_type
}
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
}
}`;
};
export default createDashboardQuery;

View File

@@ -1,11 +1,9 @@
import Icon, { SyncOutlined } from "@ant-design/icons";
import { gql, useMutation, useQuery } from "@apollo/client";
import { isEmpty, cloneDeep } from "lodash";
import { useMutation, useQuery } from "@apollo/client";
import { Button, Dropdown, Space } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import i18next from "i18next";
import _ from "lodash";
import dayjs from "../../utils/day";
import React, { useState } from "react";
import { useMemo, useState } from "react";
import { Responsive, WidthProvider } from "react-grid-layout";
import { useTranslation } from "react-i18next";
import { MdClose } from "react-icons/md";
@@ -15,38 +13,13 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import DashboardMonthlyEmployeeEfficiency, {
DashboardMonthlyEmployeeEfficiencyGql
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component";
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component";
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component";
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component";
import DashboardMonthlyRevenueGraph, {
DashboardMonthlyRevenueGraphGql
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component";
import DashboardProjectedMonthlySales, {
DashboardProjectedMonthlySalesGql
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component";
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component";
import DashboardTotalProductionHours, {
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
//Combination of the following:
// /node_modules/react-grid-layout/css/styles.css
// /node_modules/react-resizable/css/styles.css
import DashboardScheduledInToday, {
DashboardScheduledInTodayGql
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component";
import DashboardScheduledOutToday, {
DashboardScheduledOutTodayGql
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component";
import JobLifecycleDashboardComponent, {
JobLifecycleDashboardGQL
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component";
import "./dashboard-grid.styles.scss";
import { GenerateDashboardData } from "./dashboard-grid.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import componentList from "./componentList.js";
import createDashboardQuery from "./createDashboardQuery.js";
import "./dashboard-grid.styles.scss";
const ResponsiveReactGridLayout = WidthProvider(Responsive);
@@ -54,6 +27,7 @@ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -85,19 +59,21 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
layout: { ...state, layout, layouts }
}
});
if (!!result.errors) {
notification["error"]({
if (!isEmpty(result?.errors)) {
notification.error({
message: t("dashboard.errors.updatinglayout", {
message: JSON.stringify(result.errors)
})
});
}
};
const handleRemoveComponent = (key) => {
logImEXEvent("dashboard_remove_component", { name: key });
const idxToRemove = state.items.findIndex((i) => i.i === key);
const items = _.cloneDeep(state.items);
const items = cloneDeep(state.items);
items.splice(idxToRemove, 1);
setState({ ...state, items });
@@ -120,7 +96,8 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
});
};
const dashboarddata = React.useMemo(() => GenerateDashboardData(data), [data]);
const dashboardData = useMemo(() => GenerateDashboardData(data), [data]);
const existingLayoutKeys = state.items.map((i) => i.i);
const menuItems = Object.keys(componentList).map((key) => ({
@@ -156,7 +133,6 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
width="100%"
layouts={state.layouts}
onLayoutChange={handleLayoutChange}
// onBreakpointChange={onBreakpointChange}
>
{state.items.map((item, index) => {
const TheComponent = componentList[item.i].component;
@@ -182,7 +158,7 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
}}
onClick={() => handleRemoveComponent(item.i)}
/>
<TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboarddata} />
<TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboardData} />
</LoadingSkeleton>
</div>
);
@@ -193,189 +169,3 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
}
export default connect(mapStateToProps, mapDispatchToProps)(DashboardGridComponent);
const componentList = {
ProductionDollars: {
label: i18next.t("dashboard.titles.productiondollars"),
component: DashboardTotalProductionDollars,
gqlFragment: null,
w: 1,
h: 1,
minW: 2,
minH: 1
},
ProductionHours: {
label: i18next.t("dashboard.titles.productionhours"),
component: DashboardTotalProductionHours,
gqlFragment: DashboardTotalProductionHoursGql,
w: 3,
h: 1,
minW: 3,
minH: 1
},
ProjectedMonthlySales: {
label: i18next.t("dashboard.titles.projectedmonthlysales"),
component: DashboardProjectedMonthlySales,
gqlFragment: DashboardProjectedMonthlySalesGql,
w: 2,
h: 1,
minW: 2,
minH: 1
},
MonthlyRevenueGraph: {
label: i18next.t("dashboard.titles.monthlyrevenuegraph"),
component: DashboardMonthlyRevenueGraph,
gqlFragment: DashboardMonthlyRevenueGraphGql,
w: 4,
h: 2,
minW: 4,
minH: 2
},
MonthlyJobCosting: {
label: i18next.t("dashboard.titles.monthlyjobcosting"),
component: DashboardMonthlyJobCosting,
gqlFragment: null,
minW: 6,
minH: 3,
w: 6,
h: 3
},
MonthlyPartsSales: {
label: i18next.t("dashboard.titles.monthlypartssales"),
component: DashboardMonthlyPartsSales,
gqlFragment: null,
minW: 2,
minH: 2,
w: 2,
h: 2
},
MonthlyLaborSales: {
label: i18next.t("dashboard.titles.monthlylaborsales"),
component: DashboardMonthlyLaborSales,
gqlFragment: null,
minW: 2,
minH: 2,
w: 2,
h: 2
},
// Typo in Efficency should be Efficiency, but changing it would reset users dashboard settings
MonthlyEmployeeEfficency: {
label: i18next.t("dashboard.titles.monthlyemployeeefficiency"),
component: DashboardMonthlyEmployeeEfficiency,
gqlFragment: DashboardMonthlyEmployeeEfficiencyGql,
minW: 2,
minH: 2,
w: 2,
h: 2
},
ScheduleInToday: {
label: i18next.t("dashboard.titles.scheduledintoday"),
component: DashboardScheduledInToday,
gqlFragment: DashboardScheduledInTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
ScheduleOutToday: {
label: i18next.t("dashboard.titles.scheduledouttoday"),
component: DashboardScheduledOutToday,
gqlFragment: DashboardScheduledOutTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
JobLifecycle: {
label: i18next.t("dashboard.titles.joblifecycle"),
component: JobLifecycleDashboardComponent,
gqlFragment: JobLifecycleDashboardGQL,
minW: 6,
minH: 3,
w: 6,
h: 3
}
};
const createDashboardQuery = (state) => {
const componentBasedAdditions =
state &&
Array.isArray(state.layout) &&
state.layout.map((item, index) => componentList[item.i].gqlFragment || "").join("");
return gql`
query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""}
monthly_sales: jobs(where: {_and: [
{ voided: {_eq: false}},
{date_invoiced: {_gte: "${dayjs()
.startOf("month")
.startOf("day")
.toISOString()}"}}, {date_invoiced: {_lte: "${dayjs()
.endOf("month")
.endOf("day")
.toISOString()}"}}]}) {
id
ro_number
date_invoiced
job_totals
rate_la1
rate_la2
rate_la3
rate_la4
rate_laa
rate_lab
rate_lad
rate_lae
rate_laf
rate_lag
rate_lam
rate_lar
rate_las
rate_lau
rate_ma2s
rate_ma2t
rate_ma3s
rate_mabl
rate_macs
rate_mahw
rate_mapa
rate_mash
rate_matd
joblines(where: { removed: { _eq: false } }) {
id
mod_lbr_ty
mod_lb_hrs
act_price
part_qty
part_type
}
}
production_jobs: jobs(where: { inproduction: { _eq: true } }) {
id
ro_number
ins_co_nm
job_totals
joblines(where: { removed: { _eq: false } }) {
id
mod_lbr_ty
mod_lb_hrs
act_price
part_qty
part_type
}
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
}
}`;
};

View File

@@ -0,0 +1,122 @@
import { UploadOutlined } from "@ant-design/icons";
import { Progress, Result, Space, Upload } from "antd";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import formatBytes from "../../utils/formatbytes";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { handleUpload } from "./documents-upload-imgproxy.utility.js";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
bodyshop: selectBodyshop
});
export function DocumentsUploadImgproxyComponent({
children,
currentUser,
bodyshop,
jobId,
tagsArray,
billId,
callbackAfterUpload,
totalSize,
ignoreSizeLimit = false
}) {
const { t } = useTranslation();
const [fileList, setFileList] = useState([]);
const notification = useNotification();
const pct = useMemo(() => {
return parseInt((totalSize / ((bodyshop && bodyshop.jobsizelimit) || 1)) * 100);
}, [bodyshop, totalSize]);
if (pct > 100 && !ignoreSizeLimit)
return (
<Result
status="error"
title={t("documents.labels.storageexceeded_title")}
subTitle={t("documents.labels.storageexceeded")}
/>
);
const handleDone = (uid) => {
setTimeout(() => {
setFileList((fileList) => fileList.filter((x) => x.uid !== uid));
}, 2000);
};
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
return (
<Upload.Dragger
multiple={true}
fileList={fileList}
disabled={!hasMediaAccess}
onChange={(f) => {
if (f.event && f.event.percent === 100) handleDone(f.file.uid);
setFileList(f.fileList);
}}
beforeUpload={(file, fileList) => {
if (ignoreSizeLimit) return true;
const newFiles = fileList.reduce((acc, val) => acc + val.size, 0);
const shouldStopUpload = (totalSize + newFiles) / ((bodyshop && bodyshop.jobsizelimit) || 1) >= 1;
//Check to see if old files plus newly uploaded ones will be too much.
if (shouldStopUpload) {
notification.error({
key: "cannotuploaddocuments",
message: t("documents.labels.upload_limitexceeded_title"),
description: t("documents.labels.upload_limitexceeded")
});
return Upload.LIST_IGNORE;
}
return true;
}}
customRequest={(ev) =>
handleUpload(
ev,
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: jobId,
billId: billId,
tagsArray: tagsArray,
callback: callbackAfterUpload
},
notification
)
}
accept="audio/*, video/*, image/*, .pdf, .doc, .docx, .xls, .xlsx"
// showUploadList={false}
>
{children || (
<>
<p className="ant-upload-drag-icon">
<UploadOutlined />
</p>
<p className="ant-upload-text">
<LockWrapperComponent featureName="media">{t("documents.labels.dragtoupload")}</LockWrapperComponent>
</p>
{!ignoreSizeLimit && (
<Space wrap className="ant-upload-text">
<Progress type="dashboard" percent={pct} size="small" />
<span>
{t("documents.labels.usage", {
percent: pct,
used: formatBytes(totalSize),
total: formatBytes(bodyshop && bodyshop.jobsizelimit)
})}
</span>
</Space>
)}
</>
)}
</Upload.Dragger>
);
}
export default connect(mapStateToProps, null)(DocumentsUploadImgproxyComponent);

View File

@@ -0,0 +1,172 @@
import axios from "axios";
import exifr from "exifr";
import i18n from "i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
import client from "../../utils/GraphQLClient";
//Context: currentUserEmail, bodyshop, jobid, invoiceid
//Required to prevent headers from getting set and rejected from Cloudinary.
var cleanAxios = axios.create();
cleanAxios.interceptors.request.eject(axiosAuthInterceptorId);
export const handleUpload = (ev, context, notification) => {
logImEXEvent("document_upload", { filetype: ev.file?.type });
const { onError, onSuccess, onProgress } = ev;
const { bodyshop, jobId } = context;
const fileName = ev.file?.name || ev.filename;
let extension = fileName.split(".").pop();
let key = `${bodyshop.id}/${jobId}/${replaceAccents(fileName).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}`;
uploadToS3(key, extension, ev.file.type, ev.file, onError, onSuccess, onProgress, context, notification).catch(
(error) => {
console.error("Error uploading file to S3", error);
notification.error({
message: i18n.t("documents.errors.insert", {
message: error.message
})
});
}
);
};
//Handles only 1 file at a time.
export const uploadToS3 = async (
key,
extension,
fileType,
file,
onError,
onSuccess,
onProgress,
context,
notification
) => {
const { bodyshop, jobId, billId, uploaded_by, callback } = context;
//Get the signed url allowing us to PUT to S3.
const signedURLResponse = await axios.post("/media/imgproxy/sign", {
filenames: [key],
bodyshopid: bodyshop.id,
jobid: jobId
});
if (signedURLResponse.status !== 200) {
if (onError) onError(signedURLResponse.statusText);
notification.error({
message: i18n.t("documents.errors.getpresignurl", {
message: signedURLResponse.statusText
})
});
return;
}
//Key should be same as we provided to maintain backwards compatibility.
const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } = signedURLResponse.data.signedUrls[0];
const options = {
onUploadProgress: (e) => {
if (onProgress) onProgress({ percent: (e.loaded / e.total) * 100 });
}
};
try {
const s3UploadResponse = await cleanAxios.put(preSignedUploadUrlToS3, file, options);
//Insert the document with the matching key.
let takenat;
if (fileType.includes("image")) {
try {
const exif = await exifr.parse(file);
takenat = exif && exif.DateTimeOriginal;
} catch (error) {
console.log("Unable to parse image file for EXIF Data", error.message);
}
}
const documentInsert = await client.mutate({
mutation: INSERT_NEW_DOCUMENT,
variables: {
docInput: [
{
...(jobId ? { jobid: jobId } : {}),
...(billId ? { billid: billId } : {}),
uploaded_by: uploaded_by,
key: s3Key,
type: fileType,
extension: s3UploadResponse.data.format || extension,
bodyshopid: bodyshop.id,
size: s3UploadResponse.data.bytes || file.size, //Leftover from Cloudinary. We don't do any optimization on upload, so it will always be file.size.
takenat
}
]
}
});
if (!documentInsert.errors) {
if (onSuccess)
onSuccess({
uid: documentInsert.data.insert_documents.returning[0].id,
name: documentInsert.data.insert_documents.returning[0].name,
status: "done",
key: documentInsert.data.insert_documents.returning[0].key
});
notification.success({
key: "docuploadsuccess",
message: i18n.t("documents.successes.insert")
});
if (callback) {
callback();
}
} else {
if (onError) onError(JSON.stringify(documentInsert.errors));
notification.error({
message: i18n.t("documents.errors.insert", {
message: JSON.stringify(documentInsert.errors)
})
});
return;
}
} catch (error) {
console.log("Error uploading file to S3", error.message, error.stack);
notification.error({
message: i18n.t("documents.errors.insert", {
message: error.message
})
});
if (onError) onError(JSON.stringify(error.message));
}
};
function replaceAccents(str) {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
}

View File

@@ -10,6 +10,8 @@ import AlertComponent from "../alert/alert.component";
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -23,6 +25,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(EmailDocumentsCompon
export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState, bodyshop }) {
const { t } = useTranslation();
const {
treatments: { Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Imgproxy"],
splitKey: bodyshop && bodyshop.imexshopid
});
const [selectedMedia, setSelectedMedia] = selectedMediaState;
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, {
@@ -46,17 +55,37 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState,
10485760 - new Blob([form.getFieldValue("html")]).size ? (
<div style={{ color: "red" }}>{t("general.errors.sizelimit")}</div>
) : null}
{!bodyshop.uselocalmediaserver && data && (
<JobDocumentsGalleryExternal
data={data ? data.documents : []}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && (
<JobsDocumentsLocalGalleryExternalComponent
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={emailConfig.jobid}
/>
{Imgproxy.treatment === "on" ? (
<>
{!bodyshop.uselocalmediaserver && data && (
<JobsDocumentImgproxyGalleryExternal
jobId={emailConfig.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && (
<JobsDocumentsLocalGalleryExternalComponent
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={emailConfig.jobid}
/>
)}
</>
) : (
<>
{!bodyshop.uselocalmediaserver && data && (
<JobDocumentsGalleryExternal
data={data ? data.documents : []}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && (
<JobsDocumentsLocalGalleryExternalComponent
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={emailConfig.jobid}
/>
)}
</>
)}
</div>
);

View File

@@ -20,6 +20,7 @@ function FeatureWrapper({
children,
upsellComponent,
bypass,
// eslint-disable-next-line no-unused-vars
...restProps
}) {
const { t } = useTranslation();
@@ -78,7 +79,11 @@ export function HasFeatureAccess({ featureName, bodyshop, bypass, debug = false
}
return true;
}
return bodyshop?.features?.allAccess || dayjs(bodyshop?.features[featureName]).isAfter(dayjs());
return (
bodyshop?.features?.allAccess ||
bodyshop?.features?.[featureName] ||
dayjs(bodyshop?.features[featureName]).isAfter(dayjs())
);
}
export default connect(mapStateToProps, null)(FeatureWrapper);

View File

@@ -40,7 +40,6 @@ import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
@@ -51,6 +50,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
// Redux mappings
const mapStateToProps = createStructuredSelector({

View File

@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";

View File

@@ -1,17 +1,22 @@
import { PrinterFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client";
import { PauseCircleOutlined, PlayCircleOutlined, PrinterFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client";
import { Button, Card, Col, Divider, Drawer, Grid, Row, Space } from "antd";
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { QUERY_JOB_CARD_DETAILS } from "../../graphql/jobs.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import AlertComponent from "../alert/alert.component";
import JobSyncButton from "../job-sync-button/job-sync-button.component";
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
import JobsDetailHeader from "../jobs-detail-header/jobs-detail-header.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import JobDetailCardsDamageComponent from "./job-detail-cards.damage.component";
@@ -21,15 +26,21 @@ import JobDetailCardsInsuranceComponent from "./job-detail-cards.insurance.compo
import JobDetailCardsNotesComponent from "./job-detail-cards.notes.component";
import JobDetailCardsPartsComponent from "./job-detail-cards.parts.component";
import JobDetailCardsTotalsComponent from "./job-detail-cards.totals.component";
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" }))
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
});
const span = {
@@ -38,8 +49,9 @@ const span = {
xxl: { span: 8 }
};
export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
export function JobDetailCards({ bodyshop, setPrintCenterContext, insertAuditTrail }) {
const { scenarioNotificationsOn } = useSocket();
const [updateJob] = useMutation(UPDATE_JOB);
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
.filter((screen) => !!screen[1])
.slice(-1)[0];
@@ -91,7 +103,29 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
extra={
<Space wrap>
<JobSyncButton job={data.jobs_by_pk} />
<Button
onClick={() => {
logImEXEvent("production_toggle_alert");
updateJob({
variables: {
jobId: data.jobs_by_pk.id,
job: {
suspended: !data.jobs_by_pk.suspended
}
}
});
insertAuditTrail({
jobid: data.jobs_by_pk.id,
operation: AuditTrailMapping.jobsuspend(
data.jobs_by_pk.suspended ? !data.jobs_by_pk.suspended : true
),
type: "jobsuspend"
});
}}
icon={data.jobs_by_pk.suspended ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
>
{data.jobs_by_pk.suspended ? t("production.actions.unsuspend") : t("production.actions.suspend")}
</Button>
<Button
onClick={() => {
setPrintCenterContext({
@@ -103,8 +137,8 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
}
});
}}
icon={<PrinterFilled />}
>
<PrinterFilled />
{t("jobs.actions.printCenter")}
</Button>
<Link to={`/manage/jobs/${data.jobs_by_pk.id}?tab=repairdata`}>

View File

@@ -1,9 +1,9 @@
import { Col, Row } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobReconciliationBillsTable from "../job-reconciliation-bills-table/job-reconciliation-bills-table.component";
import JobReconciliationPartsTable from "../job-reconciliation-parts-table/job-reconciliation-parts-table.component";
import JobReconciliationTotals from "../job-reconciliation-totals/job-reconciliation-totals.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
export default function JobReconciliationModalComponent({ job, bills }) {
const jobLineState = useState([]);
@@ -20,7 +20,7 @@ export default function JobReconciliationModalComponent({ job, bills }) {
const filterFunction = InstanceRenderManager({
imex: (j) =>
(j.part_type !== null && j.part_type !== "PAE") ||
(j.part_type !== null && j.part_type !== "PAE" && j.act_price !== 0 && j.part_qty !== 0) ||
(j.line_desc && j.line_desc.toLowerCase().includes("towing") && j.lbr_op === "OP13") ||
j.db_ref === "936004", //ADD SHIPPING LINE.
rome: (j) =>

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
@@ -133,6 +133,16 @@ export function JobsDetailHeaderActions({
const { socket } = useSocket();
const notification = useNotification();
const isDevEnv = import.meta.env.DEV;
const isProdEnv = import.meta.env.PROD;
const userEmail = currentUser?.email || "";
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canSubmitForTesting = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
const {
treatments: { ImEXPay }
} = useSplitTreatments({
@@ -171,7 +181,7 @@ export function JobsDetailHeaderActions({
{ defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
(newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification["success"]({
notification.success({
message: t("jobs.successes.duplicated")
});
},
@@ -181,7 +191,7 @@ export function JobsDetailHeaderActions({
const handleDuplicateConfirm = () =>
DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification["success"]({
notification.success({
message: t("jobs.successes.duplicated")
});
});
@@ -217,13 +227,13 @@ export function JobsDetailHeaderActions({
const result = await deleteJob({ variables: { id: job.id } });
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.delete")
});
//go back to jobs list.
history(`/manage/`);
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.deleted", {
error: JSON.stringify(result.errors)
})
@@ -275,9 +285,9 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({ message: t("csi.successes.created") });
notification.success({ message: t("csi.successes.created") });
} else {
notification["error"]({
notification.error({
message: t("csi.errors.creating", {
message: JSON.stringify(result.errors)
})
@@ -316,7 +326,7 @@ export function JobsDetailHeaderActions({
`${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}`
);
} else {
notification["error"]({
notification.error({
message: t("messaging.error.invalidphone")
});
}
@@ -328,7 +338,7 @@ export function JobsDetailHeaderActions({
);
}
} else {
notification["error"]({
notification.error({
message: t("csi.errors.notconfigured")
});
}
@@ -358,7 +368,7 @@ export function JobsDetailHeaderActions({
});
setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`);
} else {
notification["error"]({
notification.error({
message: t("messaging.error.invalidphone")
});
}
@@ -398,7 +408,7 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.voided")
});
insertAuditTrail({
@@ -409,7 +419,7 @@ export function JobsDetailHeaderActions({
//go back to jobs list.
history(`/manage/`);
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.voiding", {
error: JSON.stringify(result.errors)
})
@@ -442,7 +452,7 @@ export function JobsDetailHeaderActions({
console.log("handle -> XML", QbXmlResponse);
} catch (error) {
console.log("Error getting QBXML from Server.", error);
notification["error"]({
notification.error({
message: t("jobs.errors.exporting", {
error: "Unable to retrieve QBXML. " + JSON.stringify(error.message)
})
@@ -460,7 +470,7 @@ export function JobsDetailHeaderActions({
});
} catch (error) {
console.log("Error connecting to quickbooks or partner.", error);
notification["error"]({
notification.error({
message: t("jobs.errors.exporting-partner")
});
@@ -556,7 +566,7 @@ export function JobsDetailHeaderActions({
}
});
if (!jobUpdate.errors) {
notification["success"]({
notification.success({
message: t("appointments.successes.canceled")
});
insertAuditTrail({
@@ -931,11 +941,11 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.partsqueue")
});
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.saving", {
error: JSON.stringify(result.errors)
})
@@ -1111,6 +1121,27 @@ export function JobsDetailHeaderActions({
});
}
if (canSubmitForTesting) {
menuItems.push({
key: "submitfortesting",
id: "job-actions-submitfortesting",
label: t("menus.jobsactions.submit-for-testing"),
onClick: async () => {
try {
await axios.post("/job/totals-recorder", { id: job.id });
notification.success({
message: t("general.messages.submit-for-testing")
});
} catch (err) {
console.error(`Error submitting job for testing: ${err?.message}`);
notification.error({
message: t("general.errors.submit-for-testing-error")
});
}
}
});
}
const menu = {
items: menuItems,
key: "popovermenu"

View File

@@ -5,6 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import CABCpvrtCalculator from "../ca-bc-pvrt-calculator/ca-bc-pvrt-calculator.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import JobsDetailRatesChangeButton from "../jobs-detail-rates-change-button/jobs-detail-rates-change-button.component";
@@ -14,9 +15,8 @@ import JobsDetailRatesLabor from "./jobs-detail-rates.labor.component";
import JobsDetailRatesMaterials from "./jobs-detail-rates.materials.component";
import JobsDetailRatesOther from "./jobs-detail-rates.other.component";
import JobsDetailRatesParts from "./jobs-detail-rates.parts.component";
import JobsDetailRatesTaxes from "./jobs-detail-rates.taxes.component";
import JobsDetailRatesProfileOVerride from "./jobs-detail-rates.profile-override.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsDetailRatesTaxes from "./jobs-detail-rates.taxes.component";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -66,14 +66,48 @@ export function JobsDetailRates({ jobRO, form, job, bodyshop }) {
</Space>
)}
<Form.Item label={t("jobs.fields.auto_add_ats")} name="auto_add_ats" valuePropName="checked">
<Switch disabled={jobRO} />
<Switch
disabled={jobRO}
onChange={(checked) => {
if (checked) {
form.setFieldsValue({ flat_rate_ats: false });
form.setFieldsValue({ rate_ats: form.getFieldValue('rate_ats') || bodyshop.shoprates.rate_ats });
}
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.auto_add_ats !== cur.auto_add_ats}>
{() => {
if (form.getFieldValue("auto_add_ats"))
return (
<Form.Item label={t("jobs.fields.rate_ats")} name="rate_ats" initialValue={bodyshop.shoprates.rate_atp}>
<Form.Item label={t("jobs.fields.rate_ats")} name="rate_ats">
<CurrencyInput disabled={jobRO} />
</Form.Item>
);
return null;
}}
</Form.Item>
<Form.Item label={t("jobs.fields.flat_rate_ats")} name="flat_rate_ats" valuePropName="checked">
<Switch
disabled={jobRO}
onChange={(checked) => {
if (checked) {
form.setFieldsValue({ auto_add_ats: false });
form.setFieldsValue({ rate_ats_flat: form.getFieldValue('rate_ats_flat') || bodyshop.shoprates.rate_ats_flat });
}
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.flat_rate_ats !== cur.flat_rate_ats}>
{() => {
if (form.getFieldValue("flat_rate_ats"))
return (
<Form.Item
label={t("jobs.fields.rate_ats_flat")}
name="rate_ats_flat"
>
<CurrencyInput disabled={jobRO} />
</Form.Item>
);

View File

@@ -19,7 +19,13 @@ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsDownloadButton);
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
################################################################################################
*/
export function JobsDocumentsDownloadButton({ bodyshop, galleryImages, identifier }) {
const { t } = useTranslation();
const [download, setDownload] = useState(null);

View File

@@ -17,7 +17,13 @@ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsGalleryReassign);
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
################################################################################################
*/
export function JobsDocumentsGalleryReassign({ bodyshop, galleryImages, callback }) {
const { t } = useTranslation();
const [form] = Form.useForm();

View File

@@ -1,29 +1,34 @@
import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Col, Row, Space } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { Gallery } from "react-grid-gallery";
import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DocumentsUploadComponent from "../documents-upload/documents-upload.component";
import { DetermineFileType } from "../documents-upload/documents-upload.utility";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents.utility";
import JobsDocumentsDownloadButton from "./jobs-document-gallery.download.component";
import JobsDocumentsGalleryReassign from "./jobs-document-gallery.reassign.component";
import JobsDocumentsDeleteButton from "./jobs-documents-gallery.delete.component";
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-gallery.selectall.component";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
################################################################################################
*/
function JobsDocumentsComponent({
bodyshop,
data,
@@ -114,6 +119,7 @@ function JobsDocumentsComponent({
);
setgalleryImages(documents);
}, [data, setgalleryImages, t]);
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
return (
@@ -137,7 +143,6 @@ function JobsDocumentsComponent({
</Card>
</Col>
)}
<Col span={24}>
<Card>
<DocumentsUploadComponent

View File

@@ -2,29 +2,77 @@ import { useQuery } from "@apollo/client";
import React from "react";
import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
import AlertComponent from "../alert/alert.component";
import JobDocumentsImgProxy from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import JobDocuments from "./jobs-documents-gallery.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsContainer);
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
################################################################################################
*/
export function JobsDocumentsContainer({
jobId,
billId,
documentsList,
billsCallback,
refetchOverride,
ignoreSizeLimit,
bodyshop
}) {
const {
treatments: { Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Imgproxy"],
splitKey: bodyshop && bodyshop.imexshopid
});
export default function JobsDocumentsContainer({ jobId, billId, documentsList, billsCallback }) {
const { loading, error, data, refetch } = useQuery(GET_DOCUMENTS_BY_JOB, {
variables: { jobId: jobId },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !!billId
skip: Imgproxy.treatment === "on" || !!billId
});
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent type="error" message={error.message} />;
return (
<JobDocuments
data={(data && data.documents) || documentsList || []}
downloadIdentifier={data && data.jobs_by_pk.ro_number}
totalSize={data && data.documents_aggregate.aggregate.sum.size}
billId={billId}
jobId={jobId}
refetch={refetch}
billsCallback={billsCallback}
/>
);
if (Imgproxy.treatment === "on") {
return (
<JobDocumentsImgProxy
data={(data && data.documents) || documentsList || []}
downloadIdentifier={data && data.jobs_by_pk.ro_number}
totalSize={data && data.documents_aggregate.aggregate.sum.size}
billId={billId}
jobId={jobId}
refetch={refetchOverride || refetch}
billsCallback={billsCallback}
ignoreSizeLimit={ignoreSizeLimit}
/>
);
} else {
return (
<JobDocuments
data={(data && data.documents) || documentsList || []}
downloadIdentifier={data && data.jobs_by_pk.ro_number}
totalSize={data && data.documents_aggregate.aggregate.sum.size}
billId={billId}
jobId={jobId}
refetch={refetchOverride || refetch}
billsCallback={billsCallback}
ignoreSizeLimit={ignoreSizeLimit}
/>
);
}
}

View File

@@ -5,8 +5,13 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
//Context: currentUserEmail, bodyshop, jobid, invoiceid
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
################################################################################################
*/
export default function JobsDocumentsDeleteButton({ galleryImages, deletionCallback }) {
const { t } = useTranslation();
const notification = useNotification();

View File

@@ -3,6 +3,13 @@ import { Gallery } from "react-grid-gallery";
import { useTranslation } from "react-i18next";
import { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents.utility";
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
################################################################################################
*/
function JobsDocumentGalleryExternal({
data,

View File

@@ -2,6 +2,14 @@ import { Button, Space } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
################################################################################################
*/
export default function JobsDocumentsGallerySelectAllComponent({ galleryImages, setGalleryImages }) {
const { t } = useTranslation();

View File

@@ -0,0 +1,87 @@
import { Button, Space } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
//import yauzl from "yauzl";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
################################################################################################
*/
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) {
const { t } = useTranslation();
const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false);
const imagesToDownload = [
...galleryImages.images.filter((image) => image.isSelected),
...galleryImages.other.filter((image) => image.isSelected)
];
function downloadProgress(progressEvent) {
setDownload((currentDownloadState) => {
return {
downloaded: progressEvent.loaded || 0,
speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0)
};
});
}
function standardMediaDownload(bufferData) {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${identifier || "documents"}.zip`;
a.click();
}
const handleDownload = async () => {
logImEXEvent("jobs_documents_download");
setLoading(true);
const zipUrl = await axios({
url: "/media/imgproxy/download",
method: "POST",
data: { documentids: imagesToDownload.map((_) => _.id) }
});
const theDownloadedZip = await cleanAxios({
url: zipUrl.data.url,
method: "GET",
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
});
setLoading(false);
setDownload(null);
standardMediaDownload(theDownloadedZip.data);
};
return (
<>
<Button loading={download || loading} disabled={imagesToDownload.length < 1} onClick={handleDownload}>
<Space>
<span>{t("documents.actions.download")}</span>
{download && <span>{`(${formatBytes(download.downloaded)} @ ${formatBytes(download.speed)} / second)`}</span>}
</Space>
</Button>
</>
);
}

View File

@@ -0,0 +1,134 @@
import { useApolloClient } from "@apollo/client";
import { Button, Form, Popover, Space } from "antd";
import axios from "axios";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { GET_DOC_SIZE_BY_JOB } from "../../graphql/documents.queries.js";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import JobSearchSelect from "../job-search-select/job-search-select.component.jsx";
import { isFunction } from "lodash";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyGalleryReassign);
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
################################################################################################
*/
export function JobsDocumentsImgproxyGalleryReassign({ bodyshop, galleryImages, callback }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const notification = useNotification();
const selectedImages = useMemo(() => {
return [
...galleryImages.images.filter((image) => image.isSelected),
...galleryImages.other.filter((image) => image.isSelected)
];
}, [galleryImages]);
const client = useApolloClient();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleFinish = async ({ jobid }) => {
setLoading(true);
//Check to see if the space remaining on the new job is sufficient. If it isn't cancel this.
const newJobData = await client.query({
query: GET_DOC_SIZE_BY_JOB,
variables: { jobId: jobid }
});
const transferedDocSizeTotal = selectedImages.reduce((acc, val) => acc + val.size, 0);
const shouldPreventTransfer =
bodyshop.jobsizelimit - newJobData.data.documents_aggregate.aggregate.sum.size < transferedDocSizeTotal;
if (shouldPreventTransfer) {
notification.error({
key: "cannotuploaddocuments",
message: t("documents.labels.reassign_limitexceeded_title"),
description: t("documents.labels.reassign_limitexceeded")
});
setLoading(false);
return;
}
const res = await axios.post("/media/imgproxy/rename", {
tojobid: jobid,
documents: selectedImages.map((i) => {
//Need to check if the current key folder is null, or another job.
const currentKeys = i.key.split("/");
currentKeys[1] = jobid;
currentKeys.join("/");
return {
id: i.id,
from: i.key,
to: currentKeys.join("/"),
extension: i.extension,
type: i.type
};
})
});
//Add in confirmation & errors.
if (isFunction(callback)) callback();
if (res.errors) {
notification.error({
message: t("documents.errors.updating", {
message: JSON.stringify(res.errors)
})
});
}
if (!res.mutationResult?.errors) {
notification.success({
message: t("documents.successes.updated")
});
}
setOpen(false);
setLoading(false);
};
const popContent = (
<div>
<Form onFinish={handleFinish} layout="vertical" form={form}>
<Form.Item
label={t("documents.labels.newjobid")}
style={{ width: "20rem" }}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={"jobid"}
>
<JobSearchSelect notExported={false} notInvoiced={false} />
</Form.Item>
</Form>
<Space>
<Button type="primary" onClick={() => form.submit()}>
{t("general.actions.submit")}
</Button>
<Button onClick={() => setOpen(false)}>{t("general.actions.cancel")}</Button>
</Space>
</div>
);
return (
<Popover content={popContent} open={open}>
<Button disabled={selectedImages.length < 1} onClick={() => setOpen(true)} loading={loading}>
{t("documents.actions.reassign")}
</Button>
</Popover>
);
}

View File

@@ -0,0 +1,266 @@
import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Col, Row, Space } from "antd";
import axios from "axios";
import i18n from "i18next";
import { isFunction } from "lodash";
import { useCallback, useEffect, useState } from "react";
import { Gallery } from "react-grid-gallery";
import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
################################################################################################
*/
function JobsDocumentsImgproxyComponent({
bodyshop,
data,
jobId,
refetch,
billId,
billsCallback,
totalSize,
downloadIdentifier,
ignoreSizeLimit
}) {
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
const { t } = useTranslation();
const [modalState, setModalState] = useState({ open: false, index: 0 });
const fetchThumbnails = useCallback(() => {
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId });
}, [jobId, setGalleryImages]);
useEffect(() => {
if (data) {
fetchThumbnails();
}
}, [data, fetchThumbnails]);
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
return (
<div>
<Row gutter={[16, 16]}>
<Col span={24}>
<Space wrap>
<Button
onClick={() => {
//Handle any doc refresh.
isFunction(refetch) && refetch();
//Do the imgproxy refresh too
fetchThumbnails();
}}
>
<SyncOutlined />
</Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} />
<JobsDocumentsDeleteButton
galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch}
/>
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
</Space>
</Col>
{!hasMediaAccess && (
<Col span={24}>
<Card>
<UpsellComponent disableMask upsell={upsellEnum().media.general} />
</Card>
</Col>
)}
<Col span={24}>
<Card>
<DocumentsUploadImgproxyComponent
jobId={jobId}
totalSize={totalSize}
billId={billId}
callbackAfterUpload={billsCallback || fetchThumbnails || refetch}
ignoreSizeLimit={ignoreSizeLimit}
/>
</Card>
</Col>
{hasMediaAccess && !hasMobileAccess && (
<Col span={24}>
<Card>
<UpsellComponent upsell={upsellEnum().media.mobile} />
</Card>
</Col>
)}
<Col span={24}>
<Card title={t("jobs.labels.documents-images")}>
<Gallery
images={galleryImages.images}
onClick={(index, item) => {
setModalState({ open: true, index: index });
// window.open(
// item.fullsize,
// "_blank",
// "toolbar=0,location=0,menubar=0"
// );
}}
onSelect={(index, image) => {
setGalleryImages({
...galleryImages,
images: galleryImages.images.map((g, idx) =>
index === idx ? { ...g, isSelected: !g.isSelected } : g
)
});
}}
/>
</Card>
</Col>
<Col span={24}>
<Card title={t("jobs.labels.documents-other")}>
<Gallery
images={galleryImages.other}
thumbnailStyle={() => {
return {
backgroundImage: <FileExcelFilled />,
height: "100%",
width: "100%",
cursor: "pointer"
};
}}
onClick={(index) => {
window.open(galleryImages.other[index].source, "_blank", "toolbar=0,location=0,menubar=0");
}}
onSelect={(index) => {
setGalleryImages({
...galleryImages,
other: galleryImages.other.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g))
});
}}
/>
</Card>
</Col>
{modalState.open && (
<Lightbox
toolbarButtons={[
<EditFilled
key="edit"
onClick={() => {
const newWindow = window.open(
`${window.location.protocol}//${window.location.host}/edit?documentId=${
galleryImages.images[modalState.index].id
}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
}}
/>
]}
mainSrc={galleryImages.images[modalState.index].fullsize}
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
prevSrc={
galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length]
.fullsize
}
onCloseRequest={() => setModalState({ open: false, index: 0 })}
onMovePrevRequest={() =>
setModalState({
...modalState,
index: (modalState.index + galleryImages.images.length - 1) % galleryImages.images.length
})
}
onMoveNextRequest={() =>
setModalState({
...modalState,
index: (modalState.index + 1) % galleryImages.images.length
})
}
/>
)}
</Row>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyComponent);
export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, imagesOnly }) => {
const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId });
const documents = result.data.reduce(
(acc, value) => {
if (value.type.startsWith("image")) {
acc.images.push({
src: value.thumbnailUrl,
fullsize: value.originalUrl,
height: 225,
width: 225,
isSelected: false,
key: value.key,
extension: value.extension,
id: value.id,
type: value.type,
size: value.size,
tags: [{ value: value.type, title: value.type }]
});
} else {
const fileName = value.key.split("/").pop();
acc.other.push({
source: value.originalUrlViaProxyPath,
src: value.thumbnailUrl,
fullsize: value.presignedGetUrl,
tags: [
{
value: fileName,
title: fileName
},
{ value: value.type, title: value.type },
...(value.bill
? [
{
value: value.bill.vendor.name,
title: i18n.t("vendors.fields.name")
},
{ value: value.bill.date, title: i18n.t("bills.fields.date") },
{
value: value.bill.invoice_number,
title: i18n.t("bills.fields.invoice_number")
}
]
: [])
],
height: 225,
width: 225,
isSelected: false,
extension: value.extension,
key: value.key,
id: value.id,
type: value.type,
size: value.size
});
}
return acc;
},
{ images: [], other: [] }
);
setStateCallback(imagesOnly ? documents.images : documents);
};

View File

@@ -0,0 +1,37 @@
import { useQuery } from "@apollo/client";
import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import JobDocuments from "./jobs-documents-imgproxy-gallery.component";
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
################################################################################################
*/
export default function JobsDocumentsImgproxyContainer({ jobId, billId, documentsList, billsCallback }) {
const { loading, error, data, refetch } = useQuery(GET_DOCUMENTS_BY_JOB, {
variables: { jobId: jobId },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !!billId
});
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent type="error" message={error.message} />;
return (
<JobDocuments
data={(data && data.documents) || documentsList || []}
downloadIdentifier={data && data.jobs_by_pk.ro_number}
totalSize={data && data.documents_aggregate.aggregate.sum.size}
billId={billId}
jobId={jobId}
refetch={refetch}
billsCallback={billsCallback}
/>
);
}

View File

@@ -0,0 +1,75 @@
import { QuestionCircleOutlined } from "@ant-design/icons";
import { Button, Popconfirm } from "antd";
import axios from "axios";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { isFunction } from "lodash";
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
################################################################################################
*/
export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, deletionCallback }) {
const { t } = useTranslation();
const notification = useNotification();
const imagesToDelete = [
...galleryImages.images.filter((image) => image.isSelected),
...galleryImages.other.filter((image) => image.isSelected)
];
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
logImEXEvent("job_documents_delete", { count: imagesToDelete.length });
try {
setLoading(true);
const res = await axios.post("/media/imgproxy/delete", {
ids: imagesToDelete.map((d) => d.id)
});
if (res.data.error) {
notification.error({
message: t("documents.errors.deleting", {
error: JSON.stringify(res.data.error.response.errors)
})
});
} else {
notification.success({
key: "docdeletedsuccesfully",
message: t("documents.successes.delete")
});
if (isFunction(deletionCallback)) deletionCallback();
}
} catch (error) {
notification.error({
message: t("documents.errors.deleting", {
error: error.message
})
});
}
setLoading(false);
};
return (
<Popconfirm
disabled={imagesToDelete.length < 1}
icon={<QuestionCircleOutlined style={{ color: "red" }} />}
onConfirm={handleDelete}
title={t("documents.labels.confirmdelete")}
okText={t("general.actions.delete")}
okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")}
>
<Button disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")}
</Button>
</Popconfirm>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from "react";
import { Gallery } from "react-grid-gallery";
import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component";
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
################################################################################################
*/
function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState }) {
const [galleryImages, setgalleryImages] = externalMediaState;
useEffect(() => {
if (jobId) fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true });
}, [jobId, setgalleryImages]);
return (
<div className="clearfix">
<Gallery
images={galleryImages}
backdropClosesModal={true}
onSelect={(index, image) => {
setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)));
}}
/>
</div>
);
}
export default JobsDocumentImgproxyGalleryExternal;

View File

@@ -0,0 +1,63 @@
import { Button, Space } from "antd";
import { useTranslation } from "react-i18next";
/*
################################################################################################
Developer Note:
Known Technical Debt Item
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
################################################################################################
*/
export default function JobsDocumentsImgproxyGallerySelectAllComponent({ galleryImages, setGalleryImages }) {
const { t } = useTranslation();
const handleSelectAll = () => {
setGalleryImages({
...galleryImages,
other: galleryImages.other.map((i) => {
return { ...i, isSelected: true };
}),
images: galleryImages.images.map((i) => {
return { ...i, isSelected: true };
})
});
};
const handleSelectAllImages = () => {
setGalleryImages({
...galleryImages,
images: galleryImages.images.map((i) => {
return { ...i, isSelected: true };
})
});
};
const handleSelectAllDocuments = () => {
setGalleryImages({
...galleryImages,
other: galleryImages.other.map((i) => {
return { ...i, isSelected: true };
})
});
};
const handleDeselectAll = () => {
setGalleryImages({
...galleryImages,
other: galleryImages.other.map((i) => {
return { ...i, isSelected: false };
}),
images: galleryImages.images.map((i) => {
return { ...i, isSelected: false };
})
});
};
return (
<Space wrap>
<Button onClick={handleSelectAll}>{t("general.actions.selectall")}</Button>
<Button onClick={handleSelectAllImages}>{t("documents.actions.selectallimages")}</Button>
<Button onClick={handleSelectAllDocuments}>{t("documents.actions.selectallotherdocuments")}</Button>
<Button onClick={handleDeselectAll}>{t("general.actions.deselectall")}</Button>
</Space>
);
}

View File

@@ -0,0 +1,10 @@
/* you can make up upload button and sample style by using stylesheets */
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}

View File

@@ -3,10 +3,10 @@ import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
// This will be used to poll for notifications when the socket is disconnected
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;

View File

@@ -1,16 +1,17 @@
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-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 VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -32,7 +33,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
});
const { t } = useTranslation();
const handleClick = ({ item }) => {
const handleClick = ({ item, key, keyPath }) => {
form.setFieldsValue({ comments: item.props.value });
};
@@ -97,18 +98,17 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
<Checkbox />
</Form.Item>
)}
{!isReturn && (
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
<Radio.Group disabled={sendType === "oec"}>
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
</Radio.Group>
</Form.Item>
)}
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
<Radio.Group disabled={sendType === "oec"}>
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
</Radio.Group>
</Form.Item>
</LayoutFormRow>
<Divider orientation="left">{t("parts_orders.labels.inthisorder")}</Divider>
<Form.List name={["parts_order_lines", "data"]}>
{(fields, { remove, move }) => {
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (

View File

@@ -10,7 +10,7 @@ import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -1,17 +1,20 @@
import { PrinterFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client";
import { Button, Descriptions, Drawer, Space } from "antd";
import { PauseCircleOutlined, PlayCircleOutlined, PrinterFilled } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client";
import { Button, Descriptions, Drawer, Space } from "antd";
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { QUERY_JOB_CARD_DETAILS } from "../../graphql/jobs.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { logImEXEvent } from "../../firebase/firebase.utils.js";
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
@@ -24,22 +27,29 @@ import JobDetailCardsPartsComponent from "../job-detail-cards/job-detail-cards.p
import CardTemplate from "../job-detail-cards/job-detail-cards.template.component";
import JobEmployeeAssignments from "../job-employee-assignments/job-employee-assignments.container";
import ScoreboardAddButton from "../job-scoreboard-add-button/job-scoreboard-add-button.component";
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
technician: selectTechnician
});
const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" }))
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
});
export default connect(mapStateToProps, mapDispatchToProps)(ProductionListDetail);
export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, technician }) {
export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, technician, insertAuditTrail }) {
const search = queryString.parse(useLocation().search);
const history = useNavigate();
const { selected } = search;
@@ -58,6 +68,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const [updateJob] = useMutation(UPDATE_JOB);
return (
<Drawer
@@ -72,6 +83,29 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
extra={
<Space wrap>
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}
{!technician && (
<Button
onClick={() => {
logImEXEvent("production_toggle_alert");
updateJob({
variables: {
jobId: theJob.id,
job: {
suspended: !theJob.suspended
}
}
});
insertAuditTrail({
jobid: theJob.id,
operation: AuditTrailMapping.jobsuspend(theJob.suspended ? !theJob.suspended : true),
type: "jobsuspend"
});
}}
icon={theJob.suspended ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
>
{theJob.suspended ? t("production.actions.unsuspend") : t("production.actions.suspend")}
</Button>
)}
<Button
onClick={() => {
setPrintCenterContext({
@@ -83,8 +117,8 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
}
});
}}
icon={<PrinterFilled />}
>
<PrinterFilled />
{t("jobs.actions.printCenter")}
</Button>
{!technician ? <ScoreboardAddButton job={data ? data.jobs_by_pk : {}} /> : null}

View File

@@ -10,7 +10,7 @@ import {
import ProductionListTable from "./production-list-table.component";
import _ from "lodash";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
const client = useApolloClient();

View File

@@ -8,7 +8,7 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -5,7 +5,7 @@ import { QUERY_ALL_ASSOCIATIONS, UPDATE_ACTIVE_ASSOCIATION } from "../../graphql
import AlertComponent from "../alert/alert.component";
import ProfileShopsComponent from "./profile-shops.component";
import axios from "axios";
import { getToken } from "firebase/messaging";
import { getToken } from "@firebase/messaging";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";

View File

@@ -1,6 +1,5 @@
import { DeleteFilled } from "@ant-design/icons";
import { Button, Form, Input } from "antd";
import React from "react";
import { Button, Form, Input, Space } from "antd";
import { useTranslation } from "react-i18next";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
@@ -10,326 +9,338 @@ export default function ShopInfoLaborRates({ form }) {
const { t } = useTranslation();
return (
<div>
<Form.List name={["md_labor_rates"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow>
<Form.Item
label={t("jobs.fields.labor_rate_desc")}
key={`${index}rate_label`}
name={[field.name, "rate_label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laa")}
key={`${index}rate_laa`}
name={[field.name, "rate_laa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lab")}
key={`${index}rate_lab`}
name={[field.name, "rate_lab"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lad")}
key={`${index}rate_lad`}
name={[field.name, "rate_lad"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lae")}
key={`${index}rate_lae`}
name={[field.name, "rate_lae"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laf")}
key={`${index}rate_laf`}
name={[field.name, "rate_laf"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lag")}
key={`${index}rate_lag`}
name={[field.name, "rate_lag"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lam")}
key={`${index}rate_lam`}
name={[field.name, "rate_lam"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lar")}
key={`${index}rate_lar`}
name={[field.name, "rate_lar"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_las")}
key={`${index}rate_las`}
name={[field.name, "rate_las"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la1")}
key={`${index}rate_la1`}
name={[field.name, "rate_la1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la2")}
key={`${index}rate_la2`}
name={[field.name, "rate_la2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la3")}
key={`${index}rate_la3`}
name={[field.name, "rate_la3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la4")}
key={`${index}rate_la4`}
name={[field.name, "rate_la4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mash")}
key={`${index}rate_mash`}
name={[field.name, "rate_mash"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mapa")}
key={`${index}rate_mapa`}
name={[field.name, "rate_mapa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma2s")}
key={`${index}rate_ma2s`}
name={[field.name, "rate_ma2s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma3s")}
key={`${index}rate_ma3s`}
name={[field.name, "rate_ma3s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
{
// <Form.Item
// label={t("jobs.fields.rate_mabl")}
// key={`${index}rate_mabl`}
// name={[field.name, "rate_mabl"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
// <Form.Item
// label={t("jobs.fields.rate_macs")}
// key={`${index}rate_macs`}
// name={[field.name, "rate_macs"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
}
<Form.Item
label={t("jobs.fields.rate_matd")}
key={`${index}rate_matd`}
name={[field.name, "rate_matd"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mahw")}
key={`${index}rate_mahw`}
name={[field.name, "rate_mahw"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.rate_length} />
</LayoutFormRow>
<>
<LayoutFormRow header={t("bodyshop.labels.shoprates")}>
<Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}>
<CurrencyInput min={0} />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.laborrates")}>
<Form.List name={["md_labor_rates"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider={index === 0}>
<Form.Item
label={t("jobs.fields.labor_rate_desc")}
key={`${index}rate_label`}
name={[field.name, "rate_label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laa")}
key={`${index}rate_laa`}
name={[field.name, "rate_laa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lab")}
key={`${index}rate_lab`}
name={[field.name, "rate_lab"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lad")}
key={`${index}rate_lad`}
name={[field.name, "rate_lad"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lae")}
key={`${index}rate_lae`}
name={[field.name, "rate_lae"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laf")}
key={`${index}rate_laf`}
name={[field.name, "rate_laf"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lag")}
key={`${index}rate_lag`}
name={[field.name, "rate_lag"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lam")}
key={`${index}rate_lam`}
name={[field.name, "rate_lam"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lar")}
key={`${index}rate_lar`}
name={[field.name, "rate_lar"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_las")}
key={`${index}rate_las`}
name={[field.name, "rate_las"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la1")}
key={`${index}rate_la1`}
name={[field.name, "rate_la1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la2")}
key={`${index}rate_la2`}
name={[field.name, "rate_la2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la3")}
key={`${index}rate_la3`}
name={[field.name, "rate_la3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la4")}
key={`${index}rate_la4`}
name={[field.name, "rate_la4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mash")}
key={`${index}rate_mash`}
name={[field.name, "rate_mash"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mapa")}
key={`${index}rate_mapa`}
name={[field.name, "rate_mapa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma2s")}
key={`${index}rate_ma2s`}
name={[field.name, "rate_ma2s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma3s")}
key={`${index}rate_ma3s`}
name={[field.name, "rate_ma3s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
{
// <Form.Item
// label={t("jobs.fields.rate_mabl")}
// key={`${index}rate_mabl`}
// name={[field.name, "rate_mabl"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
// <Form.Item
// label={t("jobs.fields.rate_macs")}
// key={`${index}rate_macs`}
// name={[field.name, "rate_macs"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
}
<Form.Item
label={t("jobs.fields.rate_matd")}
key={`${index}rate_matd`}
name={[field.name, "rate_matd"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mahw")}
key={`${index}rate_mahw`}
name={[field.name, "rate_mahw"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Space>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.rate_length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.newlaborrate")}
</Button>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.newlaborrate")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
</div>
</div>
);
}}
</Form.List>
</LayoutFormRow>
</>
);
}

View File

@@ -1,11 +1,10 @@
import { Alert, Form, Switch } from "antd";
import React from "react";
import { Alert, Form, Select, Switch } from "antd";
import { useTranslation } from "react-i18next";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {connect} from "react-redux";
import {createStructuredSelector} from "reselect";
import {selectBodyshop} from "../../redux/user/user.selectors";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -16,17 +15,17 @@ const mapDispatchToProps = () => ({
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay);
// noinspection JSUnusedLocalSymbols
export function ShopInfoIntellipay({bodyshop, form}) {
const {t} = useTranslation();
export function ShopInfoIntellipay({ bodyshop, form }) {
const { t } = useTranslation();
return (
<>
<Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}>
{() => {
const {intellipay_config} = form.getFieldsValue();
const { intellipay_config } = form.getFieldsValue();
if (intellipay_config?.enable_cash_discount)
return <Alert message={t("bodyshop.labels.intellipay_cash_discount")}/>;
return <Alert message={t("bodyshop.labels.intellipay_cash_discount")} />;
}}
</Form.Item>
@@ -36,7 +35,93 @@ export function ShopInfoIntellipay({bodyshop, form}) {
valuePropName="checked"
name={["intellipay_config", "enable_cash_discount"]}
>
<Switch/>
<Switch />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.fields.intellipay_config.payment_type")}>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.visa")}
name={["intellipay_config", "payment_map", "visa"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.mast")}
name={["intellipay_config", "payment_map", "mast"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.amex")}
name={["intellipay_config", "payment_map", "amex"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.disc")}
name={["intellipay_config", "payment_map", "disc"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.dnrs")}
name={["intellipay_config", "payment_map", "dnrs"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.jcb")}
name={["intellipay_config", "payment_map", "jcb"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.intr")}
name={["intellipay_config", "payment_map", "intr"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
</Form.Item>
</LayoutFormRow>
</>

View File

@@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
// SocketProvider.js
import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
@@ -15,10 +16,7 @@ import {
import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
import { SocketContext, INITIAL_NOTIFICATIONS } from "./useSocket.js";
/**
* Socket Provider - Scenario Notifications / Web Socket related items
@@ -216,7 +214,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleNotification = (data) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -336,7 +333,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -378,7 +374,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleSyncAllNotificationsRead = ({ timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -490,11 +485,4 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
);
};
const useSocket = () => {
const context = useContext(SocketContext);
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
if (!context) throw new Error("useSocket must be used within a SocketProvider");
return context;
};
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };
export default SocketProvider;

View File

@@ -0,0 +1,13 @@
import { createContext, useContext } from "react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
const useSocket = () => {
const context = useContext(SocketContext);
if (!context) throw new Error("useSocket must be used within a SocketProvider");
return context;
};
export { SocketContext, INITIAL_NOTIFICATIONS, useSocket };

View File

@@ -1,8 +1,8 @@
import { getAnalytics, logEvent } from "firebase/analytics";
import { initializeApp } from "firebase/app";
import { getAuth, updatePassword, updateProfile } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { getMessaging, getToken, onMessage } from "firebase/messaging";
import { getAnalytics, logEvent } from "@firebase/analytics";
import { initializeApp } from "@firebase/app";
import { getAuth, updatePassword, updateProfile } from "@firebase/auth";
import { getFirestore } from "@firebase/firestore";
import { getMessaging, getToken, onMessage } from "@firebase/messaging";
import { store } from "../redux/store";
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);

View File

@@ -57,6 +57,7 @@ export const QUERY_BODYSHOP = gql`
logo_img_path
md_ro_statuses
md_order_statuses
tours_enabled
md_functionality_toggles
shopname
state
@@ -186,6 +187,7 @@ export const UPDATE_SHOP = gql`
phone
federal_tax_id
id
tours_enabled
insurance_vendor_id
logo_img_path
md_ro_statuses

View File

@@ -509,6 +509,7 @@ export const GET_JOB_BY_PK = gql`
est_ct_ln
est_ea
est_ph1
flat_rate_ats
federal_tax_rate
id
inproduction
@@ -649,6 +650,7 @@ export const GET_JOB_BY_PK = gql`
policy_no
production_vars
rate_ats
rate_ats_flat
rate_la1
rate_la2
rate_la3

View File

@@ -78,6 +78,9 @@ export const QUERY_PARTS_ORDER_OEC = gql`
}
ro_number
clm_no
cieca_stl
cieca_ttl
cieca_pfl
asgn_no
asgn_date
state_tax_rate
@@ -164,6 +167,7 @@ export const QUERY_PARTS_ORDER_OEC = gql`
loss_desc
loss_of_use
loss_type
materials
ownr_addr1
ownr_addr2
ownr_city

View File

@@ -56,7 +56,7 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import UndefinedToNull from "../../utils/undefinedtonull";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -13,7 +13,6 @@ export default connect(mapStateToProps, null)(LandingPage);
export function LandingPage({ currentUser }) {
const navigate = useNavigate();
console.log("Main");
useEffect(() => {
navigate("/manage/jobs");
}, [currentUser, navigate]);

View File

@@ -20,7 +20,6 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
import { requestForToken } from "../../firebase/firebase.utils";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -29,6 +28,7 @@ import WssStatusDisplayComponent from "../../components/wss-status-display/wss-s
import { selectAlerts } from "../../redux/application/application.selectors.js";
import { addAlerts } from "../../redux/application/application.actions.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const JobsPage = lazy(() => import("../jobs/jobs.page"));

View File

@@ -1,14 +1,15 @@
import { useQuery } from "@apollo/client";
import React from "react";
import AlertComponent from "../../components/alert/alert.component";
import JobsDocumentsComponent from "../../components/jobs-documents-gallery/jobs-documents-gallery.component";
import JobsDocumentsContainer from "../../components/jobs-documents-gallery/jobs-documents-gallery.container";
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -19,10 +20,18 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(TemporaryDocsComponent);
export function TemporaryDocsComponent({ bodyshop }) {
const {
treatments: { Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Imgproxy"],
splitKey: bodyshop && bodyshop.imexshopid
});
const { loading, error, data, refetch } = useQuery(QUERY_TEMPORARY_DOCS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: bodyshop.uselocalmediaserver
skip: Imgproxy.treatment === "on"
});
if (loading) return <LoadingSpinner />;
@@ -32,12 +41,14 @@ export function TemporaryDocsComponent({ bodyshop }) {
return <JobsDocumentsLocalGallery job={{ id: "temporary" }} />;
}
return (
<JobsDocumentsComponent
data={data ? data.documents : []}
jobId={null}
billId={null}
refetch={refetch}
ignoreSizeLimit
/>
<>
<JobsDocumentsContainer
documentsList={data ? data.documents : []}
jobId={null}
billId={null}
refetchOverride={refetch}
ignoreSizeLimit
/>
</>
);
}

View File

@@ -118,3 +118,8 @@ export const setCurrentEula = (eula) => ({
export const acceptEula = () => ({
type: UserActionTypes.EULA_ACCEPTED
});
export const setImexShopId = (imexshopid) => ({
type: UserActionTypes.SET_IMEX_SHOP_ID,
payload: imexshopid
});

View File

@@ -121,6 +121,11 @@ const userReducer = (state = INITIAL_STATE, action) => {
};
case UserActionTypes.SET_AUTH_LEVEL:
return { ...state, authLevel: action.payload };
case UserActionTypes.SET_IMEX_SHOP_ID:
return {
...state,
imexshopid: action.payload
};
default:
return state;
}

View File

@@ -2,20 +2,19 @@ import FingerprintJS from "@fingerprintjs/fingerprintjs";
import * as Sentry from "@sentry/browser";
import { notification } from "antd";
import axios from "axios";
import { setUserId, setUserProperties } from "firebase/analytics";
import { setUserId, setUserProperties } from "@firebase/analytics";
import {
checkActionCode,
confirmPasswordReset,
sendPasswordResetEmail,
signInWithEmailAndPassword,
signOut
} from "firebase/auth";
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "firebase/firestore";
import { getToken } from "firebase/messaging";
} from "@firebase/auth";
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
import { getToken } from "@firebase/messaging";
import i18next from "i18next";
import LogRocket from "logrocket";
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
import { factory } from "../../App/App.container";
import {
analytics,
auth,
@@ -35,6 +34,7 @@ import {
sendPasswordResetFailure,
sendPasswordResetSuccess,
setAuthlevel,
setImexShopId,
setInstanceConflict,
setInstanceId,
setLocalFingerprint,
@@ -318,7 +318,8 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
console.log(error);
}
factory.client(payload.imexshopid);
// Dispatch the imexshopid to Redux store
yield put(setImexShopId(payload.imexshopid));
const authRecord = payload.associations.filter((a) => a.useremail.toLowerCase() === userEmail.toLowerCase());
@@ -339,6 +340,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
args: [],
imex: () => {
window.$crisp.push(["set", "user:company", [payload.shopname]]);
window.$crisp.push(["set", "session:segments", [[`region:${payload.region_config}`]]]);
if (authRecord[0] && authRecord[0].user.validemail) {
window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]);
}

View File

@@ -32,6 +32,7 @@ const UserActionTypes = {
CHECK_ACTION_CODE_SUCCESS: "CHECK_ACTION_CODE_SUCCESS",
CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE",
SET_CURRENT_EULA: "SET_CURRENT_EULA",
EULA_ACCEPTED: "EULA_ACCEPTED"
EULA_ACCEPTED: "EULA_ACCEPTED",
SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID"
};
export default UserActionTypes;

View File

@@ -1,4 +0,0 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });

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

@@ -1,139 +0,0 @@
export const MockBodyshop = {
address1: "123 Fake St",
address2: "Unit #100",
city: "Vancouver",
country: "Canada",
created_at: "2019-12-10T20:03:06.420853+00:00",
email: "snaptsoft@gmail.com",
federal_tax_id: "GST10150492",
id: "52b7357c-0edd-4c95-85c3-dfdbcdfad9ac",
insurance_vendor_id: "F123456",
logo_img_path: "https://www.snapt.ca/assets/logo-placeholder.png",
md_ro_statuses: {
statuses: [
"Open",
"Scheduled",
"Arrived",
"Repair Plan",
"Parts",
"Body",
"Prep",
"Paint",
"Reassembly",
"Sublet",
"Detail",
"Completed",
"Delivered",
"Invoiced",
"Exported"
],
open_statuses: ["Open", "Scheduled", "Arrived", "Repair Plan", "Parts", "Body", "Prep", "Paint"],
default_arrived: "Arrived",
default_exported: "Exported",
default_imported: "Open",
default_invoiced: "Invoiced",
default_completed: "Completed",
default_delivered: "Delivered",
default_scheduled: "Scheduled"
},
md_order_statuses: {
statuses: ["Ordered", "Received", "Canceled", "Backordered"],
default_bo: "Backordered",
default_ordered: "Ordered",
default_canceled: "Canceled",
default_received: "Received"
},
shopname: "Testing Collision",
state: "BC",
state_tax_id: "PST1000-2991",
updated_at: "2020-03-23T22:06:03.509544+00:00",
zip_post: "V6B 1M9",
region_config: "CA_BC",
md_responsibility_centers: {
costs: [
"Aftermarket",
"ATS",
"Body",
"Detail",
"Daignostic",
"Electrical",
"Chrome",
"Frame",
"Mechanical",
"Refinish",
"Structural",
"Existing",
"Glass",
"LKQ",
"OEM",
"OEM Partial",
"Re-cored",
"Remanufactured",
"Other",
"Sublet",
"Towing"
],
profits: [
"Aftermarket",
"ATS",
"Body",
"Detail",
"Daignostic",
"Electrical",
"Chrome",
"Frame",
"Mechanical",
"Refinish",
"Structural",
"Existing",
"Glass",
"LKQ",
"OEM",
"OEM Partial",
"Re-cored",
"Remanufactured",
"Other",
"Sublet",
"Towing"
],
defaults: {
ATS: "ATS",
LAB: "Body",
LAD: "Diagnostic",
LAE: "Electrical",
LAF: "Frame",
LAG: "Glass",
LAM: "Mechanical",
LAR: "Refinish",
LAS: "Structural",
LAU: "Detail",
PAA: "Aftermarket",
PAC: "Chrome",
PAL: "LKQ",
PAM: "Remanufactured",
PAN: "OEM",
PAO: "Other",
PAP: "OEM Partial",
PAR: "16",
TOW: "Towing"
}
},
employees: [
{
id: "075b744c-8919-49ca-abb2-ccd51040326d",
first_name: "Patrick",
last_name: "BODY123",
employee_number: "101",
cost_center: "Body",
__typename: "employees"
},
{
id: "8cc787d3-1cfe-49d3-8a15-8469cd5c2e41",
first_name: "Patrick",
last_name: "Painter",
employee_number: "10211",
cost_center: "REFINISH",
__typename: "employees"
}
]
};