mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 09:19:19 +00:00
Merge pull request #267 from Sea-Haven-Industries/feat/ab/wo-ids-filter
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): SLA at-risk banner opening Work Orders on an exact id set
This commit is contained in:
commit
6e0967eaf0
25 changed files with 830 additions and 10 deletions
|
|
@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
import { getSidebarWidth } from "@/config/menu";
|
import { getSidebarWidth } from "@/config/menu";
|
||||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||||
|
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
|
||||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||||
|
|
||||||
export default function AdminShellLayout() {
|
export default function AdminShellLayout() {
|
||||||
|
|
@ -40,6 +41,7 @@ export default function AdminShellLayout() {
|
||||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||||
>
|
>
|
||||||
<AppTopbar onToggleNav={handleToggleNav} />
|
<AppTopbar onToggleNav={handleToggleNav} />
|
||||||
|
<SlaAtRiskBanner />
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
import {
|
import {
|
||||||
clearAdvancedFilterGroup,
|
clearAdvancedFilterGroup,
|
||||||
cloneAdvancedFilters,
|
|
||||||
countAdvancedFilters,
|
countAdvancedFilters,
|
||||||
getInitialAdvancedFilters,
|
getInitialAdvancedFilters,
|
||||||
|
resolveAppliedAdvancedFilters,
|
||||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||||
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
|
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
|
||||||
setAdvApplied(cloneAdvancedFilters(filters));
|
setAdvApplied(resolveAppliedAdvancedFilters(filters));
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
|
||||||
import type { NotificationCenter } from "./use-notification-center";
|
import type { NotificationCenter } from "./use-notification-center";
|
||||||
|
|
||||||
type NotificationFeedActionsProps = {
|
type NotificationFeedActionsProps = {
|
||||||
|
|
@ -6,7 +7,8 @@ type NotificationFeedActionsProps = {
|
||||||
|
|
||||||
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
||||||
const hasUnread = center.unreadIds.size > 0;
|
const hasUnread = center.unreadIds.size > 0;
|
||||||
const hasItems = center.sections.length > 0;
|
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
|
||||||
|
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
|
||||||
unread={center.unreadIds.has(item.id)}
|
unread={center.unreadIds.has(item.id)}
|
||||||
onOpen={center.open}
|
onOpen={center.open}
|
||||||
onDismiss={center.dismiss}
|
onDismiss={center.dismiss}
|
||||||
|
onAcknowledge={center.acknowledge}
|
||||||
|
acknowledging={
|
||||||
|
item.target.workOrderId != null &&
|
||||||
|
item.target.workOrderId === center.acknowledgingWorkOrderId
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,37 @@ type NotificationRowProps = {
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
onOpen: (item: NotificationItem, url?: string) => void;
|
onOpen: (item: NotificationItem, url?: string) => void;
|
||||||
onDismiss: (item: NotificationItem) => void;
|
onDismiss: (item: NotificationItem) => void;
|
||||||
|
onAcknowledge?: (item: NotificationItem) => void;
|
||||||
|
acknowledging?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */
|
||||||
|
function AcknowledgeButton({
|
||||||
|
item,
|
||||||
|
onAcknowledge,
|
||||||
|
acknowledging = false,
|
||||||
|
}: Pick<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAcknowledge?.(item)}
|
||||||
|
disabled={acknowledging}
|
||||||
|
title="Acknowledge"
|
||||||
|
aria-label="Acknowledge SLA breach"
|
||||||
|
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "2px 8px",
|
||||||
|
color: "var(--destructive)",
|
||||||
|
borderColor: "var(--destructive)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{acknowledging ? "Acknowledging…" : "Acknowledge"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
|
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
|
||||||
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
|
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
|
||||||
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
|
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
|
||||||
|
|
@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
|
export function NotificationRow({
|
||||||
|
item,
|
||||||
|
unread,
|
||||||
|
onOpen,
|
||||||
|
onDismiss,
|
||||||
|
onAcknowledge,
|
||||||
|
acknowledging,
|
||||||
|
}: NotificationRowProps) {
|
||||||
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)]"
|
||||||
|
|
@ -136,6 +172,13 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
|
||||||
<X size={13} aria-hidden />
|
<X size={13} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{item.rowType === "acknowledge" && (
|
||||||
|
<AcknowledgeButton
|
||||||
|
item={item}
|
||||||
|
onAcknowledge={onAcknowledge}
|
||||||
|
acknowledging={acknowledging}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
src/components/notifications/sla-at-risk-banner.tsx
Normal file
90
src/components/notifications/sla-at-risk-banner.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { AlertTriangle, ChevronRight, X } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
||||||
|
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||||
|
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||||
|
import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy";
|
||||||
|
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||||
|
|
||||||
|
const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = [];
|
||||||
|
|
||||||
|
/** Toasts each at-risk work order once per session, the first time the feed reports it. */
|
||||||
|
function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) {
|
||||||
|
const { claimSlaToasts } = useNotificationSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id)));
|
||||||
|
atRisk
|
||||||
|
.filter((workOrder) => claimed.has(workOrder.id))
|
||||||
|
.forEach((workOrder) =>
|
||||||
|
toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }),
|
||||||
|
);
|
||||||
|
}, [atRisk, claimSlaToasts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line at the top of every page while any of the user's Reactive/Emergency work orders is past
|
||||||
|
* half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing
|
||||||
|
* hides them for this session, and a work order that becomes at risk later brings the banner back.
|
||||||
|
*/
|
||||||
|
export function SlaAtRiskBanner() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data } = useNotificationFeed();
|
||||||
|
const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession();
|
||||||
|
const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS;
|
||||||
|
useSlaHalfwayToasts(atRisk);
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)),
|
||||||
|
[atRisk, slaBannerDismissedIds],
|
||||||
|
);
|
||||||
|
if (shown.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = shown.map((workOrder) => workOrder.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Work orders at risk of missing their response deadline"
|
||||||
|
className="flex shrink-0 items-center"
|
||||||
|
style={{
|
||||||
|
gap: 10,
|
||||||
|
padding: "6px 16px",
|
||||||
|
background: "var(--destructive)",
|
||||||
|
color: "var(--destructive-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
|
||||||
|
title="View in Work Orders"
|
||||||
|
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
|
||||||
|
style={{ color: "inherit" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
|
||||||
|
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={14} className="shrink-0" aria-hidden />
|
||||||
|
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
|
||||||
|
{slaBannerText(shown)}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={14} className="shrink-0" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissSlaBanner(ids)}
|
||||||
|
title="Dismiss"
|
||||||
|
aria-label="Dismiss SLA alert"
|
||||||
|
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
|
||||||
|
style={{ width: 22, height: 22, color: "inherit" }}
|
||||||
|
>
|
||||||
|
<X size={14} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
||||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||||
|
import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
|
||||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||||
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
|
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
||||||
[items, readIds],
|
[items, readIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const acknowledgeBreach = useAcknowledgeSlaBreach();
|
||||||
|
const acknowledge = (item: NotificationItem) => {
|
||||||
|
if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return;
|
||||||
|
acknowledgeBreach.mutate(item.target.workOrderId);
|
||||||
|
};
|
||||||
|
const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null;
|
||||||
|
|
||||||
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
|
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
|
||||||
markRead([item.id]);
|
markRead([item.id]);
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
|
|
@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
||||||
error: feed.error,
|
error: feed.error,
|
||||||
open,
|
open,
|
||||||
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
||||||
|
acknowledge,
|
||||||
|
acknowledgingWorkOrderId,
|
||||||
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
|
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
|
||||||
markAllRead: () => markRead(items.map((item) => item.id)),
|
markAllRead: () => markRead(items.map((item) => item.id)),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiGet } from "@/api/api";
|
import { apiGet, apiPostNoContent } from "@/api/api";
|
||||||
import {
|
import {
|
||||||
notificationFeedSchema,
|
notificationFeedSchema,
|
||||||
type NotificationFeed,
|
type NotificationFeed,
|
||||||
|
|
@ -7,4 +7,7 @@ import {
|
||||||
export const notificationsApi = {
|
export const notificationsApi = {
|
||||||
feed: async (): Promise<NotificationFeed> =>
|
feed: async (): Promise<NotificationFeed> =>
|
||||||
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
|
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
|
||||||
|
/** Records who acknowledged the missed response deadline in the work order's audit history. */
|
||||||
|
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
|
||||||
|
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ export type NotificationSession = {
|
||||||
readIds: ReadonlySet<string>;
|
readIds: ReadonlySet<string>;
|
||||||
dismiss: (ids: string[]) => void;
|
dismiss: (ids: string[]) => void;
|
||||||
markRead: (ids: string[]) => void;
|
markRead: (ids: string[]) => void;
|
||||||
|
/** At-risk work orders hidden from the SLA banner for this session. */
|
||||||
|
slaBannerDismissedIds: ReadonlySet<number>;
|
||||||
|
dismissSlaBanner: (workOrderIds: number[]) => void;
|
||||||
|
/** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */
|
||||||
|
claimSlaToasts: (workOrderIds: number[]) => number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
|
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { NotificationSessionContext } from "./notification-session-context";
|
import { NotificationSessionContext } from "./notification-session-context";
|
||||||
|
|
||||||
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
|
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
|
||||||
if (ids.every((id) => current.has(id))) {
|
if (ids.every((id) => current.has(id))) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
@ -17,6 +17,11 @@ function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string
|
||||||
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
|
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
|
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
|
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
|
||||||
|
const toastedSlaIds = useRef(new Set<number>());
|
||||||
|
|
||||||
const dismiss = useCallback((ids: string[]) => {
|
const dismiss = useCallback((ids: string[]) => {
|
||||||
setDismissedIds((current) => addAll(current, ids));
|
setDismissedIds((current) => addAll(current, ids));
|
||||||
|
|
@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
|
||||||
const markRead = useCallback((ids: string[]) => {
|
const markRead = useCallback((ids: string[]) => {
|
||||||
setReadIds((current) => addAll(current, ids));
|
setReadIds((current) => addAll(current, ids));
|
||||||
}, []);
|
}, []);
|
||||||
|
const dismissSlaBanner = useCallback((workOrderIds: number[]) => {
|
||||||
|
setSlaBannerDismissedIds((current) => addAll(current, workOrderIds));
|
||||||
|
}, []);
|
||||||
|
const claimSlaToasts = useCallback((workOrderIds: number[]) => {
|
||||||
|
const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id));
|
||||||
|
claimed.forEach((id) => toastedSlaIds.current.add(id));
|
||||||
|
return claimed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ dismissedIds, readIds, dismiss, markRead }),
|
() => ({
|
||||||
[dismissedIds, readIds, dismiss, markRead],
|
dismissedIds,
|
||||||
|
readIds,
|
||||||
|
dismiss,
|
||||||
|
markRead,
|
||||||
|
slaBannerDismissedIds,
|
||||||
|
dismissSlaBanner,
|
||||||
|
claimSlaToasts,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
dismissedIds,
|
||||||
|
readIds,
|
||||||
|
dismiss,
|
||||||
|
markRead,
|
||||||
|
slaBannerDismissedIds,
|
||||||
|
dismissSlaBanner,
|
||||||
|
claimSlaToasts,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,21 @@ const sectionSchema = z.object({
|
||||||
items: z.array(itemSchema),
|
items: z.array(itemSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A work order past half of its SEV response window, with the server-computed clock. */
|
||||||
|
const slaAtRiskSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
number: z.string().nullish(),
|
||||||
|
severity: z.number(),
|
||||||
|
startedAt: z.string(),
|
||||||
|
deadlineAt: z.string(),
|
||||||
|
percentElapsed: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
export const notificationFeedSchema = z.object({
|
export const notificationFeedSchema = z.object({
|
||||||
generatedAt: z.string(),
|
generatedAt: z.string(),
|
||||||
sections: z.array(sectionSchema),
|
sections: z.array(sectionSchema),
|
||||||
|
// Defaulted so a feed from a server without SLA alerts still parses.
|
||||||
|
slaAtRisk: z.array(slaAtRiskSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NotificationSeverity = z.infer<typeof severitySchema>;
|
export type NotificationSeverity = z.infer<typeof severitySchema>;
|
||||||
|
|
@ -60,3 +72,4 @@ export type NotificationTarget = z.infer<typeof targetSchema>;
|
||||||
export type NotificationItem = z.infer<typeof itemSchema>;
|
export type NotificationItem = z.infer<typeof itemSchema>;
|
||||||
export type NotificationSection = z.infer<typeof sectionSchema>;
|
export type NotificationSection = z.infer<typeof sectionSchema>;
|
||||||
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
|
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
|
||||||
|
export type SlaAtRiskWorkOrder = z.infer<typeof slaAtRiskSchema>;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
|
||||||
|
import type {
|
||||||
|
NotificationFeed,
|
||||||
|
NotificationItem,
|
||||||
|
} from "@/domain/notifications/types/notification-feed";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
function isBreachRowFor(workOrderId: number) {
|
||||||
|
return (item: NotificationItem) =>
|
||||||
|
item.reason === "sla" &&
|
||||||
|
item.rowType === "acknowledge" &&
|
||||||
|
item.target.workOrderId === workOrderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */
|
||||||
|
export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed {
|
||||||
|
const matches = isBreachRowFor(workOrderId);
|
||||||
|
return {
|
||||||
|
...feed,
|
||||||
|
sections: feed.sections.map((section) => {
|
||||||
|
const removed = section.items.filter(matches);
|
||||||
|
if (removed.length === 0) return section;
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
items: section.items.filter((item) => !matches(item)),
|
||||||
|
count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcknowledgeSlaBreach() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId),
|
||||||
|
onSuccess: (_result, workOrderId) => {
|
||||||
|
queryClient.setQueryData<NotificationFeed>(queryKeys.notifications.feed(), (feed) =>
|
||||||
|
feed ? withoutSlaBreach(feed, workOrderId) : feed,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/domain/notifications/utils/sla-alert-copy.ts
Normal file
20
src/domain/notifications/utils/sla-alert-copy.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
||||||
|
|
||||||
|
function workOrderLabel(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
||||||
|
return workOrder.number != null && workOrder.number !== ""
|
||||||
|
? `WO #${workOrder.number}`
|
||||||
|
: `WO ${workOrder.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line whatever the count, as the top-of-app banner shows it. */
|
||||||
|
export function slaBannerText(workOrders: Pick<SlaAtRiskWorkOrder, "id" | "number">[]): string {
|
||||||
|
if (workOrders.length === 1) {
|
||||||
|
return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`;
|
||||||
|
}
|
||||||
|
return `${workOrders.length} work orders are at risk of missing their response deadline`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-time toast when a work order is first seen past half of its response window. */
|
||||||
|
export function slaHalfwayToastText(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
||||||
|
return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`;
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,8 @@ export interface WorkOrderAdvancedFilters {
|
||||||
internalOnly: boolean;
|
internalOnly: boolean;
|
||||||
hasUplift: boolean;
|
hasUplift: boolean;
|
||||||
upliftStatuses: Set<UpliftFilterStatus>;
|
upliftStatuses: Set<UpliftFilterStatus>;
|
||||||
|
/** Exact work orders to show; when non-empty the server ignores every other filter. */
|
||||||
|
workOrderIds: Set<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
|
|
@ -70,6 +72,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
internalOnly: false,
|
internalOnly: false,
|
||||||
hasUplift: false,
|
hasUplift: false,
|
||||||
upliftStatuses: new Set(),
|
upliftStatuses: new Set(),
|
||||||
|
workOrderIds: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,8 @@ export interface WorkOrderBoardSearchQueryParams {
|
||||||
hasUplift?: boolean;
|
hasUplift?: boolean;
|
||||||
upliftStatuses?: string[];
|
upliftStatuses?: string[];
|
||||||
myWorkOrders?: boolean;
|
myWorkOrders?: boolean;
|
||||||
|
/** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */
|
||||||
|
ids?: string;
|
||||||
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
||||||
sortDir?: "asc" | "desc" | string;
|
sortDir?: "asc" | "desc" | string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -306,3 +306,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder
|
||||||
clear: clearUplift,
|
clear: clearUplift,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appendWorkOrderIdsChip(
|
||||||
|
chips: AdvancedFilterChip[],
|
||||||
|
filters: WorkOrderAdvancedFilters,
|
||||||
|
) {
|
||||||
|
const count = filters.workOrderIds?.size ?? 0;
|
||||||
|
if (count === 0) return;
|
||||||
|
chips.push({
|
||||||
|
key: "workOrderIds",
|
||||||
|
group: "Work orders",
|
||||||
|
value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`,
|
||||||
|
clear: (current) => ({ ...current, workOrderIds: new Set<number>() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
appendSeverityChip,
|
appendSeverityChip,
|
||||||
appendStatusPmVendorDocChips,
|
appendStatusPmVendorDocChips,
|
||||||
appendUpliftChip,
|
appendUpliftChip,
|
||||||
|
appendWorkOrderIdsChip,
|
||||||
type AdvancedFilterChip,
|
type AdvancedFilterChip,
|
||||||
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
||||||
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
@ -33,6 +34,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
|
||||||
docs: new Set(filters.docs),
|
docs: new Set(filters.docs),
|
||||||
flagColors: new Set(filters.flagColors ?? []),
|
flagColors: new Set(filters.flagColors ?? []),
|
||||||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||||
|
workOrderIds: new Set(filters.workOrderIds ?? []),
|
||||||
unscheduledOnly: Boolean(filters.unscheduledOnly),
|
unscheduledOnly: Boolean(filters.unscheduledOnly),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -56,12 +58,25 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
||||||
filters.internalOnly,
|
filters.internalOnly,
|
||||||
filters.hasUplift,
|
filters.hasUplift,
|
||||||
filters.unscheduledOnly,
|
filters.unscheduledOnly,
|
||||||
|
Boolean(filters.workOrderIds?.size),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||||
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
|
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server honours only the id set when one is sent, so an id selection and panel facets never
|
||||||
|
* combine: applying any facet replaces the selection with a normal filtered board.
|
||||||
|
*/
|
||||||
|
export function resolveAppliedAdvancedFilters(
|
||||||
|
filters: WorkOrderAdvancedFilters,
|
||||||
|
): WorkOrderAdvancedFilters {
|
||||||
|
const next = cloneAdvancedFilters(filters);
|
||||||
|
const withoutIds = { ...next, workOrderIds: new Set<number>() };
|
||||||
|
return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next;
|
||||||
|
}
|
||||||
|
|
||||||
function monthStart(y: number, m: number): string {
|
function monthStart(y: number, m: number): string {
|
||||||
return `${y}-${pad2(m + 1)}-01`;
|
return `${y}-${pad2(m + 1)}-01`;
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +135,7 @@ export function buildAdvancedFilterChips(
|
||||||
appendIndicatorChips(chips, filters);
|
appendIndicatorChips(chips, filters);
|
||||||
appendFlagAndInternalChips(chips, filters);
|
appendFlagAndInternalChips(chips, filters);
|
||||||
appendUpliftChip(chips, filters);
|
appendUpliftChip(chips, filters);
|
||||||
|
appendWorkOrderIdsChip(chips, filters);
|
||||||
|
|
||||||
return chips;
|
return chips;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||||
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
|
@ -133,6 +134,13 @@ function mapSearchFacetParams(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
|
||||||
|
function mapWorkOrderIds(ids: Set<number> | undefined): string | undefined {
|
||||||
|
// Normalized here too, so a caller that fills the set directly still stays within the server's limit.
|
||||||
|
const normalized = ids?.size ? normalizeWorkOrderIds(ids) : [];
|
||||||
|
return normalized.length > 0 ? normalized.join(",") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build GET /board query from weekly filter bar. */
|
/** Build GET /board query from weekly filter bar. */
|
||||||
export function toBoardQueryParams(
|
export function toBoardQueryParams(
|
||||||
bar: WorkOrderBarFilters,
|
bar: WorkOrderBarFilters,
|
||||||
|
|
@ -226,6 +234,7 @@ export function toBoardSearchQueryParams(input: {
|
||||||
docStatuses: mapDocStatuses(filters.docs),
|
docStatuses: mapDocStatuses(filters.docs),
|
||||||
unscheduledOnly: filters.unscheduledOnly || undefined,
|
unscheduledOnly: filters.unscheduledOnly || undefined,
|
||||||
...mapSearchFacetParams(filters),
|
...mapSearchFacetParams(filters),
|
||||||
|
ids: mapWorkOrderIds(filters.workOrderIds),
|
||||||
sortBy: input.sortBy || "scheduledDate",
|
sortBy: input.sortBy || "scheduledDate",
|
||||||
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o
|
||||||
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter";
|
import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter";
|
||||||
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
|
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
|
import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||||
|
|
||||||
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
|
@ -63,6 +64,12 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
|
||||||
export function parseDashboardDrilldownParams(
|
export function parseDashboardDrilldownParams(
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
): WorkOrderAdvancedFilters | null {
|
): WorkOrderAdvancedFilters | null {
|
||||||
|
// An exact id set replaces every other filter: nothing else may hide one of those work orders.
|
||||||
|
const workOrderIds = parseWorkOrderIdsParam(params.get("ids"));
|
||||||
|
if (workOrderIds.length > 0) {
|
||||||
|
return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) };
|
||||||
|
}
|
||||||
|
|
||||||
const dateFrom = (params.get("dateFrom") ?? "").trim();
|
const dateFrom = (params.get("dateFrom") ?? "").trim();
|
||||||
const dateTo = (params.get("dateTo") ?? "").trim();
|
const dateTo = (params.get("dateTo") ?? "").trim();
|
||||||
const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo);
|
const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo);
|
||||||
|
|
|
||||||
26
src/domain/work-orders/utils/work-order-ids-filter.ts
Normal file
26
src/domain/work-orders/utils/work-order-ids-filter.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */
|
||||||
|
export const WORK_ORDER_IDS_MAX = 200;
|
||||||
|
|
||||||
|
/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */
|
||||||
|
export function normalizeWorkOrderIds(values: Iterable<number | string>): number[] {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const value of values) {
|
||||||
|
const text = typeof value === "number" ? String(value) : value.trim();
|
||||||
|
if (!/^\d+$/.test(text)) continue;
|
||||||
|
const id = Number(text);
|
||||||
|
if (Number.isSafeInteger(id) && id > 0) ids.add(id);
|
||||||
|
if (ids.size === WORK_ORDER_IDS_MAX) break;
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */
|
||||||
|
export function parseWorkOrderIdsParam(raw: string | null): number[] {
|
||||||
|
return raw ? normalizeWorkOrderIds(raw.split(",")) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */
|
||||||
|
export function buildWorkOrderIdsHref(ids: number[]): string {
|
||||||
|
const normalized = normalizeWorkOrderIds(ids);
|
||||||
|
return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||||
|
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||||
|
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
|
||||||
|
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
|
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
import {
|
||||||
|
buildWorkOrderIdsHref,
|
||||||
|
WORK_ORDER_IDS_MAX,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */
|
||||||
|
function BoardFilterHarness() {
|
||||||
|
const filters = useWorkOrdersListFilters();
|
||||||
|
useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
||||||
|
const location = useLocation();
|
||||||
|
const params = buildResolvedBoardSearchParams({
|
||||||
|
advApplied: filters.advApplied,
|
||||||
|
search: "",
|
||||||
|
page: 0,
|
||||||
|
pageSize: 200,
|
||||||
|
sortBy: "",
|
||||||
|
sortDir: "",
|
||||||
|
locations: [],
|
||||||
|
vendors: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<output aria-label="Board search params">
|
||||||
|
{params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))}
|
||||||
|
</output>
|
||||||
|
<output aria-label="Current search">{location.search}</output>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => filters.applyAdvancedFilters({ ...filters.advInitial, rescheduled: true })}
|
||||||
|
>
|
||||||
|
Apply rescheduled only
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => filters.applyAdvancedFilters(filters.advInitial)}>
|
||||||
|
Apply unchanged
|
||||||
|
</button>
|
||||||
|
{filters.advApplied != null && (
|
||||||
|
<WorkOrdersAdvancedFiltersBanner
|
||||||
|
applied={filters.advApplied}
|
||||||
|
totalCount={2}
|
||||||
|
sites={[]}
|
||||||
|
users={[]}
|
||||||
|
onClearChip={filters.clearAdvancedFilterChip}
|
||||||
|
onClearAll={filters.clearAdvancedFilters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchParams(): Record<string, unknown> | "weekly board" {
|
||||||
|
const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? "";
|
||||||
|
return text === "weekly board" ? text : (JSON.parse(text) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Work Orders exact id filter", () => {
|
||||||
|
it("opens the board on exactly the linked work orders and shows the selection chip", async () => {
|
||||||
|
renderWithProviders(<BoardFilterHarness />, {
|
||||||
|
route: "/workorders?ids=4,9,4",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
|
||||||
|
const params = searchParams() as Record<string, unknown>;
|
||||||
|
expect(params.ids).toBe("4,9");
|
||||||
|
// No saved or default facet travels with the id set.
|
||||||
|
expect(params.statuses).toBeUndefined();
|
||||||
|
expect(params.dispatchers).toBeUndefined();
|
||||||
|
expect(params.types).toBeUndefined();
|
||||||
|
|
||||||
|
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||||
|
// The applied link params are consumed, like the dashboard drill-downs.
|
||||||
|
expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the weekly board when the selection chip is cleared", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<BoardFilterHarness />, {
|
||||||
|
route: buildWorkOrderIdsHref([12]),
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Remove filter" }));
|
||||||
|
|
||||||
|
expect(searchParams()).toBe("weekly board");
|
||||||
|
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the selection when a panel filter is applied, so no chip shows a filter the server ignores", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<BoardFilterHarness />, {
|
||||||
|
route: "/workorders?ids=4,9",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply rescheduled only" }));
|
||||||
|
|
||||||
|
const params = searchParams() as Record<string, unknown>;
|
||||||
|
expect(params.ids).toBeUndefined();
|
||||||
|
expect(params.rescheduled).toBe(true);
|
||||||
|
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the selection when the panel is applied without a filter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<BoardFilterHarness />, {
|
||||||
|
route: "/workorders?ids=4,9",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply unchanged" }));
|
||||||
|
|
||||||
|
expect((searchParams() as Record<string, unknown>).ids).toBe("4,9");
|
||||||
|
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends more ids than the server accepts, however the selection was built", () => {
|
||||||
|
const ids = new Set(Array.from({ length: WORK_ORDER_IDS_MAX + 30 }, (_, index) => index + 1));
|
||||||
|
const params = buildResolvedBoardSearchParams({
|
||||||
|
advApplied: { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: ids },
|
||||||
|
search: "",
|
||||||
|
page: 0,
|
||||||
|
pageSize: 200,
|
||||||
|
sortBy: "",
|
||||||
|
sortDir: "",
|
||||||
|
locations: [],
|
||||||
|
vendors: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(params?.ids?.split(",")).toHaveLength(WORK_ORDER_IDS_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores other drill-down params sent alongside the id set", async () => {
|
||||||
|
renderWithProviders(<BoardFilterHarness />, {
|
||||||
|
route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
|
||||||
|
const params = searchParams() as Record<string, unknown>;
|
||||||
|
expect(params.ids).toBe("5");
|
||||||
|
expect(params.statuses).toBeUndefined();
|
||||||
|
expect(params.dispatchers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the regular drill-down filters when no ids are given", async () => {
|
||||||
|
renderWithProviders(<BoardFilterHarness />, {
|
||||||
|
route: "/workorders?statuses=Scheduled",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
|
||||||
|
const params = searchParams() as Record<string, unknown>;
|
||||||
|
expect(params.ids).toBeUndefined();
|
||||||
|
expect(params.statuses).toEqual([expect.any(Number)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,8 +9,9 @@ import { NotificationSessionProvider } from "@/domain/notifications/session/noti
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const apiGet = vi.hoisted(() => vi.fn());
|
const apiGet = vi.hoisted(() => vi.fn());
|
||||||
|
const apiPostNoContent = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({ apiGet }));
|
vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent }));
|
||||||
|
|
||||||
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/providers/auth-context")>()),
|
...(await importOriginal<typeof import("@/providers/auth-context")>()),
|
||||||
|
|
@ -131,6 +132,8 @@ describe("notification center", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiGet.mockReset();
|
apiGet.mockReset();
|
||||||
apiGet.mockResolvedValue(standardFeed);
|
apiGet.mockResolvedValue(standardFeed);
|
||||||
|
apiPostNoContent.mockReset();
|
||||||
|
apiPostNoContent.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
|
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
|
||||||
|
|
@ -256,6 +259,58 @@ describe("notification center", () => {
|
||||||
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
|
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Clear all disabled when only rows that need acknowledging are left", async () => {
|
||||||
|
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBell();
|
||||||
|
|
||||||
|
const panel = await openPanel(user);
|
||||||
|
|
||||||
|
expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acknowledges a missed deadline through the API and removes the row", async () => {
|
||||||
|
apiGet.mockResolvedValue(
|
||||||
|
feed(
|
||||||
|
section("sla", "SLA at Risk", [acknowledgeRow]),
|
||||||
|
section("unassigned", "Unassigned", [unassigned]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBell();
|
||||||
|
|
||||||
|
const panel = await openPanel(user);
|
||||||
|
const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement;
|
||||||
|
const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" });
|
||||||
|
expect(acknowledge).toHaveAttribute("title", "Acknowledge");
|
||||||
|
expect(acknowledge).toHaveTextContent("Acknowledge");
|
||||||
|
|
||||||
|
apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned])));
|
||||||
|
await user.click(acknowledge);
|
||||||
|
|
||||||
|
expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the row when the acknowledgement fails", async () => {
|
||||||
|
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
|
||||||
|
apiPostNoContent.mockRejectedValue(new Error("boom"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBell();
|
||||||
|
|
||||||
|
const panel = await openPanel(user);
|
||||||
|
await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(),
|
||||||
|
);
|
||||||
|
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
|
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBell();
|
renderBell();
|
||||||
|
|
|
||||||
148
src/test/components/notifications/sla-at-risk-banner.test.tsx
Normal file
148
src/test/components/notifications/sla-at-risk-banner.test.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { act, 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 { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
|
||||||
|
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const apiGet = vi.hoisted(() => vi.fn());
|
||||||
|
const toastWarn = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/api/api", () => ({ apiGet }));
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { warn: toastWarn } }));
|
||||||
|
|
||||||
|
const BANNER = "Work orders at risk of missing their response deadline";
|
||||||
|
|
||||||
|
function atRisk(id: number, number: string | null = String(1000 + id)) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
severity: 1,
|
||||||
|
startedAt: "2026-09-18T10:00:00Z",
|
||||||
|
deadlineAt: "2026-09-18T12:00:00Z",
|
||||||
|
percentElapsed: 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function feed(...slaAtRisk: ReturnType<typeof atRisk>[]) {
|
||||||
|
return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk };
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationProbe() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBanner() {
|
||||||
|
return renderWithProviders(
|
||||||
|
<NotificationSessionProvider>
|
||||||
|
<SlaAtRiskBanner />
|
||||||
|
<LocationProbe />
|
||||||
|
</NotificationSessionProvider>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SLA at-risk banner", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiGet.mockReset();
|
||||||
|
toastWarn.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays hidden while no work order is past half of its response window", async () => {
|
||||||
|
apiGet.mockResolvedValue(feed());
|
||||||
|
renderBanner();
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiGet).toHaveBeenCalledWith("notifications"));
|
||||||
|
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
|
||||||
|
expect(toastWarn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the single work order at risk", async () => {
|
||||||
|
apiGet.mockResolvedValue(feed(atRisk(101)));
|
||||||
|
renderBanner();
|
||||||
|
|
||||||
|
const banner = await screen.findByRole("region", { name: BANNER });
|
||||||
|
expect(
|
||||||
|
within(banner).getByRole("button", {
|
||||||
|
name: "WO #1101 is at risk of missing its response deadline",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses several work orders to one line and opens Work Orders on exactly those", async () => {
|
||||||
|
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBanner();
|
||||||
|
|
||||||
|
const banner = await screen.findByRole("region", { name: BANNER });
|
||||||
|
const open = within(banner).getByRole("button", {
|
||||||
|
name: "3 work orders are at risk of missing their response deadline",
|
||||||
|
});
|
||||||
|
expect(open).toHaveAttribute("title", "View in Work Orders");
|
||||||
|
expect(within(banner).getAllByRole("button")).toHaveLength(2);
|
||||||
|
|
||||||
|
await user.click(open);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
||||||
|
"/workorders?ids=101,205,318",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses for this session only and returns when a new work order becomes at risk", async () => {
|
||||||
|
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205)));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { queryClient, unmount } = renderBanner();
|
||||||
|
|
||||||
|
const banner = await screen.findByRole("region", { name: BANNER });
|
||||||
|
await user.click(within(banner).getByRole("button", { name: "Dismiss SLA alert" }));
|
||||||
|
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
|
||||||
|
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
|
||||||
|
|
||||||
|
const returned = await screen.findByRole("region", { name: BANNER });
|
||||||
|
expect(
|
||||||
|
within(returned).getByRole("button", {
|
||||||
|
name: "WO #1318 is at risk of missing its response deadline",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
renderBanner();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: "3 work orders are at risk of missing their response deadline",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts each work order once per session, however often the feed polls", async () => {
|
||||||
|
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null)));
|
||||||
|
const { queryClient } = renderBanner();
|
||||||
|
|
||||||
|
await screen.findByRole("region", { name: BANNER });
|
||||||
|
expect(toastWarn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(toastWarn).toHaveBeenCalledWith("WO #1101 is at 50% of its SLA response window", {
|
||||||
|
toastId: "sla-at-risk-101",
|
||||||
|
});
|
||||||
|
expect(toastWarn).toHaveBeenCalledWith("WO 205 is at 50% of its SLA response window", {
|
||||||
|
toastId: "sla-at-risk-205",
|
||||||
|
});
|
||||||
|
|
||||||
|
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null), atRisk(318)));
|
||||||
|
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: "3 work orders are at risk of missing their response deadline",
|
||||||
|
});
|
||||||
|
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
|
||||||
|
|
||||||
|
expect(toastWarn).toHaveBeenCalledTimes(3);
|
||||||
|
expect(toastWarn).toHaveBeenLastCalledWith("WO #1318 is at 50% of its SLA response window", {
|
||||||
|
toastId: "sla-at-risk-318",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
NotificationFeed,
|
||||||
|
NotificationItem,
|
||||||
|
} from "@/domain/notifications/types/notification-feed";
|
||||||
|
import { withoutSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
|
||||||
|
|
||||||
|
function row(
|
||||||
|
overrides: Partial<NotificationItem> & Pick<NotificationItem, "id">,
|
||||||
|
): NotificationItem {
|
||||||
|
return {
|
||||||
|
reason: "sla",
|
||||||
|
severity: "Critical",
|
||||||
|
rowType: "acknowledge",
|
||||||
|
title: "missed its response deadline",
|
||||||
|
count: 1,
|
||||||
|
triggeredAt: "2026-09-18T12:00:00Z",
|
||||||
|
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
|
||||||
|
workOrders: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const breach9 = row({ id: "sla-breach-9" });
|
||||||
|
const breach10 = row({ id: "sla-breach-10", target: { kind: "workOrder", workOrderId: 10 } });
|
||||||
|
const atRisk9 = row({ id: "sla-at-risk-9", severity: "High", rowType: "dismissable" });
|
||||||
|
const aveta9 = row({ id: "aveta-missing-9", reason: "aveta-missing", rowType: "dismissable" });
|
||||||
|
|
||||||
|
const feed: NotificationFeed = {
|
||||||
|
generatedAt: "2026-09-18T12:00:00Z",
|
||||||
|
slaAtRisk: [],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
reason: "sla",
|
||||||
|
label: "SLA at Risk",
|
||||||
|
severity: "Critical",
|
||||||
|
count: 3,
|
||||||
|
items: [breach9, breach10, atRisk9],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reason: "aveta-missing",
|
||||||
|
label: "Aveta Missing",
|
||||||
|
severity: "Medium",
|
||||||
|
count: 1,
|
||||||
|
items: [aveta9],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("withoutSlaBreach", () => {
|
||||||
|
it("drops only the acknowledged work order's breach row and lowers its section count", () => {
|
||||||
|
const next = withoutSlaBreach(feed, 9);
|
||||||
|
|
||||||
|
expect(next.sections[0].items.map((item) => item.id)).toEqual([
|
||||||
|
"sla-breach-10",
|
||||||
|
"sla-at-risk-9",
|
||||||
|
]);
|
||||||
|
expect(next.sections[0].count).toBe(2);
|
||||||
|
expect(next.sections[1]).toBe(feed.sections[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the feed as it was when that work order has no breach row", () => {
|
||||||
|
const next = withoutSlaBreach(feed, 42);
|
||||||
|
|
||||||
|
expect(next.sections).toEqual(feed.sections);
|
||||||
|
expect(next.sections[0]).toBe(feed.sections[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
WORK_ORDER_IDS_MAX,
|
||||||
|
buildWorkOrderIdsHref,
|
||||||
|
normalizeWorkOrderIds,
|
||||||
|
parseWorkOrderIdsParam,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||||
|
|
||||||
|
describe("work-order ids filter", () => {
|
||||||
|
it("builds the Work Orders link for an exact id set", () => {
|
||||||
|
expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => {
|
||||||
|
expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101");
|
||||||
|
expect(buildWorkOrderIdsHref([])).toBe("/workorders");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the ids param, ignoring malformed tokens", () => {
|
||||||
|
expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]);
|
||||||
|
expect(parseWorkOrderIdsParam(null)).toEqual([]);
|
||||||
|
expect(parseWorkOrderIdsParam("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps at most the backend limit of distinct ids", () => {
|
||||||
|
const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1);
|
||||||
|
const normalized = normalizeWorkOrderIds([...ids, 1, 2]);
|
||||||
|
|
||||||
|
expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX);
|
||||||
|
expect(normalized[0]).toBe(1);
|
||||||
|
expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue