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} + + + + + + setUserAnchor(null)} + transformOrigin={{ horizontal: "right", vertical: "top" }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + slotProps={{ + paper: { + sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" }, + }, + }} + > + + + { + setUserAnchor(null); + navigate("/settings/dropdowns"); + }} + > + + + + Settings + + + + + + Logout + + + + ); +} 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} - - - - + - - setUserAnchor(null)} - transformOrigin={{ horizontal: "right", vertical: "top" }} - anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - slotProps={{ - paper: { - sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" }, - }, - }} - > - - - {userInitials} - - - - {user?.fullname || "User"} - - - {user?.email || ""} - - - - - { - setUserAnchor(null); - navigate("/settings/dropdowns"); - }} - > - - - - Settings - - - - - - Logout - - ); } 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) && (