Compare commits

...

3 commits

Author SHA1 Message Date
Codex Review Integration
062a1e26c8 fix: resolve notification review findings 2026-09-18 14:23:43 -03:00
Codex Review Integration
edd2e7efe2 test(visual): refresh mobile navigation baseline for the notification bell 2026-09-18 13:52:00 -03:00
Codex Review Integration
988c96c42d fix(notifications): keep vendor reminder actions and refresh header baselines
No Vendor opens the work order form on vendor assignment and Vendor Conflict
links each work order, as the vendor reminders did. Header baselines include the
notification bell, and the top bar user menu moves to its own component to stay
within the function-size gate. The vendor e2e now covers the feed contract.
2026-09-18 13:34:12 -03:00
34 changed files with 470 additions and 254 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View file

@ -78,49 +78,54 @@ test.describe("Vendor operations", () => {
.toBeGreaterThanOrEqual(280);
});
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({
page,
}) => {
await authenticate(page);
await page.route("**/api/vendor-operations/notifications", (route) =>
const workOrder = { kind: "workOrder", tab: "info" };
await page.route("**/api/notifications", (route) =>
fulfillJson(route, {
items: [
generatedAt: "2026-07-23T12:00:00Z",
sections: [
{
id: "no-vendor-501",
type: "NoVendor",
reason: "no-vendor",
label: "No Vendor",
severity: "Medium",
group: "No Vendor",
title: "No vendor assigned — 48h to service",
workOrderId: 501,
workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12:00:00Z",
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
},
{
id: "vendor-conflict-801-802",
type: "VendorConflict",
severity: "Medium",
group: "Vendor Conflict",
title: "Vendor has overlapping appointments",
triggeredAt: "2026-07-23T12:00:00Z",
workOrders: [
count: 1,
items: [
{
workOrderId: 501,
workOrderNumber: "WO-501",
start: "2026-07-25T12:00:00Z",
end: "2026-07-25T14:00:00Z",
},
{
workOrderId: 502,
workOrderNumber: "WO-502",
start: "2026-07-25T13:00:00Z",
end: "2026-07-25T15:00:00Z",
id: "no-vendor-501",
reason: "no-vendor",
severity: "Medium",
rowType: "dismissable",
title: "WO #WO-501 starts within 48h with no vendor assigned",
count: 1,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [{ id: 501, number: "WO-501" }],
},
],
actions: [
{ label: "WO-501", href: "/workorders/501" },
{ label: "WO-502", href: "/workorders/502" },
},
{
reason: "vendor-conflict",
label: "Vendor Conflict",
severity: "Medium",
count: 2,
items: [
{
id: "vendor-conflict-501-502",
reason: "vendor-conflict",
severity: "Medium",
rowType: "dismissable",
title: "Acme has overlapping appointments",
count: 2,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [
{ id: 501, number: "WO-501" },
{ id: 502, number: "WO-502" },
],
},
],
},
],
@ -129,21 +134,19 @@ test.describe("Vendor operations", () => {
await page.goto("/notifications");
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
"/workorders/edit/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute(
"href",
"/workorders/502",
);
await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click();
await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/);
});
test("filters insights and exposes both export formats", async ({ page }) => {

View file

@ -29,6 +29,8 @@ type UseSlideOverEditStateArgs = {
setTab: (tab: SlideOverTab) => void;
/** Tab to land on when a work order opens outside edit mode. */
initialTab?: SlideOverTab;
/** Changes for each explicit open request, including repeated requests for the same row/tab. */
openRequestKey?: number;
};
export function useSlideOverEditState({
@ -40,6 +42,7 @@ export function useSlideOverEditState({
closeDisabled,
setTab,
initialTab = "info",
openRequestKey = 0,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
@ -69,6 +72,11 @@ export function useSlideOverEditState({
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
useEffect(() => {
if (row?.id == null || editMode) return;
setTab(initialTab);
}, [editMode, initialTab, openRequestKey, row?.id, setTab]);
const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus);

View file

@ -36,6 +36,7 @@ type UseWorkOrderSlideOverArgs = {
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
type CompletionUploadMutate = (
@ -114,6 +115,7 @@ export function useWorkOrderSlideOver({
onClose,
saving,
initialTab,
openRequestKey,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
@ -163,6 +165,7 @@ export function useWorkOrderSlideOver({
closeDisabled,
setTab,
initialTab,
openRequestKey,
});
const uploadCompletionPdf = (file: File) => {

View file

@ -36,6 +36,7 @@ type WorkOrderSlideOverProps = {
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
export function WorkOrderSlideOver({
@ -54,8 +55,17 @@ export function WorkOrderSlideOver({
onClose,
saving,
initialTab,
openRequestKey,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving, initialTab });
const state = useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
initialTab,
openRequestKey,
});
const { infoSource, activeDraft } = state;
return (

View file

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

View file

@ -30,7 +30,7 @@ export function useDashboardDrilldownFilters(
}
appliedRef.current = true;
setFromDashboard(true);
setFromDashboard(searchParams.get("fromDashboard") === "1");
onApplyRef.current(drilldown);
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);

View file

@ -23,6 +23,7 @@ export function useWorkOrdersListPage() {
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
@ -84,6 +85,7 @@ export function useWorkOrdersListPage() {
setSlideOverRow(row);
setSlideOverEdit(edit);
setSlideOverTab(tab);
setSlideOverOpenRequestKey((key) => key + 1);
};
const handleCloseSlideOver = () => {
@ -138,6 +140,7 @@ export function useWorkOrdersListPage() {
setWizardOpen,
slideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
confirmCancel,
setConfirmCancel,
confirmComplete,

View file

@ -0,0 +1,189 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
type UserMenuHeaderProps = {
initials: string;
fullname: string;
email: string;
};
function UserMenuHeader({ initials, fullname, email }: UserMenuHeaderProps) {
return (
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{initials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{fullname}
</Typography>
<Typography variant="caption" color="text.secondary">
{email}
</Typography>
</Box>
</Box>
);
}
export function AppTopbarUserMenu() {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<>
<Box
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<UserMenuHeader
initials={userInitials}
fullname={user?.fullname || "User"}
email={user?.email || ""}
/>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</>
);
}

View file

@ -1,50 +1,14 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
AppBar,
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Toolbar,
Typography,
} from "@mui/material";
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
import { AppBar, Box, Toolbar } from "@mui/material";
import { Menu as MenuIcon } from "lucide-react";
import { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
type AppTopbarProps = {
onToggleNav: () => void;
};
export function AppTopbar({ onToggleNav }: AppTopbarProps) {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<AppBar
position="sticky"
@ -100,135 +64,8 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
<NotificationBell />
<Box
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
<AppTopbarUserMenu />
</Toolbar>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{userInitials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>
</Box>
</Box>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</AppBar>
);
}

View file

@ -17,7 +17,7 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
);
}
if (center.error != null) {
if (center.error != null && !center.hasData) {
return (
<Text variant="error" className="py-6">
Notifications could not be loaded. They will retry automatically.

View file

@ -9,8 +9,10 @@ import {
Wrench,
X,
} from "lucide-react";
import { Link } from "react-router";
import { Text } from "@/components/ui/text";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url";
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
const REASON_ICONS: Record<string, LucideIcon> = {
@ -28,10 +30,39 @@ const REASON_ICONS: Record<string, LucideIcon> = {
type NotificationRowProps = {
item: NotificationItem;
unread: boolean;
onOpen: (item: NotificationItem) => void;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
};
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
return null;
}
return (
<span className="flex flex-wrap gap-2" style={{ paddingLeft: 31, marginTop: 2 }}>
{item.workOrders.map((workOrder) => {
const url = workOrderViewUrl(workOrder.id);
return (
<Link
key={workOrder.id}
to={url}
onClick={(event) => {
event.preventDefault();
onOpen(item, url);
}}
className="text-xs underline-offset-2 hover:underline"
style={{ color: "var(--color-primary)" }}
>
{workOrder.number != null ? `WO #${workOrder.number}` : `WO ${workOrder.id}`}
</Link>
);
})}
</span>
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
const Icon = REASON_ICONS[item.reason] ?? Bell;
@ -46,36 +77,39 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
}}
data-unread={unread}
>
<button
type="button"
onClick={() => onOpen(item)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
<span
aria-hidden
className="shrink-0 rounded-full"
style={{
width: 6,
height: 6,
marginTop: 7,
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<Icon
size={15}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
/>
<Text
as="span"
variant="description"
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
<span className="flex min-w-0 flex-1 flex-col">
<button
type="button"
onClick={() => onOpen(item)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
{unread && <span className="sr-only">Unread: </span>}
{item.title}
</Text>
</button>
<span
aria-hidden
className="shrink-0 rounded-full"
style={{
width: 6,
height: 6,
marginTop: 7,
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<Icon
size={15}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
/>
<Text
as="span"
variant="description"
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
>
{unread && <span className="sr-only">Unread: </span>}
{item.title}
</Text>
</button>
<WorkOrderLinks item={item} onOpen={onOpen} />
</span>
{isDismissable(item) && (
<button
type="button"

View file

@ -24,9 +24,8 @@ export function useNotificationCenter(onNavigate?: () => void) {
[items, readIds],
);
const open = (item: NotificationItem) => {
const open = (item: NotificationItem, url = notificationTargetUrl(item)) => {
markRead([item.id]);
const url = notificationTargetUrl(item.target);
if (url == null) return;
onNavigate?.();
navigate(url);
@ -36,6 +35,7 @@ export function useNotificationCenter(onNavigate?: () => void) {
sections,
unreadIds,
isLoading: feed.isLoading,
hasData: feed.data != null,
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),

View file

@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
(status) => status !== "Completed",
);
export function workOrderDrilldownUrl(search: URLSearchParams): string {
const query = search.toString();
export function workOrderDrilldownUrl(
search: URLSearchParams,
source: "dashboard" | "notification" = "dashboard",
): string {
const params = new URLSearchParams(search);
if (source === "dashboard") {
params.set("fromDashboard", "1");
}
const query = params.toString();
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
}

View file

@ -10,5 +10,7 @@ export function useNotificationFeed() {
queryKey: queryKeys.notifications.feed(),
queryFn: notificationsApi.feed,
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
// The feed shows its own error state; a toast on every 30s poll would repeat the same failure.
meta: { suppressErrorToast: true },
});
}

View file

@ -2,7 +2,7 @@ import {
OPEN_WIZARD_STATUSES,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import type { NotificationTarget } from "@/domain/notifications/types/notification-feed";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
UNASSIGNED_QUEUE_DATE_FROM,
@ -17,15 +17,30 @@ export function unassignedQueueUrl(): string {
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return workOrderDrilldownUrl(search);
return workOrderDrilldownUrl(search, "notification");
}
export function notificationTargetUrl(target: NotificationTarget): string | null {
/** 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`;
}
export function workOrderViewUrl(workOrderId: number): string {
return `/workorders/${workOrderId}`;
}
export function notificationTargetUrl(
item: Pick<NotificationItem, "reason" | "target">,
): string | null {
const { target } = item;
if (target.kind === "queue") {
return target.queue === "unassigned" ? unassignedQueueUrl() : null;
}
if (target.workOrderId == null) {
return null;
}
if (item.reason === "no-vendor") {
return chooseVendorUrl(target.workOrderId);
}
return workOrderDeepLinkUrl(target.workOrderId, target.tab);
}

View file

@ -80,6 +80,7 @@ import DashboardPage from "@/app/(protected)/dashboard";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
@ -161,7 +162,7 @@ describe("DashboardPage", () => {
const today = businessTodayIso();
expect(navigate).toHaveBeenCalledWith(
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch(today)),
);
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));

View file

@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
vendorAssignedMessage,
@ -111,6 +112,34 @@ describe("useSlideOverEditState completed lock", () => {
expect(onSave).not.toHaveBeenCalled();
});
it("applies a repeated same-row tab request without resetting the draft", () => {
const open = baseRow();
const setTab = vi.fn();
const { result, rerender } = renderHook(
({ initialTab, openRequestKey }) =>
useSlideOverEditState({
row: open,
editMode: false,
infoSource: open,
onSave: vi.fn(),
onClose: vi.fn(),
closeDisabled: false,
setTab,
initialTab,
openRequestKey,
}),
{ initialProps: { initialTab: "info" as SlideOverTab, openRequestKey: 1 } },
);
act(() => {
result.current.handleDraftChange({ woNumber: "edited" });
});
rerender({ initialTab: "extras", openRequestKey: 2 });
expect(setTab).toHaveBeenLastCalledWith("extras");
expect(result.current.activeDraft?.woNumber).toBe("edited");
});
it("toasts vendor assigned after a successful save that includes vendorId", () => {
const onSave = vi.fn();
const open = baseRow();

View file

@ -108,4 +108,30 @@ describe("board drilldown links", () => {
customTo: "2099-12-31",
});
});
it("keeps dashboard provenance separate from notification drilldowns", async () => {
const onApply = vi.fn();
const { result } = renderHook(
() => {
const fromDashboard = useDashboardDrilldownFilters(onApply);
return { fromDashboard, navigate: useNavigate(), location: useLocation() };
},
{
wrapper: wrapper("/workorders?dateFrom=2026-09-01&dateTo=2026-09-30&fromDashboard=1"),
},
);
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.location.search).toBe(""));
expect(result.current.fromDashboard).toBe(true);
act(() => {
void result.current.navigate(
"/workorders?dateFrom=1970-01-01&dateTo=2099-12-31&dispatchers=__unassigned",
);
});
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.fromDashboard).toBe(false));
});
});

View file

@ -20,6 +20,7 @@ type ItemInput = {
count?: number;
rowType?: "dismissable" | "acknowledge";
target: { kind: "workOrder" | "queue"; workOrderId?: number; tab?: string; queue?: string };
workOrders?: { id: number; number: string }[];
};
function item(input: ItemInput) {
@ -27,7 +28,7 @@ function item(input: ItemInput) {
rowType: "dismissable" as ItemInput["rowType"],
count: 1,
triggeredAt: "2026-09-18T12:00:00Z",
workOrders: [],
workOrders: [] as { id: number; number: string }[],
...input,
};
}
@ -265,6 +266,7 @@ describe("notification center", () => {
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("fromDashboard")).toBeNull();
expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument();
});
@ -281,7 +283,7 @@ describe("notification center", () => {
);
});
it("serves the same feed as a full page", async () => {
it("serves the same feed as a full page and sends No Vendor straight to choosing a vendor", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
@ -297,10 +299,53 @@ describe("notification center", () => {
await user.click(screen.getByRole("button", { name: new RegExp(noVendor.title) }));
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders?wo=7&tab=info",
"/workorders/edit/7?assignVendor=1",
);
});
it("links every work order in a vendor conflict", async () => {
apiGet.mockResolvedValue(
feed(
section("vendor-conflict", "Vendor Conflict", [
item({
id: "vendor-conflict-501-502",
reason: "vendor-conflict",
severity: "Medium",
title: "Acme Plumbing has overlapping appointments",
count: 2,
target: { kind: "workOrder", workOrderId: 501, tab: "info" },
workOrders: [
{ id: 501, number: "501" },
{ id: 502, number: "502" },
],
}),
]),
),
);
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
const conflicts = await screen.findByRole("region", { name: "Vendor Conflict, 2" });
expect(within(conflicts).getByRole("link", { name: "WO #501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await user.click(within(conflicts).getByRole("link", { name: "WO #502" }));
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders/502",
);
const row = within(conflicts).getByRole("listitem");
expect(row.dataset.unread).toBe("false");
});
it("tells the user when the feed cannot load", async () => {
apiGet.mockRejectedValue(new Error("boom"));
renderWithProviders(

View file

@ -51,9 +51,12 @@ describe("work-order drilldown links", () => {
});
it("renders plain or query-bearing work order URLs", () => {
expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders");
expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders?fromDashboard=1");
expect(workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }))).toBe(
"/workorders?statuses=Scheduled",
"/workorders?statuses=Scheduled&fromDashboard=1",
);
expect(
workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }), "notification"),
).toBe("/workorders?statuses=Scheduled");
});
});