mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge pull request #196 from Sea-Haven-Industries/fix/ab/sh-361-sh-363-audit-log-entries
SH-361, SH-363: full audit timestamps and previous type values
This commit is contained in:
commit
785e112f63
7 changed files with 202 additions and 45 deletions
|
|
@ -6,6 +6,7 @@ import {
|
|||
getDispatcherAvatarColor,
|
||||
getInitialsFromName,
|
||||
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp";
|
||||
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
|
||||
|
||||
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
||||
|
|
@ -15,36 +16,6 @@ function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
|||
return !name || name.toLowerCase() === "system";
|
||||
}
|
||||
|
||||
function formatClock(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual (image): `· 9:48 AM`
|
||||
* System (image): `05/13 · 8:00 AM`
|
||||
* Prefer API display strings that already include · / AM|PM.
|
||||
*/
|
||||
function formatAuditTime(value: string, isSystem: boolean): string {
|
||||
if (!value) return "";
|
||||
if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
const timePart = formatClock(date);
|
||||
if (isSystem) {
|
||||
const datePart = date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
return `${datePart} · ${timePart}`;
|
||||
}
|
||||
return `· ${timePart}`;
|
||||
}
|
||||
|
||||
type SlideOverAuditListProps = {
|
||||
entries: WorkOrderAuditEntry[];
|
||||
users?: WorkOrderUser[];
|
||||
|
|
@ -151,7 +122,7 @@ export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListPr
|
|||
color: "#9CA3AF",
|
||||
}}
|
||||
>
|
||||
{formatAuditTime(entry.createdAt, isSys)}
|
||||
{formatAuditTimestamp(entry.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,24 +39,53 @@ function formatSeverityAuditLabel(raw: string): string {
|
|||
return raw;
|
||||
}
|
||||
|
||||
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const CLEARED_AUDIT_VALUE: FormattedAuditValue = { text: "—", isQuotedList: false };
|
||||
|
||||
/** `from X to Y` whenever the previous value is known; a cleared value reads as "—". */
|
||||
function formatFieldChangeSentence(
|
||||
label: string,
|
||||
oldValue: FormattedAuditValue,
|
||||
newValue: FormattedAuditValue,
|
||||
): string | null {
|
||||
if (!oldValue.text && !newValue.text) return null;
|
||||
const next = wrapAuditValue(newValue.text ? newValue : CLEARED_AUDIT_VALUE);
|
||||
if (oldValue.text) {
|
||||
return `Changed ${label} from ${wrapAuditValue(oldValue)} to ${next}`;
|
||||
}
|
||||
return `Changed ${label} to ${next}`;
|
||||
}
|
||||
|
||||
function tryTypeRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey === "type" ||
|
||||
fieldKey === "workordertype" ||
|
||||
fieldKey === "wotype" ||
|
||||
actionKey === "typechanged" ||
|
||||
actionKey.includes("workordertype");
|
||||
if (!matches || !newValue.text) return null;
|
||||
if (!matches) return null;
|
||||
|
||||
return `Changed Type to ${wrapAuditValue(newValue)}`;
|
||||
return formatFieldChangeSentence("Type", oldValue, newValue);
|
||||
}
|
||||
|
||||
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||
if (!matches || !newValue.text) return null;
|
||||
function severityValue(value: FormattedAuditValue): FormattedAuditValue {
|
||||
return value.text ? { text: formatSeverityAuditLabel(value.text), isQuotedList: false } : value;
|
||||
}
|
||||
|
||||
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
|
||||
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
||||
function trySeverityRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||
if (!matches) return null;
|
||||
|
||||
return formatFieldChangeSentence("Severity", severityValue(oldValue), severityValue(newValue));
|
||||
}
|
||||
|
||||
function tryStatusRule({
|
||||
|
|
|
|||
24
src/domain/work-orders/utils/format-audit-timestamp.ts
Normal file
24
src/domain/work-orders/utils/format-audit-timestamp.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const AUDIT_DATE_PATTERN = /\b\d{1,2}\/\d{1,2}(\/\d{2,4})?\b/;
|
||||
|
||||
/**
|
||||
* Every entry, manual or system, shows a complete timestamp — `09/15/2026 · 7:42 PM`.
|
||||
* Pre-formatted API strings are kept only when they already carry a calendar date.
|
||||
*/
|
||||
export function formatAuditTimestamp(value: string): string {
|
||||
if (!value) return "";
|
||||
if (AUDIT_DATE_PATTERN.test(value) && /\b(AM|PM)\b/i.test(value)) return value;
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
const datePart = date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
const timePart = date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${datePart} · ${timePart}`;
|
||||
}
|
||||
|
|
@ -127,6 +127,10 @@ function formatGenericAuditFallback(args: {
|
|||
if (newValue.text) {
|
||||
return `Changed ${label} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
if (action) return action;
|
||||
if (oldValue.text) {
|
||||
return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`;
|
||||
}
|
||||
// Never surface a raw audit enum such as "FieldChanged" as the sentence.
|
||||
if (fieldName && action) return `Updated ${label}`;
|
||||
return "Updated work order";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
function statusEntry(overrides: Partial<WorkOrderAuditEntry> = {}): WorkOrderAuditEntry {
|
||||
return {
|
||||
id: "s1",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "Pending",
|
||||
newValue: "En Route",
|
||||
action: "StatusChanged",
|
||||
createdAt: "2026-09-15T19:42:00",
|
||||
userName: "Alex Dispatcher",
|
||||
type: "manual",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SlideOverAuditList timestamps (SH-361)", () => {
|
||||
it("shows the date as well as the time on a manual status change", () => {
|
||||
render(<SlideOverAuditList entries={[statusEntry()]} />);
|
||||
|
||||
expect(screen.getByText('Changed status from "Pending" to "En Route"')).toBeInTheDocument();
|
||||
expect(screen.getByText(/^\d{2}\/\d{2}\/2026 · 7:42 PM$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a type correction with its previous value (SH-363)", () => {
|
||||
render(
|
||||
<SlideOverAuditList
|
||||
entries={[
|
||||
statusEntry({
|
||||
id: "t1",
|
||||
action: "FieldChanged",
|
||||
fieldName: "WorkOrderType",
|
||||
oldValue: "PM",
|
||||
newValue: "Reactive",
|
||||
}),
|
||||
statusEntry({
|
||||
id: "t2",
|
||||
action: "FieldChanged",
|
||||
fieldName: "Severity",
|
||||
oldValue: "3",
|
||||
newValue: "",
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Changed Type from "PM" to "Reactive"')).toBeInTheDocument();
|
||||
expect(screen.getByText('Changed Severity from "SEV 3" to "—"')).toBeInTheDocument();
|
||||
expect(screen.queryByText("FieldChanged")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp";
|
||||
|
||||
describe("formatAuditTimestamp (SH-361)", () => {
|
||||
it("includes the calendar date and the time for an ISO audit time", () => {
|
||||
const iso = "2026-09-15T19:42:00";
|
||||
const date = new Date(iso);
|
||||
const expectedDate = date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const formatted = formatAuditTimestamp(iso);
|
||||
|
||||
expect(formatted).toBe(`${expectedDate} · 7:42 PM`);
|
||||
expect(formatted).toMatch(/^\d{2}\/\d{2}\/\d{4} · \d{1,2}:\d{2} (AM|PM)$/);
|
||||
});
|
||||
|
||||
it("distinguishes the same time of day on different dates", () => {
|
||||
expect(formatAuditTimestamp("2026-09-14T09:48:00")).not.toBe(
|
||||
formatAuditTimestamp("2026-09-15T09:48:00"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps pre-formatted or unparseable strings unchanged", () => {
|
||||
expect(formatAuditTimestamp("· 7:42 PM")).toBe("· 7:42 PM");
|
||||
expect(formatAuditTimestamp("09/15/2026 · 7:42 PM")).toBe("09/15/2026 · 7:42 PM");
|
||||
});
|
||||
|
||||
it("returns an empty string when there is no time", () => {
|
||||
expect(formatAuditTimestamp("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -89,26 +89,67 @@ describe("formatBoardAuditAction", () => {
|
|||
).toBe("WO marked as Past Due. Schedule On has passed without completion.");
|
||||
});
|
||||
|
||||
it("formats type correction as Changed Type to (AAP, new value only)", () => {
|
||||
it("formats type correction with the previous and new type (SH-363)", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
action: "FieldChanged",
|
||||
fieldName: "WorkOrderType",
|
||||
oldValue: "PM",
|
||||
newValue: "Reactive",
|
||||
}),
|
||||
).toBe('Changed Type from "PM" to "Reactive"');
|
||||
});
|
||||
|
||||
it("formats a first type assignment without a from clause", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "WorkOrderType",
|
||||
oldValue: "",
|
||||
newValue: "Emergency",
|
||||
}),
|
||||
).toBe('Changed Type to "Emergency"');
|
||||
});
|
||||
|
||||
it("formats severity as Changed Severity to SEV N", () => {
|
||||
it("formats severity with the previous and new SEV labels", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
action: "FieldChanged",
|
||||
fieldName: "Severity",
|
||||
oldValue: "3",
|
||||
newValue: "1",
|
||||
}),
|
||||
).toBe('Changed Severity to "SEV 1"');
|
||||
).toBe('Changed Severity from "SEV 3" to "SEV 1"');
|
||||
});
|
||||
|
||||
it("formats severity cleared by a type correction instead of a raw FieldChanged (SH-363)", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "Severity",
|
||||
oldValue: "3",
|
||||
newValue: "",
|
||||
}),
|
||||
).toBe('Changed Severity from "SEV 3" to "—"');
|
||||
});
|
||||
|
||||
it("never renders a raw audit action enum for a cleared unknown field", () => {
|
||||
const cleared = formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "VendorNte",
|
||||
oldValue: "100",
|
||||
newValue: "",
|
||||
});
|
||||
const empty = formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "VendorNte",
|
||||
oldValue: "",
|
||||
newValue: "",
|
||||
});
|
||||
|
||||
expect(cleared).toBe('Changed vendor nte from "100" to "—"');
|
||||
expect(empty).toBe("Updated vendor nte");
|
||||
expect([cleared, empty]).not.toContain("FieldChanged");
|
||||
});
|
||||
|
||||
it("falls back to from → to for unknown fields", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue