From 587f4a449259102806db8e21f45e6128c834ce04 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 1 Apr 2026 22:51:05 -0700 Subject: [PATCH] IO-1366 Extend Audit Log Extend for Time Ticket Changes, Manual Lines, Manual Job, Bill Edits Signed-off-by: Allan Carr --- .../bill-detail-edit-component.jsx | 89 ++++++++++++++++++- .../job-lines-upsert-modal.container.jsx | 40 ++++++++- .../time-ticket-modal.container.jsx | 51 ++++++++++- .../jobs-create/jobs-create.container.jsx | 13 ++- client/src/translations/en_us/common.json | 8 +- client/src/translations/es/common.json | 6 +- client/src/translations/fr/common.json | 6 +- client/src/utils/AuditTrailMappings.js | 8 +- 8 files changed, 205 insertions(+), 16 deletions(-) diff --git a/client/src/components/bill-detail-edit/bill-detail-edit-component.jsx b/client/src/components/bill-detail-edit/bill-detail-edit-component.jsx index d04f86cd2..0824dbf60 100644 --- a/client/src/components/bill-detail-edit/bill-detail-edit-component.jsx +++ b/client/src/components/bill-detail-edit/bill-detail-edit-component.jsx @@ -134,11 +134,96 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) { await Promise.all(updates); + const details = (() => { + const original = data?.bills_by_pk ?? {}; + const updated = { ...bill, billlines }; + + const fmtVal = (key, val) => + val == null || val === "" + ? "<>" + : typeof val === "number" && key.toLowerCase().includes("price") + ? `$${val.toFixed(2)}` + : val; + + const keysToTrack = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"]; + const lineVals = (obj) => keysToTrack.map((k) => fmtVal(k, obj[k])).join(", "); + + const changed = Object.entries(updated) + .filter(([k, v]) => v != null && v !== "" && k !== "billlines" && k !== "__typename") + .map(([k, v]) => { + const orig = original[k]; + if (k === "date") { + const a = orig ? dayjs(orig) : null; + const b = v ? (dayjs.isDayjs(v) ? v : dayjs(v)) : null; + return (a && b && a.isSame(b, "day")) || (!a && !b) + ? null + : `date: ${a?.format("YYYY-MM-DD") ?? "<>"} → ${b?.format("YYYY-MM-DD") ?? "<>"}`; + } + return typeof orig === "object" || typeof v === "object" || String(orig) === String(v) + ? null + : `${k}: ${fmtVal(k, orig)} → ${fmtVal(k, v)}`; + }) + .filter(Boolean); + + const origLines = original.billlines ?? []; + const updLines = updated.billlines ?? []; + + const addedObjs = updLines + .filter((l) => !l.id) + .map((u) => ({ label: u.line_desc || u.description || "new line", vals: lineVals(u), handled: false })); + + const removedObjs = origLines + .filter((o) => !updLines.some((u) => u.id === o.id)) + .map((o) => ({ + label: o.line_desc || o.description || o.id || "removed line", + vals: lineVals(o), + handled: false + })); + + const labelToAdded = addedObjs.reduce((m, a) => m.set(a.label, [...(m.get(a.label) ?? []), a]), new Map()); + + const modified = [ + ...removedObjs.reduce((acc, r) => { + const candidates = labelToAdded.get(r.label) ?? []; + const exact = candidates.find((c) => c.vals === r.vals && !c.handled); + if (exact) { + exact.handled = r.handled = true; + return acc; + } // identical → cancel out + const diff = candidates.find((c) => c.vals !== r.vals && !c.handled); + if (diff) { + diff.handled = r.handled = true; + acc.push(`${r.label}: ${r.vals} → ${diff.vals}`); + } + return acc; + }, []), + ...updLines + .filter((u) => u.id) + .flatMap((u) => { + const o = origLines.find((x) => x.id === u.id); + if (!o) return []; + const diffs = keysToTrack + .filter((k) => String(o[k]) !== String(u[k])) + .map((k) => `${fmtVal(k, o[k])} → ${fmtVal(k, u[k])}`); + return diffs.length ? [`${u.line_desc || u.description || u.id}: ${diffs.join("; ")}`] : []; + }) + ]; + + [ + ["added", addedObjs.filter((a) => !a.handled).map((a) => `+${a.label} (${a.vals})`)], + ["removed", removedObjs.filter((r) => !r.handled).map((r) => `-${r.label} (${r.vals})`)], + ["modified", modified] + ].forEach(([type, items]) => { + if (items.length) changed.push(`billlines ${type}: ${items.join(" | ")}`); + }); + + return changed.length ? changed.join("; ") : bill.invoice_number || "No changes"; + })(); + insertAuditTrail({ jobid: bill.jobid, billid: search.billid, - operation: AuditTrailMapping.billupdated(bill.invoice_number), - type: "billupdated" + operation: AuditTrailMapping.billupdated(bill.invoice_number, details) }); await refetch(); diff --git a/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx b/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx index 14e1d0a85..ab5862f83 100644 --- a/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx +++ b/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx @@ -14,16 +14,19 @@ import CriticalPartsScan from "../../utils/criticalPartsScan"; import UndefinedToNull from "../../utils/undefinedtonull"; import JobLinesUpdsertModal from "./job-lines-upsert-modal.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import AuditTrailMapping from "../../utils/AuditTrailMappings.js"; +import { insertAuditTrail } from "../../redux/application/application.actions.js"; const mapStateToProps = createStructuredSelector({ jobLineEditModal: selectJobLineEditModal, bodyshop: selectBodyshop }); const mapDispatchToProps = (dispatch) => ({ - toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")) + toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")), + insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) }); -function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) { +function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) { const { treatments: { CriticalPartsScanning } } = useTreatmentsWithConfig({ @@ -74,6 +77,16 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo notification.success({ title: t("joblines.successes.created") }); + insertAuditTrail({ + jobid: jobLineEditModal.context.jobid, + operation: AuditTrailMapping.jobmanuallineinsert( + Object.entries(values) + .filter(([k, v]) => v != null && v !== "" && k !== "ah_detail_line" && k !== "prt_dsmk_p") + .map(([k, v]) => `${k}: ${v}`) + .join("; ") + ), + type: "jobmanuallineinsert" + }); } else { notification.error({ title: t("joblines.errors.creating", { @@ -103,6 +116,29 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo notification.success({ title: t("joblines.successes.updated") }); + insertAuditTrail({ + jobid: jobLineEditModal.context.jobid, + operation: AuditTrailMapping.joblineupdate( + (() => { + const original = jobLineEditModal.context || {}; + const changed = Object.entries(values) + .filter(([k, v]) => v != null && v !== "" && k !== "ah_detail_line" && k !== "prt_dsmk_p") + .map(([k, v]) => { + const orig = original[k]; + if (String(orig) === String(v)) return null; + const fmt = (key, val) => { + if (val == null || val === "") return "<>"; + if (typeof val === "number" && key.toLowerCase().includes("price")) return `$${val.toFixed(2)}`; + return val; + }; + return `${k}: ${fmt(k, orig)} → ${fmt(k, v)}`; + }) + .filter(Boolean); + return changed.length ? changed.join("; ") : "No changes"; + })() + ), + type: "joblineupdate" + }); } else { notification.success({ title: t("joblines.errors.updating", { diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx index ba3de7760..a88e6472b 100644 --- a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx +++ b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx @@ -2,7 +2,7 @@ import { PageHeader } from "@ant-design/pro-layout"; import { useMutation, useQuery } from "@apollo/client/react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Form, Modal, Space } from "antd"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -15,21 +15,26 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import dayjs from "../../utils/day"; import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component"; import TimeTicketModalComponent from "./time-ticket-modal.component"; +import { insertAuditTrail } from "../../redux/application/application.actions.js"; +import AuditTrailMapping from "../../utils/AuditTrailMappings"; const mapStateToProps = createStructuredSelector({ timeTicketModal: selectTimeTicket, bodyshop: selectBodyshop }); const mapDispatchToProps = (dispatch) => ({ - toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")) + toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")), + insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) }); -export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) { +export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const { t } = useTranslation(); const [enterAgain, setEnterAgain] = useState(false); + const lastSubmittedRef = useRef(null); + const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0); const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET); @@ -50,6 +55,8 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, }); const handleFinish = (values) => { + // Save submitted values so we can compute audit-trail details after the mutation completes + lastSubmittedRef.current = values; setLoading(true); const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid); if (timeTicketModal.context.id) { @@ -89,6 +96,44 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, title: t("timetickets.successes.created") }); + const timeticket = timeTicketModal.context?.timeticket ?? {}; + const original = timeticket || {}; + const submitted = lastSubmittedRef.current || {}; + + const fmt = (key, val) => { + if (val == null || val === "") return "<>"; + const k = key.toLowerCase(); + if (dayjs.isDayjs?.(val)) return dayjs(val).format(k.includes("clock") ? "YYYY-MM-DD HH:mm" : "YYYY-MM-DD"); + if (typeof val === "number") + return k.includes("hrs") + ? val.toFixed(1) + : k.includes("rate") || k.includes("price") + ? `$${val.toFixed(2)}` + : String(val); + if (key === "employeeid") { + const emp = EmployeeAutoCompleteData?.employees?.find(({ id }) => id === val); + return emp ? `${emp.first_name} ${emp.last_name}` : String(val); + } + return String(val); + }; + + const changed = Object.entries(submitted) + .filter(([, v]) => v != null && v !== "") + .map(([k, v]) => { + const origVal = k === "jobid" ? (original.job?.id ?? original.jobid ?? original[k]) : original[k]; + return String(fmt(k, origVal)) !== String(fmt(k, v)) ? `${k}: ${fmt(k, origVal)} → ${fmt(k, v)}` : null; + }) + .filter(Boolean); + + insertAuditTrail({ + jobid: timeticket.job?.id ?? timeticket.jobid, + operation: AuditTrailMapping.timeticketupdated( + [original.employee.first_name, original.employee.last_name].filter(Boolean).join(" "), + original.date ? dayjs(original.date).format("YYYY-MM-DD") : "<>", + changed.length ? changed.join("; ") : "No changes" + ) + }); + // Refresh parent screens (Job Labor tab, etc.) if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch(); diff --git a/client/src/pages/jobs-create/jobs-create.container.jsx b/client/src/pages/jobs-create/jobs-create.container.jsx index 5b5627ef1..b3cc808b2 100644 --- a/client/src/pages/jobs-create/jobs-create.container.jsx +++ b/client/src/pages/jobs-create/jobs-create.container.jsx @@ -8,13 +8,14 @@ import { createStructuredSelector } from "reselect"; import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import { INSERT_NEW_JOB } from "../../graphql/jobs.queries"; import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries"; -import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; +import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import JobsCreateComponent from "./jobs-create.component"; import JobCreateContext from "./jobs-create.context"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { logImEXEvent } from "../../firebase/firebase.utils"; +import AuditTrailMapping from "../../utils/AuditTrailMappings.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -22,10 +23,11 @@ const mapStateToProps = createStructuredSelector({ }); const mapDispatchToProps = (dispatch) => ({ setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), - setSelectedHeader: (key) => dispatch(setSelectedHeader(key)) + setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), + insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) }); -function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) { +function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser, insertAuditTrail }) { const { t } = useTranslation(); const notification = useNotification(); @@ -84,6 +86,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr newJobId: resp.data.insert_jobs.returning[0].id }); logImEXEvent("manual_job_create_completed", {}); + insertAuditTrail({ + jobid: resp.data.insert_jobs.returning[0].id, + operation: AuditTrailMapping.jobmanualcreate(), + type: "jobmanualcreate" + }); setIsSubmitting(false); }) .catch((error) => { diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index f0d1ebbff..f22dbdcd1 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -122,7 +122,7 @@ "billdeleted": "Bill with invoice number {{invoice_number}} deleted.", "billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.", "billposted": "Bill with invoice number {{invoice_number}} posted.", - "billupdated": "Bill with invoice number {{invoice_number}} updated.", + "billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{values}}.", "failedpayment": "Failed payment attempt.", "jobassignmentchange": "Employee {{name}} assigned to {{operation}}", "jobassignmentremoved": "Employee assignment removed for {{operation}}", @@ -137,6 +137,9 @@ "jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.", "jobinvoiced": "Job has been invoiced.", "jobioucreated": "IOU Created.", + "joblineupdate": "Job line {{line_desc}} updated.", + "jobmanualcreate": "Job manually created.", + "jobmanuallineinsert": "Job line manually added with the following details: {{values}}.", "jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.", "jobnoteadded": "Note added to Job.", "jobnotedeleted": "Note deleted from Job.", @@ -152,7 +155,8 @@ "tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}", "tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}", "tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}", - "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}" + "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}", + "timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}" } }, "billlines": { diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 3f1d15ce6..3f53233fb 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -137,6 +137,9 @@ "jobintake": "", "jobinvoiced": "", "jobioucreated": "", + "joblineupdate": "", + "jobmanualcreate": "", + "jobmanuallineinsert": "", "jobmodifylbradj": "", "jobnoteadded": "", "jobnotedeleted": "", @@ -152,7 +155,8 @@ "tasks_deleted": "", "tasks_uncompleted": "", "tasks_undeleted": "", - "tasks_updated": "" + "tasks_updated": "", + "timeticketupdated": "" } }, "billlines": { diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index c1520a5e3..a9612b05b 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -137,6 +137,9 @@ "jobintake": "", "jobinvoiced": "", "jobioucreated": "", + "joblineupdate": "", + "jobmanualcreate": "", + "jobmanuallineinsert": "", "jobmodifylbradj": "", "jobnoteadded": "", "jobnotedeleted": "", @@ -152,7 +155,8 @@ "tasks_deleted": "", "tasks_uncompleted": "", "tasks_undeleted": "", - "tasks_updated": "" + "tasks_updated": "", + "timeticketupdated": "" } }, "billlines": { diff --git a/client/src/utils/AuditTrailMappings.js b/client/src/utils/AuditTrailMappings.js index e33082c20..5e4c160b7 100644 --- a/client/src/utils/AuditTrailMappings.js +++ b/client/src/utils/AuditTrailMappings.js @@ -10,7 +10,7 @@ const AuditTrailMapping = { billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }), billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }), billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }), - billupdated: (invoice_number) => i18n.t("audit_trail.messages.billupdated", { invoice_number }), + billupdated: (invoice_number, values) => i18n.t("audit_trail.messages.billupdated", { invoice_number, values }), jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }), jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }), jobchecklist: (type, inproduction, status) => @@ -26,6 +26,9 @@ const AuditTrailMapping = { jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }), jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"), jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"), + joblineupdate: (line_desc) => i18n.t("audit_trail.messages.joblineupdate", { line_desc }), + jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"), + jobmanuallineinsert: (values) => i18n.t("audit_trail.messages.jobmanuallineinsert", { values }), jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }), jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"), jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"), @@ -72,7 +75,8 @@ const AuditTrailMapping = { i18n.t("audit_trail.messages.tasks_uncompleted", { title, uncompletedBy - }) + }), + timeticketupdated: (employee, date, details) => i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details }) }; export default AuditTrailMapping;