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.0050.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": "",