Merged in release/2025-12-05 (pull request #2696)

Release/2025 12 05 into master-AIO - IO-3450 IO-3452 IO-3262 - IO-3456 IO-3262
This commit is contained in:
Dave Richer
2025-12-06 01:48:37 +00:00
16 changed files with 316 additions and 223 deletions

View File

@@ -142,17 +142,37 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
title={t("job_lifecycle.content.legend_title")} title={t("job_lifecycle.content.legend_title")}
style={{ marginTop: "10px" }} style={{ marginTop: "10px" }}
> >
<div> <div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8
}}
>
{lifecycleData.summations.map((key) => ( {lifecycleData.summations.map((key) => (
<Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}> <Tag
key={key.status}
color={key.color}
style={{
// IMPORTANT: let the tag grow with its content
width: "auto",
padding: 0,
margin: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box"
}}
>
<div <div
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{ style={{
backgroundColor: "var(--tag-wrapper-bg)", backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)", color: "var(--tag-wrapper-text)",
padding: "4px", padding: "4px 8px",
textAlign: "center" textAlign: "center",
whiteSpace: "nowrap" // keep it on one line while letting the pill expand
}} }}
> >
{key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage}) {key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage})

View File

@@ -222,17 +222,37 @@ export function JobLifecycleComponent({ bodyshop, job, statuses }) {
</div> </div>
</BlurWrapperComponent> </BlurWrapperComponent>
<Card type="inner" title={t("job_lifecycle.content.legend_title")} style={{ marginTop: "10px" }}> <Card type="inner" title={t("job_lifecycle.content.legend_title")} style={{ marginTop: "10px" }}>
<div> <div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8
}}
>
{lifecycleData.durations.summations.map((key) => ( {lifecycleData.durations.summations.map((key) => (
<Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}> <Tag
key={key.status}
color={key.color}
style={{
// let the tag grow with its content
width: "auto",
padding: 0,
margin: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box"
}}
>
<div <div
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{ style={{
backgroundColor: "var(--tag-wrapper-bg)", backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)", color: "var(--tag-wrapper-text)",
padding: "4px", padding: "4px 8px",
textAlign: "center" textAlign: "center",
whiteSpace: "nowrap" // single line; tag gets wider instead of text escaping
}} }}
> >
{key.status} ( {key.status} (

View File

@@ -35,16 +35,14 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
...galleryImages.other.filter((image) => image.isSelected) ...galleryImages.other.filter((image) => image.isSelected)
]; ];
function downloadProgress(progressEvent) { const downloadProgress = ({ loaded }) => {
setDownload((currentDownloadState) => { setDownload((currentDownloadState) => ({
return { downloaded: loaded ?? 0,
downloaded: progressEvent.loaded || 0, speed: (loaded ?? 0) - (currentDownloadState?.downloaded ?? 0)
speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0) }));
}; };
});
}
function standardMediaDownload(bufferData) { const standardMediaDownload = (bufferData) => {
try { try {
const a = document.createElement("a"); const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData])); const url = window.URL.createObjectURL(new Blob([bufferData]));
@@ -55,29 +53,26 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
setLoading(false); setLoading(false);
setDownload(null); setDownload(null);
} }
} };
const handleDownload = async () => { const handleDownload = async () => {
logImEXEvent("jobs_documents_download"); logImEXEvent("jobs_documents_download");
setLoading(true); setLoading(true);
try { try {
const response = await axios({ const { data } = await axios({
url: "/media/imgproxy/download", url: "/media/imgproxy/download",
method: "POST", method: "POST",
responseType: "blob", responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) }, data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress onDownloadProgress: downloadProgress
}); });
setLoading(false);
setDownload(null);
// Use the response data (Blob) to trigger download // Use the response data (Blob) to trigger download
standardMediaDownload(response.data); standardMediaDownload(data);
} catch { } catch {
// handle error (optional)
} finally {
setLoading(false); setLoading(false);
setDownload(null); setDownload(null);
// handle error (optional)
} }
}; };

View File

@@ -76,14 +76,14 @@ function JobsDocumentsImgproxyComponent({
<SyncOutlined /> <SyncOutlined />
</Button> </Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} /> <JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} /> <JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
<JobsDocumentsDeleteButton <JobsDocumentsDeleteButton
galleryImages={galleryImages} galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch} deletionCallback={billsCallback || fetchThumbnails || refetch}
/> />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
</Space> </Space>
</Col> </Col>
{!hasMediaAccess && ( {!hasMediaAccess && (

View File

@@ -67,7 +67,7 @@ export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, delet
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")} cancelText={t("general.actions.cancel")}
> >
<Button disabled={imagesToDelete.length < 1} loading={loading}> <Button danger disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")} {t("documents.actions.delete")}
</Button> </Button>
</Popconfirm> </Popconfirm>

View File

@@ -107,8 +107,8 @@ export function JobsDocumentsLocalGallery({
<a href={CreateExplorerLinkForJob({ jobid: job.id })}> <a href={CreateExplorerLinkForJob({ jobid: job.id })}>
<Button>{t("documents.labels.openinexplorer")}</Button> <Button>{t("documents.labels.openinexplorer")}</Button>
</a> </a>
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} /> <JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} />
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsLocalGalleryDownloadButton job={job} /> <JobsLocalGalleryDownloadButton job={job} />
<JobsDocumentsLocalDeleteButton jobid={job.id} /> <JobsDocumentsLocalDeleteButton jobid={job.id} />
</Space> </Space>

View File

@@ -28,6 +28,8 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const imagesToDelete = (allMedia?.[jobid] || []).filter((i) => i.isSelected);
const handleDelete = async () => { const handleDelete = async () => {
logImEXEvent("job_documents_delete"); logImEXEvent("job_documents_delete");
setLoading(true); setLoading(true);
@@ -36,7 +38,7 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
`${bodyshop.localmediaserverhttp}/jobs/delete`, `${bodyshop.localmediaserverhttp}/jobs/delete`,
{ {
jobid: jobid, jobid: jobid,
files: (allMedia?.[jobid] || []).filter((i) => i.isSelected).map((i) => i.filename) files: imagesToDelete.map((i) => i.filename)
}, },
{ headers: { ims_token: bodyshop.localmediatoken } } { headers: { ims_token: bodyshop.localmediatoken } }
); );
@@ -60,14 +62,17 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
return ( return (
<Popconfirm <Popconfirm
disabled={imagesToDelete.length < 1}
icon={<QuestionCircleOutlined style={{ color: "red" }} />} icon={<QuestionCircleOutlined style={{ color: "red" }} />}
onConfirm={handleDelete} onConfirm={handleDelete}
title={t("documents.labels.confirmdelete")} title={t("documents.labels.confirmdelete")}
okText={t("general.actions.delete")} okText={t("general.actions.delete")}
okButtonProps={{ type: "danger" }} okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")} cancelText={t("general.actions.cancel")}
> >
<Button loading={loading}>{t("documents.actions.delete")}</Button> <Button danger disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")}
</Button>
</Popconfirm> </Popconfirm>
); );
} }

View File

@@ -1,8 +1,8 @@
import { Button } from "antd"; import { Button, Space } from "antd";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import cleanAxios from "../../utils/CleanAxios"; import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectAllMedia } from "../../redux/media/media.selectors"; import { selectAllMedia } from "../../redux/media/media.selectors";
@@ -19,45 +19,63 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobsLocalGalleryDown
export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) { export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [download, setDownload] = useState(null); const [loading, setLoading] = useState(false);
const [download, setDownload] = useState(false);
function downloadProgress(progressEvent) { const imagesToDownload = (allMedia?.[job.id] || []).filter((i) => i.isSelected);
setDownload((currentDownloadState) => {
return { const downloadProgress = ({ loaded }) => {
downloaded: progressEvent.loaded || 0, setDownload((currentDownloadState) => ({
speed: (progressEvent.loaded || 0) - (currentDownloadState?.downloaded || 0) 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 handleDownload = async () => {
const theDownloadedZip = await cleanAxios.post( const { localmediaserverhttp, localmediatoken } = bodyshop;
`${bodyshop.localmediaserverhttp}/jobs/download`, const { id, ro_number } = job;
{ setLoading(true);
jobid: job.id, try {
files: (allMedia?.[job.id] || []).filter((i) => i.isSelected).map((i) => i.filename) const response = await cleanAxios.post(
}, `${localmediaserverhttp}/jobs/download`,
{ {
headers: { ims_token: bodyshop.localmediatoken }, jobid: id,
responseType: "arraybuffer", files: imagesToDownload.map((i) => i.filename)
onDownloadProgress: downloadProgress },
} {
); headers: { ims_token: localmediatoken },
setDownload(null); responseType: "arraybuffer",
standardMediaDownload(theDownloadedZip.data, job.ro_number); onDownloadProgress: downloadProgress
}
);
standardMediaDownload(response.data, ro_number);
} catch {
// handle error (optional)
} finally {
setLoading(false);
setDownload(null);
}
}; };
return ( return (
<Button loading={!!download} onClick={handleDownload}> <Button disabled={imagesToDownload < 1} loading={download || loading} onClick={handleDownload}>
{t("documents.actions.download")} <Space>
<span>{t("documents.actions.download")}</span>
{download && <span>{`(${formatBytes(download.downloaded)} @ ${formatBytes(download.speed)} / second)`}</span>}
</Space>
</Button> </Button>
); );
} }
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();
}

View File

@@ -16,7 +16,7 @@ const mapDispatchToProps = () => ({
export function TechHeader({ technician }) { export function TechHeader({ technician }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Header style={{ textAlign: "center" }}> <Header style={{ textAlign: "center", height: "auto", overflow: "visible" }}>
<Typography.Title style={{ color: "#fff" }}> <Typography.Title style={{ color: "#fff" }}>
{technician {technician
? t("tech.labels.loggedin", { ? t("tech.labels.loggedin", {

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery } from "@apollo/client"; 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 axios from "axios";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -124,103 +124,12 @@ export function TechClockOffButton({
cost_center: isShiftTicket ? "timetickets.labels.shift" : technician ? technician.cost_center : null cost_center: isShiftTicket ? "timetickets.labels.shift" : technician ? technician.cost_center : null
}} }}
> >
<Row gutter={[16, 16]}> <Space direction="vertical">
<Col span={!isShiftTicket ? 8 : 24}> {!isShiftTicket ? (
{!isShiftTicket ? ( <div>
<div>
<Form.Item
label={t("timetickets.fields.actualhrs")}
name="actualhrs"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.productivehrs")}
name="productivehrs"
rules={[
{
required: true
//message: t("general.validation.required"),
},
({ getFieldValue }) => ({
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();
}
}
})
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
</div>
) : null}
<Form.Item
name="cost_center"
label={t("timetickets.fields.cost_center")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select disabled={isShiftTicket}>
{isShiftTicket ? (
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
) : (
emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))
)}
</Select>
</Form.Item>
{isShiftTicket ? (
<div></div>
) : (
<Form.Item <Form.Item
name="status" label={t("timetickets.fields.actualhrs")}
label={t("jobs.fields.status")} name="actualhrs"
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
rules={[ rules={[
{ {
required: true required: true
@@ -228,35 +137,117 @@ export function TechClockOffButton({
} }
]} ]}
> >
<Select> <InputNumber min={0} precision={1} />
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
<Select.Option key={item}></Select.Option>
))}
</Select>
</Form.Item> </Form.Item>
)} <Form.Item
<Button type="primary" htmlType="submit" loading={loading}> label={t("timetickets.fields.productivehrs")}
{t("general.actions.save")} name="productivehrs"
</Button> rules={[
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} /> {
</Col> required: true
{!isShiftTicket && ( //message: t("general.validation.required"),
<Col span={16}> },
<LaborAllocationContainer ({ getFieldValue }) => ({
jobid={jobId || null} validator(rule, value) {
loading={queryLoading} if (!bodyshop.tt_enforce_hours_for_tech_console) {
lineTicketData={lineTicketData} return Promise.resolve();
/> }
</Col> 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();
}
}
})
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
</div>
) : null}
<Form.Item
name="cost_center"
label={t("timetickets.fields.cost_center")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select disabled={isShiftTicket}>
{isShiftTicket ? (
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
) : (
emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))
)}
</Select>
</Form.Item>
{isShiftTicket ? (
<div></div>
) : (
<Form.Item
name="status"
label={t("jobs.fields.status")}
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
<Select.Option key={item}></Select.Option>
))}
</Select>
</Form.Item>
)} )}
</Row> <Button type="primary" htmlType="submit" loading={loading}>
{t("general.actions.save")}
</Button>
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} />
{!isShiftTicket && (
<LaborAllocationContainer jobid={jobId || null} loading={queryLoading} lineTicketData={lineTicketData} />
)}
</Space>
</Form> </Form>
</div> </div>
</Card> </Card>
); );
return ( return (
<Popover content={overlay} trigger="click"> <Popover
content={<div style={{ maxHeight: "75vh", overflowY: "auto" }}>{overlay}</div>}
trigger="click"
getPopupContainer={() => document.querySelector('#time-ticket-modal')}
>
<Button loading={loading} {...otherBtnProps}> <Button loading={loading} {...otherBtnProps}>
{t("timetickets.actions.clockout")} {t("timetickets.actions.clockout")}
</Button> </Button>

View File

@@ -50,7 +50,7 @@ import {
} from "./user.actions"; } from "./user.actions";
import UserActionTypes from "./user.types"; import UserActionTypes from "./user.types";
//import * as amplitude from '@amplitude/analytics-browser'; //import * as amplitude from '@amplitude/analytics-browser';
import posthog from 'posthog-js'; import posthog from "posthog-js";
const fpPromise = FingerprintJS.load(); const fpPromise = FingerprintJS.load();
@@ -269,11 +269,11 @@ export function* signInSuccessSaga({ payload }) {
instanceSeg, instanceSeg,
...(isParts ...(isParts
? [ ? [
InstanceRenderManager({ InstanceRenderManager({
imex: "ImexPartsManagement", imex: "ImexPartsManagement",
rome: "RomePartsManagement" rome: "RomePartsManagement"
}) })
] ]
: []) : [])
]; ];
window.$crisp.push(["set", "session:segments", [segs]]); 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 isParts = yield select((state) => state.application.isPartsEntry === true);
const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" }); const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" });
let featureSegments; const featureSegments =
if (payload.features?.allAccess === true) { payload.features?.allAccess === true
featureSegments = ["allAccess"]; ? ["allAccess"]
} else { : [
const featureKeys = Object.keys(payload.features).filter( "basic",
(key) => ...Object.keys(payload.features).filter(
payload.features[key] === true || (key) =>
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key]))) payload.features[key] === true ||
); (typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
featureSegments = ["basic", ...featureKeys]; )
} ];
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 regionSeg = payload.region_config ? `region:${payload.region_config}` : null;
const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments]; const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments];

View File

@@ -234,11 +234,10 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = { const ret = {
ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"), ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"),
v_vin: job.v_vin || "", v_vin: job.v_vin || "",
v_year: job.v_model_yr v_year: (() => {
? parseInt(job.v_model_yr.match(/\d/g)) const y = parseInt(job.v_model_yr);
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10) return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
: "" })(),
: "",
v_make: job.v_makedesc || "", v_make: job.v_makedesc || "",
v_model: job.v_model || "", v_model: job.v_model || "",

View File

@@ -286,11 +286,10 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = { const ret = {
ro_number: crypto.createHash("md5").update(job.ro_number, "utf8").digest("hex"), ro_number: crypto.createHash("md5").update(job.ro_number, "utf8").digest("hex"),
v_vin: job.v_vin || "", v_vin: job.v_vin || "",
v_year: job.v_model_yr v_year: (() => {
? parseInt(job.v_model_yr.match(/\d/g)) const y = parseInt(job.v_model_yr);
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10) return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
: "" })(),
: "",
v_make: job.v_make_desc || "", v_make: job.v_make_desc || "",
v_model: job.v_model_desc || "", v_model: job.v_model_desc || "",

View File

@@ -55,7 +55,9 @@ exports.default = async (req, res) => {
"patrick.fic@convenient-brands.com", "patrick.fic@convenient-brands.com",
"bradley.rhoades@convenient-brands.com", "bradley.rhoades@convenient-brands.com",
"jrome@rometech.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")}`, subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
text: ` text: `

View File

@@ -134,13 +134,16 @@ const insertUserAssociation = async (uid, email, shopId) => {
/** /**
* PATCH handler for updating bodyshop fields. * 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 req
* @param res * @param res
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const patchPartsManagementProvisioning = async (req, res) => { const patchPartsManagementProvisioning = async (req, res) => {
const { id } = req.params; const { id } = req.params;
// Fields that can be directly patched 1:1
const allowedFields = [ const allowedFields = [
"shopname", "shopname",
"address1", "address1",
@@ -151,31 +154,58 @@ const patchPartsManagementProvisioning = async (req, res) => {
"country", "country",
"email", "email",
"timezone", "timezone",
"phone", "phone"
"logo_img_path" // NOTE: logo_img_path is handled separately via logoUrl
]; ];
const updateFields = {}; const updateFields = {};
// Copy over simple scalar fields if present
for (const field of allowedFields) { for (const field of allowedFields) {
if (req.body[field] !== undefined) { if (req.body[field] !== undefined) {
updateFields[field] = req.body[field]; 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) { if (Object.keys(updateFields).length === 0) {
return res.status(400).json({ error: "No valid fields provided for update." }); return res.status(400).json({ error: "No valid fields provided for update." });
} }
// Check that the bodyshop has an external_shop_id before allowing patch // Check that the bodyshop has an external_shop_id before allowing patch
try { try {
// Fetch the bodyshop by id
const shopResp = await client.request( 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 } { id }
); );
if (!shopResp.bodyshops_by_pk?.external_shop_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." }); return res.status(400).json({ error: "Cannot patch: bodyshop does not have an external_shop_id." });
} }
} catch (err) { } 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 { try {
const resp = await client.request(UPDATE_BODYSHOP_BY_ID, { id, fields: updateFields }); const resp = await client.request(UPDATE_BODYSHOP_BY_ID, { id, fields: updateFields });
if (!resp.update_bodyshops_by_pk) { if (!resp.update_bodyshops_by_pk) {

View File

@@ -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: *[]}}} * @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 billPostedBuilder = (data) => {
const facing = data?.data?.isinhouse ? "in-house" : "vendor"; const facing = data?.data?.isinhouse ? "An In House" : "A Vendor";
const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim(); const body = `${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim();
return buildNotification(data, "notifications.job.billPosted", body, { return buildNotification(data, "notifications.job.billPosted", body, {
isInHouse: data?.data?.isinhouse, isInHouse: data?.data?.isinhouse,