IO-134 IO-836 IO 834 IO-835 IO-837
This commit is contained in:
@@ -10211,6 +10211,48 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>reassign_limitexceeded</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>reassign_limitexceeded_title</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>storageexceeded</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -28197,6 +28239,27 @@
|
||||
<folder_node>
|
||||
<name>templates</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>credits_not_received_date</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>estimator_detail</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -28533,6 +28596,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>supplement_ratio_source</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>timetickets</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function DocumentsUploadComponent({
|
||||
billId,
|
||||
callbackAfterUpload,
|
||||
totalSize,
|
||||
ignoreSizeLimit = false,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -33,7 +34,7 @@ export function DocumentsUploadComponent({
|
||||
);
|
||||
}, [bodyshop, totalSize]);
|
||||
|
||||
if (pct > 100)
|
||||
if (pct > 100 && !ignoreSizeLimit)
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
@@ -46,6 +47,7 @@ export function DocumentsUploadComponent({
|
||||
<Upload.Dragger
|
||||
multiple={true}
|
||||
beforeUpload={(file, fileList) => {
|
||||
if (ignoreSizeLimit) return true;
|
||||
const newFiles = fileList.reduce((acc, val) => acc + val.size, 0);
|
||||
const shouldStopUpload =
|
||||
(totalSize + newFiles) / ((bodyshop && bodyshop.jobsizelimit) || 1) >=
|
||||
@@ -84,16 +86,18 @@ export function DocumentsUploadComponent({
|
||||
<p className="ant-upload-text">
|
||||
Click or drag files to this area to upload.
|
||||
</p>
|
||||
<Space wrap className="ant-upload-text">
|
||||
<Progress type="dashboard" percent={pct} size="small" />
|
||||
<span>
|
||||
{t("documents.labels.usage", {
|
||||
percent: pct,
|
||||
used: formatBytes(totalSize),
|
||||
total: formatBytes(bodyshop && bodyshop.jobsizelimit),
|
||||
})}
|
||||
</span>
|
||||
</Space>
|
||||
{!ignoreSizeLimit && (
|
||||
<Space wrap className="ant-upload-text">
|
||||
<Progress type="dashboard" percent={pct} size="small" />
|
||||
<span>
|
||||
{t("documents.labels.usage", {
|
||||
percent: pct,
|
||||
used: formatBytes(totalSize),
|
||||
total: formatBytes(bodyshop && bodyshop.jobsizelimit),
|
||||
})}
|
||||
</span>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Upload.Dragger>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<JobCostingStatistics job={job} summaryData={summaryData} />
|
||||
<JobCostingPartsTable
|
||||
job={job}
|
||||
data={costCenterData}
|
||||
summaryData={summaryData}
|
||||
/>
|
||||
<JobCostingStatistics summaryData={summaryData} />
|
||||
<JobCostingPartsTable data={costCenterData} summaryData={summaryData} />
|
||||
<div className="imex-flex-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Title level={4}>
|
||||
@@ -204,7 +30,3 @@ export function JobCostingModalComponent({ bodyshop, job }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(JobCostingModalComponent);
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
@@ -43,11 +52,13 @@ export function JobCostingModalContainer({
|
||||
width="90%"
|
||||
destroyOnClose
|
||||
>
|
||||
{error ? <AlertComponent message={error.message} type="error" /> : null}
|
||||
{loading ? (
|
||||
<LoadingSpinner loading={loading} />
|
||||
{!costingData ? (
|
||||
<LoadingSpinner loading={true} />
|
||||
) : (
|
||||
<JobCostingModalComponent job={data && data.jobs_by_pk} />
|
||||
<JobCostingModalComponent
|
||||
costCenterData={costingData.costCenterData}
|
||||
summaryData={costingData.summaryData}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) {
|
||||
<Table
|
||||
title={() => {
|
||||
return (
|
||||
<div className="imex-table-header">
|
||||
<div className="imex-table-header__search">
|
||||
<Input.Search
|
||||
placeholder={t("general.labels.search")}
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchText(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder={t("general.labels.search")}
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchText(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}}
|
||||
scroll={{ x: "50%", y: "40rem" }}
|
||||
@@ -103,13 +101,13 @@ export default function JobCostingPartsTable({ job, data, summaryData }) {
|
||||
</Typography.Title>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell>
|
||||
{summaryData.totalSales.toFormat()}
|
||||
{Dinero(summaryData.totalSales).toFormat()}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell>
|
||||
{summaryData.totalCost.toFormat()}
|
||||
{Dinero(summaryData.totalCost).toFormat()}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell>
|
||||
{summaryData.gpdollars.toFormat()}
|
||||
{Dinero(summaryData.gpdollars).toFormat()}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell></Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="imex-flex-row imex-flex-row__flex-space-around">
|
||||
<Statistic
|
||||
value={summaryData.totalLaborSales.toFormat()}
|
||||
value={Dinero(summaryData.totalLaborSales).toFormat()}
|
||||
title={t("jobs.labels.sale_labor")}
|
||||
/>
|
||||
<Statistic
|
||||
value={summaryData.totalPartsSales.toFormat()}
|
||||
value={Dinero(summaryData.totalPartsSales).toFormat()}
|
||||
title={t("jobs.labels.sale_parts")}
|
||||
/>
|
||||
<Statistic
|
||||
value={summaryData.totalSales.toFormat()}
|
||||
value={Dinero(summaryData.totalSales).toFormat()}
|
||||
title={t("jobs.labels.total_sales")}
|
||||
/>
|
||||
<Statistic
|
||||
value={summaryData.totalLaborCost.toFormat()}
|
||||
value={Dinero(summaryData.totalLaborCost).toFormat()}
|
||||
title={t("jobs.labels.cost_labor")}
|
||||
/>
|
||||
<Statistic
|
||||
value={summaryData.totalPartsCost.toFormat()}
|
||||
value={Dinero(summaryData.totalPartsCost).toFormat()}
|
||||
title={t("jobs.labels.cost_parts")}
|
||||
/>
|
||||
<Statistic
|
||||
value={summaryData.totalCost.toFormat()}
|
||||
value={Dinero(summaryData.totalCost).toFormat()}
|
||||
title={t("jobs.labels.total_cost")}
|
||||
/>
|
||||
<Statistic
|
||||
value={summaryData.gpdollars.toFormat()}
|
||||
value={Dinero(summaryData.gpdollars).toFormat()}
|
||||
title={t("jobs.labels.gpdollars")}
|
||||
/>
|
||||
<Statistic
|
||||
|
||||
@@ -47,17 +47,21 @@ export function JobsTotalsTableComponent({ jobRO, job }) {
|
||||
</Col>
|
||||
<Col {...colSpan}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col sm={24} md={12}>
|
||||
<Card title={t("jobs.labels.partstotal")}>
|
||||
<JobTotalsTableParts job={job} />
|
||||
</Card>
|
||||
<Col {...colSpan}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card title={t("jobs.labels.partstotal")}>
|
||||
<JobTotalsTableParts job={job} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Card title={t("jobs.labels.othertotal")}>
|
||||
<JobTotalsTableOther job={job} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col sm={24} md={12}>
|
||||
<Card title={t("jobs.labels.othertotal")}>
|
||||
<JobTotalsTableOther job={job} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Col {...colSpan}>
|
||||
<Card title={t("jobs.labels.jobtotals")}>
|
||||
<JobTotalsTableTotals job={job} />
|
||||
</Card>
|
||||
|
||||
@@ -103,11 +103,11 @@ export default function JobTotalsTableLabor({ job }) {
|
||||
<>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell>
|
||||
{t("jobs.labels.labor_rates_subtotal")}
|
||||
<strong>{t("jobs.labels.labor_rates_subtotal")}</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell />
|
||||
<Table.Summary.Cell />
|
||||
<Table.Summary.Cell>
|
||||
<Table.Summary.Cell align="right">
|
||||
<strong>
|
||||
{Dinero(job.job_totals.rates.rates_subtotal).toFormat()}
|
||||
</strong>
|
||||
@@ -121,7 +121,7 @@ export default function JobTotalsTableLabor({ job }) {
|
||||
<Table.Summary.Cell>
|
||||
{job.job_totals.rates.mapa.hours.toFixed(2)}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell>
|
||||
<Table.Summary.Cell align="right">
|
||||
{Dinero(job.job_totals.rates.mapa.total).toFormat()}
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
@@ -133,19 +133,19 @@ export default function JobTotalsTableLabor({ job }) {
|
||||
<Table.Summary.Cell>
|
||||
{job.job_totals.rates.mash.hours.toFixed(2)}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell>
|
||||
<Table.Summary.Cell align="right">
|
||||
{Dinero(job.job_totals.rates.mash.total).toFormat()}
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell>
|
||||
{t("jobs.labels.labor_rates_subtotal")}
|
||||
<strong>{t("jobs.labels.rates_subtotal")}</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell />
|
||||
<Table.Summary.Cell />
|
||||
<Table.Summary.Cell>
|
||||
<Table.Summary.Cell align="right">
|
||||
<strong>
|
||||
{Dinero(job.job_totals.rates.rates_subtotal).toFormat()}
|
||||
{Dinero(job.job_totals.rates.subtotal).toFormat()}
|
||||
</strong>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function JobTotalsTableOther({ job }) {
|
||||
<>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell>
|
||||
{t("jobs.labels.additionaltotal")}
|
||||
<strong>{t("jobs.labels.additionaltotal")}</strong>
|
||||
</Table.Summary.Cell>
|
||||
|
||||
<Table.Summary.Cell>
|
||||
@@ -90,7 +90,7 @@ export default function JobTotalsTableOther({ job }) {
|
||||
</Table.Summary.Row>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell>
|
||||
{t("jobs.labels.subletstotal")}
|
||||
<strong>{t("jobs.labels.subletstotal")}</strong>
|
||||
</Table.Summary.Cell>
|
||||
|
||||
<Table.Summary.Cell>
|
||||
|
||||
@@ -70,7 +70,9 @@ export default function JobTotalsTableParts({ job }) {
|
||||
}}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell>{t("jobs.labels.partstotal")}</Table.Summary.Cell>
|
||||
<Table.Summary.Cell>
|
||||
<strong>{t("jobs.labels.partstotal")}</strong>
|
||||
</Table.Summary.Cell>
|
||||
|
||||
<Table.Summary.Cell>
|
||||
<strong>
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { Button, Form, Popover, notification, Space } from "antd";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { UPDATE_DOCUMENT } from "../../graphql/documents.queries";
|
||||
import { useApolloClient, useMutation } from "@apollo/client";
|
||||
import { Button, Form, notification, Popover, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import {
|
||||
GET_DOC_SIZE_BY_JOB,
|
||||
UPDATE_DOCUMENT,
|
||||
} from "../../graphql/documents.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
||||
|
||||
export default function JobsDocumentsGalleryReassign({ galleryImages }) {
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(JobsDocumentsGalleryReassign);
|
||||
|
||||
export function JobsDocumentsGalleryReassign({ bodyshop, galleryImages }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
@@ -16,7 +33,7 @@ export default function JobsDocumentsGalleryReassign({ galleryImages }) {
|
||||
...galleryImages.other.filter((image) => image.isSelected),
|
||||
];
|
||||
}, [galleryImages]);
|
||||
|
||||
const client = useApolloClient();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updateDocument] = useMutation(UPDATE_DOCUMENT);
|
||||
@@ -48,10 +65,37 @@ export default function JobsDocumentsGalleryReassign({ galleryImages }) {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
console.log("selectedImages :>> ", selectedImages);
|
||||
const handleFinish = async ({ jobid }) => {
|
||||
setLoading(true);
|
||||
|
||||
//Check to see if the space remaining on the new job is sufficient. If it isn't cancel this.
|
||||
const newJobData = await client.query({
|
||||
query: GET_DOC_SIZE_BY_JOB,
|
||||
variables: { jobId: jobid },
|
||||
});
|
||||
|
||||
const transferedDocSizeTotal = selectedImages.reduce(
|
||||
(acc, val) => acc + val.size,
|
||||
0
|
||||
);
|
||||
|
||||
const shouldPreventTransfer =
|
||||
bodyshop.jobsizelimit -
|
||||
newJobData.data.documents_aggregate.aggregate.sum.size <
|
||||
transferedDocSizeTotal;
|
||||
|
||||
if (shouldPreventTransfer) {
|
||||
notification.open({
|
||||
key: "cannotuploaddocuments",
|
||||
type: "error",
|
||||
message: t("documents.labels.reassign_limitexceeded_title"),
|
||||
description: t("documents.labels.reassign_limitexceeded"),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await axios.post("/media/rename", {
|
||||
documents: selectedImages.map((i) => {
|
||||
//Need to check if the current key folder is null, or another job.
|
||||
|
||||
@@ -18,6 +18,7 @@ function JobsDocumentsComponent({
|
||||
billsCallback,
|
||||
totalSize,
|
||||
bodyshop,
|
||||
ignoreSizeLimit,
|
||||
}) {
|
||||
const [galleryImages, setgalleryImages] = useState({ images: [], other: [] });
|
||||
const { t } = useTranslation();
|
||||
@@ -43,6 +44,7 @@ function JobsDocumentsComponent({
|
||||
extension: value.extension,
|
||||
id: value.id,
|
||||
type: value.type,
|
||||
size: value.size,
|
||||
tags: [{ value: value.type, title: value.type }],
|
||||
});
|
||||
} else {
|
||||
@@ -95,6 +97,7 @@ function JobsDocumentsComponent({
|
||||
key: value.key,
|
||||
id: value.id,
|
||||
type: value.type,
|
||||
size: value.size,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,6 +134,7 @@ function JobsDocumentsComponent({
|
||||
totalSize={totalSize}
|
||||
billId={billId}
|
||||
callbackAfterUpload={billsCallback || refetch}
|
||||
ignoreSizeLimit={ignoreSizeLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -31,17 +31,10 @@ export function TechClockOffButton({
|
||||
|
||||
const { t } = useTranslation();
|
||||
const emps = bodyshop.employees.filter(
|
||||
(e) => e.id === technician && technician.id
|
||||
(e) => e.id === (technician && technician.id)
|
||||
)[0];
|
||||
|
||||
console.log(
|
||||
"emps :>> ",
|
||||
emps,
|
||||
"e",
|
||||
bodyshop && bodyshop.employees,
|
||||
"tech",
|
||||
technician
|
||||
);
|
||||
console.log(emps && emps.rates);
|
||||
|
||||
const handleFinish = async (values) => {
|
||||
logImEXEvent("tech_clock_out_job");
|
||||
|
||||
@@ -167,6 +167,7 @@ export const QUERY_BILL_BY_PK = gql`
|
||||
key
|
||||
name
|
||||
type
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const GET_DOCUMENTS_BY_JOB = gql`
|
||||
name
|
||||
key
|
||||
type
|
||||
size
|
||||
bill {
|
||||
id
|
||||
invoice_number
|
||||
@@ -27,6 +28,18 @@ export const GET_DOCUMENTS_BY_JOB = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_DOC_SIZE_BY_JOB = gql`
|
||||
query GET_DOC_SIZE_BY_JOB($jobId: uuid!) {
|
||||
documents_aggregate(where: { jobid: { _eq: $jobId } }) {
|
||||
aggregate {
|
||||
sum {
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const INSERT_NEW_DOCUMENT = gql`
|
||||
mutation INSERT_NEW_DOCUMENT($docInput: [documents_insert_input!]!) {
|
||||
insert_documents(objects: $docInput) {
|
||||
@@ -34,6 +47,7 @@ export const INSERT_NEW_DOCUMENT = gql`
|
||||
id
|
||||
name
|
||||
key
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +74,7 @@ export const QUERY_TEMPORARY_DOCS = gql`
|
||||
key
|
||||
type
|
||||
extension
|
||||
size
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -75,6 +90,7 @@ export const UPDATE_DOCUMENT = gql`
|
||||
name
|
||||
type
|
||||
key
|
||||
size
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -811,6 +811,7 @@ export const QUERY_TECH_JOB_DETAILS = gql`
|
||||
documents(order_by: { created_at: desc }) {
|
||||
id
|
||||
key
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function TemporaryDocsComponent() {
|
||||
jobId={null}
|
||||
billId={null}
|
||||
refetch={refetch}
|
||||
ignoreSizeLimit
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -658,11 +658,13 @@
|
||||
"confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.",
|
||||
"doctype": "Document Type",
|
||||
"newjobid": "Assign to Job",
|
||||
"reassign_limitexceeded": "Reassigning all selected documents will exceed the job storage limit for your shop. ",
|
||||
"reassign_limitexceeded_title": "Unable to reassign document(s)",
|
||||
"storageexceeded": "You've exceeded your storage limit for this job. Please remove documents, or increase your storage plan.",
|
||||
"storageexceeded_title": "Storage Limit Exceeded",
|
||||
"upload": "Upload",
|
||||
"upload_limitexceeded": "Uploading all selected files will exceed the job storage limit for your shop. ",
|
||||
"upload_limitexceeded_title": "Unable to upload file(s)",
|
||||
"upload_limitexceeded": "Uploading all selected documents will exceed the job storage limit for your shop. ",
|
||||
"upload_limitexceeded_title": "Unable to upload document(s)",
|
||||
"usage": "of job storage used. ({{used}} / {{total}})"
|
||||
},
|
||||
"successes": {
|
||||
@@ -1703,6 +1705,7 @@
|
||||
"vendor": "Vendor"
|
||||
},
|
||||
"templates": {
|
||||
"credits_not_received_date": "Credits not Received by Date",
|
||||
"estimator_detail": "Jobs by Estimator (Detail)",
|
||||
"estimator_summary": "Jobs by Estimator (Summary)",
|
||||
"hours_sold_detail_closed": "Hours Sold Detail - Closed",
|
||||
@@ -1719,6 +1722,7 @@
|
||||
"purchases_by_vendor_detailed_date_range": "Purchases By Vendor - Detailed",
|
||||
"purchases_by_vendor_summary_date_range": "Purchases by Vendor - Summary",
|
||||
"schedule": "Appointment Schedule",
|
||||
"supplement_ratio_source": "Supplement Ratio by Source",
|
||||
"timetickets": "Time Tickets",
|
||||
"timetickets_employee": "Employee Time Tickets",
|
||||
"timetickets_summary": "Time Tickets Summary"
|
||||
|
||||
@@ -658,6 +658,8 @@
|
||||
"confirmdelete": "",
|
||||
"doctype": "",
|
||||
"newjobid": "",
|
||||
"reassign_limitexceeded": "",
|
||||
"reassign_limitexceeded_title": "",
|
||||
"storageexceeded": "",
|
||||
"storageexceeded_title": "",
|
||||
"upload": "Subir",
|
||||
@@ -1703,6 +1705,7 @@
|
||||
"vendor": ""
|
||||
},
|
||||
"templates": {
|
||||
"credits_not_received_date": "",
|
||||
"estimator_detail": "",
|
||||
"estimator_summary": "",
|
||||
"hours_sold_detail_closed": "",
|
||||
@@ -1719,6 +1722,7 @@
|
||||
"purchases_by_vendor_detailed_date_range": "",
|
||||
"purchases_by_vendor_summary_date_range": "",
|
||||
"schedule": "",
|
||||
"supplement_ratio_source": "",
|
||||
"timetickets": "",
|
||||
"timetickets_employee": "",
|
||||
"timetickets_summary": ""
|
||||
|
||||
@@ -658,6 +658,8 @@
|
||||
"confirmdelete": "",
|
||||
"doctype": "",
|
||||
"newjobid": "",
|
||||
"reassign_limitexceeded": "",
|
||||
"reassign_limitexceeded_title": "",
|
||||
"storageexceeded": "",
|
||||
"storageexceeded_title": "",
|
||||
"upload": "Télécharger",
|
||||
@@ -1703,6 +1705,7 @@
|
||||
"vendor": ""
|
||||
},
|
||||
"templates": {
|
||||
"credits_not_received_date": "",
|
||||
"estimator_detail": "",
|
||||
"estimator_summary": "",
|
||||
"hours_sold_detail_closed": "",
|
||||
@@ -1719,6 +1722,7 @@
|
||||
"purchases_by_vendor_detailed_date_range": "",
|
||||
"purchases_by_vendor_summary_date_range": "",
|
||||
"schedule": "",
|
||||
"supplement_ratio_source": "",
|
||||
"timetickets": "",
|
||||
"timetickets_employee": "",
|
||||
"timetickets_summary": ""
|
||||
|
||||
@@ -511,6 +511,22 @@ export const TemplateList = (type, context) => {
|
||||
//idtype: "vendor",
|
||||
disabled: false,
|
||||
},
|
||||
supplement_ratio_source: {
|
||||
title: i18n.t("reportcenter.templates.supplement_ratio_source"),
|
||||
description: "",
|
||||
subject: i18n.t("reportcenter.templates.supplement_ratio_source"),
|
||||
key: "supplement_ratio_source",
|
||||
//idtype: "vendor",
|
||||
disabled: false,
|
||||
},
|
||||
credits_not_received_date: {
|
||||
title: i18n.t("reportcenter.templates.credits_not_received_date"),
|
||||
description: "",
|
||||
subject: i18n.t("reportcenter.templates.credits_not_received_date"),
|
||||
key: "credits_not_received_date",
|
||||
//idtype: "vendor",
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(!type || type === "courtesycarcontract"
|
||||
|
||||
@@ -94,6 +94,8 @@ app.post(
|
||||
var job = require("./server/job/job");
|
||||
app.post("/job/totals", fb.validateFirebaseIdToken, job.totals);
|
||||
app.post("/job/totalsssu", fb.validateFirebaseIdToken, job.totalsSsu);
|
||||
app.post("/job/costing", fb.validateFirebaseIdToken, job.costing);
|
||||
app.post("/job/costingmulti", fb.validateFirebaseIdToken, job.costingmulti);
|
||||
|
||||
//Scheduling
|
||||
var scheduling = require("./server/scheduling/scheduling-job");
|
||||
|
||||
@@ -579,3 +579,198 @@ exports.GET_JOB_BY_PK = ` query GET_JOB_BY_PK($id: uuid!) {
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.QUERY_JOB_COSTING_DETAILS = ` query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
|
||||
jobs_by_pk(id: $id) {
|
||||
ro_number
|
||||
clm_total
|
||||
id
|
||||
ded_amt
|
||||
ded_status
|
||||
depreciation_taxes
|
||||
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_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
|
||||
ca_bc_pvrt
|
||||
joblines(where: { removed: { _eq: false } }) {
|
||||
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
|
||||
}
|
||||
bills {
|
||||
id
|
||||
federal_tax_rate
|
||||
local_tax_rate
|
||||
state_tax_rate
|
||||
is_credit_memo
|
||||
billlines {
|
||||
actual_cost
|
||||
cost_center
|
||||
id
|
||||
quantity
|
||||
}
|
||||
}
|
||||
timetickets {
|
||||
id
|
||||
rate
|
||||
cost_center
|
||||
actualhrs
|
||||
productivehrs
|
||||
}
|
||||
bodyshop{
|
||||
id
|
||||
md_responsibility_centers
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULTI($ids: [uuid!]!) {
|
||||
jobs( where: {id: {_in: $ids}}) {
|
||||
ro_number
|
||||
clm_total
|
||||
id
|
||||
ded_amt
|
||||
ded_status
|
||||
depreciation_taxes
|
||||
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_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
|
||||
ca_bc_pvrt
|
||||
joblines(where: {removed: {_eq: false}}) {
|
||||
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
|
||||
}
|
||||
bills {
|
||||
id
|
||||
federal_tax_rate
|
||||
local_tax_rate
|
||||
state_tax_rate
|
||||
is_credit_memo
|
||||
billlines {
|
||||
actual_cost
|
||||
cost_center
|
||||
id
|
||||
quantity
|
||||
}
|
||||
}
|
||||
timetickets {
|
||||
id
|
||||
rate
|
||||
cost_center
|
||||
actualhrs
|
||||
productivehrs
|
||||
}
|
||||
bodyshop {
|
||||
id
|
||||
md_responsibility_centers
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
238
server/job/job-costing.js
Normal file
238
server/job/job-costing.js
Normal file
@@ -0,0 +1,238 @@
|
||||
const Dinero = require("dinero.js");
|
||||
const queries = require("../graphql-client/queries");
|
||||
//const client = require("../graphql-client/graphql-client").client;
|
||||
const _ = require("lodash");
|
||||
const GraphQLClient = require("graphql-request").GraphQLClient;
|
||||
|
||||
// Dinero.defaultCurrency = "USD";
|
||||
// Dinero.globalLocale = "en-CA";
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
|
||||
async function JobCosting(req, res) {
|
||||
const { jobid } = req.body;
|
||||
console.log("🚀 ~ file: job-costing.js ~ line 13 ~ jobid", jobid);
|
||||
const BearerToken = req.headers.authorization;
|
||||
|
||||
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
|
||||
headers: {
|
||||
Authorization: BearerToken,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await client
|
||||
.setHeaders({ Authorization: BearerToken })
|
||||
.request(queries.QUERY_JOB_COSTING_DETAILS, {
|
||||
id: jobid,
|
||||
});
|
||||
|
||||
const ret = GenerateCostingData(resp.jobs_by_pk);
|
||||
|
||||
res.status(200).json(ret);
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
res.status(400).send(JSON.stringify(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function JobCostingMulti(req, res) {
|
||||
const { jobids } = req.body;
|
||||
console.log("🚀 ~ file: job-costing.js ~ line 13 ~ jobids", jobids);
|
||||
const BearerToken = req.headers.authorization;
|
||||
|
||||
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
|
||||
headers: {
|
||||
Authorization: BearerToken,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await client
|
||||
.setHeaders({ Authorization: BearerToken })
|
||||
.request(queries.QUERY_JOB_COSTING_DETAILS_MULTI, {
|
||||
ids: jobids,
|
||||
});
|
||||
|
||||
//for Each!***************
|
||||
const ret = {};
|
||||
resp.jobs.map((job) => {
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user