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 1d13f890..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,13 +1,21 @@ -import { 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"; import type { WorkOrderComment } from "@/domain/work-orders/types/work-order"; +import { highlightedCommentIds } from "@/domain/work-orders/utils/comment-highlight"; import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership"; import { getAvatarColorFromName, getInitialsFromName, } from "@/domain/work-orders/utils/dispatcher-avatar-color"; +import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; + +const HIGHLIGHTED_COMMENT_STYLE = { + background: "var(--color-primary-tint)", + borderLeft: "2px solid var(--color-primary)", + paddingLeft: 8, +} as const; function formatCommentTime(value: string): string { if (!value) return ""; @@ -140,6 +148,8 @@ type SlideOverCommentsTabProps = { isReadOnly: boolean; isPending: boolean; currentUserId?: string | number; + /** Set when a comment or mention notification opened this tab. */ + highlight?: CommentHighlight | null; isUpdatePending?: boolean; onSubmit: (text: string, options?: { onSuccess?: () => void }) => void; onUpdateComment?: ( @@ -154,10 +164,25 @@ export function SlideOverCommentsTab({ isReadOnly, isPending, currentUserId, + highlight, isUpdatePending = false, onSubmit, onUpdateComment, }: SlideOverCommentsTabProps) { + const highlightedIds = useMemo( + () => highlightedCommentIds(comments, highlight, currentUserId), + [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(""); @@ -238,12 +263,19 @@ export function SlideOverCommentsTab({ Boolean(onUpdateComment) && isOwnWorkOrderComment(comment, currentUserId); const isEditing = editingCommentId === comment.id; + const isHighlighted = highlightedIds.has(String(comment.id)); return (
+ {isHighlighted && New for you: } { - if (row?.id == null || editMode) return; - setTab(initialTab); - }, [editMode, initialTab, openRequestKey, row?.id, setTab]); + if (!row) return; + setTab(editMode ? "info" : initialTab); + // eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open + }, [openRequestKey]); const lockStatus = infoSource?.status ?? row?.status; const isInfoLocked = isSlideOverInfoLocked(lockStatus); diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index d43d94c1..7337ace1 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -11,6 +11,7 @@ import type { } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { resolveSlideOverAvetaRequired } from "@/domain/work-orders/utils/resolve-slide-over-aveta-required"; +import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over"; import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; @@ -25,6 +26,7 @@ type WorkOrderSlideOverTabsProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; allOrders: WorkOrderTableRow[]; + commentHighlight?: CommentHighlight | null; onDuplicateFound?: (existing: WorkOrderTableRow) => void; onOpenCompletionDoc: () => void; onRequestComplete?: () => void; @@ -39,6 +41,7 @@ export function WorkOrderSlideOverTabs({ vendors, pmTypes, allOrders, + commentHighlight, onDuplicateFound, onOpenCompletionDoc, onRequestComplete, @@ -82,6 +85,7 @@ export function WorkOrderSlideOverTabs({ isReadOnly={state.isCommentsReadOnly} isPending={state.commentMutation.isPending} currentUserId={state.user?.id} + highlight={commentHighlight} isUpdatePending={state.updateCommentMutation.isPending} onSubmit={(text, options) => state.commentMutation.mutate({ text }, options)} onUpdateComment={(commentId, text, options) => 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 545adc0b..762d5aa4 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 @@ -11,6 +11,7 @@ import type { WorkOrderTablePatch, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; +import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over"; import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav"; import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs"; @@ -36,6 +37,9 @@ type WorkOrderSlideOverProps = { onClose: () => void; saving?: boolean; 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. */ openRequestKey?: number; }; @@ -55,6 +59,7 @@ export function WorkOrderSlideOver({ onClose, saving, initialTab, + commentHighlight, openRequestKey, }: WorkOrderSlideOverProps) { const state = useWorkOrderSlideOver({ @@ -97,6 +102,7 @@ export function WorkOrderSlideOver({ vendors={vendors} pmTypes={pmTypes} allOrders={allOrders} + commentHighlight={commentHighlight} onDuplicateFound={onDuplicateFound} onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)} onRequestComplete={() => onRequestComplete?.(infoSource)} 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 50972610..3f230e88 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 @@ -52,6 +52,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps row={page.activeSlideOverRow} editMode={page.slideOverEdit} initialTab={page.slideOverTab} + commentHighlight={page.slideOverHighlight} openRequestKey={page.slideOverOpenRequestKey} users={users} sites={locations} diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts b/src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts index 335906f3..d25a0895 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts @@ -4,27 +4,54 @@ import type { SlideOverTab } from "@/app/(protected)/workorders/_components/deta import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { + type CommentHighlight, + type WorkOrderDeepLinkTab, parseWorkOrderDeepLink, withoutWorkOrderDeepLink, } from "@/domain/work-orders/utils/work-order-deep-link"; -type OpenSlideOver = (row: WorkOrderTableRow, edit: boolean, tab: SlideOverTab) => void; +export type WorkOrderDeepLinkHandlers = { + onOpenSlideOver: ( + row: WorkOrderTableRow, + edit: boolean, + tab: SlideOverTab, + highlight: CommentHighlight | null, + ) => void; + /** The uplifts surface is the dialog the board's Uplift column opens. */ + onOpenUplifts: (row: WorkOrderTableRow) => void; +}; + +function openLink( + handlers: WorkOrderDeepLinkHandlers, + row: WorkOrderTableRow, + tab: WorkOrderDeepLinkTab, + highlight: CommentHighlight | null, +): void { + if (tab === "uplifts") { + handlers.onOpenUplifts(row); + return; + } + handlers.onOpenSlideOver(row, false, tab, highlight); +} /** - * Opens `?wo=&tab=` in the slide-over once that work order's detail loads, whatever week - * the board is showing, then removes the params so closing the slide-over does not reopen it. + * Opens `?wo=&tab=` once that work order's detail loads, whatever week the board is + * showing, then removes the params so closing does not reopen it. `tab=uplifts` opens the + * uplifts dialog; every other tab opens the slide-over. */ -export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void { +export function useWorkOrderDeepLink(handlers: WorkOrderDeepLinkHandlers): void { const [searchParams, setSearchParams] = useSearchParams(); const link = parseWorkOrderDeepLink(searchParams); const workOrderId = link?.workOrderId; const tab = link?.tab ?? "info"; + const since = link?.highlight?.since; + const mentions = link?.highlight?.mentions ?? false; const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null); - const onOpenRef = useRef(onOpen); + const handlersRef = useRef(handlers); useEffect(() => { - onOpenRef.current = onOpen; - }, [onOpen]); + handlersRef.current = handlers; + }, [handlers]); useEffect(() => { if (workOrderId == null) { @@ -35,8 +62,8 @@ export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void { return; } if (loaded) { - onOpenRef.current(data.info, false, tab); + openLink(handlersRef.current, data.info, tab, since == null ? null : { since, mentions }); } setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true }); - }, [workOrderId, tab, data, error, searchParams, setSearchParams]); + }, [workOrderId, tab, since, mentions, data, error, searchParams, setSearchParams]); } 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 d912d0ec..113dec56 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 @@ -10,6 +10,7 @@ import { import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; +import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import { patchWorkOrderAsCanceled, patchWorkOrderAsCompleted, @@ -22,27 +23,38 @@ function useSlideOverOpenState() { const [slideOverRow, setSlideOverRow] = useState(null); const [slideOverEdit, setSlideOverEdit] = useState(false); const [slideOverTab, setSlideOverTab] = useState("info"); + const [slideOverHighlight, setSlideOverHighlight] = 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 [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0); const handleOpenSlideOver = ( row: WorkOrderTableRow, edit = false, tab: SlideOverTab = "info", + highlight: CommentHighlight | null = null, ) => { setSlideOverRow(row); setSlideOverEdit(edit); setSlideOverTab(tab); + setSlideOverHighlight(highlight); setSlideOverOpenRequestKey((key) => key + 1); }; + const handleCloseSlideOver = () => { + setSlideOverRow(null); + setSlideOverEdit(false); + setSlideOverHighlight(null); + }; + return { slideOverRow, - setSlideOverRow, slideOverEdit, - setSlideOverEdit, slideOverTab, + slideOverHighlight, slideOverOpenRequestKey, handleOpenSlideOver, + handleCloseSlideOver, }; } @@ -50,12 +62,12 @@ export function useWorkOrdersListPage() { const [wizardOpen, setWizardOpen] = useState(false); const { slideOverRow, - setSlideOverRow, slideOverEdit, - setSlideOverEdit, slideOverTab, + slideOverHighlight, slideOverOpenRequestKey, handleOpenSlideOver, + handleCloseSlideOver: closeSlideOverState, } = useSlideOverOpenState(); const [confirmCancel, setConfirmCancel] = useState(null); const [confirmComplete, setConfirmComplete] = useState(null); @@ -111,8 +123,7 @@ export function useWorkOrdersListPage() { }, [upliftsRow, tableData.rows]); const handleCloseSlideOver = () => { - setSlideOverRow(null); - setSlideOverEdit(false); + closeSlideOverState(); tableData.setSelectedId(null); }; @@ -162,6 +173,7 @@ export function useWorkOrdersListPage() { setWizardOpen, slideOverEdit, slideOverTab, + slideOverHighlight, slideOverOpenRequestKey, confirmCancel, setConfirmCancel, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 5e32808e..95c54dfe 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -28,7 +28,10 @@ export default function WorkOrdersListPage() { const page = useWorkOrdersListPage(); const { filters, tableData } = page; const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters); - useWorkOrderDeepLink(page.handleOpenSlideOver); + useWorkOrderDeepLink({ + onOpenSlideOver: page.handleOpenSlideOver, + onOpenUplifts: page.setUpliftsRow, + }); return ( diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index a081ca1b..ed254ab7 100644 --- a/src/components/notifications/notification-row.tsx +++ b/src/components/notifications/notification-row.tsx @@ -1,11 +1,14 @@ +import { createElement } from "react"; import type { LucideIcon } from "lucide-react"; import { AlertTriangle, Bell, + CircleDollarSign, Clock, Inbox, MessageSquare, ShieldAlert, + UserPlus, Wrench, X, } from "lucide-react"; @@ -27,6 +30,24 @@ const REASON_ICONS: Record = { "vendor-conflict": Wrench, }; +const ICON_PROPS = { + size: 15, + "aria-hidden": true, + className: "shrink-0", + style: { color: "var(--color-text-muted)", marginTop: 2 }, +} as const; + +// Recent activity mixes comments, assignments and uplift decisions; where the row leads tells them apart. +function ItemIcon({ item }: { item: NotificationItem }) { + if (item.reason === "activity" && item.target.tab === "uplifts") { + return ; + } + if (item.reason === "activity" && item.target.tab !== "comments") { + return ; + } + return createElement(REASON_ICONS[item.reason] ?? Bell, ICON_PROPS); +} + type NotificationRowProps = { item: NotificationItem; unread: boolean; @@ -64,8 +85,6 @@ function WorkOrderLinks({ item, onOpen }: Pick - + void) { const navigate = useNavigate(); + const { user } = useAuthContext(); const feed = useNotificationFeed(); const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession(); @@ -24,7 +26,7 @@ export function useNotificationCenter(onNavigate?: () => void) { [items, readIds], ); - const open = (item: NotificationItem, url = notificationTargetUrl(item)) => { + const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => { markRead([item.id]); if (url == null) return; onNavigate?.(); diff --git a/src/domain/notifications/utils/notification-target-url.ts b/src/domain/notifications/utils/notification-target-url.ts index 10ac1867..596b5992 100644 --- a/src/domain/notifications/utils/notification-target-url.ts +++ b/src/domain/notifications/utils/notification-target-url.ts @@ -10,16 +10,22 @@ import { } from "@/domain/work-orders/utils/board-query-params"; import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link"; -/** Every open, unassigned work order in any week, through the existing Work Orders filter params. */ -export function unassignedQueueUrl(): string { +const COMMENT_REASONS = new Set(["mentions", "activity"]); + +/** Every open work order held by `dispatcher`, in any week, through the Work Orders filter params. */ +function openQueueUrl(dispatcher: string): string { const search = new URLSearchParams(); search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM); search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO); - search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED); + search.set("dispatchers", dispatcher); search.set("statuses", OPEN_WIZARD_STATUSES.join(",")); return workOrderDrilldownUrl(search, "notification"); } +export function unassignedQueueUrl(): string { + return openQueueUrl(ASSIGNEE_FILTER_UNASSIGNED); +} + /** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */ export function chooseVendorUrl(workOrderId: number): string { return `/workorders/edit/${workOrderId}?assignVendor=1`; @@ -29,12 +35,21 @@ export function workOrderViewUrl(workOrderId: number): string { return `/workorders/${workOrderId}`; } +/** + * Where a feed item leads. Queues open the Work Orders board filtered; work-order items open the + * slide-over on their tab, and comment or mention items also carry which comments to highlight. + */ export function notificationTargetUrl( - item: Pick, + item: Pick, + currentUserId?: string | number | null, ): string | null { const { target } = item; if (target.kind === "queue") { - return target.queue === "unassigned" ? unassignedQueueUrl() : null; + if (target.queue === "unassigned") return unassignedQueueUrl(); + if (target.queue === "assigned-to-me" && currentUserId != null && currentUserId !== "") { + return openQueueUrl(String(currentUserId)); + } + return null; } if (target.workOrderId == null) { return null; @@ -42,5 +57,9 @@ export function notificationTargetUrl( if (item.reason === "no-vendor") { return chooseVendorUrl(target.workOrderId); } - return workOrderDeepLinkUrl(target.workOrderId, target.tab); + const highlight = + target.tab === "comments" && COMMENT_REASONS.has(item.reason) + ? { since: item.triggeredAt, mentions: item.reason === "mentions" } + : null; + return workOrderDeepLinkUrl(target.workOrderId, target.tab, highlight); } diff --git a/src/domain/work-orders/utils/comment-highlight.ts b/src/domain/work-orders/utils/comment-highlight.ts new file mode 100644 index 00000000..abc1c2c8 --- /dev/null +++ b/src/domain/work-orders/utils/comment-highlight.ts @@ -0,0 +1,45 @@ +import type { WorkOrderComment } from "@/domain/work-orders/types/work-order-detail"; +import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership"; +import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions"; +import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; + +/** + * Ids of the comments a notification was raised for. The feed starts a comment item at its first + * unanswered comment and a mention item at the mentioning comment, so this is every comment by + * someone else from that moment on, split the same way the feed splits them: comments that + * mention the user belong to Mentions, the rest to Recent activity. + */ +// Board comment times arrive as UTC without a zone designator; read them as UTC, not local time. +function parseUtc(value: string): number { + return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`); +} + +export function highlightedCommentIds( + comments: WorkOrderComment[], + highlight: CommentHighlight | null | undefined, + currentUserId: string | number | null | undefined, +): Set { + const ids = new Set(); + const since = highlight == null ? Number.NaN : parseUtc(highlight.since); + if (highlight == null || Number.isNaN(since) || currentUserId == null || currentUserId === "") { + return ids; + } + const userId = String(currentUserId); + for (const comment of comments) { + const createdAt = parseUtc(comment.createdDate); + if (Number.isNaN(createdAt) || createdAt < since || isOwnWorkOrderComment(comment, userId)) { + continue; + } + // 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)); + } + } + return ids; +} diff --git a/src/domain/work-orders/utils/work-order-deep-link.ts b/src/domain/work-orders/utils/work-order-deep-link.ts index ec39db1b..eed063d4 100644 --- a/src/domain/work-orders/utils/work-order-deep-link.ts +++ b/src/domain/work-orders/utils/work-order-deep-link.ts @@ -2,6 +2,10 @@ import { WORK_ORDERS_ROUTE } from "@/domain/dashboard/utils/work-order-drilldown const WORK_ORDER_PARAM = "wo"; const TAB_PARAM = "tab"; +const SINCE_PARAM = "since"; +const FOCUS_PARAM = "focus"; +const MENTIONS_FOCUS = "mentions"; +const COMMENTS_FOCUS = "comments"; export const WORK_ORDER_DEEP_LINK_TABS = [ "info", @@ -9,40 +13,80 @@ export const WORK_ORDER_DEEP_LINK_TABS = [ "audit", "completion", "extras", + "uplifts", ] as const; export type WorkOrderDeepLinkTab = (typeof WORK_ORDER_DEEP_LINK_TABS)[number]; +/** + * The comments a notification was raised for: everything by someone else from `since` on, + * either the ones that mention the user or the ones that do not. + */ +export type CommentHighlight = { + since: string; + mentions: boolean; +}; + export type WorkOrderDeepLink = { workOrderId: string; tab: WorkOrderDeepLinkTab; + highlight: CommentHighlight | null; }; function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab { return WORK_ORDER_DEEP_LINK_TABS.some((tab) => tab === value); } -/** `/workorders?wo=&tab=` opens that work order's slide-over on the given tab. */ -export function workOrderDeepLinkUrl(workOrderId: string | number, tab?: string | null): string { +/** + * `/workorders?wo=&tab=` opens that work order's slide-over on the given tab; `uplifts` + * opens its uplifts dialog. A Comments link can also carry the comments to highlight. + */ +export function workOrderDeepLinkUrl( + workOrderId: string | number, + tab?: string | null, + highlight?: CommentHighlight | null, +): string { const search = new URLSearchParams({ [WORK_ORDER_PARAM]: String(workOrderId) }); if (isDeepLinkTab(tab ?? null)) { search.set(TAB_PARAM, String(tab)); } + if (tab === "comments" && highlight != null) { + search.set(SINCE_PARAM, highlight.since); + search.set(FOCUS_PARAM, highlight.mentions ? MENTIONS_FOCUS : COMMENTS_FOCUS); + } return `${WORK_ORDERS_ROUTE}?${search.toString()}`; } +function parseHighlight( + params: URLSearchParams, + tab: WorkOrderDeepLinkTab, +): CommentHighlight | null { + const since = params.get(SINCE_PARAM) ?? ""; + const focus = params.get(FOCUS_PARAM); + if (tab !== "comments" || Number.isNaN(Date.parse(since))) { + return null; + } + if (focus !== MENTIONS_FOCUS && focus !== COMMENTS_FOCUS) { + return null; + } + return { since, mentions: focus === MENTIONS_FOCUS }; +} + export function parseWorkOrderDeepLink(params: URLSearchParams): WorkOrderDeepLink | null { const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim(); if (!/^\d+$/.test(workOrderId)) { return null; } - const tab = params.get(TAB_PARAM); - return { workOrderId, tab: isDeepLinkTab(tab) ? tab : "info" }; + const requested = params.get(TAB_PARAM); + const tab = isDeepLinkTab(requested) ? requested : "info"; + return { workOrderId, tab, highlight: parseHighlight(params, tab) }; } export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams { const next = new URLSearchParams(params); next.delete(WORK_ORDER_PARAM); next.delete(TAB_PARAM); + next.delete(SINCE_PARAM); + next.delete(FOCUS_PARAM); return next; } diff --git a/src/test/app/(protected)/workorders/slide-over-comment-highlight.test.tsx b/src/test/app/(protected)/workorders/slide-over-comment-highlight.test.tsx new file mode 100644 index 00000000..8b5c08ba --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-comment-highlight.test.tsx @@ -0,0 +1,95 @@ +import { screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab"; +import type { WorkOrderComment } from "@/domain/work-orders/types/work-order-detail"; +import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; +import { renderWithProviders } from "@/test/test-utils"; + +const ME = "dispatcher-me"; + +function comment( + id: number, + authorId: string, + createdDate: string, + text: string, +): WorkOrderComment { + return { + id, + createdDate, + commenttext: text, + commentType: "General", + userName: authorId === ME ? "Me Myself" : "Omar Diaz", + authorId, + commenter: null, + recordType: "WorkOrder", + }; +} + +// Board comment times arrive without a zone designator and are UTC. +const thread = [ + comment(1, "omar", "2026-09-18T08:00:00", "Earlier note, already seen"), + comment(2, "omar", "2026-09-18T09:30:00", "Vendor is running late"), + comment(3, ME, "2026-09-18T09:40:00", "My own comment"), + comment(4, "omar", "2026-09-18T10:00:00", "Can you check this @[dispatcher-me:Me Myself]"), + comment(5, "omar", "2026-09-18T10:05:00", "Arrived on site"), +]; + +function renderTab(highlight: CommentHighlight | null) { + renderWithProviders( + , + { withAuth: false }, + ); +} + +function highlightedTexts(): string[] { + return Array.from(document.querySelectorAll('[data-highlighted="true"]')).map( + (row) => row.querySelector("p")?.textContent ?? "", + ); +} + +describe("comments opened from a notification", () => { + const originalScroll = Element.prototype.scrollIntoView; + + afterEach(() => { + Element.prototype.scrollIntoView = originalScroll; + }); + + it("highlights the new comments by others that a comment notification covers", () => { + const scroll = vi.fn(); + Element.prototype.scrollIntoView = scroll; + + renderTab({ since: "2026-09-18T09:30:00Z", mentions: false }); + + expect(highlightedTexts()).toEqual(["Vendor is running late", "Arrived on site"]); + expect(screen.getAllByText("New for you:", { exact: false })).toHaveLength(2); + expect(scroll).toHaveBeenCalledTimes(1); + expect(scroll).toHaveBeenCalledWith({ block: "nearest" }); + }); + + it("highlights only the mentioning comment for a mention notification", () => { + renderTab({ since: "2026-09-18T10:00:00Z", mentions: true }); + + expect(highlightedTexts()).toEqual(["Can you check this @[dispatcher-me:Me Myself]"]); + }); + + it("never highlights the user's own comments", () => { + renderTab({ since: "2026-09-18T00:00:00Z", mentions: false }); + + expect(highlightedTexts()).not.toContain("My own comment"); + expect(highlightedTexts()).toHaveLength(3); + }); + + it("highlights nothing when the tab was opened without a notification", () => { + renderTab(null); + + expect(highlightedTexts()).toEqual([]); + expect(screen.queryByText("New for you:", { exact: false })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx b/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx index 64da8dd0..1b11c20e 100644 --- a/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx +++ b/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx @@ -32,53 +32,87 @@ describe("work order deep link", () => { detailState.requestedIds = []; }); - it("opens the linked work order on the requested tab and clears the link", async () => { - const onOpen = vi.fn(); - const { result } = renderHook( + function handlers() { + return { onOpenSlideOver: vi.fn(), onOpenUplifts: vi.fn() }; + } + + function renderLink(route: string, open: ReturnType) { + return renderHook( () => { - useWorkOrderDeepLink(onOpen); + useWorkOrderDeepLink(open); return useLocation(); }, - { wrapper: wrapper("/workorders?wo=42&tab=extras") }, + { wrapper: wrapper(route) }, ); + } + + it("opens the linked work order on the requested tab and clears the link", async () => { + const open = handlers(); + const { result } = renderLink("/workorders?wo=42&tab=extras", open); await waitFor(() => expect(result.current.search).toBe("")); expect(detailState.requestedIds).toContain("42"); - expect(onOpen).toHaveBeenCalledTimes(1); - expect(onOpen).toHaveBeenCalledWith({ id: 42, woNumber: "1042" }, false, "extras"); + expect(open.onOpenSlideOver).toHaveBeenCalledTimes(1); + expect(open.onOpenSlideOver).toHaveBeenCalledWith( + { id: 42, woNumber: "1042" }, + false, + "extras", + null, + ); + expect(open.onOpenUplifts).not.toHaveBeenCalled(); }); it("falls back to the info tab for an unknown tab", async () => { - const onOpen = vi.fn(); - renderHook(() => useWorkOrderDeepLink(onOpen), { - wrapper: wrapper("/workorders?wo=42&tab=nope"), - }); + const open = handlers(); + renderLink("/workorders?wo=42&tab=nope", open); - await waitFor(() => expect(onOpen).toHaveBeenCalledWith(expect.anything(), false, "info")); + await waitFor(() => + expect(open.onOpenSlideOver).toHaveBeenCalledWith(expect.anything(), false, "info", null), + ); + }); + + it("opens the Comments tab with the comments a notification points at", async () => { + const open = handlers(); + const { result } = renderLink( + "/workorders?wo=42&tab=comments&since=2026-09-18T09%3A30%3A00Z&focus=mentions", + open, + ); + + await waitFor(() => expect(result.current.search).toBe("")); + expect(open.onOpenSlideOver).toHaveBeenCalledTimes(1); + expect(open.onOpenSlideOver).toHaveBeenCalledWith(expect.anything(), false, "comments", { + since: "2026-09-18T09:30:00Z", + mentions: true, + }); + }); + + it("opens the uplifts dialog for an uplifts link instead of the slide-over", async () => { + const open = handlers(); + const { result } = renderLink("/workorders?wo=42&tab=uplifts", open); + + await waitFor(() => expect(result.current.search).toBe("")); + expect(open.onOpenUplifts).toHaveBeenCalledTimes(1); + expect(open.onOpenUplifts).toHaveBeenCalledWith({ id: 42, woNumber: "1042" }); + expect(open.onOpenSlideOver).not.toHaveBeenCalled(); }); it("clears the link without opening anything when the work order cannot load", async () => { detailState.data = undefined; detailState.error = new Error("not found"); - const onOpen = vi.fn(); - const { result } = renderHook( - () => { - useWorkOrderDeepLink(onOpen); - return useLocation(); - }, - { wrapper: wrapper("/workorders?wo=42") }, - ); + const open = handlers(); + const { result } = renderLink("/workorders?wo=42", open); await waitFor(() => expect(result.current.search).toBe("")); - expect(onOpen).not.toHaveBeenCalled(); + expect(open.onOpenSlideOver).not.toHaveBeenCalled(); + expect(open.onOpenUplifts).not.toHaveBeenCalled(); }); it("ignores a board URL without a work order link", () => { - const onOpen = vi.fn(); - renderHook(() => useWorkOrderDeepLink(onOpen), { wrapper: wrapper("/workorders?tab=extras") }); + const open = handlers(); + renderHook(() => useWorkOrderDeepLink(open), { wrapper: wrapper("/workorders?tab=extras") }); expect(detailState.requestedIds.every((id) => id == null)).toBe(true); - expect(onOpen).not.toHaveBeenCalled(); + expect(open.onOpenSlideOver).not.toHaveBeenCalled(); }); }); diff --git a/src/test/components/notifications/notification-center.test.tsx b/src/test/components/notifications/notification-center.test.tsx index 388a3661..781ee098 100644 --- a/src/test/components/notifications/notification-center.test.tsx +++ b/src/test/components/notifications/notification-center.test.tsx @@ -12,6 +12,11 @@ const apiGet = vi.hoisted(() => vi.fn()); vi.mock("@/api/api", () => ({ apiGet })); +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { id: "dispatcher-me" } }), +})); + type ItemInput = { id: string; reason: string; diff --git a/src/test/components/notifications/notification-event-items.test.tsx b/src/test/components/notifications/notification-event-items.test.tsx new file mode 100644 index 00000000..cc0c96bc --- /dev/null +++ b/src/test/components/notifications/notification-event-items.test.tsx @@ -0,0 +1,183 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NotificationBell } from "@/components/notifications/notification-bell"; +import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; +import { renderWithProviders } from "@/test/test-utils"; + +const apiGet = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/api", () => ({ apiGet })); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { id: "dispatcher-me" } }), +})); + +type Target = { kind: "workOrder" | "queue"; workOrderId?: number; tab?: string; queue?: string }; + +function item( + id: string, + reason: string, + severity: string, + title: string, + target: Target, + extra = {}, +) { + return { + id, + reason, + severity, + rowType: "dismissable", + title, + count: 1, + triggeredAt: "2026-09-18T12:00:00Z", + target, + workOrders: [], + ...extra, + }; +} + +const mention = item("mention-501", "mentions", "High", "Omar Diaz mentioned you on WO #1011", { + kind: "workOrder", + workOrderId: 11, + tab: "comments", +}); + +const comments = item( + "comments-12-505", + "activity", + "Medium", + "2 new comments on WO #1012", + { kind: "workOrder", workOrderId: 12, tab: "comments" }, + { triggeredAt: "2026-09-18T09:30:00Z" }, +); + +const assignedGroup = item( + "new-assigned-group-30", + "activity", + "Low", + "3 new work orders assigned to you", + { kind: "queue", queue: "assigned-to-me" }, + { count: 3 }, +); + +const assignedOne = item("new-assigned-40", "activity", "Low", "WO #1040 was assigned to you", { + kind: "workOrder", + workOrderId: 40, + tab: "info", +}); + +const upliftRejected = item( + "uplift-rejected-9", + "activity", + "High", + "Your $400.00 uplift on WO #1020 was rejected: “Quote does not match”", + { kind: "workOrder", workOrderId: 20, tab: "uplifts" }, +); + +const feed = { + generatedAt: "2026-09-18T16:00:00Z", + sections: [ + { reason: "mentions", label: "Mentions", severity: "High", count: 1, items: [mention] }, + { + reason: "activity", + label: "Recent activity", + severity: "High", + count: 6, + items: [upliftRejected, comments, assignedOne, assignedGroup], + }, + ], +}; + +function LocationProbe() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +async function openItem(title: RegExp) { + const user = userEvent.setup(); + renderWithProviders( + + + + , + { withAuth: false }, + ); + await user.click(await screen.findByRole("button", { name: /^Notifications, \d+ unread$/ })); + const panel = await screen.findByRole("dialog"); + await user.click(within(panel).getByRole("button", { name: title })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + const location = screen.getByRole("status", { name: "Current location" }).textContent ?? ""; + return new URL(location, "http://localhost"); +} + +describe("event notifications in the Notification Center", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue(feed); + }); + + it("lists mentions, comments, assignments and uplift decisions in their sections", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + { withAuth: false }, + ); + + await user.click(await screen.findByRole("button", { name: "Notifications, 5 unread" })); + const panel = await screen.findByRole("dialog"); + const mentions = within(panel).getByRole("region", { name: "Mentions, 1" }); + expect(within(mentions).getByText(mention.title)).toBeInTheDocument(); + const activity = within(panel).getByRole("region", { name: "Recent activity, 6" }); + for (const row of [upliftRejected, comments, assignedOne, assignedGroup]) { + expect(within(activity).getByText(row.title)).toBeInTheDocument(); + } + }); + + it("opens a comment item on the Comments tab with the comments to highlight", async () => { + const url = await openItem(/2 new comments on WO #1012/); + + expect(url.pathname).toBe("/workorders"); + expect(url.searchParams.get("wo")).toBe("12"); + expect(url.searchParams.get("tab")).toBe("comments"); + expect(url.searchParams.get("since")).toBe("2026-09-18T09:30:00Z"); + expect(url.searchParams.get("focus")).toBe("comments"); + }); + + it("opens a mention on the Comments tab focused on the mentioning comment", async () => { + const url = await openItem(/Omar Diaz mentioned you on WO #1011/); + + expect(url.searchParams.get("wo")).toBe("11"); + expect(url.searchParams.get("tab")).toBe("comments"); + expect(url.searchParams.get("since")).toBe("2026-09-18T12:00:00Z"); + expect(url.searchParams.get("focus")).toBe("mentions"); + }); + + it("opens a grouped assignment as the signed-in dispatcher's open queue", async () => { + const url = await openItem(/3 new work orders assigned to you/); + + expect(url.pathname).toBe("/workorders"); + expect(url.searchParams.get("dispatchers")).toBe("dispatcher-me"); + expect(url.searchParams.get("dateFrom")).toBe("1970-01-01"); + expect(url.searchParams.get("dateTo")).toBe("2099-12-31"); + expect(url.searchParams.get("statuses")).toBeTruthy(); + expect(url.searchParams.get("wo")).toBeNull(); + }); + + it("opens a single assignment straight on the work order, with no highlight", async () => { + const url = await openItem(/WO #1040 was assigned to you/); + + expect(`${url.pathname}${url.search}`).toBe("/workorders?wo=40&tab=info"); + }); + + it("opens an uplift decision on the work order's uplifts", async () => { + const url = await openItem(/uplift on WO #1020 was rejected/); + + expect(`${url.pathname}${url.search}`).toBe("/workorders?wo=20&tab=uplifts"); + }); +}); 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); + }); +});