mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 16:02:08 +00:00
chore(work-orders): merge origin/dev into SH-243 type filter branch
This commit is contained in:
commit
f522c46911
56 changed files with 2022 additions and 224 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
34
e2e/vendors/vendors.spec.ts
vendored
34
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -68,6 +68,7 @@ interface MockState {
|
||||||
patchedBody?: Record<string, unknown>;
|
patchedBody?: Record<string, unknown>;
|
||||||
patchedCompanyId?: string;
|
patchedCompanyId?: string;
|
||||||
deletedId?: string;
|
deletedId?: string;
|
||||||
|
deleteConfirmedOpenWorkOrders?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
|
@ -307,8 +308,9 @@ async function mockVendorApi(
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
await page.route(/\/api\/vendors\/\d+(\?.*)?$/, async (route) => {
|
||||||
const id = route.request().url().split("/").pop() ?? "";
|
const requestUrl = new URL(route.request().url());
|
||||||
|
const id = requestUrl.pathname.split("/").pop() ?? "";
|
||||||
if (route.request().method() === "PUT") {
|
if (route.request().method() === "PUT") {
|
||||||
state.updatedBody = route.request().postDataJSON();
|
state.updatedBody = route.request().postDataJSON();
|
||||||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||||
|
|
@ -321,6 +323,8 @@ async function mockVendorApi(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
|
state.deleteConfirmedOpenWorkOrders =
|
||||||
|
requestUrl.searchParams.get("confirmOpenWorkOrders") === "true";
|
||||||
if (options.deleteConflict) {
|
if (options.deleteConflict) {
|
||||||
await fulfillJson(
|
await fulfillJson(
|
||||||
route,
|
route,
|
||||||
|
|
@ -576,20 +580,28 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
|
test("confirms deactivation past linked work orders and preserves the vendor on a raced 409", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
const confirmState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||||
await page.getByRole("switch", { name: "Active status" }).click();
|
await page.getByRole("switch", { name: "Active status" }).click();
|
||||||
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
const dialog = page.getByRole("dialog", { name: "Deactivate this vendor?" });
|
||||||
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
await expect(dialog).toContainText("It still has 1 open work order");
|
||||||
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
await expect(
|
||||||
expect(blockedState.deletedId).toBeUndefined();
|
dialog.getByRole("link", { name: /WO-501 — Emergency boiler repair/ }),
|
||||||
|
).toHaveAttribute("href", "/workorders/501");
|
||||||
|
|
||||||
|
// SH-254: the open work orders inform the decision, they no longer block it.
|
||||||
|
const confirm = dialog.getByRole("button", { name: "Deactivate anyway" });
|
||||||
|
await expect(confirm).toBeEnabled();
|
||||||
|
await confirm.click();
|
||||||
|
|
||||||
|
await expect.poll(() => confirmState.deletedId).toBe("1");
|
||||||
|
expect(confirmState.deleteConfirmedOpenWorkOrders).toBe(true);
|
||||||
|
|
||||||
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
|
|
||||||
await page.unrouteAll({ behavior: "wait" });
|
await page.unrouteAll({ behavior: "wait" });
|
||||||
|
|
||||||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||||
|
|
@ -597,13 +609,13 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||||
await page.getByRole("switch", { name: "Active status" }).click();
|
await page.getByRole("switch", { name: "Active status" }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog", { name: "Deactivate Vendor" })
|
.getByRole("dialog", { name: "Deactivate this vendor?" })
|
||||||
.getByRole("button", {
|
.getByRole("button", {
|
||||||
name: /^Deactivate$/,
|
name: /^Deactivate$/,
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
await expect(page.getByRole("dialog", { name: "Deactivate this vendor?" })).toContainText(
|
||||||
/open work orders|conflict/i,
|
/open work orders|conflict/i,
|
||||||
);
|
);
|
||||||
expect(racedState.deletedId).toBeUndefined();
|
expect(racedState.deletedId).toBeUndefined();
|
||||||
|
|
|
||||||
124
e2e/work-orders/board-day-pagination.spec.ts
Normal file
124
e2e/work-orders/board-day-pagination.spec.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "wo-day-pagination-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) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardRow(id: number, woNumber: string, scheduledDate: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
woNumber,
|
||||||
|
workOrderType: 2,
|
||||||
|
siteCode: woNumber,
|
||||||
|
lifecycleStatusLabel: "Scheduled",
|
||||||
|
scheduledDate,
|
||||||
|
dispatcherId: "1",
|
||||||
|
dispatcherName: "Pat Dispatcher",
|
||||||
|
pm: "HVAC",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockWorkOrderBoard(page: Page) {
|
||||||
|
const wednesday = Array.from({ length: 60 }, (_, index) =>
|
||||||
|
boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"),
|
||||||
|
);
|
||||||
|
const monday = Array.from({ length: 5 }, (_, index) =>
|
||||||
|
boardRow(2000 + index, `MON-${2000 + index}`, "2026-08-17"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route(
|
||||||
|
(url) => url.pathname.startsWith("/api/"),
|
||||||
|
async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (url.includes("workorders/board/search")) {
|
||||||
|
await fulfillJson(route, { items: [], totalCount: 0, page: 1, pageSize: 100 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes("workorders/board") && route.request().method() === "GET") {
|
||||||
|
const weekStart = new URL(url).searchParams.get("weekStart") ?? "2026-08-17";
|
||||||
|
const scheduled =
|
||||||
|
weekStart === "2026-08-17" ? [...wednesday, ...monday] : monday.slice(0, 1);
|
||||||
|
await fulfillJson(route, {
|
||||||
|
weekStart,
|
||||||
|
weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14",
|
||||||
|
counts: { returned: scheduled.length, total: scheduled.length },
|
||||||
|
unscheduled: [],
|
||||||
|
scheduled,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, { data: [], items: [] });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("work order per-day pagination (SH-241)", () => {
|
||||||
|
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));
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = ".tsqd-parent-container { display: none !important; }";
|
||||||
|
document.documentElement.appendChild(style);
|
||||||
|
}, TEST_USER);
|
||||||
|
await mockWorkOrderBoard(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changes rows-per-page, hides on global filter, and resets on day or week change", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const pager = page.getByTestId("wo-day-pagination");
|
||||||
|
await page.goto("/workorders");
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(pager).toBeVisible();
|
||||||
|
await expect(page.getByText("Page 1 of 2")).toBeVisible();
|
||||||
|
await expect(page.getByText("WO-1000").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("WO-1050")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByLabel("Rows per page").selectOption("25");
|
||||||
|
await expect(page.getByText("Page 1 of 3")).toBeVisible();
|
||||||
|
await expect(page.getByText("WO-1025")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("wo-day-pagination-next").evaluate((node) => {
|
||||||
|
(node as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
await expect(page.getByText("Page 2 of 3")).toBeVisible();
|
||||||
|
await expect(page.getByText("WO-1025").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("WO-1000")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Mon · 08/17" }).click({ force: true });
|
||||||
|
await expect(page.getByText("Page 1 of 1")).toBeVisible();
|
||||||
|
await expect(page.getByText("MON-2000").first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true });
|
||||||
|
await page.getByRole("button", { name: /Aug 10/ }).click({ force: true });
|
||||||
|
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
||||||
|
await expect(page.getByText("Page 1 of")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||||
|
await page.getByRole("button", { name: /Indicators/i }).click();
|
||||||
|
await page.getByRole("button", { name: "Rescheduled ≥ 2" }).click();
|
||||||
|
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||||
|
await expect(pager).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Clear all" }).click();
|
||||||
|
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -80,6 +80,19 @@ test.describe("Work Orders week/day navigation (SH-240)", () => {
|
||||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||||
timeout: 15_000,
|
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" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");
|
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,18 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!target || target.id == null) return;
|
if (!target || target.id == null) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
deleteVendor.mutate(target.id, {
|
// The dialog has shown whatever open work orders exist, so confirming here is
|
||||||
onSuccess: () => {
|
// the explicit confirmation the API requires to deactivate past them (SH-254).
|
||||||
setTarget(null);
|
deleteVendor.mutate(
|
||||||
onSuccess?.();
|
{ id: target.id, confirmOpenWorkOrders: (impact?.openWorkOrders.length ?? 0) > 0 },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setTarget(null);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
||||||
},
|
},
|
||||||
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export interface VendorRosterForm {
|
||||||
roster: VendorCompanyRoster | undefined;
|
roster: VendorCompanyRoster | undefined;
|
||||||
companies: VendorFacetCompany[];
|
companies: VendorFacetCompany[];
|
||||||
trades: string[];
|
trades: string[];
|
||||||
|
tradesLoading: boolean;
|
||||||
selectedCompanyId: string | number | null;
|
selectedCompanyId: string | number | null;
|
||||||
selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
|
selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
|
||||||
clearSelectedCompany: (nextName?: string) => void;
|
clearSelectedCompany: (nextName?: string) => void;
|
||||||
|
|
@ -110,7 +111,7 @@ export function useVendorRosterForm({
|
||||||
mode === "update" ? vendorId : undefined,
|
mode === "update" ? vendorId : undefined,
|
||||||
mode === "update" ? companyId : undefined,
|
mode === "update" ? companyId : undefined,
|
||||||
);
|
);
|
||||||
const { data: facets } = useVendorFacets();
|
const { data: facets, isLoading: facetsLoading } = useVendorFacets();
|
||||||
const save = useSaveVendorCompanyRoster();
|
const save = useSaveVendorCompanyRoster();
|
||||||
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);
|
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);
|
||||||
|
|
||||||
|
|
@ -223,6 +224,7 @@ export function useVendorRosterForm({
|
||||||
roster: routeRoster,
|
roster: routeRoster,
|
||||||
companies,
|
companies,
|
||||||
trades,
|
trades,
|
||||||
|
tradesLoading: facetsLoading,
|
||||||
selectedCompanyId: selection.selectedCompanyId,
|
selectedCompanyId: selection.selectedCompanyId,
|
||||||
selectCompany: selection.selectCompany,
|
selectCompany: selection.selectCompany,
|
||||||
clearSelectedCompany: selection.clearSelectedCompany,
|
clearSelectedCompany: selection.clearSelectedCompany,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
errors={form.errors}
|
errors={form.errors}
|
||||||
companies={form.companies}
|
companies={form.companies}
|
||||||
tradeOptions={form.trades}
|
tradeOptions={form.trades}
|
||||||
|
tradeOptionsLoading={form.tradesLoading}
|
||||||
selectedCompanyId={form.selectedCompanyId}
|
selectedCompanyId={form.selectedCompanyId}
|
||||||
onSelectCompany={form.selectCompany}
|
onSelectCompany={form.selectCompany}
|
||||||
onClearSelectedCompany={form.clearSelectedCompany}
|
onClearSelectedCompany={form.clearSelectedCompany}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
Link,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { Link as RouterLink } from "react-router";
|
||||||
import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
interface VendorDeactivationDialogProps {
|
interface VendorDeactivationDialogProps {
|
||||||
|
|
@ -26,6 +28,15 @@ interface VendorDeactivationDialogProps {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deactivationMessage(companyName: string | undefined, openCount: number): string {
|
||||||
|
const name = companyName ? `"${companyName}"` : "This vendor";
|
||||||
|
if (openCount === 0) {
|
||||||
|
return `${name} will no longer be selectable for new work orders.`;
|
||||||
|
}
|
||||||
|
const plural = openCount === 1 ? "work order" : "work orders";
|
||||||
|
return `${name} will no longer be selectable for new work orders. It still has ${openCount} open ${plural} — they'll keep it as-is unless you reassign them.`;
|
||||||
|
}
|
||||||
|
|
||||||
export function VendorDeactivationDialog({
|
export function VendorDeactivationDialog({
|
||||||
target,
|
target,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -36,16 +47,16 @@ export function VendorDeactivationDialog({
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: VendorDeactivationDialogProps) {
|
}: VendorDeactivationDialogProps) {
|
||||||
const hasBlockingImpact = Boolean(impact && !impact.canDeactivate);
|
const openWorkOrders = impact?.openWorkOrders ?? [];
|
||||||
|
const hasOpenWorkOrders = openWorkOrders.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={Boolean(target)} onClose={isSaving ? undefined : onClose}>
|
<Dialog open={Boolean(target)} onClose={isSaving ? undefined : onClose}>
|
||||||
<DialogTitle>Deactivate Vendor</DialogTitle>
|
<DialogTitle>Deactivate this vendor?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
Deactivate "{target?.companyName}"? Existing work-order and audit history will
|
{deactivationMessage(target?.companyName, openWorkOrders.length)}
|
||||||
be preserved.
|
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|
@ -63,19 +74,13 @@ export function VendorDeactivationDialog({
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{impact != null && !isLoading && !impact.canDeactivate && (
|
{hasOpenWorkOrders && (
|
||||||
<Alert severity="error">
|
|
||||||
This vendor cannot be deactivated because it still has open work orders.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{impact != null && impact.openWorkOrders.length > 0 && (
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
Open work orders ({impact.openWorkOrders.length})
|
Open work orders ({openWorkOrders.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense sx={{ mt: 0.5 }}>
|
<List dense sx={{ mt: 0.5 }}>
|
||||||
{impact.openWorkOrders.map((wo) => (
|
{openWorkOrders.map((wo) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={String(wo.workOrderId)}
|
key={String(wo.workOrderId)}
|
||||||
sx={{ px: 0, py: 0.25 }}
|
sx={{ px: 0, py: 0.25 }}
|
||||||
|
|
@ -88,11 +93,11 @@ export function VendorDeactivationDialog({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Typography variant="body2">
|
<Link component={RouterLink} to={`/workorders/${wo.workOrderId}`}>
|
||||||
{wo.workOrderNumber
|
{wo.workOrderNumber
|
||||||
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
||||||
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
||||||
</Typography>
|
</Link>
|
||||||
{Boolean(wo.status) && (
|
{Boolean(wo.status) && (
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{wo.status}
|
{wo.status}
|
||||||
|
|
@ -113,12 +118,12 @@ export function VendorDeactivationDialog({
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="error"
|
color={hasOpenWorkOrders ? "warning" : "error"}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={isSaving || isLoading || hasBlockingImpact || Boolean(impactError)}
|
disabled={isSaving || isLoading || Boolean(impactError)}
|
||||||
>
|
>
|
||||||
{isSaving ? "Deactivating..." : "Deactivate"}
|
{isSaving ? "Deactivating..." : hasOpenWorkOrders ? "Deactivate anyway" : "Deactivate"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,7 @@ function DrawerEditor({
|
||||||
control={form.control}
|
control={form.control}
|
||||||
errors={form.errors}
|
errors={form.errors}
|
||||||
tradeOptions={form.trades}
|
tradeOptions={form.trades}
|
||||||
|
tradeOptionsLoading={form.tradesLoading}
|
||||||
showTechnicianStatus={false}
|
showTechnicianStatus={false}
|
||||||
/>
|
/>
|
||||||
{selectedIndex >= 0 && (
|
{selectedIndex >= 0 && (
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface VendorRosterFormFieldsProps {
|
||||||
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
companies?: VendorFacetCompany[];
|
companies?: VendorFacetCompany[];
|
||||||
tradeOptions?: string[];
|
tradeOptions?: string[];
|
||||||
|
tradeOptionsLoading?: boolean;
|
||||||
selectedCompanyId?: string | number | null;
|
selectedCompanyId?: string | number | null;
|
||||||
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
||||||
onClearSelectedCompany?: (nextName?: string) => void;
|
onClearSelectedCompany?: (nextName?: string) => void;
|
||||||
|
|
@ -236,6 +237,7 @@ interface TechnicianRowProps {
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
tradeOptions: string[];
|
tradeOptions: string[];
|
||||||
|
tradeOptionsLoading?: boolean;
|
||||||
showStatus: boolean;
|
showStatus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +248,7 @@ function TechnicianRow({
|
||||||
onRemove,
|
onRemove,
|
||||||
canRemove,
|
canRemove,
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
|
tradeOptionsLoading,
|
||||||
showStatus,
|
showStatus,
|
||||||
}: TechnicianRowProps) {
|
}: TechnicianRowProps) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -315,7 +318,12 @@ function TechnicianRow({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
<VendorTradeSpecialtiesField
|
||||||
|
control={control}
|
||||||
|
index={index}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
tradeOptionsLoading={tradeOptionsLoading}
|
||||||
|
/>
|
||||||
{showStatus && (
|
{showStatus && (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|
@ -341,11 +349,13 @@ function TechniciansFieldArray({
|
||||||
control,
|
control,
|
||||||
errors,
|
errors,
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
|
tradeOptionsLoading,
|
||||||
showStatus,
|
showStatus,
|
||||||
}: {
|
}: {
|
||||||
control: Control<VendorCompanyRosterFormValues>;
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
tradeOptions: string[];
|
tradeOptions: string[];
|
||||||
|
tradeOptionsLoading?: boolean;
|
||||||
showStatus: boolean;
|
showStatus: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
||||||
|
|
@ -389,6 +399,7 @@ function TechniciansFieldArray({
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
canRemove
|
canRemove
|
||||||
tradeOptions={tradeOptions}
|
tradeOptions={tradeOptions}
|
||||||
|
tradeOptionsLoading={tradeOptionsLoading}
|
||||||
showStatus={showStatus}
|
showStatus={showStatus}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
@ -399,7 +410,13 @@ function TechniciansFieldArray({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
||||||
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
|
const {
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
tradeOptions = [],
|
||||||
|
tradeOptionsLoading = false,
|
||||||
|
showTechnicianStatus = true,
|
||||||
|
} = props;
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<CompanyFields {...props} />
|
<CompanyFields {...props} />
|
||||||
|
|
@ -408,6 +425,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
||||||
control={control}
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
tradeOptions={tradeOptions}
|
tradeOptions={tradeOptions}
|
||||||
|
tradeOptionsLoading={tradeOptionsLoading}
|
||||||
showStatus={showTechnicianStatus}
|
showStatus={showTechnicianStatus}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
||||||
control={form.control}
|
control={form.control}
|
||||||
errors={form.errors}
|
errors={form.errors}
|
||||||
tradeOptions={form.trades}
|
tradeOptions={form.trades}
|
||||||
|
tradeOptionsLoading={form.tradesLoading}
|
||||||
{...companySelectionProps}
|
{...companySelectionProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Controller, type Control } from "react-hook-form";
|
import { Controller, type Control } from "react-hook-form";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||||
|
|
@ -14,8 +14,43 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Text variant="feedback"` stays mounted when silent so the aria-live region can announce later.
|
||||||
|
* An empty paragraph still claims a line box, which would add a permanent gap under the trades
|
||||||
|
* input and shift the approved drawer layout. Collapse the box while keeping the node in the DOM
|
||||||
|
* (never `display: none`, which would stop announcements).
|
||||||
|
*/
|
||||||
|
function collapsedWhenSilent(visible: boolean) {
|
||||||
|
return visible ? undefined : { height: 0, margin: 0, overflow: "hidden" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradeFeedbackRegions({
|
||||||
|
listUnavailable,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
listUnavailable: boolean;
|
||||||
|
message: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
variant="feedback"
|
||||||
|
tone="warning"
|
||||||
|
when={listUnavailable}
|
||||||
|
sx={collapsedWhenSilent(listUnavailable)}
|
||||||
|
>
|
||||||
|
Trade list is unavailable right now — you can still type a trade manually.
|
||||||
|
</Text>
|
||||||
|
<Text variant="feedback" when={Boolean(message)} sx={collapsedWhenSilent(Boolean(message))}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function splitTrades(value: string | undefined): string[] {
|
function splitTrades(value: string | undefined): string[] {
|
||||||
return (value ?? "")
|
return (value ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|
@ -23,18 +58,59 @@ function splitTrades(value: string | undefined): string[] {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupeTrades(options: string[], current: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: string[] = [];
|
||||||
|
for (const trade of [...options, ...current]) {
|
||||||
|
const key = trade.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(trade);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTradeOption(input: string, options: string[]): string | null {
|
||||||
|
const exact = options.find((option) => option === input);
|
||||||
|
if (exact) return exact;
|
||||||
|
const normalized = input.toLowerCase();
|
||||||
|
return options.find((option) => option.toLowerCase() === normalized) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
interface VendorTradeSpecialtiesFieldProps {
|
interface VendorTradeSpecialtiesFieldProps {
|
||||||
control: Control<VendorCompanyRosterFormValues>;
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
index: number;
|
index: number;
|
||||||
tradeOptions: string[];
|
tradeOptions: string[];
|
||||||
|
tradeOptionsLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SH-249: an empty tradeOptions array means two different things. While the facets
|
||||||
|
* query is in flight it means "not loaded yet"; only once it settles does it mean
|
||||||
|
* "vocabulary genuinely unavailable". Failing open during the cold-fetch window let
|
||||||
|
* free text through the canonical gate permanently, because anything committed there
|
||||||
|
* is added to knownTradesRef and stays selectable after the real list arrives.
|
||||||
|
*
|
||||||
|
* SH-249: why a typed trade was refused. Extracted to module scope so the field
|
||||||
|
* component stays inside the maintainability gate's function-length cap.
|
||||||
|
*/
|
||||||
|
function resolveRejectionMessage(value: string, loading: boolean): string {
|
||||||
|
return loading
|
||||||
|
? "Trades are still loading. Wait for the list, then pick a trade."
|
||||||
|
: `"${value}" is not in the trades list. Pick a trade from the list.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorTradeSpecialtiesField({
|
export function VendorTradeSpecialtiesField({
|
||||||
control,
|
control,
|
||||||
index,
|
index,
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
|
tradeOptionsLoading = false,
|
||||||
}: VendorTradeSpecialtiesFieldProps) {
|
}: VendorTradeSpecialtiesFieldProps) {
|
||||||
const [tradeInput, setTradeInput] = useState("");
|
const [tradeInput, setTradeInput] = useState("");
|
||||||
|
const [feedbackMessage, setFeedbackMessage] = useState<string | null>(null);
|
||||||
|
const knownTradesRef = useRef<Set<string>>(new Set());
|
||||||
|
const tradeListUnavailable = !tradeOptionsLoading && tradeOptions.length === 0;
|
||||||
|
const gateActive = tradeOptions.length > 0 || tradeOptionsLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -47,10 +123,28 @@ export function VendorTradeSpecialtiesField({
|
||||||
name={`technicians.${index}.tradeSpecialties`}
|
name={`technicians.${index}.tradeSpecialties`}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const trades = splitTrades(field.value);
|
const trades = splitTrades(field.value);
|
||||||
|
for (const trade of trades) knownTradesRef.current.add(trade);
|
||||||
|
const selectableTrades = dedupeTrades(tradeOptions, [...knownTradesRef.current]);
|
||||||
const commit = (next: string[]) => field.onChange(next.join(", "));
|
const commit = (next: string[]) => field.onChange(next.join(", "));
|
||||||
const add = (value: string) => {
|
const add = (value: string) => {
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
if (normalized && !trades.includes(normalized)) commit([...trades, normalized]);
|
setFeedbackMessage(null);
|
||||||
|
if (!normalized) {
|
||||||
|
setTradeInput("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canonical = matchTradeOption(normalized, selectableTrades);
|
||||||
|
if (gateActive && !canonical) {
|
||||||
|
setFeedbackMessage(resolveRejectionMessage(normalized, tradeOptionsLoading));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trade = canonical ?? normalized;
|
||||||
|
if (trades.some((existing) => existing.toLowerCase() === trade.toLowerCase())) {
|
||||||
|
setFeedbackMessage(`${trade} is already on this technician.`);
|
||||||
|
setTradeInput("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commit([...trades, trade]);
|
||||||
setTradeInput("");
|
setTradeInput("");
|
||||||
};
|
};
|
||||||
const move = (tradeIndex: number, direction: -1 | 1) => {
|
const move = (tradeIndex: number, direction: -1 | 1) => {
|
||||||
|
|
@ -64,31 +158,31 @@ export function VendorTradeSpecialtiesField({
|
||||||
return (
|
return (
|
||||||
<Stack spacing={1} className="mt-2">
|
<Stack spacing={1} className="mt-2">
|
||||||
<Box className="flex flex-wrap gap-1.5">
|
<Box className="flex flex-wrap gap-1.5">
|
||||||
{trades.length === 0 ? (
|
<Text variant="description" tone="muted" when={trades.length === 0}>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
No trades selected.
|
||||||
No trades selected.
|
</Text>
|
||||||
</Typography>
|
{trades.map((trade, tradeIndex) => (
|
||||||
) : (
|
<Chip
|
||||||
trades.map((trade, tradeIndex) => (
|
key={`${trade}-${tradeIndex}`}
|
||||||
<Chip
|
label={tradeIndex === 0 ? `${trade} (primary)` : trade}
|
||||||
key={`${trade}-${tradeIndex}`}
|
onDelete={() =>
|
||||||
label={tradeIndex === 0 ? `${trade} (primary)` : trade}
|
commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex))
|
||||||
onDelete={() =>
|
}
|
||||||
commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex))
|
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
||||||
}
|
/>
|
||||||
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
))}
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
freeSolo
|
freeSolo
|
||||||
options={tradeOptions}
|
options={selectableTrades}
|
||||||
value={null}
|
value={null}
|
||||||
inputValue={tradeInput}
|
inputValue={tradeInput}
|
||||||
onInputChange={(_event, value, reason) => {
|
onInputChange={(_event, value, reason) => {
|
||||||
if (reason === "input") setTradeInput(value);
|
if (reason === "input") {
|
||||||
|
setTradeInput(value);
|
||||||
|
setFeedbackMessage(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(_event, value) => {
|
onChange={(_event, value) => {
|
||||||
if (typeof value === "string") add(value);
|
if (typeof value === "string") add(value);
|
||||||
|
|
@ -147,6 +241,9 @@ export function VendorTradeSpecialtiesField({
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Outside the spaced Stack: an always-mounted live region would otherwise inherit Stack
|
||||||
|
spacing and add a permanent gap under the trades input, shifting the approved layout. */}
|
||||||
|
<TradeFeedbackRegions listUnavailable={tradeListUnavailable} message={feedbackMessage} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ function userInitials(name: string): string {
|
||||||
function DispatcherAvatar({ name, color }: { name: string; color: string }) {
|
function DispatcherAvatar({ name, color }: { name: string; color: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
aria-hidden
|
||||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
|
||||||
style={{ background: `${color}25`, color }}
|
style={{ background: `${color}25`, color }}
|
||||||
>
|
>
|
||||||
|
|
@ -75,8 +76,8 @@ export function DispatcherFilter({
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
variant="rounded"
|
variant="rounded"
|
||||||
width={160}
|
width={140}
|
||||||
height={38}
|
height={18}
|
||||||
animation="wave"
|
animation="wave"
|
||||||
aria-label="Loading dispatchers"
|
aria-label="Loading dispatchers"
|
||||||
/>
|
/>
|
||||||
|
|
@ -89,35 +90,13 @@ export function DispatcherFilter({
|
||||||
onAnchor={setAnchor}
|
onAnchor={setAnchor}
|
||||||
open={open}
|
open={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
|
className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors"
|
||||||
style={
|
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
|
||||||
!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)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
>
|
>
|
||||||
<Users size={15} style={{ color: "var(--muted-foreground)" }} />
|
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||||
{label}
|
{label}
|
||||||
<ChevronDown size={14} style={{ color: "var(--muted-foreground)" }} />
|
<ChevronDown size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||||
</MenuAnchorButton>
|
</MenuAnchorButton>
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
|
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 { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
|
||||||
import { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter";
|
import { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
@ -11,13 +9,8 @@ type WorkOrdersFilterBarProps = {
|
||||||
onWeekChange: (mondayIso: string) => void;
|
onWeekChange: (mondayIso: string) => void;
|
||||||
selectedDayIso: string;
|
selectedDayIso: string;
|
||||||
onSelectedDayChange: (dayIso: string) => void;
|
onSelectedDayChange: (dayIso: string) => void;
|
||||||
dispatcherIds: Set<string>;
|
|
||||||
onDispatcherChange: (ids: Set<string>) => void;
|
|
||||||
types: Set<WOType>;
|
types: Set<WOType>;
|
||||||
onTypesChange: (value: Set<WOType>) => void;
|
onTypesChange: (value: Set<WOType>) => void;
|
||||||
users: WorkOrderUser[];
|
|
||||||
usersLoading?: boolean;
|
|
||||||
currentUserId?: string | number;
|
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
scopeTotalCount: number;
|
scopeTotalCount: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -28,13 +21,8 @@ export function WorkOrdersFilterBar({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
selectedDayIso,
|
selectedDayIso,
|
||||||
onSelectedDayChange,
|
onSelectedDayChange,
|
||||||
dispatcherIds,
|
|
||||||
onDispatcherChange,
|
|
||||||
types,
|
types,
|
||||||
onTypesChange,
|
onTypesChange,
|
||||||
users,
|
|
||||||
usersLoading = false,
|
|
||||||
currentUserId,
|
|
||||||
filteredCount,
|
filteredCount,
|
||||||
scopeTotalCount,
|
scopeTotalCount,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
|
@ -46,22 +34,8 @@ export function WorkOrdersFilterBar({
|
||||||
className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
|
className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
|
||||||
aria-disabled={disabled}
|
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-1" />
|
|
||||||
<Text variant="caption" tone="muted" className="shrink-0">
|
|
||||||
{resultLabel}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className="flex h-14 shrink-0 flex-nowrap items-center gap-3 px-4 pt-1 pb-2"
|
className="flex h-14 shrink-0 flex-nowrap items-center gap-3 px-4 py-2"
|
||||||
aria-label="Week and day"
|
aria-label="Week and day"
|
||||||
>
|
>
|
||||||
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||||
|
|
@ -74,6 +48,9 @@ export function WorkOrdersFilterBar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<Text variant="caption" tone="muted" className="shrink-0">
|
||||||
|
{resultLabel}
|
||||||
|
</Text>
|
||||||
<WoTypeFilter selected={types} onChange={onTypesChange} disabled={disabled} />
|
<WoTypeFilter selected={types} onChange={onTypesChange} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
import {
|
import {
|
||||||
formatClosabilityMissingTitle,
|
formatClosabilityMissingTitle,
|
||||||
getClosabilityGaps,
|
getClosabilityGaps,
|
||||||
|
tableRowToClosabilityInput,
|
||||||
} from "@/domain/work-orders/utils/wo-closability";
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||||
|
|
@ -22,20 +23,7 @@ type StatusCellProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
||||||
return getClosabilityGaps({
|
return getClosabilityGaps(tableRowToClosabilityInput(wo));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PastDueStatusCell({
|
function PastDueStatusCell({
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,13 @@ export function useWoTableRowHandlers({
|
||||||
onScheduleHighlight,
|
onScheduleHighlight,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
|
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
|
||||||
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData;
|
const {
|
||||||
|
dragState: dragStateRef,
|
||||||
|
setDragViz,
|
||||||
|
reorderRows,
|
||||||
|
endDrag,
|
||||||
|
revealScheduledRow,
|
||||||
|
} = tableData;
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -77,13 +83,13 @@ export function useWoTableRowHandlers({
|
||||||
rescheduleNotify(date, {
|
rescheduleNotify(date, {
|
||||||
onNavigate: () => {
|
onNavigate: () => {
|
||||||
onNavigateToDay(date);
|
onNavigateToDay(date);
|
||||||
setTimeout(() => triggerFlash(id), 180);
|
revealScheduledRow(id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onNavigateToDay(date);
|
onNavigateToDay(date);
|
||||||
triggerFlash(id);
|
revealScheduledRow(id);
|
||||||
},
|
},
|
||||||
onScheduleWeek: (id: string | number, week: string) => {
|
onScheduleWeek: (id: string | number, week: string) => {
|
||||||
onPatch(id, {
|
onPatch(id, {
|
||||||
|
|
@ -132,7 +138,7 @@ export function useWoTableRowHandlers({
|
||||||
setDragViz,
|
setDragViz,
|
||||||
reorderRows,
|
reorderRows,
|
||||||
endDrag,
|
endDrag,
|
||||||
triggerFlash,
|
revealScheduledRow,
|
||||||
weekMonday,
|
weekMonday,
|
||||||
onNavigateToDay,
|
onNavigateToDay,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { NavArrowButton } from "@/components/ui/nav-arrow-button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
DAY_PAGE_SIZE_OPTIONS,
|
||||||
|
formatDayPaginationSummary,
|
||||||
|
type DayPageSize,
|
||||||
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
||||||
|
type WoDayPaginationFooterProps = {
|
||||||
|
dayLabel: string;
|
||||||
|
itemCount: number;
|
||||||
|
pageSize: DayPageSize;
|
||||||
|
pageIndex: number;
|
||||||
|
pageCount: number;
|
||||||
|
onPageSizeChange: (pageSize: DayPageSize) => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoDayPaginationFooter({
|
||||||
|
dayLabel,
|
||||||
|
itemCount,
|
||||||
|
pageSize,
|
||||||
|
pageIndex,
|
||||||
|
pageCount,
|
||||||
|
onPageSizeChange,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
}: WoDayPaginationFooterProps) {
|
||||||
|
const canPrev = pageIndex > 0;
|
||||||
|
const canNext = pageIndex < pageCount - 1;
|
||||||
|
const pageLabel = `Page ${pageIndex + 1} of ${pageCount}`;
|
||||||
|
const summary = formatDayPaginationSummary(dayLabel, itemCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
|
aria-label="Day pagination"
|
||||||
|
data-testid="wo-day-pagination"
|
||||||
|
>
|
||||||
|
<Text variant="caption" tone="muted">
|
||||||
|
{summary}
|
||||||
|
</Text>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Text variant="caption" tone="muted">
|
||||||
|
Rows per page
|
||||||
|
</Text>
|
||||||
|
<select
|
||||||
|
aria-label="Rows per page"
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1 text-xs"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) => onPageSizeChange(Number(event.target.value) as DayPageSize)}
|
||||||
|
>
|
||||||
|
{DAY_PAGE_SIZE_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NavArrowButton aria-label="Previous page" disabled={!canPrev} onClick={onPrev}>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
</NavArrowButton>
|
||||||
|
<Text variant="caption">{pageLabel}</Text>
|
||||||
|
<NavArrowButton
|
||||||
|
aria-label="Next page"
|
||||||
|
data-testid="wo-day-pagination-next"
|
||||||
|
disabled={!canNext}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</NavArrowButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -78,7 +78,7 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
||||||
<WoTableSectionHeader
|
<WoTableSectionHeader
|
||||||
variant="day"
|
variant="day"
|
||||||
label={group.label}
|
label={group.label}
|
||||||
count={group.items.length}
|
count={group.totalCount ?? group.items.length}
|
||||||
isToday={group.key === tableData.todayKey}
|
isToday={group.key === tableData.todayKey}
|
||||||
onAddWo={() => tableData.startDayDraft(group.key, group.date)}
|
onAddWo={() => tableData.startDayDraft(group.key, group.date)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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" }}>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,20 @@ const CARD_STYLE: React.CSSProperties = {
|
||||||
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
|
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WoTableShell({ children }: { children: React.ReactNode }) {
|
export function WoTableShell({
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-2 py-4">
|
||||||
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" style={CARD_STYLE}>
|
||||||
{children}
|
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
||||||
|
{Boolean(footer) && (
|
||||||
|
<div className="shrink-0 border-t border-border px-3 py-2">{footer}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
|
import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||||
|
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
|
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
|
||||||
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
|
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
|
||||||
|
|
@ -69,6 +70,24 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) {
|
||||||
|
if (!tableData.showDayPagination) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<WoDayPaginationFooter
|
||||||
|
dayLabel={tableData.dayFooterLabel}
|
||||||
|
itemCount={tableData.dayItemCount}
|
||||||
|
pageSize={tableData.dayPageSize}
|
||||||
|
pageIndex={tableData.dayPageIndex}
|
||||||
|
pageCount={tableData.dayPageCount}
|
||||||
|
onPageSizeChange={tableData.setDayPageSize}
|
||||||
|
onPrev={tableData.goToPrevDayPage}
|
||||||
|
onNext={tableData.goToNextDayPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WoTable({
|
export function WoTable({
|
||||||
tableData,
|
tableData,
|
||||||
users,
|
users,
|
||||||
|
|
@ -188,7 +207,7 @@ export function WoTable({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WoTableShell>
|
<WoTableShell footer={WoTableDayFooter({ tableData })}>
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
DEFAULT_DAY_PAGE_SIZE,
|
||||||
|
findItemPageIndex,
|
||||||
|
type DayPageSize,
|
||||||
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
||||||
|
type DayPageItem = { id: string | number };
|
||||||
|
|
||||||
|
type UseWorkOrdersDayPaginationInput = {
|
||||||
|
weekMonday: string;
|
||||||
|
selectedDayIso: string;
|
||||||
|
advActive: boolean;
|
||||||
|
dayItems: readonly DayPageItem[];
|
||||||
|
onRevealed: (id: string | number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkOrdersDayPagination({
|
||||||
|
weekMonday,
|
||||||
|
selectedDayIso,
|
||||||
|
advActive,
|
||||||
|
dayItems,
|
||||||
|
onRevealed,
|
||||||
|
}: UseWorkOrdersDayPaginationInput) {
|
||||||
|
const [pageSize, setPageSizeState] = useState<DayPageSize>(DEFAULT_DAY_PAGE_SIZE);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pendingRevealId, setPendingRevealId] = useState<string | number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [weekMonday, selectedDayIso, advActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingRevealId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPage = findItemPageIndex(dayItems, pendingRevealId, pageSize);
|
||||||
|
if (nextPage == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPage(nextPage);
|
||||||
|
const revealedId = pendingRevealId;
|
||||||
|
setPendingRevealId(null);
|
||||||
|
onRevealed(revealedId);
|
||||||
|
}, [pendingRevealId, dayItems, pageSize, onRevealed]);
|
||||||
|
|
||||||
|
const setPageSize = useCallback((next: DayPageSize) => {
|
||||||
|
setPageSizeState(next);
|
||||||
|
setPage(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToPrevPage = useCallback(() => {
|
||||||
|
setPage((current) => Math.max(0, current - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToNextPage = useCallback(() => {
|
||||||
|
setPage((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const revealRow = useCallback((id: string | number) => {
|
||||||
|
setPendingRevealId(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageSize,
|
||||||
|
page,
|
||||||
|
setPageSize,
|
||||||
|
goToPrevPage,
|
||||||
|
goToNextPage,
|
||||||
|
revealRow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||||
|
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
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 { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import {
|
||||||
|
pageSelectedDayGroups,
|
||||||
|
shouldShowDayPagination,
|
||||||
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
import {
|
import {
|
||||||
buildSelectedDayGroup,
|
buildSelectedDayGroup,
|
||||||
isDayScopedSearchEmpty,
|
isDayScopedSearchEmpty,
|
||||||
|
|
@ -51,7 +56,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
advActive,
|
advActive,
|
||||||
isFetching,
|
isFetching,
|
||||||
});
|
});
|
||||||
const { displayRows } = local;
|
const { displayRows, ...localUi } = local;
|
||||||
|
|
||||||
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
|
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
|
||||||
|
|
||||||
|
|
@ -85,16 +90,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||||
);
|
);
|
||||||
|
const pagination = useWorkOrdersDayPagination({
|
||||||
|
weekMonday: filters.weekMonday,
|
||||||
|
selectedDayIso: filters.selectedDayIso,
|
||||||
|
advActive,
|
||||||
|
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
|
||||||
|
onRevealed: local.triggerFlash,
|
||||||
|
});
|
||||||
|
const pagedDay = useMemo(
|
||||||
|
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
|
||||||
|
[dayGroups, pagination.page, pagination.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
const searchActive = filters.search.trim() !== "";
|
const searchActive = filters.search.trim() !== "";
|
||||||
const displayGroups = dayGroups;
|
const displayGroups = pagedDay.groups;
|
||||||
const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
|
const dayTotalCount = pagedDay.totalCount;
|
||||||
const noSearchResults =
|
const noSearchResults =
|
||||||
!advActive &&
|
!advActive &&
|
||||||
isDayScopedSearchEmpty({
|
isDayScopedSearchEmpty({
|
||||||
searchActive,
|
searchActive,
|
||||||
unassignedCount: unassigned.length,
|
unassignedCount: unassigned.length,
|
||||||
dayItemCount: dayVisibleCount,
|
dayItemCount: dayTotalCount,
|
||||||
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
|
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
|
||||||
});
|
});
|
||||||
const isEmptyWeek =
|
const isEmptyWeek =
|
||||||
|
|
@ -127,30 +143,24 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
noSearchResults,
|
noSearchResults,
|
||||||
isEmptyWeek,
|
isEmptyWeek,
|
||||||
todayKey,
|
todayKey,
|
||||||
draftDayKey: local.draftDayKey,
|
...localUi,
|
||||||
draft: local.draft,
|
|
||||||
setDraft: local.setDraft,
|
|
||||||
unassignedOpen: local.unassignedOpen,
|
|
||||||
setUnassignedOpen: local.setUnassignedOpen,
|
|
||||||
unschedOpen: local.unschedOpen,
|
|
||||||
setUnschedOpen: local.setUnschedOpen,
|
|
||||||
selectedId: local.selectedId,
|
|
||||||
setSelectedId: local.setSelectedId,
|
|
||||||
selectedRow,
|
selectedRow,
|
||||||
flashId: local.flashId,
|
|
||||||
triggerFlash: local.triggerFlash,
|
|
||||||
dragViz: local.dragViz,
|
|
||||||
setDragViz: local.setDragViz,
|
|
||||||
dragState: local.dragState,
|
|
||||||
patchRow: local.patchRow,
|
|
||||||
clearRowPatch: local.clearRowPatch,
|
|
||||||
reorderRows: local.reorderRows,
|
|
||||||
endDrag: local.endDrag,
|
|
||||||
startDayDraft: local.startDayDraft,
|
|
||||||
cancelDraft: local.cancelDraft,
|
|
||||||
addRow: local.addRow,
|
|
||||||
scopeTotalCount: data?.totalCount ?? 0,
|
scopeTotalCount: data?.totalCount ?? 0,
|
||||||
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount,
|
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount,
|
||||||
search: filters.search,
|
search: filters.search,
|
||||||
|
showDayPagination: shouldShowDayPagination({
|
||||||
|
flatListMode: advActive,
|
||||||
|
isLoading,
|
||||||
|
noSearchResults,
|
||||||
|
}),
|
||||||
|
dayPageSize: pagination.pageSize,
|
||||||
|
dayPageIndex: pagedDay.pageIndex,
|
||||||
|
dayPageCount: pagedDay.pageCount,
|
||||||
|
dayItemCount: pagedDay.totalCount,
|
||||||
|
dayFooterLabel: displayGroups[0]?.label ?? "",
|
||||||
|
setDayPageSize: pagination.setPageSize,
|
||||||
|
goToPrevDayPage: pagination.goToPrevPage,
|
||||||
|
goToNextDayPage: pagination.goToNextPage,
|
||||||
|
revealScheduledRow: pagination.revealRow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Box } from "@mui/material";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
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 { 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 { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||||
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
|
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
|
||||||
|
|
@ -30,6 +31,16 @@ export default function WorkOrdersListPage() {
|
||||||
surface
|
surface
|
||||||
eyebrow="OPERATIONS"
|
eyebrow="OPERATIONS"
|
||||||
title="Work Orders"
|
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={
|
actions={
|
||||||
<WorkOrdersListHeader
|
<WorkOrdersListHeader
|
||||||
search={filters.search}
|
search={filters.search}
|
||||||
|
|
@ -47,13 +58,8 @@ export default function WorkOrdersListPage() {
|
||||||
onWeekChange={filters.setWeekMonday}
|
onWeekChange={filters.setWeekMonday}
|
||||||
selectedDayIso={filters.selectedDayIso}
|
selectedDayIso={filters.selectedDayIso}
|
||||||
onSelectedDayChange={filters.setSelectedDayIso}
|
onSelectedDayChange={filters.setSelectedDayIso}
|
||||||
dispatcherIds={filters.dispatcherIds}
|
|
||||||
onDispatcherChange={filters.setDispatcherIds}
|
|
||||||
types={filters.types}
|
types={filters.types}
|
||||||
onTypesChange={filters.setTypes}
|
onTypesChange={filters.setTypes}
|
||||||
users={page.users}
|
|
||||||
usersLoading={page.usersLoading}
|
|
||||||
currentUserId={user?.id}
|
|
||||||
filteredCount={tableData.totalCount}
|
filteredCount={tableData.totalCount}
|
||||||
scopeTotalCount={tableData.scopeTotalCount}
|
scopeTotalCount={tableData.scopeTotalCount}
|
||||||
disabled={filters.advApplied != null}
|
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 { Stack } from "@mui/material";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
@ -8,6 +8,7 @@ type PageHeaderProps = {
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
belowTitle?: ReactNode;
|
||||||
actions?: ReactElement;
|
actions?: ReactElement;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** White surface bar (list page headers). */
|
/** White surface bar (list page headers). */
|
||||||
|
|
@ -18,6 +19,7 @@ export function PageHeader({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
belowTitle,
|
||||||
actions,
|
actions,
|
||||||
className,
|
className,
|
||||||
surface = false,
|
surface = false,
|
||||||
|
|
@ -63,6 +65,7 @@ export function PageHeader({
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
|
{Boolean(belowTitle) && <div className="mt-1">{belowTitle}</div>}
|
||||||
</Stack>
|
</Stack>
|
||||||
{Boolean(actions) && (
|
{Boolean(actions) && (
|
||||||
<Stack
|
<Stack
|
||||||
|
|
|
||||||
7
src/domain/vendors/api/vendors-api.ts
vendored
7
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -101,8 +101,11 @@ export const vendorsApi = {
|
||||||
return mapVendor(handleApiResponse(data));
|
return mapVendor(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string | number): Promise<void> => {
|
// SH-254: confirmOpenWorkOrders tells the API the caller has been shown the
|
||||||
await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
|
// vendor's open work orders and chose to proceed. Without it the API still blocks.
|
||||||
|
delete: async (id: string | number, confirmOpenWorkOrders = false): Promise<void> => {
|
||||||
|
const suffix = confirmOpenWorkOrders ? "?confirmOpenWorkOrders=true" : "";
|
||||||
|
await apiDelete(`${API_PATHS.rest.vendors}/${id}${suffix}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => {
|
getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,17 @@ import { toast } from "react-toastify";
|
||||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useDeleteVendor(): UseMutationResult<void, Error, string | number> {
|
export interface DeleteVendorInput {
|
||||||
|
id: string | number;
|
||||||
|
confirmOpenWorkOrders?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteVendor(): UseMutationResult<void, Error, DeleteVendorInput> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string | number) => vendorsApi.delete(id),
|
mutationFn: ({ id, confirmOpenWorkOrders = false }: DeleteVendorInput) =>
|
||||||
|
vendorsApi.delete(id, confirmOpenWorkOrders),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||||
toast.success("Vendor deactivated");
|
toast.success("Vendor deactivated");
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
||||||
"originalDate",
|
"originalDate",
|
||||||
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
|
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
|
||||||
"isAddOn",
|
"isAddOn",
|
||||||
|
"primaryDispatchStatus",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,10 @@ function readHasPendingUplift(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPrimaryDispatchStatus(item: Record<string, unknown>): string {
|
||||||
|
return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus");
|
||||||
|
}
|
||||||
|
|
||||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const scheduledOn = toDateInputValue(
|
const scheduledOn = toDateInputValue(
|
||||||
|
|
@ -348,6 +352,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
|
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
|
||||||
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
|
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
|
||||||
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
||||||
|
primaryDispatchStatus: readPrimaryDispatchStatus(item),
|
||||||
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
|
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
|
||||||
techPhone: readString(item, "techPhone", "TechPhone"),
|
techPhone: readString(item, "techPhone", "TechPhone"),
|
||||||
apptStart,
|
apptStart,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export interface WorkOrderBoardDetailInfoApi {
|
||||||
dayGroup?: string | null;
|
dayGroup?: string | null;
|
||||||
vendorId?: number | null;
|
vendorId?: number | null;
|
||||||
vendorName?: string | null;
|
vendorName?: string | null;
|
||||||
|
primaryDispatchStatus?: string | null;
|
||||||
techName?: string | null;
|
techName?: string | null;
|
||||||
techPhone?: string | null;
|
techPhone?: string | null;
|
||||||
apptDate?: string | null;
|
apptDate?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@ export interface WorkOrderBoardRowApi {
|
||||||
dayGroup?: string | null;
|
dayGroup?: string | null;
|
||||||
vendorId?: number | null;
|
vendorId?: number | null;
|
||||||
vendorName?: string | null;
|
vendorName?: string | null;
|
||||||
|
primaryDispatchStatus?: string | null;
|
||||||
techName?: string | null;
|
techName?: string | null;
|
||||||
techPhone?: string | null;
|
techPhone?: string | null;
|
||||||
apptDate?: string | null;
|
apptDate?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export interface WorkOrderTableRow {
|
||||||
extraServices: string[];
|
extraServices: string[];
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
company: string;
|
company: string;
|
||||||
|
primaryDispatchStatus?: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone: string;
|
techPhone: string;
|
||||||
apptStart: string;
|
apptStart: string;
|
||||||
|
|
@ -138,6 +139,7 @@ export type WorkOrderDayGroup = {
|
||||||
label: string;
|
label: string;
|
||||||
date: string;
|
date: string;
|
||||||
items: WorkOrderTableRow[];
|
items: WorkOrderTableRow[];
|
||||||
|
totalCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||||
|
|
|
||||||
80
src/domain/work-orders/utils/day-pagination.ts
Normal file
80
src/domain/work-orders/utils/day-pagination.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { WorkOrderDayGroup } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
export const DAY_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
|
||||||
|
export const DEFAULT_DAY_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export type DayPageSize = (typeof DAY_PAGE_SIZE_OPTIONS)[number];
|
||||||
|
|
||||||
|
export type PaginatedSlice<T> = {
|
||||||
|
visible: T[];
|
||||||
|
pageCount: number;
|
||||||
|
pageIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pageIndexForItem(itemIndex: number, pageSize: number): number {
|
||||||
|
if (itemIndex < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
|
||||||
|
return Math.floor(itemIndex / size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findItemPageIndex<T extends { id: string | number }>(
|
||||||
|
items: readonly T[],
|
||||||
|
id: string | number,
|
||||||
|
pageSize: number,
|
||||||
|
): number | null {
|
||||||
|
const index = items.findIndex((item) => String(item.id) === String(id));
|
||||||
|
if (index < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return pageIndexForItem(index, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginateItems<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
pageIndex: number,
|
||||||
|
pageSize: number,
|
||||||
|
): PaginatedSlice<T> {
|
||||||
|
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(items.length / size) || 1);
|
||||||
|
const page = Math.min(Math.max(0, pageIndex), pageCount - 1);
|
||||||
|
const start = page * size;
|
||||||
|
return {
|
||||||
|
visible: items.slice(start, start + size),
|
||||||
|
pageCount,
|
||||||
|
pageIndex: page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowDayPagination(input: {
|
||||||
|
flatListMode: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
noSearchResults: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return !input.flatListMode && !input.isLoading && !input.noSearchResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDayPaginationSummary(dayLabel: string, itemCount: number): string {
|
||||||
|
const label = dayLabel.trim().toUpperCase();
|
||||||
|
const noun = itemCount === 1 ? "item" : "items";
|
||||||
|
return `${label} · ${itemCount} ${noun}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageSelectedDayGroups(
|
||||||
|
dayGroups: WorkOrderDayGroup[],
|
||||||
|
pageIndex: number,
|
||||||
|
pageSize: number,
|
||||||
|
): { groups: WorkOrderDayGroup[]; pageIndex: number; pageCount: number; totalCount: number } {
|
||||||
|
const group = dayGroups[0];
|
||||||
|
if (!group) {
|
||||||
|
return { groups: dayGroups, pageIndex: 0, pageCount: 1, totalCount: 0 };
|
||||||
|
}
|
||||||
|
const slice = paginateItems(group.items, pageIndex, pageSize);
|
||||||
|
return {
|
||||||
|
groups: [{ ...group, items: slice.visible, totalCount: group.items.length }],
|
||||||
|
pageIndex: slice.pageIndex,
|
||||||
|
pageCount: slice.pageCount,
|
||||||
|
totalCount: group.items.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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 { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||||
import type {
|
import type {
|
||||||
CompletionDocStatus,
|
CompletionDocStatus,
|
||||||
|
WorkOrderTableRow,
|
||||||
WorkOrderUpliftSummary,
|
WorkOrderUpliftSummary,
|
||||||
} 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. */
|
||||||
|
vendorId: string;
|
||||||
/** Technician is optional for closability; modeled separately from company. */
|
/** Technician is optional for closability; modeled separately from company. */
|
||||||
tech: string;
|
tech: string;
|
||||||
/** Work completed date (API CompletedDate). Empty until set. */
|
/** 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());
|
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCompany(company: string): boolean {
|
function hasAssignedCompany(row: Pick<WoClosabilityInput, "company" | "vendorId">): boolean {
|
||||||
return Boolean(company.trim());
|
return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasService(pm: string): boolean {
|
function hasService(pm: string): boolean {
|
||||||
|
|
@ -80,7 +84,7 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] {
|
||||||
if (!hasDispatcher(row)) {
|
if (!hasDispatcher(row)) {
|
||||||
gaps.push("Dispatcher");
|
gaps.push("Dispatcher");
|
||||||
}
|
}
|
||||||
if (!hasCompany(row.company)) {
|
if (!hasAssignedCompany(row)) {
|
||||||
gaps.push("Company");
|
gaps.push("Company");
|
||||||
}
|
}
|
||||||
if (!row.completedDate.trim()) {
|
if (!row.completedDate.trim()) {
|
||||||
|
|
@ -143,6 +147,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
|
||||||
dispatcherId: draft.assigneeId,
|
dispatcherId: draft.assigneeId,
|
||||||
dispatcherName: draft.assigneeName,
|
dispatcherName: draft.assigneeName,
|
||||||
company: draft.vendorName,
|
company: draft.vendorName,
|
||||||
|
vendorId: draft.vendorId ?? "",
|
||||||
tech: "",
|
tech: "",
|
||||||
completedDate: "",
|
completedDate: "",
|
||||||
woNumber: draft.woNumber,
|
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` /
|
* Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` /
|
||||||
* `mediaCount` (enriched from board detail on the view route). Never treats `source`
|
* `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`.
|
* as WorkOrderType. `problem` is the same service field the board maps into `pm`.
|
||||||
*/
|
*/
|
||||||
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
|
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
|
||||||
const vendorName = wo.dispatches.find((dispatch) =>
|
const assigned = currentAssignedDispatch(wo.dispatches);
|
||||||
Boolean(dispatch.vendorName.trim()),
|
|
||||||
)?.vendorName;
|
|
||||||
return {
|
return {
|
||||||
dispatcherId: "",
|
dispatcherId: "",
|
||||||
dispatcherName: wo.assignedTo,
|
dispatcherName: wo.assignedTo,
|
||||||
company: vendorName ?? "",
|
company: assigned?.vendorName ?? "",
|
||||||
|
vendorId: assigned ? String(assigned.vendorId ?? "").trim() : "",
|
||||||
tech: "",
|
tech: "",
|
||||||
completedDate: wo.completedDate,
|
completedDate: wo.completedDate,
|
||||||
woNumber: wo.workOrderNumber || wo.internalWONumber,
|
woNumber: wo.workOrderNumber || wo.internalWONumber,
|
||||||
|
|
|
||||||
192
src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx
vendored
Normal file
192
src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm, useWatch, type Control } from "react-hook-form";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { VendorTradeSpecialtiesField } from "@/app/(protected)/vendors/_components/vendor-trade-specialties-field";
|
||||||
|
import {
|
||||||
|
emptyRosterTechnician,
|
||||||
|
emptyVendorCompanyRosterForm,
|
||||||
|
type VendorCompanyRosterFormValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function TradeSpecialtiesSpy({
|
||||||
|
control,
|
||||||
|
valuesRef,
|
||||||
|
}: {
|
||||||
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
|
valuesRef: { current: string };
|
||||||
|
}) {
|
||||||
|
const value = useWatch({ control, name: "technicians.0.tradeSpecialties" }) ?? "";
|
||||||
|
useEffect(() => {
|
||||||
|
valuesRef.current = value;
|
||||||
|
}, [value, valuesRef]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradeSpecialtiesHarness({
|
||||||
|
tradeOptions,
|
||||||
|
tradeOptionsLoading = false,
|
||||||
|
initialTradeSpecialties = "",
|
||||||
|
valuesRef,
|
||||||
|
}: {
|
||||||
|
tradeOptions: string[];
|
||||||
|
tradeOptionsLoading?: boolean;
|
||||||
|
initialTradeSpecialties?: string;
|
||||||
|
valuesRef: { current: string };
|
||||||
|
}) {
|
||||||
|
const { control } = useForm<VendorCompanyRosterFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
...emptyVendorCompanyRosterForm,
|
||||||
|
technicians: [{ ...emptyRosterTechnician, tradeSpecialties: initialTradeSpecialties }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VendorTradeSpecialtiesField
|
||||||
|
control={control}
|
||||||
|
index={0}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
tradeOptionsLoading={tradeOptionsLoading}
|
||||||
|
/>
|
||||||
|
<TradeSpecialtiesSpy control={control} valuesRef={valuesRef} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddTradeInput(): HTMLElement {
|
||||||
|
return screen.getByRole("combobox", { name: "Add Trade" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeAndCommitTrade(input: HTMLElement, value: string, commit: "enter" | "button") {
|
||||||
|
fireEvent.change(input, { target: { value } });
|
||||||
|
if (commit === "enter") {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
} else {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add trade" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VendorTradeSpecialtiesField", () => {
|
||||||
|
it("offers facet trades as selectable options when the facets query is populated", async () => {
|
||||||
|
const valuesRef = { current: "" };
|
||||||
|
renderWithProviders(
|
||||||
|
<TradeSpecialtiesHarness
|
||||||
|
tradeOptions={["Plumbing", "HVAC", "Electrical"]}
|
||||||
|
valuesRef={valuesRef}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getAddTradeInput();
|
||||||
|
fireEvent.change(input, { target: { value: "H" } });
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "HVAC" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("HVAC (primary)")).toBeInTheDocument();
|
||||||
|
expect(valuesRef.current).toBe("HVAC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail open while the trades facet is still loading", () => {
|
||||||
|
const valuesRef = { current: "" };
|
||||||
|
renderWithProviders(
|
||||||
|
<TradeSpecialtiesHarness tradeOptions={[]} tradeOptionsLoading valuesRef={valuesRef} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A cold fetch must not look like an outage.
|
||||||
|
expect(screen.queryByText(/trade list is unavailable right now/i)).toBeNull();
|
||||||
|
|
||||||
|
const input = getAddTradeInput();
|
||||||
|
typeAndCommitTrade(input, "Plumbing", "enter");
|
||||||
|
|
||||||
|
expect(screen.getByText(/trades are still loading/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Plumbing (primary)")).toBeNull();
|
||||||
|
expect(valuesRef.current).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no selectable options when the trades facet is empty, warns, and still accepts typed trades", () => {
|
||||||
|
const valuesRef = { current: "" };
|
||||||
|
renderWithProviders(<TradeSpecialtiesHarness tradeOptions={[]} valuesRef={valuesRef} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/trade list is unavailable right now/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const input = getAddTradeInput();
|
||||||
|
fireEvent.change(input, { target: { value: "P" } });
|
||||||
|
expect(screen.queryByRole("listbox")).toBeNull();
|
||||||
|
expect(screen.queryAllByRole("option")).toHaveLength(0);
|
||||||
|
|
||||||
|
typeAndCommitTrade(input, "Plumbing", "enter");
|
||||||
|
|
||||||
|
expect(screen.getByText("Plumbing (primary)")).toBeInTheDocument();
|
||||||
|
expect(valuesRef.current).toBe("Plumbing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects trades outside the canonical list with visible feedback and keeps the draft", () => {
|
||||||
|
const valuesRef = { current: "" };
|
||||||
|
renderWithProviders(
|
||||||
|
<TradeSpecialtiesHarness tradeOptions={["Plumbing", "HVAC"]} valuesRef={valuesRef} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getAddTradeInput();
|
||||||
|
typeAndCommitTrade(input, "Sprinkler Fitting", "button");
|
||||||
|
|
||||||
|
expect(screen.getByText(/is not in the trades list/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Sprinkler Fitting (primary)")).toBeNull();
|
||||||
|
expect(valuesRef.current).toBe("");
|
||||||
|
expect(input).toHaveValue("Sprinkler Fitting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits the canonical casing when a typed trade matches case-insensitively", () => {
|
||||||
|
const valuesRef = { current: "" };
|
||||||
|
renderWithProviders(
|
||||||
|
<TradeSpecialtiesHarness tradeOptions={["Plumbing", "HVAC"]} valuesRef={valuesRef} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getAddTradeInput();
|
||||||
|
typeAndCommitTrade(input, "hvac", "enter");
|
||||||
|
|
||||||
|
expect(screen.getByText("HVAC (primary)")).toBeInTheDocument();
|
||||||
|
expect(valuesRef.current).toBe("HVAC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps legacy free-text trades visible, removable, and re-selectable", async () => {
|
||||||
|
const valuesRef = { current: "" };
|
||||||
|
renderWithProviders(
|
||||||
|
<TradeSpecialtiesHarness
|
||||||
|
tradeOptions={["Plumbing", "HVAC"]}
|
||||||
|
initialTradeSpecialties="Backflow Testing, HVAC"
|
||||||
|
valuesRef={valuesRef}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Backflow Testing (primary)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("HVAC")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("remove-trade-Backflow Testing"));
|
||||||
|
expect(screen.queryByText(/Backflow Testing/)).toBeNull();
|
||||||
|
expect(valuesRef.current).toBe("HVAC");
|
||||||
|
|
||||||
|
const input = getAddTradeInput();
|
||||||
|
fireEvent.change(input, { target: { value: "Back" } });
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Backflow Testing" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Backflow Testing")).toBeInTheDocument();
|
||||||
|
expect(valuesRef.current).toBe("HVAC, Backflow Testing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags duplicate trades instead of silently doing nothing", () => {
|
||||||
|
const valuesRef = { current: "" };
|
||||||
|
renderWithProviders(
|
||||||
|
<TradeSpecialtiesHarness
|
||||||
|
tradeOptions={["Plumbing"]}
|
||||||
|
initialTradeSpecialties="Plumbing"
|
||||||
|
valuesRef={valuesRef}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getAddTradeInput();
|
||||||
|
typeAndCommitTrade(input, "plumbing", "enter");
|
||||||
|
|
||||||
|
expect(screen.getByText(/is already on this technician/i)).toBeInTheDocument();
|
||||||
|
expect(valuesRef.current).toBe("Plumbing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -177,7 +177,7 @@ describe("VendorsListPage", () => {
|
||||||
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks deactivation when the preflight reports open work orders", async () => {
|
it("offers deactivate-anyway when the preflight reports open work orders", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
data: activeRoster,
|
data: activeRoster,
|
||||||
|
|
@ -213,13 +213,52 @@ describe("VendorsListPage", () => {
|
||||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
screen.getByText(
|
||||||
|
/will no longer be selectable for new work orders\. It still has 1 open work order —/,
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Boiler repair/)).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /Boiler repair/ })).toHaveAttribute(
|
||||||
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
"href",
|
||||||
expect(mutate).not.toHaveBeenCalled();
|
"/workorders/101",
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirm = screen.getByRole("button", { name: "Deactivate anyway" });
|
||||||
|
expect(confirm).toBeEnabled();
|
||||||
|
await userEvent.click(confirm);
|
||||||
|
|
||||||
|
expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: true }, expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deactivates without the confirmation flag when nothing is linked", async () => {
|
||||||
|
setupDefaults();
|
||||||
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
data: activeRoster,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
|
data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
|
|
||||||
|
expect(screen.queryByText(/It still has/)).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /^Deactivate$/ }));
|
||||||
|
|
||||||
|
expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: false }, expect.anything());
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
it("preserves inline edits when deactivation is cancelled", async () => {
|
it("preserves inline edits when deactivation is cancelled", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
|
|
@ -247,7 +286,7 @@ describe("VendorsListPage", () => {
|
||||||
await userEvent.type(company, "Draft Company Name");
|
await userEvent.type(company, "Draft Company Name");
|
||||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", {
|
within(screen.getByRole("dialog", { name: "Deactivate this vendor?" })).getByRole("button", {
|
||||||
name: "Cancel",
|
name: "Cancel",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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: "",
|
dispatcherId: "",
|
||||||
dispatcherName: "",
|
dispatcherName: "",
|
||||||
company: "",
|
company: "",
|
||||||
|
vendorId: "",
|
||||||
tech: "",
|
tech: "",
|
||||||
completedDate: "",
|
completedDate: "",
|
||||||
woNumber: "",
|
woNumber: "",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const pastDueRow = {
|
||||||
dispatcherId: "",
|
dispatcherId: "",
|
||||||
dispatcherName: "",
|
dispatcherName: "",
|
||||||
company: "",
|
company: "",
|
||||||
|
vendorId: "",
|
||||||
tech: "",
|
tech: "",
|
||||||
completedDate: "",
|
completedDate: "",
|
||||||
woNumber: "",
|
woNumber: "",
|
||||||
|
|
|
||||||
|
|
@ -120,12 +120,12 @@ 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(
|
||||||
<StatusCell
|
<StatusCell
|
||||||
wo={makeRow({ company: "" })}
|
wo={makeRow({ company: "", vendorId: "" })}
|
||||||
onChangeStatus={onChangeStatus}
|
onChangeStatus={onChangeStatus}
|
||||||
onOpenSlideOver={vi.fn()}
|
onOpenSlideOver={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -141,7 +141,119 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
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();
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ function buildTableData() {
|
||||||
endDrag: vi.fn(),
|
endDrag: vi.fn(),
|
||||||
setUnschedOpen: vi.fn(),
|
setUnschedOpen: vi.fn(),
|
||||||
triggerFlash: vi.fn(),
|
triggerFlash: vi.fn(),
|
||||||
|
revealScheduledRow: vi.fn(),
|
||||||
setSelectedId: vi.fn(),
|
setSelectedId: vi.fn(),
|
||||||
} as never;
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify",
|
||||||
weekMoveNotify,
|
weekMoveNotify,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function buildTableData(triggerFlash = vi.fn()) {
|
function buildTableData(revealScheduledRow = vi.fn()) {
|
||||||
return {
|
return {
|
||||||
dragState: { current: { dragId: null, overId: null } },
|
dragState: { current: { dragId: null, overId: null } },
|
||||||
setDragViz: vi.fn(),
|
setDragViz: vi.fn(),
|
||||||
reorderRows: vi.fn(),
|
reorderRows: vi.fn(),
|
||||||
endDrag: vi.fn(),
|
endDrag: vi.fn(),
|
||||||
triggerFlash,
|
revealScheduledRow,
|
||||||
setSelectedId: vi.fn(),
|
setSelectedId: vi.fn(),
|
||||||
} as never;
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
@ -28,11 +28,11 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||||
it("selects the scheduled weekday in the same week so the row stays visible", () => {
|
it("selects the scheduled weekday in the same week so the row stays visible", () => {
|
||||||
const onNavigateToDay = vi.fn();
|
const onNavigateToDay = vi.fn();
|
||||||
const onPatch = vi.fn();
|
const onPatch = vi.fn();
|
||||||
const triggerFlash = vi.fn();
|
const revealScheduledRow = vi.fn();
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useWoTableRowHandlers({
|
useWoTableRowHandlers({
|
||||||
tableData: buildTableData(triggerFlash),
|
tableData: buildTableData(revealScheduledRow),
|
||||||
weekMonday: "2026-05-18",
|
weekMonday: "2026-05-18",
|
||||||
onNavigateToDay,
|
onNavigateToDay,
|
||||||
onOpenSlideOver: vi.fn(),
|
onOpenSlideOver: vi.fn(),
|
||||||
|
|
@ -46,16 +46,17 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||||
|
|
||||||
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
|
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
|
||||||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
|
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
|
||||||
expect(triggerFlash).toHaveBeenCalledWith("wo-1");
|
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
|
||||||
expect(rescheduleNotify).not.toHaveBeenCalled();
|
expect(rescheduleNotify).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends cross-week view-it navigation to the target day, not Monday", () => {
|
it("sends cross-week view-it navigation to the target day, not Monday", () => {
|
||||||
const onNavigateToDay = vi.fn();
|
const onNavigateToDay = vi.fn();
|
||||||
|
const revealScheduledRow = vi.fn();
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useWoTableRowHandlers({
|
useWoTableRowHandlers({
|
||||||
tableData: buildTableData(),
|
tableData: buildTableData(revealScheduledRow),
|
||||||
weekMonday: "2026-05-18",
|
weekMonday: "2026-05-18",
|
||||||
onNavigateToDay,
|
onNavigateToDay,
|
||||||
onOpenSlideOver: vi.fn(),
|
onOpenSlideOver: vi.fn(),
|
||||||
|
|
@ -76,6 +77,7 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||||
const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void };
|
const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void };
|
||||||
onNavigate();
|
onNavigate();
|
||||||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27");
|
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27");
|
||||||
|
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {
|
it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||||
|
import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
||||||
|
describe("useWorkOrdersDayPagination (SH-241)", () => {
|
||||||
|
const onRevealed = vi.fn();
|
||||||
|
const base = {
|
||||||
|
weekMonday: "2026-08-17",
|
||||||
|
selectedDayIso: "2026-08-17",
|
||||||
|
advActive: false,
|
||||||
|
dayItems: [] as { id: string }[],
|
||||||
|
onRevealed,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("defaults to 50 rows per page", () => {
|
||||||
|
const { result } = renderHook(() => useWorkOrdersDayPagination(base));
|
||||||
|
expect(result.current.pageSize).toBe(DEFAULT_DAY_PAGE_SIZE);
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets to page 1 when switching days", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
(props: typeof base) => useWorkOrdersDayPagination(props),
|
||||||
|
{ initialProps: base },
|
||||||
|
);
|
||||||
|
act(() => result.current.goToNextPage());
|
||||||
|
expect(result.current.page).toBe(1);
|
||||||
|
rerender({ ...base, selectedDayIso: "2026-08-18" });
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets to page 1 when the week changes", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
(props: typeof base) => useWorkOrdersDayPagination(props),
|
||||||
|
{ initialProps: base },
|
||||||
|
);
|
||||||
|
act(() => result.current.goToNextPage());
|
||||||
|
rerender({ ...base, weekMonday: "2026-08-10" });
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets to page 1 when rows-per-page changes", () => {
|
||||||
|
const { result } = renderHook(() => useWorkOrdersDayPagination(base));
|
||||||
|
act(() => result.current.goToNextPage());
|
||||||
|
act(() => result.current.setPageSize(25));
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
expect(result.current.pageSize).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets to page 1 when a global filter is applied", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
(props: typeof base) => useWorkOrdersDayPagination(props),
|
||||||
|
{ initialProps: base },
|
||||||
|
);
|
||||||
|
act(() => result.current.goToNextPage());
|
||||||
|
rerender({ ...base, advActive: true });
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the page that contains a revealed row beyond the first page", () => {
|
||||||
|
const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||||
|
const revealed = vi.fn();
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
(props: typeof base) => useWorkOrdersDayPagination(props),
|
||||||
|
{ initialProps: { ...base, onRevealed: revealed } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.revealRow("wo-55"));
|
||||||
|
rerender({ ...base, dayItems, onRevealed: revealed, selectedDayIso: "2026-08-18" });
|
||||||
|
|
||||||
|
expect(result.current.page).toBe(1);
|
||||||
|
expect(revealed).toHaveBeenCalledWith("wo-55");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WoDayPaginationFooter } from "@/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer";
|
||||||
|
|
||||||
|
describe("WoDayPaginationFooter (SH-241)", () => {
|
||||||
|
it("changes rows-per-page and navigates pages", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onPageSizeChange = vi.fn();
|
||||||
|
const onNext = vi.fn();
|
||||||
|
render(
|
||||||
|
<WoDayPaginationFooter
|
||||||
|
dayLabel="Mon · 06/01"
|
||||||
|
itemCount={60}
|
||||||
|
pageSize={50}
|
||||||
|
pageIndex={0}
|
||||||
|
pageCount={2}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
onPrev={vi.fn()}
|
||||||
|
onNext={onNext}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("MON · 06/01 · 60 items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
|
||||||
|
await user.selectOptions(screen.getByLabelText("Rows per page"), "25");
|
||||||
|
expect(onPageSizeChange).toHaveBeenCalledWith(25);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next page" }));
|
||||||
|
expect(onNext).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -43,6 +43,7 @@ function tableData(
|
||||||
selectedRow: null,
|
selectedRow: null,
|
||||||
flashId: null,
|
flashId: null,
|
||||||
triggerFlash: vi.fn(),
|
triggerFlash: vi.fn(),
|
||||||
|
revealScheduledRow: vi.fn(),
|
||||||
dragViz: null,
|
dragViz: null,
|
||||||
setDragViz: vi.fn(),
|
setDragViz: vi.fn(),
|
||||||
dragState: { current: { dragId: null, overId: null } },
|
dragState: { current: { dragId: null, overId: null } },
|
||||||
|
|
@ -56,6 +57,15 @@ function tableData(
|
||||||
scopeTotalCount: 0,
|
scopeTotalCount: 0,
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
search: "no-match",
|
search: "no-match",
|
||||||
|
showDayPagination: false,
|
||||||
|
dayPageSize: 50,
|
||||||
|
dayPageIndex: 0,
|
||||||
|
dayPageCount: 1,
|
||||||
|
dayItemCount: 0,
|
||||||
|
dayFooterLabel: "Mon · 05/18",
|
||||||
|
setDayPageSize: vi.fn(),
|
||||||
|
goToPrevDayPage: vi.fn(),
|
||||||
|
goToNextDayPage: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as ReturnTypeOfUseWorkOrdersTableData;
|
} as ReturnTypeOfUseWorkOrdersTableData;
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +89,7 @@ function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => {
|
describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => {
|
||||||
it("shows empty search only after the Unassigned queue has settled empty", () => {
|
it("shows empty search only after the Unassigned queue has settled empty", () => {
|
||||||
const { getByText, queryByText } = renderTable(tableData());
|
const { getByText, queryByText } = renderTable(tableData());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
types: new Set(),
|
||||||
|
setTypes: 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,
|
isLocalOnlyTablePatch,
|
||||||
mapDocStatusToApi,
|
mapDocStatusToApi,
|
||||||
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||||
|
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||||
|
|
||||||
describe("mapDocStatusToApi", () => {
|
describe("mapDocStatusToApi", () => {
|
||||||
it("maps NN to NotNeeded", () => {
|
it("maps NN to NotNeeded", () => {
|
||||||
|
|
@ -25,6 +26,10 @@ describe("isLocalOnlyTablePatch", () => {
|
||||||
it("does not treat site as local-only", () => {
|
it("does not treat site as local-only", () => {
|
||||||
expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false);
|
expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats primaryDispatchStatus overlay as local-only", () => {
|
||||||
|
expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("expandTablePatchToBoardFieldOps", () => {
|
describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
|
|
@ -136,6 +141,17 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
}),
|
}),
|
||||||
).toEqual([{ field: "vendorId", value: "7" }]);
|
).toEqual([{ field: "vendorId", value: "7" }]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
expandTablePatchToBoardFieldOps(
|
||||||
|
toVendorTablePatch({
|
||||||
|
vendorId: "9",
|
||||||
|
company: "New Co",
|
||||||
|
tech: "Pat",
|
||||||
|
techPhone: "555",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([{ field: "vendorId", value: "9" }]);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
expandTablePatchToBoardFieldOps({
|
expandTablePatchToBoardFieldOps({
|
||||||
site: "BK5",
|
site: "BK5",
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,52 @@ describe("mapWorkOrderTableRow isAddOn", () => {
|
||||||
expect(row.isAddOn).toBe(false);
|
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() } }));
|
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||||
import {
|
import {
|
||||||
applySchedulePatch,
|
applySchedulePatch,
|
||||||
useWorkOrderTableMutations,
|
useWorkOrderTableMutations,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
} 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> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
84
src/test/domain/work-orders/utils/day-pagination.test.ts
Normal file
84
src/test/domain/work-orders/utils/day-pagination.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DAY_PAGE_SIZE_OPTIONS,
|
||||||
|
DEFAULT_DAY_PAGE_SIZE,
|
||||||
|
findItemPageIndex,
|
||||||
|
formatDayPaginationSummary,
|
||||||
|
pageIndexForItem,
|
||||||
|
paginateItems,
|
||||||
|
shouldShowDayPagination,
|
||||||
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
||||||
|
describe("day pagination utils (SH-241)", () => {
|
||||||
|
it("defaults to 50 and exposes 25/50/100/200", () => {
|
||||||
|
expect(DEFAULT_DAY_PAGE_SIZE).toBe(50);
|
||||||
|
expect(DAY_PAGE_SIZE_OPTIONS).toEqual([25, 50, 100, 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates 60 items at 50 per page", () => {
|
||||||
|
const items = Array.from({ length: 60 }, (_, index) => index);
|
||||||
|
const first = paginateItems(items, 0, 50);
|
||||||
|
expect(first.visible).toHaveLength(50);
|
||||||
|
expect(first.pageCount).toBe(2);
|
||||||
|
const second = paginateItems(items, 1, 50);
|
||||||
|
expect(second.visible).toEqual(items.slice(50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-paginates immediately when page size changes", () => {
|
||||||
|
const items = Array.from({ length: 60 }, (_, index) => index);
|
||||||
|
expect(paginateItems(items, 0, 25).visible).toHaveLength(25);
|
||||||
|
expect(paginateItems(items, 0, 25).pageCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an out-of-range page to the last page", () => {
|
||||||
|
const paged = paginateItems([1, 2, 3], 9, 2);
|
||||||
|
expect(paged.pageIndex).toBe(1);
|
||||||
|
expect(paged.visible).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows pagination in day view even when the page is not full", () => {
|
||||||
|
expect(
|
||||||
|
shouldShowDayPagination({
|
||||||
|
flatListMode: false,
|
||||||
|
isLoading: false,
|
||||||
|
noSearchResults: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides pagination in flat-list / loading / empty search", () => {
|
||||||
|
expect(
|
||||||
|
shouldShowDayPagination({
|
||||||
|
flatListMode: true,
|
||||||
|
isLoading: false,
|
||||||
|
noSearchResults: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldShowDayPagination({
|
||||||
|
flatListMode: false,
|
||||||
|
isLoading: true,
|
||||||
|
noSearchResults: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldShowDayPagination({
|
||||||
|
flatListMode: false,
|
||||||
|
isLoading: false,
|
||||||
|
noSearchResults: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a row past the first page onto page 2 at the default size", () => {
|
||||||
|
const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||||
|
expect(pageIndexForItem(50, 50)).toBe(1);
|
||||||
|
expect(findItemPageIndex(items, "wo-55", 50)).toBe(1);
|
||||||
|
expect(findItemPageIndex(items, "missing", 50)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats the AAP footer summary", () => {
|
||||||
|
expect(formatDayPaginationSummary("Mon · 06/01", 9)).toBe("MON · 06/01 · 9 items");
|
||||||
|
expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 { describe, expect, it } from "vitest";
|
||||||
import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields";
|
import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields";
|
||||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
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 { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
|
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +14,7 @@ import {
|
||||||
hasPendingUplift,
|
hasPendingUplift,
|
||||||
isCompletionStatusLabel,
|
isCompletionStatusLabel,
|
||||||
isRealWoNumber,
|
isRealWoNumber,
|
||||||
|
tableRowToClosabilityInput,
|
||||||
wizardDraftToClosabilityInput,
|
wizardDraftToClosabilityInput,
|
||||||
} from "@/domain/work-orders/utils/wo-closability";
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
|
||||||
|
|
@ -21,6 +23,7 @@ function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityIn
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Alex",
|
dispatcherName: "Alex",
|
||||||
company: "Acme HVAC",
|
company: "Acme HVAC",
|
||||||
|
vendorId: "",
|
||||||
tech: "Sam",
|
tech: "Sam",
|
||||||
completedDate: "2026-07-01",
|
completedDate: "2026-07-01",
|
||||||
woNumber: "1001",
|
woNumber: "1001",
|
||||||
|
|
@ -74,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");
|
||||||
|
|
@ -85,6 +88,32 @@ 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", () => {
|
||||||
|
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", () => {
|
it("lists field gaps before uplift and document gaps", () => {
|
||||||
expect(
|
expect(
|
||||||
getClosabilityGaps(
|
getClosabilityGaps(
|
||||||
|
|
@ -188,6 +217,7 @@ describe("wizardDraftToClosabilityInput", () => {
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Alice",
|
dispatcherName: "Alice",
|
||||||
company: "ACME",
|
company: "ACME",
|
||||||
|
vendorId: "",
|
||||||
tech: "",
|
tech: "",
|
||||||
completedDate: "",
|
completedDate: "",
|
||||||
woNumber: "20260623001",
|
woNumber: "20260623001",
|
||||||
|
|
@ -221,6 +251,7 @@ describe("detailToClosabilityInput", () => {
|
||||||
const input = detailToClosabilityInput(detail);
|
const input = detailToClosabilityInput(detail);
|
||||||
expect(input.dispatcherName).toBe("Alice");
|
expect(input.dispatcherName).toBe("Alice");
|
||||||
expect(input.company).toBe("ACME Tech");
|
expect(input.company).toBe("ACME Tech");
|
||||||
|
expect(input.vendorId).toBe("");
|
||||||
expect(input.woNumber).toBe("20260623001");
|
expect(input.woNumber).toBe("20260623001");
|
||||||
expect(input.pm).toBe("HVAC PM");
|
expect(input.pm).toBe("HVAC PM");
|
||||||
expect(getClosabilityGaps(input)).toEqual([]);
|
expect(getClosabilityGaps(input)).toEqual([]);
|
||||||
|
|
@ -277,6 +308,171 @@ 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", () => {
|
||||||
|
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", () => {
|
describe("readDetailWoType", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue