fix(work-orders): treat vendor save as a live company assignment

Clear leftover primary dispatch status on vendor patch so Completed is not
blocked after a new company is chosen. Keep Jira keys out of source comments.
This commit is contained in:
Arthur Bassi 2026-08-20 14:17:20 -03:00
parent 72de2ea78d
commit 4c9f8b7d81
14 changed files with 164 additions and 53 deletions

View file

@ -63,6 +63,10 @@ a status or mark unverified work Done.
- **MUST NOT** leave comments that only restate what Prettier or ESLint already - **MUST NOT** leave comments that only restate what Prettier or ESLint already
enforces (formatting, naming nits the linter catches). Style is settled by the enforces (formatting, naming nits the linter catches). Style is settled by the
gates; review is for behavior, correctness, security, and architecture. gates; review is for behavior, correctness, security, and architecture.
- **MUST NOT** put Jira issue keys or ticket titles in source comments, JSDoc,
or test names (for example `(SH-183)`). Ticket identity belongs in the PR,
commit message, and branch — not in the code. Flag and request removal if a
diff adds them.
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an - **MUST** make every comment actionable: tie it to a behavior, a risk, or an
evidence-based convention in these docs, and offer a concrete fix or a evidence-based convention in these docs, and offer a concrete fix or a
targeted question. Use GitHub suggestion blocks when safe. targeted question. Use GitHub suggestion blocks when safe.

View file

@ -15,6 +15,7 @@ import type {
} from "@/domain/work-orders/types/work-order"; } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { DocBadge } from "./slide-over-doc-badge"; import { DocBadge } from "./slide-over-doc-badge";
type SlideOverInfoTabEditViewProps = { type SlideOverInfoTabEditViewProps = {
@ -140,14 +141,7 @@ export function SlideOverInfoTabEditView({
tech={draft.tech} tech={draft.tech}
techPhone={draft.techPhone} techPhone={draft.techPhone}
vendors={vendors} vendors={vendors}
onSave={(patch) => onSave={(patch) => onDraftChange(toVendorTablePatch(patch))}
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
/> />
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Status"> <SlideOverInfoRow label="Status">

View file

@ -16,6 +16,7 @@ import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell"; import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell"; import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell"; import { VendorCell } from "./cells/vendor-cell";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { toInlineDraftRow } from "./to-inline-draft-row"; import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells"; import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
@ -193,14 +194,7 @@ export function WoTableInlineRowCells({
tech={draft.tech} tech={draft.tech}
techPhone={draft.techPhone} techPhone={draft.techPhone}
vendors={vendors} vendors={vendors}
onSave={(p) => onSave={(p) => onPatch(toVendorTablePatch(p))}
onPatch({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
/> />
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>

View file

@ -10,6 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell"; import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import type { WoTableRowHandlers } from "./wo-table-row"; import type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = { type WoTableRowServiceCellsProps = {
@ -67,14 +68,7 @@ export function WoTableRowServiceCells({
techPhone={row.techPhone} techPhone={row.techPhone}
vendors={vendors} vendors={vendors}
q={search} q={search}
onSave={(p) => onSave={(p) => onPatchRow(toVendorTablePatch(p))}
onPatchRow({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
/> />
</td> </td>
<td <td

View file

@ -42,7 +42,6 @@ export interface WorkOrderBoardDetailInfoApi {
dayGroup?: string | null; dayGroup?: string | null;
vendorId?: number | null; vendorId?: number | null;
vendorName?: string | null; vendorName?: string | null;
/** Exact board DTO field; inactive primary dispatch is not a live company (SH-183). */
primaryDispatchStatus?: string | null; primaryDispatchStatus?: string | null;
techName?: string | null; techName?: string | null;
techPhone?: string | null; techPhone?: string | null;

View file

@ -184,7 +184,6 @@ export interface WorkOrderBoardRowApi {
dayGroup?: string | null; dayGroup?: string | null;
vendorId?: number | null; vendorId?: number | null;
vendorName?: string | null; vendorName?: string | null;
/** Exact board DTO field; inactive primary dispatch is not a live company (SH-183). */
primaryDispatchStatus?: string | null; primaryDispatchStatus?: string | null;
techName?: string | null; techName?: string | null;
techPhone?: string | null; techPhone?: string | null;

View file

@ -37,7 +37,6 @@ export interface WorkOrderTableRow {
extraServices: string[]; extraServices: string[];
vendorId: string; vendorId: string;
company: string; company: string;
/** Primary dispatch status when the board contract supplies it (SH-183). */
primaryDispatchStatus?: string; primaryDispatchStatus?: string;
tech: string; tech: string;
techPhone: string; techPhone: string;

View file

@ -3,7 +3,7 @@ import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-deta
/** /**
* Overlay authoritative closability fields from board detail onto a legacy GetById detail. * Overlay authoritative closability fields from board detail onto a legacy GetById detail.
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114/SH-183 sources. * Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114 sources.
*/ */
export function enrichDetailClosabilityFromBoard( export function enrichDetailClosabilityFromBoard(
detail: WorkOrderDetail, detail: WorkOrderDetail,

View file

@ -0,0 +1,18 @@
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
export type VendorAssignmentFields = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
export function toVendorTablePatch(fields: VendorAssignmentFields): WorkOrderTablePatch {
return {
vendorId: fields.vendorId,
company: fields.company,
tech: fields.tech,
techPhone: fields.techPhone,
primaryDispatchStatus: "",
};
}

View file

@ -7,11 +7,11 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */ /** Fields required to evaluate whether a WO may be marked Completed. */
export type WoClosabilityInput = { export type WoClosabilityInput = {
dispatcherId: string; dispatcherId: string;
dispatcherName: string; dispatcherName: string;
/** Company / vendor assignment — required for Completed (SH-183). */ /** Company / vendor assignment — required for Completed. */
company: string; company: string;
/** Board vendor id; assignment is valid with vendorId even when company label is empty. */ /** Board vendor id; assignment is valid with vendorId even when company label is empty. */
vendorId: string; vendorId: string;
@ -158,14 +158,12 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
}; };
} }
/** Backend terminal-cancel spellings; dispatch status is a raw API string. */
const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]); const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]);
export function isInactiveDispatchStatus(status: string | undefined): boolean { export function isInactiveDispatchStatus(status: string | undefined): boolean {
return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase()); return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase());
} }
/** Map board row onto closability; inactive primary dispatch is not a live company (SH-183). */
export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput { export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput {
const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus); const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus);
return { return {
@ -185,7 +183,6 @@ export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosability
}; };
} }
/** Current vendor assignment — cancelled/refused dispatches do not count as company (SH-183). */
function currentAssignedDispatch( function currentAssignedDispatch(
dispatches: WorkOrderDispatchSummary[], dispatches: WorkOrderDispatchSummary[],
): WorkOrderDispatchSummary | undefined { ): WorkOrderDispatchSummary | undefined {

View file

@ -120,7 +120,7 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).toHaveBeenCalledWith("Completed"); expect(onChangeStatus).toHaveBeenCalledWith("Completed");
}); });
it("disables Completed when company is missing (SH-183)", () => { it("disables Completed when company is missing", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
renderWithProviders( renderWithProviders(
@ -141,7 +141,7 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).not.toHaveBeenCalled(); expect(onChangeStatus).not.toHaveBeenCalled();
}); });
it("allows Completed when vendorId is set even if company label is empty (SH-183)", () => { it("allows Completed when vendorId is set even if company label is empty", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
renderWithProviders( renderWithProviders(
@ -159,7 +159,7 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).toHaveBeenCalledWith("Completed"); expect(onChangeStatus).toHaveBeenCalledWith("Completed");
}); });
it("allows Completed when vendorId has an empty company label and a live dispatch status (SH-183)", () => { it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
renderWithProviders( renderWithProviders(
@ -178,7 +178,7 @@ describe("StatusCell pending uplift closability", () => {
}); });
it.each(["Cancelled", "Canceled", "Refused"] as const)( it.each(["Cancelled", "Canceled", "Refused"] as const)(
"disables Completed when vendorId is leftover from a %s dispatch (SH-183)", "disables Completed when vendorId is leftover from a %s dispatch",
(primaryDispatchStatus) => { (primaryDispatchStatus) => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
@ -206,7 +206,54 @@ describe("StatusCell pending uplift closability", () => {
}, },
); );
it("allows Completed when technician is empty if company is set (SH-183)", () => { it("disables Completed when the board omits vendorId after a Refused primary", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "",
vendorId: "",
tech: "",
primaryDispatchStatus: "Refused",
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
});
it("allows Completed after a vendor patch clears leftover refused status", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "New Co",
vendorId: "9",
tech: "Pat",
primaryDispatchStatus: "",
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("allows Completed when technician is empty if company is set", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
renderWithProviders( renderWithProviders(

View file

@ -61,13 +61,27 @@ describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
expect(row.primaryDispatchStatus).toBe(value); expect(row.primaryDispatchStatus).toBe(value);
}); });
it("ignores undocumented dispatchStatus aliases", () => { it("maps backend #74 omitted vendor with Refused primaryDispatchStatus", () => {
const row = mapWorkOrderTableRow({
id: 9,
vendorId: null,
vendorName: null,
primaryDispatchStatus: "Refused",
});
expect(row.vendorId).toBe("");
expect(row.company).toBe("");
expect(row.primaryDispatchStatus).toBe("Refused");
});
it("maps live vendorId with empty company label and Sent status", () => {
const row = mapWorkOrderTableRow({ const row = mapWorkOrderTableRow({
id: 9, id: 9,
vendorId: 45, vendorId: 45,
dispatchStatus: "Refused", vendorName: "",
vendorDispatchStatus: "Cancelled", primaryDispatchStatus: "Sent",
}); });
expect(row.primaryDispatchStatus).toBe(""); expect(row.vendorId).toBe("45");
expect(row.company).toBe("");
expect(row.primaryDispatchStatus).toBe("Sent");
}); });
}); });

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import {
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
describe("toVendorTablePatch", () => {
it("clears leftover primaryDispatchStatus so a new vendor is a live assignment", () => {
const patch = toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "Pat",
techPhone: "555",
});
expect(patch.primaryDispatchStatus).toBe("");
expect(patch.vendorId).toBe("9");
const input = tableRowToClosabilityInput({
dispatcherId: "u1",
dispatcherName: "Alex",
company: patch.company ?? "",
vendorId: patch.vendorId ?? "",
tech: patch.tech ?? "",
completedDate: "2026-07-15",
woNumber: "20260623001",
pm: "HVAC PM",
docStatus: "Yes",
mediaCount: 1,
type: "PM",
primaryDispatchStatus: patch.primaryDispatchStatus,
} as WorkOrderTableRow);
expect(getClosabilityGaps(input)).not.toContain("Company");
});
});

View file

@ -77,7 +77,7 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]); expect(getClosabilityGaps(makeInput())).toEqual([]);
}); });
it("allows company without technician (SH-183)", () => { it("allows company without technician", () => {
const gaps = getClosabilityGaps(makeInput({ tech: "" })); const gaps = getClosabilityGaps(makeInput({ tech: "" }));
expect(gaps).toEqual([]); expect(gaps).toEqual([]);
expect(gaps).not.toContain("Technician"); expect(gaps).not.toContain("Technician");
@ -88,7 +88,7 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
}); });
it("allows Completed when vendorId is set and company/technician are empty (SH-183)", () => { it("allows Completed when vendorId is set and company/technician are empty", () => {
expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]); expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]);
}); });
@ -99,7 +99,7 @@ describe("getClosabilityGaps", () => {
}); });
it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)( it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)(
"blocks Completed without a company for type %s (SH-183)", "blocks Completed without a company for type %s",
(type) => { (type) => {
const gaps = getClosabilityGaps( const gaps = getClosabilityGaps(
makeInput({ makeInput({
@ -309,7 +309,7 @@ describe("detailToClosabilityInput", () => {
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]);
}); });
it("treats vendorId without vendorName as assigned company (SH-183)", () => { it("treats vendorId without vendorName as assigned company", () => {
const detail = { const detail = {
assignedTo: "Alice", assignedTo: "Alice",
completedDate: "2026-07-15", completedDate: "2026-07-15",
@ -330,7 +330,7 @@ describe("detailToClosabilityInput", () => {
}); });
it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])( it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])(
"ignores inactive dispatch status %s when resolving assigned company (SH-183)", "ignores inactive dispatch status %s when resolving assigned company",
(status) => { (status) => {
const inactiveOnly = { const inactiveOnly = {
assignedTo: "Alice", assignedTo: "Alice",
@ -352,7 +352,7 @@ describe("detailToClosabilityInput", () => {
); );
it.each(["Verified", "Completed", "Sent"])( it.each(["Verified", "Completed", "Sent"])(
"treats dispatch status %s as a live company assignment (SH-183)", "treats dispatch status %s as a live company assignment",
(status) => { (status) => {
const liveAssignment = { const liveAssignment = {
assignedTo: "Alice", assignedTo: "Alice",
@ -374,7 +374,7 @@ describe("detailToClosabilityInput", () => {
}, },
); );
it("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => { it("uses the active dispatch after an inactive cancelled spelling", () => {
const supersededThenCurrent = { const supersededThenCurrent = {
assignedTo: "Alice", assignedTo: "Alice",
completedDate: "2026-07-15", completedDate: "2026-07-15",
@ -413,14 +413,14 @@ describe("tableRowToClosabilityInput", () => {
type: "", type: "",
} as WorkOrderTableRow; } as WorkOrderTableRow;
it("keeps vendorId as a live assignment when primary dispatch status is absent (SH-183)", () => { it("keeps vendorId as a live assignment when primary dispatch status is absent", () => {
const input = tableRowToClosabilityInput(closableRow); const input = tableRowToClosabilityInput(closableRow);
expect(input.vendorId).toBe("45"); expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]); expect(getClosabilityGaps(input)).toEqual([]);
}); });
it.each(["Cancelled", "Canceled", "Refused"] as const)( it.each(["Cancelled", "Canceled", "Refused"] as const)(
"clears company assignment when primary dispatch status is %s (SH-183)", "clears company assignment when primary dispatch status is %s",
(primaryDispatchStatus) => { (primaryDispatchStatus) => {
const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus }); const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus });
expect(input.company).toBe(""); expect(input.company).toBe("");
@ -429,11 +429,26 @@ describe("tableRowToClosabilityInput", () => {
}, },
); );
it("raises Company when the board omits vendor assignment (inactive primary, SH-183)", () => { it("raises Company when the board omits vendor assignment (inactive primary)", () => {
const input = tableRowToClosabilityInput({ ...closableRow, vendorId: "", company: "" }); const input = tableRowToClosabilityInput({
...closableRow,
vendorId: "",
company: "",
primaryDispatchStatus: "Refused",
});
expect(input.vendorId).toBe(""); expect(input.vendorId).toBe("");
expect(getClosabilityGaps(input)).toContain("Company"); expect(getClosabilityGaps(input)).toContain("Company");
}); });
it("keeps a live vendorId after a vendor patch clears leftover refused status", () => {
const input = tableRowToClosabilityInput({
...closableRow,
company: "New Co",
vendorId: "9",
primaryDispatchStatus: "",
});
expect(getClosabilityGaps(input)).toEqual([]);
});
}); });
describe("readDetailWoType", () => { describe("readDetailWoType", () => {