diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png index 45c3c047..8a7a6caf 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/work-orders/completion-uploads-mobile.spec.ts b/e2e/work-orders/completion-uploads-mobile.spec.ts new file mode 100644 index 00000000..a8fde88f --- /dev/null +++ b/e2e/work-orders/completion-uploads-mobile.spec.ts @@ -0,0 +1,181 @@ +import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test"; + +// Completion uploads from a mobile-sized viewport must reach the API as +// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category. +test.use({ ...devices["Pixel 7"] }); + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; +const ROW_VERSION = "AAAAAAAAB9E="; + +const TEST_USER = { + token: "wo-completion-uploads-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +// Leading bytes of files written by real encoders (ffmpeg, iPhone .MOV, pdf). +const PDF = Buffer.from("%PDF-1.7\n%âã\n1 0 obj\n", "latin1"); +const JPEG = Buffer.from("ffd8ffe000104a46494600010200000100010000", "hex"); +const MP4 = Buffer.from("000000206674797069736f6d0000020069736f6d69736f32", "hex"); +const MOV = Buffer.from("000000146674797071742020000002007174202000000008", "hex"); + +type BoardRowOptions = { id: number; woNumber: string; workOrderType: number }; + +function boardRow({ id, woNumber, workOrderType }: BoardRowOptions) { + return { + id, + woNumber, + workOrderType, + siteCode: "BK5", + lifecycleStatusLabel: "In Progress", + scheduledDate: "2026-08-19", + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", + docStatus: "No", + rowVersion: ROW_VERSION, + }; +} + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function multipartBoundary(request: Request): string | undefined { + const contentType = request.headers()["content-type"] ?? ""; + return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1]; +} + +async function hideQueryDevtools(page: Page) { + await page.addStyleTag({ + content: + ".tsqd-parent-container { display: none !important; pointer-events: none !important; }", + }); + await page.locator(".tsqd-parent-container").evaluateAll((nodes) => { + for (const node of nodes) { + node.remove(); + } + }); +} + +async function mockBoard(page: Page, row: ReturnType) { + await page.route("**/api/**", (route) => { + const pathname = new URL(route.request().url()).pathname; + if (!pathname.startsWith("/api/")) return route.fallback(); + return fulfillJson(route, { data: [] }); + }); + await page.route("**/api/workorders/board*", (route) => { + if (route.request().url().includes("board/search")) return route.fallback(); + return fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [row], + }); + }); + await page.route("**/api/workorders/board/search*", (route) => + fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }), + ); +} + +async function openCompletionTab(page: Page, woNumber: string) { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + await page.goto("/workorders"); + await hideQueryDevtools(page); + const row = page.getByRole("row").filter({ hasText: woNumber }); + await row.getByRole("button", { name: "View details" }).click(); + const panel = page.getByRole("dialog", { name: "Work order details" }); + await expect(panel).toBeVisible({ timeout: 15_000 }); + await panel.getByRole("tab", { name: "Completion Doc" }).click(); + return panel; +} + +test.describe("Completion uploads on a mobile viewport", () => { + test("uploads a signed PDF as multipart with the file and work order version", async ({ + page, + }) => { + const row = boardRow({ id: 71, woNumber: "PM-UPLOAD-71", workOrderType: 2 }); + await mockBoard(page, row); + await page.route("**/api/workorders/71/detail", (route) => + fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }), + ); + let upload: Request | undefined; + await page.route("**/api/workorders/71/completion-doc", (route) => { + upload = route.request(); + return fulfillJson(route, { docStatus: "Yes", signOffAttachment: "/Assets/Documents/x.pdf" }); + }); + + const panel = await openCompletionTab(page, row.woNumber); + const chooser = page.waitForEvent("filechooser"); + await panel.getByRole("button", { name: "Upload signed PDF" }).click(); + await ( + await chooser + ).setFiles({ name: "Signed Completion.pdf", mimeType: "application/pdf", buffer: PDF }); + + await expect.poll(() => upload?.method()).toBe("POST"); + const boundary = multipartBoundary(upload!); + expect(boundary, upload!.headers()["content-type"]).toBeTruthy(); + const body = upload!.postDataBuffer()?.toString("latin1") ?? ""; + expect(body).toContain(`--${boundary}`); + expect(body).toContain('name="file"; filename="Signed Completion.pdf"'); + expect(body).toContain("%PDF-1.7"); + expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`)); + }); + + test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => { + const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 }); + const media: { id: number; category: number; url: string; fileName: string }[] = []; + await mockBoard(page, row); + await page.route("**/api/workorders/72/detail", (route) => + fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }), + ); + const uploads: { contentType: string; body: string }[] = []; + await page.route("**/api/workorders/72/media", (route) => { + const request = route.request(); + const body = request.postDataBuffer()?.toString("latin1") ?? ""; + uploads.push({ contentType: request.headers()["content-type"] ?? "", body }); + const fileName = /filename="([^"]+)"/.exec(body)?.[1] ?? "file"; + const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName }; + media.push(item); + return fulfillJson(route, item); + }); + let categorize: Request | undefined; + await page.route("**/api/workorders/72/media/*", (route) => { + categorize = route.request(); + const item = media[0]!; + return fulfillJson(route, { ...item, category: 1 }); + }); + + const panel = await openCompletionTab(page, row.woNumber); + const chooser = page.waitForEvent("filechooser"); + await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click(); + await ( + await chooser + ).setFiles([ + { name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG }, + { name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 }, + { name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV }, + ]); + + await expect.poll(() => uploads.length).toBe(3); + for (const [index, name] of ["IMG_0001.jpg", "VID_0002.MP4", "IMG_1587.MOV"].entries()) { + expect(uploads[index]!.contentType).toMatch(/^multipart\/form-data; boundary=/); + expect(uploads[index]!.body).toContain(`filename="${name}"`); + } + + await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before"); + await expect.poll(() => categorize?.method()).toBe("PATCH"); + const boundary = multipartBoundary(categorize!); + expect(boundary, categorize!.headers()["content-type"]).toBeTruthy(); + const body = categorize!.postDataBuffer()?.toString("latin1") ?? ""; + expect(body).toMatch(/name="category"\r\n\r\nBefore/); + expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`)); + }); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index ba2c2745..303d9711 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -81,6 +81,7 @@ export const API_PATHS = { teamMember: { getList: "User", create: "team-members", + detail: "team-members", }, contact: { getList: "Contact/GetContactList", diff --git a/src/api/api.ts b/src/api/api.ts index 147c0571..e85e8e18 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -11,28 +11,22 @@ function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); } -/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */ -export function shouldStripJsonContentType(body: unknown): boolean { - return body instanceof FormData; -} - export const api: KyInstance = ky.create({ prefix: resolveApiPrefixUrl(env.apiUrl), + // hazard: no instance-wide Content-Type. Ky sets application/json for `json` bodies, and the + // runtime must set `multipart/form-data; boundary=…` for FormData while it builds the Request. + // A default here is baked into that Request; deleting it in a hook leaves the multipart body + // with no Content-Type at all, so ASP.NET cannot bind [FromForm] fields. headers: { Accept: "application/json", - "Content-Type": "application/json", }, hooks: { beforeRequest: [ - ({ request, options }) => { + ({ request }) => { const token = getAccessToken(); if (token) { request.headers.set("Authorization", token); } - // Let the runtime set multipart boundary; default JSON Content-Type breaks FormData. - if (shouldStripJsonContentType(options.body)) { - request.headers.delete("Content-Type"); - } }, ], beforeError: [({ error }) => normalizeApiRequestError(error)], diff --git a/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx index 8ee4ac73..ef9064df 100644 --- a/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx +++ b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx @@ -29,158 +29,34 @@ import type { CreateTeamMemberInput, TeamMemberRole, } from "@/domain/team-members/types/team-member"; +import { + defaultsForRole, + getFormErrors, + PERMISSION_GROUPS, + PERMISSION_LABELS, + permissionOverrides, +} from "./team-member-permissions"; +import { TEAM_MEMBER_COLORS, TEAM_MEMBER_ROLES } from "./team-member-form-options"; -const ROLES: TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"]; -const COLORS = [ - "#F59E0B", - "#0D9488", - "#6366F1", - "#F43F5E", - "#7C3AED", - "#0EA5E9", - "#16A34A", - "#EA580C", - "#0891B2", - "#C026D3", -] as const; - -const PERMISSION_GROUPS = [ - { - label: "Team Members", - keys: ["manageTeamMembers", "changeTeamMemberRole"], - }, - { - label: "Vendors", - keys: ["createVendors", "editVendors", "deactivateVendors"], - }, - { - label: "Sites", - keys: ["createSites", "editSites", "deleteSites"], - }, - { - label: "Services", - keys: ["createServices", "editServices", "deactivateServices"], - }, - { - label: "Completion Document Templates", - keys: [ - "createCompletionDocTemplates", - "editCompletionDocTemplates", - "deleteCompletionDocTemplates", - ], - }, - { - label: "Work Orders", - keys: ["createWorkOrders", "editOthersWorkOrders", "cancelWorkOrders", "deleteWorkOrders"], - }, - { - label: "Uplifts", - keys: ["requestUplifts", "autoApproveUplifts", "reviewUplifts"], - }, - { - label: "Dashboard", - keys: ["viewAllDispatchersOnDashboard"], - }, -] as const; - -const PERMISSION_LABELS: Record = { - manageTeamMembers: "Manage team members", - changeTeamMemberRole: "Change team member role", - createVendors: "Create vendors", - editVendors: "Edit vendors", - deactivateVendors: "Deactivate vendors", - createSites: "Create sites", - editSites: "Edit sites", - deleteSites: "Delete sites", - createServices: "Create services", - editServices: "Edit services", - deactivateServices: "Deactivate services", - createCompletionDocTemplates: "Create completion document templates", - editCompletionDocTemplates: "Edit completion document templates", - deleteCompletionDocTemplates: "Delete completion document templates", - createWorkOrders: "Create work orders", - editOthersWorkOrders: "Edit other users' work orders", - cancelWorkOrders: "Cancel work orders", - deleteWorkOrders: "Delete work orders", - requestUplifts: "Request uplifts", - autoApproveUplifts: "Automatically approve uplifts", - reviewUplifts: "Review uplifts", - viewAllDispatchersOnDashboard: "View all Dispatchers on dashboard", -}; - -const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); -const DISPATCHER_DEFAULTS = new Set([ - "createVendors", - "editVendors", - "deactivateVendors", - "createSites", - "editSites", - "createWorkOrders", - "editOthersWorkOrders", - "cancelWorkOrders", - "requestUplifts", - "autoApproveUplifts", -]); -const SCHEDULER_DEFAULTS = new Set([ - ...Array.from(DISPATCHER_DEFAULTS).filter( - (key) => key !== "requestUplifts" && key !== "autoApproveUplifts", - ), - "deleteSites", - "createServices", - "editServices", - "createCompletionDocTemplates", - "editCompletionDocTemplates", - "viewAllDispatchersOnDashboard", -]); - -function defaultsForRole(role: TeamMemberRole): Set { - if (role === "Admin") return new Set(ALL_PERMISSION_KEYS); - return role === "Scheduler" ? new Set(SCHEDULER_DEFAULTS) : new Set(DISPATCHER_DEFAULTS); -} +const LEGACY_ROLE_OPTION = "__legacy-role__"; function emptyForm() { return { name: "", - role: "Dispatcher" as TeamMemberRole, + role: "Dispatcher" as TeamMemberRole | "", color: "", + colorNotice: "", email: "", phone: "", + active: true, serviceAreas: [] as string[], permissions: defaultsForRole("Dispatcher"), }; } -function permissionOverrides(role: TeamMemberRole, selected: Set) { - const defaults = defaultsForRole(role); - return Object.fromEntries( - ALL_PERMISSION_KEYS.filter((key) => defaults.has(key) !== selected.has(key)).map((key) => [ - key, - selected.has(key) ? 1 : 2, - ]), - ) as Record; -} - -type TeamMemberForm = ReturnType; +export type TeamMemberForm = ReturnType; type FormErrors = ReturnType; -function getFormErrors(form: TeamMemberForm, submitted: boolean, emailIsValid: boolean) { - return { - name: submitted && !form.name.trim() ? "Name is required." : "", - role: submitted && !form.role ? "Role is required." : "", - color: submitted && !form.color ? "Color is required." : "", - email: - submitted && !form.email.trim() - ? "Email is required." - : submitted && !emailIsValid - ? "Enter a valid email address." - : "", - serviceAreas: - submitted && form.role === "Dispatcher" && form.serviceAreas.length === 0 - ? "Select at least one service area." - : "", - }; -} - function PermissionsSection({ form, togglePermission, @@ -223,13 +99,62 @@ function PermissionsSection({ ); } -function TeamMemberFormFields({ +function RoleSelectField({ + role, + roleError, + updateRole, + disabled, + legacyRole, +}: { + role: TeamMemberRole | ""; + roleError: string; + updateRole: (role: TeamMemberRole) => void; + disabled: boolean; + legacyRole: string; +}) { + const legacyRolePending = Boolean(legacyRole) && !role; + + return ( + + Role + + {legacyRolePending && ( + + {legacyRole} is a legacy role and can no longer be saved. Choose Dispatcher, Scheduler, or + Admin. + + )} + {!legacyRolePending && Boolean(roleError) && {roleError}} + + ); +} + +export function TeamMemberFormFields({ form, setForm, errors, updateRole, toggleArea, togglePermission, + roleDisabled = false, + legacyRole = "", }: { form: TeamMemberForm; setForm: Dispatch>; @@ -237,6 +162,8 @@ function TeamMemberFormFields({ updateRole: (role: TeamMemberRole) => void; toggleArea: (area: string) => void; togglePermission: (key: string) => void; + roleDisabled?: boolean; + legacyRole?: string; }) { return ( @@ -250,35 +177,26 @@ function TeamMemberFormFields({ required fullWidth /> - - Role - - {Boolean(errors.role) && {errors.role}} - + Color - {COLORS.map((color) => ( + {TEAM_MEMBER_COLORS.map((color) => ( + + + + ) : ( + + + + )} + + ); +} + +function useTeamMemberEditor( + currentMember: TeamMemberDetail | undefined, + memberId: string | number | undefined, +) { + const [editing, setEditing] = useState(false); + const [form, setForm] = useState(emptyForm); + const [submitted, setSubmitted] = useState(false); + const updateTeamMember = useUpdateTeamMember(); + const currentMemberRef = useRef(currentMember); + + useEffect(() => { + currentMemberRef.current = currentMember; + }, [currentMember]); + + useEffect(() => { + const member = currentMemberRef.current; + if (member) { + setForm(formFromMember(member)); + setEditing(false); + setSubmitted(false); + } + }, [currentMember?.id]); + + useEffect(() => { + setEditing(false); + setSubmitted(false); + }, [memberId]); + + const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim()); + const errors = useMemo( + () => getFormErrors(form, submitted, emailIsValid), + [emailIsValid, form, submitted], + ); + const isValid = + Boolean(form.name.trim()) && + Boolean(form.role) && + Boolean(form.color) && + emailIsValid && + (form.role !== "Dispatcher" || form.serviceAreas.length > 0); + const protectedOwner = currentMember?.isAccountOwner === true; + const legacyRole = currentMember && !canonicalRole(currentMember.role) ? currentMember.role : ""; + + const updateRole = (role: TeamMemberRole) => { + setForm((current) => ({ + ...current, + role, + serviceAreas: [], + permissions: defaultsForRole(role), + })); + }; + + const toggleArea = (area: string) => { + setForm((current) => ({ + ...current, + serviceAreas: current.serviceAreas.includes(area) + ? current.serviceAreas.filter((value) => value !== area) + : [...current.serviceAreas, area], + })); + }; + + const togglePermission = (key: string) => { + setForm((current) => { + const permissions = new Set(current.permissions); + if (permissions.has(key)) permissions.delete(key); + else permissions.add(key); + return { ...current, permissions }; + }); + }; + + const save = () => { + setSubmitted(true); + if (!isValid || !currentMember || form.role === "") return; + + updateTeamMember.mutate( + { + id: currentMember.id, + input: { + name: form.name.trim(), + role: form.role, + color: form.color, + email: form.email.trim(), + phone: form.phone.trim() || undefined, + serviceAreas: form.serviceAreas, + permissionOverrides: permissionOverrides(form.role, form.permissions), + isActive: form.active, + }, + }, + { onSuccess: () => setEditing(false) }, + ); + }; + + return { + editing, + form, + setForm, + errors, + protectedOwner, + legacyRole, + isPending: updateTeamMember.isPending, + updateRole, + toggleArea, + togglePermission, + save, + cancel: () => { + if (currentMember) setForm(formFromMember(currentMember)); + setSubmitted(false); + setEditing(false); + }, + beginEdit: () => setEditing(true), + }; +} + +function DrawerHeader({ + member, + currentMember, + onClose, +}: { + member: TeamMemberListItem | null; + currentMember: TeamMemberDetail | undefined; + onClose: () => void; +}) { + return ( + <> + + + + {currentMember?.name ?? member?.name ?? "Team Member"} + + + {currentMember?.role ?? member?.role ?? ""} + + + + + + + + + ); +} + +function DrawerContent({ + detail, + currentMember, + editing, + editor, +}: { + detail: ReturnType; + currentMember: TeamMemberDetail | undefined; + editing: boolean; + editor: ReturnType; +}) { + return ( + + {Boolean(detail.isLoading) && } + {detail.error !== null && detail.error !== undefined && ( + {detail.error.message} + )} + {currentMember !== undefined && !editing && } + {currentMember !== undefined && editing && ( + + )} + + ); +} + +export function TeamMemberDetailDrawer({ + member, + onClose, +}: { + member: TeamMemberListItem | null; + onClose: () => void; +}) { + const detail = useTeamMemberDetail(member?.id ?? null); + const currentMember = detail.data; + const editor = useTeamMemberEditor(currentMember, member?.id); + + return ( + + + + + + {currentMember !== undefined && ( + + )} + + + ); +} diff --git a/src/app/(protected)/team-members/_components/team-member-form-options.ts b/src/app/(protected)/team-members/_components/team-member-form-options.ts new file mode 100644 index 00000000..3f220a37 --- /dev/null +++ b/src/app/(protected)/team-members/_components/team-member-form-options.ts @@ -0,0 +1,78 @@ +import type { TeamMemberRole } from "@/domain/team-members/types/team-member"; + +export const TEAM_MEMBER_ROLES: readonly TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"]; + +export const TEAM_MEMBER_COLORS = [ + "#F59E0B", + "#0D9488", + "#6366F1", + "#F43F5E", + "#7C3AED", + "#0EA5E9", + "#16A34A", + "#EA580C", + "#0891B2", + "#C026D3", +] as const; + +/** Returns the supported role matching the stored value (case-insensitive), or null for legacy roles. */ +export function canonicalRole(value: string): TeamMemberRole | null { + const trimmed = value.trim(); + return TEAM_MEMBER_ROLES.find((role) => role.toLowerCase() === trimmed.toLowerCase()) ?? null; +} + +function parseHexColor(value: string): [number, number, number] | null { + const match = /^#?([0-9a-f]{6})$/i.exec(value.trim()); + if (!match) return null; + const numeric = Number.parseInt(match[1], 16); + return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255]; +} + +/** Returns the palette color closest to the stored color by RGB distance, or the first palette color when unparseable. */ +export function nearestPaletteColor(value: string): string { + const target = parseHexColor(value); + if (!target) return TEAM_MEMBER_COLORS[0]; + + let nearest: string = TEAM_MEMBER_COLORS[0]; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const color of TEAM_MEMBER_COLORS) { + const [red, green, blue] = parseHexColor(color) ?? [0, 0, 0]; + const distance = (red - target[0]) ** 2 + (green - target[1]) ** 2 + (blue - target[2]) ** 2; + if (distance < nearestDistance) { + nearest = color; + nearestDistance = distance; + } + } + return nearest; +} + +export interface ResolvedFormColor { + color: string; + notice: string; +} + +/** + * Maps a stored color onto the current palette. Palette colors (any casing) are + * canonicalized; legacy colors are replaced by the nearest palette color with a + * notice so the replacement is never silent. + */ +export function resolveFormColor(stored: string): ResolvedFormColor { + const trimmed = stored.trim(); + if (!trimmed) { + return { + color: TEAM_MEMBER_COLORS[0], + notice: `No color was saved for this member; preselected ${TEAM_MEMBER_COLORS[0]} from the palette.`, + }; + } + + const canonical = TEAM_MEMBER_COLORS.find( + (color) => color.toLowerCase() === trimmed.toLowerCase(), + ); + if (canonical) return { color: canonical, notice: "" }; + + const nearest = nearestPaletteColor(trimmed); + return { + color: nearest, + notice: `Saved color ${trimmed} is not in the current palette and was replaced with the nearest palette color ${nearest}.`, + }; +} diff --git a/src/app/(protected)/team-members/_components/team-member-permissions.ts b/src/app/(protected)/team-members/_components/team-member-permissions.ts new file mode 100644 index 00000000..bd1097f5 --- /dev/null +++ b/src/app/(protected)/team-members/_components/team-member-permissions.ts @@ -0,0 +1,120 @@ +import type { TeamMemberRole } from "@/domain/team-members/types/team-member"; + +export const PERMISSION_GROUPS = [ + { label: "Team Members", keys: ["manageTeamMembers", "changeTeamMemberRole"] }, + { label: "Vendors", keys: ["createVendors", "editVendors", "deactivateVendors"] }, + { label: "Sites", keys: ["createSites", "editSites", "deleteSites"] }, + { label: "Services", keys: ["createServices", "editServices", "deactivateServices"] }, + { + label: "Completion Document Templates", + keys: [ + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", + ], + }, + { + label: "Work Orders", + keys: ["createWorkOrders", "editOthersWorkOrders", "cancelWorkOrders", "deleteWorkOrders"], + }, + { label: "Uplifts", keys: ["requestUplifts", "autoApproveUplifts", "reviewUplifts"] }, + { label: "Dashboard", keys: ["viewAllDispatchersOnDashboard"] }, +] as const; + +export const PERMISSION_LABELS: Record = { + manageTeamMembers: "Manage team members", + changeTeamMemberRole: "Change team member role", + createVendors: "Create vendors", + editVendors: "Edit vendors", + deactivateVendors: "Deactivate vendors", + createSites: "Create sites", + editSites: "Edit sites", + deleteSites: "Delete sites", + createServices: "Create services", + editServices: "Edit services", + deactivateServices: "Deactivate services", + createCompletionDocTemplates: "Create completion document templates", + editCompletionDocTemplates: "Edit completion document templates", + deleteCompletionDocTemplates: "Delete completion document templates", + createWorkOrders: "Create work orders", + editOthersWorkOrders: "Edit other users' work orders", + cancelWorkOrders: "Cancel work orders", + deleteWorkOrders: "Delete work orders", + requestUplifts: "Request uplifts", + autoApproveUplifts: "Automatically approve uplifts", + reviewUplifts: "Review uplifts", + viewAllDispatchersOnDashboard: "View all Dispatchers on dashboard", +}; + +export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); +const DISPATCHER_DEFAULTS = new Set([ + "createVendors", + "editVendors", + "deactivateVendors", + "createSites", + "editSites", + "createWorkOrders", + "editOthersWorkOrders", + "cancelWorkOrders", + "requestUplifts", + "autoApproveUplifts", +]); +const SCHEDULER_DEFAULTS = new Set([ + ...Array.from(DISPATCHER_DEFAULTS).filter( + (key) => key !== "requestUplifts" && key !== "autoApproveUplifts", + ), + "deleteSites", + "createServices", + "editServices", + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "viewAllDispatchersOnDashboard", +]); + +export function defaultsForRole(role: TeamMemberRole): Set { + if (role === "Admin") return new Set(ALL_PERMISSION_KEYS); + return role === "Scheduler" ? new Set(SCHEDULER_DEFAULTS) : new Set(DISPATCHER_DEFAULTS); +} + +export function permissionOverrides(role: TeamMemberRole, selected: Set) { + const defaults = defaultsForRole(role); + return Object.fromEntries( + ALL_PERMISSION_KEYS.filter((key) => defaults.has(key) !== selected.has(key)).map((key) => [ + key, + selected.has(key) ? 1 : 2, + ]), + ) as Record; +} + +export interface TeamMemberFormValues { + name: string; + role: TeamMemberRole | ""; + color: string; + email: string; + phone: string; + active: boolean; + serviceAreas: string[]; + permissions: Set; +} + +export function getFormErrors( + form: TeamMemberFormValues, + submitted: boolean, + emailIsValid: boolean, +) { + return { + name: submitted && !form.name.trim() ? "Name is required." : "", + role: submitted && !form.role ? "Role is required." : "", + color: submitted && !form.color ? "Color is required." : "", + email: + submitted && !form.email.trim() + ? "Email is required." + : submitted && !emailIsValid + ? "Enter a valid email address." + : "", + serviceAreas: + submitted && form.role === "Dispatcher" && form.serviceAreas.length === 0 + ? "Select at least one service area." + : "", + }; +} diff --git a/src/app/(protected)/team-members/index.tsx b/src/app/(protected)/team-members/index.tsx index 61990aa7..02e9c2ab 100644 --- a/src/app/(protected)/team-members/index.tsx +++ b/src/app/(protected)/team-members/index.tsx @@ -2,23 +2,10 @@ import { useState } from "react"; import { useDebounce } from "@/hooks/use-debounce"; import { useTeamMembersList } from "@/domain/team-members/use-cases/use-team-members-list"; import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; -import { - Alert, - Avatar, - Box, - Button, - Chip, - Drawer, - IconButton, - Stack, - Tab, - Tabs, - TextField, - Typography, -} from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; +import { Alert, Box, Button, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; import { TeamMembersTable } from "./_components/team-members-table"; import { AddTeamMemberDialog } from "./_components/add-team-member-dialog"; +import { TeamMemberDetailDrawer } from "./_components/team-member-detail-drawer"; type StatusTab = "active" | "inactive"; @@ -27,57 +14,6 @@ const STATUS_LABELS: Record = { inactive: "Inactive", }; -function MemberStatus({ status }: { status: TeamMemberListItem["status"] }) { - return ; -} - -function TeamMemberDrawer({ - member, - onClose, -}: { - member: TeamMemberListItem | null; - onClose: () => void; -}) { - return ( - - - - Team Member - - - - - {member !== null && ( - - - - {member.name.slice(0, 1).toUpperCase() || "?"} - - - {member.name || "—"} - {member.role || "—"} - - - - - Service Area: {member.serviceArea || "—"} - - - Phone: {member.phone || "—"} - - - Email: {member.email || "—"} - - - )} - - - ); -} - export default function TeamMembersListPage() { const [search, setSearch] = useState(""); const [tab, setTab] = useState("active"); @@ -177,7 +113,7 @@ export default function TeamMembersListPage() { - setSelectedMember(null)} /> + setSelectedMember(null)} /> setAddMemberOpen(false)} /> ); diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts index b9cb1455..0e53e989 100644 --- a/src/domain/team-members/api/team-members-api.ts +++ b/src/domain/team-members/api/team-members-api.ts @@ -1,11 +1,16 @@ -import { apiGet, apiPost } from "@/api/api"; +import { apiGet, apiPost, apiPut } from "@/api/api"; import { API_PATHS } from "@/api/api-paths"; import { handleApiResponse } from "@/api/handle-api-response"; -import { mapTeamMemberListItems } from "@/domain/team-members/mappers/team-member-mapper"; +import { + mapTeamMemberDetail, + mapTeamMemberListItems, +} from "@/domain/team-members/mappers/team-member-mapper"; import type { CreateTeamMemberInput, TeamMemberCreated, + TeamMemberDetail, TeamMemberListResult, + UpdateTeamMemberInput, } from "@/domain/team-members/types/team-member"; export const teamMembersApi = { @@ -19,4 +24,19 @@ export const teamMembersApi = { const data = await apiPost(API_PATHS.teamMember.create, input); return handleApiResponse(data); }, + + getDetail: async (id: string | number): Promise => { + const data = await apiGet( + `${API_PATHS.teamMember.detail}/${encodeURIComponent(String(id))}`, + ); + return mapTeamMemberDetail(handleApiResponse(data)); + }, + + update: async (id: string | number, input: UpdateTeamMemberInput): Promise => { + const data = await apiPut( + `${API_PATHS.teamMember.detail}/${encodeURIComponent(String(id))}`, + input, + ); + return mapTeamMemberDetail(handleApiResponse(data)); + }, }; diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts index 0d7d43e6..d283da28 100644 --- a/src/domain/team-members/mappers/team-member-mapper.ts +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -1,4 +1,9 @@ -import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; +import type { + TeamMemberDetail, + TeamMemberListItem, + TeamPermissionState, + TeamPermissionValue, +} from "@/domain/team-members/types/team-member"; function readString(record: Record, ...keys: string[]): string { for (const key of keys) { @@ -18,6 +23,41 @@ function readBoolean(record: Record, ...keys: string[]): boolea return false; } +function readStringArray(record: Record, ...keys: string[]): string[] { + for (const key of keys) { + const value = record[key]; + if (Array.isArray(value)) + return value.filter((item): item is string => typeof item === "string"); + if (typeof value === "string" && value.trim()) return [value]; + } + return []; +} + +function readPermissionState(value: unknown): TeamPermissionState { + if (value === 1 || value === "Allow") return 1; + if (value === 2 || value === "Deny") return 2; + return 0; +} + +function readPermissions(record: Record): TeamPermissionValue[] { + const value = record.permissions ?? record.Permissions; + if (!Array.isArray(value)) return []; + + return value.flatMap((item) => { + if (typeof item !== "object" || item === null) return []; + const permission = item as Record; + const permissionKey = readString(permission, "permissionKey", "PermissionKey"); + if (!permissionKey) return []; + return [ + { + permissionKey, + overrideState: readPermissionState(permission.overrideState ?? permission.OverrideState), + isGranted: readBoolean(permission, "isGranted", "IsGranted"), + }, + ]; + }); +} + export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem { const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; const id = record.id ?? record.Id; @@ -44,3 +84,13 @@ export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem { export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[] { return rawItems.map(mapTeamMemberListItem); } + +export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail { + const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const listItem = mapTeamMemberListItem(record); + return { + ...listItem, + serviceAreas: readStringArray(record, "serviceAreas", "ServiceAreas"), + permissions: readPermissions(record), + }; +} diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts index 30bc05e6..5b2d07c5 100644 --- a/src/domain/team-members/types/team-member.ts +++ b/src/domain/team-members/types/team-member.ts @@ -34,6 +34,30 @@ export interface TeamMemberCreated { pendingRegistration: boolean; } +export type TeamPermissionState = 0 | 1 | 2; + +export interface TeamPermissionValue { + permissionKey: string; + overrideState: TeamPermissionState; + isGranted: boolean; +} + +export interface TeamMemberDetail extends TeamMemberListItem { + serviceAreas: string[]; + permissions: TeamPermissionValue[]; +} + +export interface UpdateTeamMemberInput { + name: string; + role: TeamMemberRole; + color: string; + email: string; + phone?: string; + serviceAreas: string[]; + permissionOverrides: Record; + isActive: boolean; +} + export interface TeamMemberListResult { items: TeamMemberListItem[]; } diff --git a/src/domain/team-members/use-cases/use-team-member-detail.ts b/src/domain/team-members/use-cases/use-team-member-detail.ts new file mode 100644 index 00000000..3622ab5c --- /dev/null +++ b/src/domain/team-members/use-cases/use-team-member-detail.ts @@ -0,0 +1,45 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { + TeamMemberDetail, + UpdateTeamMemberInput, +} from "@/domain/team-members/types/team-member"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useTeamMemberDetail( + id: string | number | null, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.teamMembers.detail(id ?? ""), + queryFn: () => teamMembersApi.getDetail(id as string | number), + enabled: id !== null, + }); +} + +interface UpdateVariables { + id: string | number; + input: UpdateTeamMemberInput; +} + +export function useUpdateTeamMember(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, input }) => teamMembersApi.update(id, input), + onSuccess: (member) => { + void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all }); + void queryClient.setQueryData(queryKeys.teamMembers.detail(member.id), member); + toast.success("Team member updated."); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update team member"); + }, + }); +} diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index e2ae7411..54b333ed 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -269,6 +269,20 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M })); } +/** + * `GET workorders/{id}/detail` info serializes the board row's `MediaCount` as 0 — the detail + * DTO inherits the field but the service never sets it — so trusting it verbatim would leave the + * slide-over closability gate reporting "At least 1 photo" even after an upload. Take the larger + * of the reported count and the stored (non-legacy) attachments: a positive API count still wins, + * but a spurious (or omitted) 0 can no longer mask media that are actually present. + */ +function resolveDetailMediaCount(infoRaw: unknown, media: WorkOrderBoardMediaItem[]): number { + const record = asRecord(infoRaw); + const explicit = record.mediaCount ?? record.MediaCount; + const nonLegacyCount = media.filter((item) => !item.isLegacy).length; + return Math.max(typeof explicit === "number" ? explicit : 0, nonLegacyCount); +} + export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { const root = asRecord(raw); const infoRaw = root.info ?? root.Info ?? root; @@ -283,8 +297,10 @@ export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { : []; const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : []; + const info = mapWorkOrderTableRow(infoRaw); + return { - info: mapWorkOrderTableRow(infoRaw), + info: { ...info, mediaCount: resolveDetailMediaCount(infoRaw, media) }, completion: mapCompletion(completionRaw ?? {}), comments, audit, diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index bc4194b9..d5e49f9b 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -52,6 +52,7 @@ export const queryKeys = { teamMembers: { all: ["teamMembers"] as const, list: () => [...queryKeys.teamMembers.all, "list"] as const, + detail: (id: string | number) => [...queryKeys.teamMembers.all, "detail", id] as const, }, dashboard: { all: ["dashboard"] as const, diff --git a/src/test/api/api-formdata-content-type.test.ts b/src/test/api/api-formdata-content-type.test.ts index b914b9f0..94312aba 100644 --- a/src/test/api/api-formdata-content-type.test.ts +++ b/src/test/api/api-formdata-content-type.test.ts @@ -1,60 +1,90 @@ +// @vitest-environment node +// Node's fetch/FormData/Request follow the WHATWG rules a browser uses to derive the multipart +// Content-Type; jsdom's FormData does not, which is how a header-less upload once passed here. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { shouldStripJsonContentType } from "@/api/api"; -describe("shouldStripJsonContentType", () => { - it("returns true for FormData (multipart uploads)", () => { - const form = new FormData(); - form.append("file", new File(["x"], "a.png", { type: "image/png" })); - expect(shouldStripJsonContentType(form)).toBe(true); - }); +type Captured = { method: string; contentType: string | null; body: string }; - it("returns false for JSON body / undefined", () => { - expect(shouldStripJsonContentType(undefined)).toBe(false); - expect(shouldStripJsonContentType(JSON.stringify({ a: 1 }))).toBe(false); - expect(shouldStripJsonContentType({ a: 1 })).toBe(false); - }); -}); +const API_URL = "http://api.test/api"; +let captured: Captured[] = []; -describe("api.post FormData Content-Type", () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("does not send Content-Type: application/json when body is FormData", async () => { - let capturedContentType: string | null = null; - - globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const headers = new Headers( - init?.headers ?? (input instanceof Request ? input.headers : undefined), - ); - capturedContentType = headers.get("Content-Type"); - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, +beforeEach(() => { + captured = []; + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } })); + vi.doMock("@/lib/auth/auth-storage", () => ({ getAccessToken: () => "Bearer test-token" })); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + // Serialize exactly what would go on the wire. + const request = new Request(input, init); + captured.push({ + method: request.method, + contentType: request.headers.get("content-type"), + body: await request.text(), }); - }) as typeof fetch; + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }), + ); +}); - const { api } = await import("@/api/api"); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function lastRequest(): Captured { + const request = captured[captured.length - 1]; + if (!request) throw new Error("No request reached fetch"); + return request; +} + +describe("api multipart uploads", () => { + it("sends FormData with a multipart Content-Type that carries the body's boundary", async () => { + const { apiRequestRaw } = await import("@/api/api"); const form = new FormData(); - form.append("category", "Before"); - form.append("file", new File(["png"], "image.png", { type: "image/png" })); + form.append("file", new File(["%PDF-1.7"], "signed.pdf", { type: "application/pdf" })); + form.append("workOrderVersion", "AAAAAAAAB9E="); - await api.post("workorders/9/media", { body: form, throwHttpErrors: false }); + await apiRequestRaw("post", "workorders/10/completion-doc", { + body: form, + throwHttpErrors: false, + }); - expect(capturedContentType).not.toBe("application/json"); - // Browser sets multipart/form-data; boundary=... when Content-Type was stripped. - // In jsdom/fetch mock the runtime may leave it null or set multipart - both are OK - // as long as it is not forced JSON. - // Cast after expect(): Vitest assertion narrowing collapses string|null to never. - const contentType = capturedContentType as string | null; - if (contentType !== null) { - expect(contentType.toLowerCase()).toContain("multipart/form-data"); - } + const request = lastRequest(); + const boundary = /^multipart\/form-data; boundary=(.+)$/.exec(request.contentType ?? "")?.[1]; + expect(boundary, `Content-Type was ${String(request.contentType)}`).toBeTruthy(); + expect(request.body).toContain(`--${boundary}`); + expect(request.body).toContain('name="file"; filename="signed.pdf"'); + }); + + it("sends the multipart Content-Type for PATCH category updates and apiPostForm", async () => { + const { apiRequestRaw, apiPostForm } = await import("@/api/api"); + const category = new FormData(); + category.append("category", "Before"); + + await apiRequestRaw("patch", "workorders/10/media/5", { + body: category, + throwHttpErrors: false, + }); + expect(lastRequest().contentType).toMatch(/^multipart\/form-data; boundary=/); + + const edit = new FormData(); + edit.append("Id", "10"); + await apiPostForm("WorkOrder/EditWorkorder", edit); + expect(lastRequest().contentType).toMatch(/^multipart\/form-data; boundary=/); + }); + + it("keeps application/json for json bodies", async () => { + const { apiPost, apiPatch } = await import("@/api/api"); + + await apiPost("workorders/10/comments", { text: "hello" }); + expect(lastRequest()).toMatchObject({ + method: "POST", + contentType: "application/json", + body: '{"text":"hello"}', + }); + + await apiPatch("workorders/10/board", { field: "docStatus", value: "Yes" }); + expect(lastRequest().contentType).toBe("application/json"); }); }); diff --git a/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx b/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx new file mode 100644 index 00000000..e6dc25b1 --- /dev/null +++ b/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/test/test-utils"; +import { TeamMemberDetailDrawer } from "@/app/(protected)/team-members/_components/team-member-detail-drawer"; +import { + useTeamMemberDetail, + useUpdateTeamMember, +} from "@/domain/team-members/use-cases/use-team-member-detail"; +import type { TeamMemberDetail, TeamMemberListItem } from "@/domain/team-members/types/team-member"; + +vi.mock("@/domain/team-members/use-cases/use-team-member-detail", () => ({ + useTeamMemberDetail: vi.fn(), + useUpdateTeamMember: vi.fn(), +})); + +const member: TeamMemberListItem = { + id: "u-1", + name: "Taylor Dispatcher", + role: "Dispatcher", + serviceArea: "East", + phone: "555-0100", + email: "taylor@example.com", + color: "#F59E0B", + status: "Active", + isAccountOwner: false, + pendingRegistration: true, +}; + +const detail: TeamMemberDetail = { + ...member, + serviceAreas: ["East"], + permissions: [ + { permissionKey: "createSites", overrideState: 1, isGranted: true }, + { permissionKey: "editSites", overrideState: 2, isGranted: false }, + ], +}; + +describe("TeamMemberDetailDrawer", () => { + const mutate = vi.fn(); + + beforeEach(() => { + mutate.mockReset(); + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: detail, + isLoading: false, + error: null, + } as unknown as ReturnType); + vi.mocked(useUpdateTeamMember).mockReturnValue({ + mutate, + isPending: false, + } as unknown as ReturnType); + }); + + it("shows all member fields and grouped permission state in view mode", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0); + expect(screen.getByText("taylor@example.com")).toBeInTheDocument(); + expect(screen.getByText("East")).toBeInTheDocument(); + expect(screen.getByText("Pending registration")).toBeInTheDocument(); + expect(screen.getByText("Create sites")).toBeInTheDocument(); + expect(screen.getByText("Granted")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + }); + + it("edits fields and saves active status and permission overrides", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const name = screen.getByRole("textbox", { name: "Name" }); + await user.clear(name); + await user.type(name, "Taylor Updated"); + await user.click(screen.getByRole("switch", { name: "Active" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "u-1", + input: expect.objectContaining({ + name: "Taylor Updated", + isActive: false, + serviceAreas: ["East"], + }), + }), + expect.any(Object), + ); + }); + + it("disables role changes and deactivation for the account owner", async () => { + const owner = { ...detail, isAccountOwner: true }; + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: owner, + isLoading: false, + error: null, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + expect(screen.getByRole("combobox", { name: "Role" })).toHaveAttribute("aria-disabled", "true"); + const activeSwitch = screen.getByRole("switch", { name: "Active" }); + expect(activeSwitch).toBeDisabled(); + const tooltipTarget = activeSwitch.closest("label")?.parentElement; + if (!tooltipTarget) throw new Error("missing account-owner tooltip target"); + await user.hover(tooltipTarget); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + "The account owner cannot be deactivated.", + ), + ); + }); + + it("keeps edits when the detail query refreshes", async () => { + const user = userEvent.setup(); + const { rerender } = renderWithProviders( + , + { withAuth: false }, + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const name = screen.getByRole("textbox", { name: "Name" }); + await user.clear(name); + await user.type(name, "Taylor Updated"); + + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, phone: "555-0101" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + rerender(); + + expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Taylor Updated"); + expect(screen.getByRole("textbox", { name: "Phone" })).toHaveValue("555-0100"); + }); + + it("restores the saved detail when editing is cancelled", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const name = screen.getByRole("textbox", { name: "Name" }); + await user.clear(name); + await user.type(name, "Discarded draft"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0); + expect(screen.queryByDisplayValue("Discarded draft")).not.toBeInTheDocument(); + }); + + it("marks a legacy role in view mode", () => { + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, role: "Manager" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByText("Manager (legacy)")).toBeInTheDocument(); + }); + + it("shows a legacy role in the edit form and blocks save until a supported role is chosen", async () => { + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, role: "Manager" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const roleSelect = screen.getByRole("combobox", { name: "Role" }); + expect(roleSelect).toHaveTextContent("Manager (legacy)"); + expect( + screen.getByText(/Manager is a legacy role and can no longer be saved/), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(mutate).not.toHaveBeenCalled(); + + await user.click(roleSelect); + await user.click(screen.getByRole("option", { name: "Scheduler" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "u-1", + input: expect.objectContaining({ role: "Scheduler" }), + }), + expect.any(Object), + ); + }); + + it("preselects the nearest palette color for a legacy color and saves without a manual pick", async () => { + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, color: "#FF0000" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + expect( + screen.getByText(/Saved color #FF0000 is not in the current palette/), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select color #EA580C" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ color: "#EA580C" }), + }), + expect.any(Object), + ); + }); +}); diff --git a/src/test/app/(protected)/team-members/team-member-form-options.test.ts b/src/test/app/(protected)/team-members/team-member-form-options.test.ts new file mode 100644 index 00000000..8d9dac7f --- /dev/null +++ b/src/test/app/(protected)/team-members/team-member-form-options.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalRole, + nearestPaletteColor, + resolveFormColor, + TEAM_MEMBER_COLORS, +} from "@/app/(protected)/team-members/_components/team-member-form-options"; + +describe("canonicalRole", () => { + it("returns the canonical role for supported values", () => { + expect(canonicalRole("Dispatcher")).toBe("Dispatcher"); + expect(canonicalRole("scheduler")).toBe("Scheduler"); + expect(canonicalRole(" Admin ")).toBe("Admin"); + }); + + it("returns null for legacy and empty roles", () => { + expect(canonicalRole("Manager")).toBeNull(); + expect(canonicalRole("Supervisor")).toBeNull(); + expect(canonicalRole("")).toBeNull(); + }); +}); + +describe("nearestPaletteColor", () => { + it("returns the closest palette color by RGB distance", () => { + expect(nearestPaletteColor("#FF0000")).toBe("#EA580C"); + expect(nearestPaletteColor("#00FF00")).toBe("#16A34A"); + expect(nearestPaletteColor("#111111")).toBe("#16A34A"); + }); + + it("falls back to the first palette color for unparseable values", () => { + expect(nearestPaletteColor("not-a-color")).toBe(TEAM_MEMBER_COLORS[0]); + expect(nearestPaletteColor("")).toBe(TEAM_MEMBER_COLORS[0]); + }); +}); + +describe("resolveFormColor", () => { + it("keeps palette colors and canonicalizes their casing", () => { + expect(resolveFormColor("#F59E0B")).toEqual({ color: "#F59E0B", notice: "" }); + expect(resolveFormColor("#f59e0b")).toEqual({ color: "#F59E0B", notice: "" }); + }); + + it("replaces a legacy color with the nearest palette color and explains the replacement", () => { + const resolved = resolveFormColor("#FF0000"); + expect(resolved.color).toBe("#EA580C"); + expect(resolved.notice).toContain("#FF0000"); + expect(resolved.notice).toContain("#EA580C"); + }); + + it("preselects the first palette color when nothing was stored", () => { + const resolved = resolveFormColor(" "); + expect(resolved.color).toBe(TEAM_MEMBER_COLORS[0]); + expect(resolved.notice).not.toBe(""); + }); +}); diff --git a/src/test/domain/team-members/api/team-members-api.test.ts b/src/test/domain/team-members/api/team-members-api.test.ts index 158917f9..9fcb73de 100644 --- a/src/test/domain/team-members/api/team-members-api.test.ts +++ b/src/test/domain/team-members/api/team-members-api.test.ts @@ -3,10 +3,12 @@ import { API_PATHS } from "@/api/api-paths"; const apiGet = vi.fn(); const apiPost = vi.fn(); +const apiPut = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), apiPost: (...args: unknown[]) => apiPost(...args), + apiPut: (...args: unknown[]) => apiPut(...args), })); import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; @@ -15,6 +17,7 @@ describe("teamMembersApi", () => { beforeEach(() => { apiGet.mockReset(); apiPost.mockReset(); + apiPut.mockReset(); }); it("posts the pending team member contract without invitation fields", async () => { @@ -45,4 +48,47 @@ describe("teamMembersApi", () => { expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("invite"); expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("password"); }); + + it("gets and updates detail through the team-member detail route", async () => { + apiGet.mockResolvedValueOnce({ + data: { + id: "u/1", + name: "Taylor Dispatcher", + role: "Dispatcher", + status: "Active", + serviceAreas: ["East"], + permissions: [{ permissionKey: "createSites", overrideState: 1, isGranted: true }], + }, + }); + apiPut.mockResolvedValueOnce({ + data: { + id: "u/1", + name: "Taylor Updated", + role: "Dispatcher", + status: "Inactive", + serviceAreas: [], + permissions: [], + }, + }); + + await expect(teamMembersApi.getDetail("u/1")).resolves.toMatchObject({ + id: "u/1", + serviceAreas: ["East"], + }); + const input = { + name: "Taylor Updated", + role: "Dispatcher" as const, + color: "#F59E0B", + email: "taylor@example.com", + serviceAreas: [], + permissionOverrides: { createSites: 2 as const }, + isActive: false, + }; + await expect(teamMembersApi.update("u/1", input)).resolves.toMatchObject({ + name: "Taylor Updated", + status: "Inactive", + }); + expect(apiGet).toHaveBeenCalledWith("team-members/u%2F1"); + expect(apiPut).toHaveBeenCalledWith("team-members/u%2F1", input); + }); }); diff --git a/src/test/domain/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts index 8ef293a1..164a2ef7 100644 --- a/src/test/domain/team-members/team-member-mapper.test.ts +++ b/src/test/domain/team-members/team-member-mapper.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { mapTeamMemberListItem } from "@/domain/team-members/mappers/team-member-mapper"; +import { + mapTeamMemberDetail, + mapTeamMemberListItem, +} from "@/domain/team-members/mappers/team-member-mapper"; describe("mapTeamMemberListItem", () => { it("maps the existing User list response and derives inactive state", () => { @@ -27,3 +30,28 @@ describe("mapTeamMemberListItem", () => { }); }); }); + +describe("mapTeamMemberDetail", () => { + it("maps service areas and the full permission summary", () => { + expect( + mapTeamMemberDetail({ + Id: "u1", + Name: "Taylor", + Role: "Scheduler", + Status: "Active", + ServiceAreas: ["Central", "West"], + Permissions: [ + { PermissionKey: "createSites", OverrideState: 2, IsGranted: false }, + { PermissionKey: "editSites", OverrideState: "Allow", IsGranted: true }, + ], + }), + ).toMatchObject({ + id: "u1", + serviceAreas: ["Central", "West"], + permissions: [ + { permissionKey: "createSites", overrideState: 2, isGranted: false }, + { permissionKey: "editSites", overrideState: 1, isGranted: true }, + ], + }); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index 7f34bcc8..fb24f6b5 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -6,6 +6,10 @@ import { mapBoardDetailMediaItem, mapWorkOrderBoardDetail, } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; const SAMPLE_DETAIL = { info: { @@ -279,3 +283,56 @@ describe("mapWorkOrderBoardDetail", () => { expect(completionForm.get("file")).toBeInstanceOf(File); }); }); + +describe("mapWorkOrderBoardDetail media count (SH-339)", () => { + const reactiveInfo = { ...SAMPLE_DETAIL.info, workOrderType: 6 }; + + it("derives mediaCount from stored media when detail info omits it", () => { + const detail = mapWorkOrderBoardDetail({ ...SAMPLE_DETAIL, info: reactiveInfo }); + + expect(detail.info.type).toBe("Reactive"); + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + + it("prefers stored media when detail info reports an explicit zero", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 0 }, + }); + + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + + it("does not count legacy URL-column media, matching the board row count", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: reactiveInfo, + media: [ + { + id: -1, + category: 1, + url: "https://cdn.example.com/legacy.jpg", + fileName: "legacy.jpg", + isLegacy: true, + }, + ], + }); + + expect(detail.info.mediaCount).toBe(0); + }); + + it("keeps an explicit info mediaCount", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 5 }, + }); + + expect(detail.info.mediaCount).toBe(5); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts index f2b26ff5..d435a466 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -39,24 +39,27 @@ if (!localStorageIsUsable()) { }); } -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - }), -}); +// Files that opt into `@vitest-environment node` (real fetch/FormData semantics) have no window. +if (typeof window !== "undefined") { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); -class ResizeObserverMock { - observe() {} - unobserve() {} - disconnect() {} + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + window.ResizeObserver = ResizeObserverMock; } - -window.ResizeObserver = ResizeObserverMock;