mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
30 commits
fce8520f33
...
632e00a0ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
632e00a0ce | ||
|
|
75c82e90a5 | ||
|
|
3dd174573a | ||
|
|
e6567c2c0a | ||
|
|
3141adba13 | ||
|
|
12411dc295 | ||
|
|
91f30304a2 | ||
|
|
326ac18eb0 | ||
|
|
aebb2c6b1a | ||
|
|
3ade310a1a | ||
|
|
98daf9832d | ||
|
|
6da2e88cbf | ||
|
|
ee5eae8927 | ||
|
|
fc63948d4e | ||
|
|
4e36396868 | ||
|
|
8684cb6c8b | ||
|
|
8edcb6659d | ||
|
|
0c514c9bad | ||
|
|
670d03d572 | ||
|
|
4e5863def5 | ||
|
|
22b03b8ac0 | ||
|
|
038ce263da | ||
|
|
90a5256dee | ||
|
|
a71407b426 | ||
|
|
1305bf35dd | ||
|
|
0de695362a | ||
|
|
9a066092b3 | ||
|
|
9624c264de | ||
|
|
4d1f02b87d | ||
|
|
a710e21281 |
36 changed files with 1981 additions and 176 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 117 KiB |
127
e2e/vendors/vendors.spec.ts
vendored
127
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -65,7 +65,10 @@ interface MockState {
|
||||||
listUrls: string[];
|
listUrls: string[];
|
||||||
createdBody?: Record<string, unknown>;
|
createdBody?: Record<string, unknown>;
|
||||||
updatedBody?: Record<string, unknown>;
|
updatedBody?: Record<string, unknown>;
|
||||||
|
patchedBody?: Record<string, unknown>;
|
||||||
|
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) {
|
||||||
|
|
@ -175,6 +178,52 @@ async function mockVendorApi(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.method() === "PATCH" && pathCompanyId) {
|
||||||
|
state.patchedBody = request.postDataJSON();
|
||||||
|
state.patchedCompanyId = pathCompanyId;
|
||||||
|
const anchor = vendorRecords.find((vendor) => String(vendor.CompanyId) === pathCompanyId);
|
||||||
|
if (!anchor) {
|
||||||
|
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const added = Array.isArray(state.patchedBody.addTechnicians)
|
||||||
|
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
await fulfillJson(route, {
|
||||||
|
companyId: anchor.CompanyId,
|
||||||
|
rowVersion: "rv-patched",
|
||||||
|
name: anchor.CompanyName,
|
||||||
|
companyPhone: anchor.CompanyPhone,
|
||||||
|
email: anchor.Email,
|
||||||
|
address: anchor.Address,
|
||||||
|
city: anchor.City,
|
||||||
|
state: anchor.State,
|
||||||
|
zip: anchor.Zip,
|
||||||
|
googleMapsUrl: anchor.GoogleMapsUrl,
|
||||||
|
notes: anchor.Notes,
|
||||||
|
technicians: [
|
||||||
|
...vendorRecords
|
||||||
|
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
|
||||||
|
.map((vendor) => ({
|
||||||
|
id: vendor.Id,
|
||||||
|
contactName: vendor.ContactName,
|
||||||
|
phone: vendor.Phone,
|
||||||
|
email: vendor.Email,
|
||||||
|
preferredContact: vendor.PreferredContact ?? "Phone",
|
||||||
|
tradeSpecialties: vendor.TradeSpecialties,
|
||||||
|
isActive: vendor.IsActive,
|
||||||
|
totalJobs: vendor.TotalJobs,
|
||||||
|
})),
|
||||||
|
...added.map((technician, index) => ({
|
||||||
|
id: 900 + index,
|
||||||
|
totalJobs: 0,
|
||||||
|
...technician,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method() === "PUT" && pathCompanyId) {
|
if (request.method() === "PUT" && pathCompanyId) {
|
||||||
state.updatedBody = request.postDataJSON();
|
state.updatedBody = request.postDataJSON();
|
||||||
const technicians = Array.isArray(state.updatedBody.technicians)
|
const technicians = Array.isArray(state.updatedBody.technicians)
|
||||||
|
|
@ -259,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);
|
||||||
|
|
@ -273,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,
|
||||||
|
|
@ -409,6 +461,11 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
"https://maps.google.com/gateway",
|
"https://maps.google.com/gateway",
|
||||||
);
|
);
|
||||||
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
|
||||||
|
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: /Add Vendor/ }).getByText("Adam Whyte"),
|
||||||
|
).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Add technician" }).click();
|
await page.getByRole("button", { name: "Add technician" }).click();
|
||||||
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
||||||
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||||||
|
|
@ -420,27 +477,33 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
||||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||||
expect(state.updatedBody).toMatchObject({
|
expect(state.patchedCompanyId).toBe("101");
|
||||||
name: "Gateway Plumbing",
|
expect(state.patchedBody).toMatchObject({ rowVersion: "rv-1" });
|
||||||
companyPhone: "(314) 555-0100",
|
// Only the field the user actually typed into is transmitted. Address/phone/email were
|
||||||
notes: "Created in browser E2E",
|
// populated by selecting the company and never edited, so they must not be sent — otherwise a
|
||||||
rowVersion: "rv-1",
|
// concurrent edit by another user to any of them would be silently reverted on resubmit.
|
||||||
});
|
expect(state.patchedBody?.companyFields).toEqual({ notes: "Created in browser E2E" });
|
||||||
expect(state.updatedBody?.technicians).toEqual(
|
expect(state.patchedBody?.companyFields).not.toHaveProperty("companyPhone");
|
||||||
expect.arrayContaining([
|
expect(state.patchedBody?.companyFields).not.toHaveProperty("address");
|
||||||
expect.objectContaining({
|
expect(state.patchedBody?.companyFields).not.toHaveProperty("email");
|
||||||
contactName: "New Technician",
|
expect(state.patchedBody?.companyFields).not.toHaveProperty("name");
|
||||||
tradeSpecialties: "Plumbing, HVAC",
|
expect(state.patchedBody?.addTechnicians).toEqual([
|
||||||
}),
|
expect.objectContaining({
|
||||||
]),
|
contactName: "New Technician",
|
||||||
);
|
tradeSpecialties: "Plumbing, HVAC",
|
||||||
const submittedTechnicians = Array.isArray(state.updatedBody?.technicians)
|
}),
|
||||||
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
|
]);
|
||||||
|
const patchedTechnicians = Array.isArray(state.patchedBody?.addTechnicians)
|
||||||
|
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
|
||||||
: [];
|
: [];
|
||||||
const newTechnician = submittedTechnicians.find(
|
const newTechnician = patchedTechnicians.find(
|
||||||
(technician) => technician.contactName === "New Technician",
|
(technician) => technician.contactName === "New Technician",
|
||||||
);
|
);
|
||||||
expect(newTechnician?.preferredContact).toBeUndefined();
|
expect(newTechnician?.preferredContact).toBeUndefined();
|
||||||
|
expect(patchedTechnicians.some((technician) => technician.contactName === "Adam Whyte")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(state.updatedBody).toBeUndefined();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
|
|
@ -517,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 });
|
||||||
|
|
@ -538,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
emptyVendorCompanyRosterForm,
|
emptyVendorCompanyRosterForm,
|
||||||
|
|
@ -8,10 +9,38 @@ import {
|
||||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
||||||
import {
|
import {
|
||||||
|
buildAdditiveRosterPatch,
|
||||||
getSingleStatusOnlyChange,
|
getSingleStatusOnlyChange,
|
||||||
useSaveVendorCompanyRoster,
|
useSaveVendorCompanyRoster,
|
||||||
|
type EditedCompanyFields,
|
||||||
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||||
import { useVendorRosterResolver } from "./use-vendor-roster-resolver";
|
import { useVendorRosterResolver } from "./use-vendor-roster-resolver";
|
||||||
|
|
||||||
|
const EDITABLE_COMPANY_FIELDS = [
|
||||||
|
"name",
|
||||||
|
"companyPhone",
|
||||||
|
"email",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip",
|
||||||
|
"googleMapsUrl",
|
||||||
|
"notes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company fields the user actually typed into, taken from react-hook-form dirty state.
|
||||||
|
* Selecting a company calls reset(), so loaded values are not dirty; only genuine edits are.
|
||||||
|
* The additive PATCH must send these rather than a form-vs-roster diff, which would resend
|
||||||
|
* stale values for fields another user changed while a conflict was being resolved.
|
||||||
|
*/
|
||||||
|
function pickEditedCompanyFields(dirtyFields: Record<string, unknown>): EditedCompanyFields {
|
||||||
|
const edited: EditedCompanyFields = {};
|
||||||
|
for (const field of EDITABLE_COMPANY_FIELDS) {
|
||||||
|
if (dirtyFields[field] === true) edited[field] = true;
|
||||||
|
}
|
||||||
|
return edited;
|
||||||
|
}
|
||||||
import { toFormValues, useVendorRosterSelection } from "./use-vendor-roster-selection";
|
import { toFormValues, useVendorRosterSelection } from "./use-vendor-roster-selection";
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||||
|
|
@ -104,6 +133,10 @@ export function useVendorRosterForm({
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
const { control, handleSubmit, reset, formState } = form;
|
const { control, handleSubmit, reset, formState } = form;
|
||||||
|
// react-hook-form exposes formState through a Proxy that only tracks properties read during
|
||||||
|
// render. Reading dirtyFields inside the submit callback would not subscribe and would come
|
||||||
|
// back empty, so it is resolved here on every render.
|
||||||
|
const editedCompanyFields = pickEditedCompanyFields(formState.dirtyFields);
|
||||||
const watched = useWatch({ control });
|
const watched = useWatch({ control });
|
||||||
|
|
||||||
const clearConflict = useCallback(() => setConflict(null), []);
|
const clearConflict = useCallback(() => setConflict(null), []);
|
||||||
|
|
@ -129,17 +162,39 @@ export function useVendorRosterForm({
|
||||||
clearConflict();
|
clearConflict();
|
||||||
}, [clearConflict, createDefaults, reset, resetSelection]);
|
}, [clearConflict, createDefaults, reset, resetSelection]);
|
||||||
|
|
||||||
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
|
||||||
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
|
||||||
|
|
||||||
const submit = (formValues: VendorCompanyRosterFormValues) => {
|
const submit = (formValues: VendorCompanyRosterFormValues) => {
|
||||||
setConflict(null);
|
setConflict(null);
|
||||||
|
const values = withoutBlankNewTechnicians(formValues);
|
||||||
|
if (mode === "create" && selection.selectedRoster != null) {
|
||||||
|
const selected = selection.selectedRoster;
|
||||||
|
if (!buildAdditiveRosterPatch(selected, values, selected.rowVersion, editedCompanyFields)) {
|
||||||
|
toast.error("Enter at least one technician to add to this vendor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save.mutate(
|
||||||
|
{
|
||||||
|
mode: "add",
|
||||||
|
values,
|
||||||
|
companyId: selected.companyId,
|
||||||
|
rowVersion: selected.rowVersion,
|
||||||
|
originalRoster: selected,
|
||||||
|
editedCompanyFields,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => onSuccess?.(data),
|
||||||
|
onError: (error) => {
|
||||||
|
if (isVendorRosterConflictError(error)) setConflict(error.conflict);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{
|
{
|
||||||
mode: isUpdate ? "update" : "create",
|
mode: mode === "update" ? "update" : "create",
|
||||||
values: withoutBlankNewTechnicians(formValues),
|
values,
|
||||||
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
companyId: mode === "update" ? (routeRoster?.companyId ?? companyId) : undefined,
|
||||||
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
rowVersion: mode === "update" ? routeRoster?.rowVersion : undefined,
|
||||||
originalRoster: mode === "update" ? routeRoster : undefined,
|
originalRoster: mode === "update" ? routeRoster : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
import { useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
emptyRosterTechnician,
|
||||||
emptyVendorCompanyRosterForm,
|
emptyVendorCompanyRosterForm,
|
||||||
type VendorCompanyRosterFormValues,
|
type VendorCompanyRosterFormValues,
|
||||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
|
@ -53,6 +54,20 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toCompanyFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues {
|
||||||
|
return { ...toFormValues(roster), technicians: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCompanyFieldsOnly(
|
||||||
|
roster: VendorCompanyRoster,
|
||||||
|
): (current: VendorCompanyRosterFormValues) => VendorCompanyRosterFormValues {
|
||||||
|
return (current) => ({
|
||||||
|
...toCompanyFormValues(roster),
|
||||||
|
technicians:
|
||||||
|
current.technicians.length > 0 ? current.technicians : [{ ...emptyRosterTechnician }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useVendorRosterSelection({
|
export function useVendorRosterSelection({
|
||||||
mode,
|
mode,
|
||||||
reset,
|
reset,
|
||||||
|
|
@ -72,6 +87,13 @@ export function useVendorRosterSelection({
|
||||||
queryClient.fetchQuery({
|
queryClient.fetchQuery({
|
||||||
queryKey: queryKeys.vendors.roster("companyId", id),
|
queryKey: queryKeys.vendors.roster("companyId", id),
|
||||||
queryFn: () => vendorCompanyRosterApi.get({ companyId: id }),
|
queryFn: () => vendorCompanyRosterApi.get({ companyId: id }),
|
||||||
|
// SH-250: this fetch exists to obtain the freshest rowVersion, so it must not
|
||||||
|
// serve the global 5-minute staleTime cache. A conflict never invalidates this
|
||||||
|
// key (useSaveVendorCompanyRoster invalidates only on success, and its onError
|
||||||
|
// returns early for conflicts), and the Add flow has no mounted observer on it.
|
||||||
|
// Without staleTime: 0 the Reload button re-seeds the same stale rowVersion and
|
||||||
|
// the retry 409s again indefinitely.
|
||||||
|
staleTime: 0,
|
||||||
}),
|
}),
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
@ -91,7 +113,7 @@ export function useVendorRosterSelection({
|
||||||
try {
|
try {
|
||||||
const roster = await fetchRosterByCompany(company.companyId);
|
const roster = await fetchRosterByCompany(company.companyId);
|
||||||
if (requestIdRef.current !== requestId) return;
|
if (requestIdRef.current !== requestId) return;
|
||||||
reset(toFormValues(roster));
|
reset(withCompanyFieldsOnly(roster));
|
||||||
setSelectedRoster(roster);
|
setSelectedRoster(roster);
|
||||||
clearConflict();
|
clearConflict();
|
||||||
retryTargetRef.current = null;
|
retryTargetRef.current = null;
|
||||||
|
|
@ -107,7 +129,11 @@ export function useVendorRosterSelection({
|
||||||
(nextName = "") => {
|
(nextName = "") => {
|
||||||
requestIdRef.current += 1;
|
requestIdRef.current += 1;
|
||||||
if (selectedRoster) {
|
if (selectedRoster) {
|
||||||
reset({ ...emptyVendorCompanyRosterForm, name: nextName });
|
reset((current) => ({
|
||||||
|
...emptyVendorCompanyRosterForm,
|
||||||
|
name: nextName,
|
||||||
|
technicians: current.technicians,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
setSelectedRoster(null);
|
setSelectedRoster(null);
|
||||||
clearConflict();
|
clearConflict();
|
||||||
|
|
@ -134,7 +160,11 @@ export function useVendorRosterSelection({
|
||||||
void fetchRosterByCompany(selectedRoster.companyId)
|
void fetchRosterByCompany(selectedRoster.companyId)
|
||||||
.then((roster) => {
|
.then((roster) => {
|
||||||
if (requestIdRef.current !== requestId) return;
|
if (requestIdRef.current !== requestId) return;
|
||||||
reset(toFormValues(roster));
|
// SH-250: refresh the visible company fields alongside rowVersion.
|
||||||
|
// Refreshing only selectedRoster left the form on pre-conflict values,
|
||||||
|
// so a retry diffed stale fields against the reloaded roster and could
|
||||||
|
// overwrite the concurrent company update that caused the 409.
|
||||||
|
reset(withCompanyFieldsOnly(roster));
|
||||||
setSelectedRoster(roster);
|
setSelectedRoster(roster);
|
||||||
retryTargetRef.current = null;
|
retryTargetRef.current = null;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||||
|
import { VendorStatusBadge } from "./vendor-status-badge";
|
||||||
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
||||||
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||||
import { useVendorRosterForm } from "./use-vendor-roster-form";
|
import { useVendorRosterForm } from "./use-vendor-roster-form";
|
||||||
|
|
@ -81,6 +82,13 @@ function DrawerHeader({
|
||||||
technician?: VendorRosterTechnician;
|
technician?: VendorRosterTechnician;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const title = technician?.contactName || vendor?.contactName || roster?.name || "Vendor company";
|
||||||
|
const company = roster?.name || vendor?.companyName || "Vendor details";
|
||||||
|
// The company is a subtitle under the technician's name. When there is no
|
||||||
|
// technician the title already falls back to the company, and repeating it
|
||||||
|
// would render the same line twice.
|
||||||
|
const subtitle = company === title ? null : company;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
|
|
@ -94,11 +102,13 @@ function DrawerHeader({
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
|
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
|
||||||
{technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
|
{title}
|
||||||
</Text>
|
|
||||||
<Text variant="description" tone="muted">
|
|
||||||
{roster?.name || vendor?.companyName || "Vendor details"}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{subtitle !== null && (
|
||||||
|
<Text variant="description" tone="muted">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
|
|
@ -118,17 +128,24 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
<Field label="Company Phone" value={roster.companyPhone} />
|
<Field label="Company Phone" value={roster.companyPhone} />
|
||||||
<Field label="Company Email" value={roster.email} />
|
<Field label="Company Email" value={roster.email} />
|
||||||
<Field label="Address" value={address} />
|
<Field label="Address" value={address} />
|
||||||
{Boolean(roster.googleMapsUrl) && (
|
<Box>
|
||||||
<Link
|
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||||
href={roster.googleMapsUrl}
|
Google Maps
|
||||||
target="_blank"
|
</Text>
|
||||||
rel="noopener noreferrer"
|
{roster.googleMapsUrl ? (
|
||||||
className="inline-flex items-center gap-1"
|
<Link
|
||||||
>
|
href={roster.googleMapsUrl}
|
||||||
<LaunchIcon fontSize="small" />
|
target="_blank"
|
||||||
Open in Google Maps
|
rel="noopener noreferrer"
|
||||||
</Link>
|
className="inline-flex items-center gap-1"
|
||||||
)}
|
>
|
||||||
|
<LaunchIcon fontSize="small" />
|
||||||
|
Open in Google Maps
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Text variant="description">—</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -154,23 +171,14 @@ function TechnicianSection({ technician }: { technician: VendorRosterTechnician
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={4} sx={{ alignItems: "flex-end" }}>
|
<Stack direction="row" sx={{ alignItems: "flex-end", justifyContent: "space-between" }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text variant="title">{technician.totalJobs ?? 0}</Text>
|
<Text variant="title">{technician.totalJobs ?? 0}</Text>
|
||||||
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||||
Total Jobs
|
Total Jobs
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<VendorStatusBadge isActive={technician.isActive} />
|
||||||
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.5 }}>
|
|
||||||
Status
|
|
||||||
</Text>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={technician.isActive ? "Active" : "Inactive"}
|
|
||||||
color={technician.isActive ? "success" : "default"}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -254,10 +262,13 @@ function DrawerEditor({
|
||||||
const selectedIndex = technicians.findIndex(
|
const selectedIndex = technicians.findIndex(
|
||||||
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
||||||
);
|
);
|
||||||
const selectedTotalJobs =
|
const persistedTechnician = roster?.technicians.find(
|
||||||
roster?.technicians.find(
|
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
||||||
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
);
|
||||||
)?.totalJobs ?? vendor.totalJobs;
|
const selectedTotalJobs = persistedTechnician?.totalJobs ?? vendor.totalJobs;
|
||||||
|
// Deactivation is a property of the saved vendor, not of the toggle: flipping an
|
||||||
|
// inactive vendor on and back off without saving must not ask to deactivate it.
|
||||||
|
const persistedIsActive = persistedTechnician?.isActive ?? vendor.isActive;
|
||||||
|
|
||||||
if (form.isLoading) {
|
if (form.isLoading) {
|
||||||
return <CircularProgress size={28} sx={{ m: "auto" }} />;
|
return <CircularProgress size={28} sx={{ m: "auto" }} />;
|
||||||
|
|
@ -310,7 +321,7 @@ function DrawerEditor({
|
||||||
checked={Boolean(field.value)}
|
checked={Boolean(field.value)}
|
||||||
slotProps={{ input: { "aria-label": "Active status" } }}
|
slotProps={{ input: { "aria-label": "Active status" } }}
|
||||||
onChange={(_event, checked) => {
|
onChange={(_event, checked) => {
|
||||||
if (field.value && !checked) onRequestDeactivation(vendor);
|
if (persistedIsActive && !checked) onRequestDeactivation(vendor);
|
||||||
else field.onChange(checked);
|
else field.onChange(checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
20
src/app/(protected)/vendors/_components/vendor-status-badge.tsx
vendored
Normal file
20
src/app/(protected)/vendors/_components/vendor-status-badge.tsx
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Box, Stack } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
/** Dot plus text, so the status never reads by colour alone. */
|
||||||
|
export function VendorStatusBadge({ isActive }: { isActive: boolean }) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "9999px",
|
||||||
|
backgroundColor: isActive ? "success.main" : "text.secondary",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { VendorStatusBadge } from "./vendor-status-badge";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
|
@ -50,23 +51,6 @@ function stopPropagation(event: MouseEvent): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function VendorStatus({ isActive }: { isActive: boolean }) {
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", pl: 1 }}>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "9999px",
|
|
||||||
backgroundColor: isActive ? "success.main" : "text.secondary",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VendorTableRowProps {
|
interface VendorTableRowProps {
|
||||||
row: VendorListItem;
|
row: VendorListItem;
|
||||||
onOpenDetail: (row: VendorListItem) => void;
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
|
|
@ -177,7 +161,9 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<VendorStatus isActive={row.isActive} />
|
<Box sx={{ pl: 1 }}>
|
||||||
|
<VendorStatusBadge isActive={row.isActive} />
|
||||||
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Stack
|
<Stack
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { isHTTPError } from "ky";
|
import { isHTTPError } from "ky";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { apiGet, apiPost, apiPut } from "@/api/api";
|
import { apiGet, apiPatch, apiPost, apiPut } from "@/api/api";
|
||||||
import { handleApiResponse } from "@/api/handle-api-response";
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
mapRosterConflict,
|
mapRosterConflict,
|
||||||
mapVendorCompanyRoster,
|
mapVendorCompanyRoster,
|
||||||
|
mapVendorRosterAdditivePatchToBackend,
|
||||||
mapVendorRosterToBackend,
|
mapVendorRosterToBackend,
|
||||||
} from "@/domain/vendors/mappers/vendor-roster-mapper";
|
} from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||||
import { VendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
import { VendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||||
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorRosterAdditivePatch } from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||||
|
|
||||||
const STALE_MESSAGE =
|
const STALE_MESSAGE =
|
||||||
"This company was changed by another session. Reload the latest version and try again.";
|
"This company was changed by another session. Reload the latest version and try again.";
|
||||||
|
|
@ -97,4 +99,22 @@ export const vendorCompanyRosterApi = {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addTechnicians: async (
|
||||||
|
companyId: string | number,
|
||||||
|
patch: VendorRosterAdditivePatch,
|
||||||
|
): Promise<VendorCompanyRoster> => {
|
||||||
|
try {
|
||||||
|
const data = await apiPatch<unknown>(
|
||||||
|
API_PATHS.vendorCompanyRoster.byCompany(companyId),
|
||||||
|
mapVendorRosterAdditivePatchToBackend(patch),
|
||||||
|
);
|
||||||
|
return mapVendorCompanyRoster(handleApiResponse(data));
|
||||||
|
} catch (error) {
|
||||||
|
if (isHTTPError(error) && error.response.status === 409) {
|
||||||
|
await throwRosterConflict(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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> => {
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,36 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorRosterAdditivePatch {
|
||||||
|
rowVersion: string;
|
||||||
|
addTechnicians: unknown[];
|
||||||
|
companyFields?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorRosterAdditivePatchToBackend(
|
||||||
|
patch: VendorRosterAdditivePatch,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const item = asRecord(patch);
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
rowVersion: readString(item, "rowVersion", "RowVersion"),
|
||||||
|
addTechnicians: patch.addTechnicians.map((technician) => {
|
||||||
|
const mapped = mapRosterTechnicianToBackend(technician);
|
||||||
|
delete mapped.id;
|
||||||
|
return mapped;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
if (patch.companyFields) {
|
||||||
|
// SH-250: match the reconcile write path, which canonicalizes companyPhone.
|
||||||
|
// Copying companyFields verbatim let additive patches send a different phone
|
||||||
|
// shape than PUT for the same user input.
|
||||||
|
const companyFields = { ...patch.companyFields };
|
||||||
|
if (companyFields.companyPhone !== undefined)
|
||||||
|
companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone);
|
||||||
|
payload.companyFields = companyFields;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function mapBlockedWorkOrder(raw: unknown): VendorRosterBlockedWorkOrder {
|
function mapBlockedWorkOrder(raw: unknown): VendorRosterBlockedWorkOrder {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,14 @@ export const vendorCompanyRosterUpdateSchema = vendorCompanyRosterSchema.and(
|
||||||
|
|
||||||
export type VendorCompanyRosterUpdateValues = z.infer<typeof vendorCompanyRosterUpdateSchema>;
|
export type VendorCompanyRosterUpdateValues = z.infer<typeof vendorCompanyRosterUpdateSchema>;
|
||||||
|
|
||||||
|
// SH-250: the preferred-contact control was removed from the form, so seeding a
|
||||||
|
// value here fabricated one on submit. `append` already omits it; the schema
|
||||||
|
// keeps it optional, so both paths now agree and the backend receives it only
|
||||||
|
// when a real value exists.
|
||||||
export const emptyRosterTechnician: RosterTechnicianValues = {
|
export const emptyRosterTechnician: RosterTechnicianValues = {
|
||||||
contactName: "",
|
contactName: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
email: "",
|
email: "",
|
||||||
preferredContact: "Phone",
|
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,17 @@ import type {
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export interface SaveVendorCompanyRosterInput {
|
export interface SaveVendorCompanyRosterInput {
|
||||||
mode: "create" | "update";
|
mode: "create" | "update" | "add";
|
||||||
values: VendorCompanyRosterFormValues;
|
values: VendorCompanyRosterFormValues;
|
||||||
companyId?: string | number | null;
|
companyId?: string | number | null;
|
||||||
rowVersion?: string;
|
rowVersion?: string;
|
||||||
originalRoster?: VendorCompanyRoster;
|
originalRoster?: VendorCompanyRoster;
|
||||||
|
/** Company fields the user actually edited. See buildAdditiveRosterPatch. */
|
||||||
|
editedCompanyFields?: EditedCompanyFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EditedCompanyFields = Partial<Record<(typeof COMPANY_FIELDS)[number], boolean>>;
|
||||||
|
|
||||||
export interface SaveVendorCompanyRosterContext {
|
export interface SaveVendorCompanyRosterContext {
|
||||||
conflict?: unknown;
|
conflict?: unknown;
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +69,66 @@ export function getSingleStatusOnlyChange(
|
||||||
return changed.length === 1 ? changed[0] : null;
|
return changed.length === 1 ? changed[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorRosterAdditivePatchInput {
|
||||||
|
rowVersion: string;
|
||||||
|
addTechnicians: Array<{
|
||||||
|
contactName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
preferredContact?: string;
|
||||||
|
tradeSpecialties: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}>;
|
||||||
|
companyFields?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTechnicianContent(technician: {
|
||||||
|
contactName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
tradeSpecialties: string;
|
||||||
|
}): boolean {
|
||||||
|
return Boolean(
|
||||||
|
technician.contactName.trim() ||
|
||||||
|
technician.phone.trim() ||
|
||||||
|
technician.email.trim() ||
|
||||||
|
technician.tradeSpecialties.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the additive PATCH body for the Add flow.
|
||||||
|
*
|
||||||
|
* `editedCompanyFields` must carry the fields the user actually touched. A plain
|
||||||
|
* form-vs-roster diff is unsafe here: after a stale-409 the roster is refetched while the form
|
||||||
|
* keeps the values loaded before the conflict, so a field another user changed in that window
|
||||||
|
* would look "different" and be sent back with our stale value, silently reverting their edit.
|
||||||
|
* Only fields the user edited are ever transmitted.
|
||||||
|
*/
|
||||||
|
export function buildAdditiveRosterPatch(
|
||||||
|
roster: VendorCompanyRoster | null | undefined,
|
||||||
|
values: VendorCompanyRosterFormValues,
|
||||||
|
rowVersion: string,
|
||||||
|
editedCompanyFields: EditedCompanyFields = {},
|
||||||
|
): VendorRosterAdditivePatchInput | null {
|
||||||
|
const addTechnicians = values.technicians.filter(
|
||||||
|
(technician) => technician.id == null && hasTechnicianContent(technician),
|
||||||
|
);
|
||||||
|
const companyFields: Record<string, string> = {};
|
||||||
|
if (roster) {
|
||||||
|
for (const field of COMPANY_FIELDS) {
|
||||||
|
if (editedCompanyFields[field] !== true) continue;
|
||||||
|
if (values[field] !== roster[field]) companyFields[field] = values[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (addTechnicians.length === 0 && Object.keys(companyFields).length === 0) return null;
|
||||||
|
return {
|
||||||
|
rowVersion,
|
||||||
|
addTechnicians,
|
||||||
|
...(Object.keys(companyFields).length > 0 ? { companyFields } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useSaveVendorCompanyRoster(): UseMutationResult<
|
export function useSaveVendorCompanyRoster(): UseMutationResult<
|
||||||
VendorCompanyRoster,
|
VendorCompanyRoster,
|
||||||
unknown,
|
unknown,
|
||||||
|
|
@ -85,6 +149,7 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
|
||||||
companyId,
|
companyId,
|
||||||
rowVersion,
|
rowVersion,
|
||||||
originalRoster,
|
originalRoster,
|
||||||
|
editedCompanyFields,
|
||||||
}: SaveVendorCompanyRosterInput) => {
|
}: SaveVendorCompanyRosterInput) => {
|
||||||
if (mode === "update") {
|
if (mode === "update") {
|
||||||
const statusChange = originalRoster
|
const statusChange = originalRoster
|
||||||
|
|
@ -108,17 +173,31 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
|
||||||
if (!rowVersion) throw new Error("Row version is required to update");
|
if (!rowVersion) throw new Error("Row version is required to update");
|
||||||
return vendorCompanyRosterApi.update(companyId, values, rowVersion);
|
return vendorCompanyRosterApi.update(companyId, values, rowVersion);
|
||||||
}
|
}
|
||||||
|
if (mode === "add") {
|
||||||
|
if (companyId === undefined || companyId === null || companyId === "") {
|
||||||
|
throw new Error("Company id is required to add technicians");
|
||||||
|
}
|
||||||
|
if (!rowVersion) throw new Error("Row version is required to add technicians");
|
||||||
|
const patch = buildAdditiveRosterPatch(
|
||||||
|
originalRoster,
|
||||||
|
values,
|
||||||
|
rowVersion,
|
||||||
|
editedCompanyFields,
|
||||||
|
);
|
||||||
|
if (!patch) throw new Error("Nothing to add to this vendor company");
|
||||||
|
return vendorCompanyRosterApi.addTechnicians(companyId, patch);
|
||||||
|
}
|
||||||
return vendorCompanyRosterApi.create(values);
|
return vendorCompanyRosterApi.create(values);
|
||||||
},
|
},
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||||
if (variables.mode === "update" && variables.companyId) {
|
if ((variables.mode === "update" || variables.mode === "add") && variables.companyId) {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.vendors.roster("companyId", variables.companyId),
|
queryKey: queryKeys.vendors.roster("companyId", variables.companyId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toast.success(
|
toast.success(
|
||||||
variables.mode === "update" ? "Vendor company updated" : "Vendor company created",
|
variables.mode === "create" ? "Vendor company created" : "Vendor company updated",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -12,15 +12,19 @@ vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
||||||
useVendorCompanyRoster: () => ({
|
useVendorCompanyRoster: () => rosterQueryResult,
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
let rosterQueryResult: {
|
||||||
|
data: VendorCompanyRoster | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: ReturnType<typeof vi.fn>;
|
||||||
|
} = { data: undefined, isLoading: false, isError: false, error: null, refetch: vi.fn() };
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
|
||||||
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
|
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
|
||||||
}));
|
}));
|
||||||
|
|
@ -36,6 +40,7 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () =>
|
||||||
});
|
});
|
||||||
|
|
||||||
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
||||||
|
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const company: VendorFacetCompany = {
|
const company: VendorFacetCompany = {
|
||||||
|
|
@ -81,10 +86,87 @@ const secondRoster: VendorCompanyRoster = {
|
||||||
email: "dispatch@metro.test",
|
email: "dispatch@metro.test",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rosterWithTechnicians: VendorCompanyRoster = {
|
||||||
|
...roster,
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
contactName: "Ray Holt",
|
||||||
|
phone: "(314) 555-0122",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
contactName: "Amy Santiago",
|
||||||
|
phone: "(314) 555-0133",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Text",
|
||||||
|
tradeSpecialties: "HVAC",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const newTechnician = {
|
||||||
|
contactName: "Dana Kim",
|
||||||
|
phone: "(314) 555-0144",
|
||||||
|
email: "dana@test.test",
|
||||||
|
preferredContact: "Text" as const,
|
||||||
|
tradeSpecialties: "HVAC",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function submitValues(
|
||||||
|
technicians: VendorCompanyRosterFormValues["technicians"],
|
||||||
|
): VendorCompanyRosterFormValues {
|
||||||
|
return {
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRosterQuery(): void {
|
||||||
|
rosterQueryResult = {
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetRosterQuery();
|
||||||
|
});
|
||||||
|
|
||||||
function createClient(): QueryClient {
|
function createClient(): QueryClient {
|
||||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SH-250: mirrors the app's real staleTime (src/lib/query/query-client.ts). The default
|
||||||
|
* test client uses staleTime 0, which silently masks cache-staleness defects on the
|
||||||
|
* reload path — the conflict recovery must not depend on an empty cache.
|
||||||
|
*/
|
||||||
|
function createCachingClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, staleTime: 5 * 60 * 1000 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function makeWrapper(client: QueryClient) {
|
function makeWrapper(client: QueryClient) {
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
|
@ -316,3 +398,288 @@ describe("useVendorRosterForm stale-selection handling", () => {
|
||||||
expect(result.current.name.field.value).toBe("Draft Vendor");
|
expect(result.current.name.field.value).toBe("Draft Vendor");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useVendorRosterForm additive add flow (SH-250)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rosterGet.mockReset();
|
||||||
|
saveMutate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues an additive add — never update — when an existing company is selected", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
|
||||||
|
const technician = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: "technicians.0.contactName",
|
||||||
|
});
|
||||||
|
return { form, technician };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
result.current.technician.field.onChange("Dana Kim");
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.form.submit(submitValues([newTechnician]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||||
|
const input = saveMutate.mock.calls[0]?.[0] as { mode: string };
|
||||||
|
expect(input.mode).toBe("add");
|
||||||
|
expect(saveMutate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: 5,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
originalRoster: roster,
|
||||||
|
values: expect.objectContaining({ technicians: [newTechnician] }),
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still creates via POST when no existing company is matched", () => {
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.submit(submitValues([newTechnician]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveMutate.mock.calls[0]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({ mode: "create", companyId: undefined, rowVersion: undefined }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks submit when the selected company has nothing to add", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||||
|
wrapper: makeWrapper(createClient()),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.selectCompany(company);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.submit(submitValues([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveMutate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the entered technician and retries with the fresh rowVersion after reload", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
|
||||||
|
const technician = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: "technicians.0.contactName",
|
||||||
|
});
|
||||||
|
return { form, technician };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
result.current.technician.field.onChange("Dana Kim");
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.form.submit(submitValues([newTechnician]));
|
||||||
|
});
|
||||||
|
expect(saveMutate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ rowVersion: "rv-1" }));
|
||||||
|
|
||||||
|
rosterGet.mockResolvedValueOnce({ ...roster, rowVersion: "rv-2" });
|
||||||
|
await act(async () => {
|
||||||
|
result.current.form.reload();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.form.selectedCompanyId).toBe(5));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.form.submit(submitValues([newTechnician]));
|
||||||
|
});
|
||||||
|
expect(saveMutate.mock.calls[1]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({ mode: "add", rowVersion: "rv-2" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the visible company fields on reload, not just rowVersion", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
|
||||||
|
const name = useController({ control: form.control, name: "name" });
|
||||||
|
return { form, name };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
expect(result.current.name.field.value).toBe("Gateway Plumbing");
|
||||||
|
|
||||||
|
// Someone else renamed the company, which is what caused the 409.
|
||||||
|
rosterGet.mockResolvedValueOnce({
|
||||||
|
...roster,
|
||||||
|
rowVersion: "rv-2",
|
||||||
|
name: "Gateway Plumbing & Drain",
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
result.current.form.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload must not leave the form on the pre-conflict name, or the retry
|
||||||
|
// would diff stale fields and overwrite the concurrent update.
|
||||||
|
await waitFor(() => expect(result.current.name.field.value).toBe("Gateway Plumbing & Drain"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reload bypasses the cache so the retry carries the fresh rowVersion", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
|
||||||
|
{ wrapper: makeWrapper(createCachingClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.selectCompany(company);
|
||||||
|
});
|
||||||
|
expect(rosterGet).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.submit(submitValues([newTechnician]));
|
||||||
|
});
|
||||||
|
expect(saveMutate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ rowVersion: "rv-1" }));
|
||||||
|
|
||||||
|
// The 409 never invalidates this key, and in the Add flow nothing else observes it.
|
||||||
|
// Without staleTime: 0 on the reload fetch, the cached rv-1 is served straight back
|
||||||
|
// and every retry 409s again.
|
||||||
|
rosterGet.mockResolvedValueOnce({ ...roster, rowVersion: "rv-2" });
|
||||||
|
await act(async () => {
|
||||||
|
result.current.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(rosterGet).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.submit(submitValues([newTechnician]));
|
||||||
|
});
|
||||||
|
expect(saveMutate.mock.calls[1]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({ mode: "add", rowVersion: "rv-2" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not regress the Edit Vendor reconcile path (mode update)", () => {
|
||||||
|
rosterQueryResult = {
|
||||||
|
data: rosterWithTechnicians,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), {
|
||||||
|
wrapper: makeWrapper(createClient()),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.submit(submitValues([...rosterWithTechnicians.technicians, newTechnician]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveMutate.mock.calls[0]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
mode: "update",
|
||||||
|
companyId: 5,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
originalRoster: rosterWithTechnicians,
|
||||||
|
values: expect.objectContaining({
|
||||||
|
technicians: [...rosterWithTechnicians.technicians, newTechnician],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useVendorRosterForm technician autopopulation (SH-246)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rosterGet.mockReset();
|
||||||
|
saveMutate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not autopopulate technician fields when a company is selected", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(rosterWithTechnicians);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create" });
|
||||||
|
const technicians = useController({ control: form.control, name: "technicians" });
|
||||||
|
const name = useController({ control: form.control, name: "name" });
|
||||||
|
return { form, technicians, name };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
|
||||||
|
const technicians = result.current.technicians.field.value;
|
||||||
|
expect(technicians).toEqual([
|
||||||
|
{
|
||||||
|
contactName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// SH-250: the blank row must not carry a fabricated preferredContact, since
|
||||||
|
// the control was removed from the form and `append` omits it too.
|
||||||
|
expect(technicians[0]).not.toHaveProperty("preferredContact");
|
||||||
|
expect(technicians).not.toContainEqual(expect.objectContaining({ contactName: "Ray Holt" }));
|
||||||
|
expect(technicians).not.toContainEqual(
|
||||||
|
expect.objectContaining({ contactName: "Amy Santiago" }),
|
||||||
|
);
|
||||||
|
expect(result.current.name.field.value).toBe("Gateway Plumbing");
|
||||||
|
expect(result.current.form.selectedCompanyId).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a typed technician when the company selection is cleared to free text", async () => {
|
||||||
|
rosterGet.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
|
||||||
|
const technician = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: "technicians.0.contactName",
|
||||||
|
});
|
||||||
|
return { form, technician };
|
||||||
|
},
|
||||||
|
{ wrapper: makeWrapper(createClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.form.selectCompany(company);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
result.current.technician.field.onChange("Dana Kim");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.form.clearSelectedCompany("Draft Vendor");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.selectedCompanyId).toBeNull();
|
||||||
|
expect(result.current.technician.field.value).toBe("Dana Kim");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer";
|
import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
@ -134,3 +135,139 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
||||||
expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("VendorDetailDrawer design parity", () => {
|
||||||
|
it("renders the company once when there is no technician name to head the panel", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(rosterWith([]));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDetailDrawer open={true} vendor={{ ...vendor, contactName: "" }} onClose={vi.fn()} />,
|
||||||
|
{ route: "/vendors", withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" });
|
||||||
|
expect(heading).toBeInTheDocument();
|
||||||
|
// The header block holds the title alone — no subtitle repeating the company.
|
||||||
|
expect(heading.parentElement?.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the company subtitle under a technician name", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Gateway Plumbing").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the Google Maps field below Address even when no URL is stored", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Google Maps")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("link", { name: /Open in Google Maps/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links out to Google Maps when a URL is stored", () => {
|
||||||
|
const roster = rosterWith([
|
||||||
|
{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 },
|
||||||
|
]);
|
||||||
|
roster.data.googleMapsUrl = "https://maps.google.com/?q=1+Industrial+Pkwy";
|
||||||
|
useVendorCompanyRoster.mockReturnValue(roster);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: /Open in Google Maps/ })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://maps.google.com/?q=1+Industrial+Pkwy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the status opposite Total Jobs and drops the redundant Status label", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = screen.getByText("Total Jobs").closest("div")?.parentElement;
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
expect(getComputedStyle(row as Element).justifyContent).toBe("space-between");
|
||||||
|
expect(row).toHaveTextContent("Active");
|
||||||
|
expect(screen.queryByText("Status")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VendorDetailDrawer deactivation prompt", () => {
|
||||||
|
const inactiveVendor: VendorListItem = { ...vendor, isActive: false };
|
||||||
|
|
||||||
|
it("does not prompt to deactivate a saved-inactive vendor toggled on and back off", async () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([
|
||||||
|
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: false, totalJobs: 5 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const onRequestDeactivation = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDetailDrawer
|
||||||
|
open={true}
|
||||||
|
vendor={inactiveVendor}
|
||||||
|
startInEdit={true}
|
||||||
|
onRequestDeactivation={onRequestDeactivation}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ route: "/vendors", withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("switch", { name: "Active status" });
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
expect(onRequestDeactivation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts to deactivate a saved-active vendor when the toggle is switched off", async () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([
|
||||||
|
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const onRequestDeactivation = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDetailDrawer
|
||||||
|
open={true}
|
||||||
|
vendor={vendor}
|
||||||
|
startInEdit={true}
|
||||||
|
onRequestDeactivation={onRequestDeactivation}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ route: "/vendors", withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
|
|
||||||
|
expect(onRequestDeactivation).toHaveBeenCalledWith(vendor);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import { API_PATHS } from "@/api/api-paths";
|
||||||
const apiGet = vi.fn();
|
const apiGet = vi.fn();
|
||||||
const apiPost = vi.fn();
|
const apiPost = vi.fn();
|
||||||
const apiPut = vi.fn();
|
const apiPut = vi.fn();
|
||||||
|
const apiPatch = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({
|
vi.mock("@/api/api", () => ({
|
||||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||||
|
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("ky", () => ({
|
vi.mock("ky", () => ({
|
||||||
|
|
@ -38,6 +40,7 @@ describe("vendorCompanyRosterApi", () => {
|
||||||
apiGet.mockReset();
|
apiGet.mockReset();
|
||||||
apiPost.mockReset();
|
apiPost.mockReset();
|
||||||
apiPut.mockReset();
|
apiPut.mockReset();
|
||||||
|
apiPatch.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches by vendorId or companyId with the correct query params", async () => {
|
it("fetches by vendorId or companyId with the correct query params", async () => {
|
||||||
|
|
@ -121,4 +124,72 @@ describe("vendorCompanyRosterApi", () => {
|
||||||
|
|
||||||
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toBe(generic);
|
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toBe(generic);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches the additive payload without a technician id and without PUT", async () => {
|
||||||
|
apiPatch.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } });
|
||||||
|
|
||||||
|
await vendorCompanyRosterApi.addTechnicians(5, {
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
addTechnicians: [
|
||||||
|
{
|
||||||
|
id: 99,
|
||||||
|
contactName: "Adam",
|
||||||
|
phone: "3145550198",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiPatch).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.vendorCompanyRoster.byCompany(5),
|
||||||
|
expect.objectContaining({
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
addTechnicians: [expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = apiPatch.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||||
|
expect(body).not.toHaveProperty("companyFields");
|
||||||
|
expect((body.addTechnicians as Array<Record<string, unknown>>)[0]).not.toHaveProperty("id");
|
||||||
|
expect(apiPut).not.toHaveBeenCalled();
|
||||||
|
expect(apiPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes companyFields on the additive patch when provided", async () => {
|
||||||
|
apiPatch.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } });
|
||||||
|
|
||||||
|
await vendorCompanyRosterApi.addTechnicians(5, {
|
||||||
|
rowVersion: "rv-2",
|
||||||
|
addTechnicians: [
|
||||||
|
{ contactName: "Adam", phone: "", email: "", tradeSpecialties: "", isActive: true },
|
||||||
|
],
|
||||||
|
companyFields: { notes: "Updated notes" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiPatch).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.vendorCompanyRoster.byCompany(5),
|
||||||
|
expect.objectContaining({
|
||||||
|
rowVersion: "rv-2",
|
||||||
|
companyFields: { notes: "Updated notes" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a stale conflict on a 409 from the additive patch", async () => {
|
||||||
|
apiPatch.mockRejectedValueOnce(httpError(409, { message: "rowversion mismatch" }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
vendorCompanyRosterApi.addTechnicians(5, {
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
addTechnicians: [
|
||||||
|
{ contactName: "Adam", phone: "", email: "", tradeSpecialties: "", isActive: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toSatisfy((error: unknown) => {
|
||||||
|
if (!isVendorRosterConflictError(error)) return false;
|
||||||
|
return error.conflict.kind === "stale";
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
263
src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx
vendored
Normal file
263
src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const rosterAdd = vi.fn();
|
||||||
|
const rosterUpdate = vi.fn();
|
||||||
|
const rosterCreate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||||
|
vendorCompanyRosterApi: {
|
||||||
|
addTechnicians: (...args: unknown[]) => rosterAdd(...args),
|
||||||
|
update: (...args: unknown[]) => rosterUpdate(...args),
|
||||||
|
create: (...args: unknown[]) => rosterCreate(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/api/vendors-api", () => ({
|
||||||
|
vendorsApi: { update: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAdditiveRosterPatch,
|
||||||
|
useSaveVendorCompanyRoster,
|
||||||
|
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||||
|
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
|
||||||
|
const roster: VendorCompanyRoster = {
|
||||||
|
companyId: 5,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "Preferred vendor",
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
contactName: "Ray Holt",
|
||||||
|
phone: "(314) 555-0122",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const newTechnician = {
|
||||||
|
contactName: "Dana Kim",
|
||||||
|
phone: "(314) 555-0144",
|
||||||
|
email: "dana@test.test",
|
||||||
|
preferredContact: "Text" as const,
|
||||||
|
tradeSpecialties: "HVAC",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formValues(
|
||||||
|
overrides: Partial<VendorCompanyRosterFormValues> = {},
|
||||||
|
): VendorCompanyRosterFormValues {
|
||||||
|
return {
|
||||||
|
name: roster.name,
|
||||||
|
companyPhone: roster.companyPhone,
|
||||||
|
email: roster.email,
|
||||||
|
address: roster.address,
|
||||||
|
city: roster.city,
|
||||||
|
state: roster.state,
|
||||||
|
zip: roster.zip,
|
||||||
|
googleMapsUrl: roster.googleMapsUrl,
|
||||||
|
notes: roster.notes,
|
||||||
|
technicians: [newTechnician],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildAdditiveRosterPatch", () => {
|
||||||
|
it("carries only the newly entered technician, never a full roster snapshot", () => {
|
||||||
|
const patch = buildAdditiveRosterPatch(roster, formValues(), "rv-1");
|
||||||
|
|
||||||
|
expect(patch).not.toBeNull();
|
||||||
|
expect(patch?.addTechnicians).toEqual([newTechnician]);
|
||||||
|
expect(patch?.addTechnicians[0]).not.toHaveProperty("id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores blank technician rows", () => {
|
||||||
|
const patch = buildAdditiveRosterPatch(
|
||||||
|
roster,
|
||||||
|
formValues({
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
contactName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
newTechnician,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
"rv-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch?.addTechnicians).toEqual([newTechnician]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends only company fields the user edited", () => {
|
||||||
|
const patch = buildAdditiveRosterPatch(
|
||||||
|
roster,
|
||||||
|
formValues({ notes: "New notes", address: "2 Oak Ave" }),
|
||||||
|
"rv-1",
|
||||||
|
{ notes: true, address: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch?.companyFields).toEqual({ notes: "New notes", address: "2 Oak Ave" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends a company field the user did not edit, even when it differs from the roster", () => {
|
||||||
|
// Regression: after a stale-409 the roster is refetched while the form keeps pre-conflict
|
||||||
|
// values. A form-vs-roster diff would resend our stale value for a field another user changed
|
||||||
|
// in that window, silently reverting their edit. Only edited fields may be transmitted.
|
||||||
|
const rosterAfterOtherUserEditedPhone = { ...roster, companyPhone: "314-555-9999" };
|
||||||
|
|
||||||
|
const patch = buildAdditiveRosterPatch(
|
||||||
|
rosterAfterOtherUserEditedPhone,
|
||||||
|
formValues({ companyPhone: roster.companyPhone, notes: "New notes" }),
|
||||||
|
"rv-2",
|
||||||
|
{ notes: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch?.companyFields).toEqual({ notes: "New notes" });
|
||||||
|
expect(patch?.companyFields).not.toHaveProperty("companyPhone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits companyFields when values differ but nothing was edited", () => {
|
||||||
|
const patch = buildAdditiveRosterPatch(
|
||||||
|
roster,
|
||||||
|
formValues({ notes: "Drifted under us" }),
|
||||||
|
"rv-1",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch).not.toHaveProperty("companyFields");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits companyFields entirely when nothing changed", () => {
|
||||||
|
const patch = buildAdditiveRosterPatch(roster, formValues(), "rv-1");
|
||||||
|
|
||||||
|
expect(patch).not.toHaveProperty("companyFields");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there is nothing to add or change", () => {
|
||||||
|
const patch = buildAdditiveRosterPatch(
|
||||||
|
roster,
|
||||||
|
formValues({
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
contactName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
"rv-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSaveVendorCompanyRoster routing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rosterAdd.mockReset();
|
||||||
|
rosterUpdate.mockReset();
|
||||||
|
rosterCreate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add mode issues the additive patch and never the reconcile PUT", async () => {
|
||||||
|
rosterAdd.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(() => useSaveVendorCompanyRoster(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate({
|
||||||
|
mode: "add",
|
||||||
|
values: formValues(),
|
||||||
|
companyId: 5,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
originalRoster: roster,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(rosterAdd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rosterAdd).toHaveBeenCalledWith(
|
||||||
|
5,
|
||||||
|
expect.objectContaining({
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
addTechnicians: [newTechnician],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(rosterUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(rosterCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update mode still issues the full reconcile PUT", async () => {
|
||||||
|
rosterUpdate.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(() => useSaveVendorCompanyRoster(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate({
|
||||||
|
mode: "update",
|
||||||
|
values: formValues({
|
||||||
|
technicians: [{ id: 11, ...newTechnician }, newTechnician],
|
||||||
|
}),
|
||||||
|
companyId: 5,
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
originalRoster: roster,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(rosterUpdate).toHaveBeenCalledWith(5, expect.anything(), "rv-1");
|
||||||
|
expect(rosterAdd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create mode still issues the POST", async () => {
|
||||||
|
rosterCreate.mockResolvedValueOnce(roster);
|
||||||
|
const { result } = renderHook(() => useSaveVendorCompanyRoster(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate({ mode: "create", values: formValues() });
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(rosterCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rosterAdd).not.toHaveBeenCalled();
|
||||||
|
expect(rosterUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue