diff --git a/client/src/pages/jobs-admin/jobs-admin.page.jsx b/client/src/pages/jobs-admin/jobs-admin.page.jsx
index 65635618b..e7f9606d8 100644
--- a/client/src/pages/jobs-admin/jobs-admin.page.jsx
+++ b/client/src/pages/jobs-admin/jobs-admin.page.jsx
@@ -1,10 +1,12 @@
-import { useQuery } from "@apollo/client/react";
-import { Button, Card, Col, Result, Row, Space, Typography } from "antd";
-import { useEffect, useState } from "react";
+import { useMutation, useQuery } from "@apollo/client/react";
+import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
+import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
+import { some } from "lodash";
+import axios from "axios";
import AlertComponent from "../../components/alert/alert.component";
import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.component";
import ScoreboardAddButton from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component";
@@ -21,14 +23,16 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
import NotFound from "../../components/not-found/not-found.component";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
-import { GET_JOB_BY_PK } from "../../graphql/jobs.queries";
+import { GET_JOB_BY_PK, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
+import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
+import AuditTrailMapping from "../../utils/AuditTrailMappings";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -36,7 +40,8 @@ const mapStateToProps = createStructuredSelector({
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
- setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
+ setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
+ insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
const colSpan = {
@@ -50,7 +55,7 @@ const cardStyle = {
height: "100%"
};
-export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop }) {
+export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop, insertAuditTrail }) {
const { jobId } = useParams();
const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
variables: { id: jobId },
@@ -61,6 +66,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
const { socket } = useSocket(); // Extract socket from context
const notification = useNotification();
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
+ const [showConvertModal, setShowConvertModal] = useState(false);
+ const [convertLoading, setConvertLoading] = useState(false);
+ const [form] = Form.useForm();
+ const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
+ const allFormValues = Form.useWatch([], form);
// Get Fortellis treatment for proper DMS mode detection
const {
@@ -105,13 +115,53 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
const handleEarlyROSuccess = (result) => {
notification.success({
- title: t("jobs.successes.early_ro_created", "Early RO Created"),
- message: `RO Number: ${result.roNumber || "N/A"}`
+ title: t("jobs.successes.early_ro_created"),
+ description: `RO Number: ${result.roNumber || "N/A"}`
});
setShowEarlyROModal(false);
refetch?.();
};
+ const handleConvert = async ({ employee_csr, category, ...values }) => {
+ if (!job?.id) return;
+ setConvertLoading(true);
+ const res = await mutationConvertJob({
+ variables: {
+ jobId: job.id,
+ job: {
+ converted: true,
+ ...(bodyshop?.enforce_conversion_csr ? { employee_csr } : {}),
+ ...(bodyshop?.enforce_conversion_category ? { category } : {}),
+ ...values
+ }
+ }
+ });
+
+ if (values.ca_gst_registrant) {
+ await axios.post("/job/totalsssu", {
+ id: job.id
+ });
+ }
+
+ if (!res.errors) {
+ refetch();
+ notification.success({
+ title: t("jobs.successes.converted")
+ });
+
+ insertAuditTrail({
+ jobid: job.id,
+ operation: AuditTrailMapping.jobconverted(res.data.update_jobs.returning[0].ro_number),
+ type: "jobconverted"
+ });
+
+ setShowConvertModal(false);
+ }
+ setConvertLoading(false);
+ };
+
+ const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
+
if (loading) return
;
if (error) return
;
if (!data.jobs_by_pk) return
;
@@ -138,7 +188,12 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
{isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && (
+ )}
+ {isReynoldsMode && !job?.converted && !job?.dms_id && (
+
)}
@@ -176,6 +231,161 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
socket={socket}
job={job}
/>
+
+ {/* Convert without Early RO Modal */}
+
setShowConvertModal(false)}
+ title={t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
+ footer={null}
+ width={700}
+ destroyOnHidden
+ >
+
+
+
+ {bodyshop?.enforce_class && (
+
+
+
+ )}
+ {bodyshop?.enforce_referral && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {bodyshop?.enforce_conversion_csr && (
+
+
+
+ )}
+ {bodyshop?.enforce_conversion_category && (
+
+
+
+ )}
+ {bodyshop?.region_config?.toLowerCase().startsWith("ca") && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/client/src/pages/jobs-close/jobs-close.component.jsx b/client/src/pages/jobs-close/jobs-close.component.jsx
index cf0b1d620..c12695afd 100644
--- a/client/src/pages/jobs-close/jobs-close.component.jsx
+++ b/client/src/pages/jobs-close/jobs-close.component.jsx
@@ -9,6 +9,7 @@ import {
Form,
Input,
InputNumber,
+ Modal,
Popconfirm,
Row,
Select,
@@ -42,7 +43,7 @@ import { setModalContext } from "../../redux/modals/modals.actions.js";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
-import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
+import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -71,6 +72,11 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const notification = useNotification();
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
+ const dmsMode = getDmsMode(bodyshop, "off");
+ const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
+ const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
+ const canSendToDMS = !isReynoldsMode || hasEarlyRO;
+ const [showEarlyROModal, setShowEarlyROModal] = useState(false);
const {
treatments: { Qb_Multi_Ar, ClosingPeriod }
@@ -82,18 +88,18 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const handleFinish = async ({ removefromproduction, ...values }) => {
setLoading(true);
-
+
// Validate that all joblines have valid IDs
- const joblinesWithIds = values.joblines.filter(jl => jl && jl.id);
+ const joblinesWithIds = values.joblines.filter((jl) => jl && jl.id);
if (joblinesWithIds.length !== values.joblines.length) {
notification.error({
title: t("jobs.errors.invalidjoblines"),
- message: t("jobs.errors.missingjoblineids")
+ description: t("jobs.errors.missingjoblineids")
});
setLoading(false);
return;
}
-
+
const result = await client.mutate({
mutation: generateJobLinesUpdatesForInvoicing(values.joblines)
});
@@ -208,9 +214,17 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
{bodyshopHasDmsKey(bodyshop) && (
-
-
-
+ <>
+ {canSendToDMS ? (
+
+
+
+ ) : (
+
+ )}
+ >
)}
);
}
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index 5f5c5514f..626ab5e4c 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
- "alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export."
+ "alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export.",
+ "earlyrorequired": "Early RO Required",
+ "earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
},
"labels": {
"refreshallocations": "Refresh to see DMS Allocations."
@@ -1244,6 +1246,7 @@
"deselectall": "Deselect All",
"download": "Download",
"edit": "Edit",
+ "gotoadmin": "Go to Admin Panel",
"login": "Login",
"next": "Next",
"ok": "Ok",
@@ -1622,11 +1625,13 @@
"changestatus": "Change Status",
"changestimator": "Change Estimator",
"convert": "Convert",
+ "convertwithoutearlyro": "Convert without Early RO",
"createiou": "Create IOU",
"deliver": "Deliver",
"deliver_quick": "Quick Deliver",
"dms": {
"addpayer": "Add Payer",
+ "createearlyro": "Create RR RO",
"createnewcustomer": "Create New Customer",
"findmakemodelcode": "Find Make/Model Code",
"getmakes": "Get Makes",
@@ -1635,6 +1640,7 @@
},
"post": "Post",
"refetchmakesmodels": "Refetch Make and Model Codes",
+ "update_ro": "Update RO",
"usegeneric": "Use Generic Customer",
"useselected": "Use Selected Customer"
},
@@ -1794,6 +1800,7 @@
},
"cost": "Cost",
"cost_dms_acctnumber": "Cost DMS Acct #",
+ "customer": "Customer #",
"dms_make": "DMS Make",
"dms_model": "DMS Model",
"dms_model_override": "Override DMS Make/Model",
@@ -2107,6 +2114,11 @@
"damageto": "Damage to $t(jobs.fields.area_of_damage_impact.{{area_of_damage}}).",
"defaultstory": "B/S RO: {{ro_number}}. Owner: {{ownr_nm}}. Insurance Co: {{ins_co_nm}}. Claim/PO #: {{clm_po}}",
"disablebillwip": "Cost and WIP for bills has been ignored per shop configuration.",
+ "earlyro": {
+ "created": "Early RO Created:",
+ "fields": "Required fields:",
+ "willupdate": "This will update the existing RO with full job data."
+ },
"invoicedatefuture": "Invoice date must be today or in the future for CDK posting.",
"kmoutnotgreaterthankmin": "Mileage out must be greater than mileage in.",
"logs": "Logs",
@@ -2264,6 +2276,7 @@
"delete": "Job deleted successfully.",
"deleted": "Job deleted successfully.",
"duplicated": "Job duplicated successfully. ",
+ "early_ro_created": "Early RO Created",
"exported": "Job(s) exported successfully. ",
"invoiced": "Job closed and invoiced successfully.",
"ioucreated": "IOU created successfully. Click to see.",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 6559e1038..664c5081c 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
- "alreadyexported": ""
+ "alreadyexported": "",
+ "earlyrorequired": "",
+ "earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
@@ -1244,6 +1246,7 @@
"deselectall": "",
"download": "",
"edit": "Editar",
+ "gotoadmin": "",
"login": "",
"next": "",
"ok": "",
@@ -1622,11 +1625,13 @@
"changestatus": "Cambiar Estado",
"changestimator": "",
"convert": "Convertir",
+ "convertwithoutearlyro": "",
"createiou": "",
"deliver": "",
"deliver_quick": "",
"dms": {
"addpayer": "",
+ "createearlyro": "",
"createnewcustomer": "",
"findmakemodelcode": "",
"getmakes": "",
@@ -1635,6 +1640,7 @@
},
"post": "",
"refetchmakesmodels": "",
+ "update_ro": "",
"usegeneric": "",
"useselected": ""
},
@@ -1794,6 +1800,7 @@
},
"cost": "",
"cost_dms_acctnumber": "",
+ "customer": "",
"dms_make": "",
"dms_model": "",
"dms_model_override": "",
@@ -2107,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
+ "earlyro": {
+ "created": "",
+ "fields": "",
+ "willupdate": ""
+ },
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2264,6 +2276,7 @@
"delete": "",
"deleted": "Trabajo eliminado con éxito.",
"duplicated": "",
+ "early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index 00af4c6aa..67a14d5ff 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
- "alreadyexported": ""
+ "alreadyexported": "",
+ "earlyrorequired": "",
+ "earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
@@ -1244,6 +1246,7 @@
"deselectall": "",
"download": "",
"edit": "modifier",
+ "gotoadmin": "",
"login": "",
"next": "",
"ok": "",
@@ -1622,11 +1625,13 @@
"changestatus": "Changer le statut",
"changestimator": "",
"convert": "Convertir",
+ "convertwithoutearlyro": "",
"createiou": "",
"deliver": "",
"deliver_quick": "",
"dms": {
"addpayer": "",
+ "createearlyro": "",
"createnewcustomer": "",
"findmakemodelcode": "",
"getmakes": "",
@@ -1635,6 +1640,7 @@
},
"post": "",
"refetchmakesmodels": "",
+ "update_ro": "",
"usegeneric": "",
"useselected": ""
},
@@ -1794,6 +1800,7 @@
},
"cost": "",
"cost_dms_acctnumber": "",
+ "customer": "",
"dms_make": "",
"dms_model": "",
"dms_model_override": "",
@@ -2107,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
+ "earlyro": {
+ "created": "",
+ "fields": "",
+ "willupdate": ""
+ },
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2264,6 +2276,7 @@
"delete": "",
"deleted": "Le travail a bien été supprimé.",
"duplicated": "",
+ "early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",
diff --git a/server.js b/server.js
index 07901ab6e..33cbe014c 100644
--- a/server.js
+++ b/server.js
@@ -40,6 +40,8 @@ const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket");
const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue");
+const { loadChatterApiQueue } = require("./server/data/queues/chatterApiQueue");
+const { processChatterApiJob } = require("./server/data/chatter-api");
const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000;
@@ -391,6 +393,15 @@ const applySocketIO = async ({ server, app }) => {
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
+ // Load chatterApi queue with processJob function and redis helpers
+ const chatterApiQueue = await loadChatterApiQueue({
+ pubClient,
+ logger,
+ processJob: processChatterApiJob,
+ getChatterToken: redisHelpers.getChatterToken,
+ setChatterToken: redisHelpers.setChatterToken
+ });
+
// Assuming loadEmailQueue and loadAppQueue return Promises
const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([
loadEmailQueue(queueSettings),
@@ -410,6 +421,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
notificationsFcmQueue.on("error", (error) => {
logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
+
+ chatterApiQueue.on("error", (error) => {
+ logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
+ });
};
/**
diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js
index caee129f0..1aecb7cfb 100644
--- a/server/data/chatter-api.js
+++ b/server/data/chatter-api.js
@@ -40,7 +40,6 @@ const logger = require("../utils/logger");
const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client");
const client = require("../graphql-client/graphql-client").client;
-const { sendServerEmail } = require("../email/sendemail");
const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION";
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
@@ -53,74 +52,98 @@ const clientCache = new Map(); // companyId -> ChatterApiClient
const tokenInFlight = new Map(); // companyId -> Promise