feat(notifications): open comment, mention, assignment and uplift items where they happened

Comment and mention items open the work order on the Comments tab with the
comments they were raised for highlighted; a grouped assignment opens the
dispatcher's own open queue; uplift decisions open the work order's uplifts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codex Review Integration 2026-09-18 13:42:13 -03:00
parent dcf944d7dc
commit ae941bc93d
16 changed files with 599 additions and 74 deletions

View file

@ -1,13 +1,28 @@
import { useState } from "react";
import { useMemo, 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;
// 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 "";
@ -140,6 +155,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 +171,16 @@ 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 [commentText, setCommentText] = useState("");
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
const [draftText, setDraftText] = useState("");
@ -238,12 +261,19 @@ export function SlideOverCommentsTab({
Boolean(onUpdateComment) &&
isOwnWorkOrderComment(comment, currentUserId);
const isEditing = editingCommentId === comment.id;
const isHighlighted = highlightedIds.has(String(comment.id));
return (
<div
key={String(comment.id)}
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
ref={comment.id === firstHighlightedId ? scrollIntoViewOnMount : undefined}
data-highlighted={isHighlighted}
style={{
borderBottom: "1px solid var(--color-bg-muted)",
...(isHighlighted ? HIGHLIGHTED_COMMENT_STYLE : {}),
}}
>
{isHighlighted && <span className="sr-only">New for you: </span>}
<SlideOverCommentItem
comment={comment}
canEdit={canEdit}

View file

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

View file

@ -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,8 @@ type WorkOrderSlideOverProps = {
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
commentHighlight?: CommentHighlight | null;
};
export function WorkOrderSlideOver({
@ -54,6 +57,7 @@ export function WorkOrderSlideOver({
onClose,
saving,
initialTab,
commentHighlight,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving, initialTab });
const { infoSource, activeDraft } = state;
@ -87,6 +91,7 @@ export function WorkOrderSlideOver({
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
commentHighlight={commentHighlight}
onDuplicateFound={onDuplicateFound}
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
onRequestComplete={() => onRequestComplete?.(infoSource)}

View file

@ -52,6 +52,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
initialTab={page.slideOverTab}
commentHighlight={page.slideOverHighlight}
users={users}
sites={locations}
vendors={vendors}

View file

@ -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=<id>&tab=<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=<id>&tab=<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]);
}

View file

@ -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,
@ -18,11 +19,36 @@ import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
function useSlideOverState() {
const [row, setRow] = useState<WorkOrderTableRow | null>(null);
const [edit, setEdit] = useState(false);
const [tab, setTab] = useState<SlideOverTab>("info");
const [highlight, setHighlight] = useState<CommentHighlight | null>(null);
const open = (
next: WorkOrderTableRow,
nextEdit = false,
nextTab: SlideOverTab = "info",
nextHighlight: CommentHighlight | null = null,
) => {
setRow(next);
setEdit(nextEdit);
setTab(nextTab);
setHighlight(nextHighlight);
};
const close = () => {
setRow(null);
setEdit(false);
setHighlight(null);
};
return { row, edit, tab, highlight, open, close };
}
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const slideOver = useSlideOverState();
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
@ -56,6 +82,7 @@ export function useWorkOrdersListPage() {
[vendors],
);
const slideOverRow = slideOver.row;
const activeSlideOverRow = useMemo(() => {
if (!slideOverRow) return null;
return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
@ -76,19 +103,8 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
}, [upliftsRow, tableData.rows]);
const handleOpenSlideOver = (
row: WorkOrderTableRow,
edit = false,
tab: SlideOverTab = "info",
) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
setSlideOverTab(tab);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
slideOver.close();
tableData.setSelectedId(null);
};
@ -136,8 +152,9 @@ export function useWorkOrdersListPage() {
return {
wizardOpen,
setWizardOpen,
slideOverEdit,
slideOverTab,
slideOverEdit: slideOver.edit,
slideOverTab: slideOver.tab,
slideOverHighlight: slideOver.highlight,
confirmCancel,
setConfirmCancel,
confirmComplete,
@ -163,7 +180,7 @@ export function useWorkOrdersListPage() {
activeDocRow,
activeMediaRow,
activeUpliftsRow,
handleOpenSlideOver,
handleOpenSlideOver: slideOver.open,
handleCloseSlideOver,
handleConfirmCancel,
handleConfirmComplete,

View file

@ -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 (
<Box className="flex h-full flex-col overflow-hidden p-2.5">

View file

@ -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";
@ -25,6 +28,24 @@ const REASON_ICONS: Record<string, LucideIcon> = {
"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 <CircleDollarSign {...ICON_PROPS} />;
}
if (item.reason === "activity" && item.target.tab !== "comments") {
return <UserPlus {...ICON_PROPS} />;
}
return createElement(REASON_ICONS[item.reason] ?? Bell, ICON_PROPS);
}
type NotificationRowProps = {
item: NotificationItem;
unread: boolean;
@ -33,8 +54,6 @@ type NotificationRowProps = {
};
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
const Icon = REASON_ICONS[item.reason] ?? Bell;
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
@ -61,12 +80,7 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<Icon
size={15}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
/>
<ItemIcon item={item} />
<Text
as="span"
variant="description"

View file

@ -8,9 +8,11 @@ import {
isDismissable,
visibleNotificationSections,
} from "@/domain/notifications/utils/visible-notification-sections";
import { useAuthContext } from "@/providers/auth-context";
export function useNotificationCenter(onNavigate?: () => void) {
const navigate = useNavigate();
const { user } = useAuthContext();
const feed = useNotificationFeed();
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
@ -26,7 +28,7 @@ export function useNotificationCenter(onNavigate?: () => void) {
const open = (item: NotificationItem) => {
markRead([item.id]);
const url = notificationTargetUrl(item.target);
const url = notificationTargetUrl(item, user?.id);
if (url == null) return;
onNavigate?.();
navigate(url);

View file

@ -2,7 +2,7 @@ import {
OPEN_WIZARD_STATUSES,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import type { NotificationTarget } from "@/domain/notifications/types/notification-feed";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
UNASSIGNED_QUEUE_DATE_FROM,
@ -10,22 +10,44 @@ 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);
}
export function notificationTargetUrl(target: NotificationTarget): string | null {
export function unassignedQueueUrl(): string {
return openQueueUrl(ASSIGNEE_FILTER_UNASSIGNED);
}
/**
* 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<NotificationItem, "reason" | "triggeredAt" | "target">,
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;
}
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);
}

View file

@ -0,0 +1,39 @@
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<string> {
const ids = new Set<string>();
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;
}
const mentionsUser = extractMentionIds(comment.commenttext ?? "").includes(userId);
if (mentionsUser === highlight.mentions) {
ids.add(String(comment.id));
}
}
return ids;
}

View file

@ -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=<id>&tab=<tab>` opens that work order's slide-over on the given tab. */
export function workOrderDeepLinkUrl(workOrderId: string | number, tab?: string | null): string {
/**
* `/workorders?wo=<id>&tab=<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;
}

View file

@ -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(
<SlideOverCommentsTab
comments={thread}
isReadOnly={false}
isPending={false}
currentUserId={ME}
highlight={highlight}
onSubmit={vi.fn()}
/>,
{ 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();
});
});

View file

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

View file

@ -12,6 +12,11 @@ const apiGet = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", () => ({ apiGet }));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: "dispatcher-me" } }),
}));
type ItemInput = {
id: string;
reason: string;

View file

@ -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<typeof import("@/providers/auth-context")>()),
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 <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
}
async function openItem(title: RegExp) {
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
<NotificationBell />
<LocationProbe />
</NotificationSessionProvider>,
{ 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(
<NotificationSessionProvider>
<NotificationBell />
</NotificationSessionProvider>,
{ 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");
});
});