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.
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 191 KiB |
85
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -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 }) => {
|
||||
|
|
|
|||
189
src/components/layout/app-topbar-user-menu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -20,12 +20,27 @@ export function unassignedQueueUrl(): string {
|
|||
return workOrderDrilldownUrl(search);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -281,7 +282,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 +298,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(
|
||||
|
|
|
|||