211 lines
7.2 KiB
JavaScript
211 lines
7.2 KiB
JavaScript
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;
|
|
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 (
|
|
<div>
|
|
<JobCostingStatistics job={job} summaryData={summaryData} />
|
|
<JobCostingPartsTable
|
|
job={job}
|
|
data={costCenterData}
|
|
summaryData={summaryData}
|
|
/>
|
|
<div className="imex-flex-row">
|
|
<div style={{ flex: 1 }}>
|
|
<Typography.Title level={4}>
|
|
{t("jobs.labels.sales")}
|
|
</Typography.Title>
|
|
<JobCostingPie type="sales" costCenterData={costCenterData} />
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<Typography.Title level={4}>{t("jobs.labels.cost")}</Typography.Title>
|
|
<JobCostingPie type="cost" costCenterData={costCenterData} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
export default connect(
|
|
mapStateToProps,
|
|
mapDispatchToProps
|
|
)(JobCostingModalComponent);
|