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..884ac5ac3 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,112 @@ -import { useEffect } from "react"; -import { Gallery } from "react-grid-gallery"; -import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component"; +import { useEffect, useMemo, useState, useCallback } from "react"; +import axios from "axios"; +import { useTranslation } from "react-i18next"; +import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; +import LoadingSpinner from "../loading-spinner/loading-spinner.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 [rawMedia, setRawMedia] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); + + const fetchThumbnails = useCallback(async () => { + const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId }); + return result.data; + }, [jobId]); useEffect(() => { - if (jobId) fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true }); - }, [jobId, setgalleryImages]); + if (!jobId) return; + setIsLoading(true); + fetchThumbnails() + .then(setRawMedia) + .catch(console.error) + .finally(() => setIsLoading(false)); + }, [jobId, fetchThumbnails]); + + const documents = useMemo(() => { + return rawMedia + .filter((v) => v.type?.startsWith("image")) + .map((v) => ({ + src: v.thumbnailUrl, + thumbnail: v.thumbnailUrl, + fullsize: v.originalUrl, + width: 225, + height: 225, + thumbnailWidth: 225, + thumbnailHeight: 225, + caption: v.key, + filename: v.key, + // additional properties if needed + key: v.key, + id: v.id, + type: v.type, + size: v.size, + extension: v.extension + })); + }, [rawMedia]); + + useEffect(() => { + const prevSelection = new Map(galleryImages.map((p) => [p.filename, p.isSelected])); + const nextImages = documents.map((d) => ({ ...d, isSelected: prevSelection.get(d.filename) || false })); + // Micro-optimization: if array length and each filename + selection flag match, skip creating a new array. + if (galleryImages.length === nextImages.length) { + let identical = true; + for (let i = 0; i < nextImages.length; i++) { + if ( + galleryImages[i].filename !== nextImages[i].filename || + galleryImages[i].isSelected !== nextImages[i].isSelected + ) { + identical = false; + break; + } + } + if (identical) { + setIsLoading(false); // ensure loading stops even on no-change + return; + } + } + setgalleryImages(nextImages); + setIsLoading(false); // stop loading after transform regardless of emptiness + }, [documents, setgalleryImages, galleryImages, jobId]); + + 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 ( +
+
No job selected.
+
+ ); + } 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 da94678a8..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 { EditFilled, 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,54 +132,34 @@ 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} /> 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..f4c671f4f 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 @@ -6,15 +6,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; * Props: * - images: Array<{ src, fullsize, filename?, isSelected? }> * - onToggle(index) + * - onClick(index) optional for viewing */ export function LocalMediaGrid({ images, onToggle, + onClick, thumbSize = 100, gap = 8, minColumns = 3, maxColumns = 12, - context = "default" + context = "default", + expandHeight = false }) { const containerRef = useRef(null); const [cols, setCols] = useState(() => { @@ -114,8 +117,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 +133,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 +199,23 @@ export function LocalMediaGrid({ }} /> )} + {onClick && ( + { + e.stopPropagation(); + onToggle(idx); + }} + onClick={(e) => e.stopPropagation()} + style={{ + position: 'absolute', + top: 5, + right: 5, + zIndex: 2 + }} + /> + )}
))} {/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */} 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 f0ebb9826..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 { useTreatmentsWithConfig } 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 } - } = useTreatmentsWithConfig({ - 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" - }; - } - ]} - > - - - ] - : []), - ...(ADPPayroll.treatment === "on" - ? [ - - - - ] - : []) - ] - : []), - - - - ]} - null}> {[ + + - VIN - Claim No. - Deductible Amount - -); 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 5841e2cd7..4551a2f9b 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 { useTreatmentsWithConfig } 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"; @@ -15,6 +15,7 @@ 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 { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; +import DatePickerRanges from "../../utils/DatePickerRanges"; const SelectorDiv = styled.div` .ant-form-item .ant-select { @@ -35,10 +36,10 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { const hasDMSKey = bodyshopHasDmsKey(bodyshop); const { - treatments: { Qb_Multi_Ar, DmsAp } + treatments: { ClosingPeriod, ADPPayroll, Qb_Multi_Ar, DmsAp } } = useTreatmentsWithConfig({ attributes: {}, - names: ["Qb_Multi_Ar", "DmsAp"], + names: ["ClosingPeriod", "ADPPayroll", "Qb_Multi_Ar", "DmsAp"], splitKey: bodyshop?.imexshopid }); @@ -64,6 +65,14 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { setProfitOptions(getCenterNames(["md_responsibility_centers", "profits"])); }; + const ReceivableCustomFieldSelect = ( + + ); + return (
@@ -287,6 +296,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: ( + + + + ) + }), + + + , + + + + ] + : []), + + + , + + + , + ...(ClosingPeriod.treatment === "on" + ? [ + + + + ] + : []), + ...(ADPPayroll.treatment === "on" + ? [ + + + + ] + : []), + ...(ADPPayroll.treatment === "on" + ? [ + + + + ] + : []) + ] + : []), + + + + ]} + {HasFeatureAccess({ featureName: "export", bodyshop }) && ( <> 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"),