diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx index 6d8add04..3d6937c1 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; @@ -17,13 +17,6 @@ const HIGHLIGHTED_COMMENT_STYLE = { paddingLeft: 8, } as const; -// Brings the first comment a notification pointed at into view when it mounts. -function scrollIntoViewOnMount(node: HTMLDivElement | null): void { - if (node != null && typeof node.scrollIntoView === "function") { - node.scrollIntoView({ block: "nearest" }); - } -} - function formatCommentTime(value: string): string { if (!value) return ""; const date = new Date(value); @@ -181,6 +174,15 @@ export function SlideOverCommentsTab({ [comments, highlight, currentUserId], ); const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id; + const firstHighlightedRef = useRef(null); + // Bring the first comment a notification pointed at into view whenever the target changes — the + // tab may already be mounted when a later notification re-opens this same work order. + useEffect(() => { + const node = firstHighlightedRef.current; + if (firstHighlightedId != null && typeof node?.scrollIntoView === "function") { + node.scrollIntoView({ block: "nearest" }); + } + }, [firstHighlightedId]); const [commentText, setCommentText] = useState(""); const [editingCommentId, setEditingCommentId] = useState(null); const [draftText, setDraftText] = useState(""); @@ -266,7 +268,7 @@ export function SlideOverCommentsTab({ return (
void; /** Tab to land on when a work order opens outside edit mode. */ initialTab?: SlideOverTab; + /** Increments on every open so re-opening the same work order re-applies the requested tab. */ + openSeq?: number; }; export function useSlideOverEditState({ @@ -40,6 +42,7 @@ export function useSlideOverEditState({ closeDisabled, setTab, initialTab = "info", + openSeq, }: UseSlideOverEditStateArgs) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); @@ -69,6 +72,15 @@ export function useSlideOverEditState({ // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate }, [row?.id, editMode]); + // Re-apply the requested tab on every open, including a second notification for the work order + // already on screen (same row id) where the identity effect above does not re-run. Tab-only so + // an in-flight edit draft is never discarded. + useEffect(() => { + if (!row) return; + setTab(editMode ? "info" : initialTab); + // eslint-disable-next-line react-hooks/exhaustive-deps -- openSeq gates each open + }, [openSeq]); + const lockStatus = infoSource?.status ?? row?.status; const isInfoLocked = isSlideOverInfoLocked(lockStatus); diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index 7d3e83b1..341e163e 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -36,6 +36,7 @@ type UseWorkOrderSlideOverArgs = { onClose: () => void; saving?: boolean; initialTab?: SlideOverTab; + openSeq?: number; }; type CompletionUploadMutate = ( @@ -114,6 +115,7 @@ export function useWorkOrderSlideOver({ onClose, saving, initialTab, + openSeq, }: UseWorkOrderSlideOverArgs) { const { user } = useAuthContext(); const workOrderId = row?.id; @@ -163,6 +165,7 @@ export function useWorkOrderSlideOver({ closeDisabled, setTab, initialTab, + openSeq, }); const uploadCompletionPdf = (file: File) => { diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index 6fe97128..581fa6b5 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -39,6 +39,8 @@ type WorkOrderSlideOverProps = { initialTab?: SlideOverTab; /** Comments a notification opened the slide-over for; highlighted on the Comments tab. */ commentHighlight?: CommentHighlight | null; + /** Increments on every open so re-opening the same work order re-applies tab/highlight. */ + openSeq?: number; }; export function WorkOrderSlideOver({ @@ -58,8 +60,17 @@ export function WorkOrderSlideOver({ saving, initialTab, commentHighlight, + openSeq, }: WorkOrderSlideOverProps) { - const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving, initialTab }); + const state = useWorkOrderSlideOver({ + row, + editMode, + onSave, + onClose, + saving, + initialTab, + openSeq, + }); const { infoSource, activeDraft } = state; return ( diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index b6c8a394..34e2fbab 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -53,6 +53,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps editMode={page.slideOverEdit} initialTab={page.slideOverTab} commentHighlight={page.slideOverHighlight} + openSeq={page.slideOverOpenSeq} users={users} sites={locations} vendors={vendors} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 271d3fc3..eeb5a622 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -24,6 +24,9 @@ function useSlideOverState() { const [edit, setEdit] = useState(false); const [tab, setTab] = useState("info"); const [highlight, setHighlight] = useState(null); + // Bumps on every open() so the slide-over re-applies tab/highlight even when a second + // notification targets the work order already on screen (same row id). + const [openSeq, setOpenSeq] = useState(0); const open = ( next: WorkOrderTableRow, @@ -35,6 +38,7 @@ function useSlideOverState() { setEdit(nextEdit); setTab(nextTab); setHighlight(nextHighlight); + setOpenSeq((seq) => seq + 1); }; const close = () => { @@ -43,7 +47,7 @@ function useSlideOverState() { setHighlight(null); }; - return { row, edit, tab, highlight, open, close }; + return { row, edit, tab, highlight, openSeq, open, close }; } export function useWorkOrdersListPage() { @@ -155,6 +159,7 @@ export function useWorkOrdersListPage() { slideOverEdit: slideOver.edit, slideOverTab: slideOver.tab, slideOverHighlight: slideOver.highlight, + slideOverOpenSeq: slideOver.openSeq, confirmCancel, setConfirmCancel, confirmComplete, diff --git a/src/domain/work-orders/utils/comment-highlight.ts b/src/domain/work-orders/utils/comment-highlight.ts index 9a401035..abc1c2c8 100644 --- a/src/domain/work-orders/utils/comment-highlight.ts +++ b/src/domain/work-orders/utils/comment-highlight.ts @@ -30,7 +30,13 @@ export function highlightedCommentIds( if (Number.isNaN(createdAt) || createdAt < since || isOwnWorkOrderComment(comment, userId)) { continue; } - const mentionsUser = extractMentionIds(comment.commenttext ?? "").includes(userId); + // Board comments expose mentions two ways: an encoded `@[id:name]` token in the text, or a + // `mentions` id array alongside plain `@Name` text. Treat either as a mention of the user. + const mentionIds = new Set(extractMentionIds(comment.commenttext ?? "")); + for (const id of comment.mentions ?? []) { + mentionIds.add(String(id)); + } + const mentionsUser = mentionIds.has(userId); if (mentionsUser === highlight.mentions) { ids.add(String(comment.id)); } diff --git a/src/test/domain/work-orders/utils/comment-highlight.test.ts b/src/test/domain/work-orders/utils/comment-highlight.test.ts new file mode 100644 index 00000000..735d8387 --- /dev/null +++ b/src/test/domain/work-orders/utils/comment-highlight.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { WorkOrderComment } from "@/domain/work-orders/types/work-order-detail"; +import { highlightedCommentIds } from "@/domain/work-orders/utils/comment-highlight"; +import { encodeMentionToken } from "@/domain/work-orders/utils/comment-mentions"; + +function makeComment(overrides: Partial): WorkOrderComment { + return { + id: "c1", + createdDate: "2026-01-01T00:00:00", + commenttext: "", + commentType: "internal", + userName: "Someone Else", + authorId: "other", + commenter: null, + recordType: null, + ...overrides, + }; +} + +const SINCE = "2025-12-31T00:00:00Z"; + +describe("highlightedCommentIds", () => { + it("treats an API mentions id array as a mention even with plain @Name text", () => { + const comment = makeComment({ + id: "m1", + commenttext: "Hey @Jane can you check this", + mentions: ["user-1"], + }); + + const mentionHit = highlightedCommentIds([comment], { since: SINCE, mentions: true }, "user-1"); + expect(mentionHit.has("m1")).toBe(true); + + // The same comment must NOT tint under Recent activity (comments bucket). + const commentBucket = highlightedCommentIds( + [comment], + { since: SINCE, mentions: false }, + "user-1", + ); + expect(commentBucket.has("m1")).toBe(false); + }); + + it("still recognises encoded @[id:name] tokens", () => { + const comment = makeComment({ + id: "e1", + commenttext: `Ping ${encodeMentionToken("user-1", "Jane Doe")} please`, + }); + + expect( + highlightedCommentIds([comment], { since: SINCE, mentions: true }, "user-1").has("e1"), + ).toBe(true); + }); + + it("keeps a non-mention comment in the comments bucket", () => { + const comment = makeComment({ id: "p1", commenttext: "Status update, no mentions" }); + + expect( + highlightedCommentIds([comment], { since: SINCE, mentions: false }, "user-1").has("p1"), + ).toBe(true); + expect( + highlightedCommentIds([comment], { since: SINCE, mentions: true }, "user-1").has("p1"), + ).toBe(false); + }); +});