diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index 7153c09d..36602c27 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/work-orders/wo-number-sh-placeholder.spec.ts b/e2e/work-orders/wo-number-sh-placeholder.spec.ts new file mode 100644 index 00000000..f2211583 --- /dev/null +++ b/e2e/work-orders/wo-number-sh-placeholder.spec.ts @@ -0,0 +1,121 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-number-sh-placeholder-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + 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) }); +} + +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(); + } + }); +} + +const REAL_ROW = { + id: 1000, + woNumber: "12345678901", + workOrderType: 2, + siteCode: "DAL1", + lifecycleStatusLabel: "Scheduled", + scheduledDate: "2026-08-19", + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", +}; + +async function mockBoard(page: Page, patches: unknown[]) { + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + const request = route.request(); + const url = request.url(); + if (request.method() !== "GET") { + patches.push({ url, body: request.postDataJSON() }); + await fulfillJson(route, {}, 500); + return; + } + if (url.includes("workorders/board/search")) { + await fulfillJson(route, { items: [REAL_ROW], totalCount: 1, page: 0, pageSize: 50 }); + return; + } + if (url.includes("workorders/board")) { + await fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [REAL_ROW], + }); + return; + } + await fulfillJson(route, { data: [], items: [] }); + }, + ); +} + +test.describe("inline WO number SH placeholder", () => { + test.setTimeout(60_000); + + test.beforeEach(async ({ page }) => { + 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); + }); + + test("keeps a typed SH number on a new inline row and blocks downgrading a saved real number", async ({ + page, + }) => { + const mutations: unknown[] = []; + await mockBoard(page, mutations); + await page.goto("/workorders"); + await hideQueryDevtools(page); + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByText("12345678901").first()).toBeVisible(); + + await page + .getByRole("button", { name: /Add WO/ }) + .first() + .click(); + await page + .getByText(/^Insert WO #/) + .first() + .click(); + const draftInput = page.getByLabel("Work order number"); + await draftInput.fill("SH00001"); + await draftInput.press("Enter"); + await expect(draftInput).toHaveCount(0); + await expect(page.getByText("SH00001", { exact: true })).toBeVisible(); + await expect(page.getByText(/must be 11 digits/)).toHaveCount(0); + + await page.getByText("12345678901", { exact: true }).first().click(); + const savedInput = page.getByLabel("Work order number"); + await savedInput.fill("SH00009"); + await savedInput.press("Enter"); + await expect( + page.getByText("Cannot replace an APM work order number with an internal number"), + ).toBeVisible(); + await expect(page.getByText("12345678901", { exact: true }).first()).toBeVisible(); + expect(mutations).toEqual([]); + }); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 667cbf69..10575aea 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -160,6 +160,9 @@ export const API_PATHS = { update: "DropdownOptions/Update", delete: "DropdownOptions", }, + services: { + list: "services", + }, user: { list: "User", }, diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 2e6dceb9..0058d354 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -28,6 +28,7 @@ export function buildInlineDraftCreateInput( scheduledOn: draft.scheduledOn, dueDate: draft.dueDate, pm: draft.pm, + serviceId: null, extraServices: draft.extraServices ?? [], avetaRequired: Boolean(draft.avetaRequired), pmNote: draft.serviceNotes ?? "", diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 39744598..9273a5e8 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -3,6 +3,12 @@ import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; +import { + WO_NUMBER_DOWNGRADE_MESSAGE, + WO_NUMBER_FORMAT_MESSAGE, + isValidBoardWoNumber, + isWoNumberDowngrade, +} from "@/domain/work-orders/utils/wo-number-input"; import { EditableWONumberInput } from "./editable-wo-number-input"; import { HighlightText } from "./highlight-text"; import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills"; @@ -26,6 +32,9 @@ type EditableWONumberCellProps = { requiredPlaceholder?: string; }; +/** Inline-create rows pass this id; their number is draft state, never a saved WO number. */ +const UNSAVED_DRAFT_ID = "draft"; + async function resolveDuplicateWoNumber( draft: string, allOrders: WorkOrderTableRow[], @@ -88,8 +97,14 @@ export function EditableWONumberCell({ setEditing(false); return; } - if (draft.length !== 11) { - toast("Work order number must be exactly 11 digits"); + if (!isValidBoardWoNumber(draft)) { + toast(WO_NUMBER_FORMAT_MESSAGE); + return; + } + if (String(currentId) !== UNSAVED_DRAFT_ID && isWoNumberDowngrade(value, draft)) { + toast.error(WO_NUMBER_DOWNGRADE_MESSAGE); + setDraft(value); + setEditing(false); return; } setIsChecking(true); @@ -122,14 +137,14 @@ export function EditableWONumberCell({ const handleBlur = () => { if (isChecking) return; - if (draft !== value && draft.length === 11) { + if (draft !== value && isValidBoardWoNumber(draft)) { commit(); return; } setEditing(false); }; - const invalid = editing && draft.length > 0 && draft.length !== 11; + const invalid = editing && draft.length > 0 && !isValidBoardWoNumber(draft); if (locked) { return ( diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx index f4becca8..1ef6512e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx @@ -1,3 +1,5 @@ +import { sanitizeBoardWoNumberInput } from "@/domain/work-orders/utils/wo-number-input"; + type EditableWONumberInputProps = { draft: string; invalid: boolean; @@ -21,9 +23,8 @@ export function EditableWONumberInput({ onChange(event.target.value.replace(/\D/g, "").slice(0, 11))} + onChange={(event) => onChange(sanitizeBoardWoNumberInput(event.target.value))} onFocus={(event) => event.target.select()} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { @@ -36,7 +37,7 @@ export function EditableWONumberInput({ } }} onBlur={onBlur ?? onCommit} - placeholder="12345678901" + placeholder="e.g. 12345678901 or SH00001" aria-label="Work order number" aria-invalid={invalid} className="font-mono focus:outline-none" diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx index 1df4a262..8f7d75a6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx @@ -39,7 +39,7 @@ export function PmTypePickerPopover({ autoFocus value={search} onChange={(e) => onSearchChange(e.target.value)} - placeholder="Search PM type…" + placeholder="Search service…" className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts index 96b1caa2..1051ea00 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -11,9 +11,10 @@ export const WO_TABLE_COLS = [ { key: "vendor", label: "VENDOR", w: 184 }, { key: "appt", label: "APPT TIME", w: 128 }, { key: "status", label: "STATUS", w: 148 }, + // Completed Date gates the Completion Document, so it sits between Status and Comp Doc. + { key: "completed", label: "COMPLETED DATE", w: 130 }, { key: "doc", label: "COMP DOC", w: 84 }, { key: "uplift", label: "UPLIFT", w: 108 }, - { key: "completed", label: "COMPLETED DATE", w: 130 }, { key: "actions", label: "", w: 100 }, ] as const; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 0326f870..ad995968 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -105,6 +105,17 @@ export function WoTableRowServiceCells({ onRequestComplete={() => handlers.onRequestComplete?.(row)} /> + e.stopPropagation()} + > + onPatchRow({ completedDate: date })} + /> + e.stopPropagation()}> - e.stopPropagation()} - > - onPatchRow({ completedDate: date })} - /> - ); } diff --git a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx index 4d92a8f0..039770ac 100644 --- a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx +++ b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx @@ -82,7 +82,10 @@ export function NewWoWizard({ locationDetail={wizard.locationDetail} locationDetailLoading={wizard.locationDetailLoading} locationDetailError={wizard.locationDetailError} - services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))} + services={wizard.services.map((service) => ({ + id: service.id, + value: service.name, + }))} showErrors={wizard.showErrors} svcOpen={wizard.svcOpen} onSvcOpenChange={wizard.setSvcOpen} diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts index 908c6d1a..0a5f394b 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts @@ -2,8 +2,6 @@ import { useEffect } from "react"; import { guardDialogClose } from "@/lib/dialog-guard"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; - import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; @@ -16,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft"; import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions"; import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill"; +import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type"; type UseNewWoWizardControllerArgs = { open: boolean; @@ -32,7 +31,7 @@ export function useNewWoWizardController({ const draftState = useNewWoWizardDraft(open); const { data: locations = [] } = useLocationSites(); - const { data: problems = [] } = useDropdownOptionsByCategory("Problem"); + const { data: registryServices = [] } = useServicesForWorkOrderType(draftState.draft.type); const { data: locationDetail, isLoading: locationDetailLoading, @@ -78,7 +77,7 @@ export function useNewWoWizardController({ ...draftState, patchDraft, locations, - problems, + services: registryServices, locationDetail, locationDetailLoading, locationDetailError, diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx index bfefcf5d..baadd66e 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx @@ -32,8 +32,10 @@ export function WizardStepLocationServiceSelect({ nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {}; const selectSvc = (p: string) => { + const selected = services.find((service) => service.value === p); onPatch({ pm: p, + serviceId: selected?.id ?? null, extraServices: [], ...clearVendorIfPrimaryChanged(p), }); @@ -48,7 +50,7 @@ export function WizardStepLocationServiceSelect({ icon={ } - searchPlaceholder="Search PM type…" + searchPlaceholder="Search services…" options={services.map((s) => { const Icon = getPmTypeIcon(s.value); return { diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index 400ad771..7a655742 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -270,9 +270,23 @@ export function WizardStepTypeSchedule({ { + const typeChanged = t !== draft.type; onPatch({ type: t, severity: nextSeverityForType(draft.type, t, draft.severity), + // A registry service is scoped to its work-order type, so a service picked + // under the previous type fails backend ResolveServiceAsync (SH-187/#131). + // Clear the service (and its vendor, filtered by the service) on a type change. + ...(typeChanged + ? { + pm: "", + serviceId: null, + extraServices: [], + vendorId: "", + vendorName: "", + techPhone: "", + } + : {}), }); }} /> diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts new file mode 100644 index 00000000..58ba217c --- /dev/null +++ b/src/domain/services/api/services-api.ts @@ -0,0 +1,30 @@ +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { mapServices } from "@/domain/services/mappers/service-mapper"; +import type { Service } from "@/domain/services/types/service"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +const TYPE_TO_API: Record, number> = { + PM: WORK_ORDER_TYPE_API.PM, + Reactive: WORK_ORDER_TYPE_API.Reactive, + Emergency: WORK_ORDER_TYPE_API.Emergency, +}; + +export const servicesApi = { + getActive: async (): Promise => { + const data = await apiGet(API_PATHS.services.list, { + searchParams: { isActive: true }, + }); + return mapServices(handleApiResponse(data)); + }, + + getActiveForWorkOrderType: async (workOrderType: WOType): Promise => { + if (workOrderType === "Overdue") return []; + const data = await apiGet(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] }, + }); + return mapServices(handleApiResponse(data)); + }, +}; diff --git a/src/domain/services/mappers/service-mapper.ts b/src/domain/services/mappers/service-mapper.ts new file mode 100644 index 00000000..4b6d6f6e --- /dev/null +++ b/src/domain/services/mappers/service-mapper.ts @@ -0,0 +1,71 @@ +import type { Service } from "@/domain/services/types/service"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readId(item: Record): string | number { + const value = item.id ?? item.Id; + return typeof value === "string" || typeof value === "number" ? value : ""; +} + +function readString(item: Record, ...keys: string[]): string { + for (const key of keys) { + const value = item[key]; + if (typeof value === "string") return value.trim(); + } + return ""; +} + +function readBool(item: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = item[key]; + if (typeof value === "boolean") return value; + } + return false; +} + +function mapWorkOrderType(value: unknown): WOType | null { + if (typeof value === "number") { + return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized === "pm" || normalized === "preventivemaintenance" + ? "PM" + : normalized === "emergency" + ? "Emergency" + : normalized === "reactive" + ? "Reactive" + : null; + } + return null; +} + +export function mapService(raw: unknown): Service { + const item = asRecord(raw); + const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes; + return { + id: readId(item), + name: readString(item, "name", "Name"), + trade: readString(item, "trade", "Trade"), + iconKey: readString(item, "iconKey", "IconKey"), + requiresCompletionDocument: readBool( + item, + "requiresCompletionDocument", + "RequiresCompletionDocument", + ), + isActive: readBool(item, "isActive", "IsActive"), + supportedWorkOrderTypes: Array.isArray(types) + ? types.flatMap((value) => { + const mapped = mapWorkOrderType(value); + return mapped ? [mapped] : []; + }) + : [], + }; +} + +export function mapServices(raw: unknown): Service[] { + return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : []; +} diff --git a/src/domain/services/types/service.ts b/src/domain/services/types/service.ts new file mode 100644 index 00000000..fa7a87d9 --- /dev/null +++ b/src/domain/services/types/service.ts @@ -0,0 +1,11 @@ +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +export interface Service { + id: string | number; + name: string; + trade: string; + iconKey: string; + requiresCompletionDocument: boolean; + isActive: boolean; + supportedWorkOrderTypes: WOType[]; +} diff --git a/src/domain/services/use-cases/use-services-for-work-order-type.ts b/src/domain/services/use-cases/use-services-for-work-order-type.ts new file mode 100644 index 00000000..6dd8d4ac --- /dev/null +++ b/src/domain/services/use-cases/use-services-for-work-order-type.ts @@ -0,0 +1,25 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { servicesApi } from "@/domain/services/api/services-api"; +import type { Service } from "@/domain/services/types/service"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useServicesForWorkOrderType( + workOrderType: WOType | "", +): UseQueryResult { + const effectiveType = workOrderType || "PM"; + return useQuery({ + queryKey: queryKeys.services.byWorkOrderType(effectiveType), + queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType), + enabled: effectiveType !== "Overdue", + staleTime: 60 * 1000, + }); +} + +export function useActiveServices(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.services.active(), + queryFn: () => servicesApi.getActive(), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts index 32e87840..e45381e7 100644 --- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts @@ -19,6 +19,10 @@ export function assignWizardServicePocFields( payload.primaryService = primaryService; } + if (draft.serviceId != null) { + payload.serviceId = draft.serviceId; + } + const serviceNotes = trimOrUndefined(draft.pmNote); if (serviceNotes) { diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index d1d4afc3..2f019989 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -400,6 +400,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { locationId: typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "", pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service", "trade", "Trade"), + serviceId: readNumber(item, "serviceId", "ServiceId"), serviceNotes: readString(item, "serviceNotes", "ServiceNotes", "pmNote", "PmNote"), extraServices: readStringArray(item, "extraServices", "ExtraServices"), vendorId: vendorIdNum != null ? String(vendorIdNum) : "", diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 4c5606b4..b7f2693a 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -105,6 +105,8 @@ export interface WorkOrderBoardCreatePayload { trade?: string; /** Primary service label; persisted as board `pm`. */ primaryService?: string; + /** Registry service identity; the backend validates active/type-compatible selections. */ + serviceId?: string | number; /** Services beyond primary; order preserved. */ extraServices?: string[]; /** Issue detail from Service & Notes (`pmNote` in draft). */ @@ -171,6 +173,7 @@ export interface WorkOrderBoardRowApi { originalDate?: string | null; originalWeek?: string | null; workOrderType?: number | null; + serviceId?: number | null; isPastDue?: boolean; siteCode?: string | null; locationName?: string | null; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index d362f931..179f6c95 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -60,6 +60,7 @@ export interface WorkOrderTableRow { location: string; locationId: string | number; pm: string; + serviceId?: string | number | null; /** Issue detail from Service & Notes. */ serviceNotes: string; /** Additional services beyond primary `pm`. */ diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index f13b387e..ca1e94ad 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -37,6 +37,8 @@ export interface WorkOrderWizardDraft { scheduledOn: string; dueDate: string; pm: string; + /** Registry service selected for this work order; null keeps the optional field empty. */ + serviceId: string | number | null; /** Extra services beyond primary `pm` (Service & Notes). */ extraServices: string[]; /** Free-text note attached to the service (Service & Notes). */ @@ -82,6 +84,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { scheduledOn: "", dueDate: "", pm: "", + serviceId: null, extraServices: [], pmNote: "", avetaRequired: false, diff --git a/src/domain/work-orders/utils/wo-number-input.ts b/src/domain/work-orders/utils/wo-number-input.ts new file mode 100644 index 00000000..7f3ae624 --- /dev/null +++ b/src/domain/work-orders/utils/wo-number-input.ts @@ -0,0 +1,33 @@ +import { isRealWoNumber } from "@/domain/work-orders/utils/wo-closability"; + +/** Board WO numbers are an 11-digit APM number or an SH placeholder of at most 11 characters. */ +export const BOARD_WO_NUMBER_MAX_LENGTH = 11; + +export const WO_NUMBER_FORMAT_MESSAGE = + "Work order number must be 11 digits or an SH number (e.g. SH00001)"; + +export const WO_NUMBER_DOWNGRADE_MESSAGE = + "Cannot replace an APM work order number with an internal number"; + +export function sanitizeBoardWoNumberInput(raw: string): string { + return raw + .replace(/[^a-zA-Z0-9]/g, "") + .toUpperCase() + .slice(0, BOARD_WO_NUMBER_MAX_LENGTH); +} + +export function isProvisionalWoNumber(woNumber: string): boolean { + return /^SH/i.test(woNumber.trim()); +} + +export function isValidBoardWoNumber(woNumber: string): boolean { + return /^\d{11}$/.test(woNumber) || /^SH\d{1,9}$/i.test(woNumber); +} + +/** + * Only an already-saved real APM number is protected from being replaced by an + * SH placeholder. A brand-new WO has no saved number, so the rule never applies there. + */ +export function isWoNumberDowngrade(savedWoNumber: string, nextWoNumber: string): boolean { + return isRealWoNumber(savedWoNumber) && isProvisionalWoNumber(nextWoNumber); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 8e687e6b..05ce7b90 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -61,6 +61,12 @@ export const queryKeys = { byCategory: (category: string, parent?: string) => [...queryKeys.dropdownOptions.all, "byCategory", category, parent ?? ""] as const, }, + services: { + all: ["services"] as const, + active: () => [...queryKeys.services.all, "active"] as const, + byWorkOrderType: (workOrderType: string) => + [...queryKeys.services.all, "byWorkOrderType", workOrderType] as const, + }, locations: { all: ["locations"] as const, list: (params?: Record) => diff --git a/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx new file mode 100644 index 00000000..ad16d2e8 --- /dev/null +++ b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => { + const toastFn = Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }); + return { toast: toastFn }; +}); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { findByWoNumber: vi.fn() }, +})); + +function renderCell(value: string, currentId: string | number) { + const onSave = vi.fn(); + renderWithProviders( + , + ); + return onSave; +} + +function typeAndCommit(nextValue: string) { + fireEvent.click(screen.getByText(/./, { selector: "span" })); + const input = screen.getByLabelText("Work order number"); + fireEvent.change(input, { target: { value: nextValue } }); + fireEvent.keyDown(input, { key: "Enter" }); +} + +describe("EditableWONumberCell SH placeholder numbers", () => { + beforeEach(() => { + vi.mocked(workOrdersApi.findByWoNumber).mockResolvedValue(null as never); + vi.mocked(toast).mockClear(); + vi.mocked(toast.error).mockClear(); + }); + + it("saves a freshly typed SH number on a brand-new inline work order", async () => { + const onSave = renderCell("", "draft"); + + typeAndCommit("SH00001"); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00001")); + expect(toast).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("keeps a typed SH prefix instead of stripping it to digits", () => { + renderCell("", "draft"); + + fireEvent.click(screen.getByText("-")); + const input = screen.getByLabelText("Work order number"); + fireEvent.change(input, { target: { value: "sh00001" } }); + + expect(input).toHaveValue("SH00001"); + }); + + it("does not treat an unsaved inline draft number as a real number to protect", async () => { + const onSave = renderCell("12345678901", "draft"); + + typeAndCommit("SH00002"); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00002")); + }); + + it("blocks replacing a saved real APM number with an SH number", async () => { + const onSave = renderCell("12345678901", 42); + + typeAndCommit("SH00001"); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + "Cannot replace an APM work order number with an internal number", + ), + ); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText("12345678901")).toBeInTheDocument(); + }); + + it("lets a saved SH number be replaced by a real 11-digit number", async () => { + const onSave = renderCell("SH00001", 42); + + typeAndCommit("12345678901"); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("12345678901")); + }); + + it("still rejects a numeric number that is not 11 digits", () => { + const onSave = renderCell("", "draft"); + + typeAndCommit("12345"); + + expect(toast).toHaveBeenCalledWith( + "Work order number must be 11 digits or an SH number (e.g. SH00001)", + ); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/service-picker-search-placeholder.test.tsx b/src/test/app/(protected)/workorders/service-picker-search-placeholder.test.tsx new file mode 100644 index 00000000..7a2d7fa1 --- /dev/null +++ b/src/test/app/(protected)/workorders/service-picker-search-placeholder.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select"; +import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +describe("service picker search placeholder", () => { + it("uses a type-neutral search placeholder in the wizard service picker", () => { + const draft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, pm: "" }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /select service/i })); + + expect(screen.getByPlaceholderText("Search services…")).toBeTruthy(); + expect(screen.queryByPlaceholderText("Search PM type…")).toBeNull(); + }); + + it("uses a type-neutral search placeholder in the table service picker popover", () => { + const anchor = document.createElement("button"); + + render( + , + ); + + expect(screen.getByPlaceholderText("Search service…")).toBeTruthy(); + expect(screen.queryByPlaceholderText("Search PM type…")).toBeNull(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx index 3812fb63..c46f95df 100644 --- a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx @@ -79,6 +79,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( expect(onPatch).toHaveBeenCalledWith({ pm: "Electrical", + serviceId: "3", extraServices: [], vendorId: "", vendorName: "", diff --git a/src/test/app/(protected)/workorders/wizard-step-type-schedule-clears-service.test.tsx b/src/test/app/(protected)/workorders/wizard-step-type-schedule-clears-service.test.tsx new file mode 100644 index 00000000..9c9582ee --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-step-type-schedule-clears-service.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +const USERS = [{ id: "sarah", name: "Sarah Chen" }]; + +describe("WizardStepTypeSchedule — service clear on type change", () => { + it("clears the service and its vendor when the work-order type changes", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM", + pm: "HVAC", + serviceId: "1", + extraServices: ["Plumbing"], + vendorId: "9", + vendorName: "Old Vendor", + techPhone: "555-0100", + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Reactive/ })); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "Reactive", + pm: "", + serviceId: null, + extraServices: [], + vendorId: "", + vendorName: "", + techPhone: "", + }), + ); + }); + + it("keeps the selected service when the same type is re-selected", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM", + pm: "HVAC", + serviceId: "1", + extraServices: ["Plumbing"], + vendorId: "9", + vendorName: "Old Vendor", + techPhone: "555-0100", + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Scheduled preventive maintenance/ })); + + const patch = onPatch.mock.calls[0][0] as Partial; + expect(patch).not.toHaveProperty("pm"); + expect(patch).not.toHaveProperty("serviceId"); + expect(patch).not.toHaveProperty("extraServices"); + expect(patch).not.toHaveProperty("vendorId"); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx b/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx new file mode 100644 index 00000000..aeee1da9 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx @@ -0,0 +1,116 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols"; +import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head"; +import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => cell:service, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => cell:vendor, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => cell:appt, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => cell:status, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => cell:completed, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({ + CompDocCell: () => cell:doc, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/uplift-cell", () => ({ + UpliftCell: () => cell:uplift, +})); + +/** Documented left-to-right data column order for the Work Orders board. */ +const DOCUMENTED_ORDER = [ + "SITE", + "WO", + "TYPE OF WO", + "ASSIGNED TO", + "SCHEDULE ON", + "DUE DATE", + "SERVICE", + "VENDOR", + "APPT TIME", + "STATUS", + "COMPLETED DATE", + "COMP DOC", + "UPLIFT", +]; + +describe("Work Orders board column order", () => { + it("renders the documented header order with grip/flag before Site and actions after Uplift", () => { + renderWithProviders( + + +
, + { withAuth: false }, + ); + + const headers = screen.getAllByRole("columnheader").map((th) => th.textContent?.trim() ?? ""); + expect(headers.filter(Boolean)).toEqual(expect.arrayContaining(DOCUMENTED_ORDER)); + expect(WO_TABLE_COLS.map((col) => col.key)).toEqual([ + "grip", + "flag", + "site", + "wo", + "type", + "assigned", + "sched", + "due", + "pm", + "vendor", + "appt", + "status", + "completed", + "doc", + "uplift", + "actions", + ]); + expect(WO_TABLE_COLS.map((col) => col.label).filter(Boolean)).toEqual(DOCUMENTED_ORDER); + }); + + it("renders row cells under the matching headers: Status, Completed Date, Comp Doc, Uplift", () => { + const row = { + id: 7, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + } as WorkOrderTableRow; + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); + + const cells = within(screen.getByTestId("row")) + .getAllByRole("cell") + .map((td) => td.textContent); + expect(cells).toEqual([ + "cell:service", + "cell:vendor", + "cell:appt", + "cell:status", + "cell:completed", + "cell:doc", + "cell:uplift", + ]); + }); +}); diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts new file mode 100644 index 00000000..ab558d03 --- /dev/null +++ b/src/test/domain/services/api/services-api.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), +})); + +import { servicesApi } from "@/domain/services/api/services-api"; + +describe("servicesApi", () => { + beforeEach(() => { + apiGet.mockReset(); + }); + + it("loads active services filtered by the selected work-order type", async () => { + apiGet.mockResolvedValue([ + { + id: 7, + name: "HVAC inspection", + trade: "HVAC", + iconKey: "hvac-inspection", + isActive: true, + supportedWorkOrderTypes: [2, "Reactive"], + }, + ]); + + const services = await servicesApi.getActiveForWorkOrderType("PM"); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: 2 }, + }); + expect(services[0]).toMatchObject({ + id: 7, + name: "HVAC inspection", + trade: "HVAC", + supportedWorkOrderTypes: ["PM", "Reactive"], + }); + }); + + it("does not request services for the unsupported Overdue type", async () => { + const services = await servicesApi.getActiveForWorkOrderType("Overdue"); + + expect(apiGet).not.toHaveBeenCalled(); + expect(services).toEqual([]); + }); +}); 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 75f1e499..c4599641 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 @@ -140,6 +140,25 @@ describe("workOrdersApi.createFromBoard", () => { locationId: 9, }); }); + + it("includes the registry service id when selected in the wizard", async () => { + await workOrdersApi.createFromBoard({ + ...EMPTY_WIZARD_DRAFT, + type: "Reactive", + siteCode: "BK5", + pm: "HVAC inspection", + serviceId: 7, + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.workOrder.boardCreate, + expect.objectContaining({ + workOrderType: 6, + primaryService: "HVAC inspection", + serviceId: 7, + }), + ); + }); }); describe("workOrdersApi.update", () => { diff --git a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts index 9b74c71a..1820df72 100644 --- a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts +++ b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts @@ -4,10 +4,11 @@ import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; describe("COMPLETED DATE board column wiring", () => { - it("includes COMPLETED DATE column after COMP DOC", () => { + it("includes COMPLETED DATE column between STATUS and COMP DOC", () => { const keys = WO_TABLE_COLS.map((col) => col.key); expect(keys).toContain("completed"); - expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc")); + expect(keys.indexOf("completed")).toBe(keys.indexOf("status") + 1); + expect(keys.indexOf("completed")).toBeLessThan(keys.indexOf("doc")); }); it("expands completedDate patch to board field op", () => {