From fe848b5de451d29920e79552ba670299a9170995 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 1 Apr 2026 22:51:05 -0700 Subject: [PATCH 1/2] 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; From 704543d8230419b839c45b39120a8729abb5aa18 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 2 Apr 2026 21:40:59 -0400 Subject: [PATCH 2/2] IO-1366 Refine audit trail detail logging --- .../bill-detail-edit-component.jsx | 96 +-------- .../job-lines-upsert-modal.container.jsx | 30 +-- .../time-ticket-modal.container.jsx | 121 ++++++------ client/src/translations/en_us/common.json | 7 +- client/src/translations/es/common.json | 7 +- client/src/translations/fr/common.json | 7 +- client/src/utils/AuditTrailMappings.js | 12 +- client/src/utils/auditTrailDetails.js | 186 ++++++++++++++++++ 8 files changed, 272 insertions(+), 194 deletions(-) create mode 100644 client/src/utils/auditTrailDetails.js 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 0824dbf60..2704986b5 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 @@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions"; import { selectBodyshop } from "../../redux/user/user.selectors"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; import dayjs from "../../utils/day"; +import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails"; import AlertComponent from "../alert/alert.component"; import BillFormContainer from "../bill-form/bill-form.container"; import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component"; @@ -134,96 +135,17 @@ 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"; - })(); + const details = buildBillUpdateAuditDetails({ + originalBill: data?.bills_by_pk, + bill, + billlines + }); insertAuditTrail({ - jobid: bill.jobid, + jobid: bill.jobid ?? data?.bills_by_pk?.jobid, billid: search.billid, - operation: AuditTrailMapping.billupdated(bill.invoice_number, details) + operation: AuditTrailMapping.billupdated(bill.invoice_number, details), + type: "billupdated" }); 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 ab5862f83..5140e43dd 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 @@ -16,6 +16,7 @@ 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"; +import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js"; const mapStateToProps = createStructuredSelector({ jobLineEditModal: selectJobLineEditModal, @@ -79,12 +80,7 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo }); 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("; ") - ), + operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)), type: "jobmanuallineinsert" }); } else { @@ -119,23 +115,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo 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"; - })() + values.line_desc || jobLineEditModal.context.line_desc || "manual line", + buildJobLineUpdateAuditDetails({ + originalLine: jobLineEditModal.context, + values + }) ), type: "joblineupdate" }); 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 a88e6472b..4f664a11c 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 @@ -17,6 +17,7 @@ import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time import TimeTicketModalComponent from "./time-ticket-modal.component"; import { insertAuditTrail } from "../../redux/application/application.actions.js"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; +import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js"; const mapStateToProps = createStructuredSelector({ timeTicketModal: selectTimeTicket, @@ -53,87 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); + const employees = EmployeeAutoCompleteData?.employees ?? []; 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) { - updateTicket({ - variables: { - timeticketId: timeTicketModal.context.id, - timeticket: { - ...values, - rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null - } - } - }) - .then(handleMutationSuccess) - .catch(handleMutationError); - } else { - //Get selected employee rate. - insertTicket({ - variables: { - timeTicketInput: [ - { + const isEdit = Boolean(timeTicketModal.context.id); + const emps = employees.filter((employee) => employee.id === values.employeeid); + const mutation = isEdit + ? updateTicket({ + variables: { + timeticketId: timeTicketModal.context.id, + timeticket: { ...values, rate: - emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null, - bodyshopid: bodyshop.id, - created_by: timeTicketModal.context.created_by + emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null } - ] - } - }) - .then(handleMutationSuccess) - .catch(handleMutationError); - } + } + }) + : insertTicket({ + variables: { + timeTicketInput: [ + { + ...values, + rate: + emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null, + bodyshopid: bodyshop.id, + created_by: timeTicketModal.context.created_by + } + ] + } + }); + + mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError); }; - const handleMutationSuccess = () => { + const handleMutationSuccess = (result, isEdit) => { notification.success({ 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 savedTicket = + result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {}; + const originalTicket = timeTicketModal.context?.timeticket ?? {}; + const submittedValues = { + ...(lastSubmittedRef.current ?? {}), + date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null, + employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null, + jobid: + lastSubmittedRef.current?.jobid ?? + savedTicket.jobid ?? + timeTicketModal.context.jobId ?? + originalTicket.job?.id ?? + originalTicket.jobid ?? + null }; - - 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" - ) + const auditSummary = buildTimeTicketAuditSummary({ + originalTicket, + submittedValues, + employees }); + if (auditSummary.jobid) { + insertAuditTrail({ + jobid: auditSummary.jobid, + operation: isEdit + ? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details) + : AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details), + type: isEdit ? "timeticketupdated" : "timeticketcreated" + }); + } + // Refresh parent screens (Job Labor tab, etc.) if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch(); diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index f22dbdcd1..9e48806ec 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 with the following details: {{values}}.", + "billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.", "failedpayment": "Failed payment attempt.", "jobassignmentchange": "Employee {{name}} assigned to {{operation}}", "jobassignmentremoved": "Employee assignment removed for {{operation}}", @@ -137,9 +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.", + "joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.", "jobmanualcreate": "Job manually created.", - "jobmanuallineinsert": "Job line manually added with the following details: {{values}}.", + "jobmanuallineinsert": "Job line manually added with the following details: {{details}}.", "jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.", "jobnoteadded": "Note added to Job.", "jobnotedeleted": "Note deleted from Job.", @@ -156,6 +156,7 @@ "tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}", "tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}", "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}", + "timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.", "timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}" } }, diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 3f53233fb..cb5613c61 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -122,7 +122,6 @@ "billdeleted": "", "billposted": "", "billmarkforreexport": "", - "billupdated": "", "failedpayment": "", "jobassignmentchange": "", "jobassignmentremoved": "", @@ -137,9 +136,6 @@ "jobintake": "", "jobinvoiced": "", "jobioucreated": "", - "joblineupdate": "", - "jobmanualcreate": "", - "jobmanuallineinsert": "", "jobmodifylbradj": "", "jobnoteadded": "", "jobnotedeleted": "", @@ -155,8 +151,7 @@ "tasks_deleted": "", "tasks_uncompleted": "", "tasks_undeleted": "", - "tasks_updated": "", - "timeticketupdated": "" + "tasks_updated": "" } }, "billlines": { diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index a9612b05b..745e8cd65 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -122,7 +122,6 @@ "billdeleted": "", "billmarkforreexport": "", "billposted": "", - "billupdated": "", "failedpayment": "", "jobassignmentchange": "", "jobassignmentremoved": "", @@ -137,9 +136,6 @@ "jobintake": "", "jobinvoiced": "", "jobioucreated": "", - "joblineupdate": "", - "jobmanualcreate": "", - "jobmanuallineinsert": "", "jobmodifylbradj": "", "jobnoteadded": "", "jobnotedeleted": "", @@ -155,8 +151,7 @@ "tasks_deleted": "", "tasks_uncompleted": "", "tasks_undeleted": "", - "tasks_updated": "", - "timeticketupdated": "" + "tasks_updated": "" } }, "billlines": { diff --git a/client/src/utils/AuditTrailMappings.js b/client/src/utils/AuditTrailMappings.js index 5e4c160b7..23a2600ed 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, values) => i18n.t("audit_trail.messages.billupdated", { invoice_number, values }), + billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }), 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,9 +26,10 @@ 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 }), + joblineupdate: (lineDescription, details) => + i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }), jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"), - jobmanuallineinsert: (values) => i18n.t("audit_trail.messages.jobmanuallineinsert", { values }), + jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }), 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"), @@ -76,7 +77,10 @@ const AuditTrailMapping = { title, uncompletedBy }), - timeticketupdated: (employee, date, details) => i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details }) + timeticketcreated: (employee, date, details) => + i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }), + timeticketupdated: (employee, date, details) => + i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details }) }; export default AuditTrailMapping; diff --git a/client/src/utils/auditTrailDetails.js b/client/src/utils/auditTrailDetails.js new file mode 100644 index 000000000..16398267a --- /dev/null +++ b/client/src/utils/auditTrailDetails.js @@ -0,0 +1,186 @@ +import dayjs from "./day"; + +const EMPTY_VALUE = "<>"; +const NO_CHANGES = "No changes"; + +const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"]; +const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]); +const DATE_ONLY_KEYS = new Set(["date"]); +const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]); +const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]); +const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]); + +const isBlank = (value) => value == null || value === ""; + +const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value); + +const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD")); + +const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm")); + +const formatNumber = (value, fractionDigits) => + typeof value === "number" ? value.toFixed(fractionDigits) : String(value); + +const compareValue = (key, value) => { + if (isBlank(value)) return EMPTY_VALUE; + if (DATE_TIME_KEYS.has(key)) return formatDateTime(value); + if (DATE_ONLY_KEYS.has(key)) return formatDate(value); + if (dayjs.isDayjs?.(value)) return formatDateTime(value); + return String(value); +}; + +const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) => + keys + .filter((key) => key !== "__typename" && !skippedKeys.has(key)) + .filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key])) + .map((key) => { + if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null; + return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`; + }) + .filter(Boolean); + +const formatBillValue = (key, value) => { + if (isBlank(value)) return EMPTY_VALUE; + if (DATE_ONLY_KEYS.has(key)) return formatDate(value); + if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value); + return String(value); +}; + +const formatJobLineValue = (key, value) => { + if (isBlank(value)) return EMPTY_VALUE; + if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value); + if (HOUR_KEYS.has(key)) return formatNumber(value, 1); + return String(value); +}; + +const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => { + if ( + (employeeId == null || fallbackEmployee?.id === employeeId) && + (fallbackEmployee?.first_name || fallbackEmployee?.last_name) + ) { + return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" "); + } + + const employee = employees.find(({ id }) => id === employeeId); + if (employee) { + return [employee.first_name, employee.last_name].filter(Boolean).join(" "); + } + + return employeeId ? String(employeeId) : EMPTY_VALUE; +}; + +const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => { + if (isBlank(value)) return EMPTY_VALUE; + if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee); + if (DATE_TIME_KEYS.has(key)) return formatDateTime(value); + if (DATE_ONLY_KEYS.has(key)) return formatDate(value); + if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value); + if (HOUR_KEYS.has(key)) return formatNumber(value, 1); + if (typeof value === "boolean") return value ? "true" : "false"; + return String(value); +}; + +const buildBillLineSummary = (line) => + BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", "); + +export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) { + const updatedBill = { ...bill, billlines }; + const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter( + (key) => key !== "billlines" + ); + + const changed = buildFieldChangeDetails({ + keys: billKeys, + original: originalBill, + updated: updatedBill, + displayValue: formatBillValue + }); + + const originalBillLines = originalBill.billlines ?? []; + const updatedBillLines = updatedBill.billlines ?? []; + + const addedLines = updatedBillLines + .filter((line) => !line.id) + .map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`); + + const removedLines = originalBillLines + .filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id)) + .map( + (line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})` + ); + + const modifiedLines = updatedBillLines + .filter((line) => line.id) + .flatMap((line) => { + const originalLine = originalBillLines.find(({ id }) => id === line.id); + if (!originalLine) return []; + + const lineChanges = buildFieldChangeDetails({ + keys: BILL_LINE_KEYS, + original: originalLine, + updated: line, + displayValue: formatBillValue + }); + + if (!lineChanges.length) return []; + + return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`]; + }); + + if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`); + if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`); + if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`); + + return changed.length ? changed.join("; ") : NO_CHANGES; +} + +export function buildJobLineInsertAuditDetails(values = {}) { + const details = Object.entries(values) + .filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value)) + .map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`); + + return details.length ? details.join("; ") : NO_CHANGES; +} + +export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) { + const details = buildFieldChangeDetails({ + keys: Object.keys(values), + original: originalLine, + updated: values, + displayValue: formatJobLineValue, + skippedKeys: JOB_LINE_SKIP_KEYS + }); + + return details.length ? details.join("; ") : NO_CHANGES; +} + +export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) { + const normalizedOriginal = { + ...originalTicket, + jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null + }; + + const details = buildFieldChangeDetails({ + keys: Object.keys(submittedValues), + original: normalizedOriginal, + updated: submittedValues, + displayValue: (key, value) => + formatTimeTicketValue(key, value, { + employees, + fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null + }) + }); + + const employeeName = getEmployeeName( + submittedValues.employeeid ?? normalizedOriginal.employeeid, + employees, + normalizedOriginal.employee + ); + + return { + date: formatDate(submittedValues.date ?? normalizedOriginal.date), + details: details.length ? details.join("; ") : NO_CHANGES, + employeeName, + jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null + }; +}