From 922d6c9a57d077f6d422136fc24af62a3764ca2d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 17:44:53 -0300 Subject: [PATCH] feat(vendors): edit assigned vendor from work order --- e2e/work-orders/vendor-inline-editor.spec.ts | 90 +++++++ src/app/(protected)/workorders/[id].tsx | 225 ++++++++++++++++++ src/domain/work-orders/api/work-orders-api.ts | 10 +- .../work-orders/mappers/work-order-mapper.ts | 2 + src/domain/work-orders/types/dispatch.ts | 11 + src/domain/work-orders/types/work-order.ts | 1 + .../use-cases/use-work-order-mutations.ts | 26 ++ .../work-orders/api/work-orders-api.test.ts | 19 ++ .../mappers/work-order-mapper.test.ts | 23 +- 9 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 e2e/work-orders/vendor-inline-editor.spec.ts diff --git a/e2e/work-orders/vendor-inline-editor.spec.ts b/e2e/work-orders/vendor-inline-editor.spec.ts new file mode 100644 index 00000000..95639b66 --- /dev/null +++ b/e2e/work-orders/vendor-inline-editor.spec.ts @@ -0,0 +1,90 @@ +import { expect, test, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "work-order-vendor-e2e-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +test("edits only the vendor assigned to the current work order", async ({ page }) => { + let updateRequest: { url: string; body: Record } | undefined; + + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + Id: 501, + InternalWONumber: "WO-501", + WorkerOrderNumber: "CUSTOMER-501", + WorkerOrderTitle: "Emergency boiler repair", + Status: "Dispatched", + Comments: [], + AuditLog: [], + Dispatches: [ + { + Id: 801, + DispatchNumber: "D-801", + VendorId: 7, + VendorName: "Gateway Plumbing", + VendorEmail: "dispatch@gateway.test", + Status: "Sent", + DispatchedAt: "2026-07-23T12:00:00Z", + }, + ], + }), + ); + await page.route("**/api/vendors/7/work-order-update", async (route) => { + updateRequest = { + url: route.request().url(), + body: route.request().postDataJSON() as Record, + }; + await fulfillJson(route, { data: { id: 7 } }); + }); + await page.route("**/api/vendors/7", (route) => + fulfillJson(route, { + data: { + Id: 7, + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + PreferredContact: "Email", + Phone: "3145550198", + Email: "dispatch@gateway.test", + Notes: "Preferred after-hours vendor", + IsActive: true, + }, + }), + ); + await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] })); + + await page.goto("/workorders/501"); + await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible(); + await page.getByRole("button", { name: "Edit vendor" }).click(); + + await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible(); + await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte"); + await page.getByLabel("Technician name").fill("Taylor Tech"); + await page.getByLabel("Preferred contact").click(); + await page.getByRole("option", { name: "Text" }).click(); + await page.getByLabel("Phone").fill("3145550111"); + await page.getByLabel("Email").fill("taylor@example.com"); + await page.getByLabel("Notes").fill("Text before arrival"); + await page.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden(); + expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/); + expect(updateRequest?.body).toEqual({ + workOrderId: 501, + contactName: "Taylor Tech", + preferredContact: "Text", + phone: "(314) 555-0111", + email: "taylor@example.com", + notes: "Text before arrival", + }); +}); diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index a282040c..610148a2 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -10,6 +10,10 @@ import { Button, Chip, CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Link, Menu, MenuItem, @@ -36,8 +40,11 @@ import { useAddWorkOrderComment, useChangeWorkOrderAssignment, useChangeWorkOrderStatus, + useUpdateWorkOrderVendor, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import type { VendorPreferredContact } from "@/domain/vendors/types/vendor"; import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils"; const STATUS_COLORS: Record< @@ -53,6 +60,31 @@ const STATUS_COLORS: Record< type ActivityTab = "customer" | "internal" | "audit"; +interface VendorEditorState { + vendorId: string | number; + vendorName: string; + contactName: string; + preferredContact: VendorPreferredContact; + phone: string; + email: string; + notes: string; +} + +function formatPhoneMask(input: string): string { + const digits = input.replace(/\D/g, "").slice(0, 10); + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} + +function isVendorEditorValid(editor: VendorEditorState): boolean { + const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone); + const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email); + return ( + phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200 + ); +} + function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) { const sorted = useMemo( () => @@ -167,6 +199,7 @@ export default function WorkOrderViewPage() { const statusMutation = useChangeWorkOrderStatus(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); const commentMutation = useAddWorkOrderComment(workOrderId); + const vendorMutation = useUpdateWorkOrderVendor(workOrderId); const [activeTab, setActiveTab] = useState("customer"); const [commentText, setCommentText] = useState(""); @@ -174,6 +207,9 @@ export default function WorkOrderViewPage() { const [dispatchDetailId, setDispatchDetailId] = useState(null); const [statusAnchor, setStatusAnchor] = useState(null); const [assignAnchor, setAssignAnchor] = useState(null); + const [vendorEditor, setVendorEditor] = useState(null); + const [vendorEditorLoading, setVendorEditorLoading] = useState(false); + const [vendorEditorError, setVendorEditorError] = useState(""); const customerComments = useMemo( () => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"), @@ -200,6 +236,74 @@ export default function WorkOrderViewPage() { ); }; + const openVendorEditor = async ( + event: React.MouseEvent, + vendorId: string | number, + vendorName: string, + ) => { + event.stopPropagation(); + setVendorEditorError(""); + setVendorEditorLoading(true); + setVendorEditor({ + vendorId, + vendorName, + contactName: "", + preferredContact: "Phone", + phone: "", + email: "", + notes: "", + }); + + try { + const vendor = await vendorsApi.getById(vendorId); + setVendorEditor({ + vendorId, + vendorName: vendor.companyName || vendorName, + contactName: vendor.contactName, + preferredContact: vendor.preferredContact, + phone: vendor.phone, + email: vendor.email, + notes: vendor.notes, + }); + } catch (vendorError) { + setVendorEditorError( + vendorError instanceof Error ? vendorError.message : "Unable to load vendor details", + ); + } finally { + setVendorEditorLoading(false); + } + }; + + const saveVendorEditor = () => { + if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return; + const numericWorkOrderId = Number(workOrderId); + if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) { + setVendorEditorError("A valid work order is required"); + return; + } + + vendorMutation.mutate( + { + vendorId: vendorEditor.vendorId, + payload: { + workOrderId: numericWorkOrderId, + contactName: vendorEditor.contactName.trim(), + preferredContact: vendorEditor.preferredContact, + phone: vendorEditor.phone, + email: vendorEditor.email.trim(), + notes: vendorEditor.notes.trim(), + }, + }, + { + onSuccess: () => { + setVendorEditor(null); + void refetch(); + }, + onError: (vendorError) => setVendorEditorError(vendorError.message), + }, + ); + }; + if (isLoading) { return ( @@ -426,6 +530,7 @@ export default function WorkOrderViewPage() { Status Date Wait + Actions @@ -465,6 +570,17 @@ export default function WorkOrderViewPage() { "—" )} + + + ); })} @@ -550,6 +666,115 @@ export default function WorkOrderViewPage() { onUpdated={() => void refetch()} /> )} + { + if (!vendorMutation.isPending) setVendorEditor(null); + }} + fullWidth + maxWidth="sm" + > + Edit assigned vendor + + + + {vendorEditor?.vendorName} + + {vendorEditorError && {vendorEditorError}} + {vendorEditorLoading ? ( + + + + ) : ( + <> + + setVendorEditor((current) => + current ? { ...current, contactName: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 200 } }} + /> + + setVendorEditor((current) => + current + ? { + ...current, + preferredContact: event.target.value as VendorPreferredContact, + } + : current, + ) + } + > + Phone + Email + Text + + + setVendorEditor((current) => + current + ? { ...current, phone: formatPhoneMask(event.target.value) } + : current, + ) + } + error={Boolean( + vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone), + )} + helperText="Use (XXX) XXX-XXXX" + /> + + setVendorEditor((current) => + current ? { ...current, email: event.target.value } : current, + ) + } + /> + + setVendorEditor((current) => + current ? { ...current, notes: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 2000 } }} + /> + + )} + + + + + + + ); } diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index c17f207d..a3ede840 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost } from "@/api/api"; +import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { isWoTableMockEnabled, @@ -37,6 +37,7 @@ import type { LocationOption, TaskListTemplate, UpdateDispatchPayload, + WorkOrderVendorUpdatePayload, VendorDropdownItem, WorkOrderComment, WorkOrderDetail, @@ -294,6 +295,13 @@ export const workOrdersApi = { await apiPost(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload)); }, + updateVendorFromWorkOrder: async ( + vendorId: string | number, + payload: WorkOrderVendorUpdatePayload, + ): Promise => { + await apiPut(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload); + }, + addDispatchComment: async ( dispatchId: string | number, text: string, diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 687aa3b1..c424c26e 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -250,9 +250,11 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm return { id: readId(item), + vendorId: readId({ id: item.vendorId ?? item.VendorId }), dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"), poNumber: readString(item, "poNumber", "PoNumber", "PONumber"), vendorName: readString(item, "vendorName", "VendorName"), + vendorEmail: readString(item, "vendorEmail", "VendorEmail"), nteAmount: readNumber(item, "nteAmount", "NTEAmount"), status: readString(item, "status", "Status"), dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"), diff --git a/src/domain/work-orders/types/dispatch.ts b/src/domain/work-orders/types/dispatch.ts index d5df044c..fdb0175c 100644 --- a/src/domain/work-orders/types/dispatch.ts +++ b/src/domain/work-orders/types/dispatch.ts @@ -1,13 +1,24 @@ export interface WorkOrderDispatchSummary { id: string | number; + vendorId: string | number; dispatchNumber: string; poNumber: string; vendorName: string; + vendorEmail: string; nteAmount: number | null; status: string; dispatchedAt: string; } +export interface WorkOrderVendorUpdatePayload { + workOrderId: number; + contactName?: string; + preferredContact: "Phone" | "Email" | "Text"; + phone?: string; + email?: string; + notes?: string; +} + export interface DispatchChecklistItem { id: string | number; itemText: string; diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts index c1440bff..f266bc6a 100644 --- a/src/domain/work-orders/types/work-order.ts +++ b/src/domain/work-orders/types/work-order.ts @@ -7,6 +7,7 @@ export type { DispatchWorkOrderRef, UpdateDispatchPayload, WorkOrderDispatchSummary, + WorkOrderVendorUpdatePayload, } from "./dispatch"; export type { diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index ed877ab5..5c69e3a4 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -11,6 +11,7 @@ import type { WorkOrderComment, WorkOrderDetail, WorkOrderFormValues, + WorkOrderVendorUpdatePayload, } from "@/domain/work-orders/types/work-order"; import { queryKeys } from "@/infra/query-key/query-key"; @@ -46,6 +47,11 @@ interface ChangeWorkOrderAssignmentInput { userName: string; } +interface UpdateWorkOrderVendorInput { + vendorId: string | number; + payload: WorkOrderVendorUpdatePayload; +} + export function useCreateWorkOrder(): UseMutationResult< WorkOrderDetail, Error, @@ -89,6 +95,26 @@ export function useUpdateWorkOrder( }); } +export function useUpdateWorkOrderVendor( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ vendorId, payload }) => + workOrdersApi.updateVendorFromWorkOrder(vendorId, payload), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); + toast.success("Vendor contact updated"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update vendor contact"); + }, + }); +} + export function useAddWorkOrderComment( workOrderId: string | number, ): UseMutationResult { diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 32040a19..f44e7b30 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { API_PATHS } from "@/api/api-paths"; const apiGet = vi.fn(); +const apiPut = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), + apiPut: (...args: unknown[]) => apiPut(...args), apiPost: vi.fn(), apiDelete: vi.fn(), apiPostNoContent: vi.fn(), @@ -15,6 +17,7 @@ import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; describe("workOrdersApi.getById", () => { beforeEach(() => { apiGet.mockReset(); + apiPut.mockReset(); apiGet.mockResolvedValue({ Id: 10, title: "Test WO", @@ -22,6 +25,22 @@ describe("workOrdersApi.getById", () => { }); }); + it("updates only the assigned vendor through the work-order-scoped endpoint", async () => { + apiPut.mockResolvedValueOnce(undefined); + const payload = { + workOrderId: 10, + contactName: "Taylor Tech", + preferredContact: "Text" as const, + phone: "(314) 555-0199", + email: "taylor@example.com", + notes: "Text before arrival", + }; + + await workOrdersApi.updateVendorFromWorkOrder(7, payload); + + expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7/work-order-update`, payload); + }); + it("calls GetWorkorderById with id query param, not path suffix", async () => { await workOrdersApi.getById(10); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index a9dfc9bf..632fa346 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -1,5 +1,26 @@ import { describe, expect, it } from "vitest"; -import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper"; +import { + mapUpliftRequest, + mapWorkOrderDispatchSummary, +} from "@/domain/work-orders/mappers/work-order-mapper"; + +describe("mapWorkOrderDispatchSummary", () => { + it("preserves the assigned vendor identity needed by inline editing", () => { + expect( + mapWorkOrderDispatchSummary({ + Id: 21, + VendorId: 7, + VendorName: "Gateway Plumbing", + VendorEmail: "dispatch@gateway.test", + }), + ).toMatchObject({ + id: 21, + vendorId: 7, + vendorName: "Gateway Plumbing", + vendorEmail: "dispatch@gateway.test", + }); + }); +}); describe("mapUpliftRequest", () => { it("maps decided uplift fields from PascalCase payload", () => {