Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2997)

feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel
This commit is contained in:
Dave Richer
2026-02-12 22:13:38 +00:00
9 changed files with 332 additions and 27 deletions

View File

@@ -208,16 +208,16 @@ export default function RRPostForm({
});
};
// Check if early RO was created (job has dms_id)
const hasEarlyRO = !!job?.dms_id;
// Check if early RO was created (job has all early RO fields)
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
return (
<Card title={t("jobs.labels.dms.postingform")}>
{hasEarlyRO && (
<Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}>
Early RO Created: {job.dms_id}
{t("jobs.labels.dms.earlyro.created")} {job.dms_id}
<br />
<Typography.Text type="secondary">This will update the existing RO with full job data.</Typography.Text>
<Typography.Text type="secondary">{t("jobs.labels.dms.earlyro.willupdate")}</Typography.Text>
</Typography.Paragraph>
)}
<Form
@@ -372,7 +372,7 @@ export default function RRPostForm({
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{hasEarlyRO ? t("jobs.actions.dms.update_ro", "Update RO") : t("jobs.actions.dms.post")}
{hasEarlyRO ? t("jobs.actions.dms.update_ro") : t("jobs.actions.dms.post")}
</Button>
</Space>
);

View File

@@ -106,8 +106,8 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
setEarlyRoCreated(true); // Mark early RO as created
setEarlyRoCreatedThisSession(true); // Mark as created in this session
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"}`
});
// Delay refetch to keep success message visible for 2 seconds
setTimeout(() => {

View File

@@ -2001,6 +2001,9 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
qb_multiple_payers
lbr_adjustments
ownr_ea
dms_id
dms_customer_id
dms_advisor_id
payments {
amount
created_at

View File

@@ -426,6 +426,24 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
// Check if Reynolds mode requires early RO
const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id);
if (isRrMode && !hasEarlyRO) {
return (
<Result
status="warning"
title={t("dms.errors.earlyrorequired")}
subTitle={t("dms.errors.earlyrorequired.message")}
extra={
<Link to={`/manage/jobs/${jobId}/admin`}>
<Button type="primary">{t("general.actions.gotoadmin")}</Button>
</Link>
}
/>
);
}
return (
<div>
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />

View File

@@ -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 <LoadingSpinner />;
if (error) return <AlertComponent title={error.message} type="error" />;
if (!data.jobs_by_pk) return <NotFound />;
@@ -138,7 +188,12 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
<JobsAdminRemoveAR job={data ? data.jobs_by_pk : {}} />
{isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && (
<Button className="ant-btn ant-btn-default" onClick={() => setShowEarlyROModal(true)}>
Create RR RO
{t("jobs.actions.dms.createearlyro", "Create RR RO")}
</Button>
)}
{isReynoldsMode && !job?.converted && !job?.dms_id && (
<Button type="primary" danger onClick={() => setShowConvertModal(true)}>
{t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
</Button>
)}
</Space>
@@ -176,6 +231,161 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
socket={socket}
job={job}
/>
{/* Convert without Early RO Modal */}
<Modal
open={showConvertModal}
onCancel={() => setShowConvertModal(false)}
title={t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
footer={null}
width={700}
destroyOnHidden
>
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job?.towin,
ca_gst_registrant: job?.ca_gst_registrant,
employee_csr: job?.employee_csr,
category: job?.category,
referral_source: job?.referral_source,
referral_source_extra: job?.referral_source_extra ?? ""
}}
>
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: true
}
]}
>
<Select showSearch>
{bodyshop?.md_ins_cos?.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
{bodyshop?.enforce_class && (
<Form.Item
name={"class"}
label={t("jobs.fields.class")}
rules={[
{
required: bodyshop.enforce_class
}
]}
>
<Select>
{bodyshop?.md_classes?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.enforce_referral && (
<>
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
rules={[
{
required: bodyshop.enforce_referral
}
]}
>
<Select>
{bodyshop?.md_referral_sources?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop?.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
}
]}
>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
>
{bodyshop?.employees
?.filter((emp) => emp.active)
?.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
}
]}
>
<Select allowClear>
{bodyshop?.md_categories?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.region_config?.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap style={{ marginTop: 16 }}>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</Modal>
</RbacWrapper>
);
}

View File

@@ -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
</Button>
</Popconfirm>
{bodyshopHasDmsKey(bodyshop) && (
<Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link>
<>
{canSendToDMS ? (
<Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link>
) : (
<Button disabled={job.date_exported || !jobRO} onClick={() => setShowEarlyROModal(true)}>
{t("jobs.actions.sendtodms")}
</Button>
)}
</>
)}
<Button
onClick={() => {
@@ -527,6 +541,30 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Divider />
<JobsCloseLines job={job} />
</Form>
{/* Early RO Required Modal */}
<Modal
open={showEarlyROModal}
onCancel={() => setShowEarlyROModal(false)}
footer={null}
title={
<Space>
<Typography.Text type="warning" style={{ fontSize: "1.2em" }}>
</Typography.Text>
<span>{t("dms.errors.earlyrorequired")}</span>
</Space>
}
>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Typography.Paragraph>{t("dms.errors.earlyrorequired.message")}</Typography.Paragraph>
<Link to={`/manage/jobs/${job.id}/admin`}>
<Button type="primary" block onClick={() => setShowEarlyROModal(false)}>
{t("general.actions.gotoadmin")}
</Button>
</Link>
</Space>
</Modal>
</div>
);
}

View File

@@ -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"
},
@@ -2108,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",
@@ -2265,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.",

View File

@@ -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": ""
},
@@ -2108,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2265,6 +2276,7 @@
"delete": "",
"deleted": "Trabajo eliminado con éxito.",
"duplicated": "",
"early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",

View File

@@ -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": ""
},
@@ -2108,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2265,6 +2276,7 @@
"delete": "",
"deleted": "Le travail a bien été supprimé.",
"duplicated": "",
"early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",