Compare commits

...

82 Commits

Author SHA1 Message Date
Dave
05414d9177 Revert "Merged in feature/IO-3484-Shop-Config-Account-Move (pull request #2769)"
This reverts commit 0f5dd02d75, reversing
changes made to 2eca085284.
2026-01-02 17:17:11 -05:00
Allan Carr
0f5dd02d75 Merged in feature/IO-3484-Shop-Config-Account-Move (pull request #2769)
IO-3484 Shop Config Acct Section Move

Approved-by: Dave Richer
2026-01-02 22:06:59 +00:00
Allan Carr
5310866302 IO-3484 Shop Config Acct Section Move
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-02 13:40:50 -08:00
Allan Carr
2eca085284 Merged in feature/IO-3484-Shop-Config-Account-Move (pull request #2767)
IO-3484 Shop Config Accounting Section Move

Approved-by: Dave Richer
2026-01-02 19:10:48 +00:00
Allan Carr
e3ab229ac5 IO-3484 Shop Config Accounting Section Move
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-31 15:39:21 -08:00
Allan Carr
955150ba97 Merged in feature/IO-3491-QBO-SALESTERMSREF (pull request #2764)
IO-3491 QBO SALESTERMSREF

Approved-by: Dave Richer
2025-12-31 17:27:56 +00:00
Allan Carr
a5f7ff3089 IO-3491 QBO SALESTERMSREF
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-31 09:25:12 -08:00
Dave Richer
864baebcdb Merged in feature/IO-3479-Customer-Phone-Numbers (pull request #2761)
Feature/IO-3479 Customer Phone Numbers
2025-12-31 00:15:48 +00:00
Dave
63ce7b5c79 feature/IO-3479-Customer-Phone-Numbers - Implementation 2025-12-30 19:14:57 -05:00
Dave
79a3b58a86 feature/IO-3479-Customer-Phone-Numbers - DB Modifications 2025-12-30 18:56:05 -05:00
Dave
45dd3d8cd6 feature/IO-3479-Customer-Phone-Numbers - DB Modifications 2025-12-30 17:31:20 -05:00
Dave Richer
931096f829 Merged in feature/IO-2566-2567-Time-Tickets-Caching (pull request #2758)
feature/IO-2566-2567-Time-Tickets-Caching - Fix
2025-12-30 22:06:53 +00:00
Dave
a70e3e26d0 feature/IO-2566-2567-Time-Tickets-Caching - Fix 2025-12-30 17:05:58 -05:00
Dave Richer
03c7781d59 Merged in feature/IO-3477-Set-Parts-Location (pull request #2755)
feature/IO-3477-Set-Parts-Location: Done
2025-12-30 20:11:24 +00:00
Dave
c68feef0b5 feature/IO-3477-Set-Parts-Location: Done 2025-12-30 15:10:47 -05:00
Dave Richer
e9dd155875 Merged in feature/IO-3478-Mark-Conversation-Unread (pull request #2752)
Feature/IO-3478 Mark Conversation Unread
2025-12-30 19:21:22 +00:00
Dave
5b11587380 feature/IO-3478-Mark-Conversation-Unread: Make Notifications more realtime 2025-12-30 14:20:26 -05:00
Dave
9dbe246575 feature/IO-3478-Mark-Conversation-Unread: Finished 2025-12-30 14:08:33 -05:00
Dave
657720cb10 Initial 2025-12-30 13:42:04 -05:00
Dave
2c7b328596 Initial 2025-12-30 13:41:26 -05:00
Dave Richer
21271004c5 Merged in feature/IO-1710-prevent-duplicate-ins-companies (pull request #2749)
feature/IO-1710-prevent-duplicate-ins-companies
2025-12-30 17:55:02 +00:00
Dave
155d0af509 feature/IO-1710-prevent-duplicate-ins-companies 2025-12-30 12:54:27 -05:00
Allan Carr
b92b92b2cd Merged in feature/IO-3480-Production-Actual-Completion (pull request #2746)
IO-3480 Production List Actual Completion

Approved-by: Dave Richer
2025-12-26 22:54:12 +00:00
Allan Carr
9627800277 IO-3480 Production List Actual Completion
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-26 14:52:47 -08:00
Dave Richer
edf65e4fd1 Merged in feature/IO-3401-Parts-Rec-Enhanced (pull request #2743)
feature/IO-3401-Parts-Rec-Enhanced - Implement
2025-12-23 16:28:00 +00:00
Dave
1ad7468d14 feature/IO-3401-Parts-Rec-Enhanced - Implement 2025-12-23 11:27:14 -05:00
Dave Richer
1582c2ed45 Merged in feature/IO-3487-Auto-Add-Profile-Watchers (pull request #2740)
feature/IO-3487-Auto-Add-Profile-Watchers - Fix Auto Add on a profile level
2025-12-22 19:19:33 +00:00
Dave
9b44dd844f feature/IO-3487-Auto-Add-Profile-Watchers - Fix Auto Add on a profile level 2025-12-22 14:18:13 -05:00
Dave
e22aa60a14 Merge remote-tracking branch 'origin/master-AIO' into release/2025-12-19 2025-12-19 15:14:42 -05:00
Dave Richer
4a7bb07345 Merged in hotfix/missing-dms-migration (pull request #2738)
hotfix/missing-dms-migration - Add Back DMS_ID
2025-12-19 20:13:11 +00:00
Dave
01fec9fa79 hotfix/missing-dms-migration - Add Back DMS_ID 2025-12-19 15:12:24 -05:00
Dave Richer
2f88d613c3 Merged in release/2025-12-19-mini (pull request #2732)
Release/2025 12 19 mini - IO-3467 IO-3468 IO-3402 IO-3473
2025-12-19 19:14:30 +00:00
Dave Richer
c9467b3982 Merged in feature/IO-3402-Import-Add-Notifiers (pull request #2734)
feature/IO-3402-Import-Add-Notifiers - Fix Normalize
2025-12-19 19:12:04 +00:00
Dave Richer
5be5f12fae Merged in feature/IO-3402-Import-Add-Notifiers (pull request #2733)
feature/IO-3402-Import-Add-Notifiers - Fix Normalize
2025-12-19 19:11:17 +00:00
Dave
ca1a456312 feature/IO-3402-Import-Add-Notifiers - Fix Normalize 2025-12-19 14:10:15 -05:00
Dave
ca4c48bd5c release/2025-12-19-mini - Merge 2025-12-19 12:41:23 -05:00
Dave Richer
e5fd5c8bcb Merged in feature/IO-3468-sentry-exceptions (pull request #2731)
Feature/IO-3468 sentry exceptions
2025-12-19 17:39:23 +00:00
Dave Richer
46945a24a7 Merged in feature/IO-3467-report-center-filter-ui (pull request #2730)
IO-3467 Resolve UI on report center filter.
2025-12-19 17:35:47 +00:00
Dave Richer
be746500a6 Merged in feature/IO-3402-Import-Add-Notifiers (pull request #2729)
Feature/IO-3402 Import Add Notifiers
2025-12-19 17:35:12 +00:00
Dave
40263791b8 IO-3473 trim user input 2025-12-19 12:10:12 -05:00
Dave
71c6d9fa94 IO-3473 trim user input 2025-12-19 12:07:30 -05:00
Dave Richer
86381adff9 Merged in feature/IO-3402-Import-Add-Notifiers (pull request #2722)
feature/IO-3402-Import-Add-Notifiers - Fix
2025-12-18 23:26:33 +00:00
Dave
c010665ea9 feature/IO-3402-Import-Add-Notifiers - Fix 2025-12-18 18:26:02 -05:00
Allan Carr
781b6c8df6 Merged in feature/IO-3464-S3-Image-Edit (pull request #2719)
IO-3464 Document Edit

Approved-by: Dave Richer
2025-12-18 22:15:27 +00:00
Allan Carr
dfe0afd4f3 IO-3464 Document Edit
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-18 11:22:28 -08:00
Dave Richer
c3b976f6d3 Merged in feature/IO-3468-sentry-exceptions (pull request #2716)
feature/IO-3468-Senty-Exceptions - Fix unused import
2025-12-18 18:53:25 +00:00
Dave
6d94ce7e5c feature/IO-3468-Senty-Exceptions - Fix unused import 2025-12-18 13:52:55 -05:00
Dave Richer
f65acdd660 Merged in feature/IO-3402-Import-Add-Notifiers (pull request #2713)
feature/IO-3402-Import-Add-Notifiers - Fix Auto Notifiers
2025-12-18 18:28:55 +00:00
Dave
d6fba12cd9 feature/IO-3402-Import-Add-Notifiers - Fix Auto Notifiers 2025-12-18 13:27:24 -05:00
Patrick Fic
0b24cb484a Merged in feature/IO-3468-sentry-exceptions (pull request #2710)
IO-3468 Add sentry exceptions & minor nul coalesce fixes.
2025-12-16 18:29:01 +00:00
Patrick Fic
182a8d59ab IO-3468 Add sentry exceptions & minor nul coalesce fixes. 2025-12-16 10:28:00 -08:00
Allan Carr
aa722a97f0 Merged in feature/IO-3464-S3-Image-Edit (pull request #2708)
IO-3464 Remove extra edit route

Approved-by: Dave Richer
2025-12-16 00:17:56 +00:00
Allan Carr
c675a328a8 IO-3464 Remove extra edit route
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-15 12:40:43 -08:00
Patrick Fic
e8bf687d58 Merged in feature/IO-3467-report-center-filter-ui (pull request #2707)
IO-3467 Resolve UI on report center filter.
2025-12-15 16:44:29 +00:00
Patrick Fic
f1847ef650 IO-3467 Resolve UI on report center filter. 2025-12-15 08:37:32 -08:00
Allan Carr
6d6fc9d552 Merged in feature/IO-3464-S3-Image-Edit (pull request #2704)
Feature/IO-3464 S3 Image Edit

Approved-by: Dave Richer
2025-12-15 14:09:01 +00:00
Allan Carr
6eb432b5b7 IO-3464 Local Media Edit Image
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-12 20:56:39 -08:00
Allan Carr
56d50b855b IO-3464 S3 Document Editor
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-12 19:39:02 -08:00
Allan Carr
6ea1c291e6 Merged in release/2025-12-19 (pull request #2703)
Release/2025 12 19
2025-12-12 02:36:57 +00:00
Allan Carr
05d5c96491 Merged in feature/IO-3462-Project-Mexico-Mod (pull request #2701)
IO-3462 Project Mexico Mod

Approved-by: Dave Richer
2025-12-10 20:18:14 +00:00
Allan Carr
35a566cbe5 IO-3462 Project Mexico Mod
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-10 09:52:04 -08:00
Dave Richer
f12e40e4c6 Merged in feature/IO-3461-Fix-EULA (pull request #2698)
feature/IO-3461-Fix-Eula
2025-12-09 22:15:22 +00:00
Dave
bb4e671c83 feature/IO-3461-Fix-Eula 2025-12-09 17:13:59 -05:00
Dave Richer
d1637d2432 Merged in release/2025-12-05 (pull request #2696)
Release/2025 12 05 into master-AIO - IO-3450 IO-3452 IO-3262 - IO-3456 IO-3262
2025-12-06 01:48:37 +00:00
Allan Carr
1c79628613 Merged in feature/IO-3262-Tech-Console-Job-Clock-Out (pull request #2692)
IO-3262 Correction for v_year in Project Mexico

Approved-by: Dave Richer
2025-12-05 19:00:38 +00:00
Allan Carr
521a7084b7 IO-3262 Correction for v_year in Project Mexico
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-05 09:48:32 -08:00
Dave Richer
77268d5f5b Merged in feature/IO-3457-job-lifecyle-tags (pull request #2689)
feature/IO-3457-Job-Lifecycle-Tags
2025-12-04 21:23:27 +00:00
Dave
1b3abf17ec feature/IO-3457-Job-Lifecycle-Tags 2025-12-04 16:22:41 -05:00
Dave Richer
3cfd445894 Merged in feature/IO-3456-Broken-Image-Path (pull request #2687)
feature/IO-3456-Broken-Image - Fix issue
2025-12-04 19:22:03 +00:00
Dave
b510eec9aa feature/IO-3456-Broken-Image - Fix issue 2025-12-04 14:20:58 -05:00
Allan Carr
e5eac0933f Merged in feature/IO-3262-Tech-Console-Job-Clock-Out (pull request #2685)
IO-3262 Add email address to Usage Report

Approved-by: Dave Richer
2025-12-03 19:04:08 +00:00
Allan Carr
a3c71fdfc0 IO-3262 Add email address to Usage Report
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-03 10:28:20 -08:00
Allan Carr
78750d3d96 Merged in feature/IO-3262-Tech-Console-Job-Clock-Out (pull request #2682)
IO-3262 Tech Console Job Clock Out

Approved-by: Dave Richer
2025-12-02 18:59:41 +00:00
Allan Carr
90edf94fee IO-3262 Tech Console Job Clock Out
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-01 12:50:11 -08:00
Allan Carr
065fb72677 Merged in feature/IO-3452-Documents-Adjustments (pull request #2678)
IO-3452 Documents Adjustments

Approved-by: Dave Richer
2025-11-28 15:49:40 +00:00
Allan Carr
ddc6141480 IO-3452 Documents Adjustments
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-27 13:15:26 -08:00
Allan Carr
7bc137fa79 Merged in feature/IO-3450-Additional-Crisp-Segments (pull request #2675)
Feature/IO-3450 Additional Crisp Segments

Approved-by: Dave Richer
2025-11-26 16:30:07 +00:00
Allan Carr
dafe9de753 IO-3450 Grammer Correction
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-25 18:10:10 -08:00
Allan Carr
78a8474a24 IO-3450 Additional Crisp Segments
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-25 17:56:19 -08:00
Dave Richer
123066f1cd Merged in release/2025-11-21 (pull request #2671)
Release/2025 11 21 into Master-AIO - IO-3435 IO-3445 IO-3440 IO-3446
2025-11-21 19:19:15 +00:00
Allan Carr
a153cca3c0 Merged in feature/IO-3440-Payment-By-Date-Excel (pull request #2669)
IO-3440 Payment By Date - Excel
2025-11-21 00:26:54 +00:00
Allan Carr
35c7c32c8e IO-3440 Payment By Date - Excel
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-20 16:29:05 -08:00
108 changed files with 2889 additions and 1384 deletions

View File

@@ -138,7 +138,7 @@ export function App({
); );
} }
if (currentEula && !currentUser.eulaIsAccepted) { if (!isPartsEntry && currentEula && !currentUser.eulaIsAccepted) {
return <Eula />; return <Eula />;
} }

View File

@@ -2,7 +2,6 @@ import { useApolloClient } from "@apollo/client";
import { getToken } from "@firebase/messaging"; import { getToken } from "@firebase/messaging";
import axios from "axios"; import axios from "axios";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { messaging, requestForToken } from "../../firebase/firebase.utils"; import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component"; import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss"; import "./chat-affix.styles.scss";
@@ -10,14 +9,14 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) { export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
const { t } = useTranslation();
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useSocket(); const { socket } = useSocket();
// 1) FCM subscription (independent of socket handler registration)
useEffect(() => { useEffect(() => {
if (!bodyshop?.messagingservicesid) return; if (!bodyshop?.messagingservicesid) return;
async function SubscribeToTopicForFCMNotification() { async function subscribeToTopicForFCMNotification() {
try { try {
await requestForToken(); await requestForToken();
await axios.post("/notifications/subscribe", { await axios.post("/notifications/subscribe", {
@@ -32,17 +31,35 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
} }
} }
SubscribeToTopicForFCMNotification(); subscribeToTopicForFCMNotification();
}, [bodyshop?.messagingservicesid, bodyshop?.imexshopid]);
// Register WebSocket handlers // 2) Register socket handlers as soon as socket is connected (regardless of chatVisible)
if (socket?.connected) { useEffect(() => {
registerMessagingHandlers({ socket, client, currentUser, bodyshop, t }); if (!socket) return;
if (!bodyshop?.messagingservicesid) return;
if (!bodyshop?.id) return;
return () => { // If socket isn't connected yet, ensure no stale handlers remain.
unregisterMessagingHandlers({ socket }); if (!socket.connected) {
}; unregisterMessagingHandlers({ socket });
return;
} }
}, [bodyshop, socket, t, client]);
// Prevent duplicate listeners if this effect runs more than once.
unregisterMessagingHandlers({ socket });
registerMessagingHandlers({
socket,
client,
currentUser,
bodyshop
});
return () => {
unregisterMessagingHandlers({ socket });
};
}, [socket, socket?.connected, bodyshop?.id, bodyshop?.messagingservicesid, client, currentUser?.email]);
if (!bodyshop?.messagingservicesid) return <></>; if (!bodyshop?.messagingservicesid) return <></>;

View File

@@ -13,68 +13,241 @@ const logLocal = (message, ...args) => {
} }
}; };
// Utility function to enrich conversation data const safeIsoNow = () => new Date().toISOString();
const enrichConversation = (conversation, isOutbound) => ({ const isSystemMsid = (msid) => typeof msid === "string" && msid.startsWith("SYS_");
const normalizeConversationForList = (raw, { isoutbound, isSystem } = {}) => {
const c = raw || {};
const id = c.id;
return {
__typename: "conversations",
id,
phone_num: c.phone_num ?? null,
updated_at: c.updated_at ?? safeIsoNow(),
unreadcnt: typeof c.unreadcnt === "number" ? c.unreadcnt : 0,
archived: c.archived ?? false,
label: c.label ?? null,
job_conversations: Array.isArray(c.job_conversations)
? c.job_conversations.map((jc) => {
const job = jc?.job || {};
const jobId = jc?.jobid ?? job?.id ?? null;
return {
__typename: "job_conversations",
jobid: jobId,
conversationid: jc?.conversationid ?? id ?? null,
job: {
__typename: "jobs",
id: jobId,
ro_number: job?.ro_number ?? null,
ownr_fn: job?.ownr_fn ?? null,
ownr_ln: job?.ownr_ln ?? null,
ownr_co_nm: job?.ownr_co_nm ?? null
}
};
})
: [],
messages_aggregate: c.messages_aggregate || {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: isoutbound || isSystem ? 0 : 1
}
}
};
};
const CONVERSATION_LIST_ITEM_FRAGMENT = gql`
fragment _ConversationListItem on conversations {
id
phone_num
updated_at
unreadcnt
archived
label
job_conversations {
jobid
conversationid
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
}
}
`;
const normalizeMessageForCache = (raw, fallbackConversationId) => {
const m = raw || {};
return {
__typename: "messages",
id: m.id,
conversationid: m.conversationid ?? m.conversation?.id ?? fallbackConversationId,
status: m.status ?? null,
text: m.text ?? "",
is_system: typeof m.is_system === "boolean" ? m.is_system : false,
isoutbound: typeof m.isoutbound === "boolean" ? m.isoutbound : false,
image: typeof m.image === "boolean" ? m.image : false,
image_path: m.image_path ?? null,
userid: m.userid ?? null,
created_at: m.created_at ?? safeIsoNow(),
read: typeof m.read === "boolean" ? m.read : false
};
};
const isConversationDetailsCached = (client, conversationId) => {
try {
return !!client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
})?.conversations_by_pk;
} catch {
return false;
}
};
const conversationDetailsCached = (client, conversationId) => {
try {
const res = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
});
return !!res?.conversations_by_pk;
} catch {
return false;
}
};
const messageEntityCached = (client, messageId) => {
const cacheId = client.cache.identify({ __typename: "messages", id: messageId });
if (!cacheId) return false;
try {
client.cache.readFragment({
id: cacheId,
fragment: gql`
fragment _MsgExists on messages {
id
}
`
});
return true;
} catch {
return false;
}
};
const enrichConversation = (conversation, { isoutbound, isSystem }) => ({
...conversation, ...conversation,
updated_at: conversation.updated_at || new Date().toISOString(), updated_at: conversation.updated_at || safeIsoNow(),
unreadcnt: conversation.unreadcnt || 0, unreadcnt: typeof conversation.unreadcnt === "number" ? conversation.unreadcnt : 0,
archived: conversation.archived || false, archived: conversation.archived ?? false,
label: conversation.label || null, label: conversation.label ?? null,
job_conversations: conversation.job_conversations || [], job_conversations: conversation.job_conversations || [],
messages_aggregate: conversation.messages_aggregate || { messages_aggregate: conversation.messages_aggregate || {
__typename: "messages_aggregate", __typename: "messages_aggregate",
aggregate: { aggregate: {
__typename: "messages_aggregate_fields", __typename: "messages_aggregate_fields",
count: isOutbound ? 0 : 1 count: isoutbound || isSystem ? 0 : 1
} }
}, },
__typename: "conversations" __typename: "conversations"
}); });
// Can be uncommonted to test the playback of the notification sound const upsertConversationIntoOffsetZeroList = (client, conversationObj, { isoutbound, isSystem } = {}) => {
// window.testTone = () => { const normalized = normalizeConversationForList(conversationObj, { isoutbound, isSystem });
// const notificationSound = new Audio(newMessageSound); if (!normalized?.id) return;
// notificationSound.play().catch((error) => {
// console.error("Error playing notification sound:", error); const convCacheId = client.cache.identify(normalized);
// }); if (!convCacheId) return;
// };
client.cache.writeFragment({
id: convCacheId,
fragment: CONVERSATION_LIST_ITEM_FRAGMENT,
data: normalized
});
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existing = [], { args, readField }) {
if (!args || args.offset !== 0) return existing;
const archivedEq = args?.where?.archived?._eq;
if (archivedEq === true) return existing;
const without = existing.filter((c) => readField("id", c) !== normalized.id);
return [{ __ref: convCacheId }, ...without];
}
}
});
};
export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => { export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => {
if (!(socket && client)) return; if (!(socket && client)) return;
const handleNewMessageSummary = async (message) => { // Coalesce unread refetches (avoid spamming during bursts)
const { conversationId, newConversation, existingConversation, isoutbound } = message; let unreadRefetchInFlight = null;
const refetchUnreadCount = () => {
if (unreadRefetchInFlight) return;
// True only when DB value is strictly true; falls back to true on cache miss unreadRefetchInFlight = client
const isNewMessageSoundEnabled = (client) => { .refetchQueries({
include: ["UNREAD_CONVERSATION_COUNT"]
})
.catch(() => {
// best-effort
})
.finally(() => {
unreadRefetchInFlight = null;
});
};
const handleNewMessageSummary = async (message) => {
const { conversationId, newConversation, existingConversation, isoutbound, msid, updated_at } = message;
const isSystem = isSystemMsid(msid);
const isNewMessageSoundEnabled = (clientInstance) => {
try { try {
const email = currentUser?.email; const email = currentUser?.email;
if (!email) return true; // default allow if we can't resolve user if (!email) return true;
const res = client.readQuery({ const res = clientInstance.readQuery({
query: QUERY_ACTIVE_ASSOCIATION_SOUND, query: QUERY_ACTIVE_ASSOCIATION_SOUND,
variables: { email } variables: { email }
}); });
const flag = res?.associations?.[0]?.new_message_sound; const flag = res?.associations?.[0]?.new_message_sound;
return flag === true; // strictly true => enabled return flag === true;
} catch { } catch {
// If the query hasn't been seeded in cache yet, default ON
return true; return true;
} }
}; };
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation }); logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
const queryVariables = { offset: 0 };
if (!isoutbound) { if (!isoutbound) {
// Play notification sound for new inbound message (scoped to bodyshop)
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) { if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
playNewMessageSound(bodyshop.id); playNewMessageSound(bodyshop.id);
} }
// Real-time badge update for affix (best-effort, coalesced)
if (!isSystem) {
refetchUnreadCount();
}
} }
if (!existingConversation && conversationId) { if (!existingConversation && conversationId) {
// Attempt to read from the cache to determine if this is actually a new conversation
try { try {
const cachedConversation = client.cache.readFragment({ const cachedConversation = client.cache.readFragment({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
@@ -86,75 +259,54 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
}); });
if (cachedConversation) { if (cachedConversation) {
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", { logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", { conversationId });
conversationId return handleNewMessageSummary({ ...message, existingConversation: true });
});
return handleNewMessageSummary({
...message,
existingConversation: true
});
} }
} catch { } catch {
logLocal("handleNewMessageSummary - Cache miss", { conversationId }); // Cache miss is normal
} }
} }
// Handle new conversation
if (!existingConversation && newConversation?.phone_num) { if (!existingConversation && newConversation?.phone_num) {
logLocal("handleNewMessageSummary - New Conversation", newConversation);
try { try {
const queryResults = client.cache.readQuery({ upsertConversationIntoOffsetZeroList(client, newConversation, { isoutbound, isSystem });
query: CONVERSATION_LIST_QUERY,
variables: queryVariables
});
const existingConversations = queryResults?.conversations || [];
const enrichedConversation = enrichConversation(newConversation, isoutbound);
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existingConversations = []) {
return [enrichedConversation, ...existingConversations];
}
}
});
}
} catch (error) { } catch (error) {
console.error("Error updating cache for new conversation:", error); console.error("Error updating cache for new conversation:", error);
} }
return; return;
} }
// Handle existing conversation if (existingConversation && conversationId) {
if (existingConversation) {
try { try {
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: { fields: {
updated_at: () => new Date().toISOString(), updated_at: () => updated_at || safeIsoNow(),
archived: () => false, archived: () => false,
messages_aggregate(cached = { aggregate: { count: 0 } }) {
const currentCount = cached.aggregate?.count || 0; messages_aggregate(cached = null) {
if (!isoutbound) { if (isoutbound || isSystem) return cached;
return {
__typename: "messages_aggregate", const currentCount = cached?.aggregate?.count ?? 0;
aggregate: { return {
__typename: "messages_aggregate_fields", __typename: "messages_aggregate",
count: currentCount + 1 aggregate: {
} __typename: "messages_aggregate_fields",
}; count: currentCount + 1
} }
return cached; };
},
unreadcnt(cached) {
if (isoutbound || isSystem) return cached;
const n = typeof cached === "number" ? cached : 0;
return n + 1;
} }
} }
}); });
} catch (error) { } catch (error) {
console.error("Error updating cache for existing conversation:", error); console.error("Error updating cache for existing conversation:", error);
} }
return; return;
} }
@@ -166,88 +318,78 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
logLocal("handleNewMessageDetailed - Start", message); logLocal("handleNewMessageDetailed - Start", message);
try { if (!conversationId || !isConversationDetailsCached(client, conversationId)) return;
// Check if the conversation exists in the cache
const queryResults = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
});
if (!queryResults?.conversations_by_pk) { try {
console.warn("Conversation not found in cache:", { conversationId }); const normalized = normalizeMessageForCache(newMessage, conversationId);
const messageCacheId = client.cache.identify(normalized);
if (!messageCacheId) {
console.warn("handleNewMessageDetailed - Could not identify message for cache", {
conversationId,
newMessageId: newMessage?.id
});
return; return;
} }
// Append the new message to the conversation's message list using cache.modify client.cache.writeFragment({
id: messageCacheId,
fragment: gql`
fragment _IncomingMessage on messages {
id
conversationid
status
text
is_system
isoutbound
image
image_path
userid
created_at
read
}
`,
data: normalized
});
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: { fields: {
messages(existingMessages = []) { messages(existing = [], { readField }) {
return [...existingMessages, newMessage]; const already = existing.some((ref) => readField("id", ref) === normalized.id);
if (already) return existing;
return [...existing, { __ref: messageCacheId }];
},
updated_at() {
return normalized.created_at || safeIsoNow();
} }
} }
}); });
logLocal("handleNewMessageDetailed - Message appended successfully", { logLocal("handleNewMessageDetailed - Message appended successfully", { conversationId });
conversationId,
newMessage
});
} catch (error) { } catch (error) {
console.error("Error updating conversation messages in cache:", error); console.error("Error updating conversation messages in cache:", error);
} }
}; };
const handleMessageChanged = (message) => { const handleMessageChanged = (message) => {
if (!message) { if (!message?.id) return;
logLocal("handleMessageChanged - No message provided", message);
return;
}
logLocal("handleMessageChanged - Start", message); logLocal("handleMessageChanged - Start", message);
if (!messageEntityCached(client, message.id)) return;
try { try {
const msgCacheId = client.cache.identify({ __typename: "messages", id: message.id });
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: message.conversationid }), id: msgCacheId,
fields: { fields: {
messages(existingMessages = [], { readField }) { status(existing) {
return existingMessages.map((messageRef) => { return message.type === "status-changed" ? (message.status ?? existing) : existing;
// Check if this is the message to update },
if (readField("id", messageRef) === message.id) { text(existing) {
const currentStatus = readField("status", messageRef); return message.type === "text-updated" ? (message.text ?? existing) : existing;
// Handle known types of message changes
switch (message.type) {
case "status-changed":
// Prevent overwriting if the current status is already "delivered"
if (currentStatus === "delivered") {
logLocal("handleMessageChanged - Status already delivered, skipping update", {
messageId: message.id
});
return messageRef;
}
// Update the status field
return {
...messageRef,
status: message.status
};
case "text-updated":
// Handle changes to the message text
return {
...messageRef,
text: message.text
};
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
return messageRef;
}
}
return messageRef;
});
} }
} }
}); });
@@ -262,149 +404,140 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
}; };
const handleConversationChanged = async (data) => { const handleConversationChanged = async (data) => {
if (!data) { if (!data?.conversationId) return;
logLocal("handleConversationChanged - No data provided", data);
return; const {
} conversationId,
type,
job_conversations,
messageIds,
messageIdsMarkedRead,
lastUnreadMessageId,
unreadCount,
...fields
} = data;
const { conversationId, type, job_conversations, messageIds, ...fields } = data;
logLocal("handleConversationChanged - Start", data); logLocal("handleConversationChanged - Start", data);
const updatedAt = new Date().toISOString(); const updatedAt = safeIsoNow();
const detailsCached = conversationDetailsCached(client, conversationId);
const updateConversationList = (newConversation) => {
try {
const existingList = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
const updatedList = existingList?.conversations
? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
: [newConversation]; // Prevent duplicates
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 },
data: {
conversations: updatedList
}
});
logLocal("handleConversationChanged - Conversation list updated successfully", newConversation);
} catch (error) {
console.error("Error updating conversation list in the cache:", error);
}
};
// Handle specific types
try { try {
switch (type) { switch (type) {
case "conversation-marked-read": case "conversation-marked-read": {
if (conversationId && messageIds?.length > 0) { refetchUnreadCount();
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), if (detailsCached && Array.isArray(messageIds)) {
fields: { messageIds.forEach((id) => {
messages(existingMessages = [], { readField }) { if (!messageEntityCached(client, id)) return;
return existingMessages.map((message) => { client.cache.modify({
if (messageIds.includes(readField("id", message))) { id: client.cache.identify({ __typename: "messages", id }),
return { ...message, read: true }; fields: { read: () => true }
} });
return message;
});
},
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
})
}
}); });
} }
break;
case "conversation-created": client.cache.modify({
updateConversationList({ ...fields, job_conversations, updated_at: updatedAt }); id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
}),
unreadcnt: () => 0,
updated_at: () => updatedAt
}
});
break; break;
}
case "conversation-marked-unread": {
refetchUnreadCount();
const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1;
const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : [];
if (detailsCached) {
idsMarkedRead.forEach((id) => {
if (!messageEntityCached(client, id)) return;
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id }),
fields: { read: () => true }
});
});
if (lastUnreadMessageId && messageEntityCached(client, lastUnreadMessageId)) {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }),
fields: { read: () => false }
});
}
}
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
updated_at: () => updatedAt,
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: safeUnreadCount
}
}),
unreadcnt: () => safeUnreadCount
}
});
break;
}
case "conversation-created": {
// New conversation likely implies new unread inbound message(s)
refetchUnreadCount();
const conv = enrichConversation(
{ id: conversationId, job_conversations, ...fields, updated_at: updatedAt },
{ isoutbound: false, isSystem: false }
);
upsertConversationIntoOffsetZeroList(client, conv);
break;
}
case "conversation-unarchived": case "conversation-unarchived":
case "conversation-archived": case "conversation-archived": {
try { // Keep unread badge correct even if archiving affects counts
const listQueryVariables = { offset: 0 }; refetchUnreadCount();
const detailsQueryVariables = { conversationId };
// Check if conversation details exist in the cache await client.refetchQueries({
const detailsExist = !!client.cache.readQuery({ include: [CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS]
query: GET_CONVERSATION_DETAILS, });
variables: detailsQueryVariables
});
// Refetch conversation list
await client.refetchQueries({
include: [CONVERSATION_LIST_QUERY, ...(detailsExist ? [GET_CONVERSATION_DETAILS] : [])],
variables: [
{ query: CONVERSATION_LIST_QUERY, variables: listQueryVariables },
...(detailsExist
? [
{
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
}
]
: [])
]
});
logLocal("handleConversationChanged - Refetched queries after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change:", error);
}
break; break;
}
case "tag-added": { case "tag-added": {
// Ensure `job_conversations` is properly formatted const formattedJobConversations = (job_conversations || []).map((jc) => ({
const formattedJobConversations = job_conversations.map((jc) => ({
__typename: "job_conversations", __typename: "job_conversations",
jobid: jc.jobid || jc.job?.id, jobid: jc.jobid || jc.job?.id,
conversationid: conversationId, conversationid: conversationId,
job: jc.job || { job: {
__typename: "jobs", __typename: "jobs",
id: data.selectedJob.id, id: jc.job?.id,
ro_number: data.selectedJob.ro_number, ro_number: jc.job?.ro_number,
ownr_co_nm: data.selectedJob.ownr_co_nm, ownr_co_nm: jc.job?.ownr_co_nm,
ownr_fn: data.selectedJob.ownr_fn, ownr_fn: jc.job?.ownr_fn,
ownr_ln: data.selectedJob.ownr_ln ownr_ln: jc.job?.ownr_ln
} }
})); }));
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: { fields: {
job_conversations: (existing = []) => { job_conversations(existing = [], { readField }) {
// Ensure no duplicates based on both `conversationid` and `jobid` const seen = new Set(existing.map((x) => readField("jobid", x)).filter(Boolean));
const existingLinks = new Set( const incoming = formattedJobConversations.filter((x) => x.jobid && !seen.has(x.jobid));
existing.map((jc) => { return [...existing, ...incoming];
const jobId = client.cache.readFragment({
id: client.cache.identify(jc),
fragment: gql`
fragment JobConversationLinkAdded on job_conversations {
jobid
conversationid
}
`
})?.jobid;
return `${jobId}:${conversationId}`; // Unique identifier for a job-conversation link
})
);
const newItems = formattedJobConversations.filter((jc) => {
const uniqueLink = `${jc.jobid}:${jc.conversationid}`;
return !existingLinks.has(uniqueLink);
});
return [...existing, ...newItems];
} }
} }
}); });
@@ -412,46 +545,31 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
break; break;
} }
case "tag-removed": case "tag-removed": {
try { const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId });
const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId });
// Evict the specific cache entry for job_conversations
client.cache.evict({
id: conversationCacheId,
fieldName: "job_conversations"
});
// Garbage collect evicted entries
client.cache.gc();
logLocal("handleConversationChanged - tag removed - Refetched conversation list after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change: (Tag Removed)", error);
}
client.cache.evict({
id: conversationCacheId,
fieldName: "job_conversations"
});
client.cache.gc();
break; break;
}
default: default: {
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: { fields: Object.fromEntries(
...Object.fromEntries( Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)]) )
)
}
}); });
}
} }
} catch (error) { } catch (error) {
console.error("Error handling conversation changes:", { type, error }); console.error("Error handling conversation changes:", { type, error });
} }
}; };
// Existing handler for phone number opt-out
const handlePhoneNumberOptedOut = async (data) => { const handlePhoneNumberOptedOut = async (data) => {
const { bodyshopid, phone_number } = data; const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedOut - Start", data); logLocal("handlePhoneNumberOptedOut - Start", data);
@@ -461,22 +579,18 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
id: "ROOT_QUERY", id: "ROOT_QUERY",
fields: { fields: {
phone_number_opt_out(existing = [], { readField }) { phone_number_opt_out(existing = [], { readField }) {
const phoneNumberExists = existing.some( const exists = existing.some(
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid (ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
); );
if (exists) return existing;
if (phoneNumberExists) {
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
return existing;
}
const newOptOut = { const newOptOut = {
__typename: "phone_number_opt_out", __typename: "phone_number_opt_out",
id: `temporary-${phone_number}-${Date.now()}`, id: `temporary-${phone_number}-${Date.now()}`,
bodyshopid, bodyshopid,
phone_number, phone_number,
created_at: new Date().toISOString(), created_at: safeIsoNow(),
updated_at: new Date().toISOString() updated_at: safeIsoNow()
}; };
return [...existing, newOptOut]; return [...existing, newOptOut];
@@ -491,46 +605,36 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
args: { bodyshopid, search: phone_number } args: { bodyshopid, search: phone_number }
}); });
client.cache.gc(); client.cache.gc();
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
} catch (error) { } catch (error) {
console.error("Error updating cache for phone number opt-out:", error); console.error("Error updating cache for phone number opt-out:", error);
logLocal("handlePhoneNumberOptedOut - Error", { error: error.message });
} }
}; };
// New handler for phone number opt-in
const handlePhoneNumberOptedIn = async (data) => { const handlePhoneNumberOptedIn = async (data) => {
const { bodyshopid, phone_number } = data; const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedIn - Start", data); logLocal("handlePhoneNumberOptedIn - Start", data);
try { try {
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
client.cache.modify({ client.cache.modify({
id: "ROOT_QUERY", id: "ROOT_QUERY",
fields: { fields: {
phone_number_opt_out(existing = [], { readField }) { phone_number_opt_out(existing = [], { readField }) {
// Filter out the phone number from the opt-out list
return existing.filter( return existing.filter(
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid) (ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
); );
} }
}, },
broadcast: true // Trigger UI updates broadcast: true
}); });
// Evict the cache entry to force a refetch on next query
client.cache.evict({ client.cache.evict({
id: "ROOT_QUERY", id: "ROOT_QUERY",
fieldName: "phone_number_opt_out", fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number } args: { bodyshopid, search: phone_number }
}); });
client.cache.gc(); client.cache.gc();
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
} catch (error) { } catch (error) {
console.error("Error updating cache for phone number opt-in:", error); console.error("Error updating cache for phone number opt-in:", error);
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
} }
}; };

View File

@@ -12,7 +12,7 @@ import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons"; import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss"; import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js"; import { GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS } from "../../graphql/phone-number-opt-out.queries.js";
import { phone } from "phone"; import { phone } from "phone";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -29,13 +29,26 @@ const mapDispatchToProps = (dispatch) => ({
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [, forceUpdate] = useState(false); const [, forceUpdate] = useState(false);
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, { const phoneNumbers = useMemo(() => {
return (conversationList || [])
.map((item) => {
try {
const p = phone(item.phone_num, "CA")?.phoneNumber;
return p ? p.replace(/^\+1/, "") : null;
} catch {
return null;
}
})
.filter(Boolean);
}, [conversationList]);
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS, {
variables: { variables: {
bodyshopid: bodyshop.id, bodyshopid: bodyshop?.id,
phone_numbers: phoneNumbers phone_numbers: phoneNumbers
}, },
skip: !conversationList.length, skip: !bodyshop?.id || phoneNumbers.length === 0,
fetchPolicy: "cache-and-network" fetchPolicy: "cache-and-network"
}); });
@@ -58,15 +71,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
return _.orderBy(conversationList, ["updated_at"], ["desc"]); return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]); }, [conversationList]);
const renderConversation = (index, t) => { const renderConversation = (index) => {
const item = sortedConversationList[index]; const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const hasOptOutEntry = optOutMap.has(normalizedPhone); const normalizedPhone = (() => {
try {
return phone(item.phone_num, "CA")?.phoneNumber?.replace(/^\+1/, "") || "";
} catch {
return "";
}
})();
const hasOptOutEntry = normalizedPhone ? optOutMap.has(normalizedPhone) : false;
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>; const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft = const cardContentLeft =
item.job_conversations.length > 0 item.job_conversations.length > 0
? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>) ? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>)
: null; : null;
const names = <>{_.uniq(item.job_conversations.map((j) => OwnerNameDisplayFunction(j.job)))}</>; const names = <>{_.uniq(item.job_conversations.map((j) => OwnerNameDisplayFunction(j.job)))}</>;
const cardTitle = ( const cardTitle = (
<> <>
@@ -80,9 +103,10 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
)} )}
</> </>
); );
const cardExtra = ( const cardExtra = (
<> <>
<Badge count={item.messages_aggregate.aggregate.count} /> <Badge count={item.messages_aggregate?.aggregate?.count || 0} />
{hasOptOutEntry && ( {hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}> <Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}> <Tag color="red" icon={<ExclamationCircleOutlined />}>
@@ -92,6 +116,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
)} )}
</> </>
); );
const getCardStyle = () => const getCardStyle = () =>
item.id === selectedConversation item.id === selectedConversation
? { backgroundColor: "var(--card-selected-bg)" } ? { backgroundColor: "var(--card-selected-bg)" }
@@ -104,24 +129,8 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
> >
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}> <Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div <div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
style={{ <div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
</Card> </Card>
</List.Item> </List.Item>
); );
@@ -131,7 +140,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container"> <div className="chat-list-container">
<Virtuoso <Virtuoso
data={sortedConversationList} data={sortedConversationList}
itemContent={(index) => renderConversation(index, t)} itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
/> />
</div> </div>

View File

@@ -5,24 +5,24 @@ import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conv
import ChatLabelComponent from "../chat-label/chat-label.component"; import ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component"; import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container"; import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
import { createStructuredSelector } from "reselect"; import ChatMarkUnreadButton from "../chat-mark-unread-button/chat-mark-unread-button.component";
import { connect } from "react-redux";
const mapStateToProps = createStructuredSelector({}); export function ChatConversationTitle({ conversation, onMarkUnread, markUnreadDisabled, markUnreadLoading }) {
const mapDispatchToProps = () => ({});
export function ChatConversationTitle({ conversation }) {
return ( return (
<Space className="chat-title" wrap> <Space className="chat-title" wrap>
<PhoneNumberFormatter>{conversation?.phone_num}</PhoneNumberFormatter> <PhoneNumberFormatter>{conversation?.phone_num}</PhoneNumberFormatter>
<ChatLabelComponent conversation={conversation} /> <ChatLabelComponent conversation={conversation} />
<ChatPrintButton conversation={conversation} /> <ChatPrintButton conversation={conversation} />
<ChatConversationTitleTags jobConversations={conversation?.job_conversations || []} /> <ChatConversationTitleTags jobConversations={conversation?.job_conversations || []} />
<ChatTagRoContainer conversation={conversation || []} /> <ChatTagRoContainer conversation={conversation || []} />
<ChatMarkUnreadButton disabled={markUnreadDisabled} loading={markUnreadLoading} onMarkUnread={onMarkUnread} />
<ChatArchiveButton conversation={conversation} /> <ChatArchiveButton conversation={conversation} />
</Space> </Space>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle); export default ChatConversationTitle;

View File

@@ -19,7 +19,9 @@ export function ChatConversationComponent({
conversation, conversation,
messages, messages,
handleMarkConversationAsRead, handleMarkConversationAsRead,
bodyshop handleMarkLastMessageAsUnread,
markingAsUnreadInProgress,
canMarkUnread
}) { }) {
const [loading, error] = subState; const [loading, error] = subState;
@@ -33,7 +35,12 @@ export function ChatConversationComponent({
onMouseDown={handleMarkConversationAsRead} onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead} onKeyDown={handleMarkConversationAsRead}
> >
<ChatConversationTitle conversation={conversation} bodyshop={bodyshop} /> <ChatConversationTitle
conversation={conversation}
onMarkUnread={handleMarkLastMessageAsUnread}
markUnreadDisabled={!canMarkUnread}
markUnreadLoading={markingAsUnreadInProgress}
/>
<ChatMessageListComponent messages={messages} /> <ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} /> <ChatSendMessage conversation={conversation} />
</div> </div>

View File

@@ -1,6 +1,6 @@
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client"; import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
@@ -18,8 +18,8 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useSocket(); const { socket } = useSocket();
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [markingAsUnreadInProgress, setMarkingAsUnreadInProgress] = useState(false);
// Fetch conversation details
const { const {
loading: convoLoading, loading: convoLoading,
error: convoError, error: convoError,
@@ -27,24 +27,23 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
} = useQuery(GET_CONVERSATION_DETAILS, { } = useQuery(GET_CONVERSATION_DETAILS, {
variables: { conversationId: selectedConversation }, variables: { conversationId: selectedConversation },
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only",
skip: !selectedConversation
}); });
// Subscription for conversation updates const conversation = convoData?.conversations_by_pk;
// Subscription for conversation updates (used when socket is NOT connected)
useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, { useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
skip: socket?.connected, skip: socket?.connected || !selectedConversation,
variables: { conversationId: selectedConversation }, variables: { conversationId: selectedConversation },
onData: ({ data: subscriptionResult, client }) => { onData: ({ data: subscriptionResult, client }) => {
// Extract the messages array from the result
const messages = subscriptionResult?.data?.messages; const messages = subscriptionResult?.data?.messages;
if (!messages || messages.length === 0) { if (!messages || messages.length === 0) return;
console.warn("No messages found in subscription result.");
return;
}
messages.forEach((message) => { messages.forEach((message) => {
const messageRef = client.cache.identify(message); const messageRef = client.cache.identify(message);
// Write the new message to the cache
client.cache.writeFragment({ client.cache.writeFragment({
id: messageRef, id: messageRef,
fragment: gql` fragment: gql`
@@ -64,7 +63,6 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
data: message data: message
}); });
// Update the conversation cache to include the new message
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: selectedConversation }), id: client.cache.identify({ __typename: "conversations", id: selectedConversation }),
fields: { fields: {
@@ -82,6 +80,28 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
} }
}); });
/**
* Best-effort badge update:
* This assumes your list query uses messages_aggregate.aggregate.count as UNREAD inbound count.
* If its total messages, rename/create a dedicated unread aggregate in the list query and update that field instead.
*/
const setConversationUnreadCountBestEffort = useCallback(
(conversationId, unreadCount) => {
if (!conversationId) return;
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages_aggregate(existing) {
if (!existing?.aggregate) return existing;
return { ...existing, aggregate: { ...existing.aggregate, count: unreadCount } };
}
}
});
},
[client.cache]
);
const updateCacheWithReadMessages = useCallback( const updateCacheWithReadMessages = useCallback(
(conversationId, messageIds) => { (conversationId, messageIds) => {
if (!conversationId || !messageIds?.length) return; if (!conversationId || !messageIds?.length) return;
@@ -89,13 +109,34 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
messageIds.forEach((messageId) => { messageIds.forEach((messageId) => {
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: messageId }), id: client.cache.identify({ __typename: "messages", id: messageId }),
fields: { fields: { read: () => true }
read: () => true
}
}); });
}); });
setConversationUnreadCountBestEffort(conversationId, 0);
}, },
[client.cache] [client.cache, setConversationUnreadCountBestEffort]
);
const applyUnreadStateWithMaxOneUnread = useCallback(
({ conversationId, lastUnreadMessageId, messageIdsMarkedRead = [], unreadCount = 1 }) => {
if (!conversationId || !lastUnreadMessageId) return;
messageIdsMarkedRead.forEach((id) => {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id }),
fields: { read: () => true }
});
});
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }),
fields: { read: () => false }
});
setConversationUnreadCountBestEffort(conversationId, unreadCount);
},
[client.cache, setConversationUnreadCountBestEffort]
); );
// WebSocket event handlers // WebSocket event handlers
@@ -103,20 +144,25 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
if (!socket?.connected) return; if (!socket?.connected) return;
const handleConversationChange = (data) => { const handleConversationChange = (data) => {
if (data.type === "conversation-marked-read") { if (data?.type === "conversation-marked-read") {
const { conversationId, messageIds } = data; updateCacheWithReadMessages(data.conversationId, data.messageIds);
updateCacheWithReadMessages(conversationId, messageIds); }
if (data?.type === "conversation-marked-unread") {
applyUnreadStateWithMaxOneUnread({
conversationId: data.conversationId,
lastUnreadMessageId: data.lastUnreadMessageId,
messageIdsMarkedRead: data.messageIdsMarkedRead,
unreadCount: data.unreadCount
});
} }
}; };
socket.on("conversation-changed", handleConversationChange); socket.on("conversation-changed", handleConversationChange);
return () => socket.off("conversation-changed", handleConversationChange);
}, [socket, updateCacheWithReadMessages, applyUnreadStateWithMaxOneUnread]);
return () => { // Join/leave conversation via WebSocket
socket.off("conversation-changed", handleConversationChange);
};
}, [socket, updateCacheWithReadMessages]);
// Join and leave conversation via WebSocket
useEffect(() => { useEffect(() => {
if (!socket?.connected || !selectedConversation || !bodyshop?.id) return; if (!socket?.connected || !selectedConversation || !bodyshop?.id) return;
@@ -133,15 +179,21 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
}; };
}, [socket, bodyshop, selectedConversation]); }, [socket, bodyshop, selectedConversation]);
// Mark conversation as read const inboundNonSystemMessages = useMemo(() => {
const handleMarkConversationAsRead = async () => { const msgs = conversation?.messages || [];
if (!convoData || markingAsReadInProgress) return; return msgs
.filter((m) => m && !m.isoutbound && !m.is_system)
.slice()
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
}, [conversation?.messages]);
const conversation = convoData.conversations_by_pk; const canMarkUnread = inboundNonSystemMessages.length > 0;
if (!conversation) return;
const handleMarkConversationAsRead = async () => {
if (!conversation || markingAsReadInProgress) return;
const unreadMessageIds = conversation.messages const unreadMessageIds = conversation.messages
?.filter((message) => !message.read && !message.isoutbound) ?.filter((message) => !message.read && !message.isoutbound && !message.is_system)
.map((message) => message.id); .map((message) => message.id);
if (unreadMessageIds?.length > 0) { if (unreadMessageIds?.length > 0) {
@@ -162,12 +214,48 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
} }
}; };
const handleMarkLastMessageAsUnread = async () => {
if (!conversation || markingAsUnreadInProgress) return;
if (!bodyshop?.id || !bodyshop?.imexshopid) return;
const lastInbound = inboundNonSystemMessages[inboundNonSystemMessages.length - 1];
if (!lastInbound?.id) return;
setMarkingAsUnreadInProgress(true);
try {
const res = await axios.post("/sms/markLastMessageUnread", {
conversationId: conversation.id,
imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
});
const payload = res?.data || {};
if (payload.lastUnreadMessageId) {
applyUnreadStateWithMaxOneUnread({
conversationId: conversation.id,
lastUnreadMessageId: payload.lastUnreadMessageId,
messageIdsMarkedRead: payload.messageIdsMarkedRead || [],
unreadCount: typeof payload.unreadCount === "number" ? payload.unreadCount : 1
});
} else {
setConversationUnreadCountBestEffort(conversation.id, 0);
}
} catch (error) {
console.error("Error marking last message unread:", error.message);
} finally {
setMarkingAsUnreadInProgress(false);
}
};
return ( return (
<ChatConversationComponent <ChatConversationComponent
subState={[convoLoading, convoError]} subState={[convoLoading, convoError]}
conversation={convoData?.conversations_by_pk || {}} conversation={conversation || {}}
messages={convoData?.conversations_by_pk?.messages || []} messages={conversation?.messages || []}
handleMarkConversationAsRead={handleMarkConversationAsRead} handleMarkConversationAsRead={handleMarkConversationAsRead}
handleMarkLastMessageAsUnread={handleMarkLastMessageAsUnread}
markingAsUnreadInProgress={markingAsUnreadInProgress}
canMarkUnread={canMarkUnread}
/> />
); );
} }

View File

@@ -0,0 +1,24 @@
import { Button, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
export default function ChatMarkUnreadButton({ disabled, loading, onMarkUnread }) {
const { t } = useTranslation();
return (
<Tooltip title={t("messaging.labels.mark_unread")}>
<Button
size="small"
className="unread-button"
type="primary"
children={t("messaging.labels.mark_unread")}
loading={loading}
disabled={disabled}
onMouseDown={(e) => e.stopPropagation()} // prevent parent mark-read handler
onClick={(e) => {
e.stopPropagation();
onMarkUnread?.();
}}
/>
</Tooltip>
);
}

View File

@@ -10,6 +10,11 @@
border-radius: 4px; border-radius: 4px;
} }
.unread-button {
height: 20px;
border-radius: 4px;
}
.chat-title { .chat-title {
margin-bottom: 5px; margin-bottom: 5px;
} }

View File

@@ -19,30 +19,35 @@ const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)) openChatByPhone: (phone) => dispatch(openChatByPhone(phone))
}); });
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) { export function ChatOpenButton({ bodyshop, searchingForConversation, phone, type, jobid, openChatByPhone }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { socket } = useSocket(); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
if (!phone) return <></>; if (!phone) return <></>;
if (!bodyshop.messagingservicesid) return <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>; if (!bodyshop.messagingservicesid) {
return <PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>;
}
return ( return (
<a <a
href="# " href="# "
onClick={(e) => { onClick={(e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (searchingForConversation) return; // Prevent finding the same thing twice.
const p = parsePhoneNumber(phone, "CA"); const p = parsePhoneNumber(phone, "CA");
if (searchingForConversation) return; //This is to prevent finding the same thing twice.
if (p && p.isValid()) { if (p && p.isValid()) {
openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid, socket }); openChatByPhone({ phone_num: p.formatInternational(), jobid, socket });
} else { } else {
notification["error"]({ message: t("messaging.error.invalidphone") }); notification["error"]({ message: t("messaging.error.invalidphone") });
} }
}} }}
> >
<PhoneNumberFormatter>{phone}</PhoneNumberFormatter> <PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>
</a> </a>
); );
} }

View File

@@ -133,6 +133,9 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
ownr_ln: contract.job.owner.ownr_ln, ownr_ln: contract.job.owner.ownr_ln,
ownr_co_nm: contract.job.owner.ownr_co_nm, ownr_co_nm: contract.job.owner.ownr_co_nm,
ownr_ph1: contract.job.owner.ownr_ph1, ownr_ph1: contract.job.owner.ownr_ph1,
ownr_ph2: contract.job.owner.ownr_ph2,
ownr_ph1_ty: contract.job.owner.ownr_ph1_ty,
ownr_ph2_ty: contract.job.owner.ownr_ph2_ty,
ownr_ea: contract.job.owner.ownr_ea, ownr_ea: contract.job.owner.ownr_ea,
v_model_desc: contract.job.vehicle && contract.job.vehicle.v_model_desc, v_model_desc: contract.job.vehicle && contract.job.vehicle.v_model_desc,
v_model_yr: contract.job.vehicle && contract.job.vehicle.v_model_yr, v_model_yr: contract.job.vehicle && contract.job.vehicle.v_model_yr,
@@ -253,6 +256,10 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
} }
}; };
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
//Calcualte the new job totals. //Calcualte the new job totals.
const newTotals = ( const newTotals = (

View File

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

View File

@@ -256,9 +256,9 @@ export default function DashboardScheduledDeliveryToday({ data, ...cardProps })
responsive: ["md"], responsive: ["md"],
render: (text, record) => ( render: (text, record) => (
<Space size="small" wrap> <Space size="small" wrap>
<ChatOpenButton phone={record.ownr_ph1} jobid={record.jobid} /> <ChatOpenButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.jobid} />
<ChatOpenButton phone={record.ownr_ph2} jobid={record.jobid} /> <ChatOpenButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.jobid} />
</Space> </Space>
) )
}, },
@@ -397,6 +397,8 @@ export const DashboardScheduledDeliveryTodayGql = `
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
production_vars production_vars
ro_number ro_number
scheduled_delivery scheduled_delivery

View File

@@ -48,6 +48,8 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
ownr_ln: item.job.ownr_ln, ownr_ln: item.job.ownr_ln,
ownr_ph1: item.job.ownr_ph1, ownr_ph1: item.job.ownr_ph1,
ownr_ph2: item.job.ownr_ph2, ownr_ph2: item.job.ownr_ph2,
ownr_ph1_ty: item.job.ownr_ph1_ty,
ownr_ph2_ty: item.job.ownr_ph2_ty,
production_vars: item.job.production_vars, production_vars: item.job.production_vars,
ro_number: item.job.ro_number, ro_number: item.job.ro_number,
suspended: item.job.suspended, suspended: item.job.suspended,
@@ -264,8 +266,8 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
responsive: ["md"], responsive: ["md"],
render: (text, record) => ( render: (text, record) => (
<Space size="small" wrap> <Space size="small" wrap>
<ChatOpenButton phone={record.ownr_ph1} jobid={record.jobid} /> <ChatOpenButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.jobid} />
<ChatOpenButton phone={record.ownr_ph2} jobid={record.jobid} /> <ChatOpenButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.jobid} />
</Space> </Space>
) )
}, },
@@ -400,6 +402,8 @@ export const DashboardScheduledInTodayGql = `
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
production_vars production_vars
ro_number ro_number
suspended suspended

View File

@@ -256,9 +256,9 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
responsive: ["md"], responsive: ["md"],
render: (text, record) => ( render: (text, record) => (
<Space size="small" wrap> <Space size="small" wrap>
<ChatOpenButton phone={record.ownr_ph1} jobid={record.jobid} /> <ChatOpenButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.jobid} />
<ChatOpenButton phone={record.ownr_ph2} jobid={record.jobid} /> <ChatOpenButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.jobid} />
</Space> </Space>
) )
}, },
@@ -397,6 +397,8 @@ export const DashboardScheduledOutTodayGql = `
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
production_vars production_vars
ro_number ro_number
scheduled_completion scheduled_completion

View File

@@ -0,0 +1,166 @@
import axios from "axios";
import { Result } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
const imgRef = useRef(null);
const [loading, setLoading] = useState(false);
const [uploaded, setuploaded] = useState(false);
const [loadedImageUrl, setLoadedImageUrl] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const { t } = useTranslation();
const notification = useNotification();
const [uploading, setUploading] = useState(false);
const triggerUpload = useCallback(
async (dataUrl) => {
if (uploading) return;
setUploading(true);
const blob = await b64toBlob(dataUrl);
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
const parts = nameWithoutExt.split("-");
const baseParts = [];
for (let i = 0; i < parts.length; i++) {
if (/^\d+$/.test(parts[i])) {
break;
}
baseParts.push(parts[i]);
}
const adjustedBase = baseParts.length > 0 ? baseParts.join("-") : "edited";
const adjustedFilename = `${adjustedBase}.jpg`;
const file = new File([blob], adjustedFilename, { type: "image/jpeg" });
handleUpload({
ev: {
file: file,
filename: adjustedFilename,
onSuccess: () => {
setUploading(false);
setLoading(false);
setuploaded(true);
},
onError: () => {
setUploading(false);
setLoading(false);
}
},
context: {
jobid: jobid,
callback: () => {} // Optional callback
},
notification
});
},
[filename, jobid, notification, uploading]
);
useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
markerArea.current = new markerjs2.MarkerArea(imgRef.current);
// attach an event handler to assign annotated image back to our image element
markerArea.current.addEventListener("close", () => {
// NO OP
});
markerArea.current.addEventListener("render", (event) => {
const dataUrl = event.dataUrl;
imgRef.current.src = dataUrl;
markerArea.current.close();
triggerUpload(dataUrl);
});
// launch marker.js
markerArea.current.renderAtNaturalSize = true;
markerArea.current.renderImageType = "image/jpeg";
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
}
}, [triggerUpload, imageLoaded]);
useEffect(() => {
if (!imageUrl) return;
const controller = new AbortController();
const loadImage = async () => {
setImageLoaded(false);
setImageLoading(true);
try {
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
const blobUrl = URL.createObjectURL(response.data);
setLoadedImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
});
} catch (error) {
if (axios.isCancel?.(error) || error.name === "CanceledError") {
// request was aborted — safe to ignore
return;
}
console.error("Failed to fetch image blob", error);
} finally {
if (!controller.signal.aborted) {
setImageLoading(false);
}
}
};
loadImage();
return () => {
controller.abort();
};
}, [imageUrl]);
useEffect(() => {
return () => {
if (loadedImageUrl) {
URL.revokeObjectURL(loadedImageUrl);
}
};
}, [loadedImageUrl]);
async function b64toBlob(url) {
const res = await fetch(url);
return await res.blob();
}
return (
<div>
{!loading && !uploaded && loadedImageUrl && (
<img
ref={imgRef}
src={loadedImageUrl}
alt="sample"
onLoad={() => setImageLoaded(true)}
onError={(error) => {
console.error("Failed to load image", error);
}}
style={{ maxWidth: "90vw", maxHeight: "90vh" }}
/>
)}
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(DocumentEditorLocalComponent);

View File

@@ -1,4 +1,5 @@
//import "tui-image-editor/dist/tui-image-editor.css"; //import "tui-image-editor/dist/tui-image-editor.css";
import axios from "axios";
import { Result } from "antd"; import { Result } from "antd";
import * as markerjs2 from "markerjs2"; import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
@@ -6,8 +7,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload/documents-upload.utility"; import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
import { GenerateSrcUrl } from "../jobs-documents-gallery/job-documents.utility";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
@@ -23,6 +23,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
const imgRef = useRef(null); const imgRef = useRef(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploaded, setuploaded] = useState(false); const [uploaded, setuploaded] = useState(false);
const [imageUrl, setImageUrl] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null); const markerArea = useRef(null);
const { t } = useTranslation(); const { t } = useTranslation();
const notification = useNotification(); const notification = useNotification();
@@ -55,7 +58,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
); );
useEffect(() => { useEffect(() => {
if (imgRef.current !== null) { if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea // create a marker.js MarkerArea
markerArea.current = new markerjs2.MarkerArea(imgRef.current); markerArea.current = new markerjs2.MarkerArea(imgRef.current);
@@ -78,7 +81,52 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
//markerArea.current.settings.displayMode = "inline"; //markerArea.current.settings.displayMode = "inline";
markerArea.current.show(); markerArea.current.show();
} }
}, [triggerUpload]); }, [triggerUpload, imageLoaded]);
useEffect(() => {
if (!document?.id) return;
const controller = new AbortController();
const loadImage = async () => {
setImageLoaded(false);
setImageLoading(true);
try {
const response = await axios.post(
"/media/imgproxy/original",
{ documentId: document.id },
{
responseType: "blob",
signal: controller.signal
}
);
const blobUrl = URL.createObjectURL(response.data);
setImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
});
} catch (error) {
if (axios.isCancel?.(error) || error.name === "CanceledError") {
// request was aborted — safe to ignore
return;
}
console.error("Failed to fetch original image blob", error);
} finally {
setImageLoading(false);
}
};
loadImage();
return () => {
controller.abort();
};
}, [document]);
useEffect(() => {
return () => {
if (imageUrl) {
URL.revokeObjectURL(imageUrl);
}
};
}, [imageUrl]);
async function b64toBlob(url) { async function b64toBlob(url) {
const res = await fetch(url); const res = await fetch(url);
@@ -87,16 +135,21 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
return ( return (
<div> <div>
{!loading && !uploaded && ( {!loading && !uploaded && imageUrl && (
<img <img
ref={imgRef} ref={imgRef}
src={GenerateSrcUrl(document)} src={imageUrl}
alt="sample" alt="sample"
crossOrigin="anonymous" onLoad={() => setImageLoaded(true)}
onError={(error) => {
console.error("Failed to load original image", error);
}}
style={{ maxWidth: "90vw", maxHeight: "90vh" }} style={{ maxWidth: "90vw", maxHeight: "90vh" }}
/> />
)} )}
{loading && <LoadingSpinner message={t("documents.labels.uploading")} />} {(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />} {uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
</div> </div>
); );

View File

@@ -11,6 +11,7 @@ import { setBodyshop } from "../../redux/user/user.actions";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import DocumentEditor from "./document-editor.component"; import DocumentEditor from "./document-editor.component";
import { DocumentEditorLocalComponent } from "./document-editor-local.component";
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setBodyshop: (bs) => dispatch(setBodyshop(bs)) setBodyshop: (bs) => dispatch(setBodyshop(bs))
@@ -21,7 +22,7 @@ export default connect(null, mapDispatchToProps)(DocumentEditorContainer);
export function DocumentEditorContainer({ setBodyshop }) { export function DocumentEditorContainer({ setBodyshop }) {
//Get the image details for the image to be saved. //Get the image details for the image to be saved.
//Get the document id from the search string. //Get the document id from the search string.
const { documentId } = queryString.parse(useLocation().search); const { documentId, imageUrl, filename, jobid } = queryString.parse(useLocation().search);
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
loading: loadingShop, loading: loadingShop,
@@ -32,24 +33,45 @@ export function DocumentEditorContainer({ setBodyshop }) {
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
useEffect(() => { const isLocalMedia = !!dataShop?.bodyshops?.[0]?.uselocalmediaserver;
if (dataShop) setBodyshop(dataShop.bodyshops[0]);
}, [dataShop, setBodyshop]);
const { loading, error, data } = useQuery(GET_DOCUMENT_BY_PK, { const {
loading: loadingDoc,
error: errorDoc,
data: dataDoc
} = useQuery(GET_DOCUMENT_BY_PK, {
variables: { documentId }, variables: { documentId },
skip: !documentId, skip: !documentId || isLocalMedia,
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
if (loading || loadingShop) return <LoadingSpinner />; useEffect(() => {
if (error || errorShop) return <AlertComponent message={error.message || errorShop.message} type="error" />; if (dataShop) setBodyshop(dataShop.bodyshops[0]);
}, [dataShop, setBodyshop]);
if (!data || !data.documents_by_pk) return <Result status="404" title={t("general.errors.notfound")} />; if (loadingShop) return <LoadingSpinner />;
if (errorShop) return <AlertComponent message={errorShop.message} type="error" />;
if (isLocalMedia) {
if (imageUrl && filename && jobid) {
return (
<div>
<DocumentEditorLocalComponent imageUrl={imageUrl} filename={filename} jobid={jobid} />
</div>
);
} else {
return <Result status="404" title={t("general.errors.notfound")} />;
}
}
if (loadingDoc) return <LoadingSpinner />;
if (errorDoc) return <AlertComponent message={errorDoc.message} type="error" />;
if (!dataDoc || !dataDoc.documents_by_pk) return <Result status="404" title={t("general.errors.notfound")} />;
return ( return (
<div> <div>
<DocumentEditor document={data ? data.documents_by_pk : null} /> <DocumentEditor document={dataDoc ? dataDoc.documents_by_pk : null} />
</div> </div>
); );
} }

View File

@@ -55,7 +55,8 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
const useremail = currentUser.email; const useremail = currentUser.email;
try { try {
const { ...otherFormValues } = formValues; // eslint-disable-next-line no-unused-vars
const { accepted_terms, ...otherFormValues } = formValues;
// Trim the values of the fields before submitting // Trim the values of the fields before submitting
const trimmedFormValues = Object.entries(otherFormValues).reduce((acc, [key, value]) => { const trimmedFormValues = Object.entries(otherFormValues).reduce((acc, [key, value]) => {

View File

@@ -73,8 +73,8 @@ export default function GlobalSearchOs() {
<span> <span>
<OwnerNameDisplay ownerObject={owner} /> <OwnerNameDisplay ownerObject={owner} />
</span> </span>
<PhoneNumberFormatter>{owner.ownr_ph1}</PhoneNumberFormatter> <PhoneNumberFormatter type={owner?.ownr_ph1_ty}>{owner.ownr_ph1}</PhoneNumberFormatter>
<PhoneNumberFormatter>{owner.ownr_ph2}</PhoneNumberFormatter> <PhoneNumberFormatter type={owner?.ownr_ph2_ty}>{owner.ownr_ph2}</PhoneNumberFormatter>
</Space> </Space>
</Link> </Link>
) )

View File

@@ -63,8 +63,8 @@ export default function GlobalSearch() {
<span> <span>
<OwnerNameDisplay ownerObject={owner} /> <OwnerNameDisplay ownerObject={owner} />
</span> </span>
<PhoneNumberFormatter>{owner.ownr_ph1}</PhoneNumberFormatter> <PhoneNumberFormatter type={owner.ownr_ph1_ty}>{owner.ownr_ph1}</PhoneNumberFormatter>
<PhoneNumberFormatter>{owner.ownr_ph2}</PhoneNumberFormatter> <PhoneNumberFormatter type={owner.ownr_ph2_ty}>{owner.ownr_ph2}</PhoneNumberFormatter>
</Space> </Space>
</Link> </Link>
) )

View File

@@ -220,10 +220,10 @@ export function ScheduleEventComponent({
</DataLabel> </DataLabel>
<DataLabel label={t("jobs.fields.ownr_ea")}>{(event.job && event.job.ownr_ea) || ""}</DataLabel> <DataLabel label={t("jobs.fields.ownr_ea")}>{(event.job && event.job.ownr_ea) || ""}</DataLabel>
<DataLabel label={t("jobs.fields.ownr_ph1")}> <DataLabel label={t("jobs.fields.ownr_ph1")}>
<ChatOpenButton phone={event.job && event.job.ownr_ph1} jobid={event.job.id} /> <ChatOpenButton phone={event?.job?.ownr_ph1} type={event?.job?.ownr_ph1_ty} jobid={event.job.id} />
</DataLabel> </DataLabel>
<DataLabel label={t("jobs.fields.ownr_ph2")}> <DataLabel label={t("jobs.fields.ownr_ph2")}>
<ChatOpenButton phone={event.job && event.job.ownr_ph2} jobid={event.job.id} /> <ChatOpenButton phone={event?.job?.ownr_ph2} type={event?.job?.ownr_ph2_ty} jobid={event.job.id} />
</DataLabel> </DataLabel>
<DataLabel hideIfNull label={t("jobs.fields.loss_of_use")}> <DataLabel hideIfNull label={t("jobs.fields.loss_of_use")}>
{(event.job && event.job.loss_of_use) || ""} {(event.job && event.job.loss_of_use) || ""}

View File

@@ -43,16 +43,18 @@ export function JobCreateIOU({ bodyshop, currentUser, job, selectedJobLines, tec
const handleCreateIou = async () => { const handleCreateIou = async () => {
setLoading(true); setLoading(true);
//Query all of the job details to recreate. //Query all of the job details to recreate.
const iouId = await CreateIouForJob( const iouId = await CreateIouForJob({
client, apolloClient: client,
job.id, jobLinesToKeep: selectedJobLines,
{ jobId: job.id,
config: {
status: bodyshop.md_ro_statuses.default_open, status: bodyshop.md_ro_statuses.default_open,
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
useremail: currentUser.email useremail: currentUser.email
}, },
selectedJobLines currentUser
); });
notification.open({ notification.open({
type: "success", type: "success",
message: t("jobs.successes.ioucreated"), message: t("jobs.successes.ioucreated"),

View File

@@ -9,10 +9,10 @@ import {
WarningFilled WarningFilled
} from "@ant-design/icons"; } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useMutation } from "@apollo/client"; import { gql, useMutation } from "@apollo/client";
import { Button, Dropdown, Input, Space, Table, Tag } from "antd"; import { Button, Dropdown, Input, Modal, Select, Space, Table, Tag, Typography } from "antd";
import axios from "axios"; import axios from "axios";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -47,6 +47,19 @@ import JobLinesExpander from "./job-lines-expander.component";
import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component"; import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
update_joblines(where: { id: { _in: $ids } }, _set: { location: $location }) {
affected_rows
returning {
id
location
}
}
}
`;
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -83,6 +96,9 @@ export function JobLinesComponent({
isPartsEntry isPartsEntry
}) { }) {
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK); const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
const notification = useNotification();
const { const {
treatments: { Enhanced_Payroll } treatments: { Enhanced_Payroll }
} = useSplitTreatments({ } = useSplitTreatments({
@@ -103,9 +119,83 @@ export function JobLinesComponent({
} }
}); });
// Bulk location modal state
const [bulkLocationOpen, setBulkLocationOpen] = useState(false);
const [bulkLocation, setBulkLocation] = useState(null);
const [bulkLocationSaving, setBulkLocationSaving] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const jobIsPrivate = bodyshop.md_ins_cos.find((c) => c.name === job.ins_co_nm)?.private; const jobIsPrivate = bodyshop.md_ins_cos.find((c) => c.name === job.ins_co_nm)?.private;
const selectedLineIds = useMemo(() => selectedLines.map((l) => l?.id).filter(Boolean), [selectedLines]);
const commonSelectedLocation = useMemo(() => {
const locs = selectedLines
.map((l) => (typeof l?.location === "string" ? l.location : ""))
.map((x) => x.trim())
.filter(Boolean);
if (locs.length === 0) return null;
const uniq = _.uniq(locs);
return uniq.length === 1 ? uniq[0] : null;
}, [selectedLines]);
const openBulkLocationModal = () => {
setBulkLocation(commonSelectedLocation);
setBulkLocationOpen(true);
logImEXEvent("joblines_bulk_location_open", { count: selectedLineIds.length });
};
const closeBulkLocationModal = () => {
setBulkLocationOpen(false);
setBulkLocation(null);
};
const saveBulkLocation = async () => {
if (selectedLineIds.length === 0) return;
setBulkLocationSaving(true);
try {
const locationToSave = (bulkLocation ?? "").toString();
const result = await bulkUpdateLocations({
variables: {
ids: selectedLineIds,
location: locationToSave
}
});
if (!result?.errors) {
// Keep UI selection consistent without waiting for refetch
setSelectedLines((prev) =>
prev.map((l) => (l && selectedLineIds.includes(l.id) ? { ...l, location: locationToSave } : l))
);
notification["success"]({ message: t("joblines.successes.saved") });
logImEXEvent("joblines_bulk_location_saved", {
count: selectedLineIds.length,
location: locationToSave
});
closeBulkLocationModal();
if (refetch) refetch();
} else {
notification["error"]({
message: t("joblines.errors.saving", { error: JSON.stringify(result.errors) })
});
}
} catch (error) {
notification["error"]({
message: t("joblines.errors.saving", { error: error?.message || String(error) })
});
} finally {
setBulkLocationSaving(false);
}
};
const columns = [ const columns = [
{ {
title: "#", title: "#",
@@ -171,46 +261,16 @@ export function JobLinesComponent({
? ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG", "PAO"] ? ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG", "PAO"]
: ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"] : ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"]
}, },
{ { text: t("joblines.fields.part_types.PAN"), value: ["PAN"] },
text: t("joblines.fields.part_types.PAN"), { text: t("joblines.fields.part_types.PAP"), value: ["PAP"] },
value: ["PAN"] { text: t("joblines.fields.part_types.PAL"), value: ["PAL"] },
}, { text: t("joblines.fields.part_types.PAA"), value: ["PAA"] },
{ { text: t("joblines.fields.part_types.PAG"), value: ["PAG"] },
text: t("joblines.fields.part_types.PAP"), { text: t("joblines.fields.part_types.PAS"), value: ["PAS"] },
value: ["PAP"] { text: t("joblines.fields.part_types.PASL"), value: ["PASL"] },
}, { text: t("joblines.fields.part_types.PAC"), value: ["PAC"] },
{ { text: t("joblines.fields.part_types.PAR"), value: ["PAR"] },
text: t("joblines.fields.part_types.PAL"), { text: t("joblines.fields.part_types.PAM"), value: ["PAM"] },
value: ["PAL"]
},
{
text: t("joblines.fields.part_types.PAA"),
value: ["PAA"]
},
{
text: t("joblines.fields.part_types.PAG"),
value: ["PAG"]
},
{
text: t("joblines.fields.part_types.PAS"),
value: ["PAS"]
},
{
text: t("joblines.fields.part_types.PASL"),
value: ["PASL"]
},
{
text: t("joblines.fields.part_types.PAC"),
value: ["PAC"]
},
{
text: t("joblines.fields.part_types.PAR"),
value: ["PAR"]
},
{
text: t("joblines.fields.part_types.PAM"),
value: ["PAM"]
},
...(isPartsEntry ...(isPartsEntry
? [ ? [
{ {
@@ -220,7 +280,6 @@ export function JobLinesComponent({
] ]
: []) : [])
], ],
onFilter: (value, record) => value.includes(record.part_type), onFilter: (value, record) => value.includes(record.part_type),
render: (text, record) => (record.part_type ? t(`joblines.fields.part_types.${record.part_type}`) : null) render: (text, record) => (record.part_type ? t(`joblines.fields.part_types.${record.part_type}`) : null)
}, },
@@ -246,7 +305,6 @@ export function JobLinesComponent({
title: t("joblines.fields.mod_lbr_ty"), title: t("joblines.fields.mod_lbr_ty"),
dataIndex: "mod_lbr_ty", dataIndex: "mod_lbr_ty",
key: "mod_lbr_ty", key: "mod_lbr_ty",
sorter: (a, b) => alphaSort(a.mod_lbr_ty, b.mod_lbr_ty), sorter: (a, b) => alphaSort(a.mod_lbr_ty, b.mod_lbr_ty),
sortOrder: state.sortedInfo.columnKey === "mod_lbr_ty" && state.sortedInfo.order, sortOrder: state.sortedInfo.columnKey === "mod_lbr_ty" && state.sortedInfo.order,
render: (text, record) => (record.mod_lbr_ty ? t(`joblines.fields.lbr_types.${record.mod_lbr_ty}`) : null) render: (text, record) => (record.mod_lbr_ty ? t(`joblines.fields.lbr_types.${record.mod_lbr_ty}`) : null)
@@ -255,7 +313,6 @@ export function JobLinesComponent({
title: t("joblines.fields.mod_lb_hrs"), title: t("joblines.fields.mod_lb_hrs"),
dataIndex: "mod_lb_hrs", dataIndex: "mod_lb_hrs",
key: "mod_lb_hrs", key: "mod_lb_hrs",
sorter: (a, b) => a.mod_lb_hrs - b.mod_lb_hrs, sorter: (a, b) => a.mod_lb_hrs - b.mod_lb_hrs,
sortOrder: state.sortedInfo.columnKey === "mod_lb_hrs" && state.sortedInfo.order sortOrder: state.sortedInfo.columnKey === "mod_lb_hrs" && state.sortedInfo.order
}, },
@@ -310,18 +367,12 @@ export function JobLinesComponent({
sorter: (a, b) => alphaSort(a.status, b.status), sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order, sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
filteredValue: state.filteredInfo.status || null, filteredValue: state.filteredInfo.status || null,
filters: filters:
(jobLines && (jobLines &&
jobLines jobLines
.map((l) => l.status) .map((l) => l.status)
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => ({ text: s || t("dashboard.errors.status"), value: [s] }))) ||
return {
text: s || t("dashboard.errors.status"),
value: [s]
};
})) ||
[], [],
onFilter: (value, record) => value.includes(record.status), onFilter: (value, record) => value.includes(record.status),
render: (text, record) => <JobLineStatusPopup jobline={record} disabled={jobRO} /> render: (text, record) => <JobLineStatusPopup jobline={record} disabled={jobRO} />
@@ -376,9 +427,7 @@ export function JobLinesComponent({
}); });
} }
}); });
await axios.post("/job/totalsssu", { await axios.post("/job/totalsssu", { id: job.id });
id: job.id
});
if (refetch) refetch(); if (refetch) refetch();
}} }}
> >
@@ -448,6 +497,36 @@ export function JobLinesComponent({
return ( return (
<div> <div>
<PartsOrderModalContainer /> <PartsOrderModalContainer />
<Modal
open={bulkLocationOpen}
title={t("joblines.actions.updatelocation")}
onCancel={closeBulkLocationModal}
onOk={saveBulkLocation}
okButtonProps={{
disabled: jobRO || technician || selectedLineIds.length === 0,
loading: bulkLocationSaving
}}
destroyOnHidden
>
<Space direction="vertical" style={{ width: "100%" }}>
<Typography.Text type="secondary">
{t("general.labels.selected")}: {selectedLineIds.length}
</Typography.Text>
<Select
allowClear
placeholder={t("joblines.fields.location")}
value={bulkLocation}
style={{ width: "100%" }}
popupMatchSelectWidth={false}
onChange={(val) => setBulkLocation(val ?? null)}
options={(bodyshop?.md_parts_locations || []).map((loc) => ({ label: loc, value: loc }))}
/>
<Typography.Text type="secondary">{t("joblines.labels.bulk_location_help")}</Typography.Text>
</Space>
</Modal>
{!technician && ( {!technician && (
<PartsOrderDrawer <PartsOrderDrawer
job={job} job={job}
@@ -457,6 +536,7 @@ export function JobLinesComponent({
setTaskUpsertContext={setTaskUpsertContext} setTaskUpsertContext={setTaskUpsertContext}
/> />
)} )}
<PageHeader <PageHeader
title={t("jobs.labels.estimatelines")} title={t("jobs.labels.estimatelines")}
extra={ extra={
@@ -465,6 +545,16 @@ export function JobLinesComponent({
<SyncOutlined /> <SyncOutlined />
</Button> </Button>
{/* Bulk Update Location */}
<Button
id="job-lines-bulk-update-location-button"
disabled={jobRO || technician || selectedLineIds.length === 0}
onClick={openBulkLocationModal}
>
{t("joblines.actions.updatelocation")}
{selectedLineIds.length > 0 && ` (${selectedLineIds.length})`}
</Button>
{job.special_coverage_policy && ( {job.special_coverage_policy && (
<Tag color="tomato"> <Tag color="tomato">
<Space> <Space>
@@ -473,6 +563,7 @@ export function JobLinesComponent({
</Space> </Space>
</Tag> </Tag>
)} )}
{!isPartsEntry && ( {!isPartsEntry && (
<JobLineDispatchButton <JobLineDispatchButton
selectedLines={selectedLines} selectedLines={selectedLines}
@@ -481,9 +572,11 @@ export function JobLinesComponent({
disabled={technician} disabled={technician}
/> />
)} )}
{Enhanced_Payroll.treatment === "on" && ( {Enhanced_Payroll.treatment === "on" && (
<JobLineBulkAssignComponent selectedLines={selectedLines} setSelectedLines={setSelectedLines} job={job} /> <JobLineBulkAssignComponent selectedLines={selectedLines} setSelectedLines={setSelectedLines} job={job} />
)} )}
{!isPartsEntry && ( {!isPartsEntry && (
<Button <Button
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician} disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
@@ -499,27 +592,20 @@ export function JobLinesComponent({
isinhouse: true, isinhouse: true,
date: dayjs(), date: dayjs(),
total: 0, total: 0,
billlines: selectedLines.map((p) => { billlines: selectedLines.map((p) => ({
return { joblineid: p.id,
joblineid: p.id, actual_price: p.act_price,
actual_price: p.act_price, actual_cost: 0,
actual_cost: 0, //p.act_price, line_desc: p.line_desc,
line_desc: p.line_desc, line_remarks: p.line_remarks,
line_remarks: p.line_remarks, part_type: p.part_type,
part_type: p.part_type, quantity: p.quantity || 1,
quantity: p.quantity || 1, applicable_taxes: { local: false, state: false, federal: false }
applicable_taxes: { }))
local: false,
state: false,
federal: false
}
};
})
} }
} }
}); });
//Clear out the selected lines. IO-785
setSelectedLines([]); setSelectedLines([]);
}} }}
> >
@@ -528,6 +614,7 @@ export function JobLinesComponent({
{selectedLines.length > 0 && ` (${selectedLines.length})`} {selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button> </Button>
)} )}
<Button <Button
id="job-lines-order-parts-button" id="job-lines-order-parts-button"
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician} disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
@@ -544,13 +631,13 @@ export function JobLinesComponent({
} }
}); });
//Clear out the selected lines. IO-785
setSelectedLines([]); setSelectedLines([]);
}} }}
> >
{t("parts.actions.order")} {t("parts.actions.order")}
{selectedLines.length > 0 && ` (${selectedLines.length})`} {selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button> </Button>
{!isPartsEntry && ( {!isPartsEntry && (
<Button <Button
id="job-lines-filter-parts-only-button" id="job-lines-filter-parts-only-button"
@@ -567,9 +654,11 @@ export function JobLinesComponent({
<FilterFilled /> {t("jobs.actions.filterpartsonly")} <FilterFilled /> {t("jobs.actions.filterpartsonly")}
</Button> </Button>
)} )}
<Dropdown menu={markMenu} trigger={["click"]}> <Dropdown menu={markMenu} trigger={["click"]}>
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button> <Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
</Dropdown> </Dropdown>
{!isPartsEntry && ( {!isPartsEntry && (
<Button <Button
disabled={jobRO || technician} disabled={jobRO || technician}
@@ -583,9 +672,12 @@ export function JobLinesComponent({
{t("joblines.actions.new")} {t("joblines.actions.new")}
</Button> </Button>
)} )}
{!isPartsEntry && {!isPartsEntry &&
InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })} InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })}
<JobCreateIOU job={job} selectedJobLines={selectedLines} /> <JobCreateIOU job={job} selectedJobLines={selectedLines} />
<Input.Search <Input.Search
placeholder={t("general.labels.search")} placeholder={t("general.labels.search")}
onChange={(e) => { onChange={(e) => {
@@ -596,6 +688,7 @@ export function JobLinesComponent({
</Space> </Space>
} }
/> />
<Table <Table
columns={columns} columns={columns}
rowKey="id" rowKey="id"
@@ -603,9 +696,7 @@ export function JobLinesComponent({
pagination={false} pagination={false}
dataSource={jobLines} dataSource={jobLines}
onChange={handleTableChange} onChange={handleTableChange}
scroll={{ scroll={{ x: true }}
x: true
}}
expandable={{ expandable={{
expandedRowRender: (record) => expandedRowRender: (record) =>
isPartsEntry ? ( isPartsEntry ? (
@@ -614,7 +705,6 @@ export function JobLinesComponent({
<JobLinesExpander jobline={record} jobid={job.id} /> <JobLinesExpander jobline={record} jobid={job.id} />
), ),
rowExpandable: () => true, rowExpandable: () => true,
//expandRowByClick: true,
expandIcon: ({ expanded, onExpand, record }) => expandIcon: ({ expanded, onExpand, record }) =>
expanded ? ( expanded ? (
<MinusCircleTwoTone onClick={(e) => onExpand(record, e)} /> <MinusCircleTwoTone onClick={(e) => onExpand(record, e)} />
@@ -627,17 +717,15 @@ export function JobLinesComponent({
/> />
) )
}} }}
onRow={(record) => { onRow={(record) => ({
return { onDoubleClick: () => {
onDoubleClick: () => { logImEXEvent("joblines_double_click_select", {});
logImEXEvent("joblines_double_click_select", {}); const notMatchingLines = selectedLines.filter((i) => i.id !== record.id);
const notMatchingLines = selectedLines.filter((i) => i.id !== record.id); notMatchingLines.length !== selectedLines.length
notMatchingLines.length !== selectedLines.length ? setSelectedLines(notMatchingLines)
? setSelectedLines(notMatchingLines) : setSelectedLines([...selectedLines, record]);
: setSelectedLines([...selectedLines, record]); }
} // double click row })}
};
}}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedLines.map((item) => item && item.id), selectedRowKeys: selectedLines.map((item) => item && item.id),
onSelectAll: (selected, selectedRows) => { onSelectAll: (selected, selectedRows) => {

View File

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

View File

@@ -1,25 +1,23 @@
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Select, Space } from "antd"; import { Select, Space, Tag } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries"; import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = () => ({ const mapDispatchToProps = () => ({});
//setUserLanguage: language => dispatch(setUserLanguage(language))
}); const CLEAR_VALUE = "__CLEAR_LOCATION__";
export function JobLineLocationPopup({ bodyshop, jobline, disabled }) { export function JobLineLocationPopup({ bodyshop, jobline, disabled }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false);
const [location, setLocation] = useState(jobline.location); const [location, setLocation] = useState(jobline.location);
const [updateJob] = useMutation(UPDATE_JOB_LINE); const [updateJob] = useMutation(UPDATE_JOB_LINE);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -29,55 +27,78 @@ export function JobLineLocationPopup({ bodyshop, jobline, disabled }) {
if (editing) setLocation(jobline.location); if (editing) setLocation(jobline.location);
}, [editing, jobline.location]); }, [editing, jobline.location]);
const handleChange = (e) => { const options = useMemo(() => {
setLocation(e); const locs = bodyshop?.md_parts_locations || [];
}; return [
{ label: t("general.labels.none", "No location"), value: CLEAR_VALUE },
...locs.map((loc) => ({ label: loc, value: loc }))
];
}, [bodyshop?.md_parts_locations, t]);
const handleSave = async () => { const saveLocation = async (nextLocation) => {
setLoading(true); setSaving(true);
const result = await updateJob({
variables: { lineId: jobline.id, line: { location: location || "" } }
});
if (!result.errors) { try {
notification["success"]({ message: t("joblines.successes.saved") }); const result = await updateJob({
} else { variables: { lineId: jobline.id, line: { location: nextLocation || "" } }
notification["error"]({
message: t("joblines.errors.saving", {
error: JSON.stringify(result.errors)
})
}); });
if (!result.errors) {
notification["success"]({ message: t("joblines.successes.saved") });
} else {
notification["error"]({
message: t("joblines.errors.saving", {
error: JSON.stringify(result.errors)
})
});
}
} catch (error) {
notification["error"]({
message: t("joblines.errors.saving", { error: error?.message || String(error) })
});
} finally {
setSaving(false);
setEditing(false);
} }
setLoading(false);
setEditing(false);
}; };
if (editing) const handleChange = async (value) => {
const next = value === CLEAR_VALUE ? null : value;
setLocation(next);
await saveLocation(next);
};
if (editing) {
return ( return (
<div> <div style={{ width: "100%", display: "flex", alignItems: "center" }}>
<LoadingSpinner loading={loading}> <Select
<Select autoFocus
autoFocus size="small"
allowClear value={location ?? undefined}
popupMatchSelectWidth={100} loading={saving}
value={location} disabled={saving}
onClear={() => setLocation(null)} style={{ flex: 1, minWidth: 0 }}
onSelect={handleChange} popupMatchSelectWidth={false}
onBlur={handleSave} getPopupContainer={(triggerNode) => triggerNode.parentNode}
> onChange={handleChange}
{bodyshop.md_parts_locations.map((loc, idx) => ( onBlur={() => !saving && setEditing(false)}
<Select.Option key={idx} value={loc}> options={options}
{loc} />
</Select.Option>
))}
</Select>
</LoadingSpinner>
</div> </div>
); );
}
return ( return (
<div style={{ width: "100%", minHeight: "2rem", cursor: "pointer" }} onClick={() => !disabled && setEditing(true)}> <div
style={{ width: "100%", minHeight: "2rem", cursor: disabled ? "default" : "pointer" }}
onClick={() => !disabled && setEditing(true)}
>
<Space wrap> <Space wrap>
{jobline.location} {jobline.location ? (
<Tag>{jobline.location}</Tag>
) : (
<span style={{ opacity: 0.6 }}>{t("general.labels.none")}</span>
)}
{jobline.parts_dispatch_lines?.length > 0 && "-Disp"} {jobline.parts_dispatch_lines?.length > 0 && "-Disp"}
</Space> </Space>
</div> </div>

View File

@@ -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 (
<Popover
open={open}
onOpenChange={handleOpenChange}
trigger={["click"]}
placement={popoverPlacement}
content={
<div onClick={stop} style={{ minWidth: 260 }}>
<JobPartsQueueCount parts={parts} />
</div>
}
>
<div
onClick={stop}
style={{
width: "100%",
height: "19px",
cursor: canOpen ? "pointer" : "default",
userSelect: "none"
}}
title={canOpen ? t("production.labels.click_for_statuses") : undefined}
>
{displayText}
</div>
</Popover>
);
}
JobPartsReceived.propTypes = {
bodyshop: PropTypes.object,
parts: PropTypes.array,
displayMode: PropTypes.oneOf(["full", "compact"]),
popoverPlacement: PropTypes.string
};
export default connect(mapStateToProps)(JobPartsReceived);

View File

@@ -154,6 +154,10 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
: {}) : {})
}; };
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
if (selectedOwner) { if (selectedOwner) {
newJob.ownerid = selectedOwner; newJob.ownerid = selectedOwner;
delete newJob.owner; delete newJob.owner;

View File

@@ -1,4 +1,4 @@
import { Form, Input } from "antd"; import { Form, Input, Select } from "antd";
import { useContext } from "react"; import { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
@@ -7,6 +7,11 @@ import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-forma
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function JobsCreateOwnerInfoNewComponent() { export default function JobsCreateOwnerInfoNewComponent() {
const PHONE_TYPE_OPTIONS = [
{ label: t("owners.labels.home"), value: "Home" },
{ label: t("owners.labels.work"), value: "Work" },
{ label: t("owners.labels.cell"), value: "Cell" }
];
const [state] = useContext(JobCreateContext); const [state] = useContext(JobCreateContext);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -105,26 +110,56 @@ export default function JobsCreateOwnerInfoNewComponent() {
]} ]}
name={["owner", "data", "ownr_ea"]} name={["owner", "data", "ownr_ea"]}
> >
<FormItemEmail <FormItemEmail disabled={!state.owner.new} />
//email={form.getFieldValue("ownr_ea")}
disabled={!state.owner.new}
/>
</Form.Item> </Form.Item>
<Form.Item
label={t("owners.fields.ownr_ph1")} {/* Phone 1 + Type */}
name={["owner", "data", "ownr_ph1"]} <Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph1")]} <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
> <Form.Item
<FormItemPhone disabled={!state.owner.new} /> name={["owner", "data", "ownr_ph1"]}
noStyle
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph1")]}
>
<FormItemPhone disabled={!state.owner.new} style={{ flex: 1, minWidth: 150 }} />
</Form.Item>
<Form.Item name={["owner", "data", "ownr_ph1_ty"]} noStyle>
<Select
disabled={!state.owner.new}
allowClear
placeholder="Type"
options={PHONE_TYPE_OPTIONS}
style={{ width: 110 }}
/>
</Form.Item>
</div>
</Form.Item> </Form.Item>
<Form.Item
label={t("owners.fields.ownr_ph2")} {/* Phone 2 + Type */}
name={["owner", "data", "ownr_ph2"]} <Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph2")]} <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
> <Form.Item
<FormItemPhone disabled={!state.owner.new} /> name={["owner", "data", "ownr_ph2"]}
noStyle
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "owner.data.ownr_ph2")]}
>
<FormItemPhone disabled={!state.owner.new} style={{ flex: 1, minWidth: 150 }} />
</Form.Item>
<Form.Item name={["owner", "data", "ownr_ph2_ty"]} noStyle>
<Select
disabled={!state.owner.new}
allowClear
placeholder="Type"
options={PHONE_TYPE_OPTIONS}
style={{ width: 110 }}
/>
</Form.Item>
</div>
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow grow> <LayoutFormRow grow>
<Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}> <Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}>
<Input disabled={!state.owner.new} /> <Input disabled={!state.owner.new} />

View File

@@ -61,7 +61,7 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
title: t("owners.fields.ownr_ph1"), title: t("owners.fields.ownr_ph1"),
dataIndex: "ownr_ph1", dataIndex: "ownr_ph1",
key: "ownr_ph1", key: "ownr_ph1",
render: (text, record) => <PhoneFormatter>{record.ownr_ph1}</PhoneFormatter>, render: (text, record) => <PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>,
sorter: (a, b) => alphaSort(a.ownr_ph1, b.ownr_ph1), sorter: (a, b) => alphaSort(a.ownr_ph1, b.ownr_ph1),
sortOrder: tableState.sortedInfo.columnKey === "ownr_ph1" && tableState.sortedInfo.order 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"), title: t("owners.fields.ownr_ph2"),
dataIndex: "ownr_ph2", dataIndex: "ownr_ph2",
key: "ownr_ph2", key: "ownr_ph2",
render: (text, record) => <PhoneFormatter>{record.ownr_ph2}</PhoneFormatter>, render: (text, record) => <PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>,
sorter: (a, b) => alphaSort(a.ownr_ph2, b.ownr_ph2), sorter: (a, b) => alphaSort(a.ownr_ph2, b.ownr_ph2),
sortOrder: tableState.sortedInfo.columnKey === "ownr_ph2" && tableState.sortedInfo.order sortOrder: tableState.sortedInfo.columnKey === "ownr_ph2" && tableState.sortedInfo.order
} }

View File

@@ -175,25 +175,33 @@ export function JobsDetailHeaderActions({
}; };
const handleDuplicate = () => const handleDuplicate = () =>
DuplicateJob( DuplicateJob({
client, apolloClient: client,
job.id, jobId: job.id,
{ defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
(newJobId) => { completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`); history(`/manage/jobs/${newJobId}`);
notification.success({ notification.success({
message: t("jobs.successes.duplicated") message: t("jobs.successes.duplicated")
}); });
}, },
true keepJobLines: true,
); currentUser
});
const handleDuplicateConfirm = () => const handleDuplicateConfirm = () =>
DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => { DuplicateJob({
history(`/manage/jobs/${newJobId}`); apolloClient: client,
notification.success({ jobId: job.id,
message: t("jobs.successes.duplicated") 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) => { const handleFinish = async (values) => {
@@ -609,7 +617,7 @@ export function JobsDetailHeaderActions({
<FormDateTimePickerComponent <FormDateTimePickerComponent
onBlur={() => { onBlur={() => {
const start = form.getFieldValue("start"); const start = form.getFieldValue("start");
form.setFieldsValue({ end: start.add(30, "minutes") }); form.setFieldsValue({ end: start?.add(30, "minutes") });
}} }}
/> />
</Form.Item> </Form.Item>

View File

@@ -5,7 +5,14 @@ import { INSERT_NEW_JOB, QUERY_JOB_FOR_DUPE } from "../../graphql/jobs.queries";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import i18n from "i18next"; 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"); logImEXEvent("job_duplicate");
const { defaultOpenStatus } = config; const { defaultOpenStatus } = config;
@@ -19,6 +26,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
const existingJob = _.cloneDeep(jobs_by_pk); const existingJob = _.cloneDeep(jobs_by_pk);
delete existingJob.__typename; delete existingJob.__typename;
delete existingJob.id; delete existingJob.id;
delete existingJob.created_user_email;
delete existingJob.createdat; delete existingJob.createdat;
delete existingJob.updatedat; delete existingJob.updatedat;
delete existingJob.cieca_stl; delete existingJob.cieca_stl;
@@ -29,6 +37,10 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
status: defaultOpenStatus status: defaultOpenStatus
}; };
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
const _tempLines = _.cloneDeep(existingJob.joblines); const _tempLines = _.cloneDeep(existingJob.joblines);
_tempLines.forEach((line) => { _tempLines.forEach((line) => {
delete line.id; delete line.id;
@@ -55,7 +67,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
return; return;
} }
export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKeep) { export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToKeep, currentUser }) {
logImEXEvent("job_create_iou"); logImEXEvent("job_create_iou");
const { status } = config; const { status } = config;
@@ -109,6 +121,9 @@ export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKee
delete newJob.joblines; delete newJob.joblines;
newJob.joblines = { data: _tempLines }; newJob.joblines = { data: _tempLines };
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
const res2 = await apolloClient.mutate({ const res2 = await apolloClient.mutate({
mutation: INSERT_NEW_JOB, mutation: INSERT_NEW_JOB,
variables: { job: [newJob] } variables: { job: [newJob] }

View File

@@ -251,16 +251,16 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<div> <div>
<DataLabel key="2" label={t("jobs.fields.ownr_ph1")}> <DataLabel key="2" label={t("jobs.fields.ownr_ph1")}>
{disabled || isPartsEntry ? ( {disabled || isPartsEntry ? (
<PhoneNumberFormatter>{job.ownr_ph1}</PhoneNumberFormatter> <PhoneNumberFormatter type={job.ownr_ph1_ty}>{job.ownr_ph1}</PhoneNumberFormatter>
) : ( ) : (
<ChatOpenButton phone={job.ownr_ph1} jobid={job.id} /> <ChatOpenButton type={job.ownr_ph1_ty} phone={job.ownr_ph1} jobid={job.id} />
)} )}
</DataLabel> </DataLabel>
<DataLabel key="22" label={t("jobs.fields.ownr_ph2")}> <DataLabel key="22" label={t("jobs.fields.ownr_ph2")}>
{disabled || isPartsEntry ? ( {disabled || isPartsEntry ? (
<PhoneNumberFormatter>{job.ownr_ph2}</PhoneNumberFormatter> <PhoneNumberFormatter type={job.ownr_ph2_ty}>{job.ownr_ph2}</PhoneNumberFormatter>
) : ( ) : (
<ChatOpenButton phone={job.ownr_ph2} jobid={job.id} /> <ChatOpenButton type={job.ownr_ph2_ty} phone={job.ownr_ph2} jobid={job.id} />
)} )}
</DataLabel> </DataLabel>
<DataLabel key="3" label={t("owners.fields.address")}> <DataLabel key="3" label={t("owners.fields.address")}>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { FileExcelFilled, SyncOutlined } from "@ant-design/icons"; import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Col, Row, Space } from "antd"; import { Alert, Button, Card, Col, Row, Space } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Gallery } from "react-grid-gallery"; import { Gallery } from "react-grid-gallery";
@@ -107,8 +107,8 @@ export function JobsDocumentsLocalGallery({
<a href={CreateExplorerLinkForJob({ jobid: job.id })}> <a href={CreateExplorerLinkForJob({ jobid: job.id })}>
<Button>{t("documents.labels.openinexplorer")}</Button> <Button>{t("documents.labels.openinexplorer")}</Button>
</a> </a>
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} /> <JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} />
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsLocalGalleryDownloadButton job={job} /> <JobsLocalGalleryDownloadButton job={job} />
<JobsDocumentsLocalDeleteButton jobid={job.id} /> <JobsDocumentsLocalDeleteButton jobid={job.id} />
</Space> </Space>
@@ -185,6 +185,21 @@ export function JobsDocumentsLocalGallery({
</Col> </Col>
{modalState.open && ( {modalState.open && (
<Lightbox <Lightbox
toolbarButtons={[
<EditFilled
key="edit"
onClick={() => {
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} mainSrc={jobMedia.images[modalState.index].fullsize}
nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize} nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize}
prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize} prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize}

View File

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

View File

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

View File

@@ -64,7 +64,11 @@ export default function JobsFindModalComponent({
width: "12%", width: "12%",
ellipsis: true, ellipsis: true,
render: (text, record) => { render: (text, record) => {
return record.ownr_ph1 ? <PhoneFormatter>{record.ownr_ph1}</PhoneFormatter> : t("general.labels.unknown"); return record.ownr_ph1 ? (
<PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>
) : (
t("general.labels.unknown")
);
} }
}, },
{ {
@@ -74,7 +78,11 @@ export default function JobsFindModalComponent({
width: "12%", width: "12%",
ellipsis: true, ellipsis: true,
render: (text, record) => { render: (text, record) => {
return record.ownr_ph2 ? <PhoneFormatter>{record.ownr_ph2}</PhoneFormatter> : t("general.labels.unknown"); return record.ownr_ph2 ? (
<PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>
) : (
t("general.labels.unknown")
);
} }
}, },
{ {
@@ -245,7 +253,11 @@ export default function JobsFindModalComponent({
> >
{t("jobs.labels.override_header")} {t("jobs.labels.override_header")}
</Checkbox> </Checkbox>
<Checkbox checked={partsQueueToggle} onChange={(e) => setPartsQueueToggle(e.target.checked)} id="parts_queue_toggle"> <Checkbox
checked={partsQueueToggle}
onChange={(e) => setPartsQueueToggle(e.target.checked)}
id="parts_queue_toggle"
>
{t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")} {t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")}
</Checkbox> </Checkbox>
<Checkbox <Checkbox

View File

@@ -72,14 +72,14 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
dataIndex: "ownr_ph1", dataIndex: "ownr_ph1",
key: "ownr_ph1", key: "ownr_ph1",
ellipsis: true, ellipsis: true,
render: (text, record) => <StartChatButton phone={record.ownr_ph1} jobid={record.id} /> render: (text, record) => <StartChatButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.id} />
}, },
{ {
title: t("jobs.fields.ownr_ph2"), title: t("jobs.fields.ownr_ph2"),
dataIndex: "ownr_ph2", dataIndex: "ownr_ph2",
key: "ownr_ph2", key: "ownr_ph2",
ellipsis: true, ellipsis: true,
render: (text, record) => <StartChatButton phone={record.ownr_ph2} jobid={record.id} /> render: (text, record) => <StartChatButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.id} />
}, },
{ {
title: t("jobs.fields.status"), title: t("jobs.fields.status"),

View File

@@ -139,7 +139,7 @@ export function JobsList({ bodyshop }) {
key: "ownr_ph1", key: "ownr_ph1",
ellipsis: true, ellipsis: true,
responsive: ["md"], responsive: ["md"],
render: (text, record) => <ChatOpenButton phone={record.ownr_ph1} jobid={record.id} /> render: (text, record) => <ChatOpenButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.id} />
}, },
{ {
title: t("jobs.fields.ownr_ph2"), title: t("jobs.fields.ownr_ph2"),
@@ -147,7 +147,7 @@ export function JobsList({ bodyshop }) {
key: "ownr_ph2", key: "ownr_ph2",
ellipsis: true, ellipsis: true,
responsive: ["md"], responsive: ["md"],
render: (text, record) => <ChatOpenButton phone={record.ownr_ph2} jobid={record.id} /> render: (text, record) => <ChatOpenButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.id} />
}, },
{ {

View File

@@ -140,7 +140,7 @@ export function JobsReadyList({ bodyshop }) {
key: "ownr_ph1", key: "ownr_ph1",
ellipsis: true, ellipsis: true,
responsive: ["md"], responsive: ["md"],
render: (text, record) => <ChatOpenButton phone={record.ownr_ph1} jobid={record.id} /> render: (text, record) => <ChatOpenButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.id} />
}, },
{ {
title: t("jobs.fields.ownr_ph2"), title: t("jobs.fields.ownr_ph2"),
@@ -148,7 +148,7 @@ export function JobsReadyList({ bodyshop }) {
key: "ownr_ph2", key: "ownr_ph2",
ellipsis: true, ellipsis: true,
responsive: ["md"], responsive: ["md"],
render: (text, record) => <ChatOpenButton phone={record.ownr_ph2} jobid={record.id} /> render: (text, record) => <ChatOpenButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.id} />
}, },
{ {
title: t("jobs.fields.status"), title: t("jobs.fields.status"),

View File

@@ -1,4 +1,4 @@
import { Form, Input, Tooltip } from "antd"; import { Form, Input, Select, Tooltip } from "antd";
import { CloseCircleFilled } from "@ant-design/icons"; import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component"; import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
@@ -10,6 +10,12 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
const { t } = useTranslation(); const { t } = useTranslation();
const { getFieldValue } = form; const { getFieldValue } = form;
const PHONE_TYPE_OPTIONS = [
{ label: t("owners.labels.home"), value: "Home" },
{ label: t("owners.labels.work"), value: "Work" },
{ label: t("owners.labels.cell"), value: "Cell" }
];
return ( return (
<div> <div>
<FormFieldsChanged form={form} /> <FormFieldsChanged form={form} />
@@ -30,6 +36,7 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
<Input disabled /> <Input disabled />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.address")}> <LayoutFormRow header={t("owners.forms.address")}>
<Form.Item label={t("owners.fields.ownr_addr1")} name="ownr_addr1"> <Form.Item label={t("owners.fields.ownr_addr1")} name="ownr_addr1">
<Input /> <Input />
@@ -50,6 +57,7 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
<Input /> <Input />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.contact")}> <LayoutFormRow header={t("owners.forms.contact")}>
<Form.Item <Form.Item
label={t("owners.fields.ownr_ea")} label={t("owners.fields.ownr_ea")}
@@ -63,6 +71,8 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
> >
<FormItemEmail email={getFieldValue("ownr_ea")} /> <FormItemEmail email={getFieldValue("ownr_ea")} />
</Form.Item> </Form.Item>
{/* Phone 1 + Type + Opt-out icon */}
<Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}> <Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item <Form.Item
@@ -72,6 +82,11 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
> >
<Input style={{ flex: 1, minWidth: "150px" }} /> <Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item> </Form.Item>
<Form.Item name="ownr_ph1_ty" noStyle>
<Select allowClear placeholder="Type" options={PHONE_TYPE_OPTIONS} style={{ width: 110 }} />
</Form.Item>
{isPhone1OptedOut && ( {isPhone1OptedOut && (
<Tooltip title={t("consent.text_body")}> <Tooltip title={t("consent.text_body")}>
<CloseCircleFilled <CloseCircleFilled
@@ -88,6 +103,8 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
)} )}
</div> </div>
</Form.Item> </Form.Item>
{/* Phone 2 + Type + Opt-out icon */}
<Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}> <Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item <Form.Item
@@ -97,6 +114,11 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
> >
<Input style={{ flex: 1, minWidth: "150px" }} /> <Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item> </Form.Item>
<Form.Item name="ownr_ph2_ty" noStyle>
<Select allowClear placeholder="Type" options={PHONE_TYPE_OPTIONS} style={{ width: 110 }} />
</Form.Item>
{isPhone2OptedOut && ( {isPhone2OptedOut && (
<Tooltip title={t("consent.text_body")}> <Tooltip title={t("consent.text_body")}>
<CloseCircleFilled <CloseCircleFilled
@@ -113,6 +135,7 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
)} )}
</div> </div>
</Form.Item> </Form.Item>
<Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact"> <Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact">
<Input /> <Input />
</Form.Item> </Form.Item>
@@ -120,6 +143,7 @@ export default function OwnerDetailFormComponent({ form, isPhone1OptedOut, isPho
<Input /> <Input />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<Form.Item label={t("owners.fields.note")} name="note"> <Form.Item label={t("owners.fields.note")} name="note">
<Input.TextArea rows={4} /> <Input.TextArea rows={4} />
</Form.Item> </Form.Item>

View File

@@ -25,8 +25,10 @@ export default function OwnerDetailUpdateJobsComponent({ owner, selectedJobs, di
ownr_ea: owner["ownr_ea"], ownr_ea: owner["ownr_ea"],
ownr_fn: owner["ownr_fn"], ownr_fn: owner["ownr_fn"],
ownr_ph1: owner["ownr_ph1"], ownr_ph1: owner["ownr_ph1"],
ownr_ph1_ty: owner["ownr_ph1_ty"],
ownr_ln: owner["ownr_ln"], ownr_ln: owner["ownr_ln"],
ownr_ph2: owner["ownr_ph2"], ownr_ph2: owner["ownr_ph2"],
ownr_ph2_ty: owner["ownr_ph2_ty"],
ownr_st: owner["ownr_st"], ownr_st: owner["ownr_st"],
ownr_title: owner["ownr_title"], ownr_title: owner["ownr_title"],
ownr_zip: owner["ownr_zip"] ownr_zip: owner["ownr_zip"]

View File

@@ -47,13 +47,13 @@ export default function OwnerFindModalComponent({
title: t("owners.fields.ownr_ph1"), title: t("owners.fields.ownr_ph1"),
dataIndex: "ownr_ph1", dataIndex: "ownr_ph1",
key: "ownr_ph1", key: "ownr_ph1",
render: (text, record) => <PhoneFormatter>{record.ownr_ph1}</PhoneFormatter> render: (text, record) => <PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>
}, },
{ {
title: t("owners.fields.ownr_ph2"), title: t("owners.fields.ownr_ph2"),
dataIndex: "ownr_ph2", dataIndex: "ownr_ph2",
key: "ownr_ph2", key: "ownr_ph2",
render: (text, record) => <PhoneFormatter>{record.ownr_ph2}</PhoneFormatter> render: (text, record) => <PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>
}, },
{ {
title: t("owners.fields.note"), title: t("owners.fields.note"),

View File

@@ -15,10 +15,10 @@ export default function OwnerTagPopoverComponent({ job }) {
<OwnerNameDisplay ownerObject={job} /> <OwnerNameDisplay ownerObject={job} />
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item key="2" label={t("jobs.fields.ownr_ph1")}> <Descriptions.Item key="2" label={t("jobs.fields.ownr_ph1")}>
<PhoneFormatter>{job.ownr_ph1 || ""}</PhoneFormatter> <PhoneFormatter type={job.ownr_ph1_ty}>{job.ownr_ph1 || ""}</PhoneFormatter>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item key="22" label={t("jobs.fields.ownr_ph2")}> <Descriptions.Item key="22" label={t("jobs.fields.ownr_ph2")}>
<PhoneFormatter>{job.ownr_ph2 || ""}</PhoneFormatter> <PhoneFormatter type={job.ownr_ph2_ty}>{job.ownr_ph2 || ""}</PhoneFormatter>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item key="3" label={t("owners.fields.address")}> <Descriptions.Item key="3" label={t("owners.fields.address")}>
{`${job.ownr_addr1 || ""} ${job.ownr_addr2 || ""} ${ {`${job.ownr_addr1 || ""} ${job.ownr_addr2 || ""} ${
@@ -36,13 +36,10 @@ export default function OwnerTagPopoverComponent({ job }) {
<OwnerNameDisplay ownerObject={job.owner} /> <OwnerNameDisplay ownerObject={job.owner} />
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item key="2" label={t("jobs.fields.ownr_ph1")}> <Descriptions.Item key="2" label={t("jobs.fields.ownr_ph1")}>
<PhoneFormatter>{job.owner.ownr_ph1 || ""}</PhoneFormatter> <PhoneFormatter type={job.owner.ownr_ph1_ty}>{job.owner.ownr_ph1 || ""}</PhoneFormatter>
</Descriptions.Item>
<Descriptions.Item key="22" label={t("jobs.fields.ownr_ph2")}>
<PhoneFormatter>{job.owner.ownr_ph2 || ""}</PhoneFormatter>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item key="2" label={t("jobs.fields.ownr_ph2")}> <Descriptions.Item key="2" label={t("jobs.fields.ownr_ph2")}>
<PhoneFormatter>{job.owner.ownr_ph2 || ""}</PhoneFormatter> <PhoneFormatter type={job.owner.ownr_ph2_ty}>{job.owner.ownr_ph2 || ""}</PhoneFormatter>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item key="3" label={t("owners.fields.address")}> <Descriptions.Item key="3" label={t("owners.fields.address")}>
{`${job.owner.ownr_addr1 || ""} ${job.owner.ownr_addr2 || ""} ${ {`${job.owner.ownr_addr1 || ""} ${job.owner.ownr_addr2 || ""} ${

View File

@@ -39,7 +39,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
dataIndex: "ownr_ph1", dataIndex: "ownr_ph1",
key: "ownr_ph1", key: "ownr_ph1",
render: (text, record) => { render: (text, record) => {
return <PhoneFormatter>{record.ownr_ph1}</PhoneFormatter>; return <PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>;
} }
}, },
{ {
@@ -47,7 +47,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
dataIndex: "ownr_ph2", dataIndex: "ownr_ph2",
key: "ownr_ph2", key: "ownr_ph2",
render: (text, record) => { render: (text, record) => {
return <PhoneFormatter>{record.ownr_ph2}</PhoneFormatter>; return <PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>;
} }
}, },
{ {

View File

@@ -16,11 +16,11 @@ import { pageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters"; import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage"; import useLocalStorage from "../../utils/useLocalStorage";
import AlertComponent from "../alert/alert.component"; 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 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 OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import JobPartsReceived from "../job-parts-received/job-parts-received.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -235,7 +235,9 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.partsstatus"), title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus", dataIndex: "partsstatus",
key: "partsstatus", key: "partsstatus",
render: (text, record) => <JobPartsQueueCount parts={record.joblines_status} /> render: (text, record) => (
<JobPartsReceived parts={record.joblines_status} displayMode="full" popoverPlacement="topLeft" />
)
}, },
{ {
title: t("jobs.fields.comment"), title: t("jobs.fields.comment"),

View File

@@ -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 OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx"; import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import { PiMicrosoftTeamsLogo } from "react-icons/pi"; import { PiMicrosoftTeamsLogo } from "react-icons/pi";
import ProductionListColumnPartsReceived from "../production-list-columns/production-list-columns.partsreceived.component";
const cardColor = (ssbuckets, totalHrs) => { const cardColor = (ssbuckets, totalHrs) => {
const bucket = ssbuckets.find((bucket) => bucket.gte <= totalHrs && (!bucket.lt || bucket.lt > totalHrs)); const bucket = ssbuckets.find((bucket) => bucket.gte <= totalHrs && (!bucket.lt || bucket.lt > totalHrs));
@@ -312,6 +313,20 @@ const TasksToolTip = ({ metadata, cardSettings, t }) =>
</Col> </Col>
); );
const PartsReceivedComponent = ({ metadata, cardSettings, card }) =>
cardSettings?.partsreceived && (
<Col span={24} style={{ textAlign: "center" }}>
<ProductionListColumnPartsReceived
displayMode="full"
popoverPlacement="topLeft"
record={{
...metadata,
id: card?.id,
refetch: card?.refetch
}}
/>
</Col>
);
export default function ProductionBoardCard({ technician, card, bodyshop, cardSettings }) { export default function ProductionBoardCard({ technician, card, bodyshop, cardSettings }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { metadata } = card; const { metadata } = card;
@@ -411,6 +426,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
<SubletsComponent metadata={metadata} cardSettings={cardSettings} /> <SubletsComponent metadata={metadata} cardSettings={cardSettings} />
<ProductionNoteComponent metadata={metadata} cardSettings={cardSettings} card={card} /> <ProductionNoteComponent metadata={metadata} cardSettings={cardSettings} card={card} />
<PartsStatusComponent metadata={metadata} cardSettings={cardSettings} /> <PartsStatusComponent metadata={metadata} cardSettings={cardSettings} />
<PartsReceivedComponent metadata={metadata} cardSettings={cardSettings} card={card} />
</Row> </Row>
); );

View File

@@ -18,7 +18,8 @@ const InformationSettings = ({ t }) => (
"partsstatus", "partsstatus",
"estimator", "estimator",
"subtotal", "subtotal",
"tasks" "tasks",
"partsreceived"
].map((item) => ( ].map((item) => (
<Col xs={24} sm={12} md={8} lg={6} key={item}> <Col xs={24} sm={12} md={8} lg={6} key={item}>
<Form.Item name={item} valuePropName="checked"> <Form.Item name={item} valuePropName="checked">

View File

@@ -74,6 +74,7 @@ const defaultKanbanSettings = {
cardSize: "small", cardSize: "small",
model_info: true, model_info: true,
kiosk: false, kiosk: false,
partsreceived: false,
totalHrs: true, totalHrs: true,
totalAmountInProduction: false, totalAmountInProduction: false,
totalLAB: true, totalLAB: true,

View File

@@ -161,7 +161,6 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
dataIndex: "actual_in_time", dataIndex: "actual_in_time",
key: "actual_in_time", key: "actual_in_time",
ellipsis: true, ellipsis: true,
render: (text, record) => <TimeFormatter>{record.actual_in}</TimeFormatter> render: (text, record) => <TimeFormatter>{record.actual_in}</TimeFormatter>
}, },
{ {
@@ -181,6 +180,22 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
render: (text, record) => <TimeFormatter>{record.scheduled_completion}</TimeFormatter> render: (text, record) => <TimeFormatter>{record.scheduled_completion}</TimeFormatter>
}, },
{
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) => <ProductionListDate record={record} field="actual_completion" time />
},
{
title: i18n.t("jobs.fields.actual_completion") + " (HH:MM)",
dataIndex: "actual_completion_time",
key: "actual_completion_time",
ellipsis: true,
render: (text, record) => <TimeFormatter>{record.actual_completion}</TimeFormatter>
},
{ {
title: i18n.t("jobs.fields.date_last_contacted"), title: i18n.t("jobs.fields.date_last_contacted"),
dataIndex: "date_last_contacted", dataIndex: "date_last_contacted",
@@ -255,14 +270,14 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
dataIndex: "ownr_ph1", dataIndex: "ownr_ph1",
key: "ownr_ph1", key: "ownr_ph1",
ellipsis: true, ellipsis: true,
render: (text, record) => <PhoneFormatter>{record.ownr_ph1}</PhoneFormatter> render: (text, record) => <PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>
}, },
{ {
title: i18n.t("jobs.fields.ownr_ph2"), title: i18n.t("jobs.fields.ownr_ph2"),
dataIndex: "ownr_ph2", dataIndex: "ownr_ph2",
key: "ownr_ph2", key: "ownr_ph2",
ellipsis: true, ellipsis: true,
render: (text, record) => <PhoneFormatter>{record.ownr_ph2}</PhoneFormatter> render: (text, record) => <PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>
}, },
{ {
title: i18n.t("jobs.fields.specialcoveragepolicy"), title: i18n.t("jobs.fields.specialcoveragepolicy"),

View File

@@ -144,7 +144,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
<Spin spinning={loading}> <Spin spinning={loading}>
{record[type] ? ( {record[type] ? (
<div> <div>
<span>{`${theEmployee.first_name || ""} ${theEmployee.last_name || ""}`}</span> <span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} /> <DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
</div> </div>
) : ( ) : (

View File

@@ -1,33 +1,5 @@
import { useMemo } from "react"; import JobPartsReceived from "../job-parts-received/job-parts-received.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ export default function ProductionListColumnPartsReceived({ record }) {
bodyshop: selectBodyshop return <JobPartsReceived parts={record.joblines_status} displayMode="full" popoverPlacement="topLeft" />;
});
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})`;
} }

View File

@@ -154,13 +154,25 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
<OwnerNameDisplay ownerObject={theJob} /> <OwnerNameDisplay ownerObject={theJob} />
{!technician ? ( {!technician ? (
<> <>
<StartChatButton phone={data.jobs_by_pk.ownr_ph1} jobid={data.jobs_by_pk.id} /> <StartChatButton
<StartChatButton phone={data.jobs_by_pk.ownr_ph2} jobid={data.jobs_by_pk.id} /> type={data.jobs_by_pk.ownr_ph1_ty}
phone={data.jobs_by_pk.ownr_ph1}
jobid={data.jobs_by_pk.id}
/>
<StartChatButton
type={data.jobs_by_pk.ownr_ph2_ty}
phone={data.jobs_by_pk.ownr_ph2}
jobid={data.jobs_by_pk.id}
/>
</> </>
) : ( ) : (
<> <>
<PhoneNumberFormatter>{data.jobs_by_pk.ownr_ph1}</PhoneNumberFormatter> <PhoneNumberFormatter type={data.jobs_by_pk.ownr_ph1_ty}>
<PhoneNumberFormatter>{data.jobs_by_pk.ownr_ph2}</PhoneNumberFormatter> {data.jobs_by_pk.ownr_ph1}
</PhoneNumberFormatter>
<PhoneNumberFormatter type={data.jobs_by_pk.ownr_ph2_ty}>
{data.jobs_by_pk.ownr_ph2}
</PhoneNumberFormatter>
</> </>
)} )}
</Space> </Space>

View File

@@ -143,7 +143,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
//TODO: Find a way to filter out / blur on demand. //TODO: Find a way to filter out / blur on demand.
return ( return (
<div> <div className="report-center-modal">
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} /> <Input.Search onChange={(e) => setSearch(e.target.value)} value={search} />
<Form.Item name="defaultSorters" hidden /> <Form.Item name="defaultSorters" hidden />
@@ -163,13 +163,14 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
{Object.keys(grouped) {Object.keys(grouped)
//.filter((key) => !groupExcludeKeyFilter.includes(key)) //.filter((key) => !groupExcludeKeyFilter.includes(key))
.map((key) => ( .map((key) => (
<Col md={8} sm={12} key={key}> <Col xs={24} sm={12} md={Object.keys(grouped).length === 1 ? 24 : 8} key={key}>
<Card.Grid <Card.Grid
style={{ style={{
width: "100%", width: "100%",
height: "100%", height: "100%",
maxHeight: "33vh", maxHeight: "33vh",
overflowY: "scroll" overflowY: "scroll",
minWidth: "200px"
}} }}
> >
<Typography.Title level={4}>{t(`reportcenter.labels.groups.${key}`)}</Typography.Title> <Typography.Title level={4}>{t(`reportcenter.labels.groups.${key}`)}</Typography.Title>
@@ -177,7 +178,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
<BlurWrapperComponent <BlurWrapperComponent
featureName={groupExcludeKeyFilter.find((g) => g.key === key).featureName} featureName={groupExcludeKeyFilter.find((g) => g.key === key).featureName}
> >
<ul style={{ listStyleType: "none", columns: "2 auto" }}> <ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}>
{grouped[key].map((item) => ( {grouped[key].map((item) => (
<li key={item.key}> <li key={item.key}>
<Radio key={item.key} value={item.key}> <Radio key={item.key} value={item.key}>
@@ -188,7 +189,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
</ul> </ul>
</BlurWrapperComponent> </BlurWrapperComponent>
) : ( ) : (
<ul style={{ listStyleType: "none", columns: "2 auto" }}> <ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}>
{grouped[key].map((item) => {grouped[key].map((item) =>
item.featureNameRestricted ? ( item.featureNameRestricted ? (
<li key={item.key}> <li key={item.key}>

View File

@@ -11,3 +11,38 @@
} }
} }
} }
// Report center modal fixes for column layout
.report-center-modal {
.ant-form-item .ant-radio-group {
width: 100%;
.ant-card-grid {
padding: 16px;
box-sizing: border-box;
ul {
width: 100%;
li {
margin-bottom: 8px;
break-inside: avoid;
page-break-inside: avoid;
.ant-radio-wrapper {
display: flex;
align-items: flex-start;
width: 100%;
span:not(.ant-radio) {
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
flex: 1;
}
}
}
}
}
}
}

View File

@@ -1,12 +1,9 @@
import { DeleteFilled } from "@ant-design/icons"; 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; 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 FeatureWrapper, { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormItemEmail from "../form-items-formatted/email-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 }) { export function ShopInfoGeneral({ form, bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const {
treatments: { ClosingPeriod, ADPPayroll }
} = useSplitTreatments({
attributes: {},
names: ["ClosingPeriod", "ADPPayroll"],
splitKey: bodyshop?.imexshopid
});
return ( return (
<div> <div>
<LayoutFormRow header={t("bodyshop.labels.businessinformation")} id="businessinformation"> <LayoutFormRow header={t("bodyshop.labels.businessinformation")} id="businessinformation">
@@ -143,299 +132,6 @@ export function ShopInfoGeneral({ form, bodyshop }) {
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup">
{[
...(HasFeatureAccess({ featureName: "export", bodyshop })
? [
<Form.Item
key="qbo"
label={t("bodyshop.labels.qbo")}
valuePropName="checked"
name={["accountingconfig", "qbo"]}
>
<Switch />
</Form.Item>,
InstanceRenderManager({
imex: (
<Form.Item key="qbo_usa_wrapper" shouldUpdate noStyle>
{() => (
<Form.Item
label={t("bodyshop.labels.qbo_usa")}
shouldUpdate
valuePropName="checked"
name={["accountingconfig", "qbo_usa"]}
>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
</Form.Item>
)}
</Form.Item>
)
}),
<Form.Item
key="qbo_departmentid"
label={t("bodyshop.labels.qbo_departmentid")}
name={["accountingconfig", "qbo_departmentid"]}
>
<Input />
</Form.Item>,
<Form.Item
key="accountingtiers"
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>,
<Form.Item key="twotierpref_wrapper" shouldUpdate>
{() => {
return (
<Form.Item
label={t("bodyshop.labels.2tiersetup")}
shouldUpdate
rules={[
{
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
>
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item>
);
}}
</Form.Item>,
<Form.Item
key="printlater"
label={t("bodyshop.labels.printlater")}
valuePropName="checked"
name={["accountingconfig", "printlater"]}
>
<Switch />
</Form.Item>,
<Form.Item
key="emaillater"
label={t("bodyshop.labels.emaillater")}
valuePropName="checked"
name={["accountingconfig", "emaillater"]}
>
<Switch />
</Form.Item>
]
: []),
<Form.Item
key="inhousevendorid"
label={t("bodyshop.fields.inhousevendorid")}
name={"inhousevendorid"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>,
<Form.Item
key="default_adjustment_rate"
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>,
InstanceRenderManager({
imex: (
<Form.Item key="federal_tax_id" label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
<Input />
</Form.Item>
)
}),
<Form.Item key="state_tax_id" label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>,
...(HasFeatureAccess({ featureName: "bills", bodyshop })
? [
InstanceRenderManager({
imex: (
<Form.Item
key="invoice_federal_tax_rate"
label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
)
}),
<Form.Item
key="invoice_state_tax_rate"
label={t("bodyshop.fields.invoice_state_tax_rate")}
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>,
<Form.Item
key="invoice_local_tax_rate"
label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
]
: []),
<Form.Item
key="md_payment_types"
name={["md_payment_types"]}
label={t("bodyshop.fields.md_payment_types")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>,
<Form.Item
key="md_categories"
name={["md_categories"]}
label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>,
...(HasFeatureAccess({ featureName: "export", bodyshop })
? [
<Form.Item
key="ReceivableCustomField1"
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="ReceivableCustomField2"
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="ReceivableCustomField3"
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="md_classes"
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>,
<Form.Item
key="enforce_class"
name={["enforce_class"]}
label={t("bodyshop.fields.enforce_class")}
valuePropName="checked"
>
<Switch />
</Form.Item>,
...(ClosingPeriod.treatment === "on"
? [
<Form.Item
key="ClosingPeriod"
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
]
: []),
...(ADPPayroll.treatment === "on"
? [
<Form.Item
key="companyCode"
name={["accountingconfig", "companyCode"]}
label={t("bodyshop.fields.companycode")}
>
<Input />
</Form.Item>
]
: []),
...(ADPPayroll.treatment === "on"
? [
<Form.Item
key="batchID"
name={["accountingconfig", "batchID"]}
label={t("bodyshop.fields.batchid")}
>
<Input />
</Form.Item>
]
: [])
]
: []),
<Form.Item
key="accumulatePayableLines"
name={["accountingconfig", "accumulatePayableLines"]}
label={t("bodyshop.fields.accumulatePayableLines")}
valuePropName="checked"
>
<Switch />
</Form.Item>
]}
</LayoutFormRow>
<FeatureWrapper featureName="scoreboard" noauth={() => null}> <FeatureWrapper featureName="scoreboard" noauth={() => null}>
<LayoutFormRow header={t("bodyshop.labels.scoreboardsetup")} id="scoreboardsetup"> <LayoutFormRow header={t("bodyshop.labels.scoreboardsetup")} id="scoreboardsetup">
<Form.Item <Form.Item
@@ -499,6 +195,19 @@ export function ShopInfoGeneral({ form, bodyshop }) {
</FeatureWrapper> </FeatureWrapper>
<LayoutFormRow header={t("bodyshop.labels.systemsettings")} id="systemsettings"> <LayoutFormRow header={t("bodyshop.labels.systemsettings")} id="systemsettings">
{[ {[
<Form.Item
key="md_categories"
name={["md_categories"]}
label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>,
<Form.Item <Form.Item
key="md_referral_sources" key="md_referral_sources"
name={["md_referral_sources"]} name={["md_referral_sources"]}
@@ -934,6 +643,8 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}} }}
</Form.List> </Form.List>
</LayoutFormRow> </LayoutFormRow>
{/*Start Insurance Provider Row */}
<LayoutFormRow <LayoutFormRow
grow grow
header={<span id="insurancecos-header">{t("bodyshop.labels.insurancecos")}</span>} header={<span id="insurancecos-header">{t("bodyshop.labels.insurancecos")}</span>}
@@ -950,11 +661,31 @@ export function ShopInfoGeneral({ form, bodyshop }) {
label={t("bodyshop.fields.md_ins_co.name")} label={t("bodyshop.fields.md_ins_co.name")}
key={`${index}name`} key={`${index}name`}
name={[field.name, "name"]} name={[field.name, "name"]}
dependencies={[["md_ins_cos"]]}
rules={[ rules={[
{ {
required: true required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} },
({ getFieldValue }) => ({
validator: async (_, value) => {
const normalizedValue = (value ?? "").toString().trim().toLowerCase();
if (!normalizedValue) return Promise.resolve(); // handled by required
const list = getFieldValue(["md_ins_cos"]) || [];
const normalizedNames = list
.map((c) => (c?.name ?? "").toString().trim().toLowerCase())
.filter(Boolean);
const count = normalizedNames.filter((n) => n === normalizedValue).length;
if (count > 1) {
throw new Error(t("bodyshop.errors.duplicate_insurance_company"));
}
return Promise.resolve();
}
})
]} ]}
> >
<Input /> <Input />
@@ -1031,6 +762,8 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}} }}
</Form.List> </Form.List>
</LayoutFormRow> </LayoutFormRow>
{/*End Insurance Provider Row */}
<LayoutFormRow grow header={t("bodyshop.labels.estimators")} id="estimators"> <LayoutFormRow grow header={t("bodyshop.labels.estimators")} id="estimators">
<Form.List name={["md_estimators"]}> <Form.List name={["md_estimators"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
@@ -1568,11 +1301,3 @@ export function ShopInfoGeneral({ form, bodyshop }) {
</div> </div>
); );
} }
const ReceivableCustomFieldSelect = (
<Select allowClear>
<Select.Option value="v_vin">VIN</Select.Option>
<Select.Option value="clm_no">Claim No.</Select.Option>
<Select.Option value="ded_amt">Deductible Amount</Select.Option>
</Select>
);

View File

@@ -16,6 +16,7 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text> <Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? ( {employeeOptions.length > 0 ? (
<Form.Item <Form.Item
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
name="notification_followers" name="notification_followers"
rules={[ rules={[
{ {
@@ -42,11 +43,6 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
options={employeeOptions} options={employeeOptions}
placeholder={t("bodyshop.fields.notifications.placeholder")} placeholder={t("bodyshop.fields.notifications.placeholder")}
showEmail={true} showEmail={true}
onChange={(value) => {
// Filter out null or invalid values before passing to Form
const cleanedValue = value?.filter((id) => id != null && typeof id === "string" && id.trim() !== "");
return cleanedValue;
}}
/> />
</Form.Item> </Form.Item>
) : ( ) : (

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; 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 { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; 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 LayoutFormRow from "../layout-form-row/layout-form-row.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopInfoResponsibilitycentersTaxesComponent from "./shop-info.responsibilitycenters.taxes.component"; import ShopInfoResponsibilitycentersTaxesComponent from "./shop-info.responsibilitycenters.taxes.component";
import DatePickerRanges from "../../utils/DatePickerRanges";
const SelectorDiv = styled.div` const SelectorDiv = styled.div`
.ant-form-item .ant-select { .ant-form-item .ant-select {
@@ -34,11 +35,11 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
treatments: { Qb_Multi_Ar, DmsAp } treatments: { ClosingPeriod, ADPPayroll, Qb_Multi_Ar, DmsAp }
} = useSplitTreatments({ } = useSplitTreatments({
attributes: {}, attributes: {},
names: ["Qb_Multi_Ar", "DmsAp"], names: ["ClosingPeriod", "ADPPayroll", "Qb_Multi_Ar", "DmsAp"],
splitKey: bodyshop && bodyshop.imexshopid splitKey: bodyshop?.imexshopid
}); });
const [costOptions, setCostOptions] = useState([ 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) || [])]); setProfitOptions([...(form.getFieldValue(["md_responsibility_centers", "profits"]).map((i) => i && i.name) || [])]);
}; };
const ReceivableCustomFieldSelect = (
<Select allowClear>
<Select.Option value="v_vin">VIN</Select.Option>
<Select.Option value="clm_no">Claim No.</Select.Option>
<Select.Option value="ded_amt">Deductible Amount</Select.Option>
</Select>
);
return ( return (
<div> <div>
<RbacWrapper action="shop:responsibilitycenter"> <RbacWrapper action="shop:responsibilitycenter">
@@ -314,6 +323,286 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
</DataLabel> </DataLabel>
</> </>
)} )}
<LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup">
{[
...(HasFeatureAccess({ featureName: "export", bodyshop })
? [
<Form.Item
key="qbo"
label={t("bodyshop.labels.qbo")}
valuePropName="checked"
name={["accountingconfig", "qbo"]}
>
<Switch />
</Form.Item>,
InstanceRenderManager({
imex: (
<Form.Item key="qbo_usa_wrapper" shouldUpdate noStyle>
{() => (
<Form.Item
label={t("bodyshop.labels.qbo_usa")}
shouldUpdate
valuePropName="checked"
name={["accountingconfig", "qbo_usa"]}
>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
</Form.Item>
)}
</Form.Item>
)
}),
<Form.Item
key="qbo_departmentid"
label={t("bodyshop.labels.qbo_departmentid")}
name={["accountingconfig", "qbo_departmentid"]}
>
<Input />
</Form.Item>,
<Form.Item
key="accountingtiers"
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>,
<Form.Item key="twotierpref_wrapper" shouldUpdate>
{() => {
return (
<Form.Item
label={t("bodyshop.labels.2tiersetup")}
shouldUpdate
rules={[
{
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
>
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item>
);
}}
</Form.Item>,
<Form.Item
key="printlater"
label={t("bodyshop.labels.printlater")}
valuePropName="checked"
name={["accountingconfig", "printlater"]}
>
<Switch />
</Form.Item>,
<Form.Item
key="emaillater"
label={t("bodyshop.labels.emaillater")}
valuePropName="checked"
name={["accountingconfig", "emaillater"]}
>
<Switch />
</Form.Item>
]
: []),
<Form.Item
key="inhousevendorid"
label={t("bodyshop.fields.inhousevendorid")}
name={"inhousevendorid"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>,
<Form.Item
key="default_adjustment_rate"
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>,
InstanceRenderManager({
imex: (
<Form.Item key="federal_tax_id" label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
<Input />
</Form.Item>
)
}),
<Form.Item key="state_tax_id" label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>,
...(HasFeatureAccess({ featureName: "bills", bodyshop })
? [
InstanceRenderManager({
imex: (
<Form.Item
key="invoice_federal_tax_rate"
label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
)
}),
<Form.Item
key="invoice_state_tax_rate"
label={t("bodyshop.fields.invoice_state_tax_rate")}
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>,
<Form.Item
key="invoice_local_tax_rate"
label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
]
: []),
<Form.Item
key="md_payment_types"
name={["md_payment_types"]}
label={t("bodyshop.fields.md_payment_types")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>,
...(HasFeatureAccess({ featureName: "export", bodyshop })
? [
<Form.Item
key="ReceivableCustomField1"
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="ReceivableCustomField2"
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="ReceivableCustomField3"
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="md_classes"
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>,
<Form.Item
key="enforce_class"
name={["enforce_class"]}
label={t("bodyshop.fields.enforce_class")}
valuePropName="checked"
>
<Switch />
</Form.Item>,
...(ClosingPeriod.treatment === "on"
? [
<Form.Item
key="ClosingPeriod"
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
]
: []),
...(ADPPayroll.treatment === "on"
? [
<Form.Item
key="companyCode"
name={["accountingconfig", "companyCode"]}
label={t("bodyshop.fields.companycode")}
>
<Input />
</Form.Item>
]
: []),
...(ADPPayroll.treatment === "on"
? [
<Form.Item
key="batchID"
name={["accountingconfig", "batchID"]}
label={t("bodyshop.fields.batchid")}
>
<Input />
</Form.Item>
]
: [])
]
: []),
<Form.Item
key="accumulatePayableLines"
name={["accountingconfig", "accumulatePayableLines"]}
label={t("bodyshop.fields.accumulatePayableLines")}
valuePropName="checked"
>
<Switch />
</Form.Item>
]}
</LayoutFormRow>
{HasFeatureAccess({ featureName: "export", bodyshop }) && ( {HasFeatureAccess({ featureName: "export", bodyshop }) && (
<> <>
<LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.costs")} id="costs"> <LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.costs")} id="costs">

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { useLazyQuery } from "@apollo/client"; import { useLazyQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd"; import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -33,7 +34,9 @@ export function TimeTicketModalComponent({
authLevel, authLevel,
employeeAutoCompleteOptions, employeeAutoCompleteOptions,
disabled, disabled,
employeeSelectDisabled employeeSelectDisabled,
lineTicketRefreshKey,
isOpen
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
@@ -48,6 +51,18 @@ export function TimeTicketModalComponent({
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "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 }) => { const CostCenterSelect = ({ emps, value, ...props }) => {
return ( return (
<Select <Select
@@ -304,6 +319,7 @@ export function TimeTicketModalComponent({
)} )}
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<Form.Item dependencies={["jobid"]}> <Form.Item dependencies={["jobid"]}>
{() => { {() => {
const jobid = form.getFieldValue("jobid"); const jobid = form.getFieldValue("jobid");

View File

@@ -29,6 +29,9 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false); const [enterAgain, setEnterAgain] = useState(false);
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET); const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
const [updateTicket] = useMutation(UPDATE_TIME_TICKET); const [updateTicket] = useMutation(UPDATE_TIME_TICKET);
const { const {
@@ -85,11 +88,17 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
notification["success"]({ notification["success"]({
message: t("timetickets.successes.created") 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(); form.resetFields();
@@ -229,6 +238,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
</Space> </Space>
} }
/> />
<TimeTicketModalComponent <TimeTicketModalComponent
isEdit={timeTicketModal.context.id} isEdit={timeTicketModal.context.id}
form={form} form={form}
@@ -238,6 +248,8 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
timeTicketModal.context?.timeticket?.committed_at || timeTicketModal.context?.timeticket?.committed_at ||
(timeTicketModal.context?.timeticket?.employeeid && !timeTicketModal.context.id ? true : false) (timeTicketModal.context?.timeticket?.employeeid && !timeTicketModal.context.id ? true : false)
} }
lineTicketRefreshKey={lineTicketRefreshKey}
isOpen={timeTicketModal.open}
/> />
</Form> </Form>
</Modal> </Modal>

View File

@@ -5,7 +5,7 @@ import { getFirestore } from "@firebase/firestore";
import { getMessaging, getToken, onMessage } from "@firebase/messaging"; import { getMessaging, getToken, onMessage } from "@firebase/messaging";
import { store } from "../redux/store"; import { store } from "../redux/store";
//import * as amplitude from '@amplitude/analytics-browser'; //import * as amplitude from '@amplitude/analytics-browser';
import posthog from 'posthog-js' // import posthog from 'posthog-js'
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG); const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
initializeApp(config); initializeApp(config);
@@ -74,7 +74,6 @@ onMessage(messaging, (payload) => {
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => { export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
try { try {
const state = stateProp || store.getState(); const state = stateProp || store.getState();
const eventParams = { const eventParams = {
@@ -99,8 +98,7 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
// ); // );
logEvent(analytics, eventName, eventParams); logEvent(analytics, eventName, eventParams);
//amplitude.track(eventName, eventParams); //amplitude.track(eventName, eventParams);
posthog.capture(eventName, eventParams); //posthog.capture(eventName, eventParams);
} finally { } finally {
//If it fails, just keep going. //If it fails, just keep going.
} }

View File

@@ -40,6 +40,8 @@ export const QUERY_ALL_ACTIVE_APPOINTMENTS = gql`
ownr_fn ownr_fn
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
ownr_ea ownr_ea
clm_total clm_total
id id

View File

@@ -2,7 +2,27 @@ import { gql } from "@apollo/client";
export const UNREAD_CONVERSATION_COUNT = gql` export const UNREAD_CONVERSATION_COUNT = gql`
query UNREAD_CONVERSATION_COUNT { 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 { aggregate {
count count
} }
@@ -19,7 +39,7 @@ export const CONVERSATION_LIST_QUERY = gql`
unreadcnt unreadcnt
archived archived
label label
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) { messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate { aggregate {
count count
} }
@@ -41,6 +61,7 @@ export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) { subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) {
messages(order_by: { created_at: asc_nulls_first }, where: { conversationid: { _eq: $conversationId } }) { messages(order_by: { created_at: asc_nulls_first }, where: { conversationid: { _eq: $conversationId } }) {
id id
conversationid
status status
text text
is_system is_system
@@ -76,6 +97,7 @@ export const GET_CONVERSATION_DETAILS = gql`
} }
messages(order_by: { created_at: asc_nulls_first }) { messages(order_by: { created_at: asc_nulls_first }) {
id id
conversationid
status status
text text
is_system is_system
@@ -110,7 +132,7 @@ export const CONVERSATION_ID_BY_PHONE = gql`
ro_number 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 { aggregate {
count count
} }
@@ -139,7 +161,7 @@ export const CREATE_CONVERSATION = gql`
ro_number 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 { aggregate {
count count
} }

View File

@@ -19,7 +19,9 @@ export const QUERY_ALL_ACTIVE_JOBS_PAGINATED = gql`
ownr_ln ownr_ln
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph1_ty
ownr_ph2 ownr_ph2
ownr_ph2_ty
ownr_ea ownr_ea
ownerid ownerid
comment comment
@@ -69,6 +71,8 @@ export const QUERY_ALL_ACTIVE_JOBS = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
ownr_ea ownr_ea
ownerid ownerid
comment comment
@@ -122,6 +126,8 @@ export const QUERY_PARTS_QUEUE = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
ownr_ea ownr_ea
plate_no plate_no
plate_st plate_st
@@ -179,6 +185,8 @@ export const QUERY_EXACT_JOB_IN_PRODUCTION = gql`
clm_total clm_total
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
special_coverage_policy special_coverage_policy
owner_owing owner_owing
production_vars production_vars
@@ -249,6 +257,8 @@ export const QUERY_EXACT_JOBS_IN_PRODUCTION = gql`
clm_total clm_total
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
special_coverage_policy special_coverage_policy
owner_owing owner_owing
production_vars production_vars
@@ -615,6 +625,8 @@ export const GET_JOB_BY_PK = gql`
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
ownr_st ownr_st
ownr_zip ownr_zip
tax_number tax_number
@@ -631,6 +643,8 @@ export const GET_JOB_BY_PK = gql`
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
ownr_st ownr_st
ownr_zip ownr_zip
parts_tax_rates parts_tax_rates
@@ -714,7 +728,7 @@ export const GET_JOB_BY_PK = gql`
v_model_yr v_model_yr
v_model_desc v_model_desc
v_vin 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_at
created_by created_by
critical critical
@@ -830,6 +844,8 @@ export const QUERY_JOB_CARD_DETAILS = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
comment comment
ownr_ea ownr_ea
ca_gst_registrant ca_gst_registrant
@@ -1000,7 +1016,6 @@ export const QUERY_JOB_CARD_DETAILS = gql`
key key
type type
} }
} }
} }
`; `;
@@ -1096,6 +1111,7 @@ export const UPDATE_JOB = gql`
scheduled_completion scheduled_completion
scheduled_delivery scheduled_delivery
actual_in actual_in
actual_completion
date_repairstarted date_repairstarted
date_void date_void
date_lost_sale date_lost_sale
@@ -1230,6 +1246,8 @@ export const GET_JOB_INFO_FOR_STRIPE = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
ownr_ea ownr_ea
} }
} }
@@ -1443,8 +1461,10 @@ export const QUERY_JOB_FOR_DUPE = gql`
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph1x ownr_ph1x
ownr_ph1_ty
ownr_ph2 ownr_ph2
ownr_ph2x ownr_ph2x
ownr_ph2_ty
ownr_st ownr_st
ownr_title ownr_title
ownr_zip ownr_zip
@@ -1690,8 +1710,10 @@ export const QUERY_ALL_JOB_FIELDS = gql`
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph1x ownr_ph1x
ownr_ph1_ty
ownr_ph2 ownr_ph2
ownr_ph2x ownr_ph2x
ownr_ph2_ty
ownr_st ownr_st
ownr_title ownr_title
ownr_zip ownr_zip
@@ -1829,6 +1851,8 @@ export const QUERY_ALL_JOBS_PAGINATED_STATUS_FILTERED = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
plate_no plate_no
plate_st plate_st
v_vin v_vin
@@ -1869,6 +1893,8 @@ export const QUERY_SIMPLIFIED_PARTS_PAGINATED_STATUS_FILTERED = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
plate_no plate_no
plate_st plate_st
v_vin v_vin
@@ -2117,6 +2143,8 @@ export const GET_JOB_FOR_CC_CONTRACT = gql`
ownr_zip ownr_zip
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
} }
} }
`; `;
@@ -2415,7 +2443,7 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
start start
status status
} }
notes(where:{pinned: {_eq: true}}, order_by: {updated_at: desc}) { notes(where: { pinned: { _eq: true } }, order_by: { updated_at: desc }) {
created_at created_at
created_by created_by
critical critical
@@ -2503,6 +2531,8 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
ownr_ln ownr_ln
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
owner { owner {
id id
preferred_contact preferred_contact
@@ -2592,6 +2622,7 @@ export const QUERY_JOBS_IN_PRODUCTION = gql`
vehicleid vehicleid
plate_no plate_no
actual_in actual_in
actual_completion
scheduled_completion scheduled_completion
scheduled_delivery scheduled_delivery
date_last_contacted date_last_contacted
@@ -2600,6 +2631,8 @@ export const QUERY_JOBS_IN_PRODUCTION = gql`
clm_total clm_total
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
special_coverage_policy special_coverage_policy
owner_owing owner_owing
production_vars production_vars

View File

@@ -8,6 +8,8 @@ export const QUERY_SEARCH_OWNER_BY_IDX = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2
ownr_city ownr_city
@@ -57,8 +59,10 @@ export const QUERY_OWNER_BY_ID = gql`
ownr_ea ownr_ea
ownr_fn ownr_fn
ownr_ph1 ownr_ph1
ownr_ph1_ty
ownr_ln ownr_ln
ownr_ph2 ownr_ph2
ownr_ph2_ty
ownr_st ownr_st
ownr_title ownr_title
ownr_zip ownr_zip
@@ -112,8 +116,10 @@ export const QUERY_ALL_OWNERS = gql`
ownr_ea ownr_ea
ownr_fn ownr_fn
ownr_ph1 ownr_ph1
ownr_ph1_ty
ownr_ln ownr_ln
ownr_ph2 ownr_ph2
ownr_ph2_ty
ownr_st ownr_st
ownr_title ownr_title
ownr_zip ownr_zip
@@ -136,8 +142,10 @@ export const QUERY_ALL_OWNERS_PAGINATED = gql`
ownr_ea ownr_ea
ownr_fn ownr_fn
ownr_ph1 ownr_ph1
ownr_ph1_ty
ownr_ln ownr_ln
ownr_ph2 ownr_ph2
ownr_ph2_ty
ownr_st ownr_st
ownr_title ownr_title
ownr_zip ownr_zip
@@ -164,8 +172,10 @@ export const QUERY_OWNER_FOR_JOB_CREATION = gql`
ownr_ea ownr_ea
ownr_fn ownr_fn
ownr_ph1 ownr_ph1
ownr_ph1_ty
ownr_ln ownr_ln
ownr_ph2 ownr_ph2
ownr_ph2_ty
ownr_st ownr_st
ownr_title ownr_title
ownr_zip ownr_zip

View File

@@ -177,10 +177,12 @@ export const QUERY_PARTS_ORDER_OEC = gql`
ownr_fax ownr_fax
ownr_faxx ownr_faxx
ownr_ph1 ownr_ph1
ownr_ph1_ty
ownr_fn ownr_fn
ownr_ln ownr_ln
ownr_ph1x ownr_ph1x
ownr_ph2 ownr_ph2
ownr_ph2_ty
ownr_ph2x ownr_ph2x
ownr_st ownr_st
ownr_title ownr_title

View File

@@ -22,6 +22,8 @@ export const GLOBAL_SEARCH_QUERY = gql`
ownr_co_nm ownr_co_nm
ownr_ph1 ownr_ph1
ownr_ph2 ownr_ph2
ownr_ph1_ty
ownr_ph2_ty
} }
search_vehicles(args: { search: $search }, limit: 25) { search_vehicles(args: { search: $search }, limit: 25) {
id id

View File

@@ -9,21 +9,23 @@ import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries"; import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries"; import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; 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 InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsCreateComponent from "./jobs-create.component"; import JobsCreateComponent from "./jobs-create.component";
import JobCreateContext from "./jobs-create.context"; import JobCreateContext from "./jobs-create.context";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop,
currentUser: selectCurrentUser
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)) setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
}); });
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) { function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
const { t } = useTranslation(); const { t } = useTranslation();
const notification = useNotification(); const notification = useNotification();
@@ -74,7 +76,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
}, [t, setBreadcrumbs, setSelectedHeader]); }, [t, setBreadcrumbs, setSelectedHeader]);
const runInsertJob = (job) => { const runInsertJob = (job) => {
insertJob({ variables: { job: job } }) insertJob({ variables: { job } })
.then((resp) => { .then((resp) => {
setState({ setState({
...state, ...state,
@@ -150,6 +152,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
if (job.owner === null) delete job.owner; if (job.owner === null) delete job.owner;
if (job.vehicle === null) delete job.vehicle; 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); runInsertJob(job);
}; };

View File

@@ -50,7 +50,7 @@ import {
} from "./user.actions"; } from "./user.actions";
import UserActionTypes from "./user.types"; import UserActionTypes from "./user.types";
//import * as amplitude from '@amplitude/analytics-browser'; //import * as amplitude from '@amplitude/analytics-browser';
import posthog from 'posthog-js'; import posthog from "posthog-js";
const fpPromise = FingerprintJS.load(); const fpPromise = FingerprintJS.load();
@@ -239,22 +239,13 @@ export function* signInSuccessSaga({ payload }) {
try { try {
window.$crisp.push(["set", "user:nickname", [payload.displayName || payload.email]]); 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({ InstanceRenderManager({
executeFunction: true, executeFunction: true,
args: [], args: [],
imex: () => {
window.$crisp.push(["set", "session:segments", [["imex"]]]);
},
rome: () => { rome: () => {
window.$zoho.salesiq.visitor.name(payload.displayName || payload.email); window.$zoho.salesiq.visitor.name(payload.displayName || payload.email);
window.$zoho.salesiq.visitor.email(payload.email); window.$zoho.salesiq.visitor.email(payload.email);
window.$crisp.push(["set", "session:segments", [["rome"]]]);
} }
}); });
@@ -262,18 +253,20 @@ export function* signInSuccessSaga({ payload }) {
try { try {
const state = yield select(); const state = yield select();
const isParts = state?.application?.isPartsEntry === true; 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 // Always ensure segments include instance + user, and append partsManagement if applicable
const segs = [ const segs = [
currentUserSegment, ...instanceSeg,
instanceSeg,
...(isParts ...(isParts
? [ ? [
InstanceRenderManager({ InstanceRenderManager({
imex: "ImexPartsManagement", imex: "ImexPartsManagement",
rome: "RomePartsManagement" rome: "RomePartsManagement"
}) })
] ]
: []) : [])
]; ];
window.$crisp.push(["set", "session:segments", [segs]]); window.$crisp.push(["set", "session:segments", [segs]]);
@@ -373,22 +366,39 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
// Build consolidated Crisp segments including instance, region, features, and parts mode // Build consolidated Crisp segments including instance, region, features, and parts mode
const isParts = yield select((state) => state.application.isPartsEntry === true); const isParts = yield select((state) => state.application.isPartsEntry === true);
const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" }); const instanceSeg = InstanceRenderManager({
imex: ["imex-online-user", "imex"],
rome: ["rome-online-user", "rome"]
});
let featureSegments; const featureSegments =
if (payload.features?.allAccess === true) { payload.features?.allAccess === true
featureSegments = ["allAccess"]; ? ["allAccess"]
} else { : [
const featureKeys = Object.keys(payload.features).filter( "basic",
(key) => ...Object.keys(payload.features).filter(
payload.features[key] === true || (key) =>
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key]))) payload.features[key] === true ||
); (typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
featureSegments = ["basic", ...featureKeys]; )
} ];
const additionalSegments = [
payload.cdk_dealerid && "CDK",
payload.pbs_serialnumber && "PBS",
// payload.rr_dealerid && "Reynolds",
payload.accountingconfig.qbo === true && "QBO",
payload.accountingconfig.qbo === false &&
!payload.cdk_dealerid &&
!payload.pbs_serialnumber &&
// !payload.rr_dealerid &&
"QBD"
].filter(Boolean);
featureSegments.push(...additionalSegments);
const regionSeg = payload.region_config ? `region:${payload.region_config}` : null; const regionSeg = payload.region_config ? `region:${payload.region_config}` : null;
const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments]; const segments = [...instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments];
if (isParts) { if (isParts) {
segments.push(InstanceRenderManager({ imex: "ImexPartsManagement", rome: "RomePartsManagement" })); segments.push(InstanceRenderManager({ imex: "ImexPartsManagement", rome: "RomePartsManagement" }));
} }

View File

@@ -277,7 +277,8 @@
"errors": { "errors": {
"creatingdefaultview": "Error creating default view.", "creatingdefaultview": "Error creating default view.",
"loading": "Unable to load shop details. Please call technical support.", "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": { "fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}", "ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -1270,6 +1271,7 @@
"vehicle": "Vehicle" "vehicle": "Vehicle"
}, },
"labels": { "labels": {
"selected": "Selected",
"settings": "Settings", "settings": "Settings",
"actions": "Actions", "actions": "Actions",
"areyousure": "Are you sure?", "areyousure": "Are you sure?",
@@ -1491,7 +1493,8 @@
"assign_team": "Assign Team", "assign_team": "Assign Team",
"converttolabor": "Convert amount to Labor.", "converttolabor": "Convert amount to Labor.",
"dispatchparts": "Dispatch Parts ({{count}})", "dispatchparts": "Dispatch Parts ({{count}})",
"new": "New Line" "new": "New Line",
"updatelocation": "Update Location"
}, },
"errors": { "errors": {
"creating": "Error encountered while creating job line. {{message}}", "creating": "Error encountered while creating job line. {{message}}",
@@ -1572,7 +1575,8 @@
"ioucreated": "IOU", "ioucreated": "IOU",
"new": "New Line", "new": "New Line",
"nostatus": "No Status", "nostatus": "No Status",
"presets": "Jobline Presets" "presets": "Jobline Presets",
"bulk_location_help": "This will set the same location on all selected lines."
}, },
"successes": { "successes": {
"created": "Job line created successfully.", "created": "Job line created successfully.",
@@ -2430,7 +2434,8 @@
"selectmedia": "Select Media", "selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}", "sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...", "typeamessage": "Send a message...",
"unarchive": "Unarchive" "unarchive": "Unarchive",
"mark_unread": "Mark as unread"
}, },
"render": { "render": {
"conversation_list": "Conversation List" "conversation_list": "Conversation List"
@@ -2590,7 +2595,10 @@
"fromclaim": "Current Claim", "fromclaim": "Current Claim",
"fromowner": "Historical Owner Record", "fromowner": "Historical Owner Record",
"relatedjobs": "Related Jobs", "relatedjobs": "Related Jobs",
"updateowner": "Update Owner" "updateowner": "Update Owner",
"work": "Work",
"home": "Home",
"cell": "Cell"
}, },
"successes": { "successes": {
"delete": "Owner deleted successfully.", "delete": "Owner deleted successfully.",
@@ -2950,6 +2958,8 @@
"settings": "Error saving board settings: {{error}}" "settings": "Error saving board settings: {{error}}"
}, },
"labels": { "labels": {
"click_for_statuses": "Click to view parts statuses",
"partsreceived": "Parts Received",
"actual_in": "Actual In", "actual_in": "Actual In",
"addnewprofile": "Add New Profile", "addnewprofile": "Add New Profile",
"alert": "Alert", "alert": "Alert",

View File

@@ -277,7 +277,8 @@
"errors": { "errors": {
"creatingdefaultview": "", "creatingdefaultview": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.", "loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": "" "saving": "",
"duplicate_insurance_company": ""
}, },
"fields": { "fields": {
"ReceivableCustomField": "", "ReceivableCustomField": "",
@@ -1270,6 +1271,7 @@
"vehicle": "" "vehicle": ""
}, },
"labels": { "labels": {
"selected": "",
"actions": "Comportamiento", "actions": "Comportamiento",
"settings": "", "settings": "",
"areyousure": "", "areyousure": "",
@@ -1491,7 +1493,8 @@
"assign_team": "", "assign_team": "",
"converttolabor": "", "converttolabor": "",
"dispatchparts": "", "dispatchparts": "",
"new": "" "new": "",
"updatelocation": ""
}, },
"errors": { "errors": {
"creating": "", "creating": "",
@@ -1572,7 +1575,8 @@
"ioucreated": "", "ioucreated": "",
"new": "Nueva línea", "new": "Nueva línea",
"nostatus": "", "nostatus": "",
"presets": "" "presets": "",
"bulk_location_help": ""
}, },
"successes": { "successes": {
"created": "", "created": "",
@@ -2430,7 +2434,8 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Enviar un mensaje...", "typeamessage": "Enviar un mensaje...",
"unarchive": "" "unarchive": "",
"mark_unread": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -2590,7 +2595,10 @@
"fromclaim": "", "fromclaim": "",
"fromowner": "", "fromowner": "",
"relatedjobs": "", "relatedjobs": "",
"updateowner": "" "updateowner": "",
"work": "",
"home": "",
"cell": ""
}, },
"successes": { "successes": {
"delete": "", "delete": "",
@@ -2950,6 +2958,8 @@
"settings": "" "settings": ""
}, },
"labels": { "labels": {
"click_for_statuses": "",
"partsreceived": "",
"actual_in": "", "actual_in": "",
"addnewprofile": "", "addnewprofile": "",
"alert": "", "alert": "",

View File

@@ -277,7 +277,8 @@
"errors": { "errors": {
"creatingdefaultview": "", "creatingdefaultview": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.", "loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": "" "saving": "",
"duplicate_insurance_company": ""
}, },
"fields": { "fields": {
"ReceivableCustomField": "", "ReceivableCustomField": "",
@@ -1270,6 +1271,7 @@
"vehicle": "" "vehicle": ""
}, },
"labels": { "labels": {
"selected": "",
"settings": "", "settings": "",
"actions": "actes", "actions": "actes",
"areyousure": "", "areyousure": "",
@@ -1491,7 +1493,8 @@
"assign_team": "", "assign_team": "",
"converttolabor": "", "converttolabor": "",
"dispatchparts": "", "dispatchparts": "",
"new": "" "new": "",
"updatelocation": ""
}, },
"errors": { "errors": {
"creating": "", "creating": "",
@@ -1572,7 +1575,8 @@
"ioucreated": "", "ioucreated": "",
"new": "Nouvelle ligne", "new": "Nouvelle ligne",
"nostatus": "", "nostatus": "",
"presets": "" "presets": "",
"bulk_location_help": ""
}, },
"successes": { "successes": {
"created": "", "created": "",
@@ -2430,7 +2434,8 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Envoyer un message...", "typeamessage": "Envoyer un message...",
"unarchive": "" "unarchive": "",
"mark_unread": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -2590,7 +2595,10 @@
"fromclaim": "", "fromclaim": "",
"fromowner": "", "fromowner": "",
"relatedjobs": "", "relatedjobs": "",
"updateowner": "" "updateowner": "",
"work": "",
"home": "",
"cell": ""
}, },
"successes": { "successes": {
"delete": "", "delete": "",
@@ -2950,6 +2958,8 @@
"settings": "" "settings": ""
}, },
"labels": { "labels": {
"click_for_statuses": "",
"partsreceived": "",
"actual_in": "", "actual_in": "",
"addnewprofile": "", "addnewprofile": "",
"alert": "", "alert": "",

View File

@@ -148,6 +148,26 @@ const cache = new InMemoryCache({
Query: { Query: {
fields: { fields: {
// Note: This is required because we switch from a read to an unread state with a toggle, // 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: { notifications: {
merge(existing = [], incoming = [], { readField }) { merge(existing = [], incoming = [], { readField }) {
// Create a map to deduplicate by __ref // Create a map to deduplicate by __ref

View File

@@ -1,7 +1,23 @@
//import NumberFormat from "react-number-format"; import { Typography } from "antd";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
export default function PhoneNumberFormatter(props) { const { Text } = Typography;
const p = parsePhoneNumber(props.children || "", "CA");
return p ? <span>{p.formatNational()}</span> : null; export default function PhoneNumberFormatter({ children, type }) {
const p = parsePhoneNumber(children || "", "CA");
if (!p) return null;
const phone = p.formatNational();
return (
<span>
<Text>{phone}</Text>
{type ? (
<>
{" "}
<Text type="secondary">({type})</Text>
</>
) : null}
</span>
);
} }

View File

@@ -1221,7 +1221,7 @@ export const TemplateList = (type, context) => {
payments_by_date_excel: { payments_by_date_excel: {
title: i18n.t("reportcenter.templates.payments_by_date_excel"), title: i18n.t("reportcenter.templates.payments_by_date_excel"),
subject: i18n.t("reportcenter.templates.payments_by_date_excel"), subject: i18n.t("reportcenter.templates.payments_by_date_excel"),
key: "payments_by_date", key: "payments_by_date_excel",
reporttype: "excel", reporttype: "excel",
disabled: false, disabled: false,
rangeFilter: { rangeFilter: {

View File

@@ -31,7 +31,8 @@ if (!import.meta.env.DEV) {
"Module specifier, 'fs' does not start", "Module specifier, 'fs' does not start",
"Module specifier, 'zlib' does not start with", "Module specifier, 'zlib' does not start with",
"Messaging: This browser doesn't support the API's required to use the Firebase SDK.", "Messaging: This browser doesn't support the API's required to use the Firebase SDK.",
"Failed to update a ServiceWorker for scope" "Failed to update a ServiceWorker for scope",
"Network Error"
], ],
integrations: [ integrations: [
// See docs for support of different versions of variation of react router // See docs for support of different versions of variation of react router

View File

@@ -24,11 +24,13 @@ const lightningCssTargets = browserslistToTargets(
}) })
); );
const currentDatePST = new Date() const pstFormatter = new Intl.DateTimeFormat("en-CA", {
.toLocaleDateString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit" }) timeZone: "America/Los_Angeles",
.split("/") year: "numeric",
.reverse() month: "2-digit",
.join("-"); day: "2-digit"
});
const currentDatePST = pstFormatter.format(new Date());
const getFormattedTimestamp = () => const getFormattedTimestamp = () =>
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m."); new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");

View File

@@ -1156,7 +1156,11 @@
enable_manual: false enable_manual: false
update: update:
columns: columns:
- imexshopid
- timezone
- shopname - shopname
- notification_followers
- state
- md_order_statuses - md_order_statuses
retry_conf: retry_conf:
interval_sec: 10 interval_sec: 10
@@ -3680,6 +3684,7 @@
- completed_tasks - completed_tasks
- converted - converted
- created_at - created_at
- created_user_email
- cust_pr - cust_pr
- date_estimated - date_estimated
- date_exported - date_exported
@@ -3698,6 +3703,7 @@
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_allocation - dms_allocation
- dms_id
- driveable - driveable
- employee_body - employee_body
- employee_csr - employee_csr
@@ -3793,8 +3799,10 @@
- ownr_fn - ownr_fn
- ownr_ln - ownr_ln
- ownr_ph1 - ownr_ph1
- ownr_ph1_ty
- ownr_ph1x - ownr_ph1x
- ownr_ph2 - ownr_ph2
- ownr_ph2_ty
- ownr_ph2x - ownr_ph2x
- ownr_st - ownr_st
- ownr_title - ownr_title
@@ -3956,6 +3964,7 @@
- completed_tasks - completed_tasks
- converted - converted
- created_at - created_at
- created_user_email
- cust_pr - cust_pr
- date_estimated - date_estimated
- date_exported - date_exported
@@ -3975,6 +3984,7 @@
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_allocation - dms_allocation
- dms_id
- driveable - driveable
- employee_body - employee_body
- employee_csr - employee_csr
@@ -4071,8 +4081,10 @@
- ownr_fn - ownr_fn
- ownr_ln - ownr_ln
- ownr_ph1 - ownr_ph1
- ownr_ph1_ty
- ownr_ph1x - ownr_ph1x
- ownr_ph2 - ownr_ph2
- ownr_ph2_ty
- ownr_ph2x - ownr_ph2x
- ownr_st - ownr_st
- ownr_title - ownr_title
@@ -4245,6 +4257,7 @@
- completed_tasks - completed_tasks
- converted - converted
- created_at - created_at
- created_user_email
- cust_pr - cust_pr
- date_estimated - date_estimated
- date_exported - date_exported
@@ -4264,6 +4277,7 @@
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_allocation - dms_allocation
- dms_id
- driveable - driveable
- employee_body - employee_body
- employee_csr - employee_csr
@@ -4360,8 +4374,10 @@
- ownr_fn - ownr_fn
- ownr_ln - ownr_ln
- ownr_ph1 - ownr_ph1
- ownr_ph1_ty
- ownr_ph1x - ownr_ph1x
- ownr_ph2 - ownr_ph2
- ownr_ph2_ty
- ownr_ph2x - ownr_ph2x
- ownr_st - ownr_st
- ownr_title - ownr_title
@@ -4634,7 +4650,7 @@
request_transform: request_transform:
body: body:
action: transform 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 method: POST
query_params: {} query_params: {}
template_engine: Kriti template_engine: Kriti
@@ -5161,7 +5177,9 @@
- ownr_fn - ownr_fn
- ownr_ln - ownr_ln
- ownr_ph1 - ownr_ph1
- ownr_ph1_ty
- ownr_ph2 - ownr_ph2
- ownr_ph2_ty
- ownr_st - ownr_st
- ownr_title - ownr_title
- ownr_zip - ownr_zip
@@ -5186,7 +5204,9 @@
- ownr_fn - ownr_fn
- ownr_ln - ownr_ln
- ownr_ph1 - ownr_ph1
- ownr_ph1_ty
- ownr_ph2 - ownr_ph2
- ownr_ph2_ty
- ownr_st - ownr_st
- ownr_title - ownr_title
- ownr_zip - ownr_zip
@@ -5222,7 +5242,9 @@
- ownr_fn - ownr_fn
- ownr_ln - ownr_ln
- ownr_ph1 - ownr_ph1
- ownr_ph1_ty
- ownr_ph2 - ownr_ph2
- ownr_ph2_ty
- ownr_st - ownr_st
- ownr_title - ownr_title
- ownr_zip - ownr_zip

View File

@@ -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 "dms_id" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_id" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "created_user_email" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."owners" add column "ownr_ph1_ty" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."owners" add column "ownr_ph2_ty" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "ownr_ph1_ty" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "ownr_ph2_ty" text
null;

View File

@@ -283,6 +283,11 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
VendorRef: { VendorRef: {
value: vendor.Id value: vendor.Id
}, },
...(vendor.TermRef && {
SalesTermRef: {
value: vendor.TermRef.value
}
}),
TxnDate: moment(bill.date) TxnDate: moment(bill.date)
//.tz(bill.job.bodyshop.timezone) //.tz(bill.job.bodyshop.timezone)
.format("YYYY-MM-DD"), .format("YYYY-MM-DD"),

View File

@@ -117,44 +117,46 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
imexshopid: shopid, imexshopid: shopid,
json: JSON.stringify(carfaxObject, null, 2), json: JSON.stringify(carfaxObject, null, 2),
filename: `${shopid}_${moment().format("DDMMYYYY_HHMMss")}.json`, filename: `${shopid}_${moment().format("DDMMYYYY_HHMMss")}.json`,
count: carfaxObject.job.length count: carfaxObject?.job?.length || 0
}; };
if (skipUpload) { if (skipUpload) {
fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json); fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json);
uploadToS3(jsonObj, S3_BUCKET_NAME); uploadToS3(jsonObj, S3_BUCKET_NAME);
} else { } else {
await uploadViaSFTP(jsonObj); if (jsonObj.count > 0) {
await uploadViaSFTP(jsonObj);
await sendMexicoBillingEmail({ await sendMexicoBillingEmail({
subject: `${shopid.replace(/_/g, "").toUpperCase()}_MexicoRPS_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`, subject: `${shopid.replace(/_/g, "").toUpperCase()}_MexicoRPS_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
text: `Errors:\n${JSON.stringify( text: `Errors:\n${JSON.stringify(
erroredJobs.map((ej) => ({ erroredJobs.map((ej) => ({
jobid: ej.job?.id, jobid: ej.job?.id,
error: ej.error error: ej.error
})), })),
null, null,
2 2
)}\n\nUploaded:\n${JSON.stringify( )}\n\nUploaded:\n${JSON.stringify(
{ {
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
imexshopid: shopid, imexshopid: shopid,
count: jsonObj.count, count: jsonObj.count,
filename: jsonObj.filename, filename: jsonObj.filename,
result: jsonObj.result result: jsonObj.result
}, },
null, null,
2 2
)}` )}`
}); });
}
} }
allJSONResults.push({ jsonObj.count > 0 && allJSONResults.push({
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
imexshopid: shopid, imexshopid: shopid,
count: jsonObj.count, count: jsonObj.count,
filename: jsonObj.filename, filename: jsonObj.filename,
result: jsonObj.result result: jsonObj.result || "No Upload Result Available"
}); });
logger.log("CARFAX-RPS-end-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("CARFAX-RPS-end-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -234,11 +236,10 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = { const ret = {
ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"), ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"),
v_vin: job.v_vin || "", v_vin: job.v_vin || "",
v_year: job.v_model_yr v_year: (() => {
? parseInt(job.v_model_yr.match(/\d/g)) const y = parseInt(job.v_model_yr);
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10) return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
: "" })(),
: "",
v_make: job.v_makedesc || "", v_make: job.v_makedesc || "",
v_model: job.v_model || "", v_model: job.v_model || "",

View File

@@ -160,40 +160,42 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
imexshopid: shopid, imexshopid: shopid,
json: JSON.stringify(carfaxObject, null, 2), json: JSON.stringify(carfaxObject, null, 2),
filename: `${shopid}_${moment().format("DDMMYYYY_HHMMss")}.json`, filename: `${shopid}_${moment().format("DDMMYYYY_HHMMss")}.json`,
count: carfaxObject.job.length count: carfaxObject?.job?.length || 0
}; };
if (skipUpload) { if (skipUpload) {
fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json); fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json);
uploadToS3(jsonObj); uploadToS3(jsonObj);
} else { } else {
await uploadViaSFTP(jsonObj); if (jsonObj.count > 0) {
await uploadViaSFTP(jsonObj);
await sendMexicoBillingEmail({ await sendMexicoBillingEmail({
subject: `${shopid.replace(/_/g, "").toUpperCase()}_Mexico${InstanceManager({ subject: `${shopid.replace(/_/g, "").toUpperCase()}_Mexico${InstanceManager({
imex: "IO", imex: "IO",
rome: "RO" rome: "RO"
})}_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`, })}_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
text: `Errors:\n${JSON.stringify( text: `Errors:\n${JSON.stringify(
erroredJobs.map((ej) => ({ erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number, ro_number: ej.job?.ro_number,
jobid: ej.job?.id, jobid: ej.job?.id,
error: ej.error error: ej.error
})), })),
null, null,
2 2
)}\n\nUploaded:\n${JSON.stringify( )}\n\nUploaded:\n${JSON.stringify(
{ {
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
imexshopid: shopid, imexshopid: shopid,
count: jsonObj.count, count: jsonObj.count,
filename: jsonObj.filename, filename: jsonObj.filename,
result: jsonObj.result result: jsonObj.result
}, },
null, null,
2 2
)}` )}`
}); });
}
} }
allJSONResults.push({ allJSONResults.push({
@@ -201,7 +203,7 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
imexshopid: shopid, imexshopid: shopid,
count: jsonObj.count, count: jsonObj.count,
filename: jsonObj.filename, filename: jsonObj.filename,
result: jsonObj.result result: jsonObj.result || "No Upload Result Available"
}); });
logger.log("CARFAX-end-shop-extract", "DEBUG", "api", bodyshop.id, { logger.log("CARFAX-end-shop-extract", "DEBUG", "api", bodyshop.id, {
@@ -286,11 +288,10 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = { const ret = {
ro_number: crypto.createHash("md5").update(job.ro_number, "utf8").digest("hex"), ro_number: crypto.createHash("md5").update(job.ro_number, "utf8").digest("hex"),
v_vin: job.v_vin || "", v_vin: job.v_vin || "",
v_year: job.v_model_yr v_year: (() => {
? parseInt(job.v_model_yr.match(/\d/g)) const y = parseInt(job.v_model_yr);
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10) return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
: "" })(),
: "",
v_make: job.v_make_desc || "", v_make: job.v_make_desc || "",
v_model: job.v_model_desc || "", v_model: job.v_model_desc || "",

View File

@@ -55,7 +55,9 @@ exports.default = async (req, res) => {
"patrick.fic@convenient-brands.com", "patrick.fic@convenient-brands.com",
"bradley.rhoades@convenient-brands.com", "bradley.rhoades@convenient-brands.com",
"jrome@rometech.com", "jrome@rometech.com",
"ivana@imexsystems.ca" "ivana@imexsystems.ca",
"support@imexsystems.ca",
"sarah@rometech.com"
], ],
subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`, subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
text: ` text: `

View File

@@ -123,7 +123,7 @@ mutation RECEIVE_MESSAGE($msg: [messages_insert_input!]!) {
exports.INSERT_MESSAGE = ` exports.INSERT_MESSAGE = `
mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!) { 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 id
archived archived
} }
@@ -147,6 +147,7 @@ mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!)
image_path image_path
image image
isoutbound isoutbound
is_system
msid msid
read read
text text
@@ -2926,6 +2927,15 @@ exports.GET_BODYSHOP_BY_ID = `
} }
`; `;
exports.GET_BODYSHOP_WATCHERS_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
notification_followers
}
}
`;
exports.GET_DOCUMENTS_BY_JOB = ` exports.GET_DOCUMENTS_BY_JOB = `
query GET_DOCUMENTS_BY_JOB($jobId: uuid!) { query GET_DOCUMENTS_BY_JOB($jobId: uuid!) {
jobs_by_pk(id: $jobId) { jobs_by_pk(id: $jobId) {
@@ -3080,17 +3090,19 @@ exports.INSERT_JOB_WATCHERS = `
`; `;
exports.GET_NOTIFICATION_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: { associations(where: {
_and: [ _and: [
{ shopid: { _eq: $shopId } }, { shopid: { _eq: $shopId } },
{ active: { _eq: true } }, { active: { _eq: true } },
{ notifications_autoadd: { _eq: true } } { notifications_autoadd: { _eq: true } },
{ useremail: { _eq: $createdUserEmail } }
] ]
}) { }) {
id id
useremail useremail
} }
employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) { employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) {
user_email user_email
} }

Some files were not shown because too many files have changed in this diff Show More