Compare commits
9 Commits
feature/IO
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2015e88a27 | ||
|
|
27c6a6e768 | ||
|
|
6c9dd969e5 | ||
|
|
344b6114f4 | ||
|
|
45e0f61f06 | ||
|
|
a906bc5816 | ||
|
|
9b62633ba6 | ||
|
|
a1a608b8cc | ||
|
|
febabc56f0 |
@@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
||||
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -27,32 +27,52 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
||||
const { t } = useTranslation();
|
||||
const [pollInterval, setPollInterval] = useState(0);
|
||||
const { socket } = useSocket();
|
||||
const client = useApolloClient(); // Apollo Client instance for cache operations
|
||||
const client = useApolloClient();
|
||||
|
||||
// Lazy query for conversations
|
||||
const [getConversations, { loading, data, refetch }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
|
||||
// When socket is connected, we do NOT poll (socket should push updates).
|
||||
// When disconnected, we poll as a fallback.
|
||||
const [pollInterval, setPollInterval] = useState(0);
|
||||
|
||||
// Ensure conversations query runs once on initial page load (component mount).
|
||||
const hasLoadedConversationsOnceRef = useRef(false);
|
||||
|
||||
// Preserve the last known unread aggregate count so the badge doesn't "vanish"
|
||||
// when UNREAD_CONVERSATION_COUNT gets skipped after socket connects.
|
||||
const [unreadAggregateCount, setUnreadAggregateCount] = useState(0);
|
||||
|
||||
// Lazy query for conversations (executed manually)
|
||||
const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: !chatVisible,
|
||||
notifyOnNetworkStatusChange: true,
|
||||
...(pollInterval > 0 ? { pollInterval } : {})
|
||||
});
|
||||
|
||||
// Query for unread count when chat is not visible
|
||||
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
|
||||
// Query for unread count when chat is not visible and socket is not connected.
|
||||
// (Once socket connects, we stop this query; we keep the last known value in state.)
|
||||
useQuery(UNREAD_CONVERSATION_COUNT, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
pollInterval: 60 * 1000 // TODO: This is a fix for now, should be coming from sockets
|
||||
skip: chatVisible || socket?.connected,
|
||||
pollInterval: socket?.connected ? 0 : 60 * 1000,
|
||||
onCompleted: (result) => {
|
||||
const nextCount = result?.messages_aggregate?.aggregate?.count;
|
||||
if (typeof nextCount === "number") setUnreadAggregateCount(nextCount);
|
||||
},
|
||||
onError: (err) => {
|
||||
// Keep last known count; do not force badge to zero on transient failures
|
||||
console.warn("UNREAD_CONVERSATION_COUNT failed:", err?.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
// Socket connection status
|
||||
// Socket connection status -> polling strategy for CONVERSATION_LIST_QUERY
|
||||
useEffect(() => {
|
||||
const handleSocketStatus = () => {
|
||||
if (socket?.connected) {
|
||||
setPollInterval(15 * 60 * 1000); // 15 minutes
|
||||
setPollInterval(0); // skip polling if socket connected
|
||||
} else {
|
||||
setPollInterval(60 * 1000); // 60 seconds
|
||||
setPollInterval(60 * 1000); // fallback polling if disconnected
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,19 +91,32 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Fetch conversations when chat becomes visible
|
||||
// Run conversations query exactly once on initial load (component mount)
|
||||
useEffect(() => {
|
||||
if (chatVisible)
|
||||
getConversations({
|
||||
variables: {
|
||||
offset: 0
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(`Error fetching conversations: ${(err, err.message || "")}`);
|
||||
});
|
||||
}, [chatVisible, getConversations]);
|
||||
if (hasLoadedConversationsOnceRef.current) return;
|
||||
|
||||
// Get unread count from the cache
|
||||
hasLoadedConversationsOnceRef.current = true;
|
||||
|
||||
getConversations({
|
||||
variables: { offset: 0 }
|
||||
}).catch((err) => {
|
||||
console.error(`Error fetching conversations: ${err?.message || ""}`, err);
|
||||
});
|
||||
}, [getConversations]);
|
||||
|
||||
const handleManualRefresh = async () => {
|
||||
try {
|
||||
if (called && typeof refetch === "function") {
|
||||
await refetch({ offset: 0 });
|
||||
} else {
|
||||
await getConversations({ variables: { offset: 0 } });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error refreshing conversations: ${err?.message || ""}`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unread count from the cache (preferred). Fallback to preserved aggregate count.
|
||||
const unreadCount = (() => {
|
||||
try {
|
||||
const cachedData = client.readQuery({
|
||||
@@ -91,18 +124,23 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
|
||||
if (!cachedData?.conversations) {
|
||||
return unreadData?.messages_aggregate?.aggregate?.count;
|
||||
const conversations = cachedData?.conversations;
|
||||
|
||||
if (!Array.isArray(conversations) || conversations.length === 0) {
|
||||
return unreadAggregateCount;
|
||||
}
|
||||
|
||||
// Aggregate unread message count
|
||||
return cachedData.conversations.reduce((total, conversation) => {
|
||||
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
|
||||
const hasUnreadCounts = conversations.some((c) => c?.messages_aggregate?.aggregate?.count != null);
|
||||
if (!hasUnreadCounts) {
|
||||
return unreadAggregateCount;
|
||||
}
|
||||
|
||||
return conversations.reduce((total, conversation) => {
|
||||
const unread = conversation?.messages_aggregate?.aggregate?.count || 0;
|
||||
return total + unread;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.warn("Unread count not found in cache:", error);
|
||||
return 0; // Fallback if not in cache
|
||||
} catch {
|
||||
return unreadAggregateCount;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -117,9 +155,12 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
<Tooltip title={t("messaging.labels.recentonly")}>
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
|
||||
|
||||
<SyncOutlined style={{ cursor: "pointer" }} onClick={handleManualRefresh} />
|
||||
|
||||
{!socket?.connected && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
|
||||
</Space>
|
||||
|
||||
<ShrinkOutlined
|
||||
onClick={() => toggleChatVisible()}
|
||||
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Carousel } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import { GenerateThumbUrl } from "../jobs-documents-gallery/job-documents.utility";
|
||||
import { fetchImgproxyThumbnails } from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component";
|
||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||
import CardTemplate from "./job-detail-cards.template.component";
|
||||
|
||||
export default function JobDetailCardsDocumentsComponent({ loading, data, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||
const [thumbnails, setThumbnails] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.id) {
|
||||
fetchImgproxyThumbnails({ setStateCallback: setThumbnails, jobId: data.id, imagesOnly: true });
|
||||
}
|
||||
}, [data?.id]);
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
@@ -22,18 +30,19 @@ export default function JobDetailCardsDocumentsComponent({ loading, data, bodysh
|
||||
title={t("jobs.labels.cards.documents")}
|
||||
extraLink={`/manage/jobs/${data.id}?tab=documents`}
|
||||
>
|
||||
{!hasMediaAccess && (
|
||||
<UpsellComponent disableMask upsell={upsellEnum().media.general}>
|
||||
{data.documents.length > 0 ? (
|
||||
{!hasMediaAccess && <UpsellComponent disableMask upsell={upsellEnum().media.general} />}
|
||||
{hasMediaAccess && (
|
||||
<>
|
||||
{thumbnails.length > 0 ? (
|
||||
<Carousel autoplay>
|
||||
{data.documents.map((item) => (
|
||||
<img key={item.id} src={GenerateThumbUrl(item)} alt={item.name} />
|
||||
{thumbnails.map((item) => (
|
||||
<img key={item.id} src={item.src} alt={item.filename} />
|
||||
))}
|
||||
</Carousel>
|
||||
) : (
|
||||
<div>{t("documents.errors.nodocuments")}</div>
|
||||
)}
|
||||
</UpsellComponent>
|
||||
</>
|
||||
)}
|
||||
</CardTemplate>
|
||||
);
|
||||
|
||||
@@ -1,75 +1,24 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
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";
|
||||
|
||||
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]);
|
||||
await fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true });
|
||||
}, [jobId, setgalleryImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
setIsLoading(true);
|
||||
fetchThumbnails()
|
||||
.then(setRawMedia)
|
||||
.catch(console.error)
|
||||
.finally(() => setIsLoading(false));
|
||||
fetchThumbnails().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)));
|
||||
|
||||
@@ -199,7 +199,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
||||
{!bodyshop.uselocalmediaserver && (
|
||||
<>
|
||||
<div style={{ height: "8px" }} />
|
||||
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} />
|
||||
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1507,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
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user