mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge branch 'dev' into fix/sh-183-closability-company-not-tech
This commit is contained in:
commit
23a80a7a14
33 changed files with 2096 additions and 336 deletions
93
e2e/vendors/vendors.spec.ts
vendored
93
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -65,6 +65,8 @@ interface MockState {
|
|||
listUrls: string[];
|
||||
createdBody?: Record<string, unknown>;
|
||||
updatedBody?: Record<string, unknown>;
|
||||
patchedBody?: Record<string, unknown>;
|
||||
patchedCompanyId?: string;
|
||||
deletedId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +177,52 @@ async function mockVendorApi(
|
|||
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) {
|
||||
state.updatedBody = request.postDataJSON();
|
||||
const technicians = Array.isArray(state.updatedBody.technicians)
|
||||
|
|
@ -409,6 +457,11 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
"https://maps.google.com/gateway",
|
||||
);
|
||||
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.getByLabel("Technician name (optional)").last().fill("New Technician");
|
||||
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||||
|
|
@ -420,27 +473,33 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||
expect(state.updatedBody).toMatchObject({
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
notes: "Created in browser E2E",
|
||||
rowVersion: "rv-1",
|
||||
});
|
||||
expect(state.updatedBody?.technicians).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
contactName: "New Technician",
|
||||
tradeSpecialties: "Plumbing, HVAC",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const submittedTechnicians = Array.isArray(state.updatedBody?.technicians)
|
||||
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
|
||||
expect(state.patchedCompanyId).toBe("101");
|
||||
expect(state.patchedBody).toMatchObject({ rowVersion: "rv-1" });
|
||||
// Only the field the user actually typed into is transmitted. Address/phone/email were
|
||||
// populated by selecting the company and never edited, so they must not be sent — otherwise a
|
||||
// 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.patchedBody?.companyFields).not.toHaveProperty("companyPhone");
|
||||
expect(state.patchedBody?.companyFields).not.toHaveProperty("address");
|
||||
expect(state.patchedBody?.companyFields).not.toHaveProperty("email");
|
||||
expect(state.patchedBody?.companyFields).not.toHaveProperty("name");
|
||||
expect(state.patchedBody?.addTechnicians).toEqual([
|
||||
expect.objectContaining({
|
||||
contactName: "New Technician",
|
||||
tradeSpecialties: "Plumbing, HVAC",
|
||||
}),
|
||||
]);
|
||||
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",
|
||||
);
|
||||
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();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
|
|
|
|||
124
e2e/work-orders/week-day-navigation.spec.ts
Normal file
124
e2e/work-orders/week-day-navigation.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-week-day-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 | null) {
|
||||
return {
|
||||
id,
|
||||
woNumber,
|
||||
workOrderType: 2,
|
||||
siteCode: woNumber,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate,
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
};
|
||||
}
|
||||
|
||||
async function mockWorkOrderBoard(page: Page) {
|
||||
await page.route("**/api/**", (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (pathname.includes("/workorders/board")) {
|
||||
return route.fallback();
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/workorders/board*", (route) => {
|
||||
if (route.request().url().includes("board/search")) {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(route.request().url());
|
||||
const weekStart = url.searchParams.get("weekStart") ?? "2026-08-17";
|
||||
const scheduled =
|
||||
weekStart === "2026-08-17"
|
||||
? [boardRow(1, "MON-WO", "2026-08-17"), boardRow(2, "WED-WO", "2026-08-19")]
|
||||
: [boardRow(4, "PREV-MON-WO", "2026-08-10")];
|
||||
return fulfillJson(route, {
|
||||
weekStart,
|
||||
weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14",
|
||||
counts: { returned: scheduled.length + 1, total: scheduled.length + 1 },
|
||||
unscheduled: [boardRow(3, "UNSCHED-WO", null)],
|
||||
scheduled,
|
||||
});
|
||||
});
|
||||
await page.route("**/api/workorders/board/search*", (route) =>
|
||||
fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Work Orders week/day navigation (SH-240)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript(
|
||||
(user) => localStorage.setItem("auth", JSON.stringify(user)),
|
||||
TEST_USER,
|
||||
);
|
||||
await mockWorkOrderBoard(page);
|
||||
});
|
||||
|
||||
test("scopes the table to one day and excludes Unscheduled", async ({ page }) => {
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
await expect(page.getByText("WED-WO").first()).toBeVisible();
|
||||
await expect(page.getByText("MON-WO")).toHaveCount(0);
|
||||
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Unscheduled/ })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Mon · 08/17" }).click();
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByText("MON-WO").first()).toBeVisible();
|
||||
await expect(page.getByText("WED-WO")).toHaveCount(0);
|
||||
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("updates day tabs and selects Monday when the week changes", async ({ page }) => {
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Week 8/17–8/21" }).click();
|
||||
await page.getByRole("button", { name: /Aug 10/ }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Week 8/10–8/14" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/10" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("tab", { name: "Fri · 08/14" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveCount(0);
|
||||
await expect(page.getByText("PREV-MON-WO").first()).toBeVisible();
|
||||
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||
import {
|
||||
emptyVendorCompanyRosterForm,
|
||||
|
|
@ -8,10 +9,38 @@ import {
|
|||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
||||
import {
|
||||
buildAdditiveRosterPatch,
|
||||
getSingleStatusOnlyChange,
|
||||
useSaveVendorCompanyRoster,
|
||||
type EditedCompanyFields,
|
||||
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||
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 { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||
import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||
|
|
@ -104,6 +133,10 @@ export function useVendorRosterForm({
|
|||
mode: "onChange",
|
||||
});
|
||||
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 clearConflict = useCallback(() => setConflict(null), []);
|
||||
|
|
@ -129,17 +162,39 @@ export function useVendorRosterForm({
|
|||
clearConflict();
|
||||
}, [clearConflict, createDefaults, reset, resetSelection]);
|
||||
|
||||
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
||||
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
||||
|
||||
const submit = (formValues: VendorCompanyRosterFormValues) => {
|
||||
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(
|
||||
{
|
||||
mode: isUpdate ? "update" : "create",
|
||||
values: withoutBlankNewTechnicians(formValues),
|
||||
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
||||
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
||||
mode: mode === "update" ? "update" : "create",
|
||||
values,
|
||||
companyId: mode === "update" ? (routeRoster?.companyId ?? companyId) : undefined,
|
||||
rowVersion: mode === "update" ? routeRoster?.rowVersion : undefined,
|
||||
originalRoster: mode === "update" ? routeRoster : undefined,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
emptyRosterTechnician,
|
||||
emptyVendorCompanyRosterForm,
|
||||
type VendorCompanyRosterFormValues,
|
||||
} 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({
|
||||
mode,
|
||||
reset,
|
||||
|
|
@ -72,6 +87,13 @@ export function useVendorRosterSelection({
|
|||
queryClient.fetchQuery({
|
||||
queryKey: queryKeys.vendors.roster("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],
|
||||
);
|
||||
|
|
@ -91,7 +113,7 @@ export function useVendorRosterSelection({
|
|||
try {
|
||||
const roster = await fetchRosterByCompany(company.companyId);
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
reset(toFormValues(roster));
|
||||
reset(withCompanyFieldsOnly(roster));
|
||||
setSelectedRoster(roster);
|
||||
clearConflict();
|
||||
retryTargetRef.current = null;
|
||||
|
|
@ -107,7 +129,11 @@ export function useVendorRosterSelection({
|
|||
(nextName = "") => {
|
||||
requestIdRef.current += 1;
|
||||
if (selectedRoster) {
|
||||
reset({ ...emptyVendorCompanyRosterForm, name: nextName });
|
||||
reset((current) => ({
|
||||
...emptyVendorCompanyRosterForm,
|
||||
name: nextName,
|
||||
technicians: current.technicians,
|
||||
}));
|
||||
}
|
||||
setSelectedRoster(null);
|
||||
clearConflict();
|
||||
|
|
@ -134,7 +160,11 @@ export function useVendorRosterSelection({
|
|||
void fetchRosterByCompany(selectedRoster.companyId)
|
||||
.then((roster) => {
|
||||
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);
|
||||
retryTargetRef.current = null;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { NavPillButton } from "@/components/ui/nav-pill-button";
|
||||
import { formatDayTabLabel, weekdaysMonFri } from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
|
||||
type DayTabsProps = {
|
||||
weekMonday: string;
|
||||
selectedDayIso: string;
|
||||
onChange: (dayIso: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function DayTabs({ weekMonday, selectedDayIso, onChange, disabled = false }: DayTabsProps) {
|
||||
const days = weekdaysMonFri(weekMonday);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto" role="tablist" aria-label="Weekdays">
|
||||
{days.map((iso) => {
|
||||
const label = formatDayTabLabel(iso);
|
||||
const active = iso === selectedDayIso;
|
||||
return (
|
||||
<NavPillButton
|
||||
key={iso}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(iso)}
|
||||
>
|
||||
{label}
|
||||
</NavPillButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { NavArrowButton } from "@/components/ui/nav-arrow-button";
|
||||
|
||||
import { NavPillButton } from "@/components/ui/nav-pill-button";
|
||||
|
||||
import {
|
||||
buildWeekWindow,
|
||||
isFutureWeek,
|
||||
isLiveWeek,
|
||||
} from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
type WeekNavProps = {
|
||||
weekMonday: string;
|
||||
|
||||
onChange: (mondayIso: string) => void;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const VISIBLE_COUNT = 3;
|
||||
|
||||
const WINDOW_BEFORE = 12;
|
||||
|
||||
const WINDOW_AFTER = 1;
|
||||
|
||||
function WeekLiveBadge() {
|
||||
return (
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
|
||||
fontSize: 9,
|
||||
|
||||
fontWeight: 700,
|
||||
|
||||
color: "#ffffff",
|
||||
|
||||
background: "var(--success)",
|
||||
|
||||
borderRadius: 9999,
|
||||
|
||||
padding: "1px 5px",
|
||||
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeekNav({ weekMonday, onChange, disabled = false }: WeekNavProps) {
|
||||
const weeks = useMemo(
|
||||
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
|
||||
|
||||
[weekMonday],
|
||||
);
|
||||
|
||||
const activeIndex = useMemo(
|
||||
() => weeks.findIndex((week) => week.mondayIso === weekMonday),
|
||||
|
||||
[weeks, weekMonday],
|
||||
);
|
||||
|
||||
const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
|
||||
const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT);
|
||||
|
||||
setOffset((current) => {
|
||||
if (activeIndex < current) return Math.min(activeIndex, maxOffset);
|
||||
|
||||
if (activeIndex > current + VISIBLE_COUNT - 1) {
|
||||
return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset);
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
}, [activeIndex, weeks.length]);
|
||||
|
||||
const visible = weeks.slice(offset, offset + VISIBLE_COUNT);
|
||||
|
||||
const canGoBack = offset > 0;
|
||||
|
||||
const canGoForward = offset + VISIBLE_COUNT < weeks.length;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<NavArrowButton
|
||||
disabled={disabled || !canGoBack}
|
||||
onClick={() => setOffset((current) => current - 1)}
|
||||
aria-label="Previous weeks"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</NavArrowButton>
|
||||
|
||||
{visible.map((week) => {
|
||||
const isActive = weekMonday === week.mondayIso;
|
||||
|
||||
const isDisabled = isFutureWeek(week.mondayIso);
|
||||
|
||||
const showLiveBadge = isLiveWeek(week.mondayIso);
|
||||
|
||||
return (
|
||||
<NavPillButton
|
||||
key={week.mondayIso}
|
||||
active={isActive}
|
||||
disabled={disabled || isDisabled}
|
||||
onClick={() => onChange(week.mondayIso)}
|
||||
>
|
||||
{week.label}
|
||||
|
||||
{showLiveBadge && <WeekLiveBadge />}
|
||||
</NavPillButton>
|
||||
);
|
||||
})}
|
||||
|
||||
<NavArrowButton
|
||||
disabled={disabled || !canGoForward}
|
||||
onClick={() => setOffset((current) => current + 1)}
|
||||
aria-label="Next weeks"
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</NavArrowButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { Popover } from "@mui/material";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||
import {
|
||||
compactWeekLabel,
|
||||
isFutureWeek,
|
||||
isLiveWeek,
|
||||
} from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WeekSelectorProps = {
|
||||
weekMonday: string;
|
||||
onChange: (mondayIso: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function WeekLiveBadge() {
|
||||
return (
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
background: "var(--success)",
|
||||
borderRadius: 9999,
|
||||
padding: "1px 5px",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeekSelector({ weekMonday, onChange, disabled = false }: WeekSelectorProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
const live = isLiveWeek(weekMonday);
|
||||
const label = compactWeekLabel(weekMonday);
|
||||
|
||||
const handlePick = (mondayIso: string) => {
|
||||
if (isFutureWeek(mondayIso)) {
|
||||
return;
|
||||
}
|
||||
onChange(mondayIso);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuAnchorButton
|
||||
onAnchor={setAnchor}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
aria-label={`Week ${label}`}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-[13px] text-[13px] font-semibold",
|
||||
"border-[#bfe2f5] bg-(--color-primary-tint) text-primary",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{Boolean(live) && <WeekLiveBadge />}
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
</MenuAnchorButton>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
>
|
||||
<div className="p-3">
|
||||
<WeekPicker selected={weekMonday} onPick={handlePick} isWeekDisabled={isFutureWeek} />
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
|
||||
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
|
||||
import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav";
|
||||
import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
|
||||
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type WorkOrdersFilterBarProps = {
|
||||
weekMonday: string;
|
||||
onWeekChange: (mondayIso: string) => void;
|
||||
selectedDayIso: string;
|
||||
onSelectedDayChange: (dayIso: string) => void;
|
||||
dispatcherIds: Set<string>;
|
||||
onDispatcherChange: (ids: Set<string>) => void;
|
||||
typeFilter: WorkOrderTypeFilter;
|
||||
|
|
@ -22,6 +26,8 @@ type WorkOrdersFilterBarProps = {
|
|||
export function WorkOrdersFilterBar({
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
selectedDayIso,
|
||||
onSelectedDayChange,
|
||||
dispatcherIds,
|
||||
onDispatcherChange,
|
||||
typeFilter,
|
||||
|
|
@ -37,21 +43,35 @@ export function WorkOrdersFilterBar({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4"
|
||||
className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<DispatcherFilter
|
||||
selectedIds={dispatcherIds}
|
||||
onChange={onDispatcherChange}
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={currentUserId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
|
||||
<div className="flex-1" />
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
|
||||
<div className="flex items-center gap-3 px-4 pt-2">
|
||||
<DispatcherFilter
|
||||
selectedIds={dispatcherIds}
|
||||
onChange={onDispatcherChange}
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={currentUserId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Text variant="caption" tone="muted" className="shrink-0">
|
||||
{resultLabel}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-4 pt-1" aria-label="Week and day">
|
||||
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||
<DayTabs
|
||||
weekMonday={weekMonday}
|
||||
selectedDayIso={selectedDayIso}
|
||||
onChange={onSelectedDayChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-4 pt-1 pb-2">
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
|
@ -13,7 +12,8 @@ import type { WoTableRowHandlers } from "./wo-table-row";
|
|||
type UseWoTableRowHandlersOptions = {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
weekMonday: string;
|
||||
onWeekChange: (mondayIso: string) => void;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
onOpenAdvancedFilters?: () => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -26,7 +26,8 @@ type UseWoTableRowHandlersOptions = {
|
|||
export function useWoTableRowHandlers({
|
||||
tableData,
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
|
|
@ -35,14 +36,7 @@ export function useWoTableRowHandlers({
|
|||
onScheduleHighlight,
|
||||
onDuplicateFound,
|
||||
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
|
||||
const {
|
||||
dragState: dragStateRef,
|
||||
setDragViz,
|
||||
reorderRows,
|
||||
endDrag,
|
||||
setUnschedOpen,
|
||||
triggerFlash,
|
||||
} = tableData;
|
||||
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
|
@ -80,14 +74,16 @@ export function useWoTableRowHandlers({
|
|||
onScheduleDate: (id: string | number, date: string) => {
|
||||
onPatch(id, applySchedulePatch(date, false));
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
const targetMonday = mondayOf(date);
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onWeekChange(targetMonday);
|
||||
onNavigateToDay(date);
|
||||
setTimeout(() => triggerFlash(id), 180);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNavigateToDay(date);
|
||||
triggerFlash(id);
|
||||
},
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPatch(id, {
|
||||
|
|
@ -97,12 +93,10 @@ export function useWoTableRowHandlers({
|
|||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
setUnschedOpen(true);
|
||||
triggerFlash(id);
|
||||
weekMoveNotify(week, {
|
||||
onNavigate: () => {
|
||||
setUnschedOpen(true);
|
||||
setTimeout(() => triggerFlash(id), 180);
|
||||
onNavigateToDay(week);
|
||||
onOpenAdvancedFilters?.();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
@ -138,10 +132,10 @@ export function useWoTableRowHandlers({
|
|||
setDragViz,
|
||||
reorderRows,
|
||||
endDrag,
|
||||
setUnschedOpen,
|
||||
triggerFlash,
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Fragment, type ReactNode } from "react";
|
|||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
UNASSIGNED_DAY_KEY,
|
||||
UNSCHEDULED_DAY_KEY,
|
||||
type WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
|
|
@ -21,11 +20,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
|||
}
|
||||
|
||||
const unassignedRows = tableData.unassigned ?? [];
|
||||
const showUnscheduled =
|
||||
(tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
// SH-165: "still loading" and "zero unassigned" are distinct states. Without
|
||||
// this split the pin announced "No unassigned work orders." while the queue
|
||||
// was still paging board/search on first mount.
|
||||
const showUnassignedLoading =
|
||||
tableData.unassignedOpen &&
|
||||
unassignedRows.length === 0 &&
|
||||
|
|
@ -79,24 +73,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
|||
{unassignedRows.map((row) => renderRow(row))}
|
||||
</>
|
||||
)}
|
||||
{showUnscheduled && (
|
||||
<>
|
||||
<WoTableSectionHeader
|
||||
variant="unscheduled"
|
||||
label="Unscheduled"
|
||||
count={tableData.unscheduled.length}
|
||||
collapsed={!tableData.unschedOpen}
|
||||
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
|
||||
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
|
||||
/>
|
||||
{tableData.unschedOpen && (
|
||||
<>
|
||||
{renderInlineRow(UNSCHEDULED_DAY_KEY)}
|
||||
{tableData.unscheduled.map((row) => renderRow(row, true))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tableData.displayGroups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
<WoTableSectionHeader
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
|
|
@ -29,7 +30,7 @@ type WoTableProps = {
|
|||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
weekMonday: string;
|
||||
onWeekChange: (mondayIso: string) => void;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -40,6 +41,34 @@ type WoTableProps = {
|
|||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||
};
|
||||
|
||||
type WoTableEmptyShellProps = {
|
||||
message: string;
|
||||
actionLabel: string;
|
||||
onAction?: () => void;
|
||||
};
|
||||
|
||||
function WoTableLoading({
|
||||
weekMonday,
|
||||
selectedDayIso,
|
||||
}: {
|
||||
weekMonday: string;
|
||||
selectedDayIso?: string;
|
||||
}) {
|
||||
return (
|
||||
<WoTableShell>
|
||||
<WoTableSkeleton weekMonday={weekMonday} selectedDayIso={selectedDayIso} />
|
||||
</WoTableShell>
|
||||
);
|
||||
}
|
||||
|
||||
function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShellProps) {
|
||||
return (
|
||||
<WoTableShell>
|
||||
<WoTableEmptyState message={message} actionLabel={actionLabel} onAction={onAction} />
|
||||
</WoTableShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoTable({
|
||||
tableData,
|
||||
users,
|
||||
|
|
@ -47,7 +76,7 @@ export function WoTable({
|
|||
vendors,
|
||||
pmTypes,
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
onNavigateToDay,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
|
|
@ -70,7 +99,8 @@ export function WoTable({
|
|||
const handlers = useWoTableRowHandlers({
|
||||
tableData,
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
|
|
@ -125,9 +155,7 @@ export function WoTable({
|
|||
|
||||
if (tableData.isLoading) {
|
||||
return (
|
||||
<WoTableShell>
|
||||
<WoTableSkeleton weekMonday={weekMonday} />
|
||||
</WoTableShell>
|
||||
<WoTableLoading weekMonday={weekMonday} selectedDayIso={tableData.displayGroups[0]?.date} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -135,25 +163,26 @@ export function WoTable({
|
|||
|
||||
if (emptyAdv) {
|
||||
return (
|
||||
<WoTableShell>
|
||||
<WoTableEmptyState
|
||||
message="No work orders match these filters"
|
||||
actionLabel="Adjust filters"
|
||||
onAction={onOpenAdvancedFilters}
|
||||
/>
|
||||
</WoTableShell>
|
||||
<WoTableEmptyShell
|
||||
message="No work orders match these filters"
|
||||
actionLabel="Adjust filters"
|
||||
onAction={onOpenAdvancedFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tableData.advActive && tableData.noSearchResults) {
|
||||
const showSearchEmpty =
|
||||
!tableData.advActive &&
|
||||
tableData.noSearchResults &&
|
||||
isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError);
|
||||
|
||||
if (showSearchEmpty) {
|
||||
return (
|
||||
<WoTableShell>
|
||||
<WoTableEmptyState
|
||||
message="No work orders match your search"
|
||||
actionLabel="Clear search"
|
||||
onAction={onClearSearch}
|
||||
/>
|
||||
</WoTableShell>
|
||||
<WoTableEmptyShell
|
||||
message="No work orders match your search"
|
||||
actionLabel="Clear search"
|
||||
onAction={onClearSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Skeleton } from "@mui/material";
|
||||
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||
import { TableSkeletonRows } from "@/components/ui/table-skeleton-rows";
|
||||
import { buildWeekSkeleton } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { formatDayTabLabel, weekdaysMonFri } from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
|
||||
import {
|
||||
WO_TABLE_COLS,
|
||||
|
|
@ -11,13 +11,26 @@ import {
|
|||
|
||||
type WoTableSkeletonProps = {
|
||||
weekMonday: string;
|
||||
selectedDayIso?: string;
|
||||
rowsPerDay?: number;
|
||||
};
|
||||
|
||||
const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w }));
|
||||
|
||||
export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonProps) {
|
||||
const days = buildWeekSkeleton(weekMonday);
|
||||
function skeletonDayIso(weekMonday: string, selectedDayIso?: string): string {
|
||||
const days = weekdaysMonFri(weekMonday);
|
||||
if (selectedDayIso && days.includes(selectedDayIso)) {
|
||||
return selectedDayIso;
|
||||
}
|
||||
return weekMonday;
|
||||
}
|
||||
|
||||
export function WoTableSkeleton({
|
||||
weekMonday,
|
||||
selectedDayIso,
|
||||
rowsPerDay = 2,
|
||||
}: WoTableSkeletonProps) {
|
||||
const dayIso = skeletonDayIso(weekMonday, selectedDayIso);
|
||||
|
||||
return (
|
||||
<LoadingRegion label="Loading work orders" className="h-full">
|
||||
|
|
@ -30,9 +43,8 @@ export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonP
|
|||
>
|
||||
<WoTableHead />
|
||||
<tbody>
|
||||
{days.map((day) => (
|
||||
<DaySkeletonSection key={day.key} label={day.label} rows={rowsPerDay} />
|
||||
))}
|
||||
<DaySkeletonSection label="Unassigned" rows={1} />
|
||||
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
|
||||
</tbody>
|
||||
</table>
|
||||
</LoadingRegion>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import {
|
|||
type WeekPickerProps = {
|
||||
selected: string;
|
||||
onPick: (mondayIso: string) => void;
|
||||
isWeekDisabled?: (mondayIso: string) => boolean;
|
||||
};
|
||||
|
||||
export function WeekPicker({ selected, onPick }: WeekPickerProps) {
|
||||
export function WeekPicker({ selected, onPick, isWeekDisabled }: WeekPickerProps) {
|
||||
const init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
|
||||
const [iy, im] = init.split("-").map(Number);
|
||||
const [viewY, setViewY] = useState(iy);
|
||||
|
|
@ -66,12 +67,14 @@ export function WeekPicker({ selected, onPick }: WeekPickerProps) {
|
|||
{weeks.map((mon) => {
|
||||
const isSel = Boolean(selected) && selected === mon;
|
||||
const isThis = mon === thisMonday;
|
||||
const isDisabled = Boolean(isWeekDisabled?.(mon));
|
||||
return (
|
||||
<button
|
||||
key={mon}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => onPick(mon)}
|
||||
className="flex items-center transition-colors"
|
||||
className="flex items-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
style={{
|
||||
height: 36,
|
||||
borderRadius: 6,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ import {
|
|||
toBoardQueryParams,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import {
|
||||
firstWeekday,
|
||||
initialSelectedDay,
|
||||
scheduledDayTab,
|
||||
weekdaysMonFri,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
|
||||
export function useWorkOrdersListFilters() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
|
@ -27,6 +33,9 @@ export function useWorkOrdersListFilters() {
|
|||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
||||
const [selectedDayIso, setSelectedDayIsoState] = useState(() =>
|
||||
initialSelectedDay(currentWeekMonday()),
|
||||
);
|
||||
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set());
|
||||
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
|
||||
|
||||
|
|
@ -42,6 +51,7 @@ export function useWorkOrdersListFilters() {
|
|||
);
|
||||
|
||||
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
|
||||
const weekDays = useMemo(() => weekdaysMonFri(weekMonday), [weekMonday]);
|
||||
|
||||
const boardParams = useMemo<WorkOrderBoardQueryParams>(
|
||||
() => toBoardQueryParams(barFilters, debouncedSearch),
|
||||
|
|
@ -55,6 +65,18 @@ export function useWorkOrdersListFilters() {
|
|||
|
||||
const setWeekMonday = (value: string) => {
|
||||
setWeekMondayState(value);
|
||||
setSelectedDayIsoState(firstWeekday(value));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const setSelectedDayIso = (value: string) => {
|
||||
setSelectedDayIsoState(value);
|
||||
};
|
||||
|
||||
const navigateToDay = (dateIso: string) => {
|
||||
const tab = scheduledDayTab(dateIso);
|
||||
setWeekMondayState(tab.weekMonday);
|
||||
setSelectedDayIsoState(tab.selectedDayIso);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
|
|
@ -123,6 +145,8 @@ export function useWorkOrdersListFilters() {
|
|||
sortBy,
|
||||
sortDir,
|
||||
weekMonday,
|
||||
selectedDayIso,
|
||||
weekDays,
|
||||
dispatcherIds,
|
||||
typeFilter,
|
||||
barFilters,
|
||||
|
|
@ -139,6 +163,8 @@ export function useWorkOrdersListFilters() {
|
|||
setPage,
|
||||
setPageSize,
|
||||
setWeekMonday,
|
||||
setSelectedDayIso,
|
||||
navigateToDay,
|
||||
setDispatcherIds,
|
||||
setTypeFilter,
|
||||
setSortBy,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/us
|
|||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
isUnassignedQueueReady,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import {
|
||||
buildDayGroups,
|
||||
composeBoardSections,
|
||||
filterByBarAndSearch,
|
||||
mergeWeeklyAndUnassignedQueue,
|
||||
|
|
@ -74,19 +78,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
});
|
||||
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
|
||||
|
||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
advActive
|
||||
? []
|
||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||
);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const visibleRows = advActive ? advResults : filtered;
|
||||
const noSearchResults = visibleRows.length === 0 && searchActive;
|
||||
const isEmptyWeek =
|
||||
const displayGroups = dayGroups;
|
||||
const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
scheduled.length === 0 &&
|
||||
unassigned.length === 0 &&
|
||||
unscheduled.length === 0 &&
|
||||
!searchActive;
|
||||
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: unassigned.length,
|
||||
dayItemCount: dayVisibleCount,
|
||||
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
|
||||
});
|
||||
const isEmptyWeek =
|
||||
!advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
|
||||
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
|
|
@ -138,9 +150,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
cancelDraft: local.cancelDraft,
|
||||
addRow: local.addRow,
|
||||
scopeTotalCount: data?.totalCount ?? 0,
|
||||
totalCount: advActive
|
||||
? (data?.totalCount ?? advResults.length)
|
||||
: (data?.returnedCount ?? weeklyItems.length),
|
||||
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount,
|
||||
search: filters.search,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export default function WorkOrdersListPage() {
|
|||
<WorkOrdersFilterBar
|
||||
weekMonday={filters.weekMonday}
|
||||
onWeekChange={filters.setWeekMonday}
|
||||
selectedDayIso={filters.selectedDayIso}
|
||||
onSelectedDayChange={filters.setSelectedDayIso}
|
||||
dispatcherIds={filters.dispatcherIds}
|
||||
onDispatcherChange={filters.setDispatcherIds}
|
||||
typeFilter={filters.typeFilter}
|
||||
|
|
@ -88,7 +90,7 @@ export default function WorkOrdersListPage() {
|
|||
vendors={page.vendors}
|
||||
pmTypes={page.pmTypes}
|
||||
weekMonday={filters.weekMonday}
|
||||
onWeekChange={filters.setWeekMonday}
|
||||
onNavigateToDay={filters.navigateToDay}
|
||||
onOpenSlideOver={page.handleOpenSlideOver}
|
||||
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||
onOpenMedia={page.setMediaRow}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { isHTTPError } from "ky";
|
||||
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 {
|
||||
mapRosterConflict,
|
||||
mapVendorCompanyRoster,
|
||||
mapVendorRosterAdditivePatchToBackend,
|
||||
mapVendorRosterToBackend,
|
||||
} from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||
import { VendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorRosterAdditivePatch } from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||
|
||||
const STALE_MESSAGE =
|
||||
"This company was changed by another session. Reload the latest version and try again.";
|
||||
|
|
@ -97,4 +99,22 @@ export const vendorCompanyRosterApi = {
|
|||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -134,6 +134,36 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
|
|||
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 {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -83,11 +83,14 @@ export const vendorCompanyRosterUpdateSchema = vendorCompanyRosterSchema.and(
|
|||
|
||||
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 = {
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,13 +11,17 @@ import type {
|
|||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export interface SaveVendorCompanyRosterInput {
|
||||
mode: "create" | "update";
|
||||
mode: "create" | "update" | "add";
|
||||
values: VendorCompanyRosterFormValues;
|
||||
companyId?: string | number | null;
|
||||
rowVersion?: string;
|
||||
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 {
|
||||
conflict?: unknown;
|
||||
}
|
||||
|
|
@ -65,6 +69,66 @@ export function getSingleStatusOnlyChange(
|
|||
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<
|
||||
VendorCompanyRoster,
|
||||
unknown,
|
||||
|
|
@ -85,6 +149,7 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
|
|||
companyId,
|
||||
rowVersion,
|
||||
originalRoster,
|
||||
editedCompanyFields,
|
||||
}: SaveVendorCompanyRosterInput) => {
|
||||
if (mode === "update") {
|
||||
const statusChange = originalRoster
|
||||
|
|
@ -108,17 +173,31 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
|
|||
if (!rowVersion) throw new Error("Row version is required to update");
|
||||
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);
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
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({
|
||||
queryKey: queryKeys.vendors.roster("companyId", variables.companyId),
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
variables.mode === "update" ? "Vendor company updated" : "Vendor company created",
|
||||
variables.mode === "create" ? "Vendor company created" : "Vendor company updated",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
|
|||
88
src/domain/work-orders/utils/week-day-nav-utils.ts
Normal file
88
src/domain/work-orders/utils/week-day-nav-utils.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type {
|
||||
WorkOrderDayGroup,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
const DAY_TAB_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
||||
|
||||
export function weekdaysMonFri(weekMonday: string): string[] {
|
||||
return Array.from({ length: 5 }, (_, index) => addDaysIso(weekMonday, index));
|
||||
}
|
||||
|
||||
export function firstWeekday(weekMonday: string): string {
|
||||
return weekMonday;
|
||||
}
|
||||
|
||||
/** Maps a scheduled date onto the Mon–Fri tab that can show it (weekends fall back to Monday). */
|
||||
export function scheduledDayTab(dateIso: string): { weekMonday: string; selectedDayIso: string } {
|
||||
const day = dateIso.slice(0, 10);
|
||||
const weekMonday = mondayOf(day);
|
||||
const days = weekdaysMonFri(weekMonday);
|
||||
return {
|
||||
weekMonday,
|
||||
selectedDayIso: days.includes(day) ? day : firstWeekday(weekMonday),
|
||||
};
|
||||
}
|
||||
|
||||
export function isUnassignedQueueReady(
|
||||
unassignedLoading: boolean,
|
||||
unassignedError: Error | null,
|
||||
): boolean {
|
||||
return !unassignedLoading && unassignedError == null;
|
||||
}
|
||||
|
||||
export function isDayScopedSearchEmpty(args: {
|
||||
searchActive: boolean;
|
||||
unassignedCount: number;
|
||||
dayItemCount: number;
|
||||
unassignedQueueReady: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
args.searchActive &&
|
||||
args.unassignedQueueReady &&
|
||||
args.unassignedCount === 0 &&
|
||||
args.dayItemCount === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function initialSelectedDay(weekMonday: string, today = todayIso()): string {
|
||||
if (isLiveWeek(weekMonday) && isDateInWeek(today, weekMonday)) {
|
||||
return today;
|
||||
}
|
||||
return firstWeekday(weekMonday);
|
||||
}
|
||||
|
||||
export function formatDayTabLabel(iso: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
||||
return "";
|
||||
}
|
||||
const day = new Date(`${iso}T12:00:00`);
|
||||
const [, month, date] = iso.split("-");
|
||||
return `${DAY_TAB_WEEKDAYS[day.getDay()]} · ${month}/${date}`;
|
||||
}
|
||||
|
||||
export function rowMatchesSelectedDay(
|
||||
row: Pick<WorkOrderTableRow, "scheduledOn">,
|
||||
selectedDayIso: string,
|
||||
): boolean {
|
||||
return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso;
|
||||
}
|
||||
|
||||
export function buildSelectedDayGroup(
|
||||
weekMonday: string,
|
||||
selectedDayIso: string,
|
||||
scheduled: WorkOrderTableRow[],
|
||||
): WorkOrderDayGroup {
|
||||
const days = weekdaysMonFri(weekMonday);
|
||||
const date = days.includes(selectedDayIso) ? selectedDayIso : firstWeekday(weekMonday);
|
||||
const [, month, day] = date.split("-");
|
||||
return {
|
||||
key: `${month}/${day}`,
|
||||
label: formatDayTabLabel(date),
|
||||
date,
|
||||
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)),
|
||||
};
|
||||
}
|
||||
|
|
@ -12,15 +12,19 @@ vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
||||
useVendorCompanyRoster: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useVendorCompanyRoster: () => rosterQueryResult,
|
||||
}));
|
||||
|
||||
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", () => ({
|
||||
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 type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const company: VendorFacetCompany = {
|
||||
|
|
@ -81,10 +86,87 @@ const secondRoster: VendorCompanyRoster = {
|
|||
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 {
|
||||
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) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
|
|
@ -316,3 +398,288 @@ describe("useVendorRosterForm stale-selection handling", () => {
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ describe("useWoTableRowHandlers onDuplicateFound", () => {
|
|||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(),
|
||||
weekMonday: "2026-07-20",
|
||||
onWeekChange: vi.fn(),
|
||||
onNavigateToDay: vi.fn(),
|
||||
onOpenSlideOver,
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
|
||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
||||
const { rescheduleNotify, weekMoveNotify } = vi.hoisted(() => ({
|
||||
rescheduleNotify: vi.fn(),
|
||||
weekMoveNotify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", () => ({
|
||||
rescheduleNotify,
|
||||
weekMoveNotify,
|
||||
}));
|
||||
|
||||
function buildTableData(triggerFlash = vi.fn()) {
|
||||
return {
|
||||
dragState: { current: { dragId: null, overId: null } },
|
||||
setDragViz: vi.fn(),
|
||||
reorderRows: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
triggerFlash,
|
||||
setSelectedId: vi.fn(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||
it("selects the scheduled weekday in the same week so the row stays visible", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
const triggerFlash = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(triggerFlash),
|
||||
weekMonday: "2026-05-18",
|
||||
onNavigateToDay,
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch,
|
||||
onScheduleHighlight: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onScheduleDate("wo-1", "2026-05-21");
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
|
||||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
|
||||
expect(triggerFlash).toHaveBeenCalledWith("wo-1");
|
||||
expect(rescheduleNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends cross-week view-it navigation to the target day, not Monday", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(),
|
||||
weekMonday: "2026-05-18",
|
||||
onNavigateToDay,
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch: vi.fn(),
|
||||
onScheduleHighlight: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onScheduleDate("wo-1", "2026-05-27");
|
||||
|
||||
expect(onNavigateToDay).not.toHaveBeenCalled();
|
||||
expect(rescheduleNotify).toHaveBeenCalledWith(
|
||||
"2026-05-27",
|
||||
expect.objectContaining({ onNavigate: expect.any(Function) }),
|
||||
);
|
||||
|
||||
const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void };
|
||||
onNavigate();
|
||||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27");
|
||||
});
|
||||
|
||||
it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
const onOpenAdvancedFilters = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(),
|
||||
weekMonday: "2026-05-18",
|
||||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch,
|
||||
onScheduleHighlight: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onScheduleWeek("wo-1", "2026-05-25");
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
"wo-1",
|
||||
expect.objectContaining({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "2026-05-25",
|
||||
}),
|
||||
);
|
||||
expect(weekMoveNotify).toHaveBeenCalled();
|
||||
|
||||
const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void };
|
||||
onNavigate();
|
||||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-25");
|
||||
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
113
src/test/app/(protected)/workorders/week-day-nav.test.tsx
Normal file
113
src/test/app/(protected)/workorders/week-day-nav.test.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { act, render, renderHook, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
|
||||
import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
|
||||
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { firstWeekday } from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
|
||||
vi.mock("@/domain/work-orders/utils/wizard-date-utils", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/domain/work-orders/utils/wizard-date-utils")>();
|
||||
return {
|
||||
...actual,
|
||||
todayIso: () => "2026-08-19",
|
||||
};
|
||||
});
|
||||
|
||||
function WeekDayNavHarness() {
|
||||
const [weekMonday, setWeekMonday] = useState("2026-05-18");
|
||||
const [selectedDayIso, setSelectedDayIso] = useState("2026-05-20");
|
||||
|
||||
const handleWeekChange = (mondayIso: string) => {
|
||||
setWeekMonday(mondayIso);
|
||||
setSelectedDayIso(firstWeekday(mondayIso));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WeekSelector weekMonday={weekMonday} onChange={handleWeekChange} />
|
||||
<DayTabs
|
||||
weekMonday={weekMonday}
|
||||
selectedDayIso={selectedDayIso}
|
||||
onChange={setSelectedDayIso}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("WeekSelector and DayTabs (SH-240)", () => {
|
||||
it("renders Mon–Fri tabs for the active week", () => {
|
||||
render(
|
||||
<DayTabs weekMonday="2026-05-18" selectedDayIso="2026-05-18" onChange={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Mon · 05/18" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Tue · 05/19" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Wed · 05/20" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Thu · 05/21" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Fri · 05/22" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects a day tab without expanding other days", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WeekDayNavHarness />);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Thu · 05/21" }));
|
||||
expect(screen.getByRole("tab", { name: "Thu · 05/21" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Wed · 05/20" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("resets to Monday when the week picker selects another week", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WeekDayNavHarness />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Week 5/18–5/22" }));
|
||||
await user.click(screen.getByRole("button", { name: /May 11/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Week 5/11–5/15" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Mon · 05/11" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Fri · 05/15" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Mon · 05/18" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrdersListFilters day state (SH-240)", () => {
|
||||
it("starts on today in the live week and resets to Monday on week change", () => {
|
||||
const { result } = renderHook(() => useWorkOrdersListFilters());
|
||||
|
||||
expect(result.current.weekMonday).toBe("2026-08-17");
|
||||
expect(result.current.selectedDayIso).toBe("2026-08-19");
|
||||
|
||||
act(() => {
|
||||
result.current.setWeekMonday("2026-08-10");
|
||||
});
|
||||
|
||||
expect(result.current.weekMonday).toBe("2026-08-10");
|
||||
expect(result.current.selectedDayIso).toBe("2026-08-10");
|
||||
});
|
||||
|
||||
it("follows a scheduled date onto that weekday instead of resetting to Monday", () => {
|
||||
const { result } = renderHook(() => useWorkOrdersListFilters());
|
||||
|
||||
act(() => {
|
||||
result.current.navigateToDay("2026-05-20");
|
||||
});
|
||||
|
||||
expect(result.current.weekMonday).toBe("2026-05-18");
|
||||
expect(result.current.selectedDayIso).toBe("2026-05-20");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
|
||||
useLocationSites: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
|
||||
useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
|
||||
}));
|
||||
|
||||
function tableData(
|
||||
overrides: Partial<ReturnTypeOfUseWorkOrdersTableData> = {},
|
||||
): ReturnTypeOfUseWorkOrdersTableData {
|
||||
return {
|
||||
rows: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
advActive: false,
|
||||
advResults: [],
|
||||
unassigned: [],
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unscheduled: [],
|
||||
displayGroups: [{ key: "05/18", label: "Mon · 05/18", date: "2026-05-18", items: [] }],
|
||||
searchActive: true,
|
||||
noSearchResults: true,
|
||||
isEmptyWeek: false,
|
||||
todayKey: null,
|
||||
draftDayKey: null,
|
||||
draft: { locationId: "", site: "" },
|
||||
setDraft: vi.fn(),
|
||||
unassignedOpen: true,
|
||||
setUnassignedOpen: vi.fn(),
|
||||
unschedOpen: true,
|
||||
setUnschedOpen: vi.fn(),
|
||||
selectedId: null,
|
||||
setSelectedId: vi.fn(),
|
||||
selectedRow: null,
|
||||
flashId: null,
|
||||
triggerFlash: vi.fn(),
|
||||
dragViz: null,
|
||||
setDragViz: vi.fn(),
|
||||
dragState: { current: { dragId: null, overId: null } },
|
||||
patchRow: vi.fn(),
|
||||
clearRowPatch: vi.fn(),
|
||||
reorderRows: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
startDayDraft: vi.fn(),
|
||||
cancelDraft: vi.fn(),
|
||||
addRow: vi.fn(),
|
||||
scopeTotalCount: 0,
|
||||
totalCount: 0,
|
||||
search: "no-match",
|
||||
...overrides,
|
||||
} as ReturnTypeOfUseWorkOrdersTableData;
|
||||
}
|
||||
|
||||
function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
|
||||
return renderWithProviders(
|
||||
<WoTable
|
||||
tableData={data}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
weekMonday="2026-05-18"
|
||||
onNavigateToDay={vi.fn()}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
onOpenMedia={vi.fn()}
|
||||
onClearSearch={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => {
|
||||
it("shows empty search only after the Unassigned queue has settled empty", () => {
|
||||
const { getByText, queryByText } = renderTable(tableData());
|
||||
|
||||
expect(getByText("No work orders match your search")).toBeInTheDocument();
|
||||
expect(queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Unassigned loading visible instead of a premature empty search", () => {
|
||||
const { getByText, queryByText } = renderTable(
|
||||
tableData({
|
||||
noSearchResults: true,
|
||||
unassignedLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
|
||||
expect(getByText(/Loading unassigned work orders/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Unassigned error visible instead of hiding it behind empty search", () => {
|
||||
const { getByText, queryByText } = renderTable(
|
||||
tableData({
|
||||
noSearchResults: true,
|
||||
unassignedError: new Error("queue unavailable"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
|
||||
expect(getByText("queue unavailable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -180,7 +180,7 @@ function UnassignedAssignHarness({
|
|||
}
|
||||
|
||||
describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||
it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => {
|
||||
it("renders Unassigned above the selected day without Unscheduled (SH-240)", () => {
|
||||
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
|
||||
const undatedUnassigned = makeRow({
|
||||
id: "us1",
|
||||
|
|
@ -235,26 +235,21 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
|||
const unassignedHeader = screen.getByRole("button", {
|
||||
name: "Unassigned, 2 work orders, expanded",
|
||||
});
|
||||
const unscheduledHeader = screen.getByRole("button", {
|
||||
name: "Unscheduled, 1 work order, expanded",
|
||||
});
|
||||
const dayLabel = screen.getByText("TUE · 06/02");
|
||||
|
||||
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
|
||||
expect(unassignedHeader.compareDocumentPosition(dayLabel)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("row-us-assigned")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId(/row-/)).toHaveLength(4);
|
||||
expect(screen.getAllByTestId(/row-/)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("collapses and expands the Unassigned section via the disclosure control", async () => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_component
|
|||
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const HIGHLIGHT_BOX_SHADOW = "0 0 0 2px var(--color-accent)";
|
||||
|
||||
const unscheduledRow: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
|
|
@ -67,6 +65,8 @@ const handlers: WoTableRowHandlers = {
|
|||
onScheduleDate: vi.fn(),
|
||||
onScheduleWeek: vi.fn(),
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenCompDoc: vi.fn(),
|
||||
onOpenUplifts: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
|
|
@ -74,55 +74,47 @@ const handlers: WoTableRowHandlers = {
|
|||
onDragEnd: vi.fn(),
|
||||
};
|
||||
|
||||
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
|
||||
function renderUnscheduledHarness() {
|
||||
const tableData = {
|
||||
advActive: false,
|
||||
unassigned: [],
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unassignedOpen: true,
|
||||
setUnassignedOpen: vi.fn(),
|
||||
unscheduled: [unscheduledRow],
|
||||
unschedOpen: true,
|
||||
draftDayKey: null,
|
||||
displayGroups: [],
|
||||
todayKey: null,
|
||||
startDayDraft: vi.fn(),
|
||||
setUnschedOpen: vi.fn(),
|
||||
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
|
||||
describe("WoTableBody unscheduled exclusion (SH-240)", () => {
|
||||
it("does not render Unscheduled rows in the day-tab board view", () => {
|
||||
const tableData = {
|
||||
advActive: false,
|
||||
unassigned: [],
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unassignedOpen: true,
|
||||
setUnassignedOpen: vi.fn(),
|
||||
unscheduled: [unscheduledRow],
|
||||
unschedOpen: true,
|
||||
draftDayKey: null,
|
||||
displayGroups: [],
|
||||
todayKey: null,
|
||||
startDayDraft: vi.fn(),
|
||||
setUnschedOpen: vi.fn(),
|
||||
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
|
||||
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
<tr key={String(row.id)}>
|
||||
<WoTableRowCells
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={false}
|
||||
handlers={handlers}
|
||||
onPatchRow={vi.fn()}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
<tr key={String(row.id)}>
|
||||
<WoTableRowCells
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={false}
|
||||
handlers={handlers}
|
||||
onPatchRow={vi.fn()}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return renderWithProviders(
|
||||
<table>
|
||||
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
|
||||
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
|
||||
renderUnscheduledHarness();
|
||||
|
||||
const scheduleOn = screen.getByRole("button", { name: /^—$/ });
|
||||
const dueDate = screen.getByRole("button", { name: /07-15/ });
|
||||
|
||||
expect(scheduleOn).toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
|
||||
expect(dueDate).not.toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
|
||||
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("WO-42")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
|
|||
clearAdvancedFilterChip: vi.fn(),
|
||||
activeFilterCount: advApplied ? 1 : 0,
|
||||
weekMonday: "2026-07-20",
|
||||
selectedDayIso: "2026-07-20",
|
||||
setWeekMonday: vi.fn(),
|
||||
setSelectedDayIso: vi.fn(),
|
||||
navigateToDay: vi.fn(),
|
||||
dispatcherIds: new Set<string>(),
|
||||
setDispatcherIds: vi.fn(),
|
||||
typeFilter: "All",
|
||||
|
|
@ -251,8 +254,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
|||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Week 7/20–7/24" })).toBeDisabled();
|
||||
expect(screen.getByRole("tab", { name: "Mon · 07/20" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
|
||||
expect(screen.getByText("42 work orders")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
|
|||
clearAdvancedFilterChip: vi.fn(),
|
||||
activeFilterCount: 0,
|
||||
weekMonday: "2026-07-20",
|
||||
selectedDayIso: "2026-07-20",
|
||||
setWeekMonday: vi.fn(),
|
||||
setSelectedDayIso: vi.fn(),
|
||||
navigateToDay: vi.fn(),
|
||||
dispatcherIds: new Set<string>(),
|
||||
setDispatcherIds: vi.fn(),
|
||||
typeFilter: "All",
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import { API_PATHS } from "@/api/api-paths";
|
|||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||
}));
|
||||
|
||||
vi.mock("ky", () => ({
|
||||
|
|
@ -38,6 +40,7 @@ describe("vendorCompanyRosterApi", () => {
|
|||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPut.mockReset();
|
||||
apiPatch.mockReset();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
181
src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
Normal file
181
src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
firstWeekday,
|
||||
formatDayTabLabel,
|
||||
initialSelectedDay,
|
||||
isDayScopedSearchEmpty,
|
||||
isUnassignedQueueReady,
|
||||
rowMatchesSelectedDay,
|
||||
scheduledDayTab,
|
||||
weekdaysMonFri,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
function row(overrides: Partial<WorkOrderTableRow>): WorkOrderTableRow {
|
||||
return {
|
||||
id: "1",
|
||||
woNumber: "1",
|
||||
site: "",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "d1",
|
||||
dispatcherName: "D",
|
||||
scheduledOn: "",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "",
|
||||
locationId: "",
|
||||
pm: "",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Incomplete",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
additionalContacts: [],
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("week-day-nav-utils (SH-240)", () => {
|
||||
it("lists Mon–Fri of the active week", () => {
|
||||
expect(weekdaysMonFri("2026-05-18")).toEqual([
|
||||
"2026-05-18",
|
||||
"2026-05-19",
|
||||
"2026-05-20",
|
||||
"2026-05-21",
|
||||
"2026-05-22",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats day tabs as Title · MM/DD", () => {
|
||||
expect(formatDayTabLabel("2026-05-18")).toBe("Mon · 05/18");
|
||||
expect(formatDayTabLabel("2026-05-22")).toBe("Fri · 05/22");
|
||||
});
|
||||
|
||||
it("resets to Monday when the week changes", () => {
|
||||
expect(firstWeekday("2026-05-25")).toBe("2026-05-25");
|
||||
});
|
||||
|
||||
it("selects today on the live week when today is a weekday", () => {
|
||||
const liveMonday = currentWeekMonday();
|
||||
const wednesday = addDaysIso(liveMonday, 2);
|
||||
expect(initialSelectedDay(liveMonday, wednesday)).toBe(wednesday);
|
||||
});
|
||||
|
||||
it("selects Monday on a non-live week", () => {
|
||||
expect(initialSelectedDay("2026-05-18", "2026-05-20")).toBe("2026-05-18");
|
||||
});
|
||||
|
||||
it("excludes unscheduled and other days from the selected day group", () => {
|
||||
const monday = row({
|
||||
id: "mon",
|
||||
scheduledOn: "2026-05-18",
|
||||
dayGroup: "05/18",
|
||||
dayLabel: "MON · 05/18",
|
||||
});
|
||||
const wednesday = row({
|
||||
id: "wed",
|
||||
scheduledOn: "2026-05-20",
|
||||
dayGroup: "05/20",
|
||||
dayLabel: "WED · 05/20",
|
||||
});
|
||||
const unscheduled = row({ id: "us", scheduledOn: "", scheduleWeekOnly: true });
|
||||
|
||||
expect(rowMatchesSelectedDay(unscheduled, "2026-05-18")).toBe(false);
|
||||
expect(rowMatchesSelectedDay(monday, "2026-05-18")).toBe(true);
|
||||
|
||||
const group = buildSelectedDayGroup("2026-05-18", "2026-05-18", [
|
||||
monday,
|
||||
wednesday,
|
||||
unscheduled,
|
||||
]);
|
||||
expect(group.date).toBe("2026-05-18");
|
||||
expect(group.label).toBe("Mon · 05/18");
|
||||
expect(group.items.map((item) => item.id)).toEqual(["mon"]);
|
||||
});
|
||||
|
||||
it("maps a scheduled weekday onto its week and day tab", () => {
|
||||
expect(scheduledDayTab("2026-05-20")).toEqual({
|
||||
weekMonday: "2026-05-18",
|
||||
selectedDayIso: "2026-05-20",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats search as empty only when the selected day and Unassigned have no hits", () => {
|
||||
expect(
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive: true,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: 0,
|
||||
unassignedQueueReady: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive: true,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: 1,
|
||||
unassignedQueueReady: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive: true,
|
||||
unassignedCount: 2,
|
||||
dayItemCount: 0,
|
||||
unassignedQueueReady: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive: false,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: 0,
|
||||
unassignedQueueReady: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat search as empty while the Unassigned queue is loading or failed", () => {
|
||||
expect(isUnassignedQueueReady(true, null)).toBe(false);
|
||||
expect(isUnassignedQueueReady(false, new Error("queue unavailable"))).toBe(false);
|
||||
expect(isUnassignedQueueReady(false, null)).toBe(true);
|
||||
expect(
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive: true,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: 0,
|
||||
unassignedQueueReady: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue