518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
|
import { connect } from "react-redux";
|
|
import { useTranslation } from "react-i18next";
|
|
import { createStructuredSelector } from "reselect";
|
|
import queryString from "query-string";
|
|
import { useQuery } from "@apollo/client";
|
|
import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd";
|
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
|
|
|
import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries";
|
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
|
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
|
|
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
|
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
|
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode, isWssMode } from "../../utils/dmsUtils.js";
|
|
import legacySocket from "../../utils/legacySocket";
|
|
|
|
import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component";
|
|
import AlertComponent from "../../components/alert/alert.component";
|
|
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
|
|
import DmsPostForm from "../../components/dms-post-form/dms-post-form.component";
|
|
import DmsLogEvents from "../../components/dms-log-events/dms-log-events.component";
|
|
import DmsCustomerSelector from "../../components/dms-customer-selector/dms-customer-selector.component";
|
|
import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-allocations-summary.component";
|
|
import RrAllocationsSummary from "../../components/dms-allocations-summary/rr-dms-allocations-summary.component";
|
|
|
|
const mapStateToProps = createStructuredSelector({
|
|
bodyshop: selectBodyshop
|
|
});
|
|
|
|
const mapDispatchToProps = (dispatch) => ({
|
|
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
|
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
|
|
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
|
});
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(DmsContainer);
|
|
|
|
const DMS_SOCKET_EVENTS = {
|
|
[DMS_MAP.reynolds]: {
|
|
log: "rr-log-event",
|
|
partialResult: "rr-export-job:result",
|
|
validationNeeded: "rr-validation-required",
|
|
exportSuccess: "export-success",
|
|
exportFailed: "export-failed"
|
|
},
|
|
[DMS_MAP.fortellis]: {
|
|
log: "fortellis-log-event",
|
|
exportSuccess: "export-success",
|
|
exportFailed: "export-failed"
|
|
},
|
|
[DMS_MAP.cdk]: {
|
|
log: "log-event",
|
|
exportSuccess: "export-success",
|
|
exportFailed: "export-failed"
|
|
},
|
|
[DMS_MAP.pbs]: {
|
|
log: "log-event",
|
|
exportSuccess: "export-success",
|
|
exportFailed: "export-failed"
|
|
}
|
|
};
|
|
|
|
export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
|
|
const { t } = useTranslation();
|
|
const [resetAfterReconnect, setResetAfterReconnect] = useState(false);
|
|
|
|
const history = useNavigate();
|
|
const search = queryString.parse(useLocation().search);
|
|
const { jobId } = search;
|
|
|
|
const notification = useNotification();
|
|
|
|
const {
|
|
treatments: { Fortellis }
|
|
} = useSplitTreatments({
|
|
attributes: {},
|
|
names: ["Fortellis"],
|
|
splitKey: bodyshop.imexshopid
|
|
});
|
|
|
|
// Compute a single normalized mode and pick the proper socket
|
|
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
|
|
const isRrMode = mode === DMS_MAP.reynolds;
|
|
|
|
const { socket: wsssocket } = useSocket();
|
|
const activeSocket = useMemo(() => (isWssMode(mode) ? wsssocket : legacySocket), [mode, wsssocket]);
|
|
|
|
const [isConnected, setIsConnected] = useState(!!activeSocket?.connected);
|
|
|
|
// One place to set log level
|
|
const [logLevel, setLogLevel] = useState(mode === DMS_MAP.pbs ? "INFO" : "DEBUG");
|
|
|
|
const setActiveLogLevel = (level) => {
|
|
if (!activeSocket) return;
|
|
activeSocket.emit("set-log-level", level);
|
|
};
|
|
|
|
const [logs, setLogs] = useState([]);
|
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
const [detailsNonce, setDetailsNonce] = useState(0);
|
|
const [colorizeJson, setColorizeJson] = useState(false);
|
|
|
|
const [rrOpenRoLimit, setRrOpenRoLimit] = useState(false);
|
|
const clearRrOpenRoLimit = () => setRrOpenRoLimit(false);
|
|
|
|
const [rrValidationPending, setrrValidationPending] = useState(false);
|
|
|
|
const { loading, error, data } = useQuery(QUERY_JOB_EXPORT_DMS, {
|
|
variables: { id: jobId },
|
|
skip: !jobId,
|
|
fetchPolicy: "network-only",
|
|
nextFetchPolicy: "network-only"
|
|
});
|
|
|
|
const logsRef = useRef(null);
|
|
|
|
const toggleDetailsAll = () => {
|
|
setDetailsOpen((v) => !v);
|
|
setDetailsNonce((n) => n + 1);
|
|
};
|
|
|
|
// Channel names per mode to avoid branching everywhere
|
|
const channels = useMemo(() => DMS_SOCKET_EVENTS[mode] || {}, [mode]);
|
|
|
|
const providerLabel = useMemo(
|
|
() =>
|
|
({
|
|
[DMS_MAP.reynolds]: "Reynolds",
|
|
[DMS_MAP.fortellis]: "Fortellis",
|
|
[DMS_MAP.cdk]: "CDK",
|
|
[DMS_MAP.pbs]: "PBS"
|
|
})[mode] || "DMS",
|
|
[mode]
|
|
);
|
|
|
|
const transportLabel = isWssMode(mode) ? "(WSS)" : "(WS)";
|
|
|
|
const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${
|
|
isConnected ? "Connected" : "Disconnected"
|
|
}`;
|
|
|
|
const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
|
|
|
|
// 🔄 Hard reset of local + server-side DMS context when the page/job loads
|
|
useEffect(() => {
|
|
// Clear any local ephemeral state that might be stale
|
|
setLogs([]);
|
|
setRrOpenRoLimit(false);
|
|
setrrValidationPending(false);
|
|
|
|
if (!activeSocket) return;
|
|
|
|
const emitReset = () => {
|
|
// Generic reset; server can branch on `mode` if needed
|
|
activeSocket.emit("dms-reset-context", { jobId, mode });
|
|
};
|
|
|
|
if (activeSocket.connected) {
|
|
// WSS usually lands here
|
|
emitReset();
|
|
return;
|
|
}
|
|
|
|
// Legacy WS: wait for the connect before emitting reset
|
|
const handleConnectOnce = () => {
|
|
emitReset();
|
|
activeSocket.off("connect", handleConnectOnce);
|
|
};
|
|
|
|
activeSocket.on("connect", handleConnectOnce);
|
|
|
|
return () => {
|
|
activeSocket.off("connect", handleConnectOnce);
|
|
};
|
|
}, [jobId, mode, activeSocket]);
|
|
|
|
const handleExportFailed = (payload = {}) => {
|
|
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
|
|
|
|
const msg =
|
|
friendlyMessage ||
|
|
errText ||
|
|
t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again.");
|
|
|
|
const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS");
|
|
|
|
const isRrOpenRoLimit =
|
|
isRrMode &&
|
|
(vendorStatusCode === 507 ||
|
|
/MAX_OPEN_ROS/i.test(String(errorCode || "")) ||
|
|
/maximum number of open repair orders/i.test(String(msg || "").toLowerCase()));
|
|
|
|
const sev = severity || (isRrOpenRoLimit ? "warning" : "error");
|
|
|
|
if (!isRrOpenRoLimit) {
|
|
const notifyKind = sev === "warning" && typeof notification.warning === "function" ? "warning" : "error";
|
|
notification[notifyKind]({ message: vendorTitle, description: msg, duration: 10 });
|
|
} else {
|
|
setRrOpenRoLimit(true);
|
|
}
|
|
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
timestamp: new Date(),
|
|
level: (sev || "error").toUpperCase(),
|
|
message: `${vendorTitle}: ${msg}`,
|
|
meta: { errorCode, vendorStatusCode, raw: payload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
|
|
}
|
|
]);
|
|
};
|
|
|
|
// keep this in sync if mode/socket flips
|
|
useEffect(() => {
|
|
setIsConnected(!!activeSocket?.connected);
|
|
}, [activeSocket]);
|
|
|
|
useEffect(() => {
|
|
document.title = t("titles.dms", {
|
|
app: InstanceRenderManager({ imex: "$t(titles.imexonline)", rome: "$t(titles.romeonline)" })
|
|
});
|
|
setSelectedHeader("dms");
|
|
setBreadcrumbs([
|
|
{ link: "/manage/accounting/receivables", label: t("titles.bc.accounting-receivables") }
|
|
// { link: "/manage/dms", label: t("titles.bc.dms") }
|
|
]);
|
|
}, [t, setBreadcrumbs, setSelectedHeader]);
|
|
|
|
// Socket wiring (mode-aware)
|
|
useEffect(() => {
|
|
if (!activeSocket) return;
|
|
|
|
// Connect legacy socket if needed
|
|
if (!isWssMode(mode)) {
|
|
if (activeSocket.disconnected) activeSocket.connect();
|
|
}
|
|
|
|
// Set log level now and on connect/reconnect
|
|
setActiveLogLevel(logLevel);
|
|
|
|
const onConnect = () => {
|
|
setIsConnected(true);
|
|
setActiveLogLevel(logLevel);
|
|
|
|
if (resetAfterReconnect) {
|
|
activeSocket.emit("dms-reset-context", { jobId, mode });
|
|
setResetAfterReconnect(false);
|
|
}
|
|
};
|
|
|
|
const onDisconnect = () => setIsConnected(false);
|
|
|
|
const onReconnect = () => {
|
|
setIsConnected(true);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
timestamp: new Date(),
|
|
level: "warn",
|
|
message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service`
|
|
}
|
|
]);
|
|
};
|
|
|
|
const onConnectError = (err) => {
|
|
// Legacy and WSS both emit this
|
|
console.log(`connect_error due to ${err}`, err);
|
|
notification.error({ message: err.message });
|
|
};
|
|
|
|
activeSocket.on("disconnect", onDisconnect);
|
|
activeSocket.on("connect", onConnect);
|
|
activeSocket.on("reconnect", onReconnect);
|
|
activeSocket.on("connect_error", onConnectError);
|
|
|
|
// Logs
|
|
const onLog = isRrMode
|
|
? (payload = {}) => {
|
|
const normalized = {
|
|
timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(),
|
|
level: (payload.level || "INFO").toUpperCase(),
|
|
message: payload.message || payload.msg || "",
|
|
meta: payload.meta ?? payload.ctx ?? payload.details ?? null
|
|
};
|
|
setLogs((prev) => [...prev, normalized]);
|
|
}
|
|
: (payload) => setLogs((prev) => [...prev, payload]);
|
|
|
|
if (channels.log) activeSocket.on(channels.log, onLog);
|
|
|
|
// Success / Failed
|
|
const onExportSuccess = (payload) => {
|
|
const jobIdResolved = payload?.jobId ?? payload;
|
|
notification.success({ message: t("jobs.successes.exported") });
|
|
|
|
// Clear RR Validation flag if any
|
|
setrrValidationPending(false);
|
|
|
|
insertAuditTrail({
|
|
jobid: jobIdResolved,
|
|
operation: AuditTrailMapping.jobexported(),
|
|
type: "jobexported"
|
|
});
|
|
history("/manage/accounting/receivables");
|
|
};
|
|
|
|
if (channels.exportSuccess) activeSocket.on(channels.exportSuccess, onExportSuccess);
|
|
if (channels.exportFailed) activeSocket.on(channels.exportFailed, handleExportFailed);
|
|
|
|
// RR-only extras
|
|
|
|
const onPartialResult = () => {
|
|
setrrValidationPending(true);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
timestamp: new Date(),
|
|
level: "INFO",
|
|
message:
|
|
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
|
|
}
|
|
]);
|
|
notification.info({
|
|
message: "Reynolds RO created",
|
|
description:
|
|
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
|
|
duration: 8
|
|
});
|
|
};
|
|
|
|
const onValidationRequired = (payload) => {
|
|
setrrValidationPending(true);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
timestamp: new Date(),
|
|
level: "INFO",
|
|
message:
|
|
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
|
|
meta: { payload }
|
|
}
|
|
]);
|
|
};
|
|
|
|
if (isRrMode && channels.partialResult) activeSocket.on(channels.partialResult, onPartialResult);
|
|
if (isRrMode && channels.validationNeeded) activeSocket.on(channels.validationNeeded, onValidationRequired);
|
|
|
|
return () => {
|
|
activeSocket.off("connect", onConnect);
|
|
activeSocket.off("reconnect", onReconnect);
|
|
activeSocket.off("connect_error", onConnectError);
|
|
activeSocket.off("disconnect", onDisconnect);
|
|
|
|
if (channels.log) activeSocket.off(channels.log, onLog);
|
|
if (channels.exportSuccess) activeSocket.off(channels.exportSuccess, onExportSuccess);
|
|
if (channels.exportFailed) activeSocket.off(channels.exportFailed, handleExportFailed);
|
|
|
|
if (isRrMode && channels.partialResult) activeSocket.off(channels.partialResult, onPartialResult);
|
|
if (isRrMode && channels.validationNeeded) activeSocket.off(channels.validationNeeded, onValidationRequired);
|
|
|
|
// Only tear down legacy socket listeners; don't disconnect WSS from here
|
|
if (!isWssMode(mode)) {
|
|
activeSocket.removeAllListeners();
|
|
activeSocket.disconnect();
|
|
}
|
|
};
|
|
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]);
|
|
|
|
// RR finalize callback (unchanged public behavior)
|
|
const handleRrValidationFinished = () => {
|
|
if (!jobId) return;
|
|
if (!isWssMode(mode)) return; // RR is WSS-only
|
|
activeSocket.emit("rr-finalize-repair-order", { jobId }, (ack) => {
|
|
if (ack?.ok) return;
|
|
if (ack?.error) notification.error({ message: ack.error });
|
|
});
|
|
};
|
|
|
|
if (loading) return <LoadingSpinner />;
|
|
if (error) return <AlertComponent message={error.message} type="error" />;
|
|
|
|
if (!jobId || !bodyshopHasDmsKey(bodyshop) || !data?.jobs_by_pk)
|
|
return <Result status="404" title={t("general.errors.notfound")} />;
|
|
|
|
if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
|
|
|
|
return (
|
|
<div>
|
|
<AlertComponent style={{ marginBottom: 10 }} message={bannerMessage} type="warning" showIcon closable />
|
|
|
|
<Row gutter={[16, 16]}>
|
|
<Col md={24} lg={10} className="dms-equal-height-col">
|
|
{!isRrMode ? (
|
|
<DmsAllocationsSummary
|
|
key={resetKey}
|
|
title={
|
|
<span>
|
|
<Link
|
|
to={`/manage/jobs/${data && data.jobs_by_pk.id}`}
|
|
>{`${data?.jobs_by_pk && data.jobs_by_pk.ro_number}`}</Link>
|
|
{` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${data.jobs_by_pk.v_model_yr || ""} ${
|
|
data.jobs_by_pk.v_make_desc || ""
|
|
} ${data.jobs_by_pk.v_model_desc || ""}`}
|
|
</span>
|
|
}
|
|
socket={activeSocket}
|
|
jobId={jobId}
|
|
mode={mode}
|
|
/>
|
|
) : (
|
|
<RrAllocationsSummary
|
|
key={resetKey}
|
|
title={
|
|
<span>
|
|
<Link to={`/manage/jobs/${data && data.jobs_by_pk.id}`}>
|
|
{data?.jobs_by_pk && data.jobs_by_pk.ro_number}
|
|
</Link>
|
|
{` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${
|
|
data.jobs_by_pk.v_model_yr || ""
|
|
} ${data.jobs_by_pk.v_make_desc || ""} ${data.jobs_by_pk.v_model_desc || ""}`}
|
|
</span>
|
|
}
|
|
socket={activeSocket}
|
|
jobId={jobId}
|
|
/>
|
|
)}
|
|
</Col>
|
|
|
|
<Col md={24} lg={14} className="dms-equal-height-col">
|
|
<DmsPostForm key={resetKey} socket={activeSocket} job={data?.jobs_by_pk} logsRef={logsRef} mode={mode} />
|
|
</Col>
|
|
|
|
<DmsCustomerSelector
|
|
jobid={jobId}
|
|
bodyshop={bodyshop}
|
|
socket={activeSocket}
|
|
mode={mode}
|
|
rrOptions={{
|
|
openRoLimit: rrOpenRoLimit,
|
|
onOpenRoFinished: clearRrOpenRoLimit,
|
|
validationPending: rrValidationPending,
|
|
onValidationFinished: handleRrValidationFinished
|
|
}}
|
|
/>
|
|
|
|
<Col span={24}>
|
|
<div ref={logsRef}>
|
|
<Card
|
|
title={t("jobs.labels.dms.logs")}
|
|
extra={
|
|
<Space wrap>
|
|
{isRrMode && (
|
|
<>
|
|
<Switch
|
|
checked={colorizeJson}
|
|
onChange={setColorizeJson}
|
|
checkedChildren="Color JSON"
|
|
unCheckedChildren="Plain JSON"
|
|
/>
|
|
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
|
|
</>
|
|
)}
|
|
|
|
<Select
|
|
placeholder="Log Level"
|
|
value={logLevel}
|
|
onChange={(value) => {
|
|
setLogLevel(value);
|
|
setActiveLogLevel(value);
|
|
}}
|
|
>
|
|
<Select.Option key="SILLY">SILLY</Select.Option>
|
|
<Select.Option key="DEBUG">DEBUG</Select.Option>
|
|
<Select.Option key="INFO">INFO</Select.Option>
|
|
<Select.Option key="WARN">WARN</Select.Option>
|
|
<Select.Option key="ERROR">ERROR</Select.Option>
|
|
</Select>
|
|
<Button onClick={() => setLogs([])}>Clear Logs</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setLogs([]);
|
|
setResetAfterReconnect(true);
|
|
if (isWssMode(mode)) {
|
|
setActiveLogLevel(logLevel);
|
|
}
|
|
if (activeSocket) {
|
|
activeSocket.disconnect();
|
|
setTimeout(() => activeSocket.connect(), 100);
|
|
}
|
|
}}
|
|
>
|
|
Reconnect
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
<DmsLogEvents
|
|
logs={logs}
|
|
// Only honour details/colorized JSON in RR mode;
|
|
// in other modes DmsLogEvents can render a simple, flat list.
|
|
detailsOpen={isRrMode ? detailsOpen : false}
|
|
detailsNonce={detailsNonce}
|
|
colorizeJson={isRrMode ? colorizeJson : false}
|
|
showDetails={isRrMode}
|
|
/>
|
|
</Card>
|
|
</div>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
);
|
|
}
|