diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel
index ba556935d..5e8b34dc7 100644
--- a/bodyshop_translations.babel
+++ b/bodyshop_translations.babel
@@ -12169,6 +12169,27 @@
+
+ cost
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+ es-MX
+ false
+
+
+ fr-CA
+ false
+
+
+
create
@@ -12384,6 +12405,27 @@
+
+ estimated
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+ es-MX
+ false
+
+
+ fr-CA
+ false
+
+
+
existing_jobs
false
@@ -12426,6 +12468,48 @@
+
+ gpdollars
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+ es-MX
+ false
+
+
+ fr-CA
+ false
+
+
+
+
+ gppercent
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+ es-MX
+ false
+
+
+ fr-CA
+ false
+
+
+
hrs_claimed
false
@@ -12510,6 +12594,27 @@
+
+ jobcosting
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+ es-MX
+ false
+
+
+ fr-CA
+ false
+
+
+
laborallocations
false
diff --git a/client/src/App/App.container.jsx b/client/src/App/App.container.jsx
index 0733204cd..f63a7586f 100644
--- a/client/src/App/App.container.jsx
+++ b/client/src/App/App.container.jsx
@@ -108,12 +108,17 @@ if (process.env.NODE_ENV === "development") {
middlewares.push(retryLink.concat(errorLink.concat(authLink.concat(link))));
-const cache = new InMemoryCache();
+const cache = new InMemoryCache({});
export const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache,
connectToDevTools: process.env.NODE_ENV !== "production",
+ defaultOptions: {
+ watchQuery: {
+ fetchPolicy: "cache-and-network",
+ },
+ },
});
export default function AppContainer() {
diff --git a/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx b/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx
index be5c1ed23..21324666d 100644
--- a/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx
+++ b/client/src/components/form-date-time-picker/form-date-time-picker.component.jsx
@@ -1,11 +1,11 @@
-import React from "react";
+import React, { forwardRef } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { useTranslation } from "react-i18next";
//To be used as a form element only.
-const DateTimePicker = ({ value, onChange, onBlur }) => {
+const DateTimePicker = ({ value, onChange, onBlur }, ref) => {
const { t } = useTranslation();
const handleChange = (value) => {
@@ -30,4 +30,4 @@ const DateTimePicker = ({ value, onChange, onBlur }) => {
);
};
-export default DateTimePicker;
+export default forwardRef(DateTimePicker);
diff --git a/client/src/components/job-costing-modal/job-costing-modal.component.jsx b/client/src/components/job-costing-modal/job-costing-modal.component.jsx
new file mode 100644
index 000000000..8b36d8433
--- /dev/null
+++ b/client/src/components/job-costing-modal/job-costing-modal.component.jsx
@@ -0,0 +1,21 @@
+import React from "react";
+import JobCostingPartsTable from "../job-costing-parts-table/job-costing-parts-table.component";
+import { Row, Col } from "antd";
+
+const colSpan = {
+ md: { span: 24 },
+ lg: { span: 12 },
+};
+
+export default function JobCostingModalComponent({ job }) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/job-costing-modal/job-costing-modal.container.jsx b/client/src/components/job-costing-modal/job-costing-modal.container.jsx
new file mode 100644
index 000000000..51d1a0851
--- /dev/null
+++ b/client/src/components/job-costing-modal/job-costing-modal.container.jsx
@@ -0,0 +1,57 @@
+import { useQuery } from "@apollo/react-hooks";
+import { Modal } from "antd";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { connect } from "react-redux";
+import { createStructuredSelector } from "reselect";
+import { QUERY_JOB_COSTING_DETAILS } from "../../graphql/jobs.queries";
+import { toggleModalVisible } from "../../redux/modals/modals.actions";
+import { selectJobCosting } from "../../redux/modals/modals.selectors";
+import AlertComponent from "../alert/alert.component";
+import LoadingSpinner from "../loading-spinner/loading-spinner.component";
+import JobCostingModalComponent from "./job-costing-modal.component";
+
+const mapStateToProps = createStructuredSelector({
+ jobCostingModal: selectJobCosting,
+});
+
+const mapDispatchToProps = (dispatch) => ({
+ toggleModalVisible: () => dispatch(toggleModalVisible("jobCosting")),
+});
+
+export function JobCostingModalContainer({
+ jobCostingModal,
+ toggleModalVisible,
+}) {
+ const { t } = useTranslation();
+
+ const { visible, context, actions } = jobCostingModal;
+ const { jobId } = context;
+
+ const { loading, error, data } = useQuery(QUERY_JOB_COSTING_DETAILS, {
+ variables: { id: jobId },
+ skip: !jobId,
+ });
+
+ return (
+ toggleModalVisible()}
+ width='90%'
+ destroyOnClose
+ forceRender>
+ {error ? : null}
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(JobCostingModalContainer);
diff --git a/client/src/components/job-costing-parts-table/job-costing-parts-table.component.jsx b/client/src/components/job-costing-parts-table/job-costing-parts-table.component.jsx
new file mode 100644
index 000000000..fb7a4c899
--- /dev/null
+++ b/client/src/components/job-costing-parts-table/job-costing-parts-table.component.jsx
@@ -0,0 +1,120 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import "./job-costing-parts-table.styles.scss";
+import { connect } from "react-redux";
+import { createStructuredSelector } from "reselect";
+import { selectBodyshop } from "../../redux/user/user.selectors";
+import Dinero from "dinero.js";
+
+const mapStateToProps = createStructuredSelector({
+ bodyshop: selectBodyshop,
+});
+const mapDispatchToProps = (dispatch) => ({
+ //setUserLanguage: language => dispatch(setUserLanguage(language))
+});
+
+export function JobCostingPartsTable({ bodyshop, job }) {
+ console.log("JobCostingPartsTable -> job", job);
+ const { t } = useTranslation();
+ const defaultProfits = bodyshop.md_responsibility_centers.defaults.profits;
+ console.log("JobCostingPartsTable -> defaultProfits", defaultProfits);
+ const defaultCosts = bodyshop.md_responsibility_centers.defaults.costs;
+
+ //Need to get the sums of the job lines by cost center.
+ const jobLineTotalsByProfitCenter = job.joblines.reduce(
+ (acc, val) => {
+ const laborProfitCenter = defaultProfits[val.mod_lbr_ty];
+ if (!!!laborProfitCenter)
+ console.log(
+ "Unknown cost/profit center mapping for labor.",
+ val.mod_lbr_ty
+ );
+
+ const rateName = `rate_${(val.mod_lbr_ty || "").toLowerCase()}`;
+ const laborAmount = Dinero({
+ amount: Math.round((job[rateName] || 0) * 100),
+ }).multiply(val.mod_lb_hrs || 0);
+ if (!!!acc.labor[laborProfitCenter])
+ acc.labor[laborProfitCenter] = Dinero();
+ acc.labor[laborProfitCenter] = acc.labor[laborProfitCenter].add(
+ laborAmount
+ );
+
+ const partsProfitCenter = defaultProfits[val.part_type];
+ if (!!!partsProfitCenter)
+ console.log(
+ "Unknown cost/profit center mapping for parts.",
+ val.part_type
+ );
+ const partsAmount = Dinero({
+ amount: Math.round((val.act_price || 0) * 100),
+ }).multiply(val.part_qty || 1);
+ if (!!!acc.parts[partsProfitCenter])
+ acc.parts[partsProfitCenter] = Dinero();
+ acc.parts[partsProfitCenter] = acc.parts[partsProfitCenter].add(
+ partsAmount
+ );
+
+ return acc;
+ },
+ { parts: {}, labor: {} }
+ );
+
+ const invoiceTotalsByProfitCenter = job.invoices.reduce(
+ (inv_acc, inv_val) => {
+ //At the invoice level.
+ const t = inv_val.invoicelines.map((line_val) => {
+ //At the invoice line level.
+ console.log("JobCostingPartsTable -> line_val", line_val);
+ if (!!!inv_acc[line_val.cost_center])
+ inv_acc[line_val.cost_center] = Dinero();
+
+ inv_acc[line_val.cost_center] = inv_acc[line_val.cost_center].add(
+ Dinero({
+ amount: Math.round((line_val.actual_cost || 0) * 100),
+ })
+ .multiply(line_val.quantity)
+ .multiply(inv_val.is_credit_memo ? -1 : 1)
+ );
+
+ return null;
+ });
+
+ return inv_acc;
+ },
+ {}
+ );
+ console.log(
+ "JobCostingPartsTable -> invoiceTotalsByProfitCenter",
+ invoiceTotalsByProfitCenter
+ );
+
+ return (
+
+
+
+
+ | {t("bodyshop.fields.responsibilitycenter")} |
+ {t("jobs.labels.estimated")} |
+ {t("jobs.labels.cost")} |
+ {t("jobs.labels.gpdollars")} |
+ {t("jobs.labels.gppercent")} |
+
+
+
+
+ | Cost Center |
+ $12345.45 |
+ $123.45 |
+ $1234.00 |
+ 50.58% |
+
+
+
+
+ );
+}
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(JobCostingPartsTable);
diff --git a/client/src/components/job-costing-parts-table/job-costing-parts-table.styles.scss b/client/src/components/job-costing-parts-table/job-costing-parts-table.styles.scss
new file mode 100644
index 000000000..b04932766
--- /dev/null
+++ b/client/src/components/job-costing-parts-table/job-costing-parts-table.styles.scss
@@ -0,0 +1,9 @@
+.job-costing-parts-table-container {
+ display: block;
+ width: 100%;
+}
+
+.job-costing-parts-table {
+ border: black;
+ width: 100%;
+}
diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx
index 734b5e9ac..17dbddbb7 100644
--- a/client/src/components/job-detail-lines/job-lines.component.jsx
+++ b/client/src/components/job-detail-lines/job-lines.component.jsx
@@ -143,11 +143,20 @@ export function JobLinesComponent({
),
},
+ {
+ title: t("joblines.fields.mod_lbr_ty"),
+ dataIndex: "mod_lbr_ty",
+ key: "mod_lbr_ty",
+
+ sorter: (a, b) => alphaSort(a.mod_lbr_ty, b.mod_lbr_ty),
+ sortOrder:
+ state.sortedInfo.columnKey === "mod_lbr_ty" && state.sortedInfo.order,
+ },
{
title: t("joblines.fields.mod_lb_hrs"),
dataIndex: "mod_lb_hrs",
key: "mod_lb_hrs",
- responsive: ["lg"],
+
sorter: (a, b) => a.mod_lb_hrs - b.mod_lb_hrs,
sortOrder:
state.sortedInfo.columnKey === "mod_lb_hrs" && state.sortedInfo.order,
diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
index 136d32ae5..10bb44fc2 100644
--- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
+++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
@@ -24,6 +24,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(setModalContext({ context: context, modal: "invoiceEnter" })),
setPaymentContext: (context) =>
dispatch(setModalContext({ context: context, modal: "payment" })),
+ setJobCostingContext: (context) =>
+ dispatch(setModalContext({ context: context, modal: "jobCosting" })),
});
export function JobsDetailHeaderActions({
@@ -33,6 +35,7 @@ export function JobsDetailHeaderActions({
setScheduleContext,
setInvoiceEnterContext,
setPaymentContext,
+ setJobCostingContext,
}) {
const { t } = useTranslation();
const client = useApolloClient();
@@ -123,6 +126,20 @@ export function JobsDetailHeaderActions({
+ {
+ logImEXEvent("job_header_job_costing");
+
+ setJobCostingContext({
+ actions: { refetch: refetch },
+ context: {
+ jobId: job.id,
+ },
+ });
+ }}>
+ {t("jobs.labels.jobcosting")}
+
);
return (
diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js
index fe87986e8..a911a53c0 100644
--- a/client/src/graphql/jobs.queries.js
+++ b/client/src/graphql/jobs.queries.js
@@ -130,6 +130,96 @@ export const SUBSCRIPTION_JOBS_IN_PRODUCTION = gql`
}
`;
+export const QUERY_JOB_COSTING_DETAILS = gql`
+ query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
+ jobs_by_pk(id: $id) {
+ est_number
+ ro_number
+ clm_total
+ id
+ ded_amt
+ ded_status
+ depreciation_taxes
+ federal_tax_payable
+ other_amount_payable
+ towing_payable
+ storage_payable
+ adjustment_bottom_line
+ federal_tax_rate
+ state_tax_rate
+ local_tax_rate
+ tax_tow_rt
+ tax_str_rt
+ tax_paint_mat_rt
+ tax_sub_rt
+ tax_lbr_rt
+ tax_levies_rt
+ parts_tax_rates
+ job_totals
+ labor_rate_desc
+ rate_atp
+ 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
+ actual_in
+ status
+ joblines {
+ id
+ unq_seq
+ line_ind
+ tax_part
+ line_desc
+ prt_dsmk_p
+ prt_dsmk_m
+ part_type
+ oem_partno
+ db_price
+ act_price
+ part_qty
+ mod_lbr_ty
+ db_hrs
+ mod_lb_hrs
+ lbr_op
+ lbr_amt
+ op_code_desc
+ }
+ invoices {
+ id
+ federal_tax_rate
+ local_tax_rate
+ state_tax_rate
+ is_credit_memo
+ invoicelines {
+ actual_cost
+ cost_center
+ id
+ quantity
+ }
+ }
+ }
+ }
+`;
+
export const GET_JOB_BY_PK = gql`
query GET_JOB_BY_PK($id: uuid!) {
jobs_by_pk(id: $id) {
diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx
index 69df941fa..95a3d05ab 100644
--- a/client/src/pages/manage/manage.page.component.jsx
+++ b/client/src/pages/manage/manage.page.component.jsx
@@ -78,6 +78,9 @@ const InvoicesListPage = lazy(() =>
import("../invoices/invoices.page.container")
);
+const JobCostingModal = lazy(() =>
+ import("../../components/job-costing-modal/job-costing-modal.container")
+);
const EnterInvoiceModalContainer = lazy(() =>
import("../../components/invoice-enter-modal/invoice-enter-modal.container")
);
@@ -162,6 +165,7 @@ export function Manage({ match, conflict }) {
}>
+
diff --git a/client/src/redux/modals/modals.reducer.js b/client/src/redux/modals/modals.reducer.js
index bde24c656..b1ae26315 100644
--- a/client/src/redux/modals/modals.reducer.js
+++ b/client/src/redux/modals/modals.reducer.js
@@ -19,6 +19,7 @@ const INITIAL_STATE = {
printCenter: { ...baseModal },
reconciliation: { ...baseModal },
payment: { ...baseModal },
+ jobCosting: { ...baseModal },
};
const modalsReducer = (state = INITIAL_STATE, action) => {
diff --git a/client/src/redux/modals/modals.selectors.js b/client/src/redux/modals/modals.selectors.js
index a28a6f48d..0697a8145 100644
--- a/client/src/redux/modals/modals.selectors.js
+++ b/client/src/redux/modals/modals.selectors.js
@@ -50,3 +50,8 @@ export const selectPayment = createSelector(
[selectModals],
(modals) => modals.payment
);
+
+export const selectJobCosting = createSelector(
+ [selectModals],
+ (modals) => modals.jobCosting
+);
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index 61832f502..ff03fce61 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -753,6 +753,7 @@
"totals": "Totals",
"vehicle": "Vehicle"
},
+ "cost": "Cost",
"create": {
"jobinfo": "Job Info",
"newowner": "Create a new Owner instead. ",
@@ -765,12 +766,16 @@
"documents": "Documents",
"duplicateconfirm": "Are you sure you want to duplicate this job? Some elements of this job will not be duplicated.",
"employeeassignments": "Employee Assignments",
+ "estimated": "Estimated",
"existing_jobs": "Existing Jobs",
"federal_tax_amt": "Federal Taxes",
+ "gpdollars": "$ G.P.",
+ "gppercent": "% G.P.",
"hrs_claimed": "Hours Claimed",
"hrs_total": "Hours Total",
"inproduction": "In Production",
"job": "Job Details",
+ "jobcosting": "Job Costing",
"laborallocations": "Labor Allocations",
"lines": "Estimate Lines",
"local_tax_amt": "Local Taxes",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 461f93938..575ec2a3a 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -753,6 +753,7 @@
"totals": "Totales",
"vehicle": "Vehículo"
},
+ "cost": "",
"create": {
"jobinfo": "",
"newowner": "",
@@ -765,12 +766,16 @@
"documents": "documentos",
"duplicateconfirm": "",
"employeeassignments": "",
+ "estimated": "",
"existing_jobs": "Empleos existentes",
"federal_tax_amt": "",
+ "gpdollars": "",
+ "gppercent": "",
"hrs_claimed": "",
"hrs_total": "",
"inproduction": "",
"job": "",
+ "jobcosting": "",
"laborallocations": "",
"lines": "Líneas estimadas",
"local_tax_amt": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index bc88efce5..9edc1a7b2 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -753,6 +753,7 @@
"totals": "Totaux",
"vehicle": "Véhicule"
},
+ "cost": "",
"create": {
"jobinfo": "",
"newowner": "",
@@ -765,12 +766,16 @@
"documents": "Les documents",
"duplicateconfirm": "",
"employeeassignments": "",
+ "estimated": "",
"existing_jobs": "Emplois existants",
"federal_tax_amt": "",
+ "gpdollars": "",
+ "gppercent": "",
"hrs_claimed": "",
"hrs_total": "",
"inproduction": "",
"job": "",
+ "jobcosting": "",
"laborallocations": "",
"lines": "Estimer les lignes",
"local_tax_amt": "",