-
-
-
+
+
);
+ }
+
return (
-
!disabled && setEditing(true)}>
+
!disabled && setEditing(true)}
+ >
- {jobline.location}
+ {jobline.location ? (
+ {jobline.location}
+ ) : (
+ {t("general.labels.none")}
+ )}
{jobline.parts_dispatch_lines?.length > 0 && "-Disp"}
diff --git a/client/src/components/job-parts-received/job-parts-received.component.jsx b/client/src/components/job-parts-received/job-parts-received.component.jsx
new file mode 100644
index 000000000..64521fb5f
--- /dev/null
+++ b/client/src/components/job-parts-received/job-parts-received.component.jsx
@@ -0,0 +1,105 @@
+import { useCallback, useMemo, useState } from "react";
+import PropTypes from "prop-types";
+import { Popover } from "antd";
+import { connect } from "react-redux";
+import { createStructuredSelector } from "reselect";
+import { selectBodyshop } from "../../redux/user/user.selectors";
+import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.component";
+import { useTranslation } from "react-i18next";
+
+const mapStateToProps = createStructuredSelector({
+ bodyshop: selectBodyshop
+});
+
+/**
+ * Displays "Parts Received" summary (modeled after the Production Board List column),
+ * and on click shows a popover with the Parts Status grid (existing JobPartsQueueCount UI).
+ * @param bodyshop
+ * @param parts
+ * @param displayMode
+ * @param popoverPlacement
+ * @returns {JSX.Element}
+ * @constructor
+ */
+export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popoverPlacement = "top" }) {
+ const [open, setOpen] = useState(false);
+ const { t } = useTranslation();
+
+ const summary = useMemo(() => {
+ const receivedStatus = bodyshop?.md_order_statuses?.default_received;
+
+ if (!Array.isArray(parts) || parts.length === 0 || !receivedStatus) {
+ return { total: 0, received: 0, percentLabel: t("general.labels.na") };
+ }
+
+ // Keep consistent with JobPartsQueueCount: exclude PAS / PASL from parts math
+ const { total, received } = parts.reduce(
+ (acc, val) => {
+ if (val?.part_type === "PAS" || val?.part_type === "PASL") return acc;
+ const count = Number(val?.count || 0);
+ acc.total += count;
+
+ if (val?.status === receivedStatus) {
+ acc.received += count;
+ }
+ return acc;
+ },
+ { total: 0, received: 0 }
+ );
+
+ const percentLabel = total > 0 ? `${Math.round((received / total) * 100)}%` : t("general.labels.na");
+ return { total, received, percentLabel };
+ }, [parts, bodyshop?.md_order_statuses?.default_received]);
+
+ const canOpen = summary.total > 0;
+
+ const handleOpenChange = useCallback(
+ (nextOpen) => {
+ if (!canOpen) return;
+ setOpen(nextOpen);
+ },
+ [canOpen]
+ );
+
+ const displayText =
+ displayMode === "compact" ? summary.percentLabel : `${summary.percentLabel} (${summary.received}/${summary.total})`;
+
+ // Prevent row/cell click handlers (table selection, drawer selection, etc.)
+ const stop = (e) => e.stopPropagation();
+
+ return (
+
+
+
+ }
+ >
+
+ {displayText}
+
+
+ );
+}
+
+JobPartsReceived.propTypes = {
+ bodyshop: PropTypes.object,
+ parts: PropTypes.array,
+ displayMode: PropTypes.oneOf(["full", "compact"]),
+ popoverPlacement: PropTypes.string
+};
+
+export default connect(mapStateToProps)(JobPartsReceived);
diff --git a/client/src/components/jobs-available-table/jobs-available-table.container.jsx b/client/src/components/jobs-available-table/jobs-available-table.container.jsx
index f5e6589bd..35a53aefd 100644
--- a/client/src/components/jobs-available-table/jobs-available-table.container.jsx
+++ b/client/src/components/jobs-available-table/jobs-available-table.container.jsx
@@ -154,6 +154,10 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
: {})
};
+ if (currentUser?.email) {
+ newJob.created_user_email = currentUser.email;
+ }
+
if (selectedOwner) {
newJob.ownerid = selectedOwner;
delete newJob.owner;
diff --git a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx
index 915509881..2792adcdc 100644
--- a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx
+++ b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.new.component.jsx
@@ -1,15 +1,33 @@
-import { Form, Input } from "antd";
+import { Form, Input, Select } from "antd";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import FormItemEmail from "../form-items-formatted/email-form-item.component";
import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
+import { buildOwnerPhoneTypeOptions } from "../../utils/phoneTypeOptions.js";
export default function JobsCreateOwnerInfoNewComponent() {
const [state] = useContext(JobCreateContext);
-
const { t } = useTranslation();
+
+ const PHONE_TYPE_OPTIONS = buildOwnerPhoneTypeOptions(t);
+
+ const PREFERRED_CONTACT_OPTIONS = [
+ {
+ label: t("owners.labels.email", { defaultValue: "Email" }),
+ options: [{ label: t("owners.labels.email", { defaultValue: "Email" }), value: "Email" }]
+ },
+ {
+ label: t("owners.labels.sms", { defaultValue: "SMS" }),
+ options: [{ label: t("owners.labels.sms", { defaultValue: "SMS" }), value: "SMS" }]
+ },
+ {
+ label: t("owners.labels.phone", { defaultValue: "Phone" }),
+ options: PHONE_TYPE_OPTIONS
+ }
+ ];
+
return (
@@ -105,29 +123,59 @@ export default function JobsCreateOwnerInfoNewComponent() {
]}
name={["owner", "data", "ownr_ea"]}
>
-
+
- PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph1")]}
- >
-
+
+ {/* Phone 1 + Type */}
+
+
+
PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph1")]}
+ >
+
+
+
+
+
+
+
- PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph2")]}
- >
-
+
+ {/* Phone 2 + Type */}
+
+
+
PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph2")]}
+ >
+
+
+
+
+
+
+
+
-
+
diff --git a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.search.component.jsx b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.search.component.jsx
index 9f93f4362..9693d4f67 100644
--- a/client/src/components/jobs-create-owner-info/jobs-create-owner-info.search.component.jsx
+++ b/client/src/components/jobs-create-owner-info/jobs-create-owner-info.search.component.jsx
@@ -61,7 +61,7 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
title: t("owners.fields.ownr_ph1"),
dataIndex: "ownr_ph1",
key: "ownr_ph1",
- render: (text, record) =>
{record.ownr_ph1},
+ render: (text, record) =>
{record.ownr_ph1},
sorter: (a, b) => alphaSort(a.ownr_ph1, b.ownr_ph1),
sortOrder: tableState.sortedInfo.columnKey === "ownr_ph1" && tableState.sortedInfo.order
},
@@ -69,7 +69,7 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
title: t("owners.fields.ownr_ph2"),
dataIndex: "ownr_ph2",
key: "ownr_ph2",
- render: (text, record) =>
{record.ownr_ph2},
+ render: (text, record) =>
{record.ownr_ph2},
sorter: (a, b) => alphaSort(a.ownr_ph2, b.ownr_ph2),
sortOrder: tableState.sortedInfo.columnKey === "ownr_ph2" && tableState.sortedInfo.order
}
diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
index 655694d8f..42aa416fa 100644
--- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
+++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
@@ -175,25 +175,33 @@ export function JobsDetailHeaderActions({
};
const handleDuplicate = () =>
- DuplicateJob(
- client,
- job.id,
- { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
- (newJobId) => {
+ DuplicateJob({
+ apolloClient: client,
+ jobId: job.id,
+ config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
+ completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
message: t("jobs.successes.duplicated")
});
},
- true
- );
+ keepJobLines: true,
+ currentUser
+ });
const handleDuplicateConfirm = () =>
- DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => {
- history(`/manage/jobs/${newJobId}`);
- notification.success({
- message: t("jobs.successes.duplicated")
- });
+ DuplicateJob({
+ apolloClient: client,
+ jobId: job.id,
+ config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
+ completionCallback: (newJobId) => {
+ history(`/manage/jobs/${newJobId}`);
+ notification.success({
+ message: t("jobs.successes.duplicated")
+ });
+ },
+ keepJobLines: false,
+ currentUser
});
const handleFinish = async (values) => {
diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js
index 7706f91bc..8e0705e79 100644
--- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js
+++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.duplicate.util.js
@@ -5,7 +5,14 @@ import { INSERT_NEW_JOB, QUERY_JOB_FOR_DUPE } from "../../graphql/jobs.queries";
import dayjs from "../../utils/day";
import i18n from "i18next";
-export default async function DuplicateJob(apolloClient, jobId, config, completionCallback, keepJobLines = false) {
+export default async function DuplicateJob({
+ apolloClient,
+ jobId,
+ config,
+ completionCallback,
+ keepJobLines = false,
+ currentUser
+}) {
logImEXEvent("job_duplicate");
const { defaultOpenStatus } = config;
@@ -19,6 +26,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
const existingJob = _.cloneDeep(jobs_by_pk);
delete existingJob.__typename;
delete existingJob.id;
+ delete existingJob.created_user_email;
delete existingJob.createdat;
delete existingJob.updatedat;
delete existingJob.cieca_stl;
@@ -29,6 +37,10 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
status: defaultOpenStatus
};
+ if (currentUser?.email) {
+ newJob.created_user_email = currentUser.email;
+ }
+
const _tempLines = _.cloneDeep(existingJob.joblines);
_tempLines.forEach((line) => {
delete line.id;
@@ -55,7 +67,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
return;
}
-export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKeep) {
+export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToKeep, currentUser }) {
logImEXEvent("job_create_iou");
const { status } = config;
@@ -109,6 +121,9 @@ export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKee
delete newJob.joblines;
newJob.joblines = { data: _tempLines };
+ if (currentUser?.email) {
+ newJob.created_user_email = currentUser.email;
+ }
const res2 = await apolloClient.mutate({
mutation: INSERT_NEW_JOB,
variables: { job: [newJob] }
diff --git a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx
index 5ba5ce89e..80962c912 100644
--- a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx
+++ b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx
@@ -251,16 +251,16 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
{disabled || isPartsEntry ? (
- {job.ownr_ph1}
+ {job.ownr_ph1}
) : (
-
+
)}
{disabled || isPartsEntry ? (
- {job.ownr_ph2}
+ {job.ownr_ph2}
) : (
-
+
)}
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 1c14eb486..f2f5fbfd5 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
@@ -1,10 +1,9 @@
-import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
+import { EditFilled, SyncOutlined } from "@ant-design/icons";
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 { Gallery } from "react-grid-gallery";
import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
@@ -18,19 +17,13 @@ import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.downlo
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
});
const mapDispatchToProps = () => ({});
-/*
-################################################################################################
- Developer Note:
- Known Technical Debt Item
- Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
-################################################################################################
-*/
function JobsDocumentsImgproxyComponent({
bodyshop,
data,
@@ -119,17 +112,12 @@ function JobsDocumentsImgproxyComponent({
)}
- {
setModalState({ open: true, index: index });
- // window.open(
- // item.fullsize,
- // "_blank",
- // "toolbar=0,location=0,menubar=0"
- // );
}}
- onSelect={(index) => {
+ onToggle={(index) => {
setGalleryImages({
...galleryImages,
images: galleryImages.images.map((g, idx) =>
@@ -137,30 +125,26 @@ function JobsDocumentsImgproxyComponent({
)
});
}}
+ minColumns={4}
+ expandHeight={true}
/>
- {
- return {
- backgroundImage: ,
- height: "100%",
- width: "100%",
- cursor: "pointer"
- };
- }}
onClick={(index) => {
window.open(galleryImages.other[index].source, "_blank", "toolbar=0,location=0,menubar=0");
}}
- onSelect={(index) => {
+ onToggle={(index) => {
setGalleryImages({
...galleryImages,
other: galleryImages.other.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g))
});
}}
+ minColumns={4}
+ expandHeight={true}
/>
@@ -221,6 +205,7 @@ export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, billId,
width: 225,
isSelected: false,
key: value.key,
+ filename: value.key,
extension: value.extension,
id: value.id,
type: value.type,
@@ -259,6 +244,7 @@ export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, billId,
isSelected: false,
extension: value.extension,
key: value.key,
+ filename: value.key,
id: value.id,
type: value.type,
size: value.size
diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx
index 0458d3aac..badee7414 100644
--- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx
+++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx
@@ -1,31 +1,61 @@
-import { useEffect } from "react";
-import { Gallery } from "react-grid-gallery";
+import { useEffect, useState, useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
+import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component";
-/*
-################################################################################################
- Developer Note:
- Known Technical Debt Item
- Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
-################################################################################################
-*/
-
-function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState }) {
+function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState, context = "chat" }) {
const [galleryImages, setgalleryImages] = externalMediaState;
+ const [isLoading, setIsLoading] = useState(false);
+ const { t } = useTranslation();
- useEffect(() => {
- if (jobId) fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true });
+ const fetchThumbnails = useCallback(async () => {
+ await fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true });
}, [jobId, setgalleryImages]);
+ useEffect(() => {
+ if (!jobId) return;
+ setIsLoading(true);
+ fetchThumbnails().finally(() => setIsLoading(false));
+ }, [jobId, fetchThumbnails]);
+
+ const handleToggle = useCallback(
+ (idx) => {
+ setgalleryImages((imgs) => imgs.map((g, gIdx) => (gIdx === idx ? { ...g, isSelected: !g.isSelected } : g)));
+ },
+ [setgalleryImages]
+ );
+
+ const messageStyle = { textAlign: "center", padding: "1rem" };
+
+ if (!jobId) {
+ return (
+
+ );
+ }
+
return (
-
-
{
- setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)));
- }}
- />
+
+ {isLoading && galleryImages.length === 0 && (
+
+
+
+ )}
+ {galleryImages.length > 0 && (
+
+ )}
+ {galleryImages.length > 0 && (
+
+ {`${t("general.labels.media")}: ${galleryImages.length}`}
+
+ )}
);
}
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 f5570257f..4ef9cb881 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
@@ -1,7 +1,6 @@
-import { FileExcelFilled, SyncOutlined } from "@ant-design/icons";
+import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Col, Row, Space } from "antd";
import { useEffect, useState } from "react";
-import { Gallery } from "react-grid-gallery";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -14,6 +13,7 @@ import JobsDocumentsLocalDeleteButton from "./jobs-documents-local-gallery.delet
import JobsLocalGalleryDownloadButton from "./jobs-documents-local-gallery.download";
import JobsDocumentsLocalGalleryReassign from "./jobs-documents-local-gallery.reassign.component";
import JobsDocumentsLocalGallerySelectAllComponent from "./jobs-documents-local-gallery.selectall.component";
+import LocalMediaGrid from "./local-media-grid.component";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
@@ -132,59 +132,54 @@ export function JobsDocumentsLocalGallery({
-
+ )}
+ {
- toggleMediaSelected({ jobid: job.id, filename: image.filename });
- }}
- {...(optimized && {
- customControls: [
-
- ]
- })}
onClick={(index) => {
setModalState({ open: true, index: index });
- // const media = allMedia[job.id].find(
- // (m) => m.optimized === item.src
- // );
-
- // window.open(
- // media ? media.fullsize : item.fullsize,
- // "_blank",
- // "toolbar=0,location=0,menubar=0"
- // );
}}
+ onToggle={(index) => {
+ toggleMediaSelected({ jobid: job.id, filename: jobMedia.images[index].filename });
+ }}
+ minColumns={4}
+ expandHeight={true}
/>
- {
- return {
- backgroundImage: ,
- height: "100%",
- width: "100%",
- cursor: "pointer"
- };
- }}
onClick={(index) => {
window.open(jobMedia.other[index].fullsize, "_blank", "toolbar=0,location=0,menubar=0");
}}
- onSelect={(index, image) => {
- toggleMediaSelected({ jobid: job.id, filename: image.filename });
+ onToggle={(index) => {
+ toggleMediaSelected({ jobid: job.id, filename: jobMedia.other[index].filename });
}}
+ minColumns={4}
+ expandHeight={true}
/>
{modalState.open && (
{
+ const newWindow = window.open(
+ `${window.location.protocol}//${window.location.host}/edit?imageUrl=${
+ jobMedia.images[modalState.index].fullsize
+ }&filename=${jobMedia.images[modalState.index].filename}&jobid=${job.id}`,
+ "_blank",
+ "noopener,noreferrer"
+ );
+ if (newWindow) newWindow.opener = null;
+ }}
+ />
+ ]}
mainSrc={jobMedia.images[modalState.index].fullsize}
nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize}
prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize}
diff --git a/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx
index 277351296..4a3c6700b 100644
--- a/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx
+++ b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx
@@ -1,3 +1,4 @@
+import { Checkbox } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
@@ -6,16 +7,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
* Props:
* - images: Array<{ src, fullsize, filename?, isSelected? }>
* - onToggle(index)
+ * - onClick(index) optional for viewing
+ * - thumbSize: automatically set to 125 for chat, 250 for default
*/
export function LocalMediaGrid({
images,
onToggle,
- thumbSize = 100,
+ onClick,
gap = 8,
minColumns = 3,
maxColumns = 12,
- context = "default"
+ context = "default",
+ expandHeight = false
}) {
+ const thumbSize = context === "chat" ? 100 : 180;
const containerRef = useRef(null);
const [cols, setCols] = useState(() => {
// Pre-calc initial columns to stabilize layout before images render
@@ -114,8 +119,7 @@ export function LocalMediaGrid({
display: "grid",
gridTemplateColumns,
gap,
- maxHeight: 420,
- overflowY: "auto",
+ ...(expandHeight ? {} : { maxHeight: 420, overflowY: "auto" }),
overflowX: "hidden",
padding: 4,
justifyContent: justifyMode,
@@ -131,7 +135,7 @@ export function LocalMediaGrid({
role="listitem"
tabIndex={0}
aria-label={img.filename || `image ${idx + 1}`}
- onClick={() => onToggle(idx)}
+ onClick={() => (onClick ? onClick(idx) : onToggle(idx))}
onKeyDown={(e) => handleKeyDown(e, idx)}
style={{
position: "relative",
@@ -197,6 +201,24 @@ export function LocalMediaGrid({
}}
/>
)}
+ {onClick && (
+ {
+ e.stopPropagation();
+ onToggle(idx);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ position: "absolute",
+ top: 5,
+ left: 5,
+ zIndex: 2,
+ opacity: img.isSelected ? 1 : 0.4,
+ transition: "opacity 0.3s"
+ }}
+ />
+ )}
))}
{/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */}
diff --git a/client/src/components/jobs-find-modal/jobs-find-modal.component.jsx b/client/src/components/jobs-find-modal/jobs-find-modal.component.jsx
index a70e3d118..cf1679734 100644
--- a/client/src/components/jobs-find-modal/jobs-find-modal.component.jsx
+++ b/client/src/components/jobs-find-modal/jobs-find-modal.component.jsx
@@ -64,7 +64,11 @@ export default function JobsFindModalComponent({
width: "12%",
ellipsis: true,
render: (text, record) => {
- return record.ownr_ph1 ? {record.ownr_ph1} : t("general.labels.unknown");
+ return record.ownr_ph1 ? (
+ {record.ownr_ph1}
+ ) : (
+ t("general.labels.unknown")
+ );
}
},
{
@@ -74,7 +78,11 @@ export default function JobsFindModalComponent({
width: "12%",
ellipsis: true,
render: (text, record) => {
- return record.ownr_ph2 ? {record.ownr_ph2} : t("general.labels.unknown");
+ return record.ownr_ph2 ? (
+ {record.ownr_ph2}
+ ) : (
+ t("general.labels.unknown")
+ );
}
},
{
@@ -245,7 +253,11 @@ export default function JobsFindModalComponent({
>
{t("jobs.labels.override_header")}
- setPartsQueueToggle(e.target.checked)} id="parts_queue_toggle">
+ setPartsQueueToggle(e.target.checked)}
+ id="parts_queue_toggle"
+ >
{t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")}
+ render: (text, record) =>
},
{
title: t("jobs.fields.ownr_ph2"),
dataIndex: "ownr_ph2",
key: "ownr_ph2",
ellipsis: true,
- render: (text, record) =>
+ render: (text, record) =>
},
{
title: t("jobs.fields.status"),
diff --git a/client/src/components/jobs-list/jobs-list.component.jsx b/client/src/components/jobs-list/jobs-list.component.jsx
index bde88d161..efe52e959 100644
--- a/client/src/components/jobs-list/jobs-list.component.jsx
+++ b/client/src/components/jobs-list/jobs-list.component.jsx
@@ -139,7 +139,7 @@ export function JobsList({ bodyshop }) {
key: "ownr_ph1",
ellipsis: true,
responsive: ["md"],
- render: (text, record) =>
+ render: (text, record) =>
},
{
title: t("jobs.fields.ownr_ph2"),
@@ -147,7 +147,7 @@ export function JobsList({ bodyshop }) {
key: "ownr_ph2",
ellipsis: true,
responsive: ["md"],
- render: (text, record) =>
+ render: (text, record) =>
},
{
diff --git a/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx b/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx
index b218b46f4..f793f6b55 100644
--- a/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx
+++ b/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx
@@ -140,7 +140,7 @@ export function JobsReadyList({ bodyshop }) {
key: "ownr_ph1",
ellipsis: true,
responsive: ["md"],
- render: (text, record) =>
+ render: (text, record) =>
},
{
title: t("jobs.fields.ownr_ph2"),
@@ -148,7 +148,7 @@ export function JobsReadyList({ bodyshop }) {
key: "ownr_ph2",
ellipsis: true,
responsive: ["md"],
- render: (text, record) =>
+ render: (text, record) =>
},
{
title: t("jobs.fields.status"),
diff --git a/client/src/components/notification-settings/notification-settings-form.component.jsx b/client/src/components/notification-settings/notification-settings-form.component.jsx
index c728943f8..649a0be55 100644
--- a/client/src/components/notification-settings/notification-settings-form.component.jsx
+++ b/client/src/components/notification-settings/notification-settings-form.component.jsx
@@ -132,7 +132,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
dataIndex: "scenarioLabel",
key: "scenario",
render: (_, record) => t(`notifications.scenarios.${record.key}`),
- width: "90%"
+ width: "80%"
},
{
title: setIsDirty(true)} />,
@@ -156,20 +156,23 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
)
}
- // TODO: Disabled for now until FCM is implemented.
- // {
- // title: setIsDirty(true)} />,
- // dataIndex: "fcm",
- // key: "fcm",
- // align: "center",
- // render: (_, record) => (
- //
- //
- //
- // )
- // }
];
+ // Currently disabled for prod
+ if (!import.meta.env.PROD) {
+ columns.push({
+ title: setIsDirty(true)} />,
+ dataIndex: "fcm",
+ key: "fcm",
+ align: "center",
+ render: (_, record) => (
+
+
+
+ )
+ });
+ }
+
const dataSource = notificationScenarios.map((scenario) => ({ key: scenario }));
return (
@@ -186,13 +189,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
extra={
{t("notifications.labels.auto-add")}
-
+
diff --git a/client/src/components/owner-detail-form/owner-detail-form.component.jsx b/client/src/components/owner-detail-form/owner-detail-form.component.jsx
index 608d66fb1..de6f1c81c 100644
--- a/client/src/components/owner-detail-form/owner-detail-form.component.jsx
+++ b/client/src/components/owner-detail-form/owner-detail-form.component.jsx
@@ -1,15 +1,26 @@
-import { Form, Input, Tooltip } from "antd";
+import { Form, Input, Select, Tooltip } from "antd";
import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
+import { buildOwnerPhoneTypeOptions } from "../../utils/phoneTypeOptions.js";
export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPhone2OptedOut }) {
const { t } = useTranslation();
const { getFieldValue } = form;
-
+ const PHONE_TYPE_OPTIONS = buildOwnerPhoneTypeOptions(t);
+ const PREFERRED_CONTACT_OPTIONS = [
+ {
+ label: t("owners.labels.email", { defaultValue: "Email" }),
+ options: [{ label: t("owners.labels.email", { defaultValue: "Email" }), value: "Email" }]
+ },
+ {
+ label: t("owners.labels.phone", { defaultValue: "Phone" }),
+ options: PHONE_TYPE_OPTIONS
+ }
+ ];
return (
@@ -30,6 +41,7 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
+
@@ -50,6 +62,7 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
+
+
+ {/* Phone 1 + Type + Opt-out icon */}
+
+
+
+
+
{isPhone1OptedOut && (
+
+ {/* Phone 2 + Type + Opt-out icon */}
+
+
+
+
+
{isPhone2OptedOut && (
+
-
+
+
+
diff --git a/client/src/components/owner-detail-update-jobs/owner-detail-update-jobs.component.jsx b/client/src/components/owner-detail-update-jobs/owner-detail-update-jobs.component.jsx
index b021660ff..78ab8454d 100644
--- a/client/src/components/owner-detail-update-jobs/owner-detail-update-jobs.component.jsx
+++ b/client/src/components/owner-detail-update-jobs/owner-detail-update-jobs.component.jsx
@@ -25,8 +25,10 @@ export default function OwnerDetailUpdateJobsComponent({ owner, selectedJobs, di
ownr_ea: owner["ownr_ea"],
ownr_fn: owner["ownr_fn"],
ownr_ph1: owner["ownr_ph1"],
+ ownr_ph1_ty: owner["ownr_ph1_ty"],
ownr_ln: owner["ownr_ln"],
ownr_ph2: owner["ownr_ph2"],
+ ownr_ph2_ty: owner["ownr_ph2_ty"],
ownr_st: owner["ownr_st"],
ownr_title: owner["ownr_title"],
ownr_zip: owner["ownr_zip"]
diff --git a/client/src/components/owner-find-modal/owner-find-modal.component.jsx b/client/src/components/owner-find-modal/owner-find-modal.component.jsx
index 7527de91f..09067bda5 100644
--- a/client/src/components/owner-find-modal/owner-find-modal.component.jsx
+++ b/client/src/components/owner-find-modal/owner-find-modal.component.jsx
@@ -47,13 +47,13 @@ export default function OwnerFindModalComponent({
title: t("owners.fields.ownr_ph1"),
dataIndex: "ownr_ph1",
key: "ownr_ph1",
- render: (text, record) => {record.ownr_ph1}
+ render: (text, record) => {record.ownr_ph1}
},
{
title: t("owners.fields.ownr_ph2"),
dataIndex: "ownr_ph2",
key: "ownr_ph2",
- render: (text, record) => {record.ownr_ph2}
+ render: (text, record) => {record.ownr_ph2}
},
{
title: t("owners.fields.note"),
diff --git a/client/src/components/owner-tag-popover/owner-tag-popover.component.jsx b/client/src/components/owner-tag-popover/owner-tag-popover.component.jsx
index 8d086e4fa..8781fd116 100644
--- a/client/src/components/owner-tag-popover/owner-tag-popover.component.jsx
+++ b/client/src/components/owner-tag-popover/owner-tag-popover.component.jsx
@@ -15,10 +15,10 @@ export default function OwnerTagPopoverComponent({ job }) {
- {job.ownr_ph1 || ""}
+ {job.ownr_ph1 || ""}
- {job.ownr_ph2 || ""}
+ {job.ownr_ph2 || ""}
{`${job.ownr_addr1 || ""} ${job.ownr_addr2 || ""} ${
@@ -36,13 +36,10 @@ export default function OwnerTagPopoverComponent({ job }) {
- {job.owner.ownr_ph1 || ""}
-
-
- {job.owner.ownr_ph2 || ""}
+ {job.owner.ownr_ph1 || ""}
- {job.owner.ownr_ph2 || ""}
+ {job.owner.ownr_ph2 || ""}
{`${job.owner.ownr_addr1 || ""} ${job.owner.ownr_addr2 || ""} ${
diff --git a/client/src/components/owners-list/owners-list.component.jsx b/client/src/components/owners-list/owners-list.component.jsx
index cd30f7824..a93e5765c 100644
--- a/client/src/components/owners-list/owners-list.component.jsx
+++ b/client/src/components/owners-list/owners-list.component.jsx
@@ -39,7 +39,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
dataIndex: "ownr_ph1",
key: "ownr_ph1",
render: (text, record) => {
- return {record.ownr_ph1};
+ return {record.ownr_ph1};
}
},
{
@@ -47,7 +47,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
dataIndex: "ownr_ph2",
key: "ownr_ph2",
render: (text, record) => {
- return {record.ownr_ph2};
+ return {record.ownr_ph2};
}
},
{
diff --git a/client/src/components/parts-queue-list/parts-queue.list.component.jsx b/client/src/components/parts-queue-list/parts-queue.list.component.jsx
index a947eaefa..ff47d00fb 100644
--- a/client/src/components/parts-queue-list/parts-queue.list.component.jsx
+++ b/client/src/components/parts-queue-list/parts-queue.list.component.jsx
@@ -16,11 +16,11 @@ import { pageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import AlertComponent from "../alert/alert.component";
-import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.component";
import JobRemoveFromPartsQueue from "../job-remove-from-parst-queue/job-remove-from-parts-queue.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
+import JobPartsReceived from "../job-parts-received/job-parts-received.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -235,7 +235,9 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus",
key: "partsstatus",
- render: (text, record) =>
+ render: (text, record) => (
+
+ )
},
{
title: t("jobs.fields.comment"),
diff --git a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx
index 64cd69f9b..6f8d6d4a4 100644
--- a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx
+++ b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx
@@ -19,6 +19,7 @@ import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.c
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import { PiMicrosoftTeamsLogo } from "react-icons/pi";
+import ProductionListColumnPartsReceived from "../production-list-columns/production-list-columns.partsreceived.component";
const cardColor = (ssbuckets, totalHrs) => {
const bucket = ssbuckets.find((bucket) => bucket.gte <= totalHrs && (!bucket.lt || bucket.lt > totalHrs));
@@ -312,6 +313,20 @@ const TasksToolTip = ({ metadata, cardSettings, t }) =>
);
+const PartsReceivedComponent = ({ metadata, cardSettings, card }) =>
+ cardSettings?.partsreceived && (
+
+
+
+ );
export default function ProductionBoardCard({ technician, card, bodyshop, cardSettings }) {
const { t } = useTranslation();
const { metadata } = card;
@@ -411,6 +426,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
+
);
diff --git a/client/src/components/production-board-kanban/settings/InformationSettings.jsx b/client/src/components/production-board-kanban/settings/InformationSettings.jsx
index ddf88c987..4ae4e781a 100644
--- a/client/src/components/production-board-kanban/settings/InformationSettings.jsx
+++ b/client/src/components/production-board-kanban/settings/InformationSettings.jsx
@@ -18,7 +18,8 @@ const InformationSettings = ({ t }) => (
"partsstatus",
"estimator",
"subtotal",
- "tasks"
+ "tasks",
+ "partsreceived"
].map((item) => (
diff --git a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js
index 3c14a08f7..1c7a264b7 100644
--- a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js
+++ b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js
@@ -74,6 +74,7 @@ const defaultKanbanSettings = {
cardSize: "small",
model_info: true,
kiosk: false,
+ partsreceived: false,
totalHrs: true,
totalAmountInProduction: false,
totalLAB: true,
diff --git a/client/src/components/production-list-columns/production-list-columns.data.jsx b/client/src/components/production-list-columns/production-list-columns.data.jsx
index 165e15c1d..3b1e79625 100644
--- a/client/src/components/production-list-columns/production-list-columns.data.jsx
+++ b/client/src/components/production-list-columns/production-list-columns.data.jsx
@@ -161,7 +161,6 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
dataIndex: "actual_in_time",
key: "actual_in_time",
ellipsis: true,
-
render: (text, record) => {record.actual_in}
},
{
@@ -181,6 +180,22 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
render: (text, record) => {record.scheduled_completion}
},
+ {
+ title: i18n.t("jobs.fields.actual_completion"),
+ dataIndex: "actual_completion",
+ key: "actual_completion",
+ ellipsis: true,
+ sorter: (a, b) => dateSort(a.actual_completion, b.actual_completion),
+ sortOrder: state.sortedInfo.columnKey === "actual_completion" && state.sortedInfo.order,
+ render: (text, record) =>
+ },
+ {
+ title: i18n.t("jobs.fields.actual_completion") + " (HH:MM)",
+ dataIndex: "actual_completion_time",
+ key: "actual_completion_time",
+ ellipsis: true,
+ render: (text, record) => {record.actual_completion}
+ },
{
title: i18n.t("jobs.fields.date_last_contacted"),
dataIndex: "date_last_contacted",
@@ -255,14 +270,14 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
dataIndex: "ownr_ph1",
key: "ownr_ph1",
ellipsis: true,
- render: (text, record) => {record.ownr_ph1}
+ render: (text, record) => {record.ownr_ph1}
},
{
title: i18n.t("jobs.fields.ownr_ph2"),
dataIndex: "ownr_ph2",
key: "ownr_ph2",
ellipsis: true,
- render: (text, record) => {record.ownr_ph2}
+ render: (text, record) => {record.ownr_ph2}
},
{
title: i18n.t("jobs.fields.specialcoveragepolicy"),
diff --git a/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx b/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx
index b1bf59f99..c4e3a0f6b 100644
--- a/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx
+++ b/client/src/components/production-list-columns/production-list-columns.partsreceived.component.jsx
@@ -1,33 +1,5 @@
-import { useMemo } from "react";
-import { connect } from "react-redux";
-import { createStructuredSelector } from "reselect";
-import { selectBodyshop } from "../../redux/user/user.selectors";
+import JobPartsReceived from "../job-parts-received/job-parts-received.component";
-const mapStateToProps = createStructuredSelector({
- bodyshop: selectBodyshop
-});
-const mapDispatchToProps = () => ({
- //setUserLanguage: language => dispatch(setUserLanguage(language))
-});
-export default connect(mapStateToProps, mapDispatchToProps)(ProductionListColumnPartsReceived);
-
-export function ProductionListColumnPartsReceived({ bodyshop, record }) {
- const amount = useMemo(() => {
- const amount = record.joblines_status.reduce(
- (acc, val) => {
- acc.total += val.count;
- acc.received =
- val.status === bodyshop.md_order_statuses.default_received ? acc.received + val.count : acc.received;
- return acc;
- },
- { total: 0, received: 0 }
- );
-
- return {
- ...amount,
- percent: amount.total !== 0 ? ((amount.received / amount.total) * 100).toFixed(0) + "%" : "N/A"
- };
- }, [record, bodyshop.md_order_statuses]);
-
- return `${amount.percent} (${amount.received}/${amount.total})`;
+export default function ProductionListColumnPartsReceived({ record }) {
+ return ;
}
diff --git a/client/src/components/production-list-detail/production-list-detail.component.jsx b/client/src/components/production-list-detail/production-list-detail.component.jsx
index d05110fc5..0bcb36a13 100644
--- a/client/src/components/production-list-detail/production-list-detail.component.jsx
+++ b/client/src/components/production-list-detail/production-list-detail.component.jsx
@@ -154,13 +154,25 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
{!technician ? (
<>
-
-
+
+
>
) : (
<>
- {data.jobs_by_pk.ownr_ph1}
- {data.jobs_by_pk.ownr_ph2}
+
+ {data.jobs_by_pk.ownr_ph1}
+
+
+ {data.jobs_by_pk.ownr_ph2}
+
>
)}
@@ -187,7 +199,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
{!bodyshop.uselocalmediaserver && (
<>
-
+
>
)}
diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx
index acf553bb5..b24c04a03 100644
--- a/client/src/components/shop-info/shop-info.general.component.jsx
+++ b/client/src/components/shop-info/shop-info.general.component.jsx
@@ -1,12 +1,9 @@
import { DeleteFilled } from "@ant-design/icons";
-import { useSplitTreatments } from "@splitsoftware/splitio-react";
-import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd";
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
-import DatePickerRanges from "../../utils/DatePickerRanges";
-import InstanceRenderManager from "../../utils/instanceRenderMgr";
import FeatureWrapper, { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component";
@@ -26,14 +23,6 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
export function ShopInfoGeneral({ form, bodyshop }) {
const { t } = useTranslation();
- const {
- treatments: { ClosingPeriod, ADPPayroll }
- } = useSplitTreatments({
- attributes: {},
- names: ["ClosingPeriod", "ADPPayroll"],
- splitKey: bodyshop?.imexshopid
- });
-
return (
@@ -143,299 +132,6 @@ export function ShopInfoGeneral({ form, bodyshop }) {
-
- {[
- ...(HasFeatureAccess({ featureName: "export", bodyshop })
- ? [
-
-
- ,
- InstanceRenderManager({
- imex: (
-
- {() => (
-
-
-
- )}
-
- )
- }),
-
-
- ,
-
-
- 2
- 3
-
- ,
-
- {() => {
- return (
-
-
- {t("bodyshop.labels.2tiername")}
- {t("bodyshop.labels.2tiersource")}
-
-
- );
- }}
- ,
-
-
- ,
-
-
-
- ]
- : []),
-
-
- ,
-
-
- ,
- InstanceRenderManager({
- imex: (
-
-
-
- )
- }),
-
-
- ,
- ...(HasFeatureAccess({ featureName: "bills", bodyshop })
- ? [
- InstanceRenderManager({
- imex: (
-
-
-
- )
- }),
-
-
- ,
-
-
-
- ]
- : []),
-
-
- ,
-
-
- ,
- ...(HasFeatureAccess({ featureName: "export", bodyshop })
- ? [
-
- {ReceivableCustomFieldSelect}
- ,
-
- {ReceivableCustomFieldSelect}
- ,
-
- {ReceivableCustomFieldSelect}
- ,
- {
- return {
- required: getFieldValue("enforce_class"),
- //message: t("general.validation.required"),
- type: "array"
- };
- }
- ]}
- >
-
- ,
-
-
- ,
- ...(ClosingPeriod.treatment === "on"
- ? [
-
-
-
- ]
- : []),
- ...(ADPPayroll.treatment === "on"
- ? [
-
-
-
- ]
- : []),
- ...(ADPPayroll.treatment === "on"
- ? [
-
-
-
- ]
- : [])
- ]
- : []),
-
-
-
- ]}
-
null}>
{[
+
+
+ ,
+
+ {/*Start Insurance Provider Row */}
+ {/*End Insurance Provider Row */}
+
{(fields, { add, remove, move }) => {
@@ -1568,11 +1301,3 @@ export function ShopInfoGeneral({ form, bodyshop }) {
);
}
-
-const ReceivableCustomFieldSelect = (
-
-);
diff --git a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx
index 6e7a4edfd..38437a9aa 100644
--- a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx
+++ b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx
@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
-import { Button, Form, Input, InputNumber, Select, Space, Switch } from "antd";
+import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -14,6 +14,7 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopInfoResponsibilitycentersTaxesComponent from "./shop-info.responsibilitycenters.taxes.component";
+import DatePickerRanges from "../../utils/DatePickerRanges";
const SelectorDiv = styled.div`
.ant-form-item .ant-select {
@@ -34,11 +35,11 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
const { t } = useTranslation();
const {
- treatments: { Qb_Multi_Ar, DmsAp }
+ treatments: { ClosingPeriod, ADPPayroll, Qb_Multi_Ar, DmsAp }
} = useSplitTreatments({
attributes: {},
- names: ["Qb_Multi_Ar", "DmsAp"],
- splitKey: bodyshop && bodyshop.imexshopid
+ names: ["ClosingPeriod", "ADPPayroll", "Qb_Multi_Ar", "DmsAp"],
+ splitKey: bodyshop?.imexshopid
});
const [costOptions, setCostOptions] = useState([
@@ -58,6 +59,14 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
setProfitOptions([...(form.getFieldValue(["md_responsibility_centers", "profits"]).map((i) => i && i.name) || [])]);
};
+ const ReceivableCustomFieldSelect = (
+
+ );
+
return (
@@ -314,6 +323,286 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
>
)}
+
+ {[
+ ...(HasFeatureAccess({ featureName: "export", bodyshop })
+ ? [
+
+
+ ,
+ InstanceRenderManager({
+ imex: (
+
+ {() => (
+
+
+
+ )}
+
+ )
+ }),
+
+
+ ,
+
+
+ 2
+ 3
+
+ ,
+
+ {() => {
+ return (
+
+
+ {t("bodyshop.labels.2tiername")}
+ {t("bodyshop.labels.2tiersource")}
+
+
+ );
+ }}
+ ,
+
+
+ ,
+
+
+
+ ]
+ : []),
+
+
+ ,
+
+
+ ,
+ InstanceRenderManager({
+ imex: (
+
+
+
+ )
+ }),
+
+
+ ,
+ ...(HasFeatureAccess({ featureName: "bills", bodyshop })
+ ? [
+ InstanceRenderManager({
+ imex: (
+
+
+
+ )
+ }),
+
+
+ ,
+
+
+
+ ]
+ : []),
+
+
+ ,
+ ...(HasFeatureAccess({ featureName: "export", bodyshop })
+ ? [
+
+ {ReceivableCustomFieldSelect}
+ ,
+
+ {ReceivableCustomFieldSelect}
+ ,
+
+ {ReceivableCustomFieldSelect}
+ ,
+ {
+ return {
+ required: getFieldValue("enforce_class"),
+ //message: t("general.validation.required"),
+ type: "array"
+ };
+ }
+ ]}
+ >
+
+ ,
+
+
+ ,
+ ...(ClosingPeriod.treatment === "on"
+ ? [
+
+
+
+ ]
+ : []),
+ ...(ADPPayroll.treatment === "on"
+ ? [
+
+
+
+ ]
+ : []),
+ ...(ADPPayroll.treatment === "on"
+ ? [
+
+
+
+ ]
+ : [])
+ ]
+ : []),
+
+
+
+ ]}
+
{HasFeatureAccess({ featureName: "export", bodyshop }) && (
<>
diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx
index 2b5b2273e..b24637971 100644
--- a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx
+++ b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx
@@ -1,6 +1,7 @@
import { useLazyQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
+import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -33,7 +34,9 @@ export function TimeTicketModalComponent({
authLevel,
employeeAutoCompleteOptions,
disabled,
- employeeSelectDisabled
+ employeeSelectDisabled,
+ lineTicketRefreshKey,
+ isOpen
}) {
const { t } = useTranslation();
const {
@@ -48,6 +51,18 @@ export function TimeTicketModalComponent({
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
+
+ // Watch the jobid field so we can refetch the bottom section without relying on jobid changes
+ const watchedJobId = Form.useWatch("jobid", form);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ if (!watchedJobId) return;
+ if (!lineTicketRefreshKey) return;
+
+ loadLineTicketData({ variables: { id: watchedJobId } });
+ }, [lineTicketRefreshKey, watchedJobId, isOpen]);
+
const CostCenterSelect = ({ emps, value, ...props }) => {
return (
+
{() => {
const jobid = form.getFieldValue("jobid");
diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx
index 86a037165..53da3d44e 100644
--- a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx
+++ b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx
@@ -29,6 +29,9 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false);
+
+ const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
+
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
const [updateTicket] = useMutation(UPDATE_TIME_TICKET);
const {
@@ -85,11 +88,17 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
notification["success"]({
message: t("timetickets.successes.created")
});
- if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();
- if (enterAgain) {
- //Capture the existing information and repopulate it.
- const prev = form.getFieldsValue(["date", "employeeid", "flat_rate"]);
+ // Refresh parent screens (Job Labor tab, etc.)
+ if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();
+
+ // Refresh the modal "bottom section" (allocations + embedded ticket list) for the current job
+ setLineTicketRefreshKey((k) => k + 1);
+
+ if (enterAgain) {
+ // Capture existing information and repopulate it.
+ // (Include jobid so Save & New stays on the same RO if it was selected in-form.)
+ const prev = form.getFieldsValue(["jobid", "date", "employeeid", "flat_rate"]);
form.resetFields();
@@ -229,6 +238,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
}
/>
+
diff --git a/client/src/graphql/appointments.queries.js b/client/src/graphql/appointments.queries.js
index c13c6a374..51117f39f 100644
--- a/client/src/graphql/appointments.queries.js
+++ b/client/src/graphql/appointments.queries.js
@@ -40,6 +40,8 @@ export const QUERY_ALL_ACTIVE_APPOINTMENTS = gql`
ownr_fn
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
ownr_ea
clm_total
id
diff --git a/client/src/graphql/conversations.queries.js b/client/src/graphql/conversations.queries.js
index 598be73dd..fa308a3ff 100644
--- a/client/src/graphql/conversations.queries.js
+++ b/client/src/graphql/conversations.queries.js
@@ -2,7 +2,27 @@ import { gql } from "@apollo/client";
export const UNREAD_CONVERSATION_COUNT = gql`
query UNREAD_CONVERSATION_COUNT {
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ # How many conversations have at least one unread inbound, non-system message
+ conversations_aggregate(
+ where: {
+ archived: { _eq: false }
+ messages: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }
+ }
+ ) {
+ aggregate {
+ count
+ }
+ }
+
+ # How many unread inbound, non-system messages exist (excluding archived conversations)
+ messages_aggregate(
+ where: {
+ read: { _eq: false }
+ isoutbound: { _eq: false }
+ is_system: { _eq: false }
+ conversation: { archived: { _eq: false } }
+ }
+ ) {
aggregate {
count
}
@@ -19,7 +39,7 @@ export const CONVERSATION_LIST_QUERY = gql`
unreadcnt
archived
label
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
@@ -41,6 +61,7 @@ export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) {
messages(order_by: { created_at: asc_nulls_first }, where: { conversationid: { _eq: $conversationId } }) {
id
+ conversationid
status
text
is_system
@@ -76,6 +97,7 @@ export const GET_CONVERSATION_DETAILS = gql`
}
messages(order_by: { created_at: asc_nulls_first }) {
id
+ conversationid
status
text
is_system
@@ -110,7 +132,7 @@ export const CONVERSATION_ID_BY_PHONE = gql`
ro_number
}
}
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
@@ -139,7 +161,7 @@ export const CREATE_CONVERSATION = gql`
ro_number
}
}
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js
index 35dde7016..2d7169d23 100644
--- a/client/src/graphql/jobs.queries.js
+++ b/client/src/graphql/jobs.queries.js
@@ -19,7 +19,9 @@ export const QUERY_ALL_ACTIVE_JOBS_PAGINATED = gql`
ownr_ln
ownr_co_nm
ownr_ph1
+ ownr_ph1_ty
ownr_ph2
+ ownr_ph2_ty
ownr_ea
ownerid
comment
@@ -69,6 +71,8 @@ export const QUERY_ALL_ACTIVE_JOBS = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
ownr_ea
ownerid
comment
@@ -122,6 +126,8 @@ export const QUERY_PARTS_QUEUE = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
ownr_ea
plate_no
plate_st
@@ -179,6 +185,8 @@ export const QUERY_EXACT_JOB_IN_PRODUCTION = gql`
clm_total
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
special_coverage_policy
owner_owing
production_vars
@@ -249,6 +257,8 @@ export const QUERY_EXACT_JOBS_IN_PRODUCTION = gql`
clm_total
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
special_coverage_policy
owner_owing
production_vars
@@ -615,6 +625,8 @@ export const GET_JOB_BY_PK = gql`
ownr_ln
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
ownr_st
ownr_zip
tax_number
@@ -631,6 +643,8 @@ export const GET_JOB_BY_PK = gql`
ownr_ln
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
ownr_st
ownr_zip
parts_tax_rates
@@ -714,7 +728,7 @@ export const GET_JOB_BY_PK = gql`
v_model_yr
v_model_desc
v_vin
- notes(where:{pinned: {_eq: true}}, order_by: {updated_at: desc}) {
+ notes(where: { pinned: { _eq: true } }, order_by: { updated_at: desc }) {
created_at
created_by
critical
@@ -830,6 +844,8 @@ export const QUERY_JOB_CARD_DETAILS = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
comment
ownr_ea
ca_gst_registrant
@@ -1000,7 +1016,6 @@ export const QUERY_JOB_CARD_DETAILS = gql`
key
type
}
-
}
}
`;
@@ -1096,6 +1111,7 @@ export const UPDATE_JOB = gql`
scheduled_completion
scheduled_delivery
actual_in
+ actual_completion
date_repairstarted
date_void
date_lost_sale
@@ -1230,6 +1246,8 @@ export const GET_JOB_INFO_FOR_STRIPE = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
ownr_ea
}
}
@@ -1443,8 +1461,10 @@ export const QUERY_JOB_FOR_DUPE = gql`
ownr_ln
ownr_ph1
ownr_ph1x
+ ownr_ph1_ty
ownr_ph2
ownr_ph2x
+ ownr_ph2_ty
ownr_st
ownr_title
ownr_zip
@@ -1690,8 +1710,10 @@ export const QUERY_ALL_JOB_FIELDS = gql`
ownr_ln
ownr_ph1
ownr_ph1x
+ ownr_ph1_ty
ownr_ph2
ownr_ph2x
+ ownr_ph2_ty
ownr_st
ownr_title
ownr_zip
@@ -1829,6 +1851,8 @@ export const QUERY_ALL_JOBS_PAGINATED_STATUS_FILTERED = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
plate_no
plate_st
v_vin
@@ -1869,6 +1893,8 @@ export const QUERY_SIMPLIFIED_PARTS_PAGINATED_STATUS_FILTERED = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
plate_no
plate_st
v_vin
@@ -2117,6 +2143,8 @@ export const GET_JOB_FOR_CC_CONTRACT = gql`
ownr_zip
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
}
}
`;
@@ -2415,7 +2443,7 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
start
status
}
- notes(where:{pinned: {_eq: true}}, order_by: {updated_at: desc}) {
+ notes(where: { pinned: { _eq: true } }, order_by: { updated_at: desc }) {
created_at
created_by
critical
@@ -2503,6 +2531,8 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
ownr_ln
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
owner {
id
preferred_contact
@@ -2592,6 +2622,7 @@ export const QUERY_JOBS_IN_PRODUCTION = gql`
vehicleid
plate_no
actual_in
+ actual_completion
scheduled_completion
scheduled_delivery
date_last_contacted
@@ -2600,6 +2631,8 @@ export const QUERY_JOBS_IN_PRODUCTION = gql`
clm_total
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
special_coverage_policy
owner_owing
production_vars
diff --git a/client/src/graphql/owners.queries.js b/client/src/graphql/owners.queries.js
index de1b654b7..37389bada 100644
--- a/client/src/graphql/owners.queries.js
+++ b/client/src/graphql/owners.queries.js
@@ -8,6 +8,8 @@ export const QUERY_SEARCH_OWNER_BY_IDX = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
ownr_addr1
ownr_addr2
ownr_city
@@ -57,8 +59,10 @@ export const QUERY_OWNER_BY_ID = gql`
ownr_ea
ownr_fn
ownr_ph1
+ ownr_ph1_ty
ownr_ln
ownr_ph2
+ ownr_ph2_ty
ownr_st
ownr_title
ownr_zip
@@ -112,8 +116,10 @@ export const QUERY_ALL_OWNERS = gql`
ownr_ea
ownr_fn
ownr_ph1
+ ownr_ph1_ty
ownr_ln
ownr_ph2
+ ownr_ph2_ty
ownr_st
ownr_title
ownr_zip
@@ -136,8 +142,10 @@ export const QUERY_ALL_OWNERS_PAGINATED = gql`
ownr_ea
ownr_fn
ownr_ph1
+ ownr_ph1_ty
ownr_ln
ownr_ph2
+ ownr_ph2_ty
ownr_st
ownr_title
ownr_zip
@@ -164,8 +172,10 @@ export const QUERY_OWNER_FOR_JOB_CREATION = gql`
ownr_ea
ownr_fn
ownr_ph1
+ ownr_ph1_ty
ownr_ln
ownr_ph2
+ ownr_ph2_ty
ownr_st
ownr_title
ownr_zip
diff --git a/client/src/graphql/parts-orders.queries.js b/client/src/graphql/parts-orders.queries.js
index de51fab3d..de4f07cee 100644
--- a/client/src/graphql/parts-orders.queries.js
+++ b/client/src/graphql/parts-orders.queries.js
@@ -177,10 +177,12 @@ export const QUERY_PARTS_ORDER_OEC = gql`
ownr_fax
ownr_faxx
ownr_ph1
+ ownr_ph1_ty
ownr_fn
ownr_ln
ownr_ph1x
ownr_ph2
+ ownr_ph2_ty
ownr_ph2x
ownr_st
ownr_title
diff --git a/client/src/graphql/search.queries.js b/client/src/graphql/search.queries.js
index 1c1943e30..ba91fc8af 100644
--- a/client/src/graphql/search.queries.js
+++ b/client/src/graphql/search.queries.js
@@ -22,6 +22,8 @@ export const GLOBAL_SEARCH_QUERY = gql`
ownr_co_nm
ownr_ph1
ownr_ph2
+ ownr_ph1_ty
+ ownr_ph2_ty
}
search_vehicles(args: { search: $search }, limit: 25) {
id
diff --git a/client/src/pages/jobs-create/jobs-create.container.jsx b/client/src/pages/jobs-create/jobs-create.container.jsx
index 85f2b3ffe..cc3aa888d 100644
--- a/client/src/pages/jobs-create/jobs-create.container.jsx
+++ b/client/src/pages/jobs-create/jobs-create.container.jsx
@@ -9,21 +9,23 @@ 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 { selectBodyshop } from "../../redux/user/user.selectors";
+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";
+
const mapStateToProps = createStructuredSelector({
- bodyshop: selectBodyshop
+ bodyshop: selectBodyshop,
+ currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
});
-function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
+function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
const { t } = useTranslation();
const notification = useNotification();
@@ -45,7 +47,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
const [form] = Form.useForm();
const [state, setState] = contextState;
const [insertJob] = useMutation(INSERT_NEW_JOB);
- const [loadOwner, RemoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION);
+ const [loadOwner, remoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION);
useEffect(() => {
if (state.owner.selectedid) {
@@ -74,7 +76,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
}, [t, setBreadcrumbs, setSelectedHeader]);
const runInsertJob = (job) => {
- insertJob({ variables: { job: job } })
+ insertJob({ variables: { job } })
.then((resp) => {
setState({
...state,
@@ -114,15 +116,20 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
let ownerData;
if (!job.ownerid) {
- ownerData = job.owner.data;
- ownerData.shopid = bodyshop.id;
+ // Keep preferred_contact for the nested owner insert...
+ job.owner.data.shopid = bodyshop.id;
+
+ // ...but do NOT flatten preferred_contact into the job row.
+ ownerData = _.cloneDeep(job.owner.data);
delete ownerData.preferred_contact;
+
delete job.ownerid;
} else {
- ownerData = _.cloneDeep(RemoteOwnerData.data.owners_by_pk);
+ ownerData = _.cloneDeep(remoteOwnerData.data.owners_by_pk);
delete ownerData.id;
delete ownerData.__typename;
}
+
if (!state.vehicle.none) {
if (!job.vehicleid) {
delete job.vehicleid;
@@ -150,6 +157,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
if (job.owner === null) delete job.owner;
if (job.vehicle === null) delete job.vehicle;
+ // Associate to the current user if one exists
+ if (currentUser?.email) {
+ job.created_user_email = currentUser.email;
+ }
+
runInsertJob(job);
};
diff --git a/client/src/redux/user/user.sagas.js b/client/src/redux/user/user.sagas.js
index 12533865c..37e19897f 100644
--- a/client/src/redux/user/user.sagas.js
+++ b/client/src/redux/user/user.sagas.js
@@ -239,22 +239,13 @@ export function* signInSuccessSaga({ payload }) {
try {
window.$crisp.push(["set", "user:nickname", [payload.displayName || payload.email]]);
- const currentUserSegment = InstanceRenderManager({
- imex: "imex-online-user",
- rome: "rome-online-user"
- });
- window.$crisp.push(["set", "session:segments", [[currentUserSegment]]]);
InstanceRenderManager({
executeFunction: true,
args: [],
- imex: () => {
- window.$crisp.push(["set", "session:segments", [["imex"]]]);
- },
rome: () => {
window.$zoho.salesiq.visitor.name(payload.displayName || payload.email);
window.$zoho.salesiq.visitor.email(payload.email);
- window.$crisp.push(["set", "session:segments", [["rome"]]]);
}
});
@@ -262,11 +253,13 @@ export function* signInSuccessSaga({ payload }) {
try {
const state = yield select();
const isParts = state?.application?.isPartsEntry === true;
- const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" });
+ const instanceSeg = InstanceRenderManager({
+ imex: ["imex-online-user", "imex"],
+ rome: ["rome-online-user", "rome"]
+ });
// Always ensure segments include instance + user, and append partsManagement if applicable
const segs = [
- currentUserSegment,
- instanceSeg,
+ ...instanceSeg,
...(isParts
? [
InstanceRenderManager({
@@ -373,7 +366,10 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
// Build consolidated Crisp segments including instance, region, features, and parts mode
const isParts = yield select((state) => state.application.isPartsEntry === true);
- const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" });
+ const instanceSeg = InstanceRenderManager({
+ imex: ["imex-online-user", "imex"],
+ rome: ["rome-online-user", "rome"]
+ });
const featureSegments =
payload.features?.allAccess === true
@@ -402,7 +398,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
featureSegments.push(...additionalSegments);
const regionSeg = payload.region_config ? `region:${payload.region_config}` : null;
- const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments];
+ const segments = [...instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments];
if (isParts) {
segments.push(InstanceRenderManager({ imex: "ImexPartsManagement", rome: "RomePartsManagement" }));
}
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index f79a1f166..e2ff97a7f 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -277,7 +277,8 @@
"errors": {
"creatingdefaultview": "Error creating default view.",
"loading": "Unable to load shop details. Please call technical support.",
- "saving": "Error encountered while saving. {{message}}"
+ "saving": "Error encountered while saving. {{message}}",
+ "duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique"
},
"fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -1270,6 +1271,7 @@
"vehicle": "Vehicle"
},
"labels": {
+ "selected": "Selected",
"settings": "Settings",
"actions": "Actions",
"areyousure": "Are you sure?",
@@ -1491,7 +1493,8 @@
"assign_team": "Assign Team",
"converttolabor": "Convert amount to Labor.",
"dispatchparts": "Dispatch Parts ({{count}})",
- "new": "New Line"
+ "new": "New Line",
+ "updatelocation": "Update Location"
},
"errors": {
"creating": "Error encountered while creating job line. {{message}}",
@@ -1572,7 +1575,8 @@
"ioucreated": "IOU",
"new": "New Line",
"nostatus": "No Status",
- "presets": "Jobline Presets"
+ "presets": "Jobline Presets",
+ "bulk_location_help": "This will set the same location on all selected lines."
},
"successes": {
"created": "Job line created successfully.",
@@ -2430,7 +2434,8 @@
"selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...",
- "unarchive": "Unarchive"
+ "unarchive": "Unarchive",
+ "mark_unread": "Mark as unread"
},
"render": {
"conversation_list": "Conversation List"
@@ -2590,7 +2595,14 @@
"fromclaim": "Current Claim",
"fromowner": "Historical Owner Record",
"relatedjobs": "Related Jobs",
- "updateowner": "Update Owner"
+ "updateowner": "Update Owner",
+ "work": "Work",
+ "home": "Home",
+ "cell": "Cell",
+ "other": "Other",
+ "email": "Email",
+ "phone": "Phone",
+ "sms": "SMS"
},
"successes": {
"delete": "Owner deleted successfully.",
@@ -2950,6 +2962,8 @@
"settings": "Error saving board settings: {{error}}"
},
"labels": {
+ "click_for_statuses": "Click to view parts statuses",
+ "partsreceived": "Parts Received",
"actual_in": "Actual In",
"addnewprofile": "Add New Profile",
"alert": "Alert",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 2a9a6229b..95416363e 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -277,7 +277,8 @@
"errors": {
"creatingdefaultview": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
- "saving": ""
+ "saving": "",
+ "duplicate_insurance_company": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1270,6 +1271,7 @@
"vehicle": ""
},
"labels": {
+ "selected": "",
"actions": "Comportamiento",
"settings": "",
"areyousure": "",
@@ -1491,7 +1493,8 @@
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
- "new": ""
+ "new": "",
+ "updatelocation": ""
},
"errors": {
"creating": "",
@@ -1572,7 +1575,8 @@
"ioucreated": "",
"new": "Nueva línea",
"nostatus": "",
- "presets": ""
+ "presets": "",
+ "bulk_location_help": ""
},
"successes": {
"created": "",
@@ -2430,7 +2434,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Enviar un mensaje...",
- "unarchive": ""
+ "unarchive": "",
+ "mark_unread": ""
},
"render": {
"conversation_list": ""
@@ -2590,7 +2595,14 @@
"fromclaim": "",
"fromowner": "",
"relatedjobs": "",
- "updateowner": ""
+ "updateowner": "",
+ "work": "",
+ "home": "",
+ "cell": "",
+ "other": "",
+ "email": "",
+ "phone": "",
+ "sms": ""
},
"successes": {
"delete": "",
@@ -2950,6 +2962,8 @@
"settings": ""
},
"labels": {
+ "click_for_statuses": "",
+ "partsreceived": "",
"actual_in": "",
"addnewprofile": "",
"alert": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index 939f7752e..999ef1865 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -277,7 +277,8 @@
"errors": {
"creatingdefaultview": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
- "saving": ""
+ "saving": "",
+ "duplicate_insurance_company": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1270,6 +1271,7 @@
"vehicle": ""
},
"labels": {
+ "selected": "",
"settings": "",
"actions": "actes",
"areyousure": "",
@@ -1491,7 +1493,8 @@
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
- "new": ""
+ "new": "",
+ "updatelocation": ""
},
"errors": {
"creating": "",
@@ -1572,7 +1575,8 @@
"ioucreated": "",
"new": "Nouvelle ligne",
"nostatus": "",
- "presets": ""
+ "presets": "",
+ "bulk_location_help": ""
},
"successes": {
"created": "",
@@ -2430,7 +2434,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Envoyer un message...",
- "unarchive": ""
+ "unarchive": "",
+ "mark_unread": ""
},
"render": {
"conversation_list": ""
@@ -2590,7 +2595,14 @@
"fromclaim": "",
"fromowner": "",
"relatedjobs": "",
- "updateowner": ""
+ "updateowner": "",
+ "work": "",
+ "home": "",
+ "cell": "",
+ "other": "",
+ "email": "",
+ "phone": "",
+ "sms": ""
},
"successes": {
"delete": "",
@@ -2950,6 +2962,8 @@
"settings": ""
},
"labels": {
+ "click_for_statuses": "",
+ "partsreceived": "",
"actual_in": "",
"addnewprofile": "",
"alert": "",
diff --git a/client/src/utils/GraphQLClient.js b/client/src/utils/GraphQLClient.js
index 82964d49b..6efc55cde 100644
--- a/client/src/utils/GraphQLClient.js
+++ b/client/src/utils/GraphQLClient.js
@@ -148,6 +148,26 @@ const cache = new InMemoryCache({
Query: {
fields: {
// Note: This is required because we switch from a read to an unread state with a toggle,
+ conversations: {
+ keyArgs: ["where", "order_by"], // keep separate caches for archived/unarchived + sort
+ merge(existing = [], incoming = [], { args, readField }) {
+ const offset = args?.offset ?? 0;
+ const merged = existing ? existing.slice(0) : [];
+
+ for (let i = 0; i < incoming.length; i++) {
+ merged[offset + i] = incoming[i];
+ }
+
+ // Deduplicate by id (important when you also upsert via sockets)
+ const seen = new Set();
+ return merged.filter((ref) => {
+ const id = readField("id", ref);
+ if (!id || seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ });
+ }
+ },
notifications: {
merge(existing = [], incoming = [], { readField }) {
// Create a map to deduplicate by __ref
diff --git a/client/src/utils/PhoneFormatter.jsx b/client/src/utils/PhoneFormatter.jsx
index 0e99308cc..90fc49e50 100644
--- a/client/src/utils/PhoneFormatter.jsx
+++ b/client/src/utils/PhoneFormatter.jsx
@@ -1,7 +1,23 @@
-//import NumberFormat from "react-number-format";
+import { Typography } from "antd";
import parsePhoneNumber from "libphonenumber-js";
-export default function PhoneNumberFormatter(props) {
- const p = parsePhoneNumber(props.children || "", "CA");
- return p ? {p.formatNational()} : null;
+const { Text } = Typography;
+
+export default function PhoneNumberFormatter({ children, type }) {
+ const p = parsePhoneNumber(children || "", "CA");
+ if (!p) return null;
+
+ const phone = p.formatNational();
+
+ return (
+
+ {phone}
+ {type ? (
+ <>
+ {" "}
+ ({type})
+ >
+ ) : null}
+
+ );
}
diff --git a/client/src/utils/phoneTypeOptions.js b/client/src/utils/phoneTypeOptions.js
new file mode 100644
index 000000000..d1f19ac48
--- /dev/null
+++ b/client/src/utils/phoneTypeOptions.js
@@ -0,0 +1,13 @@
+export const OWNER_PHONE_TYPE_VALUES = {
+ HOME: "Home",
+ WORK: "Work",
+ CELL: "Cell",
+ OTHER: "Other"
+};
+
+export const buildOwnerPhoneTypeOptions = (t) => [
+ { label: t("owners.labels.home"), value: OWNER_PHONE_TYPE_VALUES.HOME },
+ { label: t("owners.labels.work"), value: OWNER_PHONE_TYPE_VALUES.WORK },
+ { label: t("owners.labels.cell"), value: OWNER_PHONE_TYPE_VALUES.CELL },
+ { label: t("owners.labels.other"), value: OWNER_PHONE_TYPE_VALUES.OTHER }
+];
diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml
index 9900fa00b..7cf11c8de 100644
--- a/hasura/metadata/tables.yaml
+++ b/hasura/metadata/tables.yaml
@@ -3684,6 +3684,7 @@
- completed_tasks
- converted
- created_at
+ - created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -3798,8 +3799,10 @@
- ownr_fn
- ownr_ln
- ownr_ph1
+ - ownr_ph1_ty
- ownr_ph1x
- ownr_ph2
+ - ownr_ph2_ty
- ownr_ph2x
- ownr_st
- ownr_title
@@ -3961,6 +3964,7 @@
- completed_tasks
- converted
- created_at
+ - created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -4077,8 +4081,10 @@
- ownr_fn
- ownr_ln
- ownr_ph1
+ - ownr_ph1_ty
- ownr_ph1x
- ownr_ph2
+ - ownr_ph2_ty
- ownr_ph2x
- ownr_st
- ownr_title
@@ -4251,6 +4257,7 @@
- completed_tasks
- converted
- created_at
+ - created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -4367,8 +4374,10 @@
- ownr_fn
- ownr_ln
- ownr_ph1
+ - ownr_ph1_ty
- ownr_ph1x
- ownr_ph2
+ - ownr_ph2_ty
- ownr_ph2x
- ownr_st
- ownr_title
@@ -4641,7 +4650,7 @@
request_transform:
body:
action: transform
- template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
+ template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}},\r\n \"created_user_email\": {{$body.event.data.new?.created_user_email}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
method: POST
query_params: {}
template_engine: Kriti
@@ -5168,7 +5177,9 @@
- ownr_fn
- ownr_ln
- ownr_ph1
+ - ownr_ph1_ty
- ownr_ph2
+ - ownr_ph2_ty
- ownr_st
- ownr_title
- ownr_zip
@@ -5193,7 +5204,9 @@
- ownr_fn
- ownr_ln
- ownr_ph1
+ - ownr_ph1_ty
- ownr_ph2
+ - ownr_ph2_ty
- ownr_st
- ownr_title
- ownr_zip
@@ -5229,7 +5242,9 @@
- ownr_fn
- ownr_ln
- ownr_ph1
+ - ownr_ph1_ty
- ownr_ph2
+ - ownr_ph2_ty
- ownr_st
- ownr_title
- ownr_zip
diff --git a/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/down.sql b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/down.sql
new file mode 100644
index 000000000..ddb7199ff
--- /dev/null
+++ b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/down.sql
@@ -0,0 +1,4 @@
+-- Could not auto-generate a down migration.
+-- Please write an appropriate down migration for the SQL below:
+-- alter table "public"."jobs" add column "created_user_email" text
+-- null;
diff --git a/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/up.sql b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/up.sql
new file mode 100644
index 000000000..d8244715b
--- /dev/null
+++ b/hasura/migrations/1766427606596_alter_table_public_jobs_add_column_created_user_email/up.sql
@@ -0,0 +1,2 @@
+alter table "public"."jobs" add column "created_user_email" text
+ null;
diff --git a/hasura/migrations/1767133700390_alter_table_public_owners_add_column_ownr_ph1_ty/down.sql b/hasura/migrations/1767133700390_alter_table_public_owners_add_column_ownr_ph1_ty/down.sql
new file mode 100644
index 000000000..ca4186489
--- /dev/null
+++ b/hasura/migrations/1767133700390_alter_table_public_owners_add_column_ownr_ph1_ty/down.sql
@@ -0,0 +1,4 @@
+-- Could not auto-generate a down migration.
+-- Please write an appropriate down migration for the SQL below:
+-- alter table "public"."owners" add column "ownr_ph1_ty" text
+-- null;
diff --git a/hasura/migrations/1767133700390_alter_table_public_owners_add_column_ownr_ph1_ty/up.sql b/hasura/migrations/1767133700390_alter_table_public_owners_add_column_ownr_ph1_ty/up.sql
new file mode 100644
index 000000000..416ac0d25
--- /dev/null
+++ b/hasura/migrations/1767133700390_alter_table_public_owners_add_column_ownr_ph1_ty/up.sql
@@ -0,0 +1,2 @@
+alter table "public"."owners" add column "ownr_ph1_ty" text
+ null;
diff --git a/hasura/migrations/1767133729078_alter_table_public_owners_add_column_ownr_ph2_ty/down.sql b/hasura/migrations/1767133729078_alter_table_public_owners_add_column_ownr_ph2_ty/down.sql
new file mode 100644
index 000000000..21a718779
--- /dev/null
+++ b/hasura/migrations/1767133729078_alter_table_public_owners_add_column_ownr_ph2_ty/down.sql
@@ -0,0 +1,4 @@
+-- Could not auto-generate a down migration.
+-- Please write an appropriate down migration for the SQL below:
+-- alter table "public"."owners" add column "ownr_ph2_ty" text
+-- null;
diff --git a/hasura/migrations/1767133729078_alter_table_public_owners_add_column_ownr_ph2_ty/up.sql b/hasura/migrations/1767133729078_alter_table_public_owners_add_column_ownr_ph2_ty/up.sql
new file mode 100644
index 000000000..381f77c8d
--- /dev/null
+++ b/hasura/migrations/1767133729078_alter_table_public_owners_add_column_ownr_ph2_ty/up.sql
@@ -0,0 +1,2 @@
+alter table "public"."owners" add column "ownr_ph2_ty" text
+ null;
diff --git a/hasura/migrations/1767138645058_alter_table_public_jobs_add_column_ownr_ph1_ty/down.sql b/hasura/migrations/1767138645058_alter_table_public_jobs_add_column_ownr_ph1_ty/down.sql
new file mode 100644
index 000000000..60e1189bd
--- /dev/null
+++ b/hasura/migrations/1767138645058_alter_table_public_jobs_add_column_ownr_ph1_ty/down.sql
@@ -0,0 +1,4 @@
+-- Could not auto-generate a down migration.
+-- Please write an appropriate down migration for the SQL below:
+-- alter table "public"."jobs" add column "ownr_ph1_ty" text
+-- null;
diff --git a/hasura/migrations/1767138645058_alter_table_public_jobs_add_column_ownr_ph1_ty/up.sql b/hasura/migrations/1767138645058_alter_table_public_jobs_add_column_ownr_ph1_ty/up.sql
new file mode 100644
index 000000000..3d1c0e5ea
--- /dev/null
+++ b/hasura/migrations/1767138645058_alter_table_public_jobs_add_column_ownr_ph1_ty/up.sql
@@ -0,0 +1,2 @@
+alter table "public"."jobs" add column "ownr_ph1_ty" text
+ null;
diff --git a/hasura/migrations/1767138667991_alter_table_public_jobs_add_column_ownr_ph2_ty/down.sql b/hasura/migrations/1767138667991_alter_table_public_jobs_add_column_ownr_ph2_ty/down.sql
new file mode 100644
index 000000000..b16896fbb
--- /dev/null
+++ b/hasura/migrations/1767138667991_alter_table_public_jobs_add_column_ownr_ph2_ty/down.sql
@@ -0,0 +1,4 @@
+-- Could not auto-generate a down migration.
+-- Please write an appropriate down migration for the SQL below:
+-- alter table "public"."jobs" add column "ownr_ph2_ty" text
+-- null;
diff --git a/hasura/migrations/1767138667991_alter_table_public_jobs_add_column_ownr_ph2_ty/up.sql b/hasura/migrations/1767138667991_alter_table_public_jobs_add_column_ownr_ph2_ty/up.sql
new file mode 100644
index 000000000..f513a8a1c
--- /dev/null
+++ b/hasura/migrations/1767138667991_alter_table_public_jobs_add_column_ownr_ph2_ty/up.sql
@@ -0,0 +1,2 @@
+alter table "public"."jobs" add column "ownr_ph2_ty" text
+ null;
diff --git a/server.js b/server.js
index 4ae883717..ad829d67c 100644
--- a/server.js
+++ b/server.js
@@ -38,6 +38,7 @@ const { registerCleanupTask, initializeCleanupManager } = require("./server/util
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
+const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue");
const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000;
@@ -355,9 +356,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
// Assuming loadEmailQueue and loadAppQueue return Promises
- const [notificationsEmailsQueue, notificationsAppQueue] = await Promise.all([
+ const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([
loadEmailQueue(queueSettings),
- loadAppQueue(queueSettings)
+ loadAppQueue(queueSettings),
+ loadFcmQueue(queueSettings)
]);
// Add error listeners or other setup for queues if needed
@@ -368,6 +370,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
notificationsAppQueue.on("error", (error) => {
logger.log(`Error in notificationsAppQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
+
+ notificationsFcmQueue.on("error", (error) => {
+ logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
+ });
};
/**
diff --git a/server/accounting/qbo/qbo-payables.js b/server/accounting/qbo/qbo-payables.js
index a21e8ebc0..2532fb5fc 100644
--- a/server/accounting/qbo/qbo-payables.js
+++ b/server/accounting/qbo/qbo-payables.js
@@ -283,6 +283,11 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
VendorRef: {
value: vendor.Id
},
+ ...(vendor.TermRef && {
+ SalesTermRef: {
+ value: vendor.TermRef.value
+ }
+ }),
TxnDate: moment(bill.date)
//.tz(bill.job.bodyshop.timezone)
.format("YYYY-MM-DD"),
diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js
index b51c73c49..e4621f984 100644
--- a/server/graphql-client/queries.js
+++ b/server/graphql-client/queries.js
@@ -123,7 +123,7 @@ mutation RECEIVE_MESSAGE($msg: [messages_insert_input!]!) {
exports.INSERT_MESSAGE = `
mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!) {
- update_conversations_by_pk(pk_columns: {id: $conversationid}, _set: {archived: false}) {
+ update_conversations_by_pk(pk_columns: { id: $conversationid }, _set: { archived: false }) {
id
archived
}
@@ -147,6 +147,7 @@ mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!)
image_path
image
isoutbound
+ is_system
msid
read
text
@@ -1506,6 +1507,7 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
est_ct_fn
shopid
est_ct_ln
+ ciecaid
cieca_pfl
cieca_pft
cieca_pfo
@@ -3089,17 +3091,19 @@ exports.INSERT_JOB_WATCHERS = `
`;
exports.GET_NOTIFICATION_WATCHERS = `
- query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!) {
+ query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!, $createdUserEmail: String!) {
associations(where: {
_and: [
{ shopid: { _eq: $shopId } },
{ active: { _eq: true } },
- { notifications_autoadd: { _eq: true } }
+ { notifications_autoadd: { _eq: true } },
+ { useremail: { _eq: $createdUserEmail } }
]
}) {
id
useremail
}
+
employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) {
user_email
}
@@ -3187,3 +3191,20 @@ mutation INSERT_MEDIA_ANALYTICS($mediaObject: media_analytics_insert_input!) {
}
}
`;
+
+exports.GET_USERS_FCM_TOKENS_BY_EMAILS = /* GraphQL */ `
+ query GET_USERS_FCM_TOKENS_BY_EMAILS($emails: [String!]!) {
+ users(where: { email: { _in: $emails } }) {
+ email
+ fcmtokens
+ }
+ }
+`;
+
+exports.UPDATE_USER_FCM_TOKENS_BY_EMAIL = /* GraphQL */ `
+ mutation UPDATE_USER_FCM_TOKENS_BY_EMAIL($email: String!, $fcmtokens: jsonb) {
+ update_users(where: { email: { _eq: $email } }, _set: { fcmtokens: $fcmtokens }) {
+ affected_rows
+ }
+ }
+`;
diff --git a/server/job/job-totals-USA.js b/server/job/job-totals-USA.js
index d1ff5abe6..6d5c7c8b4 100644
--- a/server/job/job-totals-USA.js
+++ b/server/job/job-totals-USA.js
@@ -381,7 +381,12 @@ async function CalculateRatesTotals({ job, client }) {
if (item.mod_lbr_ty) {
//Check to see if it has 0 hours and a price instead.
- if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0) && !IsAdditionalCost(item)) {
+ if (
+ item.lbr_op === "OP14" &&
+ item.act_price > 0 &&
+ (!item.part_type || item.mod_lb_hrs === 0) &&
+ !IsAdditionalCost(item)
+ ) {
//Scenario where SGI may pay out hours using a part price.
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
ret[item.mod_lbr_ty.toLowerCase()].base = Dinero();
@@ -943,13 +948,27 @@ function CalculateTaxesTotals(job, otherTotals) {
amount: Math.round(stlTowing.t_amt * 100)
})
);
- if (stlStorage)
+ if (!stlTowing && !job.ciecaid && job.towing_payable)
taxableAmounts.TOW = taxableAmounts.TOW.add(
- (taxableAmounts.TOW = Dinero({
+ Dinero({
+ amount: Math.round((job.towing_payable || 0) * 100)
+ })
+ );
+
+ if (stlStorage)
+ taxableAmounts.STOR = taxableAmounts.STOR.add(
+ (taxableAmounts.STOR = Dinero({
amount: Math.round(stlStorage.t_amt * 100)
}))
);
+ if (!stlStorage && !job.ciecaid && job.storage_payable)
+ taxableAmounts.STOR = taxableAmounts.STOR.add(
+ Dinero({
+ amount: Math.round((job.storage_payable || 0) * 100)
+ })
+ );
+
const pfp = job.parts_tax_rates;
//For any profile level markups/discounts, add them in now as well.
@@ -988,7 +1007,7 @@ function CalculateTaxesTotals(job, otherTotals) {
const pfo = job.cieca_pfo;
Object.keys(taxableAmounts).map((key) => {
try {
- if (key.startsWith("PA")) {
+ if (key.startsWith("PA") && key !== "PAE") {
const typeOfPart = key; // === "PAM" ? "PAC" : key;
//At least one of these scenarios must be taxable.
for (let tyCounter = 1; tyCounter <= 5; tyCounter++) {
diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js
index 85bc3393f..b6f480e5b 100644
--- a/server/media/imgproxy-media.js
+++ b/server/media/imgproxy-media.js
@@ -44,25 +44,25 @@ const generateSignedUploadUrls = async (req, res) => {
for (const filename of filenames) {
const key = filename;
const client = new S3Client({ region: InstanceRegion() });
-
+
// Check if filename indicates PDF and set content type accordingly
- const isPdf = filename.toLowerCase().endsWith('.pdf');
+ const isPdf = filename.toLowerCase().endsWith(".pdf");
const commandParams = {
Bucket: imgproxyDestinationBucket,
Key: key,
StorageClass: "INTELLIGENT_TIERING"
};
-
+
if (isPdf) {
commandParams.ContentType = "application/pdf";
}
-
+
const command = new PutObjectCommand(commandParams);
// For PDFs, we need to add conditions to the presigned URL to enforce content type
const presignedUrlOptions = { expiresIn: 360 };
if (isPdf) {
- presignedUrlOptions.signableHeaders = new Set(['content-type']);
+ presignedUrlOptions.signableHeaders = new Set(["content-type"]);
}
const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions);
@@ -265,6 +265,82 @@ const downloadFiles = async (req, res) => {
}
};
+/**
+ * Stream original image content by document ID
+ * @param req
+ * @param res
+ * @returns {Promise<*>}
+ */
+const getOriginalImageByDocumentId = async (req, res) => {
+ const {
+ body: { documentId },
+ user,
+ userGraphQLClient
+ } = req;
+
+ if (!documentId) {
+ return res.status(400).json({ message: "documentId is required" });
+ }
+
+ try {
+ logger.log("imgproxy-original-image", "DEBUG", user?.email, null, { documentId });
+
+ const { documents } = await userGraphQLClient.request(GET_DOCUMENTS_BY_IDS, { documentIds: [documentId] });
+
+ if (!documents || documents.length === 0) {
+ return res.status(404).json({ message: "Document not found" });
+ }
+
+ const [document] = documents;
+ const { type } = document;
+
+ if (!type || !type.startsWith("image")) {
+ return res.status(400).json({ message: "Document is not an image" });
+ }
+
+ const s3client = new S3Client({ region: InstanceRegion() });
+ const key = keyStandardize(document);
+
+ let s3Response;
+ try {
+ s3Response = await s3client.send(
+ new GetObjectCommand({
+ Bucket: imgproxyDestinationBucket,
+ Key: key
+ })
+ );
+ } catch (err) {
+ logger.log("imgproxy-original-image-s3-error", "ERROR", user?.email, null, {
+ key,
+ message: err.message,
+ stack: err.stack
+ });
+ return res.status(400).json({ message: "Unable to retrieve image" });
+ }
+
+ res.setHeader("Content-Type", type || "image/jpeg");
+
+ s3Response.Body.on("error", (err) => {
+ logger.log("imgproxy-original-image-s3stream-error", "ERROR", user?.email, null, {
+ key,
+ message: err.message,
+ stack: err.stack
+ });
+ res.destroy(err);
+ });
+
+ s3Response.Body.pipe(res);
+ } catch (error) {
+ logger.log("imgproxy-original-image-error", "ERROR", req.user?.email, null, {
+ documentId,
+ message: error.message,
+ stack: error.stack
+ });
+
+ return res.status(400).json({ message: error.message, stack: error.stack });
+ }
+};
+
/**
* Delete Files
* @param req
@@ -425,6 +501,7 @@ const keyStandardize = (doc) => {
module.exports = {
generateSignedUploadUrls,
getThumbnailUrls,
+ getOriginalImageByDocumentId,
downloadFiles,
deleteFiles,
moveFiles
diff --git a/server/notifications/autoAddWatchers.js b/server/notifications/autoAddWatchers.js
index 803c44984..0a0fa3e04 100644
--- a/server/notifications/autoAddWatchers.js
+++ b/server/notifications/autoAddWatchers.js
@@ -39,6 +39,7 @@ const autoAddWatchers = async (req) => {
const jobId = event?.data?.new?.id;
const shopId = event?.data?.new?.shopid;
const roNumber = event?.data?.new?.ro_number || "unknown";
+ const createdUserEmail = event?.data?.new?.created_user_email || "Unknown";
if (!jobId || !shopId) {
throw new Error(`Missing jobId (${jobId}) or shopId (${shopId}) for auto-add watchers`);
@@ -61,7 +62,8 @@ const autoAddWatchers = async (req) => {
const [notificationData, existingWatchersData] = await Promise.all([
gqlClient.request(GET_NOTIFICATION_WATCHERS, {
shopId,
- employeeIds: notificationFollowers
+ employeeIds: notificationFollowers,
+ createdUserEmail
}),
gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId })
]);
diff --git a/server/notifications/eventHandlers.js b/server/notifications/eventHandlers.js
index 87a1dceed..178f41ae2 100644
--- a/server/notifications/eventHandlers.js
+++ b/server/notifications/eventHandlers.js
@@ -205,9 +205,8 @@ const handleTaskSocketEmit = (req) => {
* @returns {Promise