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 + }; +}