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,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>
</>