mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
19 commits
58bdd6bffa
...
19a54e38c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a54e38c6 | ||
|
|
8614f9415c | ||
|
|
c101110148 | ||
|
|
9a347a67fb | ||
|
|
d8da3a5048 | ||
|
|
972e5913f3 | ||
|
|
493104c56d | ||
|
|
4c9f8b7d81 | ||
|
|
72de2ea78d | ||
|
|
a156db70bc | ||
|
|
f77428ab19 | ||
|
|
5195749623 | ||
|
|
23a80a7a14 | ||
|
|
e9b00bab57 | ||
|
|
854267e213 | ||
|
|
b3899d2a51 | ||
|
|
e3b8360a68 | ||
|
|
a43de20ed4 | ||
|
|
1554cf30d0 |
28 changed files with 931 additions and 116 deletions
|
|
@ -63,6 +63,10 @@ a status or mark unverified work Done.
|
|||
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
|
||||
enforces (formatting, naming nits the linter catches). Style is settled by the
|
||||
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
|
||||
evidence-based convention in these docs, and offer a concrete fix or a
|
||||
targeted question. Use GitHub suggestion blocks when safe.
|
||||
|
|
|
|||
|
|
@ -80,6 +80,19 @@ test.describe("Work Orders week/day navigation (SH-240)", () => {
|
|||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const dispatcher = page.getByRole("button", { name: "All dispatchers" });
|
||||
await expect(dispatcher).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Week and day").getByRole("button", { name: "All dispatchers" }),
|
||||
).toHaveCount(0);
|
||||
const titleBox = await page.getByRole("heading", { name: "Work Orders" }).boundingBox();
|
||||
const dispatcherBox = await dispatcher.boundingBox();
|
||||
const weekBox = await page.getByRole("button", { name: "Week 8/17–8/21" }).boundingBox();
|
||||
expect(titleBox).toBeTruthy();
|
||||
expect(dispatcherBox).toBeTruthy();
|
||||
expect(weekBox).toBeTruthy();
|
||||
expect(dispatcherBox!.y).toBeGreaterThan(titleBox!.y);
|
||||
expect(weekBox!.y).toBeGreaterThan(dispatcherBox!.y);
|
||||
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
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";
|
||||
|
||||
type SlideOverInfoTabEditViewProps = {
|
||||
|
|
@ -140,14 +141,7 @@ export function SlideOverInfoTabEditView({
|
|||
tech={draft.tech}
|
||||
techPhone={draft.techPhone}
|
||||
vendors={vendors}
|
||||
onSave={(patch) =>
|
||||
onDraftChange({
|
||||
vendorId: patch.vendorId,
|
||||
company: patch.company,
|
||||
tech: patch.tech,
|
||||
techPhone: patch.techPhone,
|
||||
})
|
||||
}
|
||||
onSave={(patch) => onDraftChange(toVendorTablePatch(patch))}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Status">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function userInitials(name: string): string {
|
|||
function DispatcherAvatar({ name, color }: { name: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
|
||||
style={{ background: `${color}25`, color }}
|
||||
>
|
||||
|
|
@ -75,8 +76,8 @@ export function DispatcherFilter({
|
|||
return (
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width={160}
|
||||
height={38}
|
||||
width={140}
|
||||
height={18}
|
||||
animation="wave"
|
||||
aria-label="Loading dispatchers"
|
||||
/>
|
||||
|
|
@ -89,35 +90,13 @@ export function DispatcherFilter({
|
|||
onAnchor={setAnchor}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
|
||||
style={
|
||||
!allSelected
|
||||
? {
|
||||
height: 38,
|
||||
padding: "0 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: "var(--color-primary-tint)",
|
||||
borderColor: "#bfe2f5",
|
||||
color: "var(--primary)",
|
||||
}
|
||||
: {
|
||||
height: 38,
|
||||
padding: "0 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: "var(--card)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}
|
||||
}
|
||||
className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors"
|
||||
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Users size={15} style={{ color: "var(--muted-foreground)" }} />
|
||||
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||
{label}
|
||||
<ChevronDown size={14} style={{ color: "var(--muted-foreground)" }} />
|
||||
<ChevronDown size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||
</MenuAnchorButton>
|
||||
|
||||
<Popover
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
|
||||
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
|
||||
import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
|
||||
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
|
@ -11,13 +9,8 @@ type WorkOrdersFilterBarProps = {
|
|||
onWeekChange: (mondayIso: string) => void;
|
||||
selectedDayIso: string;
|
||||
onSelectedDayChange: (dayIso: string) => void;
|
||||
dispatcherIds: Set<string>;
|
||||
onDispatcherChange: (ids: Set<string>) => void;
|
||||
typeFilter: WorkOrderTypeFilter;
|
||||
onTypeChange: (value: WorkOrderTypeFilter) => void;
|
||||
users: WorkOrderUser[];
|
||||
usersLoading?: boolean;
|
||||
currentUserId?: string | number;
|
||||
filteredCount: number;
|
||||
scopeTotalCount: number;
|
||||
disabled?: boolean;
|
||||
|
|
@ -28,13 +21,8 @@ export function WorkOrdersFilterBar({
|
|||
onWeekChange,
|
||||
selectedDayIso,
|
||||
onSelectedDayChange,
|
||||
dispatcherIds,
|
||||
onDispatcherChange,
|
||||
typeFilter,
|
||||
onTypeChange,
|
||||
users,
|
||||
usersLoading = false,
|
||||
currentUserId,
|
||||
filteredCount,
|
||||
scopeTotalCount,
|
||||
disabled = false,
|
||||
|
|
@ -46,29 +34,21 @@ export function WorkOrdersFilterBar({
|
|||
className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 pt-2">
|
||||
<DispatcherFilter
|
||||
selectedIds={dispatcherIds}
|
||||
onChange={onDispatcherChange}
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={currentUserId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex items-center gap-3 px-4 pt-2" aria-label="Week and day">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||
<DayTabs
|
||||
weekMonday={weekMonday}
|
||||
selectedDayIso={selectedDayIso}
|
||||
onChange={onSelectedDayChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Text variant="caption" tone="muted" className="shrink-0">
|
||||
{resultLabel}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-4 pt-1" aria-label="Week and day">
|
||||
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||
<DayTabs
|
||||
weekMonday={weekMonday}
|
||||
selectedDayIso={selectedDayIso}
|
||||
onChange={onSelectedDayChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-4 pt-1 pb-2">
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
|
@ -22,20 +23,7 @@ type StatusCellProps = {
|
|||
};
|
||||
|
||||
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
||||
return getClosabilityGaps({
|
||||
dispatcherId: wo.dispatcherId,
|
||||
dispatcherName: wo.dispatcherName,
|
||||
company: wo.company,
|
||||
tech: wo.tech,
|
||||
completedDate: wo.completedDate ?? "",
|
||||
woNumber: wo.woNumber,
|
||||
pm: wo.pm,
|
||||
docStatus: wo.docStatus,
|
||||
mediaCount: wo.mediaCount,
|
||||
type: wo.type,
|
||||
upliftSummary: wo.upliftSummary,
|
||||
hasPendingUplift: wo.hasPendingUplift,
|
||||
});
|
||||
return getClosabilityGaps(tableRowToClosabilityInput(wo));
|
||||
}
|
||||
|
||||
function PastDueStatusCell({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { PMTypeCell } from "./cells/pm-type-cell";
|
|||
import { StatusCell } from "./cells/status-cell";
|
||||
import { TypeCell } from "./cells/type-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 { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
|
||||
|
||||
|
|
@ -193,14 +194,7 @@ export function WoTableInlineRowCells({
|
|||
tech={draft.tech}
|
||||
techPhone={draft.techPhone}
|
||||
vendors={vendors}
|
||||
onSave={(p) =>
|
||||
onPatch({
|
||||
vendorId: p.vendorId,
|
||||
company: p.company,
|
||||
tech: p.tech,
|
||||
techPhone: p.techPhone,
|
||||
})
|
||||
}
|
||||
onSave={(p) => onPatch(toVendorTablePatch(p))}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell";
|
|||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
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";
|
||||
|
||||
type WoTableRowServiceCellsProps = {
|
||||
|
|
@ -67,14 +68,7 @@ export function WoTableRowServiceCells({
|
|||
techPhone={row.techPhone}
|
||||
vendors={vendors}
|
||||
q={search}
|
||||
onSave={(p) =>
|
||||
onPatchRow({
|
||||
vendorId: p.vendorId,
|
||||
company: p.company,
|
||||
tech: p.tech,
|
||||
techPhone: p.techPhone,
|
||||
})
|
||||
}
|
||||
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Box } from "@mui/material";
|
|||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
|
||||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
|
||||
|
|
@ -30,6 +31,16 @@ export default function WorkOrdersListPage() {
|
|||
surface
|
||||
eyebrow="OPERATIONS"
|
||||
title="Work Orders"
|
||||
belowTitle={
|
||||
<DispatcherFilter
|
||||
selectedIds={filters.dispatcherIds}
|
||||
onChange={filters.setDispatcherIds}
|
||||
users={page.users}
|
||||
usersLoading={page.usersLoading}
|
||||
currentUserId={user?.id}
|
||||
disabled={filters.advApplied != null}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<WorkOrdersListHeader
|
||||
search={filters.search}
|
||||
|
|
@ -47,13 +58,8 @@ export default function WorkOrdersListPage() {
|
|||
onWeekChange={filters.setWeekMonday}
|
||||
selectedDayIso={filters.selectedDayIso}
|
||||
onSelectedDayChange={filters.setSelectedDayIso}
|
||||
dispatcherIds={filters.dispatcherIds}
|
||||
onDispatcherChange={filters.setDispatcherIds}
|
||||
typeFilter={filters.typeFilter}
|
||||
onTypeChange={filters.setTypeFilter}
|
||||
users={page.users}
|
||||
usersLoading={page.usersLoading}
|
||||
currentUserId={user?.id}
|
||||
filteredCount={tableData.totalCount}
|
||||
scopeTotalCount={tableData.scopeTotalCount}
|
||||
disabled={filters.advApplied != null}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ReactElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Stack } from "@mui/material";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
|
@ -8,6 +8,7 @@ type PageHeaderProps = {
|
|||
eyebrow?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
belowTitle?: ReactNode;
|
||||
actions?: ReactElement;
|
||||
className?: string;
|
||||
/** White surface bar (list page headers). */
|
||||
|
|
@ -18,6 +19,7 @@ export function PageHeader({
|
|||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
belowTitle,
|
||||
actions,
|
||||
className,
|
||||
surface = false,
|
||||
|
|
@ -63,6 +65,7 @@ export function PageHeader({
|
|||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{Boolean(belowTitle) && <div className="mt-1">{belowTitle}</div>}
|
||||
</Stack>
|
||||
{Boolean(actions) && (
|
||||
<Stack
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
|||
"originalDate",
|
||||
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
|
||||
"isAddOn",
|
||||
"primaryDispatchStatus",
|
||||
]);
|
||||
|
||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||
|
|
|
|||
|
|
@ -267,6 +267,10 @@ function readHasPendingUplift(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function readPrimaryDispatchStatus(item: Record<string, unknown>): string {
|
||||
return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus");
|
||||
}
|
||||
|
||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||
const item = asRecord(raw);
|
||||
const scheduledOn = toDateInputValue(
|
||||
|
|
@ -348,6 +352,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
|
||||
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
|
||||
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
||||
primaryDispatchStatus: readPrimaryDispatchStatus(item),
|
||||
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
|
||||
techPhone: readString(item, "techPhone", "TechPhone"),
|
||||
apptStart,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export interface WorkOrderBoardDetailInfoApi {
|
|||
dayGroup?: string | null;
|
||||
vendorId?: number | null;
|
||||
vendorName?: string | null;
|
||||
primaryDispatchStatus?: string | null;
|
||||
techName?: string | null;
|
||||
techPhone?: string | null;
|
||||
apptDate?: string | null;
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export interface WorkOrderBoardRowApi {
|
|||
dayGroup?: string | null;
|
||||
vendorId?: number | null;
|
||||
vendorName?: string | null;
|
||||
primaryDispatchStatus?: string | null;
|
||||
techName?: string | null;
|
||||
techPhone?: string | null;
|
||||
apptDate?: string | null;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface WorkOrderTableRow {
|
|||
extraServices: string[];
|
||||
vendorId: string;
|
||||
company: string;
|
||||
primaryDispatchStatus?: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
apptStart: string;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* 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(
|
||||
detail: WorkOrderDetail,
|
||||
|
|
|
|||
23
src/domain/work-orders/utils/vendor-assignment-patch.ts
Normal file
23
src/domain/work-orders/utils/vendor-assignment-patch.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
export type VendorAssignmentFields = {
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
};
|
||||
|
||||
export type VendorTablePatch = Pick<
|
||||
WorkOrderTableRow,
|
||||
"vendorId" | "company" | "tech" | "techPhone" | "primaryDispatchStatus"
|
||||
>;
|
||||
|
||||
export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTablePatch {
|
||||
return {
|
||||
vendorId: fields.vendorId,
|
||||
company: fields.company,
|
||||
tech: fields.tech,
|
||||
techPhone: fields.techPhone,
|
||||
primaryDispatchStatus: "",
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispatch";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import type {
|
||||
CompletionDocStatus,
|
||||
WorkOrderTableRow,
|
||||
WorkOrderUpliftSummary,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
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 = {
|
||||
dispatcherId: string;
|
||||
dispatcherName: string;
|
||||
/** Company / vendor assignment — required for Completed (SH-183). */
|
||||
/** Company / vendor assignment — required for Completed. */
|
||||
company: string;
|
||||
/** Board vendor id; assignment is valid with vendorId even when company label is empty. */
|
||||
vendorId: string;
|
||||
/** Technician is optional for closability; modeled separately from company. */
|
||||
tech: string;
|
||||
/** Work completed date (API CompletedDate). Empty until set. */
|
||||
|
|
@ -57,8 +61,8 @@ function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatche
|
|||
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
|
||||
}
|
||||
|
||||
function hasCompany(company: string): boolean {
|
||||
return Boolean(company.trim());
|
||||
function hasAssignedCompany(row: Pick<WoClosabilityInput, "company" | "vendorId">): boolean {
|
||||
return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").trim());
|
||||
}
|
||||
|
||||
function hasService(pm: string): boolean {
|
||||
|
|
@ -80,7 +84,7 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] {
|
|||
if (!hasDispatcher(row)) {
|
||||
gaps.push("Dispatcher");
|
||||
}
|
||||
if (!hasCompany(row.company)) {
|
||||
if (!hasAssignedCompany(row)) {
|
||||
gaps.push("Company");
|
||||
}
|
||||
if (!row.completedDate.trim()) {
|
||||
|
|
@ -143,6 +147,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
|
|||
dispatcherId: draft.assigneeId,
|
||||
dispatcherName: draft.assigneeName,
|
||||
company: draft.vendorName,
|
||||
vendorId: draft.vendorId ?? "",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: draft.woNumber,
|
||||
|
|
@ -153,19 +158,55 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
|
|||
};
|
||||
}
|
||||
|
||||
const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]);
|
||||
|
||||
export function isInactiveDispatchStatus(status: string | undefined): boolean {
|
||||
return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput {
|
||||
const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus);
|
||||
return {
|
||||
dispatcherId: wo.dispatcherId,
|
||||
dispatcherName: wo.dispatcherName,
|
||||
company: inactive ? "" : wo.company,
|
||||
vendorId: inactive ? "" : (wo.vendorId ?? ""),
|
||||
tech: wo.tech,
|
||||
completedDate: wo.completedDate ?? "",
|
||||
woNumber: wo.woNumber,
|
||||
pm: wo.pm,
|
||||
docStatus: wo.docStatus,
|
||||
mediaCount: wo.mediaCount,
|
||||
type: wo.type,
|
||||
upliftSummary: wo.upliftSummary,
|
||||
hasPendingUplift: wo.hasPendingUplift,
|
||||
};
|
||||
}
|
||||
|
||||
function currentAssignedDispatch(
|
||||
dispatches: WorkOrderDispatchSummary[],
|
||||
): WorkOrderDispatchSummary | undefined {
|
||||
return dispatches.find((dispatch) => {
|
||||
if (isInactiveDispatchStatus(dispatch.status)) {
|
||||
return false;
|
||||
}
|
||||
const vendorId = String(dispatch.vendorId ?? "").trim();
|
||||
return Boolean((dispatch.vendorName ?? "").trim() || vendorId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` /
|
||||
* `mediaCount` (enriched from board detail on the view route). Never treats `source`
|
||||
* as WorkOrderType. `problem` is the same service field the board maps into `pm`.
|
||||
*/
|
||||
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
|
||||
const vendorName = wo.dispatches.find((dispatch) =>
|
||||
Boolean(dispatch.vendorName.trim()),
|
||||
)?.vendorName;
|
||||
const assigned = currentAssignedDispatch(wo.dispatches);
|
||||
return {
|
||||
dispatcherId: "",
|
||||
dispatcherName: wo.assignedTo,
|
||||
company: vendorName ?? "",
|
||||
company: assigned?.vendorName ?? "",
|
||||
vendorId: assigned ? String(assigned.vendorId ?? "").trim() : "",
|
||||
tech: "",
|
||||
completedDate: wo.completedDate,
|
||||
woNumber: wo.workOrderNumber || wo.internalWONumber,
|
||||
|
|
|
|||
125
src/test/app/(protected)/workorders/dispatcher-filter.test.tsx
Normal file
125
src/test/app/(protected)/workorders/dispatcher-filter.test.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
|
||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const users = [
|
||||
{ id: "u1", name: "Alex Rivera" },
|
||||
{ id: "u2", name: "Blair Cho" },
|
||||
];
|
||||
|
||||
describe("DispatcherFilter (SH-242)", () => {
|
||||
it("renders a plain trigger without a boxed border", () => {
|
||||
renderWithProviders(
|
||||
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={users} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "All dispatchers" });
|
||||
expect(trigger.className.split(/\s+/)).not.toContain("border");
|
||||
expect(trigger.style.height).not.toBe("38px");
|
||||
expect(trigger.style.borderColor).toBe("");
|
||||
expect(trigger.style.background).toBe("");
|
||||
expect(trigger.style.fontWeight).toBe("500");
|
||||
});
|
||||
|
||||
it("opens the menu from the keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<DispatcherFilter
|
||||
selectedIds={new Set()}
|
||||
onChange={vi.fn()}
|
||||
users={users}
|
||||
currentUserId="u1"
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "All dispatchers" }).focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.getByRole("button", { name: "My WOs only" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading skeleton while dispatchers are fetching", () => {
|
||||
renderWithProviders(
|
||||
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={[]} usersLoading />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Loading dispatchers")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "All dispatchers" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables the trigger when requested", () => {
|
||||
renderWithProviders(
|
||||
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={users} disabled />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("labels My WOs, Unassigned, and a multi-select count", () => {
|
||||
const { rerender } = renderWithProviders(
|
||||
<DispatcherFilter
|
||||
selectedIds={new Set(["u1"])}
|
||||
onChange={vi.fn()}
|
||||
users={users}
|
||||
currentUserId="u1"
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "My WOs" })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DispatcherFilter
|
||||
selectedIds={new Set([ASSIGNEE_FILTER_UNASSIGNED])}
|
||||
onChange={vi.fn()}
|
||||
users={users}
|
||||
currentUserId="u1"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Unassigned" })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DispatcherFilter
|
||||
selectedIds={new Set(["u1", "u2"])}
|
||||
onChange={vi.fn()}
|
||||
users={users}
|
||||
currentUserId="u1"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "2 selected" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scopes All, My WOs, Unassigned, and a named dispatcher", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<DispatcherFilter
|
||||
selectedIds={new Set()}
|
||||
onChange={onChange}
|
||||
users={users}
|
||||
currentUserId="u1"
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "All dispatchers" }));
|
||||
await user.click(screen.getByRole("button", { name: "My WOs only" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(new Set(["u1"]));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Unassigned" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(new Set([ASSIGNEE_FILTER_UNASSIGNED]));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Blair Cho" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(new Set(["u2"]));
|
||||
|
||||
const menu = screen.getByRole("presentation");
|
||||
await user.click(within(menu).getByRole("button", { name: "All dispatchers" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(new Set());
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ const incompleteRow = {
|
|||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
company: "",
|
||||
vendorId: "",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: "",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const pastDueRow = {
|
|||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
company: "",
|
||||
vendorId: "",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: "",
|
||||
|
|
|
|||
|
|
@ -120,12 +120,12 @@ describe("StatusCell pending uplift closability", () => {
|
|||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
});
|
||||
|
||||
it("disables Completed when company is missing (SH-183)", () => {
|
||||
it("disables Completed when company is missing", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ company: "" })}
|
||||
wo={makeRow({ company: "", vendorId: "" })}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
|
|
@ -141,7 +141,119 @@ describe("StatusCell pending uplift closability", () => {
|
|||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows Completed when technician is empty if company is set (SH-183)", () => {
|
||||
it("allows Completed when vendorId is set even if company label is empty", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ company: "", vendorId: "7", tech: "" })}
|
||||
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 vendorId has an empty company label and a live dispatch status", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })}
|
||||
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.each(["Cancelled", "Canceled", "Refused"] as const)(
|
||||
"disables Completed when vendorId is leftover from a %s dispatch",
|
||||
(primaryDispatchStatus) => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({
|
||||
company: "",
|
||||
vendorId: "45",
|
||||
tech: "",
|
||||
primaryDispatchStatus,
|
||||
})}
|
||||
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");
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
renderWithProviders(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-router")>("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/providers/auth-context", () => ({
|
||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||
useWorkOrdersListFilters: () => ({
|
||||
search: "",
|
||||
handleSearchChange: vi.fn(),
|
||||
advOpen: false,
|
||||
setAdvOpen: vi.fn(),
|
||||
advApplied: null,
|
||||
advInitial: EMPTY_ADVANCED_FILTERS,
|
||||
applyAdvancedFilters: vi.fn(),
|
||||
clearAdvancedFilters: vi.fn(),
|
||||
clearAdvancedFilterChip: vi.fn(),
|
||||
activeFilterCount: 0,
|
||||
weekMonday: "2026-07-20",
|
||||
selectedDayIso: "2026-07-20",
|
||||
setWeekMonday: vi.fn(),
|
||||
setSelectedDayIso: vi.fn(),
|
||||
navigateToDay: vi.fn(),
|
||||
dispatcherIds: new Set<string>(),
|
||||
setDispatcherIds: vi.fn(),
|
||||
typeFilter: "All",
|
||||
setTypeFilter: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||
useWorkOrdersTableData: () => ({
|
||||
rows: [],
|
||||
totalCount: 0,
|
||||
scopeTotalCount: 0,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
setSelectedId: vi.fn(),
|
||||
patchRow: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||
useWorkOrderTableMutations: () => ({ patchField: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
|
||||
useWorkOrderUsers: () => ({
|
||||
data: [{ id: "u1", name: "Dispatcher One" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderLocations: () => ({ data: [] }),
|
||||
useWorkOrderVendors: () => ({ data: [] }),
|
||||
useWorkOrderBoardDetail: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||
useDropdownOptionsByCategory: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
|
||||
isWoTableMockEnabled: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({
|
||||
WoTable: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({
|
||||
MediaUploadModal: () => null,
|
||||
}));
|
||||
|
||||
describe("Work Orders dispatcher filter layout (SH-242)", () => {
|
||||
it("places the dispatcher trigger under the title and above week/day controls", () => {
|
||||
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
|
||||
|
||||
const title = screen.getByRole("heading", { name: "Work Orders" });
|
||||
const dispatcher = screen.getByRole("button", { name: "All dispatchers" });
|
||||
const weekAndDay = screen.getByLabelText("Week and day");
|
||||
const week = screen.getByRole("button", { name: "Week 7/20–7/24" });
|
||||
|
||||
expect(title.compareDocumentPosition(dispatcher) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(dispatcher.compareDocumentPosition(weekAndDay) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(weekAndDay.contains(dispatcher)).toBe(false);
|
||||
expect(weekAndDay.contains(week)).toBe(true);
|
||||
expect(screen.getByText("0 of 0")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
isLocalOnlyTablePatch,
|
||||
mapDocStatusToApi,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
|
||||
describe("mapDocStatusToApi", () => {
|
||||
it("maps NN to NotNeeded", () => {
|
||||
|
|
@ -25,6 +26,10 @@ describe("isLocalOnlyTablePatch", () => {
|
|||
it("does not treat site as local-only", () => {
|
||||
expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats primaryDispatchStatus overlay as local-only", () => {
|
||||
expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandTablePatchToBoardFieldOps", () => {
|
||||
|
|
@ -136,6 +141,17 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
}),
|
||||
).toEqual([{ field: "vendorId", value: "7" }]);
|
||||
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps(
|
||||
toVendorTablePatch({
|
||||
vendorId: "9",
|
||||
company: "New Co",
|
||||
tech: "Pat",
|
||||
techPhone: "555",
|
||||
}),
|
||||
),
|
||||
).toEqual([{ field: "vendorId", value: "9" }]);
|
||||
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
site: "BK5",
|
||||
|
|
|
|||
|
|
@ -50,3 +50,52 @@ describe("mapWorkOrderTableRow isAddOn", () => {
|
|||
expect(row.isAddOn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
|
||||
it.each([
|
||||
["primaryDispatchStatus", "Refused"],
|
||||
["PrimaryDispatchStatus", "Canceled"],
|
||||
] as const)("maps exact DTO field %s onto primaryDispatchStatus", (key, value) => {
|
||||
const row = mapWorkOrderTableRow({ id: 9, vendorId: 45, [key]: value });
|
||||
expect(row.vendorId).toBe("45");
|
||||
expect(row.primaryDispatchStatus).toBe(value);
|
||||
});
|
||||
|
||||
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({
|
||||
id: 9,
|
||||
vendorId: 45,
|
||||
vendorName: "",
|
||||
primaryDispatchStatus: "Sent",
|
||||
});
|
||||
expect(row.vendorId).toBe("45");
|
||||
expect(row.company).toBe("");
|
||||
expect(row.primaryDispatchStatus).toBe("Sent");
|
||||
});
|
||||
|
||||
it("maps vendor PATCH replacement dispatch as Pending with a new primaryDispatchId", () => {
|
||||
const row = mapWorkOrderTableRow({
|
||||
id: 1,
|
||||
VendorId: 5,
|
||||
VendorName: "New Co",
|
||||
PrimaryDispatchStatus: "Pending",
|
||||
PrimaryDispatchId: 11,
|
||||
});
|
||||
expect(row.vendorId).toBe("5");
|
||||
expect(row.company).toBe("New Co");
|
||||
expect(row.primaryDispatchStatus).toBe("Pending");
|
||||
expect(row.primaryDispatchId).toBe(11);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,10 +21,16 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
|||
|
||||
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
|
||||
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
useWorkOrderTableMutations,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import {
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -345,3 +351,135 @@ describe("useWorkOrderTableMutations schedule isAddOn from BE", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations vendor assignment round-trip", () => {
|
||||
let rowState: WorkOrderTableRow;
|
||||
|
||||
beforeEach(() => {
|
||||
getById.mockReset();
|
||||
update.mockReset();
|
||||
patchBoardField.mockReset();
|
||||
rowState = {
|
||||
...BASE_ROW,
|
||||
vendorId: "1",
|
||||
company: "",
|
||||
tech: "",
|
||||
completedDate: "2026-07-15",
|
||||
docStatus: "Yes",
|
||||
mediaCount: 1,
|
||||
woNumber: "20260623001",
|
||||
primaryDispatchStatus: "Refused",
|
||||
primaryDispatchId: 10,
|
||||
dispatchRowVersion: "disp-v1",
|
||||
};
|
||||
});
|
||||
|
||||
it("keeps Company off the gap list when vendor PATCH returns a Pending assignment", async () => {
|
||||
const returnedRow = mapWorkOrderTableRow({
|
||||
id: 42,
|
||||
VendorId: 9,
|
||||
VendorName: "New Co",
|
||||
PrimaryDispatchStatus: "Pending",
|
||||
PrimaryDispatchId: 11,
|
||||
DispatcherId: "d1",
|
||||
DispatcherName: "Dispatcher",
|
||||
CompletedDate: "2026-07-15",
|
||||
InternalWONumber: "20260623001",
|
||||
PM: "HVAC",
|
||||
DocStatus: "Yes",
|
||||
MediaCount: 1,
|
||||
WorkOrderType: "PM",
|
||||
});
|
||||
patchBoardField.mockResolvedValue(returnedRow);
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(
|
||||
42,
|
||||
toVendorTablePatch({
|
||||
vendorId: "9",
|
||||
company: "New Co",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({
|
||||
primaryDispatchStatus: "Pending",
|
||||
primaryDispatchId: 11,
|
||||
vendorId: "9",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(patchBoardField).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({
|
||||
field: "vendorId",
|
||||
value: "9",
|
||||
dispatchVersion: "disp-v1",
|
||||
primaryDispatchId: 10,
|
||||
}),
|
||||
);
|
||||
const payload = patchBoardField.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(payload).not.toHaveProperty("primaryDispatchStatus");
|
||||
expect(returnedRow.primaryDispatchId).not.toBe(10);
|
||||
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).not.toContain("Company");
|
||||
});
|
||||
|
||||
it("keeps a Company gap when vendor PATCH still returns Refused", async () => {
|
||||
const returnedRow = mapWorkOrderTableRow({
|
||||
id: 42,
|
||||
VendorId: 9,
|
||||
VendorName: "New Co",
|
||||
PrimaryDispatchStatus: "Refused",
|
||||
PrimaryDispatchId: 10,
|
||||
});
|
||||
patchBoardField.mockResolvedValue(returnedRow);
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(
|
||||
42,
|
||||
toVendorTablePatch({
|
||||
vendorId: "9",
|
||||
company: "New Co",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ primaryDispatchStatus: "Refused" }),
|
||||
),
|
||||
);
|
||||
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).toContain("Company");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
hasPendingUplift,
|
||||
isCompletionStatusLabel,
|
||||
isRealWoNumber,
|
||||
tableRowToClosabilityInput,
|
||||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityIn
|
|||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
company: "Acme HVAC",
|
||||
vendorId: "",
|
||||
tech: "Sam",
|
||||
completedDate: "2026-07-01",
|
||||
woNumber: "1001",
|
||||
|
|
@ -74,7 +77,7 @@ describe("getClosabilityGaps", () => {
|
|||
expect(getClosabilityGaps(makeInput())).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows company without technician (SH-183)", () => {
|
||||
it("allows company without technician", () => {
|
||||
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
|
||||
expect(gaps).toEqual([]);
|
||||
expect(gaps).not.toContain("Technician");
|
||||
|
|
@ -85,6 +88,32 @@ describe("getClosabilityGaps", () => {
|
|||
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
|
||||
});
|
||||
|
||||
it("allows Completed when vendorId is set and company/technician are empty", () => {
|
||||
expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("blocks Completed when both company and vendorId are empty even with a technician", () => {
|
||||
expect(getClosabilityGaps(makeInput({ company: "", vendorId: " ", tech: "Sam" }))).toEqual([
|
||||
"Company",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)(
|
||||
"blocks Completed without a company for type %s",
|
||||
(type) => {
|
||||
const gaps = getClosabilityGaps(
|
||||
makeInput({
|
||||
company: "",
|
||||
vendorId: "",
|
||||
type,
|
||||
docStatus: "Yes",
|
||||
mediaCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(gaps).toContain("Company");
|
||||
},
|
||||
);
|
||||
|
||||
it("lists field gaps before uplift and document gaps", () => {
|
||||
expect(
|
||||
getClosabilityGaps(
|
||||
|
|
@ -188,6 +217,7 @@ describe("wizardDraftToClosabilityInput", () => {
|
|||
dispatcherId: "u1",
|
||||
dispatcherName: "Alice",
|
||||
company: "ACME",
|
||||
vendorId: "",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: "20260623001",
|
||||
|
|
@ -221,6 +251,7 @@ describe("detailToClosabilityInput", () => {
|
|||
const input = detailToClosabilityInput(detail);
|
||||
expect(input.dispatcherName).toBe("Alice");
|
||||
expect(input.company).toBe("ACME Tech");
|
||||
expect(input.vendorId).toBe("");
|
||||
expect(input.woNumber).toBe("20260623001");
|
||||
expect(input.pm).toBe("HVAC PM");
|
||||
expect(getClosabilityGaps(input)).toEqual([]);
|
||||
|
|
@ -277,6 +308,171 @@ describe("detailToClosabilityInput", () => {
|
|||
|
||||
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]);
|
||||
});
|
||||
|
||||
it("treats vendorId without vendorName as assigned company", () => {
|
||||
const detail = {
|
||||
assignedTo: "Alice",
|
||||
completedDate: "2026-07-15",
|
||||
workOrderNumber: "20260623001",
|
||||
internalWONumber: "SH-1",
|
||||
problem: "HVAC PM",
|
||||
source: "procurement-integration",
|
||||
type: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
dispatches: [{ vendorName: "", vendorId: 45 }],
|
||||
} as WorkOrderDetail;
|
||||
|
||||
const input = detailToClosabilityInput(detail);
|
||||
expect(input.company).toBe("");
|
||||
expect(input.vendorId).toBe("45");
|
||||
expect(getClosabilityGaps(input)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])(
|
||||
"ignores inactive dispatch status %s when resolving assigned company",
|
||||
(status) => {
|
||||
const inactiveOnly = {
|
||||
assignedTo: "Alice",
|
||||
completedDate: "2026-07-15",
|
||||
workOrderNumber: "20260623001",
|
||||
internalWONumber: "SH-1",
|
||||
problem: "HVAC PM",
|
||||
source: "procurement-integration",
|
||||
type: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
dispatches: [{ vendorName: "Old Co", vendorId: 45, status }],
|
||||
} as WorkOrderDetail;
|
||||
|
||||
expect(detailToClosabilityInput(inactiveOnly).vendorId).toBe("");
|
||||
expect(detailToClosabilityInput(inactiveOnly).company).toBe("");
|
||||
expect(getClosabilityGaps(detailToClosabilityInput(inactiveOnly))).toContain("Company");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Verified", "Completed", "Sent"])(
|
||||
"treats dispatch status %s as a live company assignment",
|
||||
(status) => {
|
||||
const liveAssignment = {
|
||||
assignedTo: "Alice",
|
||||
completedDate: "2026-07-15",
|
||||
workOrderNumber: "20260623001",
|
||||
internalWONumber: "SH-1",
|
||||
problem: "HVAC PM",
|
||||
source: "procurement-integration",
|
||||
type: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
dispatches: [{ vendorName: "ACME Tech", vendorId: 45, status }],
|
||||
} as WorkOrderDetail;
|
||||
|
||||
const input = detailToClosabilityInput(liveAssignment);
|
||||
expect(input.company).toBe("ACME Tech");
|
||||
expect(input.vendorId).toBe("45");
|
||||
expect(getClosabilityGaps(input)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the active dispatch after an inactive cancelled spelling", () => {
|
||||
const supersededThenCurrent = {
|
||||
assignedTo: "Alice",
|
||||
completedDate: "2026-07-15",
|
||||
workOrderNumber: "20260623001",
|
||||
internalWONumber: "SH-1",
|
||||
problem: "HVAC PM",
|
||||
source: "procurement-integration",
|
||||
type: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
dispatches: [
|
||||
{ vendorName: "Old Co", vendorId: 1, status: "Canceled" },
|
||||
{ vendorName: "ACME Tech", vendorId: 45, status: "Sent" },
|
||||
],
|
||||
} as WorkOrderDetail;
|
||||
|
||||
const input = detailToClosabilityInput(supersededThenCurrent);
|
||||
expect(input.company).toBe("ACME Tech");
|
||||
expect(input.vendorId).toBe("45");
|
||||
expect(getClosabilityGaps(input)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tableRowToClosabilityInput", () => {
|
||||
const closableRow = {
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
company: "",
|
||||
vendorId: "45",
|
||||
tech: "",
|
||||
completedDate: "2026-07-15",
|
||||
woNumber: "20260623001",
|
||||
pm: "HVAC PM",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
type: "",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
it("keeps vendorId as a live assignment when primary dispatch status is absent", () => {
|
||||
const input = tableRowToClosabilityInput(closableRow);
|
||||
expect(input.vendorId).toBe("45");
|
||||
expect(getClosabilityGaps(input)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["Cancelled", "Canceled", "Refused"] as const)(
|
||||
"clears company assignment when primary dispatch status is %s",
|
||||
(primaryDispatchStatus) => {
|
||||
const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus });
|
||||
expect(input.company).toBe("");
|
||||
expect(input.vendorId).toBe("");
|
||||
expect(getClosabilityGaps(input)).toContain("Company");
|
||||
},
|
||||
);
|
||||
|
||||
it("raises Company when the board omits vendor assignment (inactive primary)", () => {
|
||||
const input = tableRowToClosabilityInput({
|
||||
...closableRow,
|
||||
vendorId: "",
|
||||
company: "",
|
||||
primaryDispatchStatus: "Refused",
|
||||
});
|
||||
expect(input.vendorId).toBe("");
|
||||
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([]);
|
||||
});
|
||||
|
||||
it.each(["Pending", "Sent"] as const)(
|
||||
"keeps company assignment after vendor save when primary dispatch status is %s",
|
||||
(primaryDispatchStatus) => {
|
||||
const input = tableRowToClosabilityInput({
|
||||
...closableRow,
|
||||
company: "New Co",
|
||||
vendorId: "9",
|
||||
primaryDispatchStatus,
|
||||
});
|
||||
expect(input.vendorId).toBe("9");
|
||||
expect(getClosabilityGaps(input)).not.toContain("Company");
|
||||
},
|
||||
);
|
||||
|
||||
it("raises Company when leftover Refused remains on the patched vendor id", () => {
|
||||
const input = tableRowToClosabilityInput({
|
||||
...closableRow,
|
||||
company: "New Co",
|
||||
vendorId: "9",
|
||||
primaryDispatchStatus: "Refused",
|
||||
});
|
||||
expect(getClosabilityGaps(input)).toContain("Company");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readDetailWoType", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue