diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png
index 67a84215..a078ccf6 100644
Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ
diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png
index d95ef194..0db529fe 100644
Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
index f3a5e057..f4feaee6 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
index 6487de7d..1b239e83 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
index 36602c27..310f2ce5 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
index 80bcdb86..6742d5c8 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
index f724fe2d..bc920e8d 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png
index 8a7a6caf..cffc357d 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png
index 3fcea96e..dba22877 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png
index ca5acc44..c921fc40 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
index 8fb56c7f..73995d8f 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png
index 5311388e..1377fbcb 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
index 35e6b135..121879f8 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ
diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts
index 13c7b1eb..57d5671b 100644
--- a/e2e/vendors/vendor-operations.spec.ts
+++ b/e2e/vendors/vendor-operations.spec.ts
@@ -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 }) => {
diff --git a/src/components/layout/app-topbar-user-menu.tsx b/src/components/layout/app-topbar-user-menu.tsx
new file mode 100644
index 00000000..98325fff
--- /dev/null
+++ b/src/components/layout/app-topbar-user-menu.tsx
@@ -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 (
+
+
+ {initials}
+
+
+
+ {fullname}
+
+
+ {email}
+
+
+
+ );
+}
+
+export function AppTopbarUserMenu() {
+ const navigate = useNavigate();
+ const { user, logout } = useAuthContext();
+
+ const [userAnchor, setUserAnchor] = useState(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 (
+ <>
+ 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)",
+ },
+ }}
+ >
+
+ {userInitials}
+
+
+
+ {user?.fullname || "User"}
+
+
+ {userRole}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx
index 9dd16781..1be6b892 100644
--- a/src/components/layout/app-topbar.tsx
+++ b/src/components/layout/app-topbar.tsx
@@ -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);
-
- 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 (
- 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)",
- },
- }}
- >
-
- {userInitials}
-
-
-
- {user?.fullname || "User"}
-
-
- {userRole}
-
-
-
-
+
-
-
);
}
diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx
index a90aa4b3..ed254ab7 100644
--- a/src/components/notifications/notification-row.tsx
+++ b/src/components/notifications/notification-row.tsx
@@ -12,8 +12,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 = {
@@ -49,10 +51,39 @@ function ItemIcon({ item }: { item: NotificationItem }) {
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) {
+ if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {item.workOrders.map((workOrder) => {
+ const url = workOrderViewUrl(workOrder.id);
+ return (
+ {
+ 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}`}
+
+ );
+ })}
+
+ );
+}
+
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
return (
-
+
+
+
+ {unread && Unread: }
+ {item.title}
+
+
+
+
{isDismissable(item) && (
void) {
[items, readIds],
);
- const open = (item: NotificationItem) => {
+ const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
markRead([item.id]);
- const url = notificationTargetUrl(item, user?.id);
if (url == null) return;
onNavigate?.();
navigate(url);
diff --git a/src/domain/notifications/use-cases/use-notification-feed.ts b/src/domain/notifications/use-cases/use-notification-feed.ts
index 54fb34e5..98e9786b 100644
--- a/src/domain/notifications/use-cases/use-notification-feed.ts
+++ b/src/domain/notifications/use-cases/use-notification-feed.ts
@@ -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 },
});
}
diff --git a/src/domain/notifications/utils/notification-target-url.ts b/src/domain/notifications/utils/notification-target-url.ts
index 1ef67069..4d162560 100644
--- a/src/domain/notifications/utils/notification-target-url.ts
+++ b/src/domain/notifications/utils/notification-target-url.ts
@@ -26,6 +26,15 @@ export function unassignedQueueUrl(): string {
return openQueueUrl(ASSIGNEE_FILTER_UNASSIGNED);
}
+/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */
+export function chooseVendorUrl(workOrderId: number): string {
+ return `/workorders/edit/${workOrderId}?assignVendor=1`;
+}
+
+export function workOrderViewUrl(workOrderId: number): string {
+ return `/workorders/${workOrderId}`;
+}
+
/**
* Where a feed item leads. Queues open the Work Orders board filtered; work-order items open the
* slide-over on their tab, and comment or mention items also carry which comments to highlight.
@@ -45,6 +54,9 @@ export function notificationTargetUrl(
if (target.workOrderId == null) {
return null;
}
+ if (item.reason === "no-vendor") {
+ return chooseVendorUrl(target.workOrderId);
+ }
const highlight =
target.tab === "comments" && COMMENT_REASONS.has(item.reason)
? { since: item.triggeredAt, mentions: item.reason === "mentions" }
diff --git a/src/test/components/notifications/notification-center.test.tsx b/src/test/components/notifications/notification-center.test.tsx
index 32ba9a71..b76959f3 100644
--- a/src/test/components/notifications/notification-center.test.tsx
+++ b/src/test/components/notifications/notification-center.test.tsx
@@ -25,6 +25,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) {
@@ -32,7 +33,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,
};
}
@@ -286,7 +287,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(
@@ -302,10 +303,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(
+
+
+
+ ,
+ { 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(