mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 14:42:05 +00:00
Merge pull request #227 from Sea-Haven-Industries/feat/ab/sh-288-event-notifications
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
feat(notifications): open assignment, comment, mention and uplift items where they happened (SH-288, SH-289, SH-290, SH-215)
This commit is contained in:
commit
e769b5fcc2
18 changed files with 657 additions and 64 deletions
|
|
@ -1,13 +1,21 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
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 { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
|
||||||
import {
|
import {
|
||||||
getAvatarColorFromName,
|
getAvatarColorFromName,
|
||||||
getInitialsFromName,
|
getInitialsFromName,
|
||||||
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
} 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 {
|
function formatCommentTime(value: string): string {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
|
|
@ -140,6 +148,8 @@ type SlideOverCommentsTabProps = {
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
currentUserId?: string | number;
|
currentUserId?: string | number;
|
||||||
|
/** Set when a comment or mention notification opened this tab. */
|
||||||
|
highlight?: CommentHighlight | null;
|
||||||
isUpdatePending?: boolean;
|
isUpdatePending?: boolean;
|
||||||
onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
|
onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
|
||||||
onUpdateComment?: (
|
onUpdateComment?: (
|
||||||
|
|
@ -154,10 +164,25 @@ export function SlideOverCommentsTab({
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isPending,
|
isPending,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
highlight,
|
||||||
isUpdatePending = false,
|
isUpdatePending = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onUpdateComment,
|
onUpdateComment,
|
||||||
}: SlideOverCommentsTabProps) {
|
}: 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<HTMLDivElement | null>(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 [commentText, setCommentText] = useState("");
|
||||||
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
|
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
|
||||||
const [draftText, setDraftText] = useState("");
|
const [draftText, setDraftText] = useState("");
|
||||||
|
|
@ -238,12 +263,19 @@ export function SlideOverCommentsTab({
|
||||||
Boolean(onUpdateComment) &&
|
Boolean(onUpdateComment) &&
|
||||||
isOwnWorkOrderComment(comment, currentUserId);
|
isOwnWorkOrderComment(comment, currentUserId);
|
||||||
const isEditing = editingCommentId === comment.id;
|
const isEditing = editingCommentId === comment.id;
|
||||||
|
const isHighlighted = highlightedIds.has(String(comment.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={String(comment.id)}
|
key={String(comment.id)}
|
||||||
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
|
ref={comment.id === firstHighlightedId ? firstHighlightedRef : 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
|
<SlideOverCommentItem
|
||||||
comment={comment}
|
comment={comment}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,14 @@ export function useSlideOverEditState({
|
||||||
setUnsavedIntent,
|
setUnsavedIntent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (row?.id == null || editMode) return;
|
if (!row) return;
|
||||||
setTab(initialTab);
|
setTab(editMode ? "info" : initialTab);
|
||||||
}, [editMode, initialTab, openRequestKey, row?.id, setTab]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open
|
||||||
|
}, [openRequestKey]);
|
||||||
|
|
||||||
const lockStatus = infoSource?.status ?? row?.status;
|
const lockStatus = infoSource?.status ?? row?.status;
|
||||||
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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 { 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 type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
|
||||||
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ type WorkOrderSlideOverTabsProps = {
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
allOrders: WorkOrderTableRow[];
|
allOrders: WorkOrderTableRow[];
|
||||||
|
commentHighlight?: CommentHighlight | null;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
onOpenCompletionDoc: () => void;
|
onOpenCompletionDoc: () => void;
|
||||||
onRequestComplete?: () => void;
|
onRequestComplete?: () => void;
|
||||||
|
|
@ -39,6 +41,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
vendors,
|
vendors,
|
||||||
pmTypes,
|
pmTypes,
|
||||||
allOrders,
|
allOrders,
|
||||||
|
commentHighlight,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
onOpenCompletionDoc,
|
onOpenCompletionDoc,
|
||||||
onRequestComplete,
|
onRequestComplete,
|
||||||
|
|
@ -82,6 +85,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
isReadOnly={state.isCommentsReadOnly}
|
isReadOnly={state.isCommentsReadOnly}
|
||||||
isPending={state.commentMutation.isPending}
|
isPending={state.commentMutation.isPending}
|
||||||
currentUserId={state.user?.id}
|
currentUserId={state.user?.id}
|
||||||
|
highlight={commentHighlight}
|
||||||
isUpdatePending={state.updateCommentMutation.isPending}
|
isUpdatePending={state.updateCommentMutation.isPending}
|
||||||
onSubmit={(text, options) => state.commentMutation.mutate({ text }, options)}
|
onSubmit={(text, options) => state.commentMutation.mutate({ text }, options)}
|
||||||
onUpdateComment={(commentId, text, options) =>
|
onUpdateComment={(commentId, text, options) =>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} 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 { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
|
||||||
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
|
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
|
||||||
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
||||||
|
|
@ -36,6 +37,9 @@ type WorkOrderSlideOverProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
initialTab?: SlideOverTab;
|
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;
|
openRequestKey?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -55,6 +59,7 @@ export function WorkOrderSlideOver({
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
initialTab,
|
initialTab,
|
||||||
|
commentHighlight,
|
||||||
openRequestKey,
|
openRequestKey,
|
||||||
}: WorkOrderSlideOverProps) {
|
}: WorkOrderSlideOverProps) {
|
||||||
const state = useWorkOrderSlideOver({
|
const state = useWorkOrderSlideOver({
|
||||||
|
|
@ -97,6 +102,7 @@ export function WorkOrderSlideOver({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
allOrders={allOrders}
|
allOrders={allOrders}
|
||||||
|
commentHighlight={commentHighlight}
|
||||||
onDuplicateFound={onDuplicateFound}
|
onDuplicateFound={onDuplicateFound}
|
||||||
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
||||||
onRequestComplete={() => onRequestComplete?.(infoSource)}
|
onRequestComplete={() => onRequestComplete?.(infoSource)}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
||||||
row={page.activeSlideOverRow}
|
row={page.activeSlideOverRow}
|
||||||
editMode={page.slideOverEdit}
|
editMode={page.slideOverEdit}
|
||||||
initialTab={page.slideOverTab}
|
initialTab={page.slideOverTab}
|
||||||
|
commentHighlight={page.slideOverHighlight}
|
||||||
openRequestKey={page.slideOverOpenRequestKey}
|
openRequestKey={page.slideOverOpenRequestKey}
|
||||||
users={users}
|
users={users}
|
||||||
sites={locations}
|
sites={locations}
|
||||||
|
|
|
||||||
|
|
@ -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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import {
|
import {
|
||||||
|
type CommentHighlight,
|
||||||
|
type WorkOrderDeepLinkTab,
|
||||||
parseWorkOrderDeepLink,
|
parseWorkOrderDeepLink,
|
||||||
withoutWorkOrderDeepLink,
|
withoutWorkOrderDeepLink,
|
||||||
} from "@/domain/work-orders/utils/work-order-deep-link";
|
} 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
|
* Opens `?wo=<id>&tab=<tab>` once that work order's detail loads, whatever week the board is
|
||||||
* the board is showing, then removes the params so closing the slide-over does not reopen it.
|
* 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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const link = parseWorkOrderDeepLink(searchParams);
|
const link = parseWorkOrderDeepLink(searchParams);
|
||||||
const workOrderId = link?.workOrderId;
|
const workOrderId = link?.workOrderId;
|
||||||
const tab = link?.tab ?? "info";
|
const tab = link?.tab ?? "info";
|
||||||
|
const since = link?.highlight?.since;
|
||||||
|
const mentions = link?.highlight?.mentions ?? false;
|
||||||
const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null);
|
const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null);
|
||||||
const onOpenRef = useRef(onOpen);
|
const handlersRef = useRef(handlers);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onOpenRef.current = onOpen;
|
handlersRef.current = handlers;
|
||||||
}, [onOpen]);
|
}, [handlers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workOrderId == null) {
|
if (workOrderId == null) {
|
||||||
|
|
@ -35,8 +62,8 @@ export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
onOpenRef.current(data.info, false, tab);
|
openLink(handlersRef.current, data.info, tab, since == null ? null : { since, mentions });
|
||||||
}
|
}
|
||||||
setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true });
|
setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true });
|
||||||
}, [workOrderId, tab, data, error, searchParams, setSearchParams]);
|
}, [workOrderId, tab, since, mentions, data, error, searchParams, setSearchParams]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
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 { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
|
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
|
||||||
|
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
|
||||||
import {
|
import {
|
||||||
patchWorkOrderAsCanceled,
|
patchWorkOrderAsCanceled,
|
||||||
patchWorkOrderAsCompleted,
|
patchWorkOrderAsCompleted,
|
||||||
|
|
@ -22,27 +23,38 @@ function useSlideOverOpenState() {
|
||||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||||
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
|
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
|
||||||
|
const [slideOverHighlight, setSlideOverHighlight] = useState<CommentHighlight | null>(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 [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
|
||||||
|
|
||||||
const handleOpenSlideOver = (
|
const handleOpenSlideOver = (
|
||||||
row: WorkOrderTableRow,
|
row: WorkOrderTableRow,
|
||||||
edit = false,
|
edit = false,
|
||||||
tab: SlideOverTab = "info",
|
tab: SlideOverTab = "info",
|
||||||
|
highlight: CommentHighlight | null = null,
|
||||||
) => {
|
) => {
|
||||||
setSlideOverRow(row);
|
setSlideOverRow(row);
|
||||||
setSlideOverEdit(edit);
|
setSlideOverEdit(edit);
|
||||||
setSlideOverTab(tab);
|
setSlideOverTab(tab);
|
||||||
|
setSlideOverHighlight(highlight);
|
||||||
setSlideOverOpenRequestKey((key) => key + 1);
|
setSlideOverOpenRequestKey((key) => key + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseSlideOver = () => {
|
||||||
|
setSlideOverRow(null);
|
||||||
|
setSlideOverEdit(false);
|
||||||
|
setSlideOverHighlight(null);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slideOverRow,
|
slideOverRow,
|
||||||
setSlideOverRow,
|
|
||||||
slideOverEdit,
|
slideOverEdit,
|
||||||
setSlideOverEdit,
|
|
||||||
slideOverTab,
|
slideOverTab,
|
||||||
|
slideOverHighlight,
|
||||||
slideOverOpenRequestKey,
|
slideOverOpenRequestKey,
|
||||||
handleOpenSlideOver,
|
handleOpenSlideOver,
|
||||||
|
handleCloseSlideOver,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,12 +62,12 @@ export function useWorkOrdersListPage() {
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const {
|
const {
|
||||||
slideOverRow,
|
slideOverRow,
|
||||||
setSlideOverRow,
|
|
||||||
slideOverEdit,
|
slideOverEdit,
|
||||||
setSlideOverEdit,
|
|
||||||
slideOverTab,
|
slideOverTab,
|
||||||
|
slideOverHighlight,
|
||||||
slideOverOpenRequestKey,
|
slideOverOpenRequestKey,
|
||||||
handleOpenSlideOver,
|
handleOpenSlideOver,
|
||||||
|
handleCloseSlideOver: closeSlideOverState,
|
||||||
} = useSlideOverOpenState();
|
} = useSlideOverOpenState();
|
||||||
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
|
@ -111,8 +123,7 @@ export function useWorkOrdersListPage() {
|
||||||
}, [upliftsRow, tableData.rows]);
|
}, [upliftsRow, tableData.rows]);
|
||||||
|
|
||||||
const handleCloseSlideOver = () => {
|
const handleCloseSlideOver = () => {
|
||||||
setSlideOverRow(null);
|
closeSlideOverState();
|
||||||
setSlideOverEdit(false);
|
|
||||||
tableData.setSelectedId(null);
|
tableData.setSelectedId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -162,6 +173,7 @@ export function useWorkOrdersListPage() {
|
||||||
setWizardOpen,
|
setWizardOpen,
|
||||||
slideOverEdit,
|
slideOverEdit,
|
||||||
slideOverTab,
|
slideOverTab,
|
||||||
|
slideOverHighlight,
|
||||||
slideOverOpenRequestKey,
|
slideOverOpenRequestKey,
|
||||||
confirmCancel,
|
confirmCancel,
|
||||||
setConfirmCancel,
|
setConfirmCancel,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ export default function WorkOrdersListPage() {
|
||||||
const page = useWorkOrdersListPage();
|
const page = useWorkOrdersListPage();
|
||||||
const { filters, tableData } = page;
|
const { filters, tableData } = page;
|
||||||
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
||||||
useWorkOrderDeepLink(page.handleOpenSlideOver);
|
useWorkOrderDeepLink({
|
||||||
|
onOpenSlideOver: page.handleOpenSlideOver,
|
||||||
|
onOpenUplifts: page.setUpliftsRow,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
|
import { createElement } from "react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
|
CircleDollarSign,
|
||||||
Clock,
|
Clock,
|
||||||
Inbox,
|
Inbox,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
UserPlus,
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -27,6 +30,24 @@ const REASON_ICONS: Record<string, LucideIcon> = {
|
||||||
"vendor-conflict": Wrench,
|
"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 = {
|
type NotificationRowProps = {
|
||||||
item: NotificationItem;
|
item: NotificationItem;
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
|
|
@ -64,8 +85,6 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
|
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
|
||||||
const Icon = REASON_ICONS[item.reason] ?? Bell;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||||
|
|
@ -93,12 +112,7 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
|
||||||
background: unread ? "var(--color-primary)" : "transparent",
|
background: unread ? "var(--color-primary)" : "transparent",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Icon
|
<ItemIcon item={item} />
|
||||||
size={15}
|
|
||||||
aria-hidden
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
as="span"
|
as="span"
|
||||||
variant="description"
|
variant="description"
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import {
|
||||||
isDismissable,
|
isDismissable,
|
||||||
visibleNotificationSections,
|
visibleNotificationSections,
|
||||||
} from "@/domain/notifications/utils/visible-notification-sections";
|
} from "@/domain/notifications/utils/visible-notification-sections";
|
||||||
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
||||||
export function useNotificationCenter(onNavigate?: () => void) {
|
export function useNotificationCenter(onNavigate?: () => void) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuthContext();
|
||||||
const feed = useNotificationFeed();
|
const feed = useNotificationFeed();
|
||||||
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
|
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
|
||||||
|
|
||||||
|
|
@ -24,7 +26,7 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
||||||
[items, readIds],
|
[items, readIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const open = (item: NotificationItem, url = notificationTargetUrl(item)) => {
|
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
|
||||||
markRead([item.id]);
|
markRead([item.id]);
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,22 @@ import {
|
||||||
} from "@/domain/work-orders/utils/board-query-params";
|
} from "@/domain/work-orders/utils/board-query-params";
|
||||||
import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link";
|
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. */
|
const COMMENT_REASONS = new Set(["mentions", "activity"]);
|
||||||
export function unassignedQueueUrl(): string {
|
|
||||||
|
/** 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();
|
const search = new URLSearchParams();
|
||||||
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
|
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
|
||||||
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
|
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
|
||||||
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
|
search.set("dispatchers", dispatcher);
|
||||||
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
|
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
|
||||||
return workOrderDrilldownUrl(search, "notification");
|
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". */
|
/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */
|
||||||
export function chooseVendorUrl(workOrderId: number): string {
|
export function chooseVendorUrl(workOrderId: number): string {
|
||||||
return `/workorders/edit/${workOrderId}?assignVendor=1`;
|
return `/workorders/edit/${workOrderId}?assignVendor=1`;
|
||||||
|
|
@ -29,12 +35,21 @@ export function workOrderViewUrl(workOrderId: number): string {
|
||||||
return `/workorders/${workOrderId}`;
|
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(
|
export function notificationTargetUrl(
|
||||||
item: Pick<NotificationItem, "reason" | "target">,
|
item: Pick<NotificationItem, "reason" | "triggeredAt" | "target">,
|
||||||
|
currentUserId?: string | number | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
const { target } = item;
|
const { target } = item;
|
||||||
if (target.kind === "queue") {
|
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) {
|
if (target.workOrderId == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -42,5 +57,9 @@ export function notificationTargetUrl(
|
||||||
if (item.reason === "no-vendor") {
|
if (item.reason === "no-vendor") {
|
||||||
return chooseVendorUrl(target.workOrderId);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
src/domain/work-orders/utils/comment-highlight.ts
Normal file
45
src/domain/work-orders/utils/comment-highlight.ts
Normal file
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,10 @@ import { WORK_ORDERS_ROUTE } from "@/domain/dashboard/utils/work-order-drilldown
|
||||||
|
|
||||||
const WORK_ORDER_PARAM = "wo";
|
const WORK_ORDER_PARAM = "wo";
|
||||||
const TAB_PARAM = "tab";
|
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 = [
|
export const WORK_ORDER_DEEP_LINK_TABS = [
|
||||||
"info",
|
"info",
|
||||||
|
|
@ -9,40 +13,80 @@ export const WORK_ORDER_DEEP_LINK_TABS = [
|
||||||
"audit",
|
"audit",
|
||||||
"completion",
|
"completion",
|
||||||
"extras",
|
"extras",
|
||||||
|
"uplifts",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WorkOrderDeepLinkTab = (typeof WORK_ORDER_DEEP_LINK_TABS)[number];
|
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 = {
|
export type WorkOrderDeepLink = {
|
||||||
workOrderId: string;
|
workOrderId: string;
|
||||||
tab: WorkOrderDeepLinkTab;
|
tab: WorkOrderDeepLinkTab;
|
||||||
|
highlight: CommentHighlight | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab {
|
function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab {
|
||||||
return WORK_ORDER_DEEP_LINK_TABS.some((tab) => tab === value);
|
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) });
|
const search = new URLSearchParams({ [WORK_ORDER_PARAM]: String(workOrderId) });
|
||||||
if (isDeepLinkTab(tab ?? null)) {
|
if (isDeepLinkTab(tab ?? null)) {
|
||||||
search.set(TAB_PARAM, String(tab));
|
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()}`;
|
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 {
|
export function parseWorkOrderDeepLink(params: URLSearchParams): WorkOrderDeepLink | null {
|
||||||
const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim();
|
const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim();
|
||||||
if (!/^\d+$/.test(workOrderId)) {
|
if (!/^\d+$/.test(workOrderId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const tab = params.get(TAB_PARAM);
|
const requested = params.get(TAB_PARAM);
|
||||||
return { workOrderId, tab: isDeepLinkTab(tab) ? tab : "info" };
|
const tab = isDeepLinkTab(requested) ? requested : "info";
|
||||||
|
return { workOrderId, tab, highlight: parseHighlight(params, tab) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams {
|
export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
next.delete(WORK_ORDER_PARAM);
|
next.delete(WORK_ORDER_PARAM);
|
||||||
next.delete(TAB_PARAM);
|
next.delete(TAB_PARAM);
|
||||||
|
next.delete(SINCE_PARAM);
|
||||||
|
next.delete(FOCUS_PARAM);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,53 +32,87 @@ describe("work order deep link", () => {
|
||||||
detailState.requestedIds = [];
|
detailState.requestedIds = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the linked work order on the requested tab and clears the link", async () => {
|
function handlers() {
|
||||||
const onOpen = vi.fn();
|
return { onOpenSlideOver: vi.fn(), onOpenUplifts: vi.fn() };
|
||||||
const { result } = renderHook(
|
}
|
||||||
|
|
||||||
|
function renderLink(route: string, open: ReturnType<typeof handlers>) {
|
||||||
|
return renderHook(
|
||||||
() => {
|
() => {
|
||||||
useWorkOrderDeepLink(onOpen);
|
useWorkOrderDeepLink(open);
|
||||||
return useLocation();
|
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(""));
|
await waitFor(() => expect(result.current.search).toBe(""));
|
||||||
expect(detailState.requestedIds).toContain("42");
|
expect(detailState.requestedIds).toContain("42");
|
||||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
expect(open.onOpenSlideOver).toHaveBeenCalledTimes(1);
|
||||||
expect(onOpen).toHaveBeenCalledWith({ id: 42, woNumber: "1042" }, false, "extras");
|
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 () => {
|
it("falls back to the info tab for an unknown tab", async () => {
|
||||||
const onOpen = vi.fn();
|
const open = handlers();
|
||||||
renderHook(() => useWorkOrderDeepLink(onOpen), {
|
renderLink("/workorders?wo=42&tab=nope", open);
|
||||||
wrapper: wrapper("/workorders?wo=42&tab=nope"),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
it("clears the link without opening anything when the work order cannot load", async () => {
|
||||||
detailState.data = undefined;
|
detailState.data = undefined;
|
||||||
detailState.error = new Error("not found");
|
detailState.error = new Error("not found");
|
||||||
const onOpen = vi.fn();
|
const open = handlers();
|
||||||
const { result } = renderHook(
|
const { result } = renderLink("/workorders?wo=42", open);
|
||||||
() => {
|
|
||||||
useWorkOrderDeepLink(onOpen);
|
|
||||||
return useLocation();
|
|
||||||
},
|
|
||||||
{ wrapper: wrapper("/workorders?wo=42") },
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.search).toBe(""));
|
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", () => {
|
it("ignores a board URL without a work order link", () => {
|
||||||
const onOpen = vi.fn();
|
const open = handlers();
|
||||||
renderHook(() => useWorkOrderDeepLink(onOpen), { wrapper: wrapper("/workorders?tab=extras") });
|
renderHook(() => useWorkOrderDeepLink(open), { wrapper: wrapper("/workorders?tab=extras") });
|
||||||
|
|
||||||
expect(detailState.requestedIds.every((id) => id == null)).toBe(true);
|
expect(detailState.requestedIds.every((id) => id == null)).toBe(true);
|
||||||
expect(onOpen).not.toHaveBeenCalled();
|
expect(open.onOpenSlideOver).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ const apiGet = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({ apiGet }));
|
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 = {
|
type ItemInput = {
|
||||||
id: string;
|
id: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/test/domain/work-orders/utils/comment-highlight.test.ts
Normal file
63
src/test/domain/work-orders/utils/comment-highlight.test.ts
Normal file
|
|
@ -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>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue