import { useApolloClient, useMutation } from "@apollo/client/react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Checkbox, Form, Modal, Space } from "antd"; import _ from "lodash"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { INSERT_NEW_BILL } from "../../graphql/bills.queries"; import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries"; import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries"; import { QUERY_JOB_LBR_ADJUSTMENTS, UPDATE_JOB } from "../../graphql/jobs.queries"; import { MUTATION_MARK_RETURN_RECEIVED } from "../../graphql/parts-orders.queries"; import { insertAuditTrail } from "../../redux/application/application.actions"; import { toggleModalVisible } from "../../redux/modals/modals.actions"; import { selectBillEnterModal } from "../../redux/modals/modals.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; import { GenerateDocument } from "../../utils/RenderTemplate"; import { TemplateList } from "../../utils/TemplateConstants"; import confirmDialog from "../../utils/asyncConfirm"; import useLocalStorage from "../../utils/useLocalStorage"; import BillEnterAiScan from "../bill-enter-ai-scan/bill-enter-ai-scan.component.jsx"; import BillFormContainer from "../bill-form/bill-form.container"; import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility"; import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility"; import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility"; import { handleUpload } from "../documents-upload/documents-upload.utility"; import BillAiFeedback from "../bill-ai-feedback/bill-ai-feedback.component.jsx"; const mapStateToProps = createStructuredSelector({ billEnterModal: selectBillEnterModal, bodyshop: selectBodyshop, currentUser: selectCurrentUser }); const mapDispatchToProps = (dispatch) => ({ toggleModalVisible: () => dispatch(toggleModalVisible("billEnter")), insertAuditTrail: ({ jobid, billid, operation, type }) => dispatch(insertAuditTrail({ jobid, billid, operation, type })) }); const Templates = TemplateList("job_special"); function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, currentUser, insertAuditTrail }) { const [form] = Form.useForm(); const { t } = useTranslation(); const [enterAgain, setEnterAgain] = useState(false); const [insertBill] = useMutation(INSERT_NEW_BILL); const [updateJobLines] = useMutation(UPDATE_JOB_LINE); const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED); const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES); const [loading, setLoading] = useState(false); const [scanLoading, setScanLoading] = useState(false); const [isAiScan, setIsAiScan] = useState(false); const [rawAIData, setRawAIData] = useState(null); const client = useApolloClient(); const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false); const notification = useNotification(); const fileInputRef = useRef(null); const pollingIntervalRef = useRef(null); const formTopRef = useRef(null); const { treatments: { Enhanced_Payroll, Imgproxy, Bill_OCR_AI } } = useTreatmentsWithConfig({ attributes: {}, names: ["Enhanced_Payroll", "Imgproxy", "Bill_OCR_AI"], splitKey: bodyshop.imexshopid }); const formValues = useMemo(() => { return { ...billEnterModal.context.bill, //Added as a part of IO-2436 for capturing parts price changes. billlines: billEnterModal.context?.bill?.billlines?.map((line) => ({ ...line, original_actual_price: line.actual_price })), jobid: (billEnterModal.context.job && billEnterModal.context.job.id) || null, federal_tax_rate: (bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.federal_tax_rate) || 0, state_tax_rate: (bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.state_tax_rate) || 0, local_tax_rate: (bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.local_tax_rate) || 0 }; }, [billEnterModal, bodyshop]); const handleFinish = async (values) => { let totals = CalculateBillTotal(values); if (totals.discrepancy.getAmount() !== 0) { if (!(await confirmDialog(t("bills.labels.savewithdiscrepancy")))) { return; } } setLoading(true); // eslint-disable-next-line no-unused-vars const { upload, location, outstanding_returns, inventory, federal_tax_exempt, ...remainingValues } = values; let adjustmentsToInsert = {}; let payrollAdjustmentsToInsert = []; const r1 = await insertBill({ variables: { bill: [ { ...remainingValues, billlines: { data: remainingValues.billlines && remainingValues.billlines.map((i) => { const { deductedfromlbr, lbr_adjustment, // eslint-disable-next-line no-unused-vars location: lineLocation, // eslint-disable-next-line no-unused-vars part_type, // eslint-disable-next-line no-unused-vars create_ppc, // eslint-disable-next-line no-unused-vars original_actual_price, // eslint-disable-next-line no-unused-vars confidence, ...restI } = i; if (Enhanced_Payroll.treatment === "on") { if ( deductedfromlbr && true //payroll is on ) { payrollAdjustmentsToInsert.push({ id: i.joblineid, convertedtolbr: true, convertedtolbr_data: { mod_lb_hrs: lbr_adjustment.mod_lb_hrs * -1, mod_lbr_ty: lbr_adjustment.mod_lbr_ty } }); } } else { if (deductedfromlbr) { adjustmentsToInsert[lbr_adjustment.mod_lbr_ty] = (adjustmentsToInsert[lbr_adjustment.mod_lbr_ty] || 0) - restI.actual_price / lbr_adjustment.rate; } } return { ...restI, deductedfromlbr: deductedfromlbr, lbr_adjustment, joblineid: i.joblineid === "noline" ? null : i.joblineid, applicable_taxes: { federal: (i.applicable_taxes && i.applicable_taxes.federal) || false, state: (i.applicable_taxes && i.applicable_taxes.state) || false, local: (i.applicable_taxes && i.applicable_taxes.local) || false } }; }) } } ] }, refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"], awaitRefetchQueries: true }); await Promise.all( payrollAdjustmentsToInsert.map((li) => { return updateJobLines({ variables: { lineId: li.id, line: { convertedtolbr: li.convertedtolbr, convertedtolbr_data: li.convertedtolbr_data } } }); }) ); const adjKeys = Object.keys(adjustmentsToInsert); if (adjKeys.length > 0) { //Query the adjustments, merge, and update them. const existingAdjustments = await client.query({ query: QUERY_JOB_LBR_ADJUSTMENTS, variables: { id: values.jobid } }); const newAdjustments = _.cloneDeep(existingAdjustments.data.jobs_by_pk.lbr_adjustments); adjKeys.forEach((key) => { newAdjustments[key] = (newAdjustments[key] || 0) + adjustmentsToInsert[key]; insertAuditTrail({ jobid: values.jobid, operation: AuditTrailMapping.jobmodifylbradj({ mod_lbr_ty: key, hours: adjustmentsToInsert[key].toFixed(1) }), type: "jobmodifylbradj" }); }); const jobUpdate = client.mutate({ mutation: UPDATE_JOB, variables: { jobId: values.jobid, job: { lbr_adjustments: newAdjustments } } }); if (jobUpdate.errors) { notification.error({ title: t("jobs.errors.saving", { message: JSON.stringify(jobUpdate.errors) }) }); return; } } const markPolReceived = outstanding_returns && outstanding_returns.filter((o) => o.cm_received === true); if (markPolReceived && markPolReceived.length > 0) { const r2 = await updatePartsOrderLines({ variables: { partsLineIds: markPolReceived.map((p) => p.id) }, refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"] }); if (r2.errors) { setLoading(false); setEnterAgain(false); notification.error({ title: t("parts_orders.errors.updating", { message: JSON.stringify(r2.errors) }) }); } } if (r1.errors) { setLoading(false); setEnterAgain(false); notification.error({ title: t("bills.errors.creating", { message: JSON.stringify(r1.errors) }) }); } const billId = r1.data.insert_bills.returning[0].id; const markInventoryConsumed = inventory && inventory.filter((i) => i.consumefrominventory); if (markInventoryConsumed && markInventoryConsumed.length > 0) { const r2 = await updateInventoryLines({ variables: { InventoryIds: markInventoryConsumed.map((p) => p.id), consumedbybillid: billId } }); if (r2.errors) { setLoading(false); setEnterAgain(false); notification.error({ title: t("inventory.errors.updating", { message: JSON.stringify(r2.errors) }) }); } } //If it's not a credit memo, update the statuses. if (!values.is_credit_memo) { await Promise.all( remainingValues.billlines .filter((il) => il.joblineid !== "noline") .map((li) => { return updateJobLines({ variables: { lineId: li.joblineid, line: { location: li.location || location, status: bodyshop.md_order_statuses.default_received || "Received*", //Added parts price changes. ...(li.create_ppc && li.original_actual_price !== li.actual_price ? { act_price_before_ppc: li.original_actual_price, act_price: li.actual_price } : {}) } } }); }) ); } ///////////////////////// if (upload && upload.length > 0) { //insert Each of the documents? if (bodyshop.uselocalmediaserver) { upload.forEach((u) => { handleLocalUpload({ ev: { file: u.originFileObj }, context: { jobid: values.jobid, invoice_number: remainingValues.invoice_number, vendorid: remainingValues.vendorid }, notification }); }); } else { //Check if using Imgproxy or cloudinary if (Imgproxy.treatment === "on") { upload.forEach((u) => { handleUploadToImageProxy( { file: u.originFileObj }, { bodyshop: bodyshop, uploaded_by: currentUser.email, jobId: values.jobid, billId: billId, tagsArray: null, callback: null }, notification ); }); } else { upload.forEach((u) => { handleUpload( { file: u.originFileObj }, { bodyshop: bodyshop, uploaded_by: currentUser.email, jobId: values.jobid, billId: billId, tagsArray: null, callback: null }, notification ); }); } } } /////////////////////////// setLoading(false); notification.success({ title: t("bills.successes.created") }); if (generateLabel) { GenerateDocument( { name: Templates.parts_invoice_label_single.key, variables: { id: billId } }, {}, "p", null, notification ); } if (billEnterModal.actions.refetch) billEnterModal.actions.refetch(); insertAuditTrail({ jobid: values.jobid, billid: billId, operation: AuditTrailMapping.billposted(r1.data.insert_bills.returning[0].invoice_number), type: "billposted" }); if (enterAgain) { form.resetFields(); form.setFieldsValue({ ...formValues, vendorid: values.vendorid, billlines: [] }); setIsAiScan(false); setRawAIData(null); // form.resetFields(); } else { toggleModalVisible(); } setEnterAgain(false); }; const handleCancel = () => { const r = window.confirm(t("general.labels.cancel")); if (r === true) { // Clean up polling on cancel if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } setScanLoading(false); setIsAiScan(false); setRawAIData(null); toggleModalVisible(); } }; //Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up. const setPollingIntervalRef = (func) => { pollingIntervalRef.current = func; }; useEffect(() => { if (enterAgain) form.submit(); }, [enterAgain, form]); useEffect(() => { if (billEnterModal.open) { form.setFieldsValue(formValues); } else { form.resetFields(); // Clean up polling on modal close if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } setScanLoading(false); setIsAiScan(false); setRawAIData(null); } }, [billEnterModal.open, form, formValues]); // Cleanup on unmount useEffect(() => { return () => { if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }; }, []); return ( {t("bills.labels.new")} {Bill_OCR_AI.treatment === "on" && ( )} } width={"98%"} open={billEnterModal.open} okText={t("general.actions.save")} keyboard="false" onOk={() => form.submit()} onCancel={handleCancel} afterClose={() => { form.resetFields(); setLoading(false); }} footer={ {isAiScan && } setGenerateLabel(e.target.checked)}> {t("bills.labels.generatepartslabel")} {billEnterModal.context && billEnterModal.context.id ? null : ( )} } destroyOnHidden >
{ setEnterAgain(false); // Scroll to the top of the form to show validation errors if (errorInfo.errorFields && errorInfo.errorFields.length > 0) { setTimeout(() => { formTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }, 100); } }} >
); } export default connect(mapStateToProps, mapDispatchToProps)(BillEnterModalContainer);