diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 5e8b34dc7..e74d64730 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -12405,27 +12405,6 @@ - - estimated - false - - - - - - en-US - false - - - es-MX - false - - - fr-CA - false - - - existing_jobs false @@ -12930,6 +12909,48 @@ + + sale_labor + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + sale_parts + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + state_tax_amt false @@ -13014,6 +13035,27 @@ + + total_cost + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + total_repairs false @@ -13035,6 +13077,27 @@ + + total_sales + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + totals false 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 index 8b36d8433..cd3f16930 100644 --- a/client/src/components/job-costing-modal/job-costing-modal.component.jsx +++ b/client/src/components/job-costing-modal/job-costing-modal.component.jsx @@ -1,21 +1,158 @@ +import Dinero from "dinero.js"; import React from "react"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; import JobCostingPartsTable from "../job-costing-parts-table/job-costing-parts-table.component"; -import { Row, Col } from "antd"; +import JobCostingStatistics from "../job-costing-statistics/job-costing-statistics.component"; -const colSpan = { - md: { span: 24 }, - lg: { span: 12 }, -}; +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); + +export function JobCostingModalComponent({ bodyshop, job }) { + const defaultProfits = bodyshop.md_responsibility_centers.defaults.profits; + // const defaultCosts = bodyshop.md_responsibility_centers.defaults.costs; + + 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. + 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; + }, + {} + ); + + const summaryData = { + totalLaborSales: Dinero({ amount: 0 }), + totalPartsSales: Dinero({ amount: 0 }), + totalSales: Dinero({ amount: 0 }), + totalCost: Dinero({ amount: 0 }), + gpdollars: Dinero({ amount: 0 }), + gppercent: null, + gppercentFormatted: null, + }; + + const costCenterData = Object.keys(defaultProfits).map((key, idx) => { + const ccVal = defaultProfits[key]; + const sale_labor = + jobLineTotalsByProfitCenter.labor[ccVal] || Dinero({ amount: 0 }); + const sale_parts = + jobLineTotalsByProfitCenter.parts[ccVal] || Dinero({ amount: 0 }); + const cost = invoiceTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }); + const totalSales = sale_labor.add(sale_parts); + const gpdollars = totalSales.subtract(cost); + const gppercent = ( + (gpdollars.getAmount() / totalSales.getAmount()) * + 100 + ).toFixed(2); + + let gppercentFormatted; + if (isNaN(gppercent)) gppercentFormatted = "0%"; + else if (!isFinite(gppercent)) gppercentFormatted = "-∞"; + else { + gppercentFormatted = `${gppercent}%`; + } + //Push summary data to avoid extra loop. + summaryData.totalLaborSales = summaryData.totalLaborSales.add(sale_labor); + summaryData.totalPartsSales = summaryData.totalPartsSales.add(sale_parts); + summaryData.totalSales = summaryData.totalSales + .add(sale_labor) + .add(sale_parts); + summaryData.totalCost = summaryData.totalCost.add(cost); + + return { + id: idx, + cost_center: ccVal, + sale_labor: sale_labor && sale_labor.toFormat(), + sale_parts: sale_parts && sale_parts.toFormat(), + cost: cost && cost.toFormat(), + gpdollars: gpdollars.toFormat(), + gppercent: gppercentFormatted, + }; + }); + + //Final summary data massaging. + summaryData.gpdollars = summaryData.totalSales.subtract( + summaryData.totalCost + ); + summaryData.gppercent = ( + (summaryData.gpdollars.getAmount() / summaryData.totalSales.getAmount()) * + 100 + ).toFixed(2); + if (isNaN(summaryData.gppercent)) summaryData.gppercentFormatted = 0; + else if (!isFinite(summaryData.gppercent)) + summaryData.gppercentFormatted = "-∞"; + else { + summaryData.gppercentFormatted = summaryData.gppercent; + } + + console.log("JobCostingModalComponent -> summaryData", summaryData); -export default function JobCostingModalComponent({ job }) { return (
- - - - - - + +
); } +export default connect( + mapStateToProps, + mapDispatchToProps +)(JobCostingModalComponent); 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 index 51d1a0851..c7766cb6b 100644 --- a/client/src/components/job-costing-modal/job-costing-modal.container.jsx +++ b/client/src/components/job-costing-modal/job-costing-modal.container.jsx @@ -25,7 +25,7 @@ export function JobCostingModalContainer({ }) { const { t } = useTranslation(); - const { visible, context, actions } = jobCostingModal; + const { visible, context } = jobCostingModal; const { jobId } = context; const { loading, error, data } = useQuery(QUERY_JOB_COSTING_DETAILS, { @@ -38,10 +38,11 @@ export function JobCostingModalContainer({ visible={visible} title={t("jobs.labels.jobcosting")} onCancel={() => toggleModalVisible()} - width='90%' + width="90%" destroyOnClose - forceRender> - {error ? : null} + forceRender + > + {error ? : null} {loading ? ( ) : ( 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 index fb7a4c899..473a79eee 100644 --- 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 @@ -1,120 +1,81 @@ -import React from "react"; +import { Table } from "antd"; +import React, { useState } 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"; +import { alphaSort } from "../../utils/sorters"; -const mapStateToProps = createStructuredSelector({ - bodyshop: selectBodyshop, -}); -const mapDispatchToProps = (dispatch) => ({ - //setUserLanguage: language => dispatch(setUserLanguage(language)) -}); +export default function JobCostingPartsTable({ job, data }) { + const [state, setState] = useState({ + sortedInfo: {}, + }); + + const handleTableChange = (pagination, filters, sorter) => { + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); + }; -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; + const columns = [ + { + title: t("bodyshop.fields.responsibilitycenter"), + dataIndex: "cost_center", + key: "cost_center", + sorter: (a, b) => alphaSort(a.cost_center, b.cost_center), + sortOrder: + state.sortedInfo.columnKey === "cost_center" && state.sortedInfo.order, }, - { 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; + { + title: t("jobs.labels.sale_labor"), + dataIndex: "sale_labor", + key: "sale_labor", + sorter: (a, b) => alphaSort(a.sale_labor, b.sale_labor), + sortOrder: + state.sortedInfo.columnKey === "sale_labor" && state.sortedInfo.order, }, - {} - ); - console.log( - "JobCostingPartsTable -> invoiceTotalsByProfitCenter", - invoiceTotalsByProfitCenter - ); + { + title: t("jobs.labels.sale_parts"), + dataIndex: "sale_parts", + key: "sale_parts", + sorter: (a, b) => alphaSort(a.sale_parts, b.sale_parts), + sortOrder: + state.sortedInfo.columnKey === "sale_parts" && state.sortedInfo.order, + }, + { + title: t("jobs.labels.cost"), + dataIndex: "cost", + key: "cost", + sorter: (a, b) => a.cost - b.cost, + sortOrder: + state.sortedInfo.columnKey === "cost" && state.sortedInfo.order, + }, + { + title: t("jobs.labels.gpdollars"), + dataIndex: "gpdollars", + key: "gpdollars", + sorter: (a, b) => a.gpdollars - b.gpdollars, + sortOrder: + state.sortedInfo.columnKey === "gpdollars" && state.sortedInfo.order, + }, + { + title: t("jobs.labels.gppercent"), + dataIndex: "gppercent", + key: "gppercent", + sorter: (a, b) => a.gppercent - b.gppercent, + sortOrder: + state.sortedInfo.columnKey === "gppercent" && state.sortedInfo.order, + }, + ]; 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 deleted file mode 100644 index b04932766..000000000 --- a/client/src/components/job-costing-parts-table/job-costing-parts-table.styles.scss +++ /dev/null @@ -1,9 +0,0 @@ -.job-costing-parts-table-container { - display: block; - width: 100%; -} - -.job-costing-parts-table { - border: black; - width: 100%; -} diff --git a/client/src/components/job-costing-statistics/job-costing-statistics.component.jsx b/client/src/components/job-costing-statistics/job-costing-statistics.component.jsx new file mode 100644 index 000000000..fd7f2fffe --- /dev/null +++ b/client/src/components/job-costing-statistics/job-costing-statistics.component.jsx @@ -0,0 +1,39 @@ +import { Statistic } from "antd"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +export default function JobCostingStatistics({ job, summaryData }) { + const { t } = useTranslation(); + + return ( +
+
+ + + + + + +
+
+ ); +} diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index ff03fce61..c4f9545ed 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -766,7 +766,6 @@ "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.", @@ -791,11 +790,15 @@ "rates": "Rates", "rates_subtotal": "Rates Subtotal", "reconciliationheader": "Parts & Sublet Reconciliation", + "sale_labor": "Sales - Labor", + "sale_parts": "Sales - Parts", "state_tax_amt": "State/Provincial Taxes", "subletstotal": "Sublets Total", "subtotal": "Subtotal", "suspense": "Suspense", + "total_cost": "Total Cost", "total_repairs": "Total Repairs", + "total_sales": "Total Sales", "totals": "Totals", "vehicle_info": "Vehicle", "viewallocations": "View Allocations" diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 575ec2a3a..44a2d68a1 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -766,7 +766,6 @@ "documents": "documentos", "duplicateconfirm": "", "employeeassignments": "", - "estimated": "", "existing_jobs": "Empleos existentes", "federal_tax_amt": "", "gpdollars": "", @@ -791,11 +790,15 @@ "rates": "Tarifas", "rates_subtotal": "", "reconciliationheader": "", + "sale_labor": "", + "sale_parts": "", "state_tax_amt": "", "subletstotal": "", "subtotal": "", "suspense": "", + "total_cost": "", "total_repairs": "", + "total_sales": "", "totals": "", "vehicle_info": "Vehículo", "viewallocations": "" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 9edc1a7b2..725eb7fe3 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -766,7 +766,6 @@ "documents": "Les documents", "duplicateconfirm": "", "employeeassignments": "", - "estimated": "", "existing_jobs": "Emplois existants", "federal_tax_amt": "", "gpdollars": "", @@ -791,11 +790,15 @@ "rates": "Les taux", "rates_subtotal": "", "reconciliationheader": "", + "sale_labor": "", + "sale_parts": "", "state_tax_amt": "", "subletstotal": "", "subtotal": "", "suspense": "", + "total_cost": "", "total_repairs": "", + "total_sales": "", "totals": "", "vehicle_info": "Véhicule", "viewallocations": ""