From 9af6311d524231246f11e417ea3e3722ff5ca6b5 Mon Sep 17 00:00:00 2001 From: Patrick Fic <> Date: Tue, 6 Apr 2021 09:26:31 -0700 Subject: [PATCH 1/4] IO-134 Prevent doc reassignment with limit --- bodyshop_translations.babel | 63 +++++++++++++++++++ .../documents-upload.component.jsx | 26 ++++---- ...bs-document-gallery.reassign.component.jsx | 62 +++++++++++++++--- .../jobs-documents-gallery.component.jsx | 4 ++ client/src/graphql/bills.queries.js | 1 + client/src/graphql/documents.queries.js | 16 +++++ client/src/graphql/jobs.queries.js | 1 + .../temporary-docs.component.jsx | 1 + client/src/translations/en_us/common.json | 7 ++- client/src/translations/es/common.json | 3 + client/src/translations/fr/common.json | 3 + client/src/utils/TemplateConstants.js | 8 +++ 12 files changed, 173 insertions(+), 22 deletions(-) diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 8e654c027..10c74fa83 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -10211,6 +10211,48 @@ + + reassign_limitexceeded + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + reassign_limitexceeded_title + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + storageexceeded false @@ -28533,6 +28575,27 @@ + + supplement_ratio_source + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + timetickets false diff --git a/client/src/components/documents-upload/documents-upload.component.jsx b/client/src/components/documents-upload/documents-upload.component.jsx index 9c6ea830b..1fc105127 100644 --- a/client/src/components/documents-upload/documents-upload.component.jsx +++ b/client/src/components/documents-upload/documents-upload.component.jsx @@ -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 ( { + 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({

Click or drag files to this area to upload.

- - - - {t("documents.labels.usage", { - percent: pct, - used: formatBytes(totalSize), - total: formatBytes(bodyshop && bodyshop.jobsizelimit), - })} - - + {!ignoreSizeLimit && ( + + + + {t("documents.labels.usage", { + percent: pct, + used: formatBytes(totalSize), + total: formatBytes(bodyshop && bodyshop.jobsizelimit), + })} + + + )} )} diff --git a/client/src/components/jobs-documents-gallery/jobs-document-gallery.reassign.component.jsx b/client/src/components/jobs-documents-gallery/jobs-document-gallery.reassign.component.jsx index 58c847cdf..d56c733c9 100644 --- a/client/src/components/jobs-documents-gallery/jobs-document-gallery.reassign.component.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-document-gallery.reassign.component.jsx @@ -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. diff --git a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx index dc8bede19..9d017c71f 100644 --- a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx @@ -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} /> diff --git a/client/src/graphql/bills.queries.js b/client/src/graphql/bills.queries.js index 5bb4f0099..69648c933 100644 --- a/client/src/graphql/bills.queries.js +++ b/client/src/graphql/bills.queries.js @@ -167,6 +167,7 @@ export const QUERY_BILL_BY_PK = gql` key name type + size } } } diff --git a/client/src/graphql/documents.queries.js b/client/src/graphql/documents.queries.js index 3903f1d2f..443025ce9 100644 --- a/client/src/graphql/documents.queries.js +++ b/client/src/graphql/documents.queries.js @@ -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 } } `; diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 148938192..6dc3dc82f 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -811,6 +811,7 @@ export const QUERY_TECH_JOB_DETAILS = gql` documents(order_by: { created_at: desc }) { id key + size } } } diff --git a/client/src/pages/temporary-docs/temporary-docs.component.jsx b/client/src/pages/temporary-docs/temporary-docs.component.jsx index d54898375..bec1287d9 100644 --- a/client/src/pages/temporary-docs/temporary-docs.component.jsx +++ b/client/src/pages/temporary-docs/temporary-docs.component.jsx @@ -17,6 +17,7 @@ export default function TemporaryDocsComponent() { jobId={null} billId={null} refetch={refetch} + ignoreSizeLimit /> ); } diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 6830d07e2..99da29928 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -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": { @@ -1719,6 +1721,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" diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index e12ada3d4..225b47151 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -658,6 +658,8 @@ "confirmdelete": "", "doctype": "", "newjobid": "", + "reassign_limitexceeded": "", + "reassign_limitexceeded_title": "", "storageexceeded": "", "storageexceeded_title": "", "upload": "Subir", @@ -1719,6 +1721,7 @@ "purchases_by_vendor_detailed_date_range": "", "purchases_by_vendor_summary_date_range": "", "schedule": "", + "supplement_ratio_source": "", "timetickets": "", "timetickets_employee": "", "timetickets_summary": "" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 9e0e9f0c5..6ca6cbff4 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -658,6 +658,8 @@ "confirmdelete": "", "doctype": "", "newjobid": "", + "reassign_limitexceeded": "", + "reassign_limitexceeded_title": "", "storageexceeded": "", "storageexceeded_title": "", "upload": "Télécharger", @@ -1719,6 +1721,7 @@ "purchases_by_vendor_detailed_date_range": "", "purchases_by_vendor_summary_date_range": "", "schedule": "", + "supplement_ratio_source": "", "timetickets": "", "timetickets_employee": "", "timetickets_summary": "" diff --git a/client/src/utils/TemplateConstants.js b/client/src/utils/TemplateConstants.js index 6ba044339..e6f5d9453 100644 --- a/client/src/utils/TemplateConstants.js +++ b/client/src/utils/TemplateConstants.js @@ -511,6 +511,14 @@ 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, + }, } : {}), ...(!type || type === "courtesycarcontract" From 4779964449c17659522cf9494035ce314c139291 Mon Sep 17 00:00:00 2001 From: Patrick Fic <> Date: Tue, 6 Apr 2021 13:54:47 -0700 Subject: [PATCH 2/4] 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; From 5b17bfaaa0475f0c7e2d8fb6ec4914503112e41a Mon Sep 17 00:00:00 2001 From: Patrick Fic <> Date: Tue, 6 Apr 2021 14:29:44 -0700 Subject: [PATCH 3/4] IO-834 IO-835 Totals table formatting. --- bodyshop_translations.babel | 21 ++++++++++++++++ .../job-totals-table.component.jsx | 24 +++++++++++-------- .../job-totals.table.labor.component.jsx | 14 +++++------ .../job-totals.table.other.component.jsx | 4 ++-- .../job-totals.table.parts.component.jsx | 4 +++- client/src/translations/en_us/common.json | 1 + client/src/translations/es/common.json | 1 + client/src/translations/fr/common.json | 1 + client/src/utils/TemplateConstants.js | 8 +++++++ server/job/job-costing.js | 3 --- 10 files changed, 58 insertions(+), 23 deletions(-) diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 10c74fa83..b01b34c82 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -28239,6 +28239,27 @@ templates + + credits_not_received_date + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + estimator_detail false diff --git a/client/src/components/job-totals-table/job-totals-table.component.jsx b/client/src/components/job-totals-table/job-totals-table.component.jsx index 6262e2835..d021fbecd 100644 --- a/client/src/components/job-totals-table/job-totals-table.component.jsx +++ b/client/src/components/job-totals-table/job-totals-table.component.jsx @@ -47,17 +47,21 @@ export function JobsTotalsTableComponent({ jobRO, job }) {
- - - - + + + + + + + + + + + + + - - - - - - + diff --git a/client/src/components/job-totals-table/job-totals.table.labor.component.jsx b/client/src/components/job-totals-table/job-totals.table.labor.component.jsx index 47a4a9969..55b41aaf6 100644 --- a/client/src/components/job-totals-table/job-totals.table.labor.component.jsx +++ b/client/src/components/job-totals-table/job-totals.table.labor.component.jsx @@ -103,11 +103,11 @@ export default function JobTotalsTableLabor({ job }) { <> - {t("jobs.labels.labor_rates_subtotal")} + {t("jobs.labels.labor_rates_subtotal")} - + {Dinero(job.job_totals.rates.rates_subtotal).toFormat()} @@ -121,7 +121,7 @@ export default function JobTotalsTableLabor({ job }) { {job.job_totals.rates.mapa.hours.toFixed(2)} - + {Dinero(job.job_totals.rates.mapa.total).toFormat()} @@ -133,19 +133,19 @@ export default function JobTotalsTableLabor({ job }) { {job.job_totals.rates.mash.hours.toFixed(2)} - + {Dinero(job.job_totals.rates.mash.total).toFormat()} - {t("jobs.labels.labor_rates_subtotal")} + {t("jobs.labels.rates_subtotal")} - + - {Dinero(job.job_totals.rates.rates_subtotal).toFormat()} + {Dinero(job.job_totals.rates.subtotal).toFormat()} diff --git a/client/src/components/job-totals-table/job-totals.table.other.component.jsx b/client/src/components/job-totals-table/job-totals.table.other.component.jsx index 00925007c..c06cd7232 100644 --- a/client/src/components/job-totals-table/job-totals.table.other.component.jsx +++ b/client/src/components/job-totals-table/job-totals.table.other.component.jsx @@ -79,7 +79,7 @@ export default function JobTotalsTableOther({ job }) { <> - {t("jobs.labels.additionaltotal")} + {t("jobs.labels.additionaltotal")} @@ -90,7 +90,7 @@ export default function JobTotalsTableOther({ job }) { - {t("jobs.labels.subletstotal")} + {t("jobs.labels.subletstotal")} diff --git a/client/src/components/job-totals-table/job-totals.table.parts.component.jsx b/client/src/components/job-totals-table/job-totals.table.parts.component.jsx index ca1eef5b3..d71e033b4 100644 --- a/client/src/components/job-totals-table/job-totals.table.parts.component.jsx +++ b/client/src/components/job-totals-table/job-totals.table.parts.component.jsx @@ -70,7 +70,9 @@ export default function JobTotalsTableParts({ job }) { }} summary={() => ( - {t("jobs.labels.partstotal")} + + {t("jobs.labels.partstotal")} + diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 99da29928..ad3484ac4 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1705,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", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 225b47151..4d77ac2f3 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1705,6 +1705,7 @@ "vendor": "" }, "templates": { + "credits_not_received_date": "", "estimator_detail": "", "estimator_summary": "", "hours_sold_detail_closed": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 6ca6cbff4..c308bc2f7 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1705,6 +1705,7 @@ "vendor": "" }, "templates": { + "credits_not_received_date": "", "estimator_detail": "", "estimator_summary": "", "hours_sold_detail_closed": "", diff --git a/client/src/utils/TemplateConstants.js b/client/src/utils/TemplateConstants.js index e6f5d9453..1a6b49802 100644 --- a/client/src/utils/TemplateConstants.js +++ b/client/src/utils/TemplateConstants.js @@ -519,6 +519,14 @@ export const TemplateList = (type, context) => { //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" diff --git a/server/job/job-costing.js b/server/job/job-costing.js index b85e87f66..5d7fba619 100644 --- a/server/job/job-costing.js +++ b/server/job/job-costing.js @@ -3,9 +3,6 @@ const queries = require("../graphql-client/queries"); //const client = require("../graphql-client/graphql-client").client; const _ = require("lodash"); const GraphQLClient = require("graphql-request").GraphQLClient; -const { - ExportCustomJobInstance, -} = require("twilio/lib/rest/bulkexports/v1/export/exportCustomJob"); // Dinero.defaultCurrency = "USD"; // Dinero.globalLocale = "en-CA"; From 09ce789d4cf8bc60c5c76810dea18d036ec8adf7 Mon Sep 17 00:00:00 2001 From: Patrick Fic <> Date: Tue, 6 Apr 2021 15:57:14 -0700 Subject: [PATCH 4/4] IO-837 Fix Tech Clocking issue --- .../tech-job-clock-out-button.component.jsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx b/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx index d6d782556..a36c9a856 100644 --- a/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx +++ b/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx @@ -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");