Merge branch 'dev' into fix/ab/sh-356-due-date-after-schedule

This commit is contained in:
Alexandre Brandizzi 2026-09-16 18:26:20 -03:00 • committed by GitHub
commit 49fd0b039c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 202 additions and 45 deletions

View file

@ -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>

View file

@ -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({

View 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}`;
}

View file

@ -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";
}

View file

@ -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();
});
});

View file

@ -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("");
});
});

View file

@ -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", () => {