From 4779964449c17659522cf9494035ce314c139291 Mon Sep 17 00:00:00 2001 From: Patrick Fic <> Date: Tue, 6 Apr 2021 13:54:47 -0700 Subject: [PATCH] IO-836 Server Side Job Costing --- .../job-costing-modal.component.jsx | 190 +------------- .../job-costing-modal.container.jsx | 37 ++- .../job-costing-modal.pie.component.jsx | 14 +- .../job-costing-parts-table.component.jsx | 34 ++- .../job-costing-statistics.component.jsx | 18 +- server.js | 2 + server/graphql-client/queries.js | 195 ++++++++++++++ server/job/job-costing.js | 241 ++++++++++++++++++ server/job/job.js | 2 + 9 files changed, 502 insertions(+), 231 deletions(-) create mode 100644 server/job/job-costing.js 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 24d277ca2..499f0d253 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,194 +1,20 @@ import { Typography } from "antd"; -import Dinero from "dinero.js"; 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 JobCostingPartsTable from "../job-costing-parts-table/job-costing-parts-table.component"; import JobCostingStatistics from "../job-costing-statistics/job-costing-statistics.component"; import JobCostingPie from "./job-costing-modal.pie.component"; -import _ from "lodash"; -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 allProfitCenters = _.union( - bodyshop.md_responsibility_centers.profits.map((p) => p.name), - bodyshop.md_responsibility_centers.costs.map((p) => p.name) - ); - - // const defaultCosts = bodyshop.md_responsibility_centers.defaults.costs; +export default function JobCostingModalComponent({ + summaryData, + costCenterData, +}) { const { t } = useTranslation(); - const jobLineTotalsByProfitCenter = - job && - job.joblines.reduce( - (acc, val) => { - const laborProfitCenter = defaultProfits[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 billTotalsByProfitCenter = job.bills.reduce((bill_acc, bill_val) => { - //At the invoice level. - bill_val.billlines.map((line_val) => { - //At the invoice line level. - //console.log("JobCostingPartsTable -> line_val", line_val); - if (!!!bill_acc[line_val.cost_center]) - bill_acc[line_val.cost_center] = Dinero(); - - bill_acc[line_val.cost_center] = bill_acc[line_val.cost_center].add( - Dinero({ - amount: Math.round((line_val.actual_cost || 0) * 100), - }) - .multiply(line_val.quantity) - .multiply(bill_val.is_credit_memo ? -1 : 1) - ); - - return null; - }); - return bill_acc; - }, {}); - - const ticketTotalsByProfitCenter = job.timetickets.reduce( - (ticket_acc, ticket_val) => { - //At the invoice level. - if (!!!ticket_acc[ticket_val.cost_center]) - ticket_acc[ticket_val.cost_center] = Dinero(); - - ticket_acc[ticket_val.cost_center] = ticket_acc[ - ticket_val.cost_center - ].add( - Dinero({ - amount: Math.round((ticket_val.rate || 0) * 100), - }).multiply(ticket_val.actualhrs || ticket_val.productivehrs || 0) - ); - - return ticket_acc; - }, - {} - ); - - const summaryData = { - totalLaborSales: Dinero({ amount: 0 }), - totalPartsSales: Dinero({ amount: 0 }), - totalSales: Dinero({ amount: 0 }), - totalLaborCost: Dinero({ amount: 0 }), - totalPartsCost: Dinero({ amount: 0 }), - totalCost: Dinero({ amount: 0 }), - gpdollars: Dinero({ amount: 0 }), - gppercent: null, - gppercentFormatted: null, - }; - - const costCenterData = allProfitCenters.map((key, idx) => { - const ccVal = key; // defaultProfits[key]; - const sale_labor = - jobLineTotalsByProfitCenter.labor[ccVal] || Dinero({ amount: 0 }); - const sale_parts = - jobLineTotalsByProfitCenter.parts[ccVal] || Dinero({ amount: 0 }); - - const cost_labor = - ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }); - const cost_parts = billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }); - - const costs = ( - billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }) - ).add(ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 })); - const totalSales = sale_labor.add(sale_parts); - const gpdollars = totalSales.subtract(costs); - 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.totalLaborCost = summaryData.totalLaborCost.add(cost_labor); - summaryData.totalPartsCost = summaryData.totalPartsCost.add(cost_parts); - summaryData.totalCost = summaryData.totalCost.add(costs); - - return { - id: idx, - cost_center: ccVal, - sale_labor: sale_labor && sale_labor.toFormat(), - sale_parts: sale_parts && sale_parts.toFormat(), - sales: sale_labor.add(sale_parts).toFormat(), - sales_dinero: sale_labor.add(sale_parts), - cost_parts: cost_parts && cost_parts.toFormat(), - cost_labor: cost_labor && cost_labor.toFormat(), - costs: cost_parts.add(cost_labor).toFormat(), - costs_dinero: cost_parts.add(cost_labor), - 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; - } return (
- - + +
@@ -204,7 +30,3 @@ export function JobCostingModalComponent({ bodyshop, job }) {
); } -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 ace532ba4..8a1c1dc40 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 @@ -1,13 +1,11 @@ -import { useQuery } from "@apollo/client"; import { Modal } from "antd"; -import React from "react"; +import axios from "axios"; +import React, { useEffect, useState } 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"; @@ -24,14 +22,25 @@ export function JobCostingModalContainer({ toggleModalVisible, }) { const { t } = useTranslation(); - + const [costingData, setCostingData] = useState(null); const { visible, context } = jobCostingModal; const { jobId } = context; - const { loading, error, data } = useQuery(QUERY_JOB_COSTING_DETAILS, { - variables: { id: jobId }, - skip: !jobId, - }); + // const { loading, error, data } = useQuery(QUERY_JOB_COSTING_DETAILS, { + // variables: { id: jobId }, + // skip: !jobId, + // }); + + useEffect(() => { + async function getData() { + if (jobId && visible) { + const { data } = await axios.post("/job/costing", { jobid: jobId }); + console.log(data); + setCostingData(data); + } + } + getData(); + }, [jobId, visible]); return ( - {error ? : null} - {loading ? ( - + {!costingData ? ( + ) : ( - + )} ); diff --git a/client/src/components/job-costing-modal/job-costing-modal.pie.component.jsx b/client/src/components/job-costing-modal/job-costing-modal.pie.component.jsx index d4dbe2c30..8b483d113 100644 --- a/client/src/components/job-costing-modal/job-costing-modal.pie.component.jsx +++ b/client/src/components/job-costing-modal/job-costing-modal.pie.component.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from "react"; import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; - +import Dinero from "dinero.js"; export default function JobCostingPieComponent({ type = "sales", costCenterData, @@ -11,8 +11,8 @@ export default function JobCostingPieComponent({ return data.reduce((acc, i) => { const value = type === "sales" - ? i.sales_dinero.getAmount() - : i.costs_dinero.getAmount(); + ? Dinero(i.sales_dinero).getAmount() + : Dinero(i.costs_dinero).getAmount(); if (value > 0) { acc.push({ @@ -21,13 +21,13 @@ export default function JobCostingPieComponent({ label: `${i.cost_center} - ${ type === "sales" - ? i.sales_dinero.toFormat() - : i.costs_dinero.toFormat() + ? Dinero(i.sales_dinero).toFormat() + : Dinero(i.costs_dinero).toFormat() }`, value: type === "sales" - ? i.sales_dinero.getAmount() - : i.costs_dinero.getAmount(), + ? Dinero(i.sales_dinero).getAmount() + : Dinero(i.costs_dinero).getAmount(), }); } return acc; 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 3f7e576ec..acc81ef91 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,9 +1,9 @@ -import { Input, Table, Typography } from "antd"; +import { Input, Space, Table, Typography } from "antd"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { alphaSort } from "../../utils/sorters"; - -export default function JobCostingPartsTable({ job, data, summaryData }) { +import Dinero from "dinero.js"; +export default function JobCostingPartsTable({ data, summaryData }) { const [searchText, setSearchText] = useState(""); const [state, setState] = useState({ sortedInfo: {}, @@ -75,18 +75,16 @@ export default function JobCostingPartsTable({ job, data, summaryData }) { { return ( -
-
- { - e.preventDefault(); - setSearchText(e.target.value); - }} - /> -
-
+ + { + e.preventDefault(); + setSearchText(e.target.value); + }} + /> + ); }} scroll={{ x: "50%", y: "40rem" }} @@ -103,13 +101,13 @@ export default function JobCostingPartsTable({ job, data, summaryData }) { - {summaryData.totalSales.toFormat()} + {Dinero(summaryData.totalSales).toFormat()} - {summaryData.totalCost.toFormat()} + {Dinero(summaryData.totalCost).toFormat()} - {summaryData.gpdollars.toFormat()} + {Dinero(summaryData.gpdollars).toFormat()} 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 index 730fc3d76..2eac049a1 100644 --- a/client/src/components/job-costing-statistics/job-costing-statistics.component.jsx +++ b/client/src/components/job-costing-statistics/job-costing-statistics.component.jsx @@ -1,39 +1,39 @@ import { Statistic } from "antd"; import React from "react"; import { useTranslation } from "react-i18next"; - -export default function JobCostingStatistics({ job, summaryData }) { +import Dinero from "dinero.js"; +export default function JobCostingStatistics({ summaryData }) { const { t } = useTranslation(); return (
{ + ret[job.id] = GenerateCostingData(job); + }); + + res.status(200).json(ret); + } catch (error) { + console.log("error", error); + res.status(400).send(JSON.stringify(error)); + } +} + +function GenerateCostingData(job) { + const defaultProfits = + job.bodyshop.md_responsibility_centers.defaults.profits; + const allProfitCenters = _.union( + job.bodyshop.md_responsibility_centers.profits.map((p) => p.name), + job.bodyshop.md_responsibility_centers.costs.map((p) => p.name) + ); + + //Massage the data. + const jobLineTotalsByProfitCenter = + job && + job.joblines.reduce( + (acc, val) => { + const laborProfitCenter = defaultProfits[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 billTotalsByProfitCenter = job.bills.reduce((bill_acc, bill_val) => { + //At the invoice level. + bill_val.billlines.map((line_val) => { + //At the invoice line level. + //console.log("JobCostingPartsTable -> line_val", line_val); + if (!bill_acc[line_val.cost_center]) + bill_acc[line_val.cost_center] = Dinero(); + + bill_acc[line_val.cost_center] = bill_acc[line_val.cost_center].add( + Dinero({ + amount: Math.round((line_val.actual_cost || 0) * 100), + }) + .multiply(line_val.quantity) + .multiply(bill_val.is_credit_memo ? -1 : 1) + ); + + return null; + }); + return bill_acc; + }, {}); + + const ticketTotalsByProfitCenter = job.timetickets.reduce( + (ticket_acc, ticket_val) => { + //At the invoice level. + if (!ticket_acc[ticket_val.cost_center]) + ticket_acc[ticket_val.cost_center] = Dinero(); + + ticket_acc[ticket_val.cost_center] = ticket_acc[ + ticket_val.cost_center + ].add( + Dinero({ + amount: Math.round((ticket_val.rate || 0) * 100), + }).multiply(ticket_val.actualhrs || ticket_val.productivehrs || 0) + ); + + return ticket_acc; + }, + {} + ); + + const summaryData = { + totalLaborSales: Dinero({ amount: 0 }), + totalPartsSales: Dinero({ amount: 0 }), + totalSales: Dinero({ amount: 0 }), + totalLaborCost: Dinero({ amount: 0 }), + totalPartsCost: Dinero({ amount: 0 }), + totalCost: Dinero({ amount: 0 }), + gpdollars: Dinero({ amount: 0 }), + gppercent: null, + gppercentFormatted: null, + }; + + const costCenterData = allProfitCenters.map((key, idx) => { + const ccVal = key; // defaultProfits[key]; + const sale_labor = + jobLineTotalsByProfitCenter.labor[ccVal] || Dinero({ amount: 0 }); + const sale_parts = + jobLineTotalsByProfitCenter.parts[ccVal] || Dinero({ amount: 0 }); + + const cost_labor = + ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }); + const cost_parts = billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }); + + const costs = ( + billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }) + ).add(ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 })); + const totalSales = sale_labor.add(sale_parts); + const gpdollars = totalSales.subtract(costs); + 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.totalLaborCost = summaryData.totalLaborCost.add(cost_labor); + summaryData.totalPartsCost = summaryData.totalPartsCost.add(cost_parts); + summaryData.totalCost = summaryData.totalCost.add(costs); + + return { + id: idx, + cost_center: ccVal, + sale_labor: sale_labor && sale_labor.toFormat(), + sale_parts: sale_parts && sale_parts.toFormat(), + sales: sale_labor.add(sale_parts).toFormat(), + sales_dinero: sale_labor.add(sale_parts), + cost_parts: cost_parts && cost_parts.toFormat(), + cost_labor: cost_labor && cost_labor.toFormat(), + costs: cost_parts.add(cost_labor).toFormat(), + costs_dinero: cost_parts.add(cost_labor), + 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; + } + + return { summaryData, costCenterData }; +} + +exports.JobCosting = JobCosting; +exports.JobCostingMulti = JobCostingMulti; diff --git a/server/job/job.js b/server/job/job.js index b41c1dab0..975342045 100644 --- a/server/job/job.js +++ b/server/job/job.js @@ -1,2 +1,4 @@ exports.totals = require("./job-totals").default; exports.totalsSsu = require("./job-totals").totalsSsu; +exports.costing = require("./job-costing").JobCosting; +exports.costingmulti = require("./job-costing").JobCostingMulti;