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..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,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) { await Promise.all(updates); + 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), + operation: AuditTrailMapping.billupdated(bill.invoice_number, details), type: "billupdated" }); 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..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 @@ -14,16 +14,20 @@ 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"; +import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.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 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo notification.success({ title: t("joblines.successes.created") }); + insertAuditTrail({ + jobid: jobLineEditModal.context.jobid, + operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)), + type: "jobmanuallineinsert" + }); } else { notification.error({ title: t("joblines.errors.creating", { @@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo notification.success({ title: t("joblines.successes.updated") }); + insertAuditTrail({ + jobid: jobLineEditModal.context.jobid, + operation: AuditTrailMapping.joblineupdate( + values.line_desc || jobLineEditModal.context.line_desc || "manual line", + buildJobLineUpdateAuditDetails({ + originalLine: jobLineEditModal.context, + values + }) + ), + 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..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 @@ -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,27 @@ 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"; +import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js"; 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); @@ -48,47 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); + const employees = EmployeeAutoCompleteData?.employees ?? []; const handleFinish = (values) => { + 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 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 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/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 d8deb99b2..33f89f2fd 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: {{details}}.", "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 {{lineDescription}} updated with the following details: {{details}}.", + "jobmanualcreate": "Job manually created.", + "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.", @@ -152,7 +155,9 @@ "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}}", + "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}}" } }, "billlines": { diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index e888b06ae..58c7a74b5 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": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index d428eab8c..e5cccbe55 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": "", diff --git a/client/src/utils/AuditTrailMappings.js b/client/src/utils/AuditTrailMappings.js index e33082c20..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) => i18n.t("audit_trail.messages.billupdated", { invoice_number }), + 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,6 +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: (lineDescription, details) => + i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }), + jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"), + 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"), @@ -72,7 +76,11 @@ const AuditTrailMapping = { i18n.t("audit_trail.messages.tasks_uncompleted", { title, uncompletedBy - }) + }), + 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 + }; +}