diff --git a/client/src/components/dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx b/client/src/components/dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx index bc0c209ad..1c9d706d2 100644 --- a/client/src/components/dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx +++ b/client/src/components/dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx @@ -142,17 +142,37 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card title={t("job_lifecycle.content.legend_title")} style={{ marginTop: "10px" }} > -
+
{lifecycleData.summations.map((key) => ( - +
{key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage}) diff --git a/client/src/components/job-lifecycle/job-lifecycle.component.jsx b/client/src/components/job-lifecycle/job-lifecycle.component.jsx index e5b99a9f2..5b0e1c082 100644 --- a/client/src/components/job-lifecycle/job-lifecycle.component.jsx +++ b/client/src/components/job-lifecycle/job-lifecycle.component.jsx @@ -222,17 +222,37 @@ export function JobLifecycleComponent({ bodyshop, job, statuses }) {
-
+
{lifecycleData.durations.summations.map((key) => ( - +
{key.status} ( diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx index b9f8266ff..6590498aa 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx @@ -35,16 +35,14 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier, ...galleryImages.other.filter((image) => image.isSelected) ]; - function downloadProgress(progressEvent) { - setDownload((currentDownloadState) => { - return { - downloaded: progressEvent.loaded || 0, - speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0) - }; - }); - } + const downloadProgress = ({ loaded }) => { + setDownload((currentDownloadState) => ({ + downloaded: loaded ?? 0, + speed: (loaded ?? 0) - (currentDownloadState?.downloaded ?? 0) + })); + }; - function standardMediaDownload(bufferData) { + const standardMediaDownload = (bufferData) => { try { const a = document.createElement("a"); const url = window.URL.createObjectURL(new Blob([bufferData])); @@ -55,29 +53,26 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier, setLoading(false); setDownload(null); } - } + }; const handleDownload = async () => { logImEXEvent("jobs_documents_download"); setLoading(true); try { - const response = await axios({ + const { data } = await axios({ url: "/media/imgproxy/download", method: "POST", responseType: "blob", data: { jobId, documentids: imagesToDownload.map((_) => _.id) }, onDownloadProgress: downloadProgress }); - - setLoading(false); - setDownload(null); - // Use the response data (Blob) to trigger download - standardMediaDownload(response.data); + standardMediaDownload(data); } catch { + // handle error (optional) + } finally { setLoading(false); setDownload(null); - // handle error (optional) } }; diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx index 803505436..1c14eb486 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx @@ -76,14 +76,14 @@ function JobsDocumentsImgproxyComponent({ + {!billId && ( + + )} - {!billId && ( - - )} {!hasMediaAccess && ( diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.delete.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.delete.component.jsx index 4701bca67..c11059b35 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.delete.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.delete.component.jsx @@ -67,7 +67,7 @@ export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, delet okButtonProps={{ danger: true }} cancelText={t("general.actions.cancel")} > - diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx index d3176a19a..f5570257f 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx @@ -107,8 +107,8 @@ export function JobsDocumentsLocalGallery({ - + diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.delete.component.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.delete.component.jsx index c194cd57f..3c1c4df38 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.delete.component.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.delete.component.jsx @@ -28,6 +28,8 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia const [loading, setLoading] = useState(false); + const imagesToDelete = (allMedia?.[jobid] || []).filter((i) => i.isSelected); + const handleDelete = async () => { logImEXEvent("job_documents_delete"); setLoading(true); @@ -36,7 +38,7 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia `${bodyshop.localmediaserverhttp}/jobs/delete`, { jobid: jobid, - files: (allMedia?.[jobid] || []).filter((i) => i.isSelected).map((i) => i.filename) + files: imagesToDelete.map((i) => i.filename) }, { headers: { ims_token: bodyshop.localmediatoken } } ); @@ -60,14 +62,17 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia return ( } onConfirm={handleDelete} title={t("documents.labels.confirmdelete")} okText={t("general.actions.delete")} - okButtonProps={{ type: "danger" }} + okButtonProps={{ danger: true }} cancelText={t("general.actions.cancel")} > - + ); } diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.download.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.download.jsx index c5ed07019..d1c05988a 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.download.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.download.jsx @@ -1,8 +1,8 @@ -import { Button } from "antd"; +import { Button, Space } from "antd"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import cleanAxios from "../../utils/CleanAxios"; - +import formatBytes from "../../utils/formatbytes"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectAllMedia } from "../../redux/media/media.selectors"; @@ -19,45 +19,63 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobsLocalGalleryDown export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) { const { t } = useTranslation(); - const [download, setDownload] = useState(null); + const [loading, setLoading] = useState(false); + const [download, setDownload] = useState(false); - function downloadProgress(progressEvent) { - setDownload((currentDownloadState) => { - return { - downloaded: progressEvent.loaded || 0, - speed: (progressEvent.loaded || 0) - (currentDownloadState?.downloaded || 0) - }; - }); - } + const imagesToDownload = (allMedia?.[job.id] || []).filter((i) => i.isSelected); + + const downloadProgress = ({ loaded }) => { + setDownload((currentDownloadState) => ({ + downloaded: loaded || 0, + speed: (loaded || 0) - (currentDownloadState?.downloaded || 0) + })); + }; + + const standardMediaDownload = (bufferData, filename) => { + try { + const a = document.createElement("a"); + const url = window.URL.createObjectURL(new Blob([bufferData])); + a.href = url; + a.download = `${filename}.zip`; + a.click(); + } catch { + setLoading(false); + setDownload(null); + } + }; const handleDownload = async () => { - const theDownloadedZip = await cleanAxios.post( - `${bodyshop.localmediaserverhttp}/jobs/download`, - { - jobid: job.id, - files: (allMedia?.[job.id] || []).filter((i) => i.isSelected).map((i) => i.filename) - }, - { - headers: { ims_token: bodyshop.localmediatoken }, - responseType: "arraybuffer", - onDownloadProgress: downloadProgress - } - ); - setDownload(null); - standardMediaDownload(theDownloadedZip.data, job.ro_number); + const { localmediaserverhttp, localmediatoken } = bodyshop; + const { id, ro_number } = job; + setLoading(true); + try { + const response = await cleanAxios.post( + `${localmediaserverhttp}/jobs/download`, + { + jobid: id, + files: imagesToDownload.map((i) => i.filename) + }, + { + headers: { ims_token: localmediatoken }, + responseType: "arraybuffer", + onDownloadProgress: downloadProgress + } + ); + standardMediaDownload(response.data, ro_number); + } catch { + // handle error (optional) + } finally { + setLoading(false); + setDownload(null); + } }; return ( - ); } - -function standardMediaDownload(bufferData, filename) { - const a = document.createElement("a"); - const url = window.URL.createObjectURL(new Blob([bufferData])); - a.href = url; - a.download = `${filename}.zip`; - a.click(); -} diff --git a/client/src/components/tech-header/tech-header.component.jsx b/client/src/components/tech-header/tech-header.component.jsx index 72d085c12..e6c0d2f05 100644 --- a/client/src/components/tech-header/tech-header.component.jsx +++ b/client/src/components/tech-header/tech-header.component.jsx @@ -16,7 +16,7 @@ const mapDispatchToProps = () => ({ export function TechHeader({ technician }) { const { t } = useTranslation(); return ( -
+
{technician ? t("tech.labels.loggedin", { diff --git a/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx b/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx index ed48ecff5..5ef1b46c5 100644 --- a/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx +++ b/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx @@ -1,5 +1,5 @@ import { useMutation, useQuery } from "@apollo/client"; -import { Button, Card, Col, Form, InputNumber, Popover, Row, Select } from "antd"; +import { Button, Card, Form, InputNumber, Popover, Select, Space } from "antd"; import axios from "axios"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -124,103 +124,12 @@ export function TechClockOffButton({ cost_center: isShiftTicket ? "timetickets.labels.shift" : technician ? technician.cost_center : null }} > - - - {!isShiftTicket ? ( -
- - - - ({ - validator(rule, value) { - if (!bodyshop.tt_enforce_hours_for_tech_console) { - return Promise.resolve(); - } - if (!value || getFieldValue("cost_center") === null || !lineTicketData) - return Promise.resolve(); - - //Check the cost center, - const totals = CalculateAllocationsTotals( - bodyshop, - lineTicketData.joblines, - lineTicketData.timetickets, - lineTicketData.jobs_by_pk.lbr_adjustments - ); - - const fieldTypeToCheck = - bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center"; - - const costCenterDiff = - Math.round( - totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center")) - ?.difference * 10 - ) / 10; - - if (value > costCenterDiff) - return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable")); - else { - return Promise.resolve(); - } - } - }) - ]} - > - - -
- ) : null} - - - - - {isShiftTicket ? ( -
- ) : ( + + {!isShiftTicket ? ( +
- + - )} - - - - {!isShiftTicket && ( - - - + ({ + validator(rule, value) { + if (!bodyshop.tt_enforce_hours_for_tech_console) { + return Promise.resolve(); + } + if (!value || getFieldValue("cost_center") === null || !lineTicketData) + return Promise.resolve(); + //Check the cost center, + const totals = CalculateAllocationsTotals( + bodyshop, + lineTicketData.joblines, + lineTicketData.timetickets, + lineTicketData.jobs_by_pk.lbr_adjustments + ); + const fieldTypeToCheck = + bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center"; + const costCenterDiff = + Math.round( + totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center")) + ?.difference * 10 + ) / 10; + if (value > costCenterDiff) + return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable")); + else { + return Promise.resolve(); + } + } + }) + ]} + > + + +
+ ) : null} + + + + {isShiftTicket ? ( +
+ ) : ( + + + )} -
+ + + {!isShiftTicket && ( + + )} +
); return ( - + {overlay}
} + trigger="click" + getPopupContainer={() => document.querySelector('#time-ticket-modal')} + > diff --git a/client/src/redux/user/user.sagas.js b/client/src/redux/user/user.sagas.js index c508bf96e..12533865c 100644 --- a/client/src/redux/user/user.sagas.js +++ b/client/src/redux/user/user.sagas.js @@ -50,7 +50,7 @@ import { } from "./user.actions"; import UserActionTypes from "./user.types"; //import * as amplitude from '@amplitude/analytics-browser'; -import posthog from 'posthog-js'; +import posthog from "posthog-js"; const fpPromise = FingerprintJS.load(); @@ -269,11 +269,11 @@ export function* signInSuccessSaga({ payload }) { instanceSeg, ...(isParts ? [ - InstanceRenderManager({ - imex: "ImexPartsManagement", - rome: "RomePartsManagement" - }) - ] + InstanceRenderManager({ + imex: "ImexPartsManagement", + rome: "RomePartsManagement" + }) + ] : []) ]; window.$crisp.push(["set", "session:segments", [segs]]); @@ -375,17 +375,31 @@ export function* SetAuthLevelFromShopDetails({ payload }) { const isParts = yield select((state) => state.application.isPartsEntry === true); const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" }); - let featureSegments; - if (payload.features?.allAccess === true) { - featureSegments = ["allAccess"]; - } else { - const featureKeys = Object.keys(payload.features).filter( - (key) => - payload.features[key] === true || - (typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key]))) - ); - featureSegments = ["basic", ...featureKeys]; - } + const featureSegments = + payload.features?.allAccess === true + ? ["allAccess"] + : [ + "basic", + ...Object.keys(payload.features).filter( + (key) => + payload.features[key] === true || + (typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key]))) + ) + ]; + + const additionalSegments = [ + payload.cdk_dealerid && "CDK", + payload.pbs_serialnumber && "PBS", + // payload.rr_dealerid && "Reynolds", + payload.accountingconfig.qbo === true && "QBO", + payload.accountingconfig.qbo === false && + !payload.cdk_dealerid && + !payload.pbs_serialnumber && + // !payload.rr_dealerid && + "QBD" + ].filter(Boolean); + + featureSegments.push(...additionalSegments); const regionSeg = payload.region_config ? `region:${payload.region_config}` : null; const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments]; diff --git a/server/data/carfax-rps.js b/server/data/carfax-rps.js index de267d7ac..fd8f634a1 100644 --- a/server/data/carfax-rps.js +++ b/server/data/carfax-rps.js @@ -234,11 +234,10 @@ const CreateRepairOrderTag = (job, errorCallback) => { const ret = { ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"), v_vin: job.v_vin || "", - v_year: job.v_model_yr - ? parseInt(job.v_model_yr.match(/\d/g)) - ? parseInt(job.v_model_yr.match(/\d/g).join(""), 10) - : "" - : "", + v_year: (() => { + const y = parseInt(job.v_model_yr); + return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y; + })(), v_make: job.v_makedesc || "", v_model: job.v_model || "", diff --git a/server/data/carfax.js b/server/data/carfax.js index aaa7d0dde..1424dea4f 100644 --- a/server/data/carfax.js +++ b/server/data/carfax.js @@ -286,11 +286,10 @@ const CreateRepairOrderTag = (job, errorCallback) => { const ret = { ro_number: crypto.createHash("md5").update(job.ro_number, "utf8").digest("hex"), v_vin: job.v_vin || "", - v_year: job.v_model_yr - ? parseInt(job.v_model_yr.match(/\d/g)) - ? parseInt(job.v_model_yr.match(/\d/g).join(""), 10) - : "" - : "", + v_year: (() => { + const y = parseInt(job.v_model_yr); + return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y; + })(), v_make: job.v_make_desc || "", v_model: job.v_model_desc || "", diff --git a/server/data/usageReport.js b/server/data/usageReport.js index cd03bbda2..ff51c3e5a 100644 --- a/server/data/usageReport.js +++ b/server/data/usageReport.js @@ -55,7 +55,9 @@ exports.default = async (req, res) => { "patrick.fic@convenient-brands.com", "bradley.rhoades@convenient-brands.com", "jrome@rometech.com", - "ivana@imexsystems.ca" + "ivana@imexsystems.ca", + "support@imexsystems.ca", + "sarah@rometech.com" ], subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`, text: ` diff --git a/server/integrations/partsManagement/endpoints/partsManagementProvisioning.js b/server/integrations/partsManagement/endpoints/partsManagementProvisioning.js index 66ef19b59..5f626e3d9 100644 --- a/server/integrations/partsManagement/endpoints/partsManagementProvisioning.js +++ b/server/integrations/partsManagement/endpoints/partsManagementProvisioning.js @@ -134,13 +134,16 @@ const insertUserAssociation = async (uid, email, shopId) => { /** * PATCH handler for updating bodyshop fields. - * Allows patching: shopname, address1, address2, city, state, zip_post, country, email, timezone, phone, logo_img_path + * Allows patching: shopname, address1, address2, city, state, zip_post, country, email, timezone, phone + * Also allows updating logo_img_path via a simple logoUrl string, which is expanded to the full object. * @param req * @param res * @returns {Promise} */ const patchPartsManagementProvisioning = async (req, res) => { const { id } = req.params; + + // Fields that can be directly patched 1:1 const allowedFields = [ "shopname", "address1", @@ -151,31 +154,58 @@ const patchPartsManagementProvisioning = async (req, res) => { "country", "email", "timezone", - "phone", - "logo_img_path" + "phone" + // NOTE: logo_img_path is handled separately via logoUrl ]; + const updateFields = {}; + + // Copy over simple scalar fields if present for (const field of allowedFields) { if (req.body[field] !== undefined) { updateFields[field] = req.body[field]; } } + + // Handle logo update via a simple href string, same behavior as provision route + if (typeof req.body.logo_img_path === "string") { + const trimmed = req.body.logo_img_path.trim(); + if (trimmed) { + updateFields.logo_img_path = { + src: trimmed, + width: "", + height: "", + headerMargin: DefaultNewShop.logo_img_path.headerMargin + }; + } + } + if (Object.keys(updateFields).length === 0) { return res.status(400).json({ error: "No valid fields provided for update." }); } + // Check that the bodyshop has an external_shop_id before allowing patch try { - // Fetch the bodyshop by id const shopResp = await client.request( - `query GetBodyshop($id: uuid!) { bodyshops_by_pk(id: $id) { id external_shop_id } }`, + `query GetBodyshop($id: uuid!) { + bodyshops_by_pk(id: $id) { + id + external_shop_id + } + }`, { id } ); + if (!shopResp.bodyshops_by_pk?.external_shop_id) { return res.status(400).json({ error: "Cannot patch: bodyshop does not have an external_shop_id." }); } } catch (err) { - return res.status(500).json({ error: "Failed to validate bodyshop external_shop_id.", detail: err }); + return res.status(500).json({ + error: "Failed to validate bodyshop external_shop_id.", + detail: err + }); } + try { const resp = await client.request(UPDATE_BODYSHOP_BY_ID, { id, fields: updateFields }); if (!resp.update_bodyshops_by_pk) { diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index d1bdb22a0..ec24e1804 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -81,8 +81,8 @@ const alternateTransportChangedBuilder = (data) => { * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} */ const billPostedBuilder = (data) => { - const facing = data?.data?.isinhouse ? "in-house" : "vendor"; - const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim(); + const facing = data?.data?.isinhouse ? "An In House" : "A Vendor"; + const body = `${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim(); return buildNotification(data, "notifications.job.billPosted", body, { isInHouse: data?.data?.isinhouse,