Compare commits

...

13 commits

Author SHA1 Message Date
npalseahaven
3e11437559
Merge 86e43b66f4 into e769b5fcc2 2026-09-21 20:05:15 -05:00
npal
86e43b66f4 fix: reset add-item draft on template selection change (SH-378)
TaskTemplateItemsField owns newItemText locally but stays mounted
across selectTemplate/startNewTemplate (which only call form.reset()),
so a typed draft survived switching templates - the base page cleared
this draft explicitly in both handleSelect and handleNew.

Remount TaskTemplateItemsField on selection change via key={selectedId}
instead of lifting the draft into the editor hook: it's transient
input-only state, not form data, so this keeps the fix local and lets
React's own remount semantics reset it. Confirmed the two new
regression tests fail without the key and pass with it.
2026-09-21 20:04:33 -05:00
Alexandre Brandizzi
e769b5fcc2
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
feat(notifications): open assignment, comment, mention and uplift items where they happened (SH-288, SH-289, SH-290, SH-215)
2026-09-21 20:58:57 +00:00
Alexandre Brandizzi
7e68fd50d7
Merge pull request #244 from Sea-Haven-Industries/fix/ab/sh-383-mobile-video-upload
Increase work-order media upload limit to 200 MB
2026-09-21 20:58:53 +00:00
Alexandre Brandizzi
93f68f84e4 fix(workorders): cap completion documents at 50 MB 2026-09-21 15:39:47 -03:00
Alexandre Brandizzi
ca2b3b5134 fix(work-orders): raise media upload limit to 200 MB 2026-09-21 15:09:51 -03:00
Cursor Agent
647accc737
Merge remote-tracking branch 'origin/main' into feat/ab/sh-288-event-notifications
Co-authored-by: adam <adam@seahavenind.com>
2026-09-20 21:53:34 +00:00
Codex Review Integration
ff1a946e74 Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into HEAD
# Conflicts:
#	src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
2026-09-18 14:46:02 -03:00
Codex Review Integration
75c2c1c1fa Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into HEAD
# Conflicts:
#	src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts
#	src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
#	src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
#	src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx
#	src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
2026-09-18 14:35:17 -03:00
albrand
74e7c526e8 fix(notifications): re-open tab and match API mentions for event highlights
Re-apply the requested slide-over tab on every open via an open counter, so a
second comment/mention notification for the work order already on screen still
switches to Comments and re-scrolls to the highlighted comment. Treat a board
comment's mentions id array (plain @Name text) as a mention alongside encoded
@[id:name] tokens so mention items highlight and comment items exclude them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-18 14:01:55 -03:00
Codex Review Integration
de1c23b118 Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into feat/ab/sh-288-event-notifications 2026-09-18 13:54:07 -03:00
Codex Review Integration
299f471612 Merge commit '988c96c' into feat/ab/sh-288-event-notifications
# Conflicts:
#	src/components/notifications/notification-row.tsx
#	src/components/notifications/use-notification-center.ts
#	src/domain/notifications/utils/notification-target-url.ts
2026-09-18 13:43:48 -03:00
Codex Review Integration
ae941bc93d 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>
2026-09-18 13:42:13 -03:00
29 changed files with 785 additions and 83 deletions

View file

@ -1,7 +1,10 @@
import type { UseFormReturn } from "react-hook-form";
import { Box, Button, Paper, Stack, TextField, Typography } from "@mui/material";
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
import type { TaskTemplateFormValues } from "@/domain/settings/task-templates/types/task-template";
import type {
SelectedTaskTemplateId,
TaskTemplateFormValues,
} from "@/domain/settings/task-templates/types/task-template";
import { TaskTemplateItemsField } from "./task-template-items-field";
function getSubmitLabel(isSaving: boolean, isNew: boolean): string {
@ -14,6 +17,7 @@ interface TaskTemplateDetailFormProps {
isNew: boolean;
isSelected: boolean;
isSaving: boolean;
selectedId: SelectedTaskTemplateId;
onDelete: () => void;
onSubmit: () => void;
}
@ -23,6 +27,7 @@ export function TaskTemplateDetailForm({
isNew,
isSelected,
isSaving,
selectedId,
onDelete,
onSubmit,
}: TaskTemplateDetailFormProps) {
@ -69,7 +74,7 @@ export function TaskTemplateDetailForm({
/>
</Stack>
<TaskTemplateItemsField control={control} />
<TaskTemplateItemsField key={String(selectedId)} control={control} />
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
{!isNew && (

View file

@ -53,6 +53,7 @@ export default function TaskTemplatesPage() {
isNew={isNew}
isSelected={selectedId !== null}
isSaving={isSaving}
selectedId={selectedId}
onDelete={requestDelete}
onSubmit={submitForm}
/>

View file

@ -11,6 +11,7 @@ import {
formatMediaSizeLimit,
isWorkOrderDocumentsReadOnly,
isWorkOrderFileTooLarge,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
@ -35,8 +36,8 @@ type UseCompDocPdfUploadArgs = {
};
function rejectCompDocPdf(file: File, rowVersion: string): string | null {
if (isWorkOrderFileTooLarge(file)) {
return `File exceeds the ${formatMediaSizeLimit()} limit.`;
if (isWorkOrderFileTooLarge(file, MAX_WORK_ORDER_COMPLETION_DOC_BYTES)) {
return `File exceeds the ${formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)} limit.`;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
return "Please upload a PDF file.";

View file

@ -11,6 +11,7 @@ import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
} from "@/domain/work-orders/utils/work-order-media-limits";
type UseCompDocPdfActionsArgs = {
@ -55,8 +56,10 @@ export function useCompDocPdfActions({
function handleUploadPdf(files: FileList | null) {
const file = files?.[0];
if (!file) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
if (isWorkOrderFileTooLarge(file, MAX_WORK_ORDER_COMPLETION_DOC_BYTES)) {
toast.error(
`File exceeds the ${formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)} limit.`,
);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {

View file

@ -1,13 +1,21 @@
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
import { highlightedCommentIds } from "@/domain/work-orders/utils/comment-highlight";
import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
import {
getAvatarColorFromName,
getInitialsFromName,
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
const HIGHLIGHTED_COMMENT_STYLE = {
background: "var(--color-primary-tint)",
borderLeft: "2px solid var(--color-primary)",
paddingLeft: 8,
} as const;
function formatCommentTime(value: string): string {
if (!value) return "";
@ -140,6 +148,8 @@ type SlideOverCommentsTabProps = {
isReadOnly: boolean;
isPending: boolean;
currentUserId?: string | number;
/** Set when a comment or mention notification opened this tab. */
highlight?: CommentHighlight | null;
isUpdatePending?: boolean;
onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
onUpdateComment?: (
@ -154,10 +164,25 @@ export function SlideOverCommentsTab({
isReadOnly,
isPending,
currentUserId,
highlight,
isUpdatePending = false,
onSubmit,
onUpdateComment,
}: SlideOverCommentsTabProps) {
const highlightedIds = useMemo(
() => highlightedCommentIds(comments, highlight, currentUserId),
[comments, highlight, currentUserId],
);
const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id;
const firstHighlightedRef = useRef<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 [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
const [draftText, setDraftText] = useState("");
@ -238,12 +263,19 @@ export function SlideOverCommentsTab({
Boolean(onUpdateComment) &&
isOwnWorkOrderComment(comment, currentUserId);
const isEditing = editingCommentId === comment.id;
const isHighlighted = highlightedIds.has(String(comment.id));
return (
<div
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
comment={comment}
canEdit={canEdit}

View file

@ -11,6 +11,7 @@ import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-
import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
@ -71,8 +72,10 @@ export function SlideOverCompletionTab({
const handlePdfSelected = (files: FileList | null) => {
const file = files?.[0];
if (!file || !onUploadCompletionPdf) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
if (isWorkOrderFileTooLarge(file, MAX_WORK_ORDER_COMPLETION_DOC_BYTES)) {
toast.error(
`File exceeds the ${formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)} limit.`,
);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {

View file

@ -109,10 +109,14 @@ export function useSlideOverEditState({
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(() => {
if (row?.id == null || editMode) return;
setTab(initialTab);
}, [editMode, initialTab, openRequestKey, row?.id, setTab]);
if (!row) return;
setTab(editMode ? "info" : initialTab);
// eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open
}, [openRequestKey]);
const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus);

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,9 @@ type WorkOrderSlideOverProps = {
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
commentHighlight?: CommentHighlight | null;
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
openRequestKey?: number;
};
@ -55,6 +59,7 @@ export function WorkOrderSlideOver({
onClose,
saving,
initialTab,
commentHighlight,
openRequestKey,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({
@ -97,6 +102,7 @@ export function WorkOrderSlideOver({
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
commentHighlight={commentHighlight}
onDuplicateFound={onDuplicateFound}
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
onRequestComplete={() => onRequestComplete?.(infoSource)}

View file

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

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,
@ -22,27 +23,38 @@ function useSlideOverOpenState() {
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
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 handleOpenSlideOver = (
row: WorkOrderTableRow,
edit = false,
tab: SlideOverTab = "info",
highlight: CommentHighlight | null = null,
) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
setSlideOverTab(tab);
setSlideOverHighlight(highlight);
setSlideOverOpenRequestKey((key) => key + 1);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
setSlideOverHighlight(null);
};
return {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverHighlight,
slideOverOpenRequestKey,
handleOpenSlideOver,
handleCloseSlideOver,
};
}
@ -50,12 +62,12 @@ export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverHighlight,
slideOverOpenRequestKey,
handleOpenSlideOver,
handleCloseSlideOver: closeSlideOverState,
} = useSlideOverOpenState();
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
@ -111,8 +123,7 @@ export function useWorkOrdersListPage() {
}, [upliftsRow, tableData.rows]);
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
closeSlideOverState();
tableData.setSelectedId(null);
};
@ -162,6 +173,7 @@ export function useWorkOrdersListPage() {
setWizardOpen,
slideOverEdit,
slideOverTab,
slideOverHighlight,
slideOverOpenRequestKey,
confirmCancel,
setConfirmCancel,

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";
@ -27,6 +30,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;
@ -64,8 +85,6 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
}
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)]"
@ -93,12 +112,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();
@ -24,7 +26,7 @@ export function useNotificationCenter(onNavigate?: () => void) {
[items, readIds],
);
const open = (item: NotificationItem, url = notificationTargetUrl(item)) => {
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
markRead([item.id]);
if (url == null) return;
onNavigate?.();

View file

@ -10,16 +10,22 @@ import {
} from "@/domain/work-orders/utils/board-query-params";
import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link";
/** Every open, unassigned work order in any week, through the existing Work Orders filter params. */
export function unassignedQueueUrl(): string {
const COMMENT_REASONS = new Set(["mentions", "activity"]);
/** Every open work order held by `dispatcher`, in any week, through the Work Orders filter params. */
function openQueueUrl(dispatcher: string): string {
const search = new URLSearchParams();
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("dispatchers", dispatcher);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return workOrderDrilldownUrl(search, "notification");
}
export function unassignedQueueUrl(): string {
return openQueueUrl(ASSIGNEE_FILTER_UNASSIGNED);
}
/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */
export function chooseVendorUrl(workOrderId: number): string {
return `/workorders/edit/${workOrderId}?assignVendor=1`;
@ -29,12 +35,21 @@ export function workOrderViewUrl(workOrderId: number): string {
return `/workorders/${workOrderId}`;
}
/**
* Where a feed item leads. Queues open the Work Orders board filtered; work-order items open the
* slide-over on their tab, and comment or mention items also carry which comments to highlight.
*/
export function notificationTargetUrl(
item: Pick<NotificationItem, "reason" | "target">,
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;
@ -42,5 +57,9 @@ export function notificationTargetUrl(
if (item.reason === "no-vendor") {
return chooseVendorUrl(target.workOrderId);
}
return workOrderDeepLinkUrl(target.workOrderId, target.tab);
const highlight =
target.tab === "comments" && COMMENT_REASONS.has(item.reason)
? { since: item.triggeredAt, mentions: item.reason === "mentions" }
: null;
return workOrderDeepLinkUrl(target.workOrderId, target.tab, highlight);
}

View 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;
}

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

@ -1,7 +1,10 @@
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
/** Backend RequestSizeLimit for media and completion-doc uploads. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000;
/** Backend RequestSizeLimit for media uploads and Extra Docs. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000;
/** Backend RequestSizeLimit for completion-document uploads. */
export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000;
const MEDIA_MIME_TYPES = new Set([
"image/jpeg",

View file

@ -16,6 +16,14 @@ const existingTemplate: TaskTemplate = {
],
};
const secondTemplate: TaskTemplate = {
id: "9",
name: "Plumbing Inspection",
description: "Basic checklist",
isActive: true,
items: [{ itemText: "Check for leaks", sortOrder: 0 }],
};
afterEach(() => {
vi.restoreAllMocks();
});
@ -88,6 +96,38 @@ describe("TaskTemplatesPage", () => {
]);
});
it("clears the unsaved add-item draft when switching to a different template", async () => {
renderPage([existingTemplate, secondTemplate]);
fireEvent.click(await screen.findByText("HVAC Inspection"));
await screen.findByText("Checklist Items (2)");
fireEvent.change(screen.getByPlaceholderText("Add checklist item..."), {
target: { value: "Check filter" },
});
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("Check filter");
fireEvent.click(screen.getByText("Plumbing Inspection"));
await screen.findByText("Checklist Items (1)");
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("");
});
it("clears the unsaved add-item draft when starting a new template", async () => {
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
await screen.findByText("Checklist Items (2)");
fireEvent.change(screen.getByPlaceholderText("Add checklist item..."), {
target: { value: "Check filter" },
});
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("Check filter");
fireEvent.click(screen.getByRole("button", { name: "+ New Template" }));
await screen.findByText("Checklist Items (0)");
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("");
});
it("moves an item up and down without changing the item count", async () => {
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));

View file

@ -117,7 +117,7 @@ describe("MediaUploadModal API wiring", () => {
});
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
expect(
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"),
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
});

View file

@ -1,10 +1,17 @@
import { createRef } from "react";
import { screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
import {
MAX_WORK_ORDER_MEDIA_BYTES,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
describe("MediaUploaderDropzone", () => {
it("matches the AAP Photos accept copy and keeps it media-only", () => {
renderWithProviders(
@ -16,7 +23,7 @@ describe("MediaUploaderDropzone", () => {
);
expect(
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"),
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
@ -25,4 +32,19 @@ describe("MediaUploaderDropzone", () => {
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
expect(input?.getAttribute("accept")).not.toContain(".pdf");
});
it("rejects an oversized media file with the generic 200 MB toast", () => {
renderWithProviders(<MediaUploader uploads={[]} />);
const oversizedFile = {
name: "mobile-clip.MP4",
type: "application/octet-stream",
size: MAX_WORK_ORDER_MEDIA_BYTES + 1,
} as File;
const input = document.querySelector('input[type="file"]');
fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } });
expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.');
});
});

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

@ -140,13 +140,13 @@ describe("SlideOverExtrasTab Aveta", () => {
expect(getMediaContent).not.toHaveBeenCalled();
});
it("shows Extra Docs accept copy with PDF DOC and 50MB", () => {
it("shows Extra Docs accept copy with PDF DOC and 200MB", () => {
renderWithProviders(
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
);
expect(
screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50MB per file"),
screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"),
).toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toContain(".pdf");

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");
});
});

View 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);
});
});

View file

@ -6,6 +6,7 @@ import {
isWorkOrderExtraDocTypeAllowed,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
MAX_WORK_ORDER_MEDIA_BYTES,
workOrderExtraDocsAcceptAttribute,
workOrderMediaAcceptAttribute,
@ -24,13 +25,31 @@ describe("work-order-media-limits", () => {
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
});
it("caps uploads at 50_000_000 bytes and formats decimal MB", () => {
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(50_000_000);
expect(formatMediaSizeLimit()).toBe("50 MB");
it("caps uploads at 200_000_000 bytes and formats decimal MB", () => {
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
expect(formatMediaSizeLimit()).toBe("200 MB");
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false);
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
});
it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => {
expect(MAX_WORK_ORDER_COMPLETION_DOC_BYTES).toBe(50_000_000);
expect(formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)).toBe("50 MB");
expect(
isWorkOrderFileTooLarge(
{ size: MAX_WORK_ORDER_COMPLETION_DOC_BYTES } as File,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
),
).toBe(false);
expect(
isWorkOrderFileTooLarge(
{ size: MAX_WORK_ORDER_COMPLETION_DOC_BYTES + 1 } as File,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
),
).toBe(true);
});
it("allows only JPG PNG MP4 MOV by MIME or extension", () => {
expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true);
expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpg" } as File)).toBe(true);
@ -39,7 +58,19 @@ describe("work-order-media-limits", () => {
expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe(
true,
);
expect(isWorkOrderMediaTypeAllowed({ name: "a.MP4", type: "" } as File)).toBe(true);
expect(isWorkOrderMediaTypeAllowed({ name: "a.MOV", type: "" } as File)).toBe(true);
expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe(
true,
);
expect(
isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "application/octet-stream" } as File),
).toBe(true);
expect(isWorkOrderMediaTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(false);
expect(isWorkOrderMediaTypeAllowed({ name: "a.webm", type: "video/webm" } as File)).toBe(false);
expect(
isWorkOrderMediaTypeAllowed({ name: "a.txt", type: "application/octet-stream" } as File),
).toBe(false);
expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
false,
);