From 4b88a607512603b12377160b0664f0c54377ba78 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 3 Aug 2026 17:53:39 -0300 Subject: [PATCH] fix(vendors): remove preferred contact and fix trade ordering (#63) * fix(vendors): address SH-180 and SH-182 * refactor(vendors): satisfy maintainability gates --- e2e/vendors/vendors.spec.ts | 12 +- e2e/work-orders/vendor-inline-editor.spec.ts | 7 +- .../_components/use-vendor-detail-drawer.ts | 2 - .../_components/vendor-create-modal.tsx | 1 - .../vendor-detail-drawer-content.tsx | 17 +- .../_components/vendor-form-fields.tsx | 668 +++++++-------- .../vendors/_components/vendor-form.tsx | 2 - src/app/(protected)/workorders/[id].tsx | 786 ++---------------- .../_components/use-vendor-editor.ts | 151 ++++ .../_components/vendor-editor-dialog.tsx | 102 +++ .../_components/work-order-activity.tsx | 189 +++++ .../_components/work-order-view-sections.tsx | 401 +++++++++ src/domain/vendors/mappers/vendor-mapper.ts | 13 +- src/domain/work-orders/types/dispatch.ts | 1 - .../vendors/vendor-form-fields.test.tsx | 11 - .../domain/vendors/api/vendors-api.test.ts | 5 +- .../work-orders/api/work-orders-api.test.ts | 1 - 17 files changed, 1253 insertions(+), 1116 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/use-vendor-editor.ts create mode 100644 src/app/(protected)/workorders/_components/vendor-editor-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/work-order-activity.tsx create mode 100644 src/app/(protected)/workorders/_components/work-order-view-sections.tsx diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 4dae7254..51aa4783 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -313,21 +313,21 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "Add trade" }).click(); await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC"); await page.getByRole("button", { name: "Add trade" }).click(); + await expect(page.getByRole("button", { name: "Move Plumbing up" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Move HVAC down" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Move HVAC up" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Move Plumbing down" })).toBeVisible(); await page.getByRole("button", { name: "Move HVAC up" }).click(); await page.getByLabel("Notes").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Text" }).click(); - await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute( - "aria-pressed", - "true", - ); + await expect(page.getByText("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add Vendor" }).last().click(); await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0); + expect(state.createdBody).not.toHaveProperty("preferredContact"); expect(state.createdBody).toMatchObject({ companyName: "Gateway Plumbing", companyId: 101, contactName: "New Technician", companyPhone: "(314) 555-0100", - preferredContact: "Text", tradeSpecialties: "HVAC, Plumbing", notes: "Created in browser E2E", }); diff --git a/e2e/work-orders/vendor-inline-editor.spec.ts b/e2e/work-orders/vendor-inline-editor.spec.ts index 95639b66..d65c84f1 100644 --- a/e2e/work-orders/vendor-inline-editor.spec.ts +++ b/e2e/work-orders/vendor-inline-editor.spec.ts @@ -61,6 +61,9 @@ test("edits only the vendor assigned to the current work order", async ({ page } }, }), ); + await page.route("**/api/workorders/lookups/dispatchers", (route) => + fulfillJson(route, { data: [] }), + ); await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] })); await page.goto("/workorders/501"); @@ -68,10 +71,9 @@ test("edits only the vendor assigned to the current work order", async ({ page } await page.getByRole("button", { name: "Edit vendor" }).click(); await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible(); + await expect(page.getByText("Preferred contact")).toHaveCount(0); 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"); @@ -82,7 +84,6 @@ test("edits only the vendor assigned to the current work order", async ({ page } 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)/vendors/_components/use-vendor-detail-drawer.ts b/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts index 56a8fe74..5e13fad0 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts @@ -26,7 +26,6 @@ const DEFAULT_VALUES: VendorFormValues = { tradeSpecialties: "", isActive: true, companyId: null, - preferredContact: "Phone", }; function toFormValues(vendor: Vendor): VendorFormValues { @@ -46,7 +45,6 @@ function toFormValues(vendor: Vendor): VendorFormValues { tradeSpecialties: vendor.tradeSpecialties ?? "", isActive: vendor.isActive, companyId: vendor.companyId ?? null, - preferredContact: vendor.preferredContact ?? "Phone", }; } diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index e48a8aa7..fdcd9a1c 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -41,7 +41,6 @@ const defaultValues: VendorFormValues = { tradeSpecialties: "", isActive: true, companyId: null, - preferredContact: "Phone", }; export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) { diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx index e121c63d..8aa47f56 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx @@ -18,18 +18,7 @@ import { } from "@mui/material"; import { VendorFormFields } from "./vendor-form-fields"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { - Vendor, - VendorFacetCompany, - VendorListItem, - VendorPreferredContact, -} from "@/domain/vendors/types/vendor"; - -const PREFERRED_CONTACT_LABELS: Record = { - Phone: "Phone", - Email: "Email", - Text: "Text Message", -}; +import type { Vendor, VendorFacetCompany, VendorListItem } from "@/domain/vendors/types/vendor"; function getInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); @@ -123,10 +112,6 @@ export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewP - diff --git a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx index a680d383..f8fee0f4 100644 --- a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx @@ -13,15 +13,9 @@ import { FormLabel, Stack, TextField, - ToggleButton, - ToggleButtonGroup, Typography, } from "@mui/material"; -import { - VENDOR_PREFERRED_CONTACT_VALUES, - type VendorFacetCompany, - type VendorPreferredContact, -} from "@/domain/vendors/types/vendor"; +import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; interface VendorFormFieldsProps { @@ -33,6 +27,8 @@ interface VendorFormFieldsProps { showTotalJobs?: boolean; } +type VendorFieldErrors = FieldErrors; + function splitTrades(value: string | undefined): string[] { return (value ?? "") .split(",") @@ -52,11 +48,331 @@ function formatPhoneMask(input: string): string { return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; } -const PREFERRED_CONTACT_LABELS: Record = { - Phone: "Phone", - Email: "Email", - Text: "Text", -}; +function TechnicianFields({ + control, + errors, +}: { + control: Control; + errors: VendorFieldErrors; +}) { + return ( + + + Technician + + + ( + + )} + /> + ( + field.onChange(formatPhoneMask(event.target.value))} + error={Boolean(errors.phone)} + helperText={errors.phone?.message} + fullWidth + /> + )} + /> + + + ); +} + +function CompanyFields({ + control, + errors, + companies, + onCompanySelected, +}: { + control: Control; + errors: VendorFieldErrors; + companies: VendorFacetCompany[]; + onCompanySelected: (company: VendorFacetCompany | null) => void; +}) { + return ( + + + Company + + + ( + ( + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={field.value ?? ""} + inputValue={field.value ?? ""} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + field.onChange(value); + companyIdField.onChange(null); + } + }} + onChange={(_event, value, reason) => { + if (typeof value === "string") { + field.onChange(value); + companyIdField.onChange(null); + return; + } + if (value) { + field.onChange(value.name); + companyIdField.onChange(value.companyId); + if (reason === "selectOption") { + onCompanySelected(value); + } + } else { + field.onChange(""); + companyIdField.onChange(null); + } + }} + renderInput={(params) => ( + + )} + /> + )} + /> + )} + /> + ( + field.onChange(formatPhoneMask(event.target.value))} + error={Boolean(errors.companyPhone)} + helperText={errors.companyPhone?.message} + fullWidth + /> + )} + /> + ( + + )} + /> + } + /> + + } + /> + ( + + )} + /> + } + /> + + ( + + )} + /> + + + ); +} + +function TradeSpecialtiesField({ + control, + tradeOptions, +}: { + control: Control; + tradeOptions: string[]; +}) { + const [tradeInput, setTradeInput] = useState(""); + + return ( + + Trade Specialties + + First trade is primary. Reorder with the arrows. + + { + const trades = splitTrades(field.value); + const remove = (trade: string) => { + field.onChange(joinTrades(trades.filter((entry) => entry !== trade))); + }; + const move = (index: number, direction: -1 | 1) => { + const next = [...trades]; + const target = index + direction; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + field.onChange(joinTrades(next)); + }; + const add = (trade: string) => { + const normalized = trade.trim(); + if (!normalized || trades.includes(normalized)) { + setTradeInput(""); + return; + } + field.onChange(joinTrades([...trades, normalized])); + setTradeInput(""); + }; + + return ( + + + {trades.length === 0 ? ( + + No trades selected. + + ) : ( + trades.map((trade, index) => ( + remove(trade)} + deleteIcon={} + sx={{ px: 0.5 }} + /> + )) + )} + + + { + if (reason === "input") setTradeInput(value); + }} + onChange={(_event, value) => { + if (typeof value === "string") add(value); + }} + renderInput={(params) => ( + { + if (event.key === "Enter") { + event.preventDefault(); + add(tradeInput); + } + }} + /> + )} + className="flex-1" + /> + + + {trades.length > 1 && ( + + {trades.map((trade, index) => ( + + {index > 0 && ( + + )} + {index < trades.length - 1 && ( + + )} + + ))} + + )} + + ); + }} + /> + + ); +} export function VendorFormFields({ control, @@ -66,330 +382,19 @@ export function VendorFormFields({ onCompanySelected, showTotalJobs = false, }: VendorFormFieldsProps) { - const [tradeInput, setTradeInput] = useState(""); - return ( - - - Technician - - - ( - - )} - /> - ( - field.onChange(formatPhoneMask(event.target.value))} - error={Boolean(errors.phone)} - helperText={errors.phone?.message} - fullWidth - /> - )} - /> - - - Preferred Contact - - ( - { - if (value) field.onChange(value); - }} - aria-label="Preferred contact" - > - {VENDOR_PREFERRED_CONTACT_VALUES.map((option) => ( - - {PREFERRED_CONTACT_LABELS[option]} - - ))} - - )} - /> - - - - + - - - - Company - - - ( - ( - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={field.value ?? ""} - inputValue={field.value ?? ""} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - field.onChange(value); - companyIdField.onChange(null); - } - }} - onChange={(_event, value, reason) => { - if (typeof value === "string") { - field.onChange(value); - companyIdField.onChange(null); - return; - } - if (value) { - field.onChange(value.name); - companyIdField.onChange(value.companyId); - if (reason === "selectOption") { - onCompanySelected(value); - } - } else { - field.onChange(""); - companyIdField.onChange(null); - } - }} - renderInput={(params) => ( - - )} - /> - )} - /> - )} - /> - ( - field.onChange(formatPhoneMask(event.target.value))} - error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message} - fullWidth - /> - )} - /> - ( - - )} - /> - } - /> - - } - /> - ( - - )} - /> - } - /> - - ( - - )} - /> - - - + - - - Trade Specialties - - First trade is primary. Reorder with the arrows. - - { - const trades = splitTrades(field.value); - const remove = (trade: string) => { - field.onChange(joinTrades(trades.filter((entry) => entry !== trade))); - }; - const move = (index: number, direction: -1 | 1) => { - const next = [...trades]; - const target = index + direction; - if (target < 0 || target >= next.length) return; - [next[index], next[target]] = [next[target], next[index]]; - field.onChange(joinTrades(next)); - }; - const add = (trade: string) => { - const normalized = trade.trim(); - if (!normalized || trades.includes(normalized)) { - setTradeInput(""); - return; - } - field.onChange(joinTrades([...trades, normalized])); - setTradeInput(""); - }; - - return ( - - - {trades.length === 0 ? ( - - No trades selected. - - ) : ( - trades.map((trade, index) => ( - remove(trade)} - deleteIcon={} - sx={{ px: 0.5 }} - /> - )) - )} - - - { - if (reason === "input") setTradeInput(value); - }} - onChange={(_event, value) => { - if (typeof value === "string") add(value); - }} - renderInput={(params) => ( - { - if (event.key === "Enter") { - event.preventDefault(); - add(tradeInput); - } - }} - /> - )} - className="flex-1" - /> - - - {trades.length > 1 && ( - - {trades.map((trade, index) => ( - - - - - ))} - - )} - - ); - }} - /> - - + - {showTotalJobs && ( )} - = { - Open: "primary", - "In Progress": "info", - "On Hold": "warning", - Done: "success", - Cancelled: "default", -}; - -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( - () => - [...comments].sort( - (a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(), - ), - [comments], - ); - - if (!sorted.length) { - return ( - - No comments yet. - - ); - } - - return ( - - {sorted.map((comment) => ( - - - - {comment.userName || comment.commenter || "System"} - - - {comment.createdDate ? formatDateTime(comment.createdDate) : ""} - - - {comment.commenttext} - - ))} - - ); -} - -function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) { - const sorted = useMemo( - () => - [...entries].sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ), - [entries], - ); - - if (!sorted.length) { - return ( - - No changes recorded. - - ); - } - - return ( - - {sorted.map((entry) => ( - - - - {entry.userName || "System"} - - - {entry.createdAt ? formatDateTime(entry.createdAt) : ""} - - - - {entry.fieldName} {entry.oldValue} → {entry.newValue} - - - ))} - - ); -} export default function WorkOrderViewPage() { const { id } = useParams(); @@ -198,111 +28,10 @@ export default function WorkOrderViewPage() { const { data: users = [] } = useWorkOrderUsers(); const statusMutation = useChangeWorkOrderStatus(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); - const commentMutation = useAddWorkOrderComment(workOrderId); - const vendorMutation = useUpdateWorkOrderVendor(workOrderId); + const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() }); - const [activeTab, setActiveTab] = useState("customer"); - const [commentText, setCommentText] = useState(""); const [dispatchOpen, setDispatchOpen] = useState(false); 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"), - [wo?.comments], - ); - const internalComments = useMemo( - () => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"), - [wo?.comments], - ); - const auditLog = wo?.auditLog ?? []; - - const handleCommentSubmit = (event: React.FormEvent) => { - event.preventDefault(); - if (!commentText.trim()) { - return; - } - if (activeTab !== "customer" && activeTab !== "internal") { - return; - } - - commentMutation.mutate( - { text: commentText.trim(), commentType: activeTab }, - { onSuccess: () => setCommentText("") }, - ); - }; - - 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 ( @@ -324,333 +53,31 @@ export default function WorkOrderViewPage() { return ( - - - Work Orders - - - {wo.internalWONumber} - - - - - - {wo.title} - - setStatusAnchor(event.currentTarget)} - className="w-fit cursor-pointer" - /> - setStatusAnchor(null)} - > - {WORK_ORDER_STATUS_OPTIONS.map((status) => ( - { - setStatusAnchor(null); - if (status !== wo.status) { - statusMutation.mutate(status); - } - }} - > - {status} - - ))} - - - - - - - - - - - - Details - - - - - - - - - Assigned To - - - setAssignAnchor(null)} - > - { - setAssignAnchor(null); - assignmentMutation.mutate({ userId: "", userName: "" }); - }} - > - Unassigned - - {users.map((user) => ( - { - setAssignAnchor(null); - assignmentMutation.mutate({ userId: user.id, userName: user.name }); - }} - > - {user.name} - - ))} - - - - - - - - }> - - Classification - - - - - - - - - - - - }> - - Schedule - - - - - - - - - - - - - }> - - Financial - - - - - - - - - - - - }> - - Dispatches ({wo.dispatches.length}) - - - - {wo.dispatches.length ? ( - - - - - Dispatch # - Vendor - NTE - Status - Date - Wait - Actions - - - - {wo.dispatches.map((dispatch) => { - const waitColor = waitTimeColor(dispatch.dispatchedAt); - return ( - setDispatchDetailId(dispatch.id)} - > - {dispatch.dispatchNumber || dispatch.poNumber} - {dispatch.vendorName} - - {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} - - {dispatch.status} - - {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} - - - {dispatch.status === "Sent" ? ( - - {timeSince(dispatch.dispatchedAt)} - - ) : ( - "—" - )} - - - - - - ); - })} - -
-
- ) : ( - - No dispatches yet. - - )} -
-
- - }> - - Description - - - - {wo.description || "No description provided."} - - - - - Activity - - setActiveTab(value)} - className="mb-3" - > - - - - - - {activeTab === "customer" && } - {activeTab === "internal" && } - {activeTab === "audit" && } - - {activeTab === "internal" && ( - - setCommentText(event.target.value)} - placeholder="Type a comment..." - disabled={commentMutation.isPending} - /> - - - )} - + statusMutation.mutate(status)} + onDispatch={() => setDispatchOpen(true)} + onEdit={() => navigate(`/workorders/edit/${id}`)} + onBack={() => navigate("/workorders")} + /> + assignmentMutation.mutate(payload)} + /> + + + void vendorEditor.open(event, vendorId, vendorName) + } + onViewDispatch={setDispatchDetailId} + /> + {dispatchOpen && ( void refetch()} /> )} - { - if (!vendorMutation.isPending) setVendorEditor(null); - }} - fullWidth - maxWidth="sm" - > - Edit assigned vendor - - - - {vendorEditor?.vendorName} - - {Boolean(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 } }} - /> - - )} - - - - - - - -
- ); -} - -function Field({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - {value} + ); } diff --git a/src/app/(protected)/workorders/_components/use-vendor-editor.ts b/src/app/(protected)/workorders/_components/use-vendor-editor.ts new file mode 100644 index 00000000..b666f1b7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/use-vendor-editor.ts @@ -0,0 +1,151 @@ +import { useState } from "react"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import { useUpdateWorkOrderVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations"; + +export interface VendorEditorState { + vendorId: string | number; + vendorName: string; + contactName: string; + phone: string; + email: string; + notes: string; +} + +const PHONE_PATTERN = /^\(\d{3}\) \d{3}-\d{4}$/; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MAX_CONTACT_LENGTH = 200; +const MAX_NOTES_LENGTH = 2000; + +export 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)}`; +} + +export function isVendorEditorValid(editor: VendorEditorState): boolean { + const phoneValid = editor.phone === "" || PHONE_PATTERN.test(editor.phone); + const emailValid = editor.email === "" || EMAIL_PATTERN.test(editor.email); + return ( + phoneValid && + emailValid && + editor.notes.length <= MAX_NOTES_LENGTH && + editor.contactName.length <= MAX_CONTACT_LENGTH + ); +} + +interface UseVendorEditorOptions { + workOrderId: string; + onSaved: () => void; +} + +export interface UseVendorEditorResult { + editor: VendorEditorState | null; + loading: boolean; + error: string; + isPending: boolean; + phoneInvalid: boolean; + isValid: boolean; + open: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => Promise; + close: () => void; + save: () => void; + updateField: (field: keyof VendorEditorState, value: string) => void; +} + +export function useVendorEditor({ + workOrderId, + onSaved, +}: UseVendorEditorOptions): UseVendorEditorResult { + const vendorMutation = useUpdateWorkOrderVendor(workOrderId); + const [editor, setEditor] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const open = async (event: React.MouseEvent, vendorId: string | number, vendorName: string) => { + event.stopPropagation(); + setError(""); + setLoading(true); + setEditor({ + vendorId, + vendorName, + contactName: "", + phone: "", + email: "", + notes: "", + }); + + try { + const vendor = await vendorsApi.getById(vendorId); + setEditor({ + vendorId, + vendorName: vendor.companyName || vendorName, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + notes: vendor.notes, + }); + } catch (vendorError) { + setError( + vendorError instanceof Error ? vendorError.message : "Unable to load vendor details", + ); + } finally { + setLoading(false); + } + }; + + const close = () => { + if (!vendorMutation.isPending) { + setEditor(null); + } + }; + + const save = () => { + if (!editor || !isVendorEditorValid(editor)) return; + const numericWorkOrderId = Number(workOrderId); + if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) { + setError("A valid work order is required"); + return; + } + + vendorMutation.mutate( + { + vendorId: editor.vendorId, + payload: { + workOrderId: numericWorkOrderId, + contactName: editor.contactName.trim(), + phone: editor.phone, + email: editor.email.trim(), + notes: editor.notes.trim(), + }, + }, + { + onSuccess: () => { + setEditor(null); + onSaved(); + }, + onError: (vendorError) => setError(vendorError.message), + }, + ); + }; + + const updateField = (field: keyof VendorEditorState, value: string) => { + setEditor((current) => + current + ? { ...current, [field]: field === "phone" ? formatPhoneMask(value) : value } + : current, + ); + }; + + return { + editor, + loading, + error, + isPending: vendorMutation.isPending, + phoneInvalid: Boolean(editor?.phone && !PHONE_PATTERN.test(editor.phone)), + isValid: editor ? isVendorEditorValid(editor) : false, + open, + close, + save, + updateField, + }; +} diff --git a/src/app/(protected)/workorders/_components/vendor-editor-dialog.tsx b/src/app/(protected)/workorders/_components/vendor-editor-dialog.tsx new file mode 100644 index 00000000..bf26a89d --- /dev/null +++ b/src/app/(protected)/workorders/_components/vendor-editor-dialog.tsx @@ -0,0 +1,102 @@ +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { VendorEditorState } from "./use-vendor-editor"; + +const MAX_CONTACT_LENGTH = 200; +const MAX_NOTES_LENGTH = 2000; + +interface VendorEditorDialogProps { + editor: VendorEditorState | null; + loading: boolean; + error: string; + isPending: boolean; + phoneInvalid: boolean; + isValid: boolean; + onClose: () => void; + onSave: () => void; + onFieldChange: (field: keyof VendorEditorState, value: string) => void; +} + +export function VendorEditorDialog({ + editor, + loading, + error, + isPending, + phoneInvalid, + isValid, + onClose, + onSave, + onFieldChange, +}: VendorEditorDialogProps) { + return ( + + Edit assigned vendor + + + + {editor?.vendorName} + + {Boolean(error) && {error}} + {loading ? ( + + + + ) : ( + <> + onFieldChange("contactName", event.target.value)} + slotProps={{ htmlInput: { maxLength: MAX_CONTACT_LENGTH } }} + /> + onFieldChange("phone", event.target.value)} + error={phoneInvalid} + helperText="Use (XXX) XXX-XXXX" + /> + onFieldChange("email", event.target.value)} + /> + onFieldChange("notes", event.target.value)} + slotProps={{ htmlInput: { maxLength: MAX_NOTES_LENGTH } }} + /> + + )} + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-activity.tsx b/src/app/(protected)/workorders/_components/work-order-activity.tsx new file mode 100644 index 00000000..bf070f97 --- /dev/null +++ b/src/app/(protected)/workorders/_components/work-order-activity.tsx @@ -0,0 +1,189 @@ +import { useMemo, useState } from "react"; +import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; +import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order"; +import { useAddWorkOrderComment } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { formatDateTime } from "@/lib/time-utils"; + +type ActivityTab = "customer" | "internal" | "audit"; + +function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) { + const sorted = useMemo( + () => + [...comments].sort( + (a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(), + ), + [comments], + ); + + if (!sorted.length) { + return ( + + No comments yet. + + ); + } + + return ( + + {sorted.map((comment) => ( + + + + {comment.userName || comment.commenter || "System"} + + + {comment.createdDate ? formatDateTime(comment.createdDate) : ""} + + + {comment.commenttext} + + ))} + + ); +} + +function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) { + const sorted = useMemo( + () => + [...entries].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ), + [entries], + ); + + if (!sorted.length) { + return ( + + No changes recorded. + + ); + } + + return ( + + {sorted.map((entry) => ( + + + + {entry.userName || "System"} + + + {entry.createdAt ? formatDateTime(entry.createdAt) : ""} + + + + {entry.fieldName} {entry.oldValue} → {entry.newValue} + + + ))} + + ); +} + +interface WorkOrderActivityProps { + workOrderId: string; + comments: WorkOrderComment[]; + auditLog: WorkOrderAuditEntry[]; +} + +export function WorkOrderActivity({ workOrderId, comments, auditLog }: WorkOrderActivityProps) { + const commentMutation = useAddWorkOrderComment(workOrderId); + const [activeTab, setActiveTab] = useState("customer"); + const [commentText, setCommentText] = useState(""); + + const customerComments = useMemo( + () => comments.filter((comment) => comment.commentType === "customer"), + [comments], + ); + const internalComments = useMemo( + () => comments.filter((comment) => comment.commentType === "internal"), + [comments], + ); + + const handleCommentSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!commentText.trim()) { + return; + } + if (activeTab !== "customer" && activeTab !== "internal") { + return; + } + + commentMutation.mutate( + { text: commentText.trim(), commentType: activeTab }, + { onSuccess: () => setCommentText("") }, + ); + }; + + return ( + + + Activity + + setActiveTab(value)} + className="mb-3" + > + + + + + + {activeTab === "customer" && } + {activeTab === "internal" && } + {activeTab === "audit" && } + + {activeTab === "internal" && ( + + setCommentText(event.target.value)} + placeholder="Type a comment..." + disabled={commentMutation.isPending} + /> + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-view-sections.tsx b/src/app/(protected)/workorders/_components/work-order-view-sections.tsx new file mode 100644 index 00000000..7f038e7c --- /dev/null +++ b/src/app/(protected)/workorders/_components/work-order-view-sections.tsx @@ -0,0 +1,401 @@ +import { useState } from "react"; +import { Link as RouterLink } from "react-router"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Breadcrumbs, + Button, + Chip, + Link, + Menu, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { + WorkOrderDetail, + WorkOrderStatus, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; +import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order"; +import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils"; + +const STATUS_COLORS: Record< + string, + "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" +> = { + Open: "primary", + "In Progress": "info", + "On Hold": "warning", + Done: "success", + Cancelled: "default", +}; + +function Field({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value} + + ); +} + +interface WorkOrderHeaderProps { + wo: WorkOrderDetail; + onSelectStatus: (status: WorkOrderStatus) => void; + onDispatch: () => void; + onEdit: () => void; + onBack: () => void; +} + +export function WorkOrderHeader({ + wo, + onSelectStatus, + onDispatch, + onEdit, + onBack, +}: WorkOrderHeaderProps) { + const [statusAnchor, setStatusAnchor] = useState(null); + + return ( + <> + + + Work Orders + + + {wo.internalWONumber} + + + + + + {wo.title} + + setStatusAnchor(event.currentTarget)} + className="w-fit cursor-pointer" + /> + setStatusAnchor(null)} + > + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + { + setStatusAnchor(null); + if (status !== wo.status) { + onSelectStatus(status); + } + }} + > + {status} + + ))} + + + + + + + + + + + ); +} + +interface WorkOrderDetailsSectionProps { + wo: WorkOrderDetail; + users: WorkOrderUser[]; + onAssign: (payload: { userId: string | number; userName: string }) => void; +} + +export function WorkOrderDetailsSection({ wo, users, onAssign }: WorkOrderDetailsSectionProps) { + const [assignAnchor, setAssignAnchor] = useState(null); + + return ( + + + Details + + + + + + + + + Assigned To + + + setAssignAnchor(null)} + > + { + setAssignAnchor(null); + onAssign({ userId: "", userName: "" }); + }} + > + Unassigned + + {users.map((user) => ( + { + setAssignAnchor(null); + onAssign({ userId: user.id, userName: user.name }); + }} + > + {user.name} + + ))} + + + + + + + ); +} + +export function WorkOrderSummaryAccordions({ wo }: { wo: WorkOrderDetail }) { + return ( + <> + + }> + + Classification + + + + + + + + + + + + }> + + Schedule + + + + + + + + + + + + + }> + + Financial + + + + + + + + + + + + }> + + Description + + + + {wo.description || "No description provided."} + + + + ); +} + +interface DispatchAccordionProps { + wo: WorkOrderDetail; + onEditVendor: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => void; + onViewDispatch: (dispatchId: string | number) => void; +} + +export function DispatchAccordion({ wo, onEditVendor, onViewDispatch }: DispatchAccordionProps) { + return ( + + }> + + Dispatches ({wo.dispatches.length}) + + + + {wo.dispatches.length ? ( + + + + + Dispatch # + Vendor + NTE + Status + Date + Wait + Actions + + + + {wo.dispatches.map((dispatch) => { + const waitColor = waitTimeColor(dispatch.dispatchedAt); + return ( + onViewDispatch(dispatch.id)} + > + {dispatch.dispatchNumber || dispatch.poNumber} + {dispatch.vendorName} + + {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} + + {dispatch.status} + + {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} + + + {dispatch.status === "Sent" ? ( + + {timeSince(dispatch.dispatchedAt)} + + ) : ( + "—" + )} + + + + + + ); + })} + +
+
+ ) : ( + + No dispatches yet. + + )} +
+
+ ); +} diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 1b9d664e..55130818 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -79,6 +79,14 @@ function readPreferredContact( return "Phone"; } +function readPreferredContactOptional( + record: Record, +): VendorPreferredContact | undefined { + const raw = record.preferredContact ?? record.PreferredContact; + if (typeof raw !== "string" || raw.trim() === "") return undefined; + return readPreferredContact(record, "preferredContact", "PreferredContact"); +} + export function toCanonicalPhone(value: unknown): string { const original = String(value ?? ""); let digits = original.replace(/\D+/g, ""); @@ -163,8 +171,11 @@ export function mapVendorToBackend(vendor: unknown): Record { zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), isActive: readBool(item, "isActive", "IsActive"), - preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), }; + const preferredContact = readPreferredContactOptional(item); + if (preferredContact) { + payload.preferredContact = preferredContact; + } const id = item.id ?? item.Id; if (typeof id === "string" || typeof id === "number") { payload.id = id; diff --git a/src/domain/work-orders/types/dispatch.ts b/src/domain/work-orders/types/dispatch.ts index f93bd94d..9054fc01 100644 --- a/src/domain/work-orders/types/dispatch.ts +++ b/src/domain/work-orders/types/dispatch.ts @@ -15,7 +15,6 @@ export interface WorkOrderDispatchSummary { export interface WorkOrderVendorUpdatePayload { workOrderId: number; contactName?: string; - preferredContact: "Phone" | "Email" | "Text"; phone?: string; email?: string; notes?: string; diff --git a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx index 24d4c790..d1e0dbcc 100644 --- a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx @@ -38,7 +38,6 @@ const emptyForm: VendorFormValues = { tradeSpecialties: "", isActive: true, companyId: null, - preferredContact: "Phone", }; function Harness({ @@ -192,14 +191,4 @@ describe("VendorFormFields company autocomplete state", () => { await userEvent.type(companyPhone, "3145550100"); expect(companyPhone).toHaveValue("(314) 555-0100"); }); - - it("exposes a preferred contact selector with Phone, Email, and Text options", () => { - renderWithProviders(, { - withAuth: false, - }); - - expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument(); - }); }); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index e6ff71bc..8ed78f32 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -141,7 +141,6 @@ describe("vendorsApi.getList", () => { notes: "Preferred after-hours vendor", totalJobs: 72, isActive: true, - preferredContact: "Text", }; apiPost.mockResolvedValueOnce({ data: values }); apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } }); @@ -155,15 +154,16 @@ describe("vendorsApi.getList", () => { companyName: "Gateway Plumbing", notes: values.notes, companyId: 42, - preferredContact: "Text", phone: "(314) 555-0198", companyPhone: "(314) 555-0100", }), ); + expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("preferredContact"); expect(apiPut).toHaveBeenCalledWith( `${API_PATHS.rest.vendors}/7`, expect.objectContaining({ companyName: "Gateway Plumbing", isActive: false }), ); + expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("preferredContact"); expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); }); @@ -173,7 +173,6 @@ describe("vendorsApi.getList", () => { await vendorsApi.create({ companyName: "Independent Vendor LLC", contactName: "Indy Tech", - preferredContact: "Phone", }); expect(apiPost).toHaveBeenCalledWith( 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 7d0b91ea..9466e814 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 @@ -45,7 +45,6 @@ describe("workOrdersApi.getById", () => { const payload = { workOrderId: 10, contactName: "Taylor Tech", - preferredContact: "Text" as const, phone: "(314) 555-0199", email: "taylor@example.com", notes: "Text before arrival",