Compare commits

...

27 commits

Author SHA1 Message Date
Codex Review Integration
aebb2c6b1a Merge remote-tracking branch 'origin/dev' into feat/sh-253-vendor-detail-parity
# Conflicts:
#	src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
2026-08-20 11:14:51 -03:00
Alexandre Brandizzi
3ade310a1a
Merge pull request #120 from Sea-Haven-Industries/feat/sh-255-deactivate-toggle-gate
fix(vendors): gate deactivation prompt on saved status (SH-255)
2026-08-20 11:11:40 -03:00
Alexandre Brandizzi
98daf9832d
Merge branch 'dev' into feat/sh-255-deactivate-toggle-gate 2026-08-20 11:06:54 -03:00
Alexandre Brandizzi
6da2e88cbf
Merge pull request #116 from Sea-Haven-Industries/feat/sh-250-fe-additive-add
fix(vendors): additive technician add for existing companies (SH-250, SH-246)
2026-08-20 11:06:42 -03:00
Alexandre Brandizzi
4e36396868
Merge branch 'dev' into feat/sh-253-vendor-detail-parity 2026-08-20 10:58:34 -03:00
Alexandre Brandizzi
8684cb6c8b
Merge branch 'dev' into feat/sh-255-deactivate-toggle-gate 2026-08-20 10:58:31 -03:00
Alexandre Brandizzi
8edcb6659d
Merge branch 'dev' into feat/sh-250-fe-additive-add 2026-08-20 10:58:26 -03:00
Arthur Bassi
fce8520f33
Merge pull request #123 from Sea-Haven-Industries/feat/sh-240-week-day-nav
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): day-scoped week navigation (SH-240)
2026-08-20 10:42:12 -03:00
Arthur Bassi
be975c792c fix(work-orders): wait for Unassigned queue before empty search (SH-240)
Keep SH-165 loading and error visible instead of a premature empty-search shell.
2026-08-20 10:28:04 -03:00
Arthur Bassi
1a54cbd2af
Merge branch 'dev' into feat/sh-240-week-day-nav 2026-08-20 09:44:33 -03:00
Arthur Bassi
959dda08fe fix(work-orders): stack week selector with day tabs in the filter bar
Keep SH-240 nav grouped under the type row and coerce the Live badge for JSX &&.
2026-08-20 09:40:02 -03:00
Arthur Bassi
0c75bd42f4 fix(work-orders): keep search and reschedule on the selected day (SH-240)
Empty search and Schedule On must follow the day-tab board, not the whole week or Unscheduled.
2026-08-20 09:21:45 -03:00
Alexandre Brandizzi
871b7b0964
Merge pull request #118 from Sea-Haven-Industries/feat/sh-245-login-parity
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
fix(login): design parity — official logo, h1 welcome heading, label colour (SH-245)
2026-08-19 14:55:17 -03:00
Arthur Bassi
8ab56d9782 refactor(work-orders): extract WoTable loading and empty shells
Keep the list table under the changed-file function length gate.
2026-08-19 14:48:04 -03:00
Arthur Bassi
2c14b08945 feat(work-orders): day-scoped week navigation (SH-240)
Let dispatchers pick one week and one weekday so the board
replaces rows instead of stacking the full week.
2026-08-19 14:45:59 -03:00
Codex Review Integration
90a5256dee fix(vendors): gate deactivation prompt on saved status (SH-255)
The drawer's Active toggle asked to deactivate whenever the switch moved
from on to off, reading the unsaved form value. An inactive vendor toggled
on and back off without saving therefore raised "Deactivate this vendor"
for a vendor that was already inactive.

Gate on the persisted technician status from the loaded roster instead,
falling back to the clicked row. Deactivation is a property of the saved
vendor, not of the toggle.
2026-08-19 13:18:17 -03:00
Adam Moussa
d5a04d318a
Merge branch 'dev' into feat/sh-245-login-parity 2026-08-19 11:03:37 -04:00
Alexandre Brandizzi
a71407b426
Merge branch 'dev' into feat/sh-250-fe-additive-add 2026-08-19 10:28:04 -03:00
Codex Review Integration
1305bf35dd fix(vendors): bypass the query cache when reloading after a conflict (SH-250)
The reload fix re-seeded the form from queryClient.fetchQuery, which inherits the
global 5-minute staleTime. A 409 never invalidates that key -
useSaveVendorCompanyRoster invalidates only in onSuccess and its onError returns
early for conflicts - and the Add flow has no mounted observer on it, so reload
returned the cached roster with the same stale rowVersion and every retry 409'd
again. Update mode was unaffected because it recovers via query.refetch(), which
bypasses staleTime.

Pass staleTime: 0 on that fetch, since its sole purpose is the freshest
rowVersion. The regression test uses a client with the app's real staleTime; the
default test client uses 0, which masked this entirely.
2026-08-19 10:26:28 -03:00
Alexandre Brandizzi
4b73af9688
Merge branch 'dev' into feat/sh-245-login-parity 2026-08-19 10:23:56 -03:00
Alexandre Brandizzi
0de695362a
Merge branch 'dev' into feat/sh-250-fe-additive-add 2026-08-18 17:46:49 -03:00
Alexandre Brandizzi
defdc4c75b
Merge branch 'dev' into feat/sh-245-login-parity 2026-08-18 17:46:44 -03:00
Codex Review Integration
9a066092b3 fix(vendors): correct additive add reload, phone and contact (SH-250, SH-246)
Three review findings on the additive add path:

- Create-mode reload refreshed selectedRoster (including rowVersion) but left
  the form company fields on their pre-conflict values, so a retry diffed stale
  fields against the reloaded roster and could overwrite the concurrent company
  update that caused the 409. Reload now resets the form from the fresh roster,
  matching the select path.
- mapVendorRosterAdditivePatchToBackend copied companyFields verbatim while the
  reconcile write mapper canonicalizes companyPhone, so PATCH and PUT could send
  different phone shapes for the same input.
- emptyRosterTechnician seeded preferredContact "Phone" even though that
  control was removed from the form, so filling the first blank row submitted a
  fabricated value. append already omitted it; both paths now agree.
2026-08-18 17:28:48 -03:00
Codex Review Integration
9624c264de fix(vendors): send only user-edited company fields on additive add (SH-250)
Adversarial review found a last-writer-wins window: after a stale 409 the
roster is refetched while the form keeps its pre-conflict values, so the
form-vs-roster diff resent our stale value for any company field another
user changed in that window, silently reverting their edit.

Transmit only fields the user actually edited, taken from react-hook-form
dirty state. Selecting a company calls reset(), so loaded values are never
dirty and only genuine edits are sent.

dirtyFields is resolved during render: formState is a Proxy that only tracks
properties read at render time, so reading it inside the submit callback
returned empty. The vendors E2E caught that; unit tests could not, since
they pass the flags in directly.
2026-08-18 14:42:01 -03:00
Codex Review Integration
4d1f02b87d test(vendors): e2e asserts additive PATCH and no destructive PUT from add flow (SH-250, SH-246) 2026-08-18 13:13:04 -03:00
Codex Review Integration
a710e21281 fix(vendors): additive technician add for existing companies (SH-250, SH-246) 2026-08-18 13:09:33 -03:00
Codex Review Integration
54c90e0e36 fix(login): design parity — official logo, h1 welcome heading, label color (SH-245) 2026-08-18 12:08:17 -03:00
39 changed files with 2195 additions and 346 deletions

View file

@ -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();

View 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);
});
});

View file

@ -0,0 +1,25 @@
import { useState } from "react";
import { BrandLockup } from "@/components/common/brand-lockup";
import { cn } from "@/lib/utils";
export type AuthBrandLogoProps = {
className?: string;
};
export function AuthBrandLogo({ className }: AuthBrandLogoProps) {
const [useFallback, setUseFallback] = useState(false);
if (useFallback) {
return <BrandLockup className={className} />;
}
return (
<img
src="/seahaven-logo.svg"
alt="Seahaven"
onError={() => setUseFallback(true)}
className={cn("mx-auto block h-10 w-auto", className)}
/>
);
}

View file

@ -11,14 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return (
<div className={cn(className)} {...props}>
<Text variant="title" className="m-0 text-center font-bold text-foreground">
<Text
variant="display"
className="m-0 text-center !font-bold ![font-size:var(--text-xl)] !leading-[1.5] text-foreground"
>
{title}
</Text>
<Text
variant="description"
tone="muted"
when={Boolean(subtitle)}
className="mb-6! mt-1.5! text-center font-normal"
className="mb-6! mt-1.5! text-center font-normal ![font-size:var(--text-base)]"
>
{subtitle}
</Text>

View file

@ -1,10 +1,10 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { Text } from "@/components/ui/text";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
@ -27,7 +27,7 @@ export default function LoginPage() {
return (
<AuthPageShell>
<BrandLockup className="mb-8" />
<AuthBrandLogo className="mb-8" />
<AuthCardHeader title="Welcome back" subtitle="Sign in to your account" />
<LoginForm
register={register}

View file

@ -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,
},
{

View file

@ -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;
})

View file

@ -262,10 +262,13 @@ function DrawerEditor({
const selectedIndex = technicians.findIndex(
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
);
const selectedTotalJobs =
roster?.technicians.find(
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
)?.totalJobs ?? vendor.totalJobs;
const persistedTechnician = roster?.technicians.find(
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
);
const selectedTotalJobs = persistedTechnician?.totalJobs ?? vendor.totalJobs;
// Deactivation is a property of the saved vendor, not of the toggle: flipping an
// inactive vendor on and back off without saving must not ask to deactivate it.
const persistedIsActive = persistedTechnician?.isActive ?? vendor.isActive;
if (form.isLoading) {
return <CircularProgress size={28} sx={{ m: "auto" }} />;
@ -318,7 +321,7 @@ function DrawerEditor({
checked={Boolean(field.value)}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (field.value && !checked) onRequestDeactivation(vendor);
if (persistedIsActive && !checked) onRequestDeactivation(vendor);
else field.onChange(checked);
}}
/>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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

View file

@ -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}
/>
);
}

View file

@ -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>

View file

@ -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,

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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}

View file

@ -18,7 +18,7 @@ type FormFieldProps = FormControlProps & {
const appearanceClassName: Record<FormFieldAppearance, string> = {
default: "gap-1",
auth: "gap-1.5 [&_.MuiFormLabel-root]:mb-0 [&_.MuiFormLabel-root]:!font-sans [&_.MuiFormLabel-root]:![font-size:var(--text-base-sm)] [&_.MuiFormLabel-root]:!font-medium [&_.MuiFormLabel-root]:!leading-[1.5] [&_.MuiFormLabel-root]:!normal-case [&_.MuiFormLabel-root]:!tracking-normal",
auth: "gap-1.5 [&_.MuiFormLabel-root]:mb-0 [&_.MuiFormLabel-root]:!font-sans [&_.MuiFormLabel-root]:![font-size:var(--text-base-sm)] [&_.MuiFormLabel-root]:!font-medium [&_.MuiFormLabel-root]:!leading-[1.5] [&_.MuiFormLabel-root]:!normal-case [&_.MuiFormLabel-root]:!text-foreground [&_.MuiFormLabel-root]:!tracking-normal",
};
const appearanceLabelClassName: Record<FormFieldAppearance, string> = {

View file

@ -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;
}
},
};

View file

@ -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 {

View file

@ -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,
};

View file

@ -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) => {

View 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)),
};
}

View file

@ -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");
});
});

View file

@ -1,4 +1,5 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer";
import { renderWithProviders } from "@/test/test-utils";
@ -213,3 +214,60 @@ describe("VendorDetailDrawer design parity", () => {
expect(screen.queryByText("Status")).not.toBeInTheDocument();
});
});
describe("VendorDetailDrawer deactivation prompt", () => {
const inactiveVendor: VendorListItem = { ...vendor, isActive: false };
it("does not prompt to deactivate a saved-inactive vendor toggled on and back off", async () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: false, totalJobs: 5 },
]),
);
const onRequestDeactivation = vi.fn();
renderWithProviders(
<VendorDetailDrawer
open={true}
vendor={inactiveVendor}
startInEdit={true}
onRequestDeactivation={onRequestDeactivation}
onClose={vi.fn()}
/>,
{ route: "/vendors", withAuth: false },
);
const toggle = screen.getByRole("switch", { name: "Active status" });
await userEvent.click(toggle);
expect(toggle).toBeChecked();
await userEvent.click(toggle);
expect(toggle).not.toBeChecked();
expect(onRequestDeactivation).not.toHaveBeenCalled();
});
it("prompts to deactivate a saved-active vendor when the toggle is switched off", async () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
]),
);
const onRequestDeactivation = vi.fn();
renderWithProviders(
<VendorDetailDrawer
open={true}
vendor={vendor}
startInEdit={true}
onRequestDeactivation={onRequestDeactivation}
onClose={vi.fn()}
/>,
{ route: "/vendors", withAuth: false },
);
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
expect(onRequestDeactivation).toHaveBeenCalledWith(vendor);
});
});

View file

@ -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(),

View file

@ -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();
});
});

View 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");
});
});

View file

@ -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();
});
});

View file

@ -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 () => {

View file

@ -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();
});
});

View file

@ -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();

View file

@ -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",

View file

@ -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";
});
});
});

View 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();
});
});

View 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);
});
});