diff --git a/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx b/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx
index 54edf4465..605d75561 100644
--- a/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx
+++ b/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx
@@ -4,6 +4,7 @@ import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } f
import { useTranslation } from "react-i18next";
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
+import { BiSolidTrash } from "react-icons/bi";
const { Text } = Typography;
@@ -42,7 +43,8 @@ export default function JobWatcherToggleComponent({
}
onClick={() => handleRemoveWatcher(watcher.user_email)}
disabled={adding || removing} // Optional: Disable button during mutations
>
@@ -61,20 +63,28 @@ export default function JobWatcherToggleComponent({
const popoverContent = (
-
:
}
- onClick={handleToggleSelf}
- loading={adding || removing}
- >
- {isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}
-
-
-
-
- {t("notifications.labels.watching-issue")}
-
+
+ : }
+ size="medium"
+ onClick={handleToggleSelf}
+ loading={adding || removing}
+ >
+ {isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
+
+ ]}
+ >
+
+
+ {t("notifications.labels.watching-issue")}
+
+
+
+
{watcherLoading ? (
) : jobWatchers && jobWatchers.length > 0 ? (
@@ -82,12 +92,16 @@ export default function JobWatcherToggleComponent({
) : (
{t("notifications.labels.no-watchers")}
)}
-
+
{t("notifications.labels.add-watchers")}
jobWatchers.every((w) => w.user_email !== e.user_email)) || []}
+ options={
+ bodyshop?.employees?.filter((e) =>
+ jobWatchers.every((w) => w.user_email !== e.user_email && e.active && e.user_email)
+ ) || []
+ }
placeholder={t("notifications.labels.employee-search")}
value={selectedWatcher}
onChange={(value) => {
@@ -110,10 +124,9 @@ export default function JobWatcherToggleComponent({
const teamMembers = team.employee_team_members
.map((member) => {
const employee = bodyshop?.employees?.find((e) => e.id === member.employeeid);
- return employee ? employee.user_email : null;
+ return employee?.user_email && employee?.active ? employee.user_email : null;
})
.filter(Boolean);
-
return {
value: JSON.stringify(teamMembers),
label: team.name
@@ -128,7 +141,7 @@ export default function JobWatcherToggleComponent({
return (
-
+
);
}
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index fcc81ce1b..79c558d2f 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -3785,17 +3785,18 @@
"ro-number": "RO #{{ro_number}}",
"no-watchers": "No Watchers",
"notification-settings-success": "Notification Settings saved successfully.",
- "notification-settings-failure": "Error saving Notification Settings. {{error}}"
+ "notification-settings-failure": "Error saving Notification Settings. {{error}}",
+ "watch": "Watch",
+ "unwatch": "Unwatch"
},
"actions": {
- "remove": "remove"
+ "remove": "Remove"
},
"aria": {
"toggle": "Toggle Watching Job"
},
"tooltips": {
- "watch": "Watch Job",
- "unwatch": "Unwatch Job"
+ "job-watchers": "Job Watchers"
},
"scenarios": {
"job-assigned-to-me": "Job Assigned to Me",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 5a89d11ac..be571cf12 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -3785,7 +3785,9 @@
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
- "notification-settings-failure": ""
+ "notification-settings-failure": "",
+ "watch": "",
+ "unwatch": ""
},
"actions": {
"remove": ""
@@ -3794,8 +3796,7 @@
"toggle": ""
},
"tooltips": {
- "watch": "",
- "unwatch": ""
+ "job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index b9673bc45..6dcb4fefd 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -3785,7 +3785,9 @@
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
- "notification-settings-failure": ""
+ "notification-settings-failure": "",
+ "watch": "",
+ "unwatch": ""
},
"actions": {
"remove": ""
@@ -3794,8 +3796,7 @@
"toggle": ""
},
"tooltips": {
- "watch": "",
- "unwatch": ""
+ "job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",
diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml
index 63a512bab..4a780b262 100644
--- a/hasura/metadata/tables.yaml
+++ b/hasura/metadata/tables.yaml
@@ -6290,10 +6290,12 @@
columns:
- joblineid
- assigned_to
+ - due_date
- partsorderid
- completed
- description
- billid
+ - title
- priority
retry_conf:
interval_sec: 10
diff --git a/server/email/tasksEmails.js b/server/email/tasksEmails.js
index 05811e5e2..b8c3c42a3 100644
--- a/server/email/tasksEmails.js
+++ b/server/email/tasksEmails.js
@@ -11,6 +11,7 @@ const moment = require("moment-timezone");
const { taskEmailQueue } = require("./tasksEmailsQueue");
const mailer = require("./mailer");
const { InstanceEndpoints } = require("../utils/instanceMgr");
+const { formatTaskPriority } = require("../notifications/stringHelpers");
// Initialize the Tasks Email Queue
const tasksEmailQueue = taskEmailQueue();
@@ -62,16 +63,6 @@ const formatDate = (date) => {
return date ? `| Due on: ${moment(date).format("MM/DD/YYYY")}` : "";
};
-const formatPriority = (priority) => {
- if (priority === 1) {
- return "High";
- } else if (priority === 3) {
- return "Low";
- } else {
- return "Medium";
- }
-};
-
/**
* Generate the email template arguments.
* @param title
@@ -88,7 +79,7 @@ const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, j
const endPoints = InstanceEndpoints();
return {
header: title,
- subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
+ subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatTaskPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
body: `Reference: ${job.ro_number || "N/A"} | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()}
${description ? description.concat("
") : ""}View this task.`,
dateLine
};
@@ -155,7 +146,7 @@ const taskAssignedEmail = async (req, res) => {
sendMail(
"assigned",
tasks_by_pk.assigned_to_employee.user_email,
- `A ${formatPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
+ `A ${formatTaskPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
generateEmailTemplate(
generateTemplateArgs(
newTask.title,
@@ -239,7 +230,7 @@ const tasksRemindEmail = async (req, res) => {
const onlyTask = groupedTasks[recipient.email][0];
emailData.subject =
- `New ${formatPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
+ `New ${formatTaskPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
emailData.html = generateEmailTemplate(
generateTemplateArgs(
@@ -266,7 +257,7 @@ const tasksRemindEmail = async (req, res) => {
body: ``
diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js
index b1b62cd36..cc50b3115 100644
--- a/server/graphql-client/queries.js
+++ b/server/graphql-client/queries.js
@@ -2726,6 +2726,7 @@ query GET_JOB_WATCHERS($jobid: uuid!) {
bodyshop {
id
shopname
+ timezone
}
}
}
diff --git a/server/notifications/queues/appQueue.js b/server/notifications/queues/appQueue.js
index 7403e807c..12f6f6fd8 100644
--- a/server/notifications/queues/appQueue.js
+++ b/server/notifications/queues/appQueue.js
@@ -7,7 +7,7 @@ const graphQLClient = require("../../graphql-client/graphql-client").client;
const APP_CONSOLIDATION_DELAY_IN_MINS = (() => {
const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS;
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
- return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, ensure at least 1
+ return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, ensure at least 1
})();
// Base time-related constant (in milliseconds) / DO NOT TOUCH
diff --git a/server/notifications/queues/emailQueue.js b/server/notifications/queues/emailQueue.js
index 8bb788ddd..fe25c99fa 100644
--- a/server/notifications/queues/emailQueue.js
+++ b/server/notifications/queues/emailQueue.js
@@ -7,7 +7,7 @@ const { registerCleanupTask } = require("../../utils/cleanupManager");
const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => {
const envValue = process.env?.EMAIL_CONSOLIDATION_DELAY_IN_MINS;
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
- return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, ensure at least 1
+ return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, ensure at least 1
})();
// Base time-related constant (in milliseconds) / DO NOT TOUCH
diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js
index 361d130a3..1355c437b 100644
--- a/server/notifications/scenarioBuilders.js
+++ b/server/notifications/scenarioBuilders.js
@@ -1,4 +1,6 @@
-const { getJobAssignmentType } = require("./stringHelpers");
+const { getJobAssignmentType, formatTaskPriority } = require("./stringHelpers");
+const moment = require("moment-timezone");
+const { startCase } = require("lodash");
/**
* Populates the recipients for app, email, and FCM notifications based on scenario watchers.
@@ -26,17 +28,17 @@ const populateWatchers = (data, result) => {
*/
// Verified
const alternateTransportChangedBuilder = (data) => {
- const body = `The Alternate Transport status has been updated to ${data?.data?.alt_transport}.`;
+ const body = `The Alternate Transport status has been updated from ${data.changedFields.alt_transport?.old || "unset"} to ${data?.changedFields?.alt_transport?.new || "unset"}.`;
const result = {
app: {
jobId: data.jobId,
bodyShopId: data.bodyShopId,
jobRoNumber: data.jobRoNumber,
key: "notifications.job.alternateTransportChanged",
- body, // Same as email body
+ body,
variables: {
- alternateTransport: data.changedFields.alt_transport?.new,
- oldAlternateTransport: data.changedFields.alt_transport?.old
+ alternateTransport: data?.changedFields?.alt_transport?.new,
+ oldAlternateTransport: data?.changedFields?.alt_transport?.old
},
recipients: []
},
@@ -60,7 +62,6 @@ const alternateTransportChangedBuilder = (data) => {
//verified
const billPostedHandler = (data) => {
const facing = data?.data?.isinhouse ? "In-House" : "External";
-
const body = `An ${facing} Bill has been posted${data?.data?.is_credit_memo ? " (Credit Memo)" : ""}.`.trim();
const result = {
@@ -71,8 +72,8 @@ const billPostedHandler = (data) => {
key: "notifications.job.billPosted",
body,
variables: {
- facing,
- is_credit_memo: data?.data?.is_credit_memo
+ isInHouse: data?.data?.isinhouse,
+ isCreditMemo: data?.data?.is_credit_memo
},
recipients: []
},
@@ -95,7 +96,7 @@ const billPostedHandler = (data) => {
*/
// TODO: Needs change
const criticalPartsStatusChangedBuilder = (data) => {
- const body = `The critical parts status has changed to ${data.data.queued_for_parts ? "queued" : "not queued"}.`;
+ const body = `The critical parts status has changed to ${data?.data?.queued_for_parts ? "queued" : "not queued"}.`;
const result = {
app: {
jobId: data.jobId,
@@ -104,8 +105,8 @@ const criticalPartsStatusChangedBuilder = (data) => {
key: "notifications.job.criticalPartsStatusChanged",
body,
variables: {
- queuedForParts: data.data.queued_for_parts,
- oldQueuedForParts: data.changedFields.queued_for_parts?.old
+ queuedForParts: data?.data?.queued_for_parts,
+ oldQueuedForParts: data?.changedFields?.queued_for_parts?.old
},
recipients: []
},
@@ -162,7 +163,7 @@ const intakeDeliveryChecklistCompletedBuilder = (data) => {
*/
// Verified
const jobAssignedToMeBuilder = (data) => {
- const body = `You have been assigned to ${getJobAssignmentType(data.scenarioFields?.[0])}`;
+ const body = `You have been assigned to ${getJobAssignmentType(data.scenarioFields?.[0])}.`;
const result = {
app: {
jobId: data.jobId,
@@ -224,7 +225,7 @@ const jobsAddedToProductionBuilder = (data) => {
*/
// Verified
const jobStatusChangeBuilder = (data) => {
- const body = `The status has changed from ${data.changedFields.status.old} to ${data.changedFields.status.new}`;
+ const body = `The status has changed from ${data?.changedFields?.status?.old || "unset"} to ${data?.changedFields?.status?.new || "unset"}`;
const result = {
app: {
jobId: data.jobId,
@@ -294,8 +295,19 @@ const newMediaAddedReassignedBuilder = (data) => {
/**
* Builds notification data for new notes added to a job.
*/
+// verified
const newNoteAddedBuilder = (data) => {
- const body = `An Note has been added: "${data.data.text}"`;
+ const body = [
+ "A",
+ data?.data?.critical && "critical",
+ data?.data?.private && "private",
+ data?.data?.type,
+ "Note has been added by",
+ `${data.data.created_by}`
+ ]
+ .filter(Boolean)
+ .join(" ");
+
const result = {
app: {
jobId: data.jobId,
@@ -304,7 +316,10 @@ const newNoteAddedBuilder = (data) => {
key: "notifications.job.newNoteAdded",
body,
variables: {
- text: data.data.text
+ createdBy: data?.data?.created_by,
+ critical: data?.data?.critical,
+ type: data?.data?.type,
+ private: data?.data?.private
},
recipients: []
},
@@ -325,9 +340,11 @@ const newNoteAddedBuilder = (data) => {
/**
* Builds notification data for new time tickets posted.
*/
+// Verified
const newTimeTicketPostedBuilder = (data) => {
const type = data?.data?.cost_center;
- const body = `An ${type} time ticket has been posted${data?.data?.flat_rate ? " (Flat Rate)" : ""}.`.trim();
+ const body =
+ `A ${startCase(type.toLowerCase())} Time Ticket for ${data?.data?.date} has been posted${data?.data?.flat_rate ? " (Flat Rate)" : ""}.`.trim();
const result = {
app: {
@@ -337,7 +354,9 @@ const newTimeTicketPostedBuilder = (data) => {
key: "notifications.job.newTimeTicketPosted",
body,
variables: {
- type
+ type,
+ date: data?.data?.date,
+ flatRate: data?.data?.flat_rate
},
recipients: []
},
@@ -419,7 +438,27 @@ const paymentCollectedCompletedBuilder = (data) => {
* Builds notification data for changes to scheduled dates.
*/
const scheduledDatesChangedBuilder = (data) => {
- const body = `Scheduled dates have been updated.`;
+ const momentFormat = "MM/DD/YYYY hh:mm a";
+ const changedFields = data.changedFields;
+
+ // Define field configurations
+ const fieldConfigs = {
+ scheduled_in: "Scheduled In",
+ scheduled_completion: "Scheduled Completion",
+ scheduled_delivery: "Scheduled Delivery"
+ };
+
+ // Build field messages dynamically
+ const fieldMessages = Object.entries(fieldConfigs)
+ .filter(([field]) => changedFields[field]) // Only include changed fields
+ .map(([field, label]) => {
+ const { old, new: newValue } = changedFields[field];
+ const formatDate = (date) => (date ? moment(date).tz(data.bodyShopTimezone).format(momentFormat) : "unset");
+ return `${label} changed from ${formatDate(old)} to ${formatDate(newValue)}`;
+ });
+
+ const body = fieldMessages.length > 0 ? fieldMessages.join(", ") + "." : "Scheduled dates have been updated.";
+
const result = {
app: {
jobId: data.jobId,
@@ -428,12 +467,12 @@ const scheduledDatesChangedBuilder = (data) => {
key: "notifications.job.scheduledDatesChanged",
body,
variables: {
- scheduledIn: data.changedFields.scheduled_in?.new,
- oldScheduledIn: data.changedFields.scheduled_in?.old,
- scheduledCompletion: data.changedFields.scheduled_completion?.new,
- oldScheduledCompletion: data.changedFields.scheduled_completion?.old,
- scheduledDelivery: data.changedFields.scheduled_delivery?.new,
- oldScheduledDelivery: data.changedFields.scheduled_delivery?.old
+ scheduledIn: changedFields.scheduled_in?.new,
+ oldScheduledIn: changedFields.scheduled_in?.old,
+ scheduledCompletion: changedFields.scheduled_completion?.new,
+ oldScheduledCompletion: changedFields.scheduled_completion?.old,
+ scheduledDelivery: changedFields.scheduled_delivery?.new,
+ oldScheduledDelivery: changedFields.scheduled_delivery?.old
},
recipients: []
},
@@ -486,7 +525,75 @@ const supplementImportedBuilder = (data) => {
* Builds notification data for tasks updated or created.
*/
const tasksUpdatedCreatedBuilder = (data) => {
- const body = `Tasks have been ${data.isNew ? "created" : "updated"}.`;
+ const momentFormat = "MM/DD/YYYY hh:mm a";
+ const timezone = data.bodyShopTimezone;
+ const taskTitle = data?.data?.title;
+
+ let body;
+ let variables;
+
+ if (data.isNew) {
+ // Created case
+ const priority = formatTaskPriority(data?.data?.priority);
+ const createdBy = data?.data?.created_by;
+ const dueDate = data.data.due_date ? ` due on ${moment(data.data.due_date).tz(timezone).format(momentFormat)}` : "";
+ const completedOnCreation = data.data.completed === true;
+ body = `A ${priority} Task ${taskTitle} has been created${completedOnCreation ? " and marked completed" : ""} by ${createdBy}${dueDate}`;
+ variables = {
+ isNew: data.isNew,
+ roNumber: data.jobRoNumber,
+ title: data?.data?.title,
+ priority: data?.data?.priority,
+ createdBy: data?.data?.created_by,
+ dueDate: data?.data?.due_date,
+ completed: completedOnCreation ? data?.data?.completed : undefined // Only include if true
+ };
+ } else {
+ // Updated case
+ const changedFields = data.changedFields;
+ const fieldNames = Object.keys(changedFields);
+
+ // Special case: Only 'completed' changed
+ if (fieldNames.length === 1 && changedFields.completed) {
+ body = `Task ${taskTitle} was marked ${changedFields.completed.new ? "complete" : "incomplete"}`;
+ variables = {
+ isNew: data.isNew,
+ roNumber: data.jobRoNumber,
+ title: data?.data?.title,
+ changedCompleted: data?.changedFields?.completed?.new
+ };
+ } else {
+ // General update case
+ const fieldMessages = [];
+
+ if (changedFields.description) {
+ fieldMessages.push("Description Updated");
+ }
+ if (changedFields.priority) {
+ fieldMessages.push(`Priority changed to ${formatTaskPriority(changedFields.priority.new)}`);
+ }
+ if (changedFields.due_date) {
+ fieldMessages.push(`Due date set to ${moment(changedFields.due_date.new).tz(timezone).format(momentFormat)}`);
+ }
+ if (changedFields.completed) {
+ fieldMessages.push(`Status changed to ${changedFields.completed.new ? "complete" : "incomplete"}`);
+ }
+
+ body =
+ fieldMessages.length > 0
+ ? `Task ${taskTitle} updated: ${fieldMessages.join(", ")}`
+ : `Task ${taskTitle} has been updated.`;
+ variables = {
+ isNew: data.isNew,
+ roNumber: data.jobRoNumber,
+ title: data?.data?.title,
+ changedPriority: data?.changedFields?.priority?.new,
+ changedDueDate: data?.changedFields?.due_date?.new,
+ changedCompleted: data?.changedFields?.completed?.new
+ };
+ }
+ }
+
const result = {
app: {
jobId: data.jobId,
@@ -494,10 +601,7 @@ const tasksUpdatedCreatedBuilder = (data) => {
bodyShopId: data.bodyShopId,
key: data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated",
body,
- variables: {
- isNew: data.isNew,
- roNumber: data.jobRoNumber
- },
+ variables,
recipients: []
},
email: {
diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js
index 1f6afea03..ac1db6aeb 100644
--- a/server/notifications/scenarioParser.js
+++ b/server/notifications/scenarioParser.js
@@ -110,6 +110,8 @@ const scenarioParser = async (req, jobIdField) => {
const bodyShopId = watcherData?.job?.bodyshop?.id;
const bodyShopName = watcherData?.job?.bodyshop?.shopname;
+ const bodyShopTimezone = watcherData?.job?.bodyshop?.timezone;
+
const jobRoNumber = watcherData?.job?.ro_number;
const jobClaimNumber = watcherData?.job?.clm_no;
@@ -147,6 +149,7 @@ const scenarioParser = async (req, jobIdField) => {
jobWatchers,
bodyShopId,
bodyShopName,
+ bodyShopTimezone,
matchingScenarios
};
@@ -247,6 +250,7 @@ const scenarioParser = async (req, jobIdField) => {
trigger: finalScenarioData.trigger.name,
bodyShopId: finalScenarioData.bodyShopId,
bodyShopName: finalScenarioData.bodyShopName,
+ bodyShopTimezone: finalScenarioData.bodyShopTimezone,
scenarioKey: scenario.key,
scenarioTable: scenario.table,
scenarioFields: filteredScenarioFields,
diff --git a/server/notifications/stringHelpers.js b/server/notifications/stringHelpers.js
index 4e70670be..ce56063c9 100644
--- a/server/notifications/stringHelpers.js
+++ b/server/notifications/stringHelpers.js
@@ -26,6 +26,17 @@ const getJobAssignmentType = (data) => {
}
};
-module.exports = {
- getJobAssignmentType
+const formatTaskPriority = (priority) => {
+ if (priority === 1) {
+ return "High";
+ } else if (priority === 3) {
+ return "Low";
+ } else {
+ return "Medium";
+ }
+};
+
+module.exports = {
+ getJobAssignmentType,
+ formatTaskPriority
};