mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 08:52:04 +00:00
Merge branch 'dev' into feat/ab/sh-195-date-header-tooltips
This commit is contained in:
commit
ae6ba6978f
7 changed files with 202 additions and 45 deletions
|
|
@ -6,6 +6,7 @@ import {
|
||||||
getDispatcherAvatarColor,
|
getDispatcherAvatarColor,
|
||||||
getInitialsFromName,
|
getInitialsFromName,
|
||||||
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
} 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";
|
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
|
||||||
|
|
||||||
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
||||||
|
|
@ -15,36 +16,6 @@ function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
||||||
return !name || name.toLowerCase() === "system";
|
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 = {
|
type SlideOverAuditListProps = {
|
||||||
entries: WorkOrderAuditEntry[];
|
entries: WorkOrderAuditEntry[];
|
||||||
users?: WorkOrderUser[];
|
users?: WorkOrderUser[];
|
||||||
|
|
@ -151,7 +122,7 @@ export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListPr
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatAuditTime(entry.createdAt, isSys)}
|
{formatAuditTimestamp(entry.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,53 @@ function formatSeverityAuditLabel(raw: string): string {
|
||||||
return raw;
|
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 =
|
const matches =
|
||||||
fieldKey === "type" ||
|
fieldKey === "type" ||
|
||||||
fieldKey === "workordertype" ||
|
fieldKey === "workordertype" ||
|
||||||
fieldKey === "wotype" ||
|
fieldKey === "wotype" ||
|
||||||
actionKey === "typechanged" ||
|
actionKey === "typechanged" ||
|
||||||
actionKey.includes("workordertype");
|
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 {
|
function severityValue(value: FormattedAuditValue): FormattedAuditValue {
|
||||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
return value.text ? { text: formatSeverityAuditLabel(value.text), isQuotedList: false } : value;
|
||||||
if (!matches || !newValue.text) return null;
|
}
|
||||||
|
|
||||||
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
|
function trySeverityRule({
|
||||||
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
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({
|
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) {
|
if (newValue.text) {
|
||||||
return `Changed ${label} to ${wrapAuditValue(newValue)}`;
|
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";
|
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.");
|
).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(
|
expect(
|
||||||
formatBoardAuditAction({
|
formatBoardAuditAction({
|
||||||
action: "FieldPatched",
|
action: "FieldChanged",
|
||||||
fieldName: "WorkOrderType",
|
fieldName: "WorkOrderType",
|
||||||
oldValue: "PM",
|
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",
|
newValue: "Emergency",
|
||||||
}),
|
}),
|
||||||
).toBe('Changed Type to "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(
|
expect(
|
||||||
formatBoardAuditAction({
|
formatBoardAuditAction({
|
||||||
action: "FieldPatched",
|
action: "FieldChanged",
|
||||||
fieldName: "Severity",
|
fieldName: "Severity",
|
||||||
oldValue: "3",
|
oldValue: "3",
|
||||||
newValue: "1",
|
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", () => {
|
it("falls back to from → to for unknown fields", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue