Compare commits

...

66 Commits

Author SHA1 Message Date
Allan Carr
a2389b1f26 IO-3710 Visual Board Vehicle Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-20 13:42:35 -07:00
Allan Carr
3fd51f0140 IO-3710 Visual Board - Vehicle Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 16:01:38 -07:00
Allan Carr
edc9ba33c5 Merged in hotfix/2026-05-12 (pull request #3243)
IO-3691 Job Totals Issues
2026-05-12 15:56:13 +00:00
Allan Carr
4586f32f38 Merged in feature/IO-3691-Jobs-Totals-Issues (pull request #3242)
IO-3691 Job Totals Issues
2026-05-12 15:46:59 +00:00
Allan Carr
7a50f2a2fe IO-3691 Job Totals Issues
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-11 22:11:31 -07:00
Dave Richer
0c83f796db Merged in hotfix/2026-05-11 (pull request #3239)
hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds
2026-05-11 21:27:46 +00:00
Dave
a54e74a27d hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds 2026-05-11 17:24:48 -04:00
Dave Richer
d227cacd68 Merged in release/2026-05-08 (pull request #3236)
Release/2026 05 08 into master-AIO - IO-3562, IO-3647, IO-3650, IO-3667, IO-3672, IO-3673, IO-3674, IO-3676, IO-3679, IO-3686, IO-3687, IO-3688, IO-3689
2026-05-09 00:54:18 +00:00
Allan Carr
ef4565d738 Merged in feature/IO-3689-Customer-List-Restriction (pull request #3234)
IO-3689 Customer List Restriction

Approved-by: Dave Richer
2026-05-08 18:45:21 +00:00
Allan Carr
74eeceacca IO-3689 Customer List Restriction
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-08 11:32:49 -07:00
Dave Richer
6e566e2f8a Merged in feature/IO-3688-Searchable-Referral-Source (pull request #3232)
feature/IO-3688-Searchable-Referral-Source - Implement (convert button)
2026-05-08 15:43:01 +00:00
Dave
80697a5259 feature/IO-3688-Searchable-Referral-Source - Implement (convert button) 2026-05-08 11:42:19 -04:00
Dave Richer
fe8d1f7e95 Merged in feature/IO-3688-Searchable-Referral-Source (pull request #3230)
feature/IO-3688-Searchable-Referral-Source - Implement
2026-05-08 14:41:56 +00:00
Dave
32f3143dca feature/IO-3688-Searchable-Referral-Source - Implement 2026-05-08 10:40:21 -04:00
Dave Richer
0ba207a499 Merged in feature/IO-3679-Tech-Console-Null-Error (pull request #3228)
feature/IO-3679-Tech-Console-Null-Error - fix
2026-05-07 14:42:17 +00:00
Dave
f849ea9d0a feature/IO-3679-Tech-Console-Null-Error - fix 2026-05-07 10:41:19 -04:00
Patrick Fic
e0b113e5d0 Merged in feature/IO-3686-pbs-enhancements (pull request #3224)
IO-3686 River city enhancements for AR customers and Contact Code

Approved-by: Dave Richer
2026-05-07 14:03:48 +00:00
Dave Richer
fc199279d1 Merged in feature/IO-3687-Grey-Scale-Invisible-text (pull request #3225)
feature/IO-3687-Grey-Scale-Invisible-text - implement
2026-05-06 20:46:14 +00:00
Dave
fcba77fe20 feature/IO-3687-Grey-Scale-Invisible-text - implement 2026-05-06 16:45:00 -04:00
Patrick Fic
f294eafde7 IO-3686 River city enhancements for AR customers and Contact Code 2026-05-06 11:57:53 -07:00
Dave Richer
e0f55b8e7a Merged in feature/IO-3673-Order-Parts-Receive-Bill-Bug (pull request #3222)
feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix

Approved-by: Allan Carr
2026-05-06 15:48:10 +00:00
Dave Richer
ef6aee0518 Merged in feature/IO-3676-Order-As-In-House-Quantity (pull request #3221)
feature/IO-3676-Order-As-In-House-Quantity - Fix

Approved-by: Allan Carr
2026-05-06 15:48:00 +00:00
Dave
88ae1fb1cc feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix 2026-05-05 13:56:16 -04:00
Dave
c6af2b34b2 feature/IO-3676-Order-As-In-House-Quantity - Fix 2026-05-05 13:41:50 -04:00
Dave Richer
d51dcc0ef2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3219)
feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds
2026-05-05 17:31:36 +00:00
Dave
e6178a613d feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds 2026-05-05 13:31:03 -04:00
Dave Richer
2a69115903 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3217)
feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode.
2026-05-04 21:08:41 +00:00
Dave
c8262da440 feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode. 2026-05-04 16:58:06 -04:00
Dave Richer
1f41a532e2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3215)
feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR
2026-05-04 20:32:52 +00:00
Dave
32e67b14b6 feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR 2026-05-04 16:31:41 -04:00
Dave Richer
d901004751 Merged in feature/IO-3674-Fix-Save-And-New (pull request #3213)
feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee
2026-05-04 20:12:59 +00:00
Dave
661e019a4d feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee 2026-05-04 16:11:54 -04:00
Allan Carr
cd054fcf33 Merged in feature/IO-3650-Pagination-Corrections (pull request #3208)
IO-3650 Pagination Corrections

Approved-by: Dave Richer
2026-04-29 16:46:02 +00:00
Allan Carr
5ab54433ff Merged in feature/IO-3562-VPB-Exclude-Suspended (pull request #3209)
IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs

Approved-by: Dave Richer
2026-04-29 16:45:31 +00:00
Allan Carr
62c053ed87 Merged in feature/IO-3667-Visual-Production-Title-Color (pull request #3207)
IO-3667 Visual Production Title Color

Approved-by: Dave Richer
2026-04-29 16:44:46 +00:00
Allan Carr
6242e0f309 IO-3667 - Correction for Styling
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:10:49 -07:00
Allan Carr
614420d7d2 IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:06:11 -07:00
Allan Carr
3113818a91 IO-3650 Pagination Corrections
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 16:44:23 -07:00
Allan Carr
92a3e57205 IO-3667 Visual Production Title Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 15:46:40 -07:00
Dave Richer
de6038038a Merged in hotfix/2026-04-28 (pull request #3206)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 18:03:13 +00:00
Dave Richer
8a043767cd Merged in hotfix/2026-04-28 (pull request #3204)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 17:52:28 +00:00
Dave
1f8836d9d8 hotfix/2026-04-28 - Add Label, fix exported 2026-04-28 13:51:46 -04:00
Dave Richer
a267d65425 Merged in hotfix/2026-04-21 (pull request #3203)
Hotfix/2026 04 21
2026-04-22 16:44:49 +00:00
Dave Richer
9267e584ff Merged in hotfix/2026-04-21 (pull request #3201)
hotfix/2026-04-21 - fix Parts order comments
2026-04-22 16:43:19 +00:00
Dave
cacda3805a hotfix/2026-04-21 - fix Parts order comments 2026-04-22 12:42:49 -04:00
Dave Richer
69861af88c Merged in feature/IO-3647-Reynolds-Integration-Phase-2-Optional (pull request #3199)
feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional
2026-04-21 14:52:00 +00:00
Dave
d7294ebba6 feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional 2026-04-21 10:51:13 -04:00
Dave Richer
d9270102b1 Merged in hotfix/2026-04-21 (pull request #3197)
hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
2026-04-21 14:29:33 +00:00
Dave
af757ee71e hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new' 2026-04-21 10:28:26 -04:00
Dave Richer
eb666f2ca1 Merged in hotfix/2026-04-20 (pull request #3195)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:57:38 +00:00
Dave Richer
d991e32501 Merged in hotfix/2026-04-20 (pull request #3193)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:41:29 +00:00
Dave
2b8990950b hotfix/2026-04-20 - Remove item from Cost centers 2026-04-20 11:40:19 -04:00
Dave Richer
3f2e05befc Merged in release/2026-04-17 (pull request #3192)
Release/2026-04-17 into master-AIO - IO-1366, IO-3624, IO-3638
2026-04-18 02:12:37 +00:00
Dave Richer
06bfdeb449 Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3191)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 15:00:38 +00:00
Dave Richer
66df286ddb Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3189)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 14:40:15 +00:00
Dave Richer
1b2f9fc027 Merged in hotfix/2026-04-10 (pull request #3188)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:42:43 +00:00
Dave Richer
1287c7ec36 Merged in hotfix/2026-04-10 (pull request #3186)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:40:04 +00:00
Dave
fb29fa2caa hotfix/2026-04-10 - Fix Location Identifier in chatter-api 2026-04-10 11:38:56 -04:00
Dave
6bda497d8c feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts. 2026-04-09 13:54:48 -04:00
Dave Richer
a018b6dc5a Merged in feature/IO-3638-Reynolds-OpenSearch (pull request #3184)
feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops
2026-04-09 15:16:26 +00:00
Dave
8a4679f86c feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops 2026-04-09 11:14:17 -04:00
Dave Richer
4d558da46a Merged in feature/IO-1366-Re-export-Bill-Audit-Log-codex (pull request #3182)
Feature/IO-1366 Re export Bill Audit Log codex audit
2026-04-08 18:02:39 +00:00
Dave Richer
90789e743f Merged in feature/IO-3624-Shop-Config-UX-Refresh (pull request #3180)
Feature/IO-3624 Shop Config UX Refresh
2026-04-03 01:55:24 +00:00
Dave Richer
a4dbc5250e Merged in release/2026-04-03 (pull request #3179)
Release/2026-04-03 - IO-1366, IO-3356, IO-3515, IO-3587, IO-3599, IO-3609, IO-3616, IO-3622, IO-3623, IO-3627, IO-3629, IO-3637
2026-04-03 01:46:11 +00:00
Dave
704543d823 IO-1366 Refine audit trail detail logging 2026-04-02 21:40:59 -04:00
Allan Carr
fe848b5de4 IO-1366 Extend Audit Log
Extend for Time Ticket Changes, Manual Lines, Manual Job, Bill Edits

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-02 11:06:14 -04:00
68 changed files with 3208 additions and 806 deletions

View File

@@ -27699,6 +27699,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>addpayer</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>addtopartsqueue</name>
<definition_loaded>false</definition_loaded>
@@ -31118,6 +31139,27 @@
<folder_node>
<name>dms</name>
<children>
<concept_node>
<name>IsARCustomer</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>address</name>
<definition_loaded>false</definition_loaded>
@@ -45333,6 +45375,69 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>esign-document-completed</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>esign-document-opened</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>esign-document-upload-failed</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>intake-delivery-checklist-completed</name>
<definition_loaded>false</definition_loaded>

View File

@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container";
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
@@ -134,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
await Promise.all(updates);
const details = buildBillUpdateAuditDetails({
originalBill: data?.bills_by_pk,
bill,
billlines
});
insertAuditTrail({
jobid: bill.jobid,
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number),
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
type: "billupdated"
});

View File

@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
* RR-specific DMS Allocations Summary
* Focused on what we actually send to RR:
* - ROGOG (split by taxable / non-taxable segments)
* - ROLABOR shell
* - ROLABOR labor rows with bill hours / rates
*
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
const rolaborRows = useMemo(() => {
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
return rolaborPreview.ops.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return rolaborPreview.ops
.filter((op) =>
[op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt]
.map((value) => Number.parseFloat(value ?? "0"))
.some((value) => !Number.isNaN(value) && value !== 0)
)
.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
return {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
jobTotalHrs: op.bill?.jobTotalHrs,
billTime: op.bill?.billTime,
billRate: op.bill?.billRate,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
}, [rolaborPreview, opCode]);
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
@@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
{ title: "PayType", dataIndex: "payType", key: "payType" },
{ title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" },
{ title: "BillTime", dataIndex: "billTime", key: "billTime" },
{ title: "BillRate", dataIndex: "billRate", key: "billRate" },
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the
job&apos;s labor lines.
</Typography.Paragraph>
<ResponsiveTable
pagination={false}
columns={rolaborColumns}
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
rowKey="key"
dataSource={rolaborRows}
locale={{ emptyText: "No ROLABOR lines would be generated." }}

View File

@@ -1,4 +1,4 @@
import { Button, Col } from "antd";
import { Button, Checkbox, Col } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" },
{ title: t("jobs.fields.dms.id"), dataIndex: "Code", key: "ContactId" },
{
title: t("jobs.fields.dms.IsARCustomer"),
dataIndex: "IsARCustomer",
key: "IsARCustomer",
render: (text, record) => <Checkbox checked={record.IsARCustomer} disabled />
},
{
title: t("jobs.fields.dms.name1"),
key: "name1",

View File

@@ -1,5 +1,5 @@
import axios from "axios";
import { Result } from "antd";
import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -9,6 +9,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
import { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import {
addGreyscaleButtonToMarkerArea,
addImageHistoryUndoToMarkerArea,
applyGreyscaleToMarkerAreaImage,
setMarkerAreaImageSource
} from "./document-editor.utility";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const imageHistory = useRef([]);
const { t } = useTranslation();
const { token } = theme.useToken();
const notification = useNotification();
const [uploading, setUploading] = useState(false);
@@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
async (dataUrl) => {
if (uploading) return;
setUploading(true);
setLoading(true);
const blob = await b64toBlob(dataUrl);
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
const parts = nameWithoutExt.split("-");
@@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
[filename, jobid, notification, uploading]
);
const handleGreyscale = useCallback(() => {
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
imageHistory.current.push(imgRef.current.src);
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
}, [imageLoaded, imageLoading, loading, uploaded]);
const undoImageEdit = useCallback(() => {
if (!imgRef.current) return;
const previousSrc = imageHistory.current.pop();
if (previousSrc) {
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
}
}, []);
useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
@@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
}
}, [triggerUpload, imageLoaded]);
}, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
useEffect(() => {
if (!imageUrl) return;
@@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
try {
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
const blobUrl = URL.createObjectURL(response.data);
imageHistory.current = [];
setLoadedImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
@@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
}
return (
<div>
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
{!loading && !uploaded && loadedImageUrl && (
<img
ref={imgRef}
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
{uploaded && (
<Result
status="success"
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
/>
)}
</div>
);
}

View File

@@ -1,15 +1,21 @@
//import "tui-image-editor/dist/tui-image-editor.css";
import axios from "axios";
import { Result } from "antd";
import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import {
addGreyscaleButtonToMarkerArea,
addImageHistoryUndoToMarkerArea,
applyGreyscaleToMarkerAreaImage,
setMarkerAreaImageSource
} from "./document-editor.utility";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const imageHistory = useRef([]);
const { t } = useTranslation();
const { token } = theme.useToken();
const notification = useNotification();
const triggerUpload = useCallback(
@@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
[bodyshop, currentUser, document, notification]
);
const handleGreyscale = useCallback(() => {
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
imageHistory.current.push(imgRef.current.src);
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
}, [imageLoaded, imageLoading, loading, uploaded]);
const undoImageEdit = useCallback(() => {
if (!imgRef.current) return;
const previousSrc = imageHistory.current.pop();
if (previousSrc) {
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
}
}, []);
useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
@@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
}
}, [triggerUpload, imageLoaded]);
}, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
useEffect(() => {
if (!document?.id) return;
@@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
}
);
const blobUrl = URL.createObjectURL(response.data);
imageHistory.current = [];
setImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
@@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
}
return (
<div>
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
{!loading && !uploaded && imageUrl && (
<img
ref={imgRef}
@@ -150,7 +178,12 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
{uploaded && (
<Result
status="success"
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
/**
* Converts an image element to a greyscale data URL.
* @param imageElement
* @returns {string}
*/
export function convertImageElementToGreyscaleDataUrl(imageElement) {
if (!imageElement?.naturalWidth || !imageElement?.naturalHeight) {
throw new Error("Image must be loaded before it can be converted to greyscale.");
}
const canvas = document.createElement("canvas");
canvas.width = imageElement.naturalWidth;
canvas.height = imageElement.naturalHeight;
const context = canvas.getContext("2d");
context.drawImage(imageElement, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const luminance = Math.round(pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114);
pixels[i] = luminance;
pixels[i + 1] = luminance;
pixels[i + 2] = luminance;
}
context.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/jpeg", 1);
}
/**
* Adds a greyscale button to the marker area controls if it doesn't already exist.
* @param markerArea
* @param onGreyscale
* @param title
*/
export function addGreyscaleButtonToMarkerArea(markerArea, onGreyscale, title) {
requestAnimationFrame(() => {
const renderButton = markerArea?.coverDiv?.querySelector?.('[data-action="render"]');
if (!renderButton || markerArea.coverDiv.querySelector('[data-action="greyscale"]')) return;
const greyscaleButton = document.createElement("div");
greyscaleButton.className = renderButton.className;
greyscaleButton.innerHTML =
'<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20V2zm0 2.25v15.5a7.75 7.75 0 0 1 0-15.5z"/></svg>';
greyscaleButton.setAttribute("role", "button");
greyscaleButton.setAttribute("data-action", "greyscale");
greyscaleButton.setAttribute("aria-label", title);
greyscaleButton.title = title;
greyscaleButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onGreyscale();
});
renderButton.parentElement.insertBefore(greyscaleButton, renderButton);
});
}
/**
* Applies a greyscale filter to the image in the marker area and updates the image source.
* @param markerArea
* @param imageElement
* @returns {string}
*/
export function applyGreyscaleToMarkerAreaImage(markerArea, imageElement) {
const dataUrl = convertImageElementToGreyscaleDataUrl(imageElement);
setMarkerAreaImageSource(markerArea, imageElement, dataUrl);
return dataUrl;
}
/**
* Sets the image source for the marker area and updates the editing target if it's an image element.
* @param markerArea
* @param imageElement
* @param src
*/
export function setMarkerAreaImageSource(markerArea, imageElement, src) {
imageElement.src = src;
if (markerArea?.editingTarget instanceof HTMLImageElement) {
markerArea.editingTarget.src = src;
}
}
/**
* Adds undo functionality for image edits to the marker area by tracking the state before and after undo actions.
* @param markerArea
* @param canUndoImage
* @param undoImage
*/
export function addImageHistoryUndoToMarkerArea(markerArea, canUndoImage, undoImage) {
requestAnimationFrame(() => {
const undoButton = markerArea?.coverDiv?.querySelector?.('[data-action="undo"]');
if (!undoButton || undoButton.dataset.imageHistoryUndo === "true") return;
let markerStateBeforeUndo = null;
undoButton.dataset.imageHistoryUndo = "true";
undoButton.addEventListener(
"click",
() => {
markerStateBeforeUndo = JSON.stringify(markerArea.getState(true));
},
true
);
undoButton.addEventListener("click", () => {
const markerStateAfterUndo = JSON.stringify(markerArea.getState(true));
if (markerStateBeforeUndo === markerStateAfterUndo && canUndoImage()) {
undoImage();
}
markerStateBeforeUndo = null;
});
});
}

View File

@@ -44,6 +44,7 @@ import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-ass
import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-send-parts-price-change.component";
import PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component";
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
import JobLinesExpander from "./job-lines-expander.component";
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
@@ -595,16 +596,7 @@ export function JobLinesComponent({
isinhouse: true,
date: dayjs(),
total: 0,
billlines: selectedLines.map((p) => ({
joblineid: p.id,
actual_price: p.act_price,
actual_cost: 0,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: { local: false, state: false, federal: false }
}))
billlines: buildInHouseBillLines(selectedLines)
}
}
});

View File

@@ -0,0 +1,11 @@
export const buildInHouseBillLines = (lines) =>
lines.map((line) => ({
joblineid: line.id,
actual_price: line.act_price,
actual_cost: 0,
line_desc: line.line_desc,
line_remarks: line.line_remarks,
part_type: line.part_type,
quantity: line.part_qty ?? line.quantity ?? 1,
applicable_taxes: { local: false, state: false, federal: false }
}));

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
describe("buildInHouseBillLines", () => {
it("carries job line part quantity into the in-house bill line", () => {
const billLines = buildInHouseBillLines([
{
id: "job-line-1",
act_price: 125,
line_desc: "Door shell",
line_remarks: "Left",
part_type: "PAA",
part_qty: 3
}
]);
expect(billLines[0]).toMatchObject({
joblineid: "job-line-1",
actual_price: 125,
actual_cost: 0,
line_desc: "Door shell",
line_remarks: "Left",
part_type: "PAA",
quantity: 3,
applicable_taxes: { local: false, state: false, federal: false }
});
});
it("falls back to legacy quantity and then one when part quantity is absent", () => {
expect(buildInHouseBillLines([{ id: "legacy", quantity: 2 }])[0].quantity).toBe(2);
expect(buildInHouseBillLines([{ id: "missing" }])[0].quantity).toBe(1);
});
});

View File

@@ -14,16 +14,20 @@ import CriticalPartsScan from "../../utils/criticalPartsScan";
import UndefinedToNull from "../../utils/undefinedtonull";
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
jobLineEditModal: selectJobLineEditModal,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit"))
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) {
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const {
treatments: { CriticalPartsScanning }
} = useTreatmentsWithConfig({
@@ -74,6 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({
title: t("joblines.successes.created")
});
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
type: "jobmanuallineinsert"
});
} else {
notification.error({
title: t("joblines.errors.creating", {
@@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({
title: t("joblines.successes.updated")
});
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.joblineupdate(
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
buildJobLineUpdateAuditDetails({
originalLine: jobLineEditModal.context,
values
})
),
type: "joblineupdate"
});
} else {
notification.success({
title: t("joblines.errors.updating", {

View File

@@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
</>
)}
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[{ required: true }]}
>
<Form.Item name={["ins_co_nm"]} label={t("jobs.fields.ins_co_nm")} rules={[{ required: true }]}>
<Select
showSearch={{
optionFilterProp:'label'
optionFilterProp: "label"
}}
options={insuranceOptions}
/>
@@ -250,7 +246,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
label={t("jobs.fields.referralsource")}
rules={[{ required: bodyshop.enforce_referral }]}
>
<Select options={referralOptions} />
<Select showSearch={{ optionFilterProp: "label" }} options={referralOptions} />
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
@@ -272,19 +268,21 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
>
<Select
showSearch={{
optionFilterProp: 'label',
filterOption: (input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
optionFilterProp: "label",
filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}}
style={{ width: 200 }}
options={csrOptions}
/>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item name="category" label={t("jobs.fields.category")} rules={[{ required: bodyshop.enforce_conversion_category }]}>
<Form.Item
name="category"
label={t("jobs.fields.category")}
rules={[{ required: bodyshop.enforce_conversion_category }]}
>
<Select allowClear options={categoryOptions} />
</Form.Item>
)}

View File

@@ -193,6 +193,9 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
</Form.Item>
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
<Select
showSearch={{
optionFilterProp: "label"
}}
options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s

View File

@@ -43,19 +43,25 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
<Select disabled={jobRO} options={[
{ value: "W", label: t("jobs.labels.deductible.waived") },
{ value: "Y", label: t("jobs.labels.deductible.stands") }
]} />
<Select
disabled={jobRO}
options={[
{ value: "W", label: t("jobs.labels.deductible.waived") },
{ value: "Y", label: t("jobs.labels.deductible.stands") }
]}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
<CurrencyInput disabled={jobRO} min={0} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({
value: n,
label: n
}))} />
<Select
disabled={jobRO}
options={bodyshop.md_ded_notes.map((n) => ({
value: n,
label: n
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
<Input disabled={jobRO} />
@@ -65,10 +71,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</Form.Item>
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
<Select disabled={jobRO} onChange={handleInsCoChange} options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))} />
<Select
disabled={jobRO}
onChange={handleInsCoChange}
options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
<Input disabled={jobRO} />
@@ -119,19 +129,30 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
}
]}
>
<Select disabled={jobRO} allowClear options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s
}))} />
<Select
disabled={jobRO}
allowClear
showSearch={{
optionFilterProp: "label"
}}
options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({
value: s,
label: s
}))} />
<Select
disabled={jobRO}
allowClear
options={bodyshop.appt_alt_transport.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
</FormRow>
<Row gutter={[16, 16]}>
@@ -233,10 +254,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</FormRow>
<FormRow header={t("jobs.forms.other")}>
<Form.Item label={t("jobs.fields.category")} name="category">
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({
value: s,
label: s
}))} />
<Select
disabled={jobRO}
allowClear
options={bodyshop.md_categories.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} />

View File

@@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd";
import axios from "axios";
import i18n from "i18next";
import { isFunction } from "lodash";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
@@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
const { t } = useTranslation();
const [modalState, setModalState] = useState({ open: false, index: 0 });
const [previewUrls, setPreviewUrls] = useState({});
const [previewError, setPreviewError] = useState(null);
const previewUrlsRef = useRef({});
const fetchThumbnails = useCallback(() => {
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
@@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({
}
}, [data, fetchThumbnails]);
useEffect(() => {
return () => {
Object.values(previewUrlsRef.current).forEach(URL.revokeObjectURL);
};
}, []);
const selectedImage = modalState.open ? galleryImages.images[modalState.index] : null;
useEffect(() => {
if (!modalState.open || !selectedImage?.id) return;
if (previewUrlsRef.current[selectedImage.id]) {
setPreviewError(null);
return;
}
const controller = new AbortController();
async function loadPreviewImage() {
setPreviewError(null);
try {
const response = await axios.post(
"/media/imgproxy/original",
{ documentId: selectedImage.id },
{
responseType: "blob",
signal: controller.signal
}
);
const blobUrl = URL.createObjectURL(response.data);
previewUrlsRef.current = {
...previewUrlsRef.current,
[selectedImage.id]: blobUrl
};
setPreviewUrls(previewUrlsRef.current);
} catch (error) {
if (axios.isCancel?.(error) || error.name === "CanceledError") return;
console.error("Failed to fetch original image blob", error);
setPreviewError(error);
}
}
loadPreviewImage();
return () => {
controller.abort();
};
}, [modalState.open, selectedImage?.id]);
useEffect(() => {
if (modalState.open && !selectedImage) {
setModalState({ open: false, index: 0 });
}
}, [modalState.open, selectedImage]);
const openEditorForImage = useCallback((image) => {
if (!image?.id) return;
const newWindow = window.open(
`${window.location.protocol}//${window.location.host}/edit?documentId=${image.id}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
}, []);
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
const previewSrc = selectedImage ? previewUrls[selectedImage.id] : null;
const getLightboxImageSrc = useCallback(
(index) => {
const image = galleryImages.images[index];
return image ? previewUrls[image.id] || image.src : undefined;
},
[galleryImages.images, previewUrls]
);
return (
<div>
<Row gutter={[16, 16]}>
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
/>
</Card>
</Col>
{modalState.open && (
{modalState.open && selectedImage && (
<Lightbox
toolbarButtons={[
<EditFilled
key="edit"
onClick={() => {
const newWindow = window.open(
`${window.location.protocol}//${window.location.host}/edit?documentId=${
galleryImages.images[modalState.index].id
}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
openEditorForImage(selectedImage);
}}
/>
]}
mainSrc={galleryImages.images[modalState.index].fullsize}
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
prevSrc={
imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined}
mainSrc={previewSrc || selectedImage.src}
mainSrcThumbnail={selectedImage.src}
nextSrc={getLightboxImageSrc((modalState.index + 1) % galleryImages.images.length)}
nextSrcThumbnail={galleryImages.images[(modalState.index + 1) % galleryImages.images.length]?.src}
prevSrc={getLightboxImageSrc(
(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length
)}
prevSrcThumbnail={
galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length]
.fullsize
?.src
}
onCloseRequest={() => setModalState({ open: false, index: 0 })}
reactModalProps={{ ariaHideApp: false }}
onCloseRequest={() => {
setModalState({ open: false, index: 0 });
setPreviewError(null);
}}
onMovePrevRequest={() =>
setModalState({
...modalState,

View File

@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function OwnersListComponent({ loading, owners, total, refetch }) {
const search = queryString.parse(useLocation().search);
const {
page
// sortcolumn, sortorder
} = search;
const { page, pageSize } = search;
const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const [state, setState] = useState({
sortedInfo: {},
filteredInfo: { text: "" }
@@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
];
const handleTableChange = (pagination, filters, sorter) => {
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
const updatedSearch = {
...search,
page: pagination.current,
pageSize: nextPageSize,
page: pageSizeChanged ? 1 : pagination.current,
sortcolumn: sorter.columnKey,
sortorder: sorter.order
};
@@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
>
<ResponsiveTable
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1, 10), total: total }}
pagination={{ placement: "top", pageSize: currentPageSize, current: currentPage, showSizeChanger: true, total: total }}
columns={columns}
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
rowKey="id"

View File

@@ -8,14 +8,19 @@ import { pageLimit } from "../../utils/config";
export default function OwnersListContainer() {
const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams;
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_ALL_OWNERS_PAGINATED, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
search: search || "",
offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit,
offset: (currentPage - 1) * currentPageSize,
limit: currentPageSize,
order: [
{
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"

View File

@@ -176,6 +176,9 @@ export function PartsOrderModalComponent({
>
<Input />
</Form.Item>
<Form.Item key={`${index}job_line_id`} name={[field.name, "job_line_id"]} hidden>
<Input type="hidden" />
</Form.Item>
<Form.Item
label={t("parts_orders.fields.line_remarks")}
key={`${index}line_remarks`}

View File

@@ -23,6 +23,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import _ from "lodash";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -82,15 +83,10 @@ export function PartsOrderModalContainer({
// Force job_line_id from context so it never gets dropped by AntD form submission behavior.
const submittedLines = values?.parts_order_lines?.data ?? [];
const forcedLines = submittedLines.map((p, index) => {
const originalLine = linesToOrder?.[index];
const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id;
return {
...p,
job_line_id: jobLineId,
...(isReturn && { cm_received: false })
};
const forcedLines = buildSubmittedPartsOrderLines({
submittedLines,
linesToOrder,
isReturn
});
let insertResult;
@@ -147,10 +143,7 @@ export function PartsOrderModalContainer({
type: isReturn ? "jobspartsreturn" : "jobspartsorder"
});
// Use linesToOrder from context instead of form values to preserve job line ids
const jobLineIds = (linesToOrder ?? [])
.filter((line) => (isReturn ? line.joblineid : line.id))
.map((line) => (isReturn ? line.joblineid : line.id));
const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines);
try {
const jobLinesResult = await updateJobLines({
@@ -206,23 +199,20 @@ export function PartsOrderModalContainer({
isinhouse: true,
date: dayjs(),
total: 0,
billlines: forcedLines.map((p, index) => {
const originalLine = linesToOrder?.[index];
return {
joblineid: isReturn ? originalLine?.joblineid : originalLine?.id,
actual_price: p.act_price,
actual_cost: 0, // p.act_price,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: {
local: false,
state: false,
federal: false
}
};
})
billlines: forcedLines.map((p) => ({
joblineid: p.job_line_id,
actual_price: p.act_price,
actual_cost: 0, // p.act_price,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: {
local: false,
state: false,
federal: false
}
}))
}
}
});

View File

@@ -0,0 +1,23 @@
export const getPartsOrderJobLineId = ({ line, originalLine, isReturn }) => {
return line?.job_line_id || (isReturn ? originalLine?.joblineid : originalLine?.id);
};
export const buildSubmittedPartsOrderLines = ({ submittedLines = [], linesToOrder = [], isReturn }) => {
return submittedLines.map((line, index) => {
const jobLineId = getPartsOrderJobLineId({
line,
originalLine: linesToOrder?.[index],
isReturn
});
return {
...line,
job_line_id: jobLineId,
...(isReturn && { cm_received: false })
};
});
};
export const getSubmittedPartsOrderJobLineIds = (partsOrderLines = []) => {
return partsOrderLines.map((line) => line.job_line_id).filter(Boolean);
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
describe("parts order modal utilities", () => {
it("preserves submitted job line ids after a row is removed", () => {
const submittedLines = [
{ line_desc: "second line", job_line_id: "job-line-2" },
{ line_desc: "third line", job_line_id: "job-line-3" }
];
const linesToOrder = [{ id: "job-line-1" }, { id: "job-line-2" }, { id: "job-line-3" }];
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: false });
expect(result.map((line) => line.job_line_id)).toEqual(["job-line-2", "job-line-3"]);
expect(getSubmittedPartsOrderJobLineIds(result)).toEqual(["job-line-2", "job-line-3"]);
});
it("falls back to original return line ids when the form omits hidden metadata", () => {
const submittedLines = [{ line_desc: "return line" }];
const linesToOrder = [{ joblineid: "return-job-line-1" }];
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: true });
expect(result).toEqual([
{
line_desc: "return line",
job_line_id: "return-job-line-1",
cm_received: false
}
]);
});
});

View File

@@ -29,7 +29,10 @@ const mapStateToProps = createStructuredSelector({
export function PartsQueueListComponent({ bodyshop }) {
const searchParams = queryString.parse(useLocation().search);
const { selected, sortcolumn, sortorder, statusFilters } = searchParams;
const { selected, sortcolumn, sortorder, statusFilters, page, pageSize } = searchParams;
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const history = useNavigate();
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
@@ -66,7 +69,11 @@ export function PartsQueueListComponent({ bodyshop }) {
: [];
const handleTableChange = (pagination, filters, sorter) => {
// searchParams.page = pagination.current;
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
searchParams.pageSize = nextPageSize;
searchParams.page = pageSizeChanged ? 1 : pagination.current;
searchParams.sortcolumn = sorter.columnKey;
searchParams.sortorder = sorter.order;
@@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) {
loading={loading}
pagination={{
placement: "top",
pageSize: pageLimit
// current: parseInt(page || 1),
// total: data && data.jobs_aggregate.aggregate.count,
pageSize: currentPageSize,
current: currentPage,
showSizeChanger: true,
total: jobs.length
}}
columns={columns}
mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]}

View File

@@ -12,7 +12,7 @@ import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.compon
import PrintCenterItem from "../print-center-item/print-center-item.component";
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component";
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { selectTechnician } from "../../redux/tech/tech.selectors";
const mapStateToProps = createStructuredSelector({
@@ -36,6 +36,8 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
splitKey: bodyshop.imexshopid
});
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const Templates = !hasDMSKey
? Object.keys(tempList)
@@ -60,6 +62,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
(temp.regions && temp.regions[bodyshop.region_config]) ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
)
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
.filter((temp) => !technician || temp.group !== "financial");
const JobsReportsList =

View File

@@ -80,14 +80,14 @@ const ModelInfoToolTip = ({ metadata, cardSettings }) =>
<Col span={24}>
<EllipsesToolTip
title={
metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc
? `${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color
? `${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
: null
}
kiosk={cardSettings.kiosk}
>
{metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc ? (
`${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
{metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color ? (
`${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
) : (
<span>&nbsp;</span>
)}
@@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
<Card
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
size="small"
styles={{ header: { backgroundColor: "var(--card-bg-fallback)" } }}
style={{
backgroundColor: cardSettings?.cardcolor
? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})`

View File

@@ -28,11 +28,14 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
const { t } = useTranslation();
const calculateTotal = (items, key, subKey) => {
return items.reduce((acc, item) => acc + (item[key]?.aggregate?.sum?.[subKey] || 0), 0);
return items.reduce((acc, item) => acc + (item?.[key]?.aggregate?.sum?.[subKey] ?? 0), 0);
};
const calculateTotalAmount = (items, key) => {
return items.reduce((acc, item) => acc.add(Dinero(item[key]?.totals?.subtotal ?? Dinero())), Dinero({ amount: 0 }));
return items.reduce(
(acc, item) => acc.add(Dinero(item?.[key]?.totals?.subtotal ?? Dinero())),
Dinero({ amount: 0 })
);
};
const calculateReducerTotalAmount = (lanes, key) => {
@@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
return value;
};
const filteredData = cardSettings.excludeSuspended === true ? data.filter((item) => item.suspended !== true) : data;
const filteredReducerData =
cardSettings.excludeSuspended === true
? {
...reducerData,
lanes: reducerData.lanes.map((lane) => ({
...lane,
cards: lane.cards.filter((card) => card.metadata.suspended !== true)
}))
}
: reducerData;
const totalHrs = cardSettings.totalHrs
? parseFloat((calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs")).toFixed(2))
? parseFloat(
(
calculateTotal(filteredData, "labhrs", "mod_lb_hrs") + calculateTotal(filteredData, "larhrs", "mod_lb_hrs")
).toFixed(2)
)
: null;
const totalLAB = cardSettings.totalLAB
? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2))
? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLAR = cardSettings.totalLAR
? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2))
? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2))
: null;
const jobsInProduction = cardSettings.jobsInProduction ? data.length : null;
const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null;
const totalAmountInProduction = cardSettings.totalAmountInProduction
? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00")
? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00")
: null;
const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard
? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00")
: null;
const totalAmountOnBoard =
filteredReducerData && cardSettings.totalAmountOnBoard
? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00")
: null;
const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard
? parseFloat((
calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") +
calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs")
).toFixed(2))
: null;
const totalHrsOnBoard =
filteredReducerData && cardSettings.totalHrsOnBoard
? parseFloat(
(
calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") +
calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs")
).toFixed(2)
)
: null;
const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard
? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLABOnBoard =
filteredReducerData && cardSettings.totalLABOnBoard
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard
? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLAROnBoard =
filteredReducerData && cardSettings.totalLAROnBoard
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
: null;
const jobsOnBoard = reducerData && cardSettings.jobsOnBoard
? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
: null;
const jobsOnBoard =
filteredReducerData && cardSettings.jobsOnBoard
? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
: null;
const tasksInProduction = cardSettings.tasksInProduction
? data.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0)
? filteredData.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0)
: null;
const tasksOnBoard = reducerData && cardSettings.tasksOnBoard
? reducerData.lanes.reduce((acc, lane) => {
return (
acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
);
}, 0)
: null;
const tasksOnBoard =
filteredReducerData && cardSettings.tasksOnBoard
? filteredReducerData.lanes.reduce((acc, lane) => {
return (
acc +
lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
);
}, 0)
: null;
const statistics = mergeStatistics(statisticsItems, [
{ id: 0, value: totalHrs, type: StatisticType.HOURS },

View File

@@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan
};
return (
<Card title={t("production.settings.statistics_title")}>
<Card
title={t("production.settings.statistics_title")}
extra={
<div style={{ display: "flex", alignItems: "center" }}>
<Form.Item name="excludeSuspended" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox>{t("production.settings.statistics.exclude_suspended")}</Checkbox>
</Form.Item>
</div>
}
>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="grid" droppableId="statistics">
{(provided) => (

View File

@@ -91,7 +91,8 @@ const defaultKanbanSettings = {
subtotal: false,
statisticsOrder: statisticsItems.map((item) => item.id),
selectedMdInsCos: [],
selectedEstimators: []
selectedEstimators: [],
excludeSuspended: false
};
const defaultFilters = { search: "", employeeId: null, alert: false };

View File

@@ -140,13 +140,11 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) =>
technician ? (
<>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${
record.v_color || ""
} ${record.plate_no || ""}`}</>
<>{`${record.v_model_yr || ""} ${record.v_color || ""}${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</>
) : (
<Link to={`/manage/vehicles/${record.vehicleid}`}>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || ""
} ${record.v_color || ""} ${record.plate_no || ""}`}</Link>
<Link
to={`/manage/vehicles/${record.vehicleid}`}
>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</Link>
)
},
{
@@ -621,7 +619,7 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order
}
]
: []),
: [])
];
};
export default productionListColumnsData;

View File

@@ -12,6 +12,7 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { selectReportCenter } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DatePickerRanges from "../../utils/DatePickerRanges";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import dayjs from "../../utils/day";
@@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const Templates = TemplateList("report_center");
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const ReportsList = Object.keys(Templates)
.map((key) => Templates[key])
.filter((temp) => {
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
const adpPayrollOn = ADPPayroll.treatment === "on";
if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) {
return false;
}
if (enhancedPayrollOn && adpPayrollOn) {
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
}
@@ -408,6 +415,6 @@ const restrictedReports = [
{ key: "job_costing_ro_estimator", days: 183 },
{ key: "job_lifecycle_date_detail", days: 183 },
{ key: "job_lifecycle_date_summary", days: 183 },
{ key: "customer_list", days: 183 },
{ key: "customer_list_excel", days: 183 }
{ key: "customer_list", days: 736 },
{ key: "customer_list_excel", days: 736 }
];

View File

@@ -4,7 +4,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import queryString from "query-string";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -52,6 +52,7 @@ const mapDispatchToProps = () => ({
});
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const submitActionRef = useRef("save");
const { t } = useTranslation();
const [internalIsDirty, setInternalIsDirty] = useState(false);
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
@@ -128,55 +129,117 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
});
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
const syncEmployeeFormToSavedData = useCallback(
(employeeData) => {
if (employeeData) {
form.setFieldsValue(employeeData);
}
window.requestAnimationFrame(() => {
clearEmployeeFormMeta();
});
},
[clearEmployeeFormMeta, form]
);
useEffect(() => {
resetEmployeeFormToCurrentData();
}, [resetEmployeeFormToCurrentData, search.employeeId]);
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
const saveAndResetSubmitAction = useCallback(() => {
const submitAction = submitActionRef.current;
submitActionRef.current = "save";
return submitAction;
}, []);
const submitEmployeeForm = useCallback(
(submitAction = "save") => {
submitActionRef.current = submitAction;
form.submit();
},
[form]
);
const navigateToEmployee = useCallback(
(employeeId) => {
history({
search: queryString.stringify({
...search,
employeeId
})
});
},
[history, search]
);
const handleFinish = async (values) => {
const submitAction = saveAndResetSubmitAction();
const normalizedValues = {
...values,
user_email: values.user_email === "" ? null : values.user_email
};
const handleFinish = (values) => {
if (search.employeeId && search.employeeId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update");
updateEmployee({
variables: {
id: search.employeeId,
employee: {
...values,
user_email: values.user_email === "" ? null : values.user_email
try {
const result = await updateEmployee({
variables: {
id: search.employeeId,
employee: normalizedValues
}
}
})
.then(() => {
updateDirtyState(false);
void refetch();
notification.success({
title: t("employees.successes.save")
});
})
.catch((error) => {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
});
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployees({
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
}).then((r) => {
updateDirtyState(false);
search.employeeId = r.data.insert_employees.returning[0].id;
history({ search: queryString.stringify(search) });
syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
void refetch();
if (submitAction === "saveAndNew") {
navigateToEmployee("new");
}
notification.success({
title: t("employees.successes.save")
});
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}
return;
}
//New record, insert it.
logImEXEvent("shop_employee_insert");
try {
const result = await insertEmployees({
variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
});
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
if (submitAction === "saveAndNew") {
if (isNewEmployee) {
resetEmployeeFormToCurrentData();
}
navigateToEmployee("new");
} else if (savedEmployee?.id) {
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
navigateToEmployee(savedEmployee.id);
} else {
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
}
notification.success({
title: t("employees.successes.save")
});
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}
};
@@ -240,13 +303,24 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
<Card
title={employeeCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
{t("employees.actions.save_employee")}
</Button>
<Space wrap>
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
{t("general.actions.saveandnew") || "Save and New"}
</Button>
<Button
type="primary"
onClick={() => submitEmployeeForm("save")}
disabled={!resolvedIsDirty}
style={{ minWidth: 170 }}
>
{t("employees.actions.save_employee")}
</Button>
</Space>
}
>
<Form
onFinish={handleFinish}
onFinishFailed={saveAndResetSubmitAction}
autoComplete={"off"}
layout="vertical"
form={form}

View File

@@ -0,0 +1,359 @@
import { useApolloClient } from "@apollo/client/react";
import { Form } from "antd";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DELETE_VACATION,
INSERT_EMPLOYEES,
QUERY_EMPLOYEE_BY_ID,
UPDATE_EMPLOYEE
} from "../../graphql/employees.queries";
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
const insertEmployeesMock = vi.fn();
const updateEmployeeMock = vi.fn();
const deleteVacationMock = vi.fn();
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const navigateMock = vi.fn();
const notification = {
error: vi.fn(),
success: vi.fn()
};
vi.mock("@apollo/client/react", async () => {
const actual = await vi.importActual("@apollo/client/react");
return {
...actual,
useApolloClient: vi.fn(),
useQuery: (...args) => useQueryMock(...args),
useMutation: (...args) => useMutationMock(...args)
};
});
vi.mock("@splitsoftware/splitio-react", () => ({
useTreatmentsWithConfig: () => ({
treatments: {
Enhanced_Payroll: {
treatment: "off"
}
}
})
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({
search: "?employeeId=new"
}),
useNavigate: () => navigateMock
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"bodyshop.labels.employee_options": "Employee Options",
"bodyshop.labels.employee_rates": "Employee Rates",
"bodyshop.labels.employee_vacation": "Employee Vacation",
"bodyshop.labels.employees": "Employees",
"employees.actions.addrate": "Add Rate",
"employees.actions.addvacation": "Add Vacation",
"employees.actions.new": "New Employee",
"employees.actions.save_employee": "Save Employee",
"employees.fields.active": "Active",
"employees.fields.employee_number": "Employee Number",
"employees.fields.external_id": "External Id",
"employees.fields.first_name": "First Name",
"employees.fields.flat_rate": "Flat Rate",
"employees.fields.hire_date": "Hire Date",
"employees.fields.last_name": "Last Name",
"employees.fields.pin": "PIN",
"employees.fields.rate": "Rate",
"employees.fields.termination_date": "Termination Date",
"employees.fields.user_email": "User Email",
"employees.labels.active": "Active",
"employees.successes.save": "Saved",
"general.actions.saveandnew": "Save and New",
"general.labels.actions": "Actions"
};
if (key === "employees.errors.save") {
return `Save failed: ${values.message ?? ""}`;
}
if (key === "employees.validation.unique_employee_number") {
return "Employee number must be unique";
}
if (key === "bodyshop.validation.useremailmustexist") {
return "User email must exist";
}
return translations[key] || key;
}
})
}));
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../../firebase/firebase.utils", () => ({
logImEXEvent: vi.fn()
}));
vi.mock("../alert/alert.component", () => ({
default: ({ title }) => <div>{title}</div>
}));
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
default: () => null
}));
vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="text" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="email" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../layout-form-row/layout-form-row.component", () => ({
default: ({ title, extra, actions, children }) => (
<div>
{title}
{extra}
{children}
{actions}
</div>
)
}));
vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({
default: ({ title, extra, children }) => (
<div>
{title}
{extra}
{children}
</div>
)
}));
vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({
default: ({ actionLabel }) => <div>{actionLabel}</div>
}));
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
default: () => null
}));
vi.mock("../responsive-table/responsive-table.component", () => ({
default: () => null
}));
vi.mock("./shop-employees-add-vacation.component", () => ({
default: () => null
}));
vi.mock("../../utils/Ciecaselect", () => ({
default: () => []
}));
const bodyshop = {
id: "shop-1",
imexshopid: "split-shop-1",
md_responsibility_centers: {
costs: []
}
};
describe("ShopEmployeesFormComponent", () => {
let formInstance;
beforeEach(() => {
vi.clearAllMocks();
useQueryMock.mockImplementation((query) => {
if (query === QUERY_EMPLOYEE_BY_ID) {
return {
error: null,
data: null,
refetch: vi.fn(),
loading: false
};
}
return {
error: null,
data: null,
loading: false
};
});
useMutationMock.mockImplementation((mutation) => {
if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock];
if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock];
if (mutation === DELETE_VACATION) return [deleteVacationMock];
return [vi.fn()];
});
useApolloClient.mockReturnValue({
query: vi.fn().mockResolvedValue({
data: {
employees_aggregate: {
aggregate: {
count: 0
},
nodes: []
},
users: []
}
})
});
insertEmployeesMock.mockResolvedValue({
data: {
insert_employees: {
returning: [
{
id: "employee-123",
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
active: true,
termination_date: null,
hire_date: "2026-04-20",
flat_rate: false,
rates: [],
pin: "1234",
user_email: null
}
]
}
}
});
function TestHarness({ onFormReady }) {
const [form] = Form.useForm();
useEffect(() => {
onFormReady(form);
}, [form, onFormReady]);
return <ShopEmployeesFormComponent bodyshop={bodyshop} form={form} />;
}
render(
<TestHarness
onFormReady={(form) => {
formInstance = form;
}}
/>
);
});
it("marks a new employee form clean after save", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
const saveButton = screen.getByRole("button", { name: "Save Employee" });
await waitFor(() => {
expect(saveButton.disabled).toBe(false);
});
fireEvent.click(saveButton);
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledWith({
variables: {
employees: [
expect.objectContaining({
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
pin: "1234",
hire_date: "2026-04-20",
shopid: "shop-1"
})
]
},
refetchQueries: ["QUERY_EMPLOYEES"]
});
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=employee-123"
});
});
it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
fireEvent.click(screen.getByRole("button", { name: "Save and New" }));
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
await waitFor(() => {
expect(screen.getByRole("textbox", { name: "First Name" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Last Name" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Employee Number" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "PIN" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Hire Date" })).toHaveValue("");
});
expect(screen.getByText("New Employee")).toBeInTheDocument();
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=new"
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
});
});

View File

@@ -12,6 +12,8 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c
import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
@@ -25,16 +27,21 @@ import {
const timeZonesList = Intl.supportedValuesOf("timeZone");
const mapStateToProps = createStructuredSelector({});
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
export function ShopInfoGeneral({ form }) {
export function ShopInfoGeneral({ form, bodyshop }) {
const { t } = useTranslation();
const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || [];
const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name");
const hasDMSKey = bodyshop ? bodyshopHasDmsKey(bodyshop) : false;
const dmsMode = bodyshop ? getDmsMode(bodyshop, "off") : "none";
const isReynoldsMode = hasDMSKey && dmsMode === DMS_MAP.reynolds;
return (
<div>
@@ -174,7 +181,9 @@ export function ShopInfoGeneral({ form }) {
>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}</div>
<div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
</div>
<Form.Item noStyle name={["scoreboard_target", "ignoreblockeddays"]} valuePropName="checked">
<Switch />
</Form.Item>
@@ -311,7 +320,12 @@ export function ShopInfoGeneral({ form }) {
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item key="use_fippa" label={t("bodyshop.fields.use_fippa")} name={["use_fippa"]} valuePropName="checked">
<Form.Item
key="use_fippa"
label={t("bodyshop.fields.use_fippa")}
name={["use_fippa"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
@@ -478,7 +492,12 @@ export function ShopInfoGeneral({ form }) {
<div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
</div>
<Form.Item noStyle key="use_paint_scale_data" name={["use_paint_scale_data"]} valuePropName="checked">
<Form.Item
noStyle
key="use_paint_scale_data"
name={["use_paint_scale_data"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</div>
@@ -558,7 +577,12 @@ export function ShopInfoGeneral({ form }) {
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.shop_enabled_features")} id="sharing" grow style={{ marginBottom: 0 }}>
<LayoutFormRow
header={t("bodyshop.labels.shop_enabled_features")}
id="sharing"
grow
style={{ marginBottom: 0 }}
>
<Form.Item
label={t("general.actions.sharetoteams")}
valuePropName="checked"
@@ -566,6 +590,16 @@ export function ShopInfoGeneral({ form }) {
>
<Switch />
</Form.Item>
{isReynoldsMode && (
<Form.Item
initialValue
label={t("bodyshop.fields.md_functionality_toggles.enhanced_early_ros")}
name={["md_functionality_toggles", "enhanced_early_ros"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
)}
</LayoutFormRow>
</div>
</>
@@ -1582,7 +1616,6 @@ export function ShopInfoGeneral({ form }) {
form={form}
errorNames={[["md_parts_order_comment", field.name, "label"]]}
noDivider
titleOnly
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />

View File

@@ -810,16 +810,6 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
>
<Input onBlur={handleBlur} />
</Form.Item>
{!hasDMSKey && (
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
key={`${index}accountitem`}
name={[field.name, "accountitem"]}
rules={[{ required: true }]}
>
<Input onBlur={handleBlur} />
</Form.Item>
)}
{hasDMSKey && !bodyshop.rr_dealerid && (
<>
<Form.Item

View File

@@ -2,7 +2,7 @@ import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, Space } from "antd";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -15,21 +15,27 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket"))
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) {
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false);
const lastSubmittedRef = useRef(null);
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
@@ -48,47 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const employees = EmployeeAutoCompleteData?.employees ?? [];
const handleFinish = (values) => {
lastSubmittedRef.current = values;
setLoading(true);
const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid);
if (timeTicketModal.context.id) {
updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
...values,
rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
} else {
//Get selected employee rate.
insertTicket({
variables: {
timeTicketInput: [
{
const isEdit = Boolean(timeTicketModal.context.id);
const emps = employees.filter((employee) => employee.id === values.employeeid);
const mutation = isEdit
? updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
]
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
}
}
})
: insertTicket({
variables: {
timeTicketInput: [
{
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
}
]
}
});
mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError);
};
const handleMutationSuccess = () => {
const handleMutationSuccess = (result, isEdit) => {
notification.success({
title: t("timetickets.successes.created")
});
const savedTicket =
result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {};
const originalTicket = timeTicketModal.context?.timeticket ?? {};
const submittedValues = {
...(lastSubmittedRef.current ?? {}),
date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null,
employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null,
jobid:
lastSubmittedRef.current?.jobid ??
savedTicket.jobid ??
timeTicketModal.context.jobId ??
originalTicket.job?.id ??
originalTicket.jobid ??
null
};
const auditSummary = buildTimeTicketAuditSummary({
originalTicket,
submittedValues,
employees
});
if (auditSummary.jobid) {
insertAuditTrail({
jobid: auditSummary.jobid,
operation: isEdit
? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details)
: AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details),
type: isEdit ? "timeticketupdated" : "timeticketcreated"
});
}
// Refresh parent screens (Job Labor tab, etc.)
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();

View File

@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) {
const search = queryString.parse(useLocation().search);
const {
page
//sortcolumn, sortorder,
} = search;
const { page, pageSize } = search;
const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const [state, setState] = useState({
sortedInfo: {},
filteredInfo: { text: "" }
@@ -43,9 +44,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
key: "description",
render: (text, record) => {
return (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || ""
} ${record.v_color || ""}`}</span>
<span>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} `}</span>
);
}
},
@@ -62,10 +61,14 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
];
const handleTableChange = (pagination, filters, sorter) => {
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
const updatedSearch = {
...search,
page: pagination.current,
pageSize: nextPageSize,
page: pageSizeChanged ? 1 : pagination.current,
sortcolumn: sorter.columnKey,
sortorder: sorter.order
};
@@ -106,7 +109,13 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
>
<ResponsiveTable
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1), total: total }}
pagination={{
placement: "top",
pageSize: currentPageSize,
current: currentPage,
showSizeChanger: true,
total: total
}}
columns={columns}
mobileColumnKeys={["v_vin", "description", "plate_no"]}
rowKey="id"

View File

@@ -16,14 +16,18 @@ const mapStateToProps = createStructuredSelector({
export function VehiclesListContainer({ isPartsEntry }) {
const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams;
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const basePath = getPartsBasePath(isPartsEntry);
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_ALL_VEHICLES_PAGINATED, {
variables: {
search: search || "",
offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit,
offset: (currentPage - 1) * currentPageSize,
limit: currentPageSize,
order: [
{
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"

View File

@@ -18,16 +18,20 @@ const mapStateToProps = createStructuredSelector({});
export function ExportLogsPageComponent() {
const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams;
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_EXPORT_LOG_PAGINATED, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
search: search || "",
offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit,
offset: (currentPage - 1) * currentPageSize,
limit: currentPageSize,
order: [
{
...(sortcolumn === "ro_number"
@@ -61,7 +65,11 @@ export function ExportLogsPageComponent() {
if (error) return <AlertComponent title={error.message} type="error" />;
const handleTableChange = (pagination, filters, sorter) => {
searchParams.page = pagination.current;
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
searchParams.pageSize = nextPageSize;
searchParams.page = pageSizeChanged ? 1 : pagination.current;
searchParams.sortcolumn = sorter.columnKey;
searchParams.sortorder = sorter.order;
if (filters.status) {
@@ -191,8 +199,9 @@ export function ExportLogsPageComponent() {
loading={loading}
pagination={{
placement: "top",
pageSize: pageLimit,
current: parseInt(page || 1, 10),
pageSize: currentPageSize,
current: currentPage,
showSizeChanger: true,
total: data && data.search_exportlog_aggregate.aggregate.count
}}
columns={columns}

View File

@@ -1,6 +1,6 @@
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 { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
@@ -23,9 +23,8 @@ 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, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { CONVERT_JOB_TO_RO, GET_JOB_BY_PK } from "../../graphql/jobs.queries";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket";
@@ -302,7 +301,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
}
]}
>
<Select>
<Select
showSearch={{
optionFilterProp: "children"
}}
>
{bodyshop?.md_referral_sources?.map((s) => (
<Select.Option key={s} value={s}>
{s}
@@ -379,7 +382,13 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
</Form.Item>
<Space wrap style={{ marginTop: 16 }}>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
<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>

View File

@@ -8,13 +8,14 @@ import { createStructuredSelector } from "reselect";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsCreateComponent from "./jobs-create.component";
import JobCreateContext from "./jobs-create.context";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -22,10 +23,11 @@ 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 }))
});
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser, insertAuditTrail }) {
const { t } = useTranslation();
const notification = useNotification();
@@ -84,6 +86,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
newJobId: resp.data.insert_jobs.returning[0].id
});
logImEXEvent("manual_job_create_completed", {});
insertAuditTrail({
jobid: resp.data.insert_jobs.returning[0].id,
operation: AuditTrailMapping.jobmanualcreate(),
type: "jobmanualcreate"
});
setIsSubmitting(false);
})
.catch((error) => {

View File

@@ -27,12 +27,19 @@ const mapDispatchToProps = (dispatch) => ({
});
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
const technicianId = technician?.id;
const teamIds = (bodyshop?.employee_teams || [])
.filter((employeeTeam) =>
employeeTeam?.employee_team_members?.some((teamMember) => teamMember?.employeeid === technicianId)
)
.map((employeeTeam) => employeeTeam.id)
.filter(Boolean);
const hasAssignedTeams = Boolean(technicianId) && teamIds.length > 0;
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
variables: {
teamIds: bodyshop.employee_teams
.filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id))
.map((et) => et.id)
}
teamIds
},
skip: !technicianId || !hasAssignedTeams
});
const searchParams = queryString.parse(useLocation().search);
@@ -177,7 +184,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod
<Card
extra={
<Space wrap>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Button disabled={!hasAssignedTeams} onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {

View File

@@ -122,7 +122,7 @@
"billdeleted": "Bill with invoice number {{invoice_number}} deleted.",
"billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.",
"billposted": "Bill with invoice number {{invoice_number}} posted.",
"billupdated": "Bill with invoice number {{invoice_number}} updated.",
"billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.",
"failedpayment": "Failed payment attempt.",
"jobassignmentchange": "Employee {{name}} assigned to {{operation}}",
"jobassignmentremoved": "Employee assignment removed for {{operation}}",
@@ -137,6 +137,9 @@
"jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.",
"jobinvoiced": "Job has been invoiced.",
"jobioucreated": "IOU Created.",
"joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.",
"jobmanualcreate": "Job manually created.",
"jobmanuallineinsert": "Job line manually added with the following details: {{details}}.",
"jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.",
"jobnoteadded": "Note added to Job.",
"jobnotedeleted": "Note deleted from Job.",
@@ -152,7 +155,9 @@
"tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}",
"tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}",
"tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}",
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}",
"timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.",
"timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}"
}
},
"billlines": {
@@ -458,6 +463,7 @@
"md_email_cc": "Auto Email CC: $t(parts_orders.labels.{{template}})",
"md_from_emails": "Additional From Emails",
"md_functionality_toggles": {
"enhanced_early_ros": "Enable Enhance Early ROs",
"parts_queue_toggle": "Auto Add Imported/Supplemented Jobs to Parts Queue"
},
"md_hour_split": {
@@ -1216,6 +1222,7 @@
"confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.",
"doctype": "Document Type",
"dragtoupload": "Click or drag files to this area to upload",
"greyscale": "Greyscale",
"newjobid": "Assign to Job",
"openinexplorer": "Open in Explorer",
"optimizedimage": "The below image is optimized. Click on the picture below to open in a new window and view it full size, or open it in explorer.",
@@ -1782,6 +1789,7 @@
"actions": {
"addDocuments": "Add Job Documents",
"addNote": "Add Note",
"addpayer": "Add Payer",
"addtopartsqueue": "Add to Parts Queue",
"addtoproduction": "Add to Production",
"addtoscoreboard": "Add to Scoreboard",
@@ -1958,6 +1966,7 @@
"ded_status": "Deductible Status",
"depreciation_taxes": "Betterment/Depreciation/Taxes",
"dms": {
"IsARCustomer": "AR Customer?",
"address": "Customer Address",
"advisor": "Advisor #",
"amount": "Amount",
@@ -3259,6 +3268,7 @@
"information": "Information",
"layout": "Layout",
"statistics": {
"exclude_suspended": "Exclude Suspended Jobs",
"jobs_in_production": "Jobs in Production",
"tasks_in_production": "Tasks in Production",
"tasks_in_view": "Tasks in View",

View File

@@ -122,7 +122,6 @@
"billdeleted": "",
"billposted": "",
"billmarkforreexport": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -458,6 +457,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -1216,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Escala de grises",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1782,6 +1783,7 @@
"actions": {
"addDocuments": "Agregar documentos de trabajo",
"addNote": "Añadir la nota",
"addpayer": "",
"addtopartsqueue": "",
"addtoproduction": "",
"addtoscoreboard": "",
@@ -1958,6 +1960,7 @@
"ded_status": "Estado deducible",
"depreciation_taxes": "Depreciación / Impuestos",
"dms": {
"IsARCustomer": "",
"address": "",
"advisor": "",
"amount": "",
@@ -3259,6 +3262,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",

View File

@@ -122,7 +122,6 @@
"billdeleted": "",
"billmarkforreexport": "",
"billposted": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -458,6 +457,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -1216,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Niveaux de gris",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1782,6 +1783,7 @@
"actions": {
"addDocuments": "Ajouter des documents de travail",
"addNote": "Ajouter une note",
"addpayer": "",
"addtopartsqueue": "",
"addtoproduction": "",
"addtoscoreboard": "",
@@ -1958,6 +1960,7 @@
"ded_status": "Statut de franchise",
"depreciation_taxes": "Amortissement / taxes",
"dms": {
"IsARCustomer": "",
"address": "",
"advisor": "",
"amount": "",
@@ -3259,6 +3262,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",

View File

@@ -10,7 +10,7 @@ const AuditTrailMapping = {
billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }),
billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }),
billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }),
billupdated: (invoice_number) => i18n.t("audit_trail.messages.billupdated", { invoice_number }),
billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }),
jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }),
jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }),
jobchecklist: (type, inproduction, status) =>
@@ -26,6 +26,10 @@ const AuditTrailMapping = {
jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }),
jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"),
jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"),
joblineupdate: (lineDescription, details) =>
i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }),
jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"),
jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }),
jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }),
jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"),
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
@@ -72,7 +76,11 @@ const AuditTrailMapping = {
i18n.t("audit_trail.messages.tasks_uncompleted", {
title,
uncompletedBy
})
}),
timeticketcreated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }),
timeticketupdated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details })
};
export default AuditTrailMapping;

View File

@@ -1,5 +1,6 @@
import i18n from "i18next";
//import { store } from "../redux/store";
import { DMS_MAP } from "./dmsUtils";
import InstanceRenderManager from "./instanceRenderMgr";
export const EmailSettings = {
@@ -570,7 +571,8 @@ export const TemplateList = (type, context) => {
key: "dms_posting_sheet",
disabled: false,
group: "financial",
dms: true
dms: true,
excludedDmsModes: [DMS_MAP.reynolds]
},
worksheet_sorted_by_team: {
title: i18n.t("printcenter.jobs.worksheet_sorted_by_team"),

View File

@@ -0,0 +1,186 @@
import dayjs from "./day";
const EMPTY_VALUE = "<<empty>>";
const NO_CHANGES = "No changes";
const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"];
const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]);
const DATE_ONLY_KEYS = new Set(["date"]);
const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]);
const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]);
const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]);
const isBlank = (value) => value == null || value === "";
const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value);
const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD"));
const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm"));
const formatNumber = (value, fractionDigits) =>
typeof value === "number" ? value.toFixed(fractionDigits) : String(value);
const compareValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (dayjs.isDayjs?.(value)) return formatDateTime(value);
return String(value);
};
const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) =>
keys
.filter((key) => key !== "__typename" && !skippedKeys.has(key))
.filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key]))
.map((key) => {
if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null;
return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`;
})
.filter(Boolean);
const formatBillValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
return String(value);
};
const formatJobLineValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
return String(value);
};
const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => {
if (
(employeeId == null || fallbackEmployee?.id === employeeId) &&
(fallbackEmployee?.first_name || fallbackEmployee?.last_name)
) {
return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" ");
}
const employee = employees.find(({ id }) => id === employeeId);
if (employee) {
return [employee.first_name, employee.last_name].filter(Boolean).join(" ");
}
return employeeId ? String(employeeId) : EMPTY_VALUE;
};
const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => {
if (isBlank(value)) return EMPTY_VALUE;
if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee);
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
};
const buildBillLineSummary = (line) =>
BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", ");
export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) {
const updatedBill = { ...bill, billlines };
const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter(
(key) => key !== "billlines"
);
const changed = buildFieldChangeDetails({
keys: billKeys,
original: originalBill,
updated: updatedBill,
displayValue: formatBillValue
});
const originalBillLines = originalBill.billlines ?? [];
const updatedBillLines = updatedBill.billlines ?? [];
const addedLines = updatedBillLines
.filter((line) => !line.id)
.map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`);
const removedLines = originalBillLines
.filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id))
.map(
(line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})`
);
const modifiedLines = updatedBillLines
.filter((line) => line.id)
.flatMap((line) => {
const originalLine = originalBillLines.find(({ id }) => id === line.id);
if (!originalLine) return [];
const lineChanges = buildFieldChangeDetails({
keys: BILL_LINE_KEYS,
original: originalLine,
updated: line,
displayValue: formatBillValue
});
if (!lineChanges.length) return [];
return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`];
});
if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`);
if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`);
if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`);
return changed.length ? changed.join("; ") : NO_CHANGES;
}
export function buildJobLineInsertAuditDetails(values = {}) {
const details = Object.entries(values)
.filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value))
.map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`);
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) {
const details = buildFieldChangeDetails({
keys: Object.keys(values),
original: originalLine,
updated: values,
displayValue: formatJobLineValue,
skippedKeys: JOB_LINE_SKIP_KEYS
});
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) {
const normalizedOriginal = {
...originalTicket,
jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null
};
const details = buildFieldChangeDetails({
keys: Object.keys(submittedValues),
original: normalizedOriginal,
updated: submittedValues,
displayValue: (key, value) =>
formatTimeTicketValue(key, value, {
employees,
fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null
})
});
const employeeName = getEmployeeName(
submittedValues.employeeid ?? normalizedOriginal.employeeid,
employees,
normalizedOriginal.employee
);
return {
date: formatDate(submittedValues.date ?? normalizedOriginal.date),
details: details.length ? details.join("; ") : NO_CHANGES,
employeeName,
jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null
};
}

View File

@@ -1164,6 +1164,7 @@
- notification_followers
- state
- md_order_statuses
- md_ro_statuses
retry_conf:
interval_sec: 10
num_retries: 0
@@ -1184,7 +1185,8 @@
"new": {
"id": {{$body.event.data.new.id}},
"shopname": {{$body.event.data.new.shopname}},
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
"md_order_statuses": {{$body.event.data.new.md_order_statuses}},
"md_ro_statuses": {{$body.event.data.new.md_ro_statuses}}
}
},
"op": {{$body.event.op}},

View File

@@ -32,6 +32,7 @@ async function OpenSearchUpdateHandler(req, res) {
clm_no
clm_total
comment
dms_id
ins_co_nm
owner_owing
ownr_co_nm

View File

@@ -98,12 +98,26 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte
socket.JobData.ownr_fn || ""
} ${socket.JobData.ownr_ln || ""} ${socket.JobData.ownr_co_nm || ""}`
);
const ownerRef = await UpsertContactData(socket, selectedCustomerId);
socket.ownerRef = ownerRef;
WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`);
const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId);
socket.vehicleRef = vehicleRef;
//If this is an AR customer, don't do anything.
const selectedCustomer = [...(socket.DMSVehCustomer ? [{ ...socket.DMSVehCustomer, vinOwner: true }] : []),
...socket.DMSCustList]?.find((cust) => cust.ContactId === selectedCustomerId);
if (selectedCustomer?.IsARCustomer) {
WsLogger.createLogEvent(socket, "INFO", `Skipping contact and vehicle update becuase it is marked as an AR contact in PBS.`);
}
else {
const ownerRef = await UpsertContactData(socket, selectedCustomerId);
socket.ownerRef = ownerRef;
WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`);
const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId);
socket.vehicleRef = vehicleRef;
}
} else {
WsLogger.createLogEvent(
socket,

View File

@@ -1,8 +1,20 @@
const { TextractClient, StartExpenseAnalysisCommand, GetExpenseAnalysisCommand, AnalyzeExpenseCommand } = require("@aws-sdk/client-textract");
const {
TextractClient,
StartExpenseAnalysisCommand,
GetExpenseAnalysisCommand,
AnalyzeExpenseCommand
} = require("@aws-sdk/client-textract");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } = require("@aws-sdk/client-sqs");
const { v4: uuidv4 } = require('uuid');
const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPageCount, hasActiveJobs } = require("./bill-ocr-helpers");
const { v4: uuidv4 } = require("uuid");
const {
getTextractJobKey,
setTextractJob,
getTextractJob,
getFileType,
getPdfPageCount,
hasActiveJobs
} = require("./bill-ocr-helpers");
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
const { generateBillFormData } = require("./bill-ocr-generator");
const logger = require("../../utils/logger");
@@ -10,11 +22,11 @@ const _ = require("lodash");
// Initialize AWS clients
const awsConfig = {
region: process.env.AWS_AI_REGION || "ca-central-1",
credentials: {
accessKeyId: process.env.AWS_AI_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_AI_SECRET_ACCESS_KEY,
}
region: process.env.AWS_AI_REGION || "ca-central-1",
credentials: {
accessKeyId: process.env.AWS_AI_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_AI_SECRET_ACCESS_KEY
}
};
const textractClient = new TextractClient(awsConfig);
@@ -23,318 +35,339 @@ const sqsClient = new SQSClient(awsConfig);
let redisPubClient = null;
/**
* Initialize the bill-ocr module with Redis client
* @param {Object} pubClient - Redis cluster client
*/
function initializeBillOcr(pubClient) {
redisPubClient = pubClient;
redisPubClient = pubClient;
}
/**
* Check if job exists by Textract job ID
* @param {string} textractJobId
* @param {string} textractJobId
* @returns {Promise<boolean>}
*/
async function jobExists(textractJobId) {
if (!redisPubClient) {
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
}
const key = getTextractJobKey(textractJobId);
const exists = await redisPubClient.exists(key);
if (!redisPubClient) {
throw new Error("Redis client not initialized. Call initializeBillOcr first.");
}
const key = getTextractJobKey(textractJobId);
const exists = await redisPubClient.exists(key);
if (exists) {
return true;
}
return false;
if (exists) {
return true;
}
return false;
}
async function handleBillOcr(req, res) {
// Check if file was uploaded
if (!req.file) {
return res.status(400).send({ error: 'No file uploaded.' });
}
// Check if file was uploaded
if (!req.file) {
return res.status(400).send({ error: "No file uploaded." });
}
// The uploaded file is available in request file
const uploadedFile = req.file;
const { jobid, bodyshopid, partsorderid } = req.body;
logger.log("bill-ocr-start", "DEBUG", req.user.email, jobid, null);
// The uploaded file is available in request file
const uploadedFile = req.file;
const { jobid, bodyshopid, partsorderid } = req.body;
logger.log("bill-ocr-start", "DEBUG", req.user.email, jobid, null);
try {
const fileType = getFileType(uploadedFile);
// Images are always processed synchronously (single page)
if (fileType === 'image') {
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
try {
const fileType = getFileType(uploadedFile);
// Images are always processed synchronously (single page)
if (fileType === "image") {
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({
processedData: processedData,
jobid,
bodyshopid,
partsorderid,
req: req
});
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, {
..._.omit(processedData, "originalTextractResponse"),
billForm
});
return res.status(200).json({
success: true,
status: 'COMPLETED',
data: { ...processedData, billForm },
message: 'Invoice processing completed'
});
} else if (fileType === 'pdf') {
// Check the number of pages in the PDF
const pageCount = await getPdfPageCount(uploadedFile.buffer);
return res.status(200).json({
success: true,
status: "COMPLETED",
data: { ...processedData, billForm },
message: "Invoice processing completed"
});
} else if (fileType === "pdf") {
// Check the number of pages in the PDF
const pageCount = await getPdfPageCount(uploadedFile.buffer);
if (pageCount === 1) {
// Process synchronously for single-page documents
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
status: 'COMPLETED',
data: { ...processedData, billForm },
message: 'Invoice processing completed'
});
}
// Start the Textract job (non-blocking) for multi-page documents
const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid });
logger.log("bill-ocr-multipage-start", "DEBUG", req.user.email, jobid, jobInfo);
return res.status(202).json({
success: true,
textractJobId: jobInfo.jobId,
message: 'Invoice processing started',
statusUrl: `/ai/bill-ocr/status/${jobInfo.jobId}`
});
} else {
logger.log("bill-ocr-unsupported-filetype", "WARN", req.user.email, jobid, { fileType });
return res.status(400).json({
error: 'Unsupported file type',
message: 'Please upload a PDF or supported image file (JPEG, PNG, TIFF)'
});
}
} catch (error) {
logger.log("bill-ocr-error", "ERROR", req.user.email, jobid, { error: error.message, stack: error.stack });
return res.status(500).json({
error: 'Failed to start invoice processing',
message: error.message
if (pageCount === 1) {
// Process synchronously for single-page documents
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({
processedData: processedData,
jobid,
bodyshopid,
partsorderid,
req: req
});
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, {
..._.omit(processedData, "originalTextractResponse"),
billForm
});
return res.status(200).json({
success: true,
status: "COMPLETED",
data: { ...processedData, billForm },
message: "Invoice processing completed"
});
}
// Start the Textract job (non-blocking) for multi-page documents
const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid });
logger.log("bill-ocr-multipage-start", "DEBUG", req.user.email, jobid, jobInfo);
return res.status(202).json({
success: true,
textractJobId: jobInfo.jobId,
message: "Invoice processing started",
statusUrl: `/ai/bill-ocr/status/${jobInfo.jobId}`
});
} else {
logger.log("bill-ocr-unsupported-filetype", "WARN", req.user.email, jobid, { fileType });
return res.status(400).json({
error: "Unsupported file type",
message: "Please upload a PDF or supported image file (JPEG, PNG, TIFF)"
});
}
} catch (error) {
logger.log("bill-ocr-error", "ERROR", req.user.email, jobid, { error: error.message, stack: error.stack });
return res.status(500).json({
error: "Failed to start invoice processing",
message: error.message
});
}
}
async function handleBillOcrStatus(req, res) {
const { textractJobId } = req.params;
const { textractJobId } = req.params;
if (!textractJobId) {
logger.log("bill-ocr-status-error", "WARN", req.user.email, null, { error: 'No textractJobId found in params' });
return res.status(400).json({ error: 'Job ID is required' });
if (!textractJobId) {
logger.log("bill-ocr-status-error", "WARN", req.user.email, null, { error: "No textractJobId found in params" });
return res.status(400).json({ error: "Job ID is required" });
}
const jobStatus = await getTextractJob({ redisPubClient, textractJobId });
}
const jobStatus = await getTextractJob({ redisPubClient, textractJobId });
if (!jobStatus) {
return res.status(404).json({ error: "Job not found" });
}
if (!jobStatus) {
return res.status(404).json({ error: 'Job not found' });
}
if (jobStatus.status === "COMPLETED") {
// Generate billForm on-demand if not already generated
let billForm = jobStatus.data?.billForm;
if (jobStatus.status === 'COMPLETED') {
// Generate billForm on-demand if not already generated
let billForm = jobStatus.data?.billForm;
if (!billForm && jobStatus.context) {
try {
billForm = await generateBillFormData({
processedData: jobStatus.data,
jobid: jobStatus.context.jobid,
bodyshopid: jobStatus.context.bodyshopid,
partsorderid: jobStatus.context.partsorderid,
req: req // Now we have request context!
});
logger.log("bill-ocr-multipage-complete", "DEBUG", req.user.email, jobStatus.context.jobid, {
...jobStatus.data,
billForm
});
if (!billForm && jobStatus.context) {
try {
billForm = await generateBillFormData({
processedData: jobStatus.data,
jobid: jobStatus.context.jobid,
bodyshopid: jobStatus.context.bodyshopid,
partsorderid: jobStatus.context.partsorderid,
req: req // Now we have request context!
});
logger.log("bill-ocr-multipage-complete", "DEBUG", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, billForm });
// Cache the billForm back to Redis for future requests
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobStatus,
data: {
...jobStatus.data,
billForm
}
}
});
} catch (error) {
logger.log("bill-ocr-multipage-error", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: error.message, stack: error.stack });
return res.status(500).send({
status: 'COMPLETED',
error: 'Data processed but failed to generate bill form',
message: error.message,
data: jobStatus.data // Still return the raw processed data
});
}
}
return res.status(200).send({
status: 'COMPLETED',
// Cache the billForm back to Redis for future requests
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobStatus,
data: {
...jobStatus.data,
billForm
...jobStatus.data,
billForm
}
}
});
} catch (error) {
logger.log("bill-ocr-multipage-error", "ERROR", req.user.email, jobStatus.context.jobid, {
...jobStatus.data,
error: error.message,
stack: error.stack
});
} else if (jobStatus.status === 'FAILED') {
logger.log("bill-ocr-multipage-failed", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: jobStatus.error, });
return res.status(500).json({
status: 'FAILED',
error: jobStatus.error
});
} else {
return res.status(200).json({
status: jobStatus.status
return res.status(500).send({
status: "COMPLETED",
error: "Data processed but failed to generate bill form",
message: error.message,
data: jobStatus.data // Still return the raw processed data
});
}
}
return res.status(200).send({
status: "COMPLETED",
data: {
...jobStatus.data,
billForm
}
});
} else if (jobStatus.status === "FAILED") {
logger.log("bill-ocr-multipage-failed", "ERROR", req.user.email, jobStatus.context.jobid, {
...jobStatus.data,
error: jobStatus.error
});
return res.status(500).json({
status: "FAILED",
error: jobStatus.error
});
} else {
return res.status(200).json({
status: jobStatus.status
});
}
}
/**
* Process a single-page document synchronously using AnalyzeExpenseCommand
* @param {Buffer} pdfBuffer
* @param {Buffer} pdfBuffer
* @returns {Promise<Object>}
*/
async function processSinglePageDocument(pdfBuffer) {
const analyzeCommand = new AnalyzeExpenseCommand({
Document: {
Bytes: pdfBuffer
}
});
const analyzeCommand = new AnalyzeExpenseCommand({
Document: {
Bytes: pdfBuffer
}
});
const result = await textractClient.send(analyzeCommand);
const invoiceData = extractInvoiceData(result);
const processedData = processScanData(invoiceData);
const result = await textractClient.send(analyzeCommand);
const invoiceData = extractInvoiceData(result);
const processedData = processScanData(invoiceData);
return {
...processedData,
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: result
};
return {
...processedData
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: result
};
}
async function startTextractJob(pdfBuffer, context = {}) {
// Upload PDF to S3 temporarily for Textract async processing
const { bodyshopid, jobid } = context;
const s3Bucket = process.env.AWS_AI_BUCKET;
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
const snsRoleArn = process.env.AWS_TEXTRACT_SNS_ROLE_ARN;
// Upload PDF to S3 temporarily for Textract async processing
const { bodyshopid, jobid } = context;
const s3Bucket = process.env.AWS_AI_BUCKET;
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
const snsRoleArn = process.env.AWS_TEXTRACT_SNS_ROLE_ARN;
if (!s3Bucket) {
throw new Error('AWS_AI_BUCKET environment variable is required');
}
if (!snsTopicArn) {
throw new Error('AWS_TEXTRACT_SNS_TOPIC_ARN environment variable is required');
}
if (!snsRoleArn) {
throw new Error('AWS_TEXTRACT_SNS_ROLE_ARN environment variable is required');
}
if (!s3Bucket) {
throw new Error("AWS_AI_BUCKET environment variable is required");
}
if (!snsTopicArn) {
throw new Error("AWS_TEXTRACT_SNS_TOPIC_ARN environment variable is required");
}
if (!snsRoleArn) {
throw new Error("AWS_TEXTRACT_SNS_ROLE_ARN environment variable is required");
}
const uploadId = uuidv4();
const s3Key = `textract-temp/${bodyshopid}/${jobid}/${uploadId}.pdf`; //TODO Update Keys structure to something better.
const uploadId = uuidv4();
const s3Key = `textract-temp/${bodyshopid}/${jobid}/${uploadId}.pdf`; //TODO Update Keys structure to something better.
// Upload to S3
const uploadCommand = new PutObjectCommand({
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: s3Bucket,
Key: s3Key,
Body: pdfBuffer,
ContentType: "application/pdf" //Hard coded - we only support PDFs for multi-page
});
await s3Client.send(uploadCommand);
// Start async Textract expense analysis with SNS notification
const startCommand = new StartExpenseAnalysisCommand({
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Key: s3Key,
Body: pdfBuffer,
ContentType: 'application/pdf' //Hard coded - we only support PDFs for multi-page
});
await s3Client.send(uploadCommand);
Name: s3Key
}
},
NotificationChannel: {
SNSTopicArn: snsTopicArn,
RoleArn: snsRoleArn
},
ClientRequestToken: uploadId
});
// Start async Textract expense analysis with SNS notification
const startCommand = new StartExpenseAnalysisCommand({
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Name: s3Key
}
},
NotificationChannel: {
SNSTopicArn: snsTopicArn,
RoleArn: snsRoleArn
},
ClientRequestToken: uploadId
});
const startResult = await textractClient.send(startCommand);
const textractJobId = startResult.JobId;
const startResult = await textractClient.send(startCommand);
const textractJobId = startResult.JobId;
// Store job info in Redis using textractJobId as the key
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
status: "IN_PROGRESS",
s3Key: s3Key,
uploadId: uploadId,
startedAt: new Date().toISOString(),
context: context // Store the context for later use
}
});
// Store job info in Redis using textractJobId as the key
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
status: 'IN_PROGRESS',
s3Key: s3Key,
uploadId: uploadId,
startedAt: new Date().toISOString(),
context: context // Store the context for later use
}
}
);
return {
jobId: textractJobId
};
return {
jobId: textractJobId
};
}
// Process SQS messages from Textract completion notifications
async function processSQSMessages() {
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
if (!queueUrl) {
logger.log("bill-ocr-error", "ERROR", "api", null, { message: "AWS_TEXTRACT_SQS_QUEUE_URL not configured" });
return;
}
// Only poll if there are active mutli page jobs in progress
const hasActive = await hasActiveJobs({ redisPubClient });
if (!hasActive) {
return;
}
// Only poll if there are active mutli page jobs in progress
const hasActive = await hasActiveJobs({ redisPubClient });
if (!hasActive) {
return;
}
try {
const receiveCommand = new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
MessageAttributeNames: ["All"]
});
try {
const receiveCommand = new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
MessageAttributeNames: ['All']
});
const result = await sqsClient.send(receiveCommand);
const result = await sqsClient.send(receiveCommand);
if (result.Messages && result.Messages.length > 0) {
logger.log("bill-ocr-sqs-processing", "DEBUG", "api", null, {
message: `Processing ${result.Messages.length} messages from SQS`
});
for (const message of result.Messages) {
try {
// Environment-level filtering: check if this message belongs to this environment
const shouldProcess = await shouldProcessMessage(message);
if (result.Messages && result.Messages.length > 0) {
logger.log("bill-ocr-sqs-processing", "DEBUG", "api", null, { message: `Processing ${result.Messages.length} messages from SQS` });
for (const message of result.Messages) {
try {
// Environment-level filtering: check if this message belongs to this environment
const shouldProcess = await shouldProcessMessage(message);
if (shouldProcess) {
await handleTextractNotification(message);
// Delete message after successful processing
const deleteCommand = new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: message.ReceiptHandle
});
await sqsClient.send(deleteCommand);
}
} catch (error) {
logger.log("bill-ocr-sqs-processing-error", "ERROR", "api", null, { message, error: error.message, stack: error.stack });
}
}
if (shouldProcess) {
await handleTextractNotification(message);
// Delete message after successful processing
const deleteCommand = new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: message.ReceiptHandle
});
await sqsClient.send(deleteCommand);
}
} catch (error) {
logger.log("bill-ocr-sqs-processing-error", "ERROR", "api", null, {
message,
error: error.message,
stack: error.stack
});
}
} catch (error) {
logger.log("bill-ocr-sqs-receiving-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
}
} catch (error) {
logger.log("bill-ocr-sqs-receiving-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
}
/**
@@ -343,125 +376,140 @@ async function processSQSMessages() {
* @returns {Promise<boolean>}
*/
async function shouldProcessMessage(message) {
try {
const body = JSON.parse(message.Body);
const snsMessage = JSON.parse(body.Message);
const textractJobId = snsMessage.JobId;
try {
const body = JSON.parse(message.Body);
const snsMessage = JSON.parse(body.Message);
const textractJobId = snsMessage.JobId;
// Check if job exists in Redis for this environment (using environment-specific prefix)
const exists = await jobExists(textractJobId);
return exists;
} catch (error) {
logger.log("bill-ocr-message-check-error", "DEBUG", "api", null, { message: "Error checking if message should be processed", error: error.message, stack: error.stack });
// If we can't parse the message, don't process it
return false;
}
// Check if job exists in Redis for this environment (using environment-specific prefix)
const exists = await jobExists(textractJobId);
return exists;
} catch (error) {
logger.log("bill-ocr-message-check-error", "DEBUG", "api", null, {
message: "Error checking if message should be processed",
error: error.message,
stack: error.stack
});
// If we can't parse the message, don't process it
return false;
}
}
async function handleTextractNotification(message) {
const body = JSON.parse(message.Body);
let snsMessage
try {
snsMessage = JSON.parse(body.Message);
} catch (error) {
logger.log("bill-ocr-handle-textract-error", "DEBUG", "api", null, { message: "Error parsing SNS message - invalid message format.", error: error.message, stack: error.stack, body });
return;
}
const body = JSON.parse(message.Body);
let snsMessage;
try {
snsMessage = JSON.parse(body.Message);
} catch (error) {
logger.log("bill-ocr-handle-textract-error", "DEBUG", "api", null, {
message: "Error parsing SNS message - invalid message format.",
error: error.message,
stack: error.stack,
body
});
return;
}
const textractJobId = snsMessage.JobId;
const status = snsMessage.Status;
const textractJobId = snsMessage.JobId;
const status = snsMessage.Status;
// Get job info from Redis
const jobInfo = await getTextractJob({ redisPubClient, textractJobId });
// Get job info from Redis
const jobInfo = await getTextractJob({ redisPubClient, textractJobId });
if (!jobInfo) {
logger.log("bill-ocr-job-not-found", "DEBUG", "api", null, { message: `Job info not found in Redis for Textract job ID: ${textractJobId}`, textractJobId, snsMessage });
return;
}
if (!jobInfo) {
logger.log("bill-ocr-job-not-found", "DEBUG", "api", null, {
message: `Job info not found in Redis for Textract job ID: ${textractJobId}`,
textractJobId,
snsMessage
});
return;
}
if (status === 'SUCCEEDED') {
// Retrieve the results
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
if (status === "SUCCEEDED") {
// Retrieve the results
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
// Store the processed data - billForm will be generated on-demand in the status endpoint
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: 'COMPLETED',
data: {
...processedData,
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: originalResponse
},
completedAt: new Date().toISOString()
}
}
);
} else if (status === 'FAILED') {
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: 'FAILED',
error: snsMessage.StatusMessage || 'Textract job failed',
completedAt: new Date().toISOString()
}
}
);
}
// Store the processed data - billForm will be generated on-demand in the status endpoint
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: "COMPLETED",
data: {
...processedData
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: originalResponse
},
completedAt: new Date().toISOString()
}
});
} else if (status === "FAILED") {
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: "FAILED",
error: snsMessage.StatusMessage || "Textract job failed",
completedAt: new Date().toISOString()
}
});
}
}
async function retrieveTextractResults(textractJobId) {
// Handle pagination if there are multiple pages of results
let allExpenseDocuments = [];
let nextToken = null;
// Handle pagination if there are multiple pages of results
let allExpenseDocuments = [];
let nextToken = null;
do {
const getCommand = new GetExpenseAnalysisCommand({
JobId: textractJobId,
NextToken: nextToken
});
do {
const getCommand = new GetExpenseAnalysisCommand({
JobId: textractJobId,
NextToken: nextToken
});
const result = await textractClient.send(getCommand);
const result = await textractClient.send(getCommand);
if (result.ExpenseDocuments) {
allExpenseDocuments = allExpenseDocuments.concat(result.ExpenseDocuments);
}
if (result.ExpenseDocuments) {
allExpenseDocuments = allExpenseDocuments.concat(result.ExpenseDocuments);
}
nextToken = result.NextToken;
} while (nextToken);
nextToken = result.NextToken;
} while (nextToken);
// Store the complete original response
const fullTextractResponse = { ExpenseDocuments: allExpenseDocuments };
// Store the complete original response
const fullTextractResponse = { ExpenseDocuments: allExpenseDocuments };
// Extract invoice data from Textract response
const invoiceData = extractInvoiceData(fullTextractResponse);
// Extract invoice data from Textract response
const invoiceData = extractInvoiceData(fullTextractResponse);
return {
processedData: processScanData(invoiceData),
originalResponse: fullTextractResponse
};
return {
processedData: processScanData(invoiceData),
originalResponse: fullTextractResponse
};
}
// Start SQS polling (call this when server starts)
function startSQSPolling() {
const pollInterval = setInterval(() => {
processSQSMessages().catch(error => {
logger.log("bill-ocr-sqs-poll-error", "ERROR", "api", null, { message: error.message, stack: error.stack });
});
}, 10000); // Poll every 10 seconds
return pollInterval;
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
if (!queueUrl) {
logger.log("bill-ocr-error", "ERROR", "api", null, { message: "AWS_TEXTRACT_SQS_QUEUE_URL not configured" });
return;
}
const pollInterval = setInterval(() => {
processSQSMessages().catch((error) => {
logger.log("bill-ocr-sqs-poll-error", "ERROR", "api", null, { message: error.message, stack: error.stack });
});
}, 10000); // Poll every 10 seconds
return pollInterval;
}
module.exports = {
initializeBillOcr,
handleBillOcr,
handleBillOcrStatus,
startSQSPolling
};
initializeBillOcr,
handleBillOcr,
handleBillOcrStatus,
startSQSPolling
};

View File

@@ -250,7 +250,8 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop
function buildInteractionPayload(bodyshop, j) {
const isCompany = Boolean(j.ownr_co_nm);
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const locationIdentifier = bodyshop?.imexshopid ?? `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
if (j.actual_delivery && !timestamp) {

View File

@@ -2442,6 +2442,9 @@ exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) {
associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) {
id
shopid
bodyshop {
rr_dealerid
}
}
}`;
@@ -2953,6 +2956,7 @@ exports.GET_BODYSHOP_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
md_ro_statuses
md_order_statuses
shopname
imexshopid

View File

@@ -31,7 +31,7 @@ exports.totalsSsu = async function (req, res) {
id: id
});
const newTotals = await TotalsServerSide({ body: { job: job.jobs_by_pk, client: client } }, res, true);
const newTotals = await TotalsServerSide({ body: { job: job.jobs_by_pk, client: client } });
const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_JOB, {
jobId: id,
@@ -54,12 +54,15 @@ exports.totalsSsu = async function (req, res) {
error: error.message,
stack: error.stack
});
res.status(503).json({ error: "Failed to calculate totals" });
res.status(503).json({
error: "Failed to calculate totals",
message: error?.message || "Unknown error"
});
}
};
//IMPORTANT*** These two functions MUST be mirrored.
async function TotalsServerSide(req, res) {
async function TotalsServerSide(req) {
const { job, client } = req.body;
await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user });
@@ -134,10 +137,11 @@ async function TotalsServerSide(req, res) {
.filter((v) => v != null);
const taxRate = Math.max(...laborRates, ...materialRates, ...partsRates);
const totalTaxes = ret.totals.taxableAmounts.total.multiply(taxRate > 1 ? taxRate / 100 : taxRate);
ret.totals.taxableAmounts.total = ret.totals.taxableAmounts.total
.multiply(emsTaxTotal)
.divide(totalTaxes.toUnit());
if (taxRate > 0) {
ret.totals.taxableAmounts.total = Dinero({
amount: Math.round((emsTaxTotal / (taxRate > 1 ? taxRate / 100 : taxRate)) * 100)
});
}
} else {
ret.totals.taxableAmounts.total = ret.totals.taxableAmounts.total.multiply(emsTaxTotal).divide(totalUsTaxes);
}
@@ -155,7 +159,7 @@ async function TotalsServerSide(req, res) {
error: error.message,
stack: error.stack
});
res.status(400).send(JSON.stringify(error));
throw error;
}
}
@@ -168,7 +172,7 @@ async function Totals(req, res) {
const logger = req.logger;
const client = req.userGraphQLClient;
logger.log("job-totals-ssu-USA", "debug", req.user.email, job.id, {
logger.log("job-totals-ssu-USA", "debug", req?.user?.email, job.id, {
jobid: job.id,
id: id
});
@@ -185,7 +189,7 @@ async function Totals(req, res) {
res.status(200).json(ret);
} catch (error) {
logger.log("job-totals-ssu-USA-error", "error", req.user.email, job.id, {
logger.log("job-totals-ssu-USA-error", "error", req?.user?.email, job.id, {
jobid: job.id,
error: error.message,
stack: error.stack
@@ -788,7 +792,7 @@ function IsAdditionalCost(jobLine) {
return (
(jobLine.lbr_op === "OP13" || //Added to resolve manual job lines coming into other totals because they have no reference.
(jobLine.part_type === null && (jobLine.act_price || 0 > 0)) ||
(jobLine.part_type === null && (jobLine.act_price || 0) > 0) ||
(jobLine.db_ref && jobLine.db_ref.startsWith("9360"))) && //This ref works in Canada, but DB_REFS in the US do not fill in.
!isPaintOrShopMat
);
@@ -993,12 +997,13 @@ function CalculateTaxesTotals(job, otherTotals) {
})
);
if (stlStorage)
if (stlStorage) {
taxableAmounts.STOR = taxableAmounts.STOR.add(
(taxableAmounts.STOR = Dinero({
Dinero({
amount: Math.round(stlStorage.t_amt * 100)
}))
})
);
}
if (!stlStorage && !job.ciecaid && job.storage_payable)
taxableAmounts.STOR = taxableAmounts.STOR.add(

View File

@@ -23,7 +23,7 @@ exports.totalsSsu = async function (req, res) {
const BearerToken = req.BearerToken;
const client = req.userGraphQLClient;
logger.log("job-totals-ssu", "debug", req.user.email, id, null);
logger.log("job-totals-ssu", "debug", req?.user?.email, id, null);
try {
const job = await client.setHeaders({ Authorization: BearerToken }).request(queries.GET_JOB_BY_PK, {
@@ -31,7 +31,7 @@ exports.totalsSsu = async function (req, res) {
});
// Capture the output of TotalsServerSide
const newTotals = await TotalsServerSide({ body: { job: job.jobs_by_pk, client: client } }, res, true);
const newTotals = await TotalsServerSide({ body: { job: job.jobs_by_pk, client: client } });
const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_JOB, {
jobId: id,
@@ -49,7 +49,7 @@ exports.totalsSsu = async function (req, res) {
res.status(200).json({ success: true });
} catch (error) {
logger.log("job-totals-ssu-error", "error", req.user.email, id, {
logger.log("job-totals-ssu-error", "error", req?.user?.email, id, {
jobid: id,
error: error.message,
stack: error.stack
@@ -59,7 +59,7 @@ exports.totalsSsu = async function (req, res) {
};
//IMPORTANT*** These two functions MUST be mirrored.
async function TotalsServerSide(req, res) {
async function TotalsServerSide(req) {
const { job, client } = req.body;
await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user });
@@ -78,7 +78,7 @@ async function TotalsServerSide(req, res) {
error: error.message,
stack: error.stack
});
res.status(400).send(JSON.stringify(error));
throw error;
}
}
@@ -91,7 +91,7 @@ async function Totals(req, res) {
const logger = req.logger;
const client = req.userGraphQLClient;
logger.log("job-totals-ssu", "debug", req.user.email, job.id, {
logger.log("job-totals-ssu", "debug", req?.user?.email, job.id, {
jobid: job.id,
id: id
});
@@ -108,12 +108,15 @@ async function Totals(req, res) {
res.status(200).json(ret);
} catch (error) {
logger.log("job-totals-ssu-error", "error", req.user.email, job.id, {
logger.log("job-totals-ssu-error", "error", req?.user?.email, job.id, {
jobid: job.id,
error: error.message,
stack: error.stack
});
res.status(400).send(JSON.stringify(error));
res.status(503).json({
error: "Failed to calculate totals",
message: error?.message || "Unknown error"
});
}
}
@@ -626,6 +629,7 @@ function CalculateTaxesTotals(job, otherTotals) {
(val.part_type && val.part_type.startsWith("PAG") && BackupGlassTax && BackupGlassTax.prt_tax_rt) ||
(!val.part_type &&
val.db_ref === "900510" &&
job.parts_tax_rates &&
job.parts_tax_rates["PAN"] &&
job.parts_tax_rates["PAN"].prt_tax_rt) ||
0) * 100

View File

@@ -4,6 +4,7 @@ const queries = require("../graphql-client/queries");
const client = require("../graphql-client/graphql-client").client;
const { pick, isNil } = require("lodash");
const { getClient } = require("../../libs/awsUtils");
const { JOB_DOCUMENT_FIELDS, getGlobalSearchQueryStringFields } = require("./os-search-config");
async function OpenSearchUpdateHandler(req, res) {
try {
@@ -21,27 +22,7 @@ async function OpenSearchUpdateHandler(req, res) {
switch (req.body.table.name) {
case "jobs":
document = pick(req.body.event.data.new, [
"id",
"bodyshopid",
"clm_no",
"clm_total",
"comment",
"ins_co_nm",
"owner_owing",
"ownr_co_nm",
"ownr_fn",
"ownr_ln",
"ownr_ph1",
"ownr_ph2",
"plate_no",
"ro_number",
"status",
"v_model_yr",
"v_make_desc",
"v_model_desc",
"v_vin"
]);
document = pick(req.body.event.data.new, JOB_DOCUMENT_FIELDS);
document.bodyshopid = req.body.event.data.new.shopid;
break;
case "vehicles":
@@ -197,15 +178,18 @@ async function OpenSearchSearchHandler(req, res) {
user: req.user.email
});
if (assocs.length === 0) {
if (assocs.associations.length === 0) {
res.sendStatus(401);
return;
}
const osClient = await getClient();
const activeAssociation = assocs.associations[0];
const bodyShopIdMatchOverride = isNil(process.env.BODY_SHOP_ID_MATCH_OVERRIDE)
? assocs.associations[0].shopid
? activeAssociation.shopid
: process.env.BODY_SHOP_ID_MATCH_OVERRIDE;
const isReynoldsEnabled = Boolean(activeAssociation.bodyshop?.rr_dealerid);
const { body } = await osClient.search({
...(index ? { index } : { index: ["jobs", "vehicles", "owners", "bills", "payments"] }),
@@ -241,21 +225,8 @@ async function OpenSearchSearchHandler(req, res) {
query: `*${search}*`,
// Weighted Fields
fields: [
"*ro_number^20",
"*clm_no^14",
"*v_vin^12",
"*plate_no^12",
"*ownr_ln^10",
"transactionid^10",
"paymentnum^10",
"invoice_number^10",
"*ownr_fn^8",
"*ownr_co_nm^8",
"*ownr_ph1^8",
"*ownr_ph2^8",
"*vendor.name^8",
"*comment^6"
// "*"
...getGlobalSearchQueryStringFields({ isReynoldsEnabled })
// "*"
]
}
}

View File

@@ -0,0 +1,69 @@
/**
* Fields to be included in the job document indexed in OpenSearch. These fields are used for both indexing and
* searching.
* @type {string[]}
*/
const JOB_DOCUMENT_FIELDS = [
"id",
"bodyshopid",
"clm_no",
"clm_total",
"comment",
"dms_id",
"ins_co_nm",
"owner_owing",
"ownr_co_nm",
"ownr_fn",
"ownr_ln",
"ownr_ph1",
"ownr_ph2",
"plate_no",
"ro_number",
"status",
"v_model_yr",
"v_make_desc",
"v_model_desc",
"v_vin"
];
/**
* Fields to be included in the global search query string. These fields are used for constructing the search query.
* @type {string[]}
*/
const BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS = [
"*ro_number^20",
"*clm_no^14",
"*v_vin^12",
"*plate_no^12",
"*ownr_ln^10",
"transactionid^10",
"paymentnum^10",
"invoice_number^10",
"*ownr_fn^8",
"*ownr_co_nm^8",
"*ownr_ph1^8",
"*ownr_ph2^8",
"*vendor.name^8",
"*comment^6"
];
/**
* Returns the fields to be included in the global search query string. If Reynolds is enabled, it includes the dms_id
* field with a higher boost.
* @param param0
* @param param0.isReynoldsEnabled
* @returns {string[]}
*/
const getGlobalSearchQueryStringFields = ({ isReynoldsEnabled = false } = {}) => {
if (!isReynoldsEnabled) {
return BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS;
}
return ["*dms_id^20", ...BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS];
};
module.exports = {
JOB_DOCUMENT_FIELDS,
BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS,
getGlobalSearchQueryStringFields
};

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { JOB_DOCUMENT_FIELDS, BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS, getGlobalSearchQueryStringFields } = require(
"../os-search-config"
);
describe("os-search-config", () => {
it("indexes dms_id on job documents", () => {
expect(JOB_DOCUMENT_FIELDS).toContain("dms_id");
});
it("includes dms_id in global search fields for Reynolds shops", () => {
expect(getGlobalSearchQueryStringFields({ isReynoldsEnabled: true })).toContain("*dms_id^20");
});
it("keeps the default search fields unchanged for non-Reynolds shops", () => {
expect(getGlobalSearchQueryStringFields()).toEqual(BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS);
});
});

View File

@@ -46,6 +46,11 @@ const summarizeAllocationsArray = (arr) =>
cost: summarizeMoney(a.cost)
}));
const toFiniteNumber = (value) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
};
/**
* Internal per-center bucket shape for *sales*.
* We keep separate buckets for RR so we can split
@@ -62,6 +67,8 @@ function emptyCenterBucket() {
// Labor
laborTaxableSale: zero, // labor that should be taxed in RR
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
laborTaxableHours: 0,
laborNonTaxableHours: 0,
// Extras (MAPA/MASH/towing/PAO/etc)
extrasSale: zero, // total extras (taxable + non-taxable)
@@ -453,6 +460,7 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`;
const rate = job[rateKey];
const lineHours = toFiniteNumber(val.mod_lb_hrs);
const laborAmount = Dinero({
amount: Math.round(rate * 100)
@@ -460,8 +468,10 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
if (isLaborTaxable(val, taxContext)) {
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
bucket.laborTaxableHours += lineHours;
} else {
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
bucket.laborNonTaxableHours += lineHours;
}
}
@@ -478,6 +488,8 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
laborTaxableHours: b.laborTaxableHours,
laborNonTaxableHours: b.laborNonTaxableHours,
extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
@@ -916,6 +928,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
// Labor
laborTaxableSale: bucket.laborTaxableSale,
laborNonTaxableSale: bucket.laborNonTaxableSale,
laborTaxableHours: bucket.laborTaxableHours,
laborNonTaxableHours: bucket.laborNonTaxableHours,
// Extras
extrasSale,

View File

@@ -0,0 +1,206 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const graphqlRequestModuleId = require.resolve("graphql-request");
const queriesModuleId = require.resolve("../graphql-client/queries");
const rrLoggerModuleId = require.resolve("./rr-logger-event");
const rrExportLogsModuleId = require.resolve("./rr-export-logs");
const loadExportLogs = ({ requests }) => {
mock.stopAll();
mock(graphqlRequestModuleId, {
GraphQLClient: class MockGraphQLClient {
constructor(endpoint) {
this.endpoint = endpoint;
this.headers = {};
}
setHeaders(headers) {
this.headers = headers;
return this;
}
async request(query, variables) {
requests.push({
endpoint: this.endpoint,
headers: this.headers,
query,
variables
});
return {};
}
}
});
mock(queriesModuleId, {
INSERT_EXPORT_LOG: "INSERT_EXPORT_LOG",
MARK_JOB_EXPORTED: "MARK_JOB_EXPORTED"
});
mock(rrLoggerModuleId, () => {});
delete require.cache[rrExportLogsModuleId];
return require(rrExportLogsModuleId);
};
const socket = {
data: { authToken: "socket-token" },
user: { email: "tech@example.com" }
};
const job = {
id: "job-1",
bodyshop: {
id: "bodyshop-1",
md_ro_statuses: {
default_exported: "Exported"
}
}
};
describe("server/rr/rr-export-logs", () => {
const originalEndpoint = process.env.GRAPHQL_ENDPOINT;
afterEach(() => {
mock.stopAll();
delete require.cache[rrExportLogsModuleId];
process.env.GRAPHQL_ENDPOINT = originalEndpoint;
});
it("marks Reynolds full exports as exported using the shared DMS export mutation", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job,
bodyshop: job.bodyshop,
result: {
success: true,
roStatus: {
status: "SUCCESS",
statusCode: "0",
message: "Finalized"
}
},
metaExtra: {
rrPreview: {
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
rogg: {
rows: [{ jobNo: "1", opCode: "BODY", custPrice: "125.00", dlrCost: "50.00" }]
}
}
}
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
endpoint: "https://graphql.example.test/v1/graphql",
headers: { Authorization: "Bearer socket-token" },
query: "MARK_JOB_EXPORTED",
variables: {
jobId: "job-1",
job: {
status: "Exported",
date_exported: expect.any(Date)
},
log: {
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com",
metadata: {
provider: "rr",
rrPreview: {
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
rogg: {
rows: [{ jobNo: "1", opCode: "BODY", custPrice: "125.00", dlrCost: "50.00" }]
}
}
}
},
bill: {
exported: true,
exported_at: expect.any(Date)
}
}
});
});
it("uses the separately loaded bodyshop statuses when job.bodyshop is missing", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job: { id: job.id },
bodyshop: job.bodyshop,
result: {
success: true,
roStatus: {
status: "SUCCESS",
statusCode: "0",
message: "Finalized"
}
}
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
query: "MARK_JOB_EXPORTED",
variables: {
jobId: "job-1",
job: {
status: "Exported",
date_exported: expect.any(Date)
},
log: {
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com"
}
}
});
});
it("does not mark Reynolds early RO creation as exported", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job,
bodyshop: job.bodyshop,
result: { success: true },
isEarlyRo: true
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
query: "INSERT_EXPORT_LOG",
variables: {
logs: [
{
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com"
}
]
}
});
});
});

View File

@@ -1,10 +1,11 @@
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
const { buildRRPreviewMetadata } = require("./rr-preview-metadata");
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
const { resolveRROpCodeFromBodyshop } = require("./rr-utils");
const { isEnhancedEarlyROEnabled, resolveRROpCodeFromBodyshop } = require("./rr-utils");
/**
* Derive RR status information from response object.
@@ -56,6 +57,27 @@ const deriveRRStatus = (rrRes = {}) => {
};
};
const resolveRROpCode = (bodyshop, txEnvelope = {}) => {
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
if (!opCodeOverride) {
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
if (opPrefix || opBase || opSuffix) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
}
if (!opCodeOverride && !resolvedBaseOpCode) return null;
return String(opCodeOverride || resolvedBaseOpCode).trim() || null;
};
/**
* Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story).
* Used when creating RO from convert button or admin page before full job export.
@@ -93,7 +115,9 @@ const createMinimalRRRepairOrder = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Build minimal RO payload - just header, no allocations/parts/labor
// Build minimal RO payload for early review mode.
// We keep it lightweight, but include a single labor row when we can so Ignite
// exposes the labor subsection for editing.
const cleanVin =
(job?.v_vin || "")
.toString()
@@ -116,6 +140,15 @@ const createMinimalRRRepairOrder = async (args) => {
resolvedMileageIn: mileageIn
});
const enhancedEarlyROEnabled = isEnhancedEarlyROEnabled(bodyshop);
const earlyRoOpCode = enhancedEarlyROEnabled ? resolveRROpCode(bodyshop, txEnvelope) : null;
const earlyRoLabor = enhancedEarlyROEnabled
? buildMinimalRolaborFromJob(job, {
opCode: earlyRoOpCode,
payType: "Cust"
})
: null;
const payload = {
customerNo: String(selected),
advisorNo: String(advisorNo),
@@ -141,14 +174,25 @@ const createMinimalRRRepairOrder = async (args) => {
if (makeOverride) {
payload.makeOverride = makeOverride;
}
if (earlyRoLabor) {
payload.rolabor = earlyRoLabor;
}
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
payload
payload,
enhancedEarlyROEnabled,
earlyRoOpCode,
hasRolabor: !!earlyRoLabor
});
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", withRRRequestXml(response, { payload, response }));
CreateRRLogEvent(
socket,
"INFO",
"RR minimal Repair Order created",
withRRRequestXml(response, { payload, response })
);
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -221,15 +265,10 @@ const updateRRRepairOrderWithFullData = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Optional RR OpCode segments coming from the FE (RRPostForm)
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging)
try {
@@ -280,28 +319,9 @@ const updateRRRepairOrderWithFullData = async (args) => {
allocations = [];
}
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
// If the FE only sends segments, combine them here.
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
if (opCodeOverride || resolvedBaseOpCode) {
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode,
baseFromConfig: resolvedBaseOpCode,
opPrefix,
opBase,
opSuffix
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
});
// Build full RO payload for update with allocations
@@ -321,16 +341,12 @@ const updateRRRepairOrderWithFullData = async (args) => {
payload.roNo = String(roNo);
payload.outsdRoNo = job?.ro_number || job?.id || undefined;
// RR update rejects placeholder non-labor ROLABOR rows with zero labor prices.
// Keep only the actual labor jobs in ROLABOR and let ROGOG carry parts/extras.
// RR update needs a ROLABOR row for every ROGOG JobNo, but rejects zero-price placeholders.
// buildRolaborFromRogog mirrors the GOG price into each row, so keep the full 1:1 set.
if (payload.rolabor?.ops?.length && payload.rogg?.ops?.length) {
const laborJobNos = new Set(
payload.rogg.ops
.filter((op) => op?.segmentKind === "laborTaxable" || op?.segmentKind === "laborNonTaxable")
.map((op) => String(op.jobNo))
);
const roggJobNos = new Set(payload.rogg.ops.map((op) => String(op.jobNo)));
payload.rolabor.ops = payload.rolabor.ops.filter((op) => laborJobNos.has(String(op?.jobNo)));
payload.rolabor.ops = payload.rolabor.ops.filter((op) => roggJobNos.has(String(op?.jobNo)));
if (!payload.rolabor.ops.length) {
delete payload.rolabor;
@@ -377,7 +393,7 @@ const updateRRRepairOrderWithFullData = async (args) => {
success = String(roStatus.status).toUpperCase() === "SUCCESS";
}
return {
const exportResult = {
success,
data,
roStatus,
@@ -387,6 +403,8 @@ const updateRRRepairOrderWithFullData = async (args) => {
roNo: String(roNo),
xml: response?.xml
};
exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult });
return exportResult;
};
/**
@@ -426,15 +444,10 @@ const exportJobToRR = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Optional RR OpCode segments coming from the FE (RRPostForm)
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging)
try {
@@ -477,28 +490,9 @@ const exportJobToRR = async (args) => {
allocations = [];
}
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
// If the FE only sends segments, combine them here.
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
if (opCodeOverride || resolvedBaseOpCode) {
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode,
baseFromConfig: resolvedBaseOpCode,
opPrefix,
opBase,
opSuffix
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
});
// Build RO payload for create.
@@ -542,7 +536,7 @@ const exportJobToRR = async (args) => {
// Extract canonical roNo you'll need for finalize step
const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
return {
const exportResult = {
success,
data,
roStatus,
@@ -552,6 +546,8 @@ const exportJobToRR = async (args) => {
roNo,
xml: response?.xml // expose XML for logging/diagnostics
};
exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult });
return exportResult;
};
/**

View File

@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const helpersModuleId = require.resolve("./rr-job-helpers");
const lookupModuleId = require.resolve("./rr-lookup");
const loggerEventModuleId = require.resolve("./rr-logger-event");
const logXmlModuleId = require.resolve("./rr-log-xml");
const responsibilityCentersModuleId = require.resolve("./rr-responsibility-centers");
const allocationsModuleId = require.resolve("./rr-calculate-allocations");
const jobExportModuleId = require.resolve("./rr-job-export");
const makeBodyshop = (mdFunctionalityToggles) => ({
rr_configuration: {
defaults: {
prefix: "51",
base: "DOZ",
suffix: ""
}
},
...(mdFunctionalityToggles ? { md_functionality_toggles: mdFunctionalityToggles } : {})
});
const makeJob = () => ({
id: "job-1",
ro_number: "RO-123",
v_vin: "1HGBH41JXMN109186",
joblines: [{ mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 }]
});
const loadJobExport = ({
buildMinimalRolaborFromJob = vi.fn(() => ({ ops: [{ opCode: "51DOZ" }] })),
createRepairOrder = vi.fn(async () => ({ success: true, data: { dmsRoNo: "12345" } }))
} = {}) => {
mock.stopAll();
mock(helpersModuleId, {
buildRRRepairOrderPayload: vi.fn(),
buildMinimalRolaborFromJob
});
mock(lookupModuleId, {
buildClientAndOpts: () => ({
client: { createRepairOrder },
opts: { envelope: { sender: {} } }
})
});
mock(loggerEventModuleId, vi.fn());
mock(logXmlModuleId, {
withRRRequestXml: (response, payload) => payload
});
mock(responsibilityCentersModuleId, {
extractRrResponsibilityCenters: vi.fn(() => [])
});
mock(allocationsModuleId, {
default: vi.fn()
});
delete require.cache[jobExportModuleId];
return {
...require(jobExportModuleId),
buildMinimalRolaborFromJob,
createRepairOrder
};
};
afterEach(() => {
mock.stopAll();
delete require.cache[jobExportModuleId];
});
describe("server/rr/rr-job-export", () => {
it("sends early RO labor totals by default", async () => {
const { createMinimalRRRepairOrder, createRepairOrder, buildMinimalRolaborFromJob } = loadJobExport();
await createMinimalRRRepairOrder({
bodyshop: makeBodyshop(),
job: makeJob(),
advisorNo: "70754",
selectedCustomer: { custNo: "1134485" },
txEnvelope: {}
});
expect(buildMinimalRolaborFromJob).toHaveBeenCalledWith(makeJob(), {
opCode: "51DOZ",
payType: "Cust"
});
expect(createRepairOrder.mock.calls[0][0].rolabor).toEqual({ ops: [{ opCode: "51DOZ" }] });
});
it("omits early RO labor totals when the shop opts out", async () => {
const { createMinimalRRRepairOrder, createRepairOrder, buildMinimalRolaborFromJob } = loadJobExport();
await createMinimalRRRepairOrder({
bodyshop: makeBodyshop({ enhanced_early_ros: false }),
job: makeJob(),
advisorNo: "70754",
selectedCustomer: { custNo: "1134485" },
txEnvelope: {}
});
expect(buildMinimalRolaborFromJob).not.toHaveBeenCalled();
expect(createRepairOrder.mock.calls[0][0].rolabor).toBeUndefined();
});
});

View File

@@ -52,6 +52,19 @@ const asN2 = (dineroLike) => {
return amount.toFixed(2);
};
const toFiniteNumber = (value) => {
if (typeof value === "number") {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
/**
* Normalize various "money-like" shapes to integer cents.
* Supports:
@@ -100,6 +113,116 @@ const toMoneyCents = (value) => {
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 });
const formatDecimal = (value, maxDecimals = 2) => {
const factor = Math.pow(10, maxDecimals);
const rounded = Math.round(Math.max(0, toFiniteNumber(value)) * factor) / factor;
if (!Number.isFinite(rounded)) return "0";
return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0";
};
const isLaborSideProfitCenter = (alloc = {}) => {
const pc = alloc?.profitCenter || {};
if (
pc.rr_requires_rolabor ||
pc.rr_force_rolabor ||
pc.rr_labor_side ||
pc.rr_is_labor ||
pc.is_labor
) {
return true;
}
return [alloc.center, pc.name, pc.accountdesc, pc.accountname].some((value) => /\blabou?r\b/i.test(String(value || "")));
};
const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => {
const normalizedAmount = toFiniteNumber(amountUnits);
if (normalizedAmount <= 0) {
return {
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
};
}
let resolvedHours = toFiniteNumber(hours);
let resolvedRate = toFiniteNumber(rate);
if (resolvedHours > 0 && resolvedRate <= 0) {
resolvedRate = normalizedAmount / resolvedHours;
} else if (resolvedRate > 0 && resolvedHours <= 0) {
resolvedHours = normalizedAmount / resolvedRate;
} else if (resolvedHours <= 0 && resolvedRate <= 0) {
// Keep the math internally consistent even if the source job has dollars but no usable hours.
resolvedHours = 1;
resolvedRate = normalizedAmount;
}
return {
jobTotalHrs: formatDecimal(resolvedHours),
billTime: formatDecimal(resolvedHours),
billRate: resolvedRate.toFixed(2)
};
};
const buildMinimalRolaborFromJob = (job, { opCode, payType = "Cust" } = {}) => {
const trimmedOpCode = opCode != null ? String(opCode).trim() : "";
if (!job || !trimmedOpCode) return null;
let totalHours = 0;
let totalAmountUnits = 0;
for (const line of job?.joblines || []) {
const laborType = typeof line?.mod_lbr_ty === "string" ? line.mod_lbr_ty.trim() : "";
if (!laborType) continue;
const lineHours = toFiniteNumber(line?.mod_lb_hrs ?? line?.db_hrs);
const configuredRate = toFiniteNumber(job?.[`rate_${laborType.toLowerCase()}`]);
let lineAmountUnits = toFiniteNumber(line?.lbr_amt);
if (lineAmountUnits <= 0 && lineHours > 0 && configuredRate > 0) {
lineAmountUnits = lineHours * configuredRate;
}
if (lineAmountUnits <= 0 && lineHours <= 0) continue;
totalHours += lineHours;
totalAmountUnits += lineAmountUnits;
}
if (totalAmountUnits <= 0 && totalHours <= 0) return null;
const bill = buildRolaborBillFields({
amountUnits: totalAmountUnits,
hours: totalHours,
rate: totalHours > 0 ? totalAmountUnits / totalHours : 0
});
const formattedAmount = totalAmountUnits.toFixed(2);
return {
ops: [
{
opCode: trimmedOpCode,
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: toFiniteNumber(job?.tax_lbr_rt) > 0 ? "T" : "N",
bill: {
payType,
...bill
},
amount: {
payType,
amtType: "Job",
custPrice: formattedAmount,
totalAmt: formattedAmount
}
}
]
};
};
/**
* Build RR estimate block from allocation totals.
* @param {Array} allocations
@@ -228,6 +351,7 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
const pc = alloc?.profitCenter || {};
const breakOut = pc.rr_gogcode;
const itemType = pc.rr_item_type;
const laborSideProfitCenter = isLaborSideProfitCenter(alloc);
// Only centers configured for RR GOG are included
if (!breakOut || !itemType) continue;
@@ -326,6 +450,15 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Each segment becomes its own op / JobNo with a single line
segments.forEach((seg, idx) => {
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable";
const isPartsSegment = seg.kind === "partsTaxable" || seg.kind === "partsNonTaxable";
const rolaborRequired = isLaborSegment || (isPartsSegment && laborSideProfitCenter);
const segmentHours = isLaborSegment
? seg.kind === "laborTaxable"
? toFiniteNumber(alloc.laborTaxableHours)
: toFiniteNumber(alloc.laborNonTaxableHours)
: 0;
const segmentBillRate = isLaborSegment && segmentHours > 0 ? seg.saleCents / 100 / segmentHours : 0;
const line = {
breakOut,
@@ -349,7 +482,10 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Extra metadata for UI / debugging
segmentKind: seg.kind,
segmentIndex: idx,
segmentCount
segmentCount,
segmentHours,
segmentBillRate,
rolaborRequired
});
});
}
@@ -368,9 +504,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
*
* We still keep a 1:1 mapping with GOG ops: each op gets a corresponding
* OpCodeLaborInfo entry using the same JobNo and the same tax flag as its
* GOG line. Labor-specific hours/rate remain zeroed out, but actual labor
* sale amounts are mirrored into ROLABOR for labor segments so RR receives
* the expected labor pricing on updates. Non-labor ops remain zeroed.
* GOG line. Sale amounts are mirrored into ROLABOR so Reynolds has a
* non-zero job anchor for every ROGOG JobNo; when labor hours are available
* from allocations, weighted bill hours/rates are also populated.
*
* @param {Object} rogg - result of buildRogogFromAllocations
* @param {Object} opts
@@ -390,7 +526,18 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
const linePayType = firstLine.custPayTypeFlag || "C";
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0";
const laborAmount = String(firstLine?.amount?.custPrice ?? "0");
const laborBill = isLaborSegment
? buildRolaborBillFields({
amountUnits: laborAmount,
hours: op.segmentHours,
rate: op.segmentBillRate
})
: {
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
};
return {
opCode: op.opCode,
@@ -399,9 +546,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
custTxblNtxblFlag: txFlag,
bill: {
payType,
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
...laborBill
},
amount: {
payType,
@@ -686,5 +831,6 @@ module.exports = {
normalizeCustomerCandidates,
normalizeVehicleCandidates,
buildRogogFromAllocations,
buildRolaborFromRogog
buildRolaborFromRogog,
buildMinimalRolaborFromJob
};

View File

@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const graphClientModuleId = require.resolve("../graphql-client/graphql-client");
const queriesModuleId = require.resolve("../graphql-client/queries");
const helpersModuleId = require.resolve("./rr-job-helpers");
const loadHelpers = () => {
mock.stopAll();
mock(graphClientModuleId, { client: { request: async () => ({}) } });
mock(queriesModuleId, { GET_JOB_BY_PK: "GET_JOB_BY_PK" });
delete require.cache[helpersModuleId];
return require(helpersModuleId);
};
afterEach(() => {
mock.stopAll();
delete require.cache[helpersModuleId];
});
describe("server/rr/rr-job-helpers", () => {
it("builds a single early-RO labor row from aggregated job labor", () => {
const { buildMinimalRolaborFromJob } = loadHelpers();
const rolabor = buildMinimalRolaborFromJob(
{
tax_lbr_rt: 13,
joblines: [
{ mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 },
{ mod_lbr_ty: "LAD", mod_lb_hrs: 1.5, lbr_amt: 180 }
]
},
{ opCode: "51DOZ" }
);
expect(rolabor).toEqual({
ops: [
{
opCode: "51DOZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "3.5",
billTime: "3.5",
billRate: "108.57"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "380.00",
totalAmt: "380.00"
}
}
]
});
});
it("populates labor bill fields from allocation hours on the full RR payload", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-1",
ro_number: "RO-123",
v_vin: "1HGBH41JXMN109186"
},
selectedCustomer: { customerNo: "1134485" },
advisorNo: "70754",
allocations: [
{
center: "Body Labor",
partsSale: { amount: 0, precision: 2 },
laborTaxableSale: { amount: 24000, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
totalSale: { amount: 24000, precision: 2 },
cost: { amount: 12000, precision: 2 },
laborTaxableHours: 2,
laborNonTaxableHours: 0,
profitCenter: {
rr_gogcode: "BL",
rr_item_type: "G",
accountdesc: "BODY LABOR"
}
}
],
opCode: "51DOZ"
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "51DOZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "2",
billTime: "2",
billRate: "120.00"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "240.00",
totalAmt: "240.00"
}
}
]
});
});
it("mirrors parts assigned to a labor-side RR profit center into ROLABOR", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-2",
ro_number: "RO-456",
v_vin: "3GCUKHEL3TG292014"
},
selectedCustomer: { customerNo: "411588" },
advisorNo: "70754",
allocations: [
{
center: "Customer Pay CV Labor",
partsSale: { amount: 15000, precision: 2 },
partsTaxableSale: { amount: 0, precision: 2 },
partsNonTaxableSale: { amount: 15000, precision: 2 },
laborTaxableSale: { amount: 0, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
extrasTaxableSale: { amount: 0, precision: 2 },
extrasNonTaxableSale: { amount: 0, precision: 2 },
totalSale: { amount: 15000, precision: 2 },
cost: { amount: 0, precision: 2 },
profitCenter: {
rr_gogcode: "VL",
rr_item_type: "P",
accountdesc: "Customer Pay CV Labor"
}
}
],
opCode: "30CVZBDY"
});
expect(payload.rogg.ops[0]).toMatchObject({
opCode: "30CVZBDY",
jobNo: "1",
segmentKind: "partsNonTaxable",
rolaborRequired: true,
lines: [
{
breakOut: "VL",
itemType: "P",
itemDesc: "Customer Pay CV Labor",
custTxblNtxblFlag: "N",
amount: {
custPrice: "150.00",
dlrCost: "0.00"
}
}
]
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "30CVZBDY",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "N",
bill: {
payType: "Cust",
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "150.00",
totalAmt: "150.00"
}
}
]
});
});
it("mirrors regular ROGOG parts into ROLABOR so Reynolds can find the JobNo", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-3",
ro_number: "CDK10131",
v_vin: "3TMLU4EN1AM044343"
},
selectedCustomer: { customerNo: "69158" },
advisorNo: "6224",
allocations: [
{
center: "B/S PARTS",
partsSale: { amount: 15000, precision: 2 },
partsTaxableSale: { amount: 15000, precision: 2 },
partsNonTaxableSale: { amount: 0, precision: 2 },
laborTaxableSale: { amount: 0, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
extrasTaxableSale: { amount: 0, precision: 2 },
extrasNonTaxableSale: { amount: 0, precision: 2 },
totalSale: { amount: 15000, precision: 2 },
cost: { amount: 0, precision: 2 },
profitCenter: {
rr_gogcode: "FR",
rr_item_type: "G",
accountdesc: "B/S PARTS"
}
}
],
opCode: "60GMZ"
});
expect(payload.rogg.ops[0]).toMatchObject({
opCode: "60GMZ",
jobNo: "1",
segmentKind: "partsTaxable",
rolaborRequired: false
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "60GMZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "150.00",
totalAmt: "150.00"
}
}
]
});
});
});

View File

@@ -0,0 +1,85 @@
const segmentLabelMap = {
partsTaxable: "Parts Taxable",
partsNonTaxable: "Parts Non-Taxable",
extrasTaxable: "Extras Taxable",
extrasNonTaxable: "Extras Non-Taxable",
laborTaxable: "Labor Taxable",
laborNonTaxable: "Labor Non-Taxable"
};
const toCentsFromAmountString = (value) => {
const parsed = Number.parseFloat(value || "0");
return Number.isNaN(parsed) ? 0 : Math.round(parsed * 100);
};
const buildRoggRows = (rogg) => {
if (!rogg || !Array.isArray(rogg.ops)) return [];
const rows = [];
rogg.ops.forEach((op) => {
(op.lines || []).forEach((line, idx) => {
const segmentKind = op.segmentKind;
const segmentCount = op.segmentCount || 0;
const segmentLabel = segmentLabelMap[segmentKind] || segmentKind;
const itemDesc =
segmentCount > 1 && segmentLabel ? `${line.itemDesc} (${segmentLabel})` : line.itemDesc;
rows.push({
key: `${op.jobNo}-${idx}`,
opCode: op.opCode,
jobNo: op.jobNo,
breakOut: line.breakOut,
itemType: line.itemType,
itemDesc,
custQty: line.custQty,
custPayTypeFlag: line.custPayTypeFlag,
custTxblNtxblFlag: line.custTxblNtxblFlag,
custPrice: line.amount?.custPrice,
dlrCost: line.amount?.dlrCost,
segmentKind,
segmentCount
});
});
});
return rows;
};
const buildRoggTotals = (roggRows) => {
const totals = roggRows.reduce(
(acc, row) => {
acc.totalCustPriceCents += toCentsFromAmountString(row.custPrice);
acc.totalDlrCostCents += toCentsFromAmountString(row.dlrCost);
return acc;
},
{ totalCustPriceCents: 0, totalDlrCostCents: 0 }
);
return {
...totals,
totalCustPrice: (totals.totalCustPriceCents / 100).toFixed(2),
totalDlrCost: (totals.totalDlrCostCents / 100).toFixed(2)
};
};
const buildRRPreviewMetadata = ({ payload, result } = {}) => {
const rogg = payload?.rogg || null;
const roggRows = buildRoggRows(rogg);
return {
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
roNo: result?.roNo || payload?.roNo || null,
outsdRoNo: payload?.outsdRoNo || null,
rogg: {
raw: rogg,
rows: roggRows,
totals: buildRoggTotals(roggRows)
}
};
};
module.exports = {
buildRRPreviewMetadata
};

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { buildRRPreviewMetadata } = require("./rr-preview-metadata");
describe("server/rr/rr-preview-metadata", () => {
it("captures ROGOG preview rows and totals", () => {
const metadata = buildRRPreviewMetadata({
payload: {
outsdRoNo: "RO-100",
rogg: {
ops: [
{
opCode: "BODY",
jobNo: "1",
segmentKind: "laborTaxable",
segmentCount: 2,
lines: [
{
breakOut: "B",
itemType: "LAB",
itemDesc: "Body Labor",
custQty: "1.0",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
amount: {
custPrice: "125.00",
dlrCost: "50.00"
}
}
]
}
]
}
},
result: { roNo: "12345" }
});
expect(metadata).toMatchObject({
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
roNo: "12345",
outsdRoNo: "RO-100",
rogg: {
rows: [
{
opCode: "BODY",
jobNo: "1",
breakOut: "B",
itemType: "LAB",
itemDesc: "Body Labor (Labor Taxable)",
custTxblNtxblFlag: "T",
custPrice: "125.00",
dlrCost: "50.00"
}
],
totals: {
totalCustPriceCents: 12500,
totalDlrCostCents: 5000,
totalCustPrice: "125.00",
totalDlrCost: "50.00"
}
}
});
expect(metadata).not.toHaveProperty("rolabor");
});
});

View File

@@ -1497,7 +1497,8 @@ const registerRREvents = ({ socket, redisHelpers }) => {
dmsRoNo,
customerNo: String(effectiveCustNo),
advisorNo: String(advisorNo),
vin: job?.v_vin || null
vin: job?.v_vin || null,
rrPreview: result?.rrPreview || null
},
defaultRRTTL
);
@@ -1705,7 +1706,10 @@ const registerRREvents = ({ socket, redisHelpers }) => {
jobId: rid,
job,
bodyshop,
result: finalizeResult
result: finalizeResult,
metaExtra: {
rrPreview: pending?.rrPreview || null
}
});
// Clean pending key

View File

@@ -205,10 +205,19 @@ const resolveRROpCodeFromBodyshop = (bodyshop) => {
return `${prefix}${base}${suffix}`;
};
/**
* Enhanced Early RO labor totals are enabled by default for backwards compatibility.
* Shops can explicitly set md_functionality_toggles.enhanced_early_ros to false to opt out.
* @param bodyshop
* @returns {boolean}
*/
const isEnhancedEarlyROEnabled = (bodyshop) => bodyshop?.md_functionality_toggles?.enhanced_early_ros !== false;
module.exports = {
RRCacheEnums,
defaultRRTTL,
getTransactionType,
isEnhancedEarlyROEnabled,
ownersFromVinBlocks,
makeVehicleSearchPayloadFromJob,
normalizeCustomerCandidates,

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { isEnhancedEarlyROEnabled } = require("./rr-utils");
describe("server/rr/rr-utils", () => {
it("keeps enhanced early ROs enabled when the shop setting is missing", () => {
expect(isEnhancedEarlyROEnabled()).toBe(true);
expect(isEnhancedEarlyROEnabled({})).toBe(true);
expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: {} })).toBe(true);
});
it("only disables enhanced early ROs when the shop explicitly opts out", () => {
expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: { enhanced_early_ros: false } })).toBe(false);
expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: { enhanced_early_ros: true } })).toBe(true);
});
});