Compare commits
3 commits
dcf944d7dc
...
062a1e26c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062a1e26c8 | ||
|
|
edd2e7efe2 | ||
|
|
988c96c42d |
|
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: 42 KiB After Width: | Height: | Size: 42 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 }) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/ }));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||