IO-1366 Extend Audit Log

Extend for Time Ticket Changes, Manual Lines, Manual Job, Bill Edits

Signed-off-by: Allan Carr <allan@imexsystems.ca>
This commit is contained in:
Allan Carr
2026-04-01 22:51:05 -07:00
committed by Dave
parent 7688f22161
commit fe848b5de4
8 changed files with 205 additions and 16 deletions

View File

@@ -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 === ""
? "<<empty>>"
: 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") ?? "<<empty>>"}${b?.format("YYYY-MM-DD") ?? "<<empty>>"}`;
}
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();

View File

@@ -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 "<<empty>>";
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", {

View File

@@ -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 "<<empty>>";
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") : "<<empty>>",
changed.length ? changed.join("; ") : "No changes"
)
});
// Refresh parent screens (Job Labor tab, etc.)
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();