Compare commits
18 commits
2a0478decc
...
076ebc455d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076ebc455d | ||
|
|
6fac21e19b | ||
|
|
8b58eb5f64 | ||
|
|
20ef4a615d | ||
|
|
563f81e163 | ||
|
|
cd888d933d | ||
|
|
ab5e73ce82 | ||
|
|
55f548d507 | ||
|
|
839246380a | ||
|
|
15e2565aca | ||
|
|
aee2ef5164 | ||
|
|
ec56bd0d7d | ||
|
|
460397dacb | ||
|
|
1fd41e693a | ||
|
|
80640642cc | ||
|
|
7b30895aca | ||
|
|
c6e2704175 | ||
|
|
c472ce2a91 |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 195 KiB |
2529
package-lock.json
generated
78
package.json
|
|
@ -50,63 +50,63 @@
|
|||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@fontsource/dm-sans": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@fullcalendar/daygrid": "^6.1.21",
|
||||
"@fullcalendar/interaction": "^6.1.21",
|
||||
"@fullcalendar/react": "^6.1.21",
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@generouted/react-router": "^1.20.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@sentry/react": "10.75.1",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.103.1",
|
||||
"@tanstack/react-query": "5.103.1",
|
||||
"@tanstack/react-query-devtools": "5.103.1",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^2.0.2",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"ky": "^2.1.0",
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-router": "^8.2.0",
|
||||
"react-router": "^8.4.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@commitlint/cli": "^21.2.3",
|
||||
"@commitlint/config-conventional": "^21.2.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.7.0",
|
||||
"@sentry/cli": "3.8.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"prettier": "^3.9.5",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"lint-staged": "^17.5.1",
|
||||
"prettier": "^3.9.8",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
|
|
|
|||
|
|
@ -10,11 +10,21 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export const REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE =
|
||||
"Your role can't request uplifts on this work order.";
|
||||
|
||||
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
|
||||
if (status === 401) {
|
||||
return "You are not authorized to access this page.";
|
||||
}
|
||||
if (status === 403) {
|
||||
if (data && typeof data === "object") {
|
||||
const record = data as Record<string, unknown>;
|
||||
const message = record.message ?? record.Message ?? record.error;
|
||||
if (message === REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE) {
|
||||
return REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE;
|
||||
}
|
||||
}
|
||||
return "You do not have permission to perform this action.";
|
||||
}
|
||||
if (status === 404) {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ export function WoTableRowServiceCells({
|
|||
value={row.completedDate}
|
||||
q={search}
|
||||
onSave={(date) => onPatchRow({ completedDate: date })}
|
||||
onClear={
|
||||
row.completedDate && !viewOnly ? () => onPatchRow({ completedDate: "" }) : undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { Link, useLocation } from "react-router";
|
|||
import { Box, Drawer } from "@mui/material";
|
||||
|
||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||
import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
|
||||
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
||||
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
||||
|
||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||
if (isActive) {
|
||||
|
|
@ -129,6 +130,7 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
|||
const width = getSidebarWidth(collapsed);
|
||||
const { pathname } = useLocation();
|
||||
const activePath = getActiveNavPath(pathname);
|
||||
const navGroups = useNavGroups();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
|
|
|
|||
20
src/components/layout/use-nav-groups.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||
import { getNavGroups, type NavGroup } from "@/config/menu";
|
||||
|
||||
/**
|
||||
* Sidebar groups for the signed-in user. The APPROVALS group appears only
|
||||
* when the backend's can-approve answer (UpliftService tier-role rule, the
|
||||
* same authority behind uplift approve/reject) is true for either tier.
|
||||
* Loading and error states stay closed so the entry never flashes for a
|
||||
* user the backend would refuse, including when a refetch fails and the
|
||||
* query still holds an earlier `true`.
|
||||
*/
|
||||
export function useNavGroups(): NavGroup[] {
|
||||
const tier1 = useUpliftsCanApprove(1);
|
||||
const tier2 = useUpliftsCanApprove(2);
|
||||
const allows = (query: { data?: boolean; isError?: boolean }) =>
|
||||
query.data === true && !query.isError;
|
||||
const canReviewUplifts = allows(tier1) || allows(tier2);
|
||||
|
||||
return getNavGroups({ canReviewUplifts });
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
// BarChart2,
|
||||
BadgeDollarSign,
|
||||
BriefcaseBusiness,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
BarChart3,
|
||||
Star,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavItemDef = {
|
||||
|
|
@ -59,6 +61,10 @@ export const navGroups: NavGroup[] = [
|
|||
{ label: "Sites", icon: MapPin, path: "/locations" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "DATA MANAGEMENT",
|
||||
items: [{ label: "Services", icon: Wrench, path: "/settings/services" }],
|
||||
},
|
||||
{
|
||||
title: "TEAM",
|
||||
items: [{ label: "Team Members", icon: Users, path: "/team-members" }],
|
||||
|
|
@ -69,8 +75,21 @@ export const navGroups: NavGroup[] = [
|
|||
// },
|
||||
];
|
||||
|
||||
// Approvals navigation is gated on the same server-derived authority the
|
||||
// uplift approve/reject API enforces (UpliftService.CanApprove tier roles,
|
||||
// exposed via GET uplifts/can-approve), so the sidebar never shows the queue
|
||||
// to a user the backend would refuse.
|
||||
const approvalsNavGroup: NavGroup = {
|
||||
title: "APPROVALS",
|
||||
items: [{ label: "Uplift Approvals", icon: BadgeDollarSign, path: "/uplifts" }],
|
||||
};
|
||||
|
||||
export function getNavGroups({ canReviewUplifts }: { canReviewUplifts: boolean }): NavGroup[] {
|
||||
return canReviewUplifts ? [...navGroups, approvalsNavGroup] : navGroups;
|
||||
}
|
||||
|
||||
export function getActiveNavPath(pathname: string): string | undefined {
|
||||
return navGroups
|
||||
return [...navGroups, approvalsNavGroup]
|
||||
.flatMap((group) => group.items)
|
||||
.map((item) => item.path)
|
||||
.filter((path) =>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
|
||||
import { toast } from "react-toastify";
|
||||
import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
|
||||
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
|
||||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
|
|
@ -14,6 +16,25 @@ import type {
|
|||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
const CREATE_UPLIFT_ERROR_FALLBACK = "Failed to create uplift";
|
||||
const CREATE_UPLIFT_FIXED_STATUS_COPY = new Set([401, 403, 404, 500]);
|
||||
|
||||
function createUpliftErrorMessage(error: unknown): string {
|
||||
if (isHTTPError(error)) {
|
||||
const status = error.response.status;
|
||||
if (status === 403 && error.message === REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE) {
|
||||
return REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE;
|
||||
}
|
||||
return CREATE_UPLIFT_FIXED_STATUS_COPY.has(status)
|
||||
? mapHttpStatusToMessage(status)
|
||||
: CREATE_UPLIFT_ERROR_FALLBACK;
|
||||
}
|
||||
if (isNetworkError(error) || isTimeoutError(error)) {
|
||||
return error.message;
|
||||
}
|
||||
return CREATE_UPLIFT_ERROR_FALLBACK;
|
||||
}
|
||||
|
||||
function invalidateUpliftQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workOrderId: string | number,
|
||||
|
|
@ -48,7 +69,7 @@ export function useCreateWorkOrderUplift(
|
|||
toast.success("Uplift request created");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create uplift");
|
||||
toast.error(createUpliftErrorMessage(error));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import ky from "ky";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizeApiRequestError } from "@/api/api-error";
|
||||
import { normalizeApiRequestError, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
|
||||
|
||||
const RESOURCE_URL = "https://example.test/api/resource";
|
||||
|
||||
|
|
@ -29,6 +29,32 @@ describe("normalizeApiRequestError", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("preserves only the allowlisted public denial for 403 responses", async () => {
|
||||
const allowedFetch = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ message: REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
) as unknown as typeof fetch;
|
||||
const unrelatedFetch = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "accountId 42 is forbidden" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await expect(createClient(allowedFetch).get(RESOURCE_URL).json()).rejects.toThrow(
|
||||
REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE,
|
||||
);
|
||||
await expect(createClient(unrelatedFetch).get(RESOURCE_URL).json()).rejects.toThrow(
|
||||
"You do not have permission to perform this action.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a connection failure for network errors", async () => {
|
||||
const fetchImpl = vi.fn(() =>
|
||||
Promise.reject(new TypeError("fetch failed")),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 9,
|
||||
woNumber: "24818000001",
|
||||
site: "BK5",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-15",
|
||||
completedDate: "2026-07-20",
|
||||
targetWeek: "2026-07-15",
|
||||
dayGroup: "07/15",
|
||||
dayLabel: "Tue",
|
||||
location: "Lobby",
|
||||
locationId: "loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "7",
|
||||
company: "Acme",
|
||||
tech: "Sam",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "09:00 – 10:00",
|
||||
status: "Incomplete",
|
||||
docStatus: "Yes",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "2026-07-22",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
isAddOn: false,
|
||||
mediaCount: 1,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
additionalContacts: [],
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const handlers: WoTableRowHandlers = {
|
||||
onRowClick: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch: vi.fn(),
|
||||
onScheduleDate: vi.fn(),
|
||||
onScheduleWeek: vi.fn(),
|
||||
onScheduleRange: vi.fn(),
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenCompDoc: vi.fn(),
|
||||
onOpenUplifts: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
onDrop: vi.fn(),
|
||||
onDragEnd: vi.fn(),
|
||||
};
|
||||
|
||||
function renderCells(row: WorkOrderTableRow, viewOnly = false) {
|
||||
const onPatchRow = vi.fn();
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<WoTableRowServiceCells
|
||||
row={row}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
viewOnly={viewOnly}
|
||||
handlers={handlers}
|
||||
onPatchRow={onPatchRow}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
return onPatchRow;
|
||||
}
|
||||
|
||||
describe("WoTableRowServiceCells completed date Clear (SH-390)", () => {
|
||||
it("shows Clear in the completed-date editor and patches completedDate to empty", async () => {
|
||||
const onPatchRow = renderCells(makeRow());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /07-20/ }));
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /clear/i }));
|
||||
|
||||
expect(onPatchRow).toHaveBeenCalledWith({ completedDate: "" });
|
||||
});
|
||||
|
||||
it("offers no Clear when the completed date is already empty", async () => {
|
||||
renderCells(makeRow({ completedDate: "" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "—" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "20" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /clear/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no Clear on a Completed (view-only) row", async () => {
|
||||
renderCells(makeRow({ status: "Completed" }), true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /07-20/ }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "20" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /clear/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 9,
|
||||
woNumber: "24818000001",
|
||||
site: "BK5",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-15",
|
||||
completedDate: "2026-07-20",
|
||||
targetWeek: "2026-07-15",
|
||||
dayGroup: "07/15",
|
||||
dayLabel: "Tue",
|
||||
location: "Lobby",
|
||||
locationId: "loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "7",
|
||||
company: "Acme",
|
||||
tech: "Sam",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "09:00 – 10:00",
|
||||
status: "Incomplete",
|
||||
docStatus: "Yes",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "2026-07-22",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
isAddOn: false,
|
||||
mediaCount: 1,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
additionalContacts: [],
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHandlers(): WoTableRowHandlers {
|
||||
return {
|
||||
onRowClick: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch: vi.fn(),
|
||||
onScheduleDate: vi.fn(),
|
||||
onScheduleWeek: vi.fn(),
|
||||
onScheduleRange: vi.fn(),
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenCompDoc: vi.fn(),
|
||||
onOpenUplifts: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
onDrop: vi.fn(),
|
||||
onDragEnd: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(row: WorkOrderTableRow, handlers: WoTableRowHandlers) {
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<WoTableRow
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={["HVAC"]}
|
||||
isSelected={false}
|
||||
isFlashing={false}
|
||||
dragViz={{ dragId: null, overId: null }}
|
||||
handlers={handlers}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
describe("WoTableRow completed date Clear (SH-390)", () => {
|
||||
it("clears the completed date of an open row through the board patch", async () => {
|
||||
const handlers = makeHandlers();
|
||||
renderRow(makeRow(), handlers);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /07-20/ }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /clear/i }));
|
||||
|
||||
expect(handlers.onPatch).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(handlers.onPatch).mock.calls[0].slice(0, 2)).toEqual([
|
||||
9,
|
||||
{ completedDate: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["Completed", "Canceled"] as const)("offers no Clear on a %s row", async (status) => {
|
||||
const handlers = makeHandlers();
|
||||
renderRow(makeRow({ status }), handlers);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /07-20/ }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "20" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /clear/i })).not.toBeInTheDocument();
|
||||
expect(handlers.onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { toast } from "react-toastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
|
||||
import { REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
const row = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
type: "PM",
|
||||
status: "Scheduled",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function respondToCreate(status: number, message: string) {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
if (request.method === "POST") {
|
||||
return new Response(JSON.stringify({ code: "Forbidden", message }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "Success", data: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
async function submitInternalCreate(status: number, serverMessage: string) {
|
||||
const fetchMock = respondToCreate(status, serverMessage);
|
||||
renderWithProviders(<WorkOrderUpliftsDialog row={row} open onClose={vi.fn()} />, {
|
||||
withAuth: true,
|
||||
});
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Amount"), { target: { value: "125" } });
|
||||
fireEvent.change(screen.getByLabelText("Notes"), { target: { value: "Additional work" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create uplift" }));
|
||||
|
||||
await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled());
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input, init]) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
return (
|
||||
request.method === "POST" &&
|
||||
new URL(request.url).pathname.endsWith("/api/workorders/42/uplifts")
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
return vi.mocked(toast.error).mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe("internal work-order uplift create error copy", () => {
|
||||
it("shows the exact role denial for a 403", async () => {
|
||||
const message = await submitInternalCreate(403, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE);
|
||||
|
||||
expect(message).toBe(REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE);
|
||||
});
|
||||
|
||||
it("keeps unrelated 403 permission denials generic", async () => {
|
||||
const message = await submitInternalCreate(403, "accountId 42 is forbidden");
|
||||
|
||||
expect(message).toBe("You do not have permission to perform this action.");
|
||||
});
|
||||
|
||||
it("keeps unknown non-403 server diagnostics generic", async () => {
|
||||
const diagnostic = "SqlException: workOrderId 42 row rejected";
|
||||
const message = await submitInternalCreate(422, diagnostic);
|
||||
|
||||
expect(message).toBe("Failed to create uplift");
|
||||
expect(message).not.toContain(diagnostic);
|
||||
});
|
||||
});
|
||||
58
src/test/components/layout/app-sidebar-can-approve.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { createTestQueryClient, renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const canApproveApiMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
|
||||
upliftsApi: { canApprove: canApproveApiMock },
|
||||
}));
|
||||
|
||||
describe("AppSidebar Uplift Approvals gating through the can-approve query", () => {
|
||||
beforeEach(() => {
|
||||
canApproveApiMock.mockReset();
|
||||
});
|
||||
|
||||
it("shows the entry when the API allows tier 2 and fails for tier 1", async () => {
|
||||
canApproveApiMock.mockImplementation(async (tier: number) => {
|
||||
if (tier === 1) throw new Error("503");
|
||||
return true;
|
||||
});
|
||||
|
||||
renderWithProviders(<AppSidebar collapsed={false} />, { route: "/", withAuth: false });
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Uplift Approvals" })).toHaveAttribute(
|
||||
"href",
|
||||
"/uplifts",
|
||||
);
|
||||
expect(canApproveApiMock).toHaveBeenCalledWith(1);
|
||||
expect(canApproveApiMock).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("keeps the entry hidden once the API has denied both tiers", async () => {
|
||||
canApproveApiMock.mockResolvedValue(false);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
renderWithProviders(<AppSidebar collapsed={false} />, {
|
||||
route: "/",
|
||||
withAuth: false,
|
||||
queryClient,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
for (const tier of [1, 2]) {
|
||||
expect(queryClient.getQueryState(queryKeys.uplifts.canApprove(tier))?.status).toBe(
|
||||
"success",
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(screen.queryByRole("link", { name: "Uplift Approvals" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/services",
|
||||
);
|
||||
});
|
||||
});
|
||||
115
src/test/components/layout/app-sidebar-nav.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const canApproveMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||
useUpliftsCanApprove: canApproveMock,
|
||||
}));
|
||||
|
||||
function renderSidebar(route: string) {
|
||||
return renderWithProviders(<AppSidebar collapsed={false} />, {
|
||||
route,
|
||||
withAuth: false,
|
||||
});
|
||||
}
|
||||
|
||||
function sidebarLinks(): Record<string, string> {
|
||||
const nav = document.getElementById("primary-navigation");
|
||||
const links = Array.from(within(nav as HTMLElement).getAllByRole("link"));
|
||||
return Object.fromEntries(
|
||||
links.map((link) => [link.textContent ?? "", link.getAttribute("href") ?? ""]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("AppSidebar navigation entries", () => {
|
||||
it("shows Uplift Approvals for a user the backend says can approve uplifts", () => {
|
||||
canApproveMock.mockReturnValue({ data: true });
|
||||
|
||||
renderSidebar("/");
|
||||
const links = sidebarLinks();
|
||||
|
||||
expect(links["Uplift Approvals"]).toBe("/uplifts");
|
||||
});
|
||||
|
||||
it("hides Uplift Approvals when the backend says the user cannot approve uplifts", () => {
|
||||
canApproveMock.mockReturnValue({ data: false });
|
||||
|
||||
renderSidebar("/");
|
||||
const links = sidebarLinks();
|
||||
|
||||
expect(links["Uplift Approvals"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides Uplift Approvals while the backend answer is still loading", () => {
|
||||
canApproveMock.mockReturnValue({ data: undefined });
|
||||
|
||||
renderSidebar("/");
|
||||
const links = sidebarLinks();
|
||||
|
||||
expect(links["Uplift Approvals"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, "tier 1"],
|
||||
[2, "tier 2"],
|
||||
])("shows Uplift Approvals when only %s approval is allowed (%s)", (allowedTier) => {
|
||||
canApproveMock.mockImplementation((tier: number) => ({ data: tier === allowedTier }));
|
||||
|
||||
renderSidebar("/");
|
||||
const links = sidebarLinks();
|
||||
|
||||
expect(links["Uplift Approvals"]).toBe("/uplifts");
|
||||
expect(canApproveMock).toHaveBeenCalledWith(1);
|
||||
expect(canApproveMock).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("hides Uplift Approvals when the can-approve request fails", () => {
|
||||
canApproveMock.mockReturnValue({ data: undefined, isError: true, error: new Error("403") });
|
||||
|
||||
renderSidebar("/");
|
||||
const links = sidebarLinks();
|
||||
|
||||
expect(links["Uplift Approvals"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides Uplift Approvals when a refetch fails after an earlier approval", () => {
|
||||
canApproveMock.mockReturnValue({ data: true, isError: true, error: new Error("403") });
|
||||
|
||||
renderSidebar("/");
|
||||
const links = sidebarLinks();
|
||||
|
||||
expect(links["Uplift Approvals"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers Services to every user", () => {
|
||||
canApproveMock.mockReturnValue({ data: false });
|
||||
|
||||
renderSidebar("/");
|
||||
const links = sidebarLinks();
|
||||
|
||||
expect(links["Services"]).toBe("/settings/services");
|
||||
});
|
||||
|
||||
it("highlights the Uplift Approvals entry on /uplifts", () => {
|
||||
canApproveMock.mockReturnValue({ data: true });
|
||||
|
||||
renderSidebar("/uplifts");
|
||||
|
||||
expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights the Services entry on /settings/services", () => {
|
||||
canApproveMock.mockReturnValue({ data: false });
|
||||
|
||||
renderSidebar("/settings/services");
|
||||
|
||||
expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getActiveNavPath } from "@/config/menu";
|
||||
import { getActiveNavPath, getNavGroups, navGroups } from "@/config/menu";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("getActiveNavPath", () => {
|
||||
|
|
@ -10,6 +10,9 @@ describe("getActiveNavPath", () => {
|
|||
["/vendors/insights", "/vendors/insights"],
|
||||
["/vendors/preferences", "/vendors/preferences"],
|
||||
["/vendor-pos/42", "/vendor-pos"],
|
||||
["/settings/services", "/settings/services"],
|
||||
["/uplifts", "/uplifts"],
|
||||
["/uplifts/12", "/uplifts"],
|
||||
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
|
||||
expect(getActiveNavPath(pathname)).toBe(expectedPath);
|
||||
});
|
||||
|
|
@ -17,4 +20,39 @@ describe("getActiveNavPath", () => {
|
|||
it("does not match partial route segments", () => {
|
||||
expect(getActiveNavPath("/vendor-posts")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not highlight the Services entry from other settings tabs", () => {
|
||||
expect(getActiveNavPath("/settings/dropdowns")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("navGroups", () => {
|
||||
it("always offers Services in a DATA MANAGEMENT group", () => {
|
||||
const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT");
|
||||
expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services");
|
||||
});
|
||||
|
||||
it("does not include the approvals group by default", () => {
|
||||
expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNavGroups", () => {
|
||||
it("adds the APPROVALS group with Uplift Approvals for uplift reviewers", () => {
|
||||
const groups = getNavGroups({ canReviewUplifts: true });
|
||||
const approvals = groups.find((group) => group.title === "APPROVALS");
|
||||
expect(approvals?.items.map((item) => item.path)).toEqual(["/uplifts"]);
|
||||
expect(approvals?.items[0]?.label).toBe("Uplift Approvals");
|
||||
});
|
||||
|
||||
it("omits the APPROVALS group for users who cannot review uplifts", () => {
|
||||
const groups = getNavGroups({ canReviewUplifts: false });
|
||||
expect(groups.some((group) => group.title === "APPROVALS")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the approvals group after TEAM", () => {
|
||||
const groups = getNavGroups({ canReviewUplifts: true });
|
||||
const titles = groups.map((group) => group.title);
|
||||
expect(titles.indexOf("TEAM")).toBeLessThan(titles.indexOf("APPROVALS"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ describe("COMPLETED DATE board column wiring", () => {
|
|||
expect(ops).toEqual([{ field: "completedDate", value: "2026-07-30" }]);
|
||||
});
|
||||
|
||||
it("expands a clearing completedDate patch to an empty-string board field op (SH-390)", () => {
|
||||
const ops = expandScalarBoardOps({ completedDate: "" });
|
||||
expect(ops).toEqual([{ field: "completedDate", value: "" }]);
|
||||
});
|
||||
|
||||
it("maps completedDate returned by a board GET row", () => {
|
||||
const row = mapWorkOrderTableRow({
|
||||
id: 42,
|
||||
|
|
|
|||