Compare commits

...

30 commits

Author SHA1 Message Date
arthur.bassi
2e1c46f178 fix(vendors): restore filter drawer labels for e2e and visual CI
Use VendorFilterOptions again and keep the Apply filters footer label expected
by vendor Playwright specs and the committed visual baseline.
2026-08-14 09:42:18 -03:00
arthur.bassi
90546c56c3 chore(work-orders): refresh platform-polish stack with dev for PR #100
Integrate latest dev (post-#99) while preserving account-scoped query broadcast,
auth cache teardown, and wizard lifecycleStatus create mapping.
2026-08-14 09:27:33 -03:00
Arthur Bassi
ee90d73a44
feat(work-orders): board wizard create with service notes (#99)
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): board wizard create with service notes

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(work-orders): enforce single-service wizard create per SH-118

Use single service selection in the wizard and omit extraServices from board create.

* refactor(work-orders): extract wizard controller for maintainability gate

Move duplicate-WO wiring and lookup hooks out of NewWoWizard so the
changed-file function line cap passes after merging dev.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 18:43:11 -03:00
Arthur Bassi
25837171a4
feat(work-orders): duplicate WO number dialog on table and wizard (SH-189) (#90)
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
* docs(work-orders): add pending BE contract checklist for design parity gaps

* feat(work-orders): confirm dialog when duplicate wo number detected

* fix(work-orders): show duplicate wo number dialog on wizard create

* docs(work-orders): align pr description with jira and qa residuals

* style(work-orders): prettier wizard duplicate dialog files

* refactor(workorders): extract wizard state hook and list overlays

* fix(work-orders): check duplicate numbers globally

* fix(work-orders): exhaust fuzzy pages for exact WO number lookup

Prevent accepting duplicates that fall outside the first fuzzy search page.

* style(work-orders): format editable WO number cell for Prettier gate

* fix(work-orders): 0-based WO search page and stale duplicate guard

* fix(work-orders): sync draft WO# ref in effect for duplicate guard [SH-189]

* fix(work-orders): extract WO number editor under max-lines gate

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(work-orders): harden duplicate WO number checks [SH-189]

* fix(work-orders): sync wizard refs in effects and exhaust duplicate WO lookup

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(work-orders): extract hooks to satisfy max-lines governance gate

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(work-orders): format wizard state hook for Prettier gate

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(work-orders): search one-character WO numbers for duplicates

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:04:48 -03:00
Alexandre Brandizzi
4e189e903f
Merge pull request #115 from Sea-Haven-Industries/fix/vendor-portal-locked-banner-copy
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(vendor-portal): accurate locked banner copy by dispatch status
2026-08-12 12:29:31 -03:00
Alexandre Brandizzi
778472d1d8
Merge branch 'dev' into fix/vendor-portal-locked-banner-copy 2026-08-12 12:23:23 -03:00
Alexandre Brandizzi
011bf0ae8a
Merge pull request #108 from Sea-Haven-Industries/feature/wo-board-completed-date-column
feat(work-orders): editable completed date board column (SH-216)
2026-08-12 12:23:02 -03:00
Alexandre Brandizzi
449ec647b0
Merge branch 'dev' into feature/wo-board-completed-date-column 2026-08-12 12:17:36 -03:00
Alexandre Brandizzi
489469d330
Merge pull request #103 from Sea-Haven-Industries/fix/SH-109-inline-save-unscheduled
fix(work-orders): allow inline save without Schedule On (SH-109)
2026-08-12 12:17:15 -03:00
Alexandre Brandizzi
5419edc4af
Merge branch 'dev' into fix/SH-109-inline-save-unscheduled 2026-08-12 12:11:17 -03:00
Alexandre Brandizzi
a2231b6704
Merge pull request #98 from Sea-Haven-Industries/feature/wo-slide-over-media
feat(work-orders): board slide-over detail with comments, audit and media
2026-08-12 12:10:35 -03:00
Arthur Bassi
5d3fd1f986 fix(vendor-portal): align locked banner with comments-only actions 2026-08-12 11:25:41 -03:00
Arthur Bassi
abc63dc70f fix(work-orders): restore pending BE contract ledger 2026-08-12 10:58:15 -03:00
arthur.bassi
fd2b34a1cc fix(work-orders): dedupe completedDate after merging PR #45
Keep a single fixture property and board op rule so the integrated head typechecks (TS1117).
2026-08-12 09:50:03 -03:00
Arthur Bassi
2ce7210668 fix(vendor-portal): accurate locked banner copy by dispatch status 2026-08-12 09:39:16 -03:00
arthur.bassi
eadb0bda8b chore(work-orders): merge origin/dev into completedDate column branch 2026-08-12 09:35:54 -03:00
Arthur Bassi
4440f7d087 chore(work-orders): merge origin/dev into SH-109 inline save branch
EOF
2026-08-12 09:33:49 -03:00
Arthur Bassi
11e1b65bc1 fix(work-orders): pass workOrderVersion in slide-over media and fix test fixture
Wire workOrderVersion through slide-over media delete/categorize after dev
merge, and remove duplicate completedDate in unscheduled highlight fixture.
2026-08-11 16:55:31 -03:00
Arthur Bassi
93c5a35b46 chore: merge origin/dev into slide-over media branch
Sync with latest dev (CompDoc/completedDate from PR #45) and resolve
conflicts while preserving slide-over detail, workOrderVersion on media
mutations, and the consolidated list page hook.
2026-08-11 16:46:32 -03:00
arthur.bassi
29580c6add refactor(work-orders): shrink inline row cells under function line cap
Extract muted dash placeholders so governance max-lines-per-function passes.
2026-08-11 15:44:36 -03:00
arthur.bassi
8398a08b76 fix(work-orders): add completedDate to unscheduled highlight fixture
Unblocks tsc after merging origin/dev with the required table-row field.
2026-08-11 15:41:49 -03:00
arthur.bassi
94f07fb66f chore(work-orders): merge origin/dev into completedDate column branch
Align merge-base so vendor files matching dev drop out of the PR diff.
2026-08-11 15:31:57 -03:00
arthur.bassi
c9637afbd8 fix(work-orders): drop unrelated vendor changes from completedDate PR
Keep SH-216 scoped to the completedDate board column only.
2026-08-11 15:29:44 -03:00
Arthur Bassi
f666ee215d test(work-orders): cover site dialog POC autofill races 2026-08-11 15:15:02 -03:00
arthur.bassi
4637f3ab14 chore: merge origin/dev into slide-over media branch
Sync with latest dev and add completedDate to the unscheduled highlight
fixture so the merge build stays type-safe.
2026-08-11 14:02:42 -03:00
arthur.bassi
251edd9c61 fix(work-orders): drop vendor collateral from slide-over PR
Restore vendors and page-header to origin/dev so this PR stays the WO
slide-over/media slice only. Remove stale godfile baseline entries now
under the 500-line cap.
2026-08-11 13:54:31 -03:00
arthur.bassi
5108f0e816 fix(work-orders): preserve dirty POC fields during site autofill
Only autofill empty POC name/phone when location detail resolves so createMode edits typed before the request finishes are not overwritten.
2026-08-10 19:09:22 -03:00
arthur.bassi
d52df52de6 fix(vendors): restore blank-tech filter and deactivation gate
Omit empty new technician rows on save and route Active to Inactive through VendorDeactivationDialog, blocking sparse isActive:false updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 19:08:01 -03:00
Arthur Bassi
0ce63b9740 test(work-orders): cover completed date board read mapping
[recover] remove malicious eslint payload (was 0548bbb4)
2026-08-10 17:12:11 -03:00
Arthur Bassi
ebc2e9a1e5 fix(work-orders): extract inline dialog state and enable Schedule On clear
[recover] remove malicious eslint payload (was 552c2daf)
2026-08-10 17:12:09 -03:00
65 changed files with 3396 additions and 1259 deletions

View file

@ -306,11 +306,18 @@ test.describe("Vendor directory prototype parity", () => {
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("font-family", /Montserrat/);
await expect(heading).toHaveCSS("font-size", "30px");
await expect(heading).toHaveCSS("font-weight", "800");
await expect(heading).toHaveCSS("line-height", "33px");
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => {
"Phone",
"Email",
"Total Jobs",
"Status",
]) {
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
}
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
47,
);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible();
await expect(page.getByText("2 active technicians")).toBeVisible();
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
true,
);
await page.getByPlaceholder("Search name, company, trade...").fill("clayton");
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await page.getByPlaceholder("Search name, company, trade...").clear();
await page.getByPlaceholder("Search name, company, trade…").clear();
await page.getByRole("button", { name: "Open vendor filters" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
await page.getByRole("button", { name: /Company/ }).click();
await page.getByPlaceholder("All companies").click();
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
await page.getByRole("button", { name: "Apply" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
expect(
state.listUrls.some(
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
),
).toBe(true);
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
"history will be preserved",
);
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
await expect(page.getByText("No inactive technicians found")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "+ Add Vendor" }).click();
await expect(
page.getByText("Register a company and add zero, one, or multiple technicians."),
).toBeVisible();
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"https://maps.google.com/gateway",
);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await page.getByRole("button", { name: "Add technician" }).click();
await page.getByLabel("Technician Name").last().fill("New Technician");
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
await tradeInput.fill("HVAC");
await tradeInput.press("Enter");
await tradeInput.fill("Plumbing");
await tradeInput.press("Enter");
await page.getByRole("button", { name: "Move Plumbing up" }).click();
await page.getByLabel("Notes").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
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",
@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => {
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => {
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByLabel("Notes").fill("Updated in browser E2E");
await page.getByRole("button", { name: "Save" }).click();
await expect(page).toHaveURL(/\/vendors$/);
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
});
@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page, { legacyGateway: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (2)" }).click();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByRole("switch", { name: "Inactive" }).first().click();
await page.getByRole("button", { name: "Save" }).click();
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
await addButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => {
await expect(addButton).toBeFocused();
await addButton.click();
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await page.getByLabel("Company Phone (optional)").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => {
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => {
const racedState = await mockVendorApi(page, { deleteConflict: true });
await page.reload();
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
await page
.getByRole("dialog", { name: "Deactivate Vendor" })
.getByRole("button", {
@ -555,6 +574,7 @@ test.describe("Vendor directory prototype parity", () => {
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
await page.getByRole("button", { name: "Open vendor filters" }).click();
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
await expect(filterDrawer).toBeVisible();

View file

@ -2,11 +2,5 @@
"version": 1,
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
"maxFileLines": 500,
"godfileDebt": [
{
"path": "src/app/(protected)/vendors/index.tsx",
"maxLines": 612,
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
}
]
"godfileDebt": []
}

View file

@ -15,9 +15,7 @@ export interface VendorDeactivationState {
confirm: () => void;
}
export function useVendorDeactivation(options?: {
onSuccess?: () => void;
}): VendorDeactivationState {
export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
const [target, setTarget] = useState<VendorListItem | null>(null);
const [error, setError] = useState<string | null>(null);
const deleteVendor = useDeleteVendor();
@ -43,7 +41,7 @@ export function useVendorDeactivation(options?: {
deleteVendor.mutate(target.id, {
onSuccess: () => {
setTarget(null);
options?.onSuccess?.();
onSuccess?.();
},
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
});

View file

@ -31,7 +31,7 @@ export interface VendorListState {
export function useVendorListState(): VendorListState {
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [pageSize, setPageSize] = useState(48);
const [isActive, setIsActive] = useState(true);
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);

View file

@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
import {
emptyVendorCompanyRosterForm,
emptyRosterTechnician,
vendorCompanyRosterSchema,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
@ -43,6 +44,7 @@ export interface VendorRosterFormProps {
vendorId?: string | number;
companyId?: string | number;
onSuccess?: (roster: VendorCompanyRoster) => void;
startWithTechnician?: boolean;
}
export interface VendorRosterForm {
@ -73,6 +75,7 @@ export function useVendorRosterForm({
vendorId,
companyId,
onSuccess,
startWithTechnician = false,
}: VendorRosterFormProps): VendorRosterForm {
const query = useVendorCompanyRoster(
mode === "update" ? vendorId : undefined,
@ -87,9 +90,16 @@ export function useVendorRosterForm({
const trades = useMemo(() => facets?.trades ?? [], [facets]);
const resolver = useVendorRosterResolver(routeRoster);
const createDefaults = useMemo(
() => ({
...emptyVendorCompanyRosterForm,
technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [],
}),
[startWithTechnician],
);
const form = useForm<VendorCompanyRosterFormValues>({
resolver,
defaultValues: emptyVendorCompanyRosterForm,
defaultValues: createDefaults,
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
mode: "onChange",
});
@ -114,10 +124,10 @@ export function useVendorRosterForm({
}, [routeRoster, watched]);
const resetForm = useCallback(() => {
reset(emptyVendorCompanyRosterForm);
reset(createDefaults);
resetSelection();
clearConflict();
}, [clearConflict, reset, resetSelection]);
}, [clearConflict, createDefaults, reset, resetSelection]);
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
const isUpdate = mode === "update" || selection.selectedRoster != null;

View file

@ -8,8 +8,8 @@ import {
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
@ -21,7 +21,11 @@ interface VendorCreateModalProps {
}
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
const form = useVendorRosterForm({
mode: "create",
onSuccess: onClose,
startWithTechnician: true,
});
const { resetForm } = form;
useEffect(() => {
@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
open={open}
onClose={form.isSaving ? undefined : onClose}
fullWidth
maxWidth="md"
maxWidth="sm"
aria-labelledby="vendor-create-dialog-title"
slotProps={{ paper: { role: "dialog" } }}
slotProps={{
paper: {
role: "dialog",
sx: { maxHeight: "88vh", borderRadius: 2 },
},
}}
>
<DialogTitle id="vendor-create-dialog-title">
Add Vendor Company
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Register a company and add zero, one, or multiple technicians.
</Typography>
<Text variant="title" as="span">
Add Vendor
</Text>
<Text variant="description" tone="muted" sx={{ mt: 0.5 }}>
Register a vendor company and its technicians.
</Text>
</DialogTitle>
<DialogContent>
<DialogContent sx={{ maxHeight: "55vh" }}>
<Stack spacing={2} className="mt-1">
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
variant="contained"
type="submit"
form="vendor-create-form"
disabled={form.isSaving || !form.isValid}
disabled={form.isSaving}
>
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"}
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor"}
</Button>
</DialogActions>
</Dialog>

View file

@ -1,5 +1,5 @@
import { useEffect, type ReactNode } from "react";
import { useNavigate } from "react-router";
import { useEffect, useState, type ReactNode } from "react";
import { Controller, useWatch } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
@ -13,15 +13,22 @@ import {
Drawer,
IconButton,
Link,
List,
ListItem,
Stack,
Typography,
Switch,
} from "@mui/material";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorRosterForm } from "./use-vendor-roster-form";
import { Text } from "@/components/ui/text";
import type {
VendorCompanyRoster,
VendorListItem,
VendorRosterTechnician,
} from "@/domain/vendors/types/vendor";
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
const DRAWER_WIDTH = 440;
const DRAWER_WIDTH = 420;
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] {
interface VendorDetailDrawerProps {
open: boolean;
vendor: VendorListItem | null;
startInEdit?: boolean;
onRequestDeactivation?: (vendor: VendorListItem) => void;
onClose: () => void;
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<Typography
variant="subtitle2"
className="font-semibold uppercase"
sx={{ color: "text.secondary", mb: 1 }}
<Text
variant="label"
tone="muted"
className="font-semibold tracking-[0.06em] uppercase"
sx={{ mb: 1 }}
>
{children}
</Typography>
</Text>
);
}
function Field({ label, value }: { label: string; value: string }) {
const display = value && value.trim() !== "" ? value : "—";
function Field({ label, value }: { label: string; value?: string }) {
const display = !value || value.trim() === "" ? "—" : value;
return (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">{display}</Typography>
</Text>
<Text variant="description">{display}</Text>
</Box>
);
}
function TechnicianEntry({
contactName,
phone,
email,
preferredContact,
tradeSpecialties,
isActive,
totalJobs,
}: {
contactName: string;
phone: string;
email: string;
preferredContact?: string;
tradeSpecialties: string;
isActive: boolean;
totalJobs: number;
}) {
const trades = splitTrades(tradeSpecialties);
return (
<ListItem disableGutters divider>
<Stack spacing={0.5} className="w-full">
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Typography variant="body2" className="font-semibold">
{contactName || "—"}
</Typography>
<Chip
size="small"
label={isActive ? "Active" : "Inactive"}
color={isActive ? "success" : "default"}
/>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{totalJobs} jobs
</Typography>
</Stack>
<Stack direction="row" spacing={2} className="flex flex-wrap">
{Boolean(phone) && (
<Link href={`tel:${phone}`} underline="hover">
{phone}
</Link>
)}
{Boolean(email) && (
<Link href={`mailto:${email}`} underline="hover">
{email}
</Link>
)}
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
{Boolean(preferredContact) && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Prefers {preferredContact}
</Typography>
)}
{trades.map((trade, index) => (
<Chip
key={`${trade}-${index}`}
size="small"
variant="outlined"
label={index === 0 ? `${trade} (primary)` : trade}
/>
))}
</Stack>
</Stack>
</ListItem>
);
}
function DrawerHeader({
roster,
vendor,
technician,
onClose,
}: {
roster?: VendorCompanyRoster;
vendor: VendorListItem | null;
technician?: VendorRosterTechnician;
onClose: () => void;
}) {
return (
@ -141,23 +87,18 @@ function DrawerHeader({
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 3,
px: 2.5,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box>
<Typography
id="vendor-detail-drawer-title"
variant="subtitle1"
component="h2"
className="font-semibold"
>
{roster?.name || vendor?.companyName || "Vendor company"}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
</Typography>
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
{technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
</Text>
<Text variant="description" tone="muted">
{roster?.name || vendor?.companyName || "Vendor details"}
</Text>
</Box>
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
@ -167,18 +108,19 @@ function DrawerHeader({
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
const mapsUrl = roster.googleMapsUrl;
const address = [roster.address, roster.city, roster.state, roster.zip]
.filter(Boolean)
.join(", ");
return (
<Stack spacing={1.5}>
<Field label="Company" value={roster.name} />
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Email" value={roster.email} />
<Field
label="Address"
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
/>
{Boolean(mapsUrl) && (
<Field label="Company Email" value={roster.email} />
<Field label="Address" value={address} />
{Boolean(roster.googleMapsUrl) && (
<Link
href={mapsUrl}
href={roster.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
@ -191,49 +133,79 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
);
}
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
const trades = splitTrades(technician.tradeSpecialties);
return (
<Box>
<SectionTitle>Technicians</SectionTitle>
{roster.technicians.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No technicians assigned to this company.
</Typography>
) : (
<List disablePadding>
{roster.technicians.map((technician) => (
<TechnicianEntry
key={String(technician.id ?? technician.contactName)}
contactName={technician.contactName}
phone={technician.phone}
email={technician.email}
preferredContact={technician.preferredContact}
tradeSpecialties={technician.tradeSpecialties}
isActive={technician.isActive}
totalJobs={technician.totalJobs}
<Stack spacing={1.5}>
<Field label="Phone" value={technician.phone} />
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
Trade
</Text>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
{trades.length > 0 ? (
trades.map((trade, index) => (
<Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
))
) : (
<Text variant="description">—</Text>
)}
</Stack>
</Box>
<Stack direction="row" spacing={4} sx={{ alignItems: "flex-end" }}>
<Box>
<Text variant="title">{technician.totalJobs ?? 0}</Text>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
Total Jobs
</Text>
</Box>
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.5 }}>
Status
</Text>
<Chip
size="small"
label={technician.isActive ? "Active" : "Inactive"}
color={technician.isActive ? "success" : "default"}
/>
))}
</List>
)}
</Box>
</Stack>
</Stack>
</Box>
);
}
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
function DrawerBody({
roster,
technician,
}: {
roster: VendorCompanyRoster;
technician?: VendorRosterTechnician;
}) {
return (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
<SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
<Box>
<SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
</Box>
<Divider />
<TechniciansSection roster={roster} />
{technician ? (
<TechnicianSection technician={technician} />
) : (
<Text variant="description" tone="muted">
No technicians assigned to this company.
</Text>
)}
{Boolean(roster.notes) && (
<>
<Divider />
<Box>
<SectionTitle>Notes</SectionTitle>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
{roster.notes}
</Typography>
</Text>
</Box>
</>
)}
@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
<Stack
direction="row"
sx={{
px: 3,
py: 2,
px: 2.5,
py: 1.5,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
alignItems: "center",
justifyContent: "flex-end",
@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
);
}
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
const navigate = useNavigate();
function DrawerEditor({
vendor,
onClose,
onSaved,
onRequestDeactivation = () => undefined,
}: {
vendor: VendorListItem;
onClose: () => void;
onSaved: () => void;
onRequestDeactivation: (vendor: VendorListItem) => void;
}) {
const form = useVendorRosterForm({
mode: "update",
vendorId: vendor.id ?? undefined,
companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined,
onSuccess: onSaved,
});
const roster = form.roster;
const technicians = useWatch({ control: form.control, name: "technicians" }) ?? [];
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;
if (form.isLoading) {
return <CircularProgress size={28} sx={{ m: "auto" }} />;
}
if (form.isError || !roster) {
return (
<Alert severity="error" sx={{ m: 2 }}>
Failed to load vendor company. Try again.
</Alert>
);
}
return (
<Box
component="form"
id="vendor-drawer-edit-form"
onSubmit={form.handleSubmit(form.submit)}
className="flex min-h-0 flex-1 flex-col"
>
<Stack spacing={2} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
)}
{form.conflict !== null && (
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
)}
<VendorRosterFormFields
control={form.control}
errors={form.errors}
tradeOptions={form.trades}
showTechnicianStatus={false}
/>
{selectedIndex >= 0 && (
<>
<Divider />
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Box>
<Text variant="title">{selectedTotalJobs}</Text>
<Text variant="caption" tone="muted">
Total Jobs
</Text>
</Box>
<Controller
control={form.control}
name={`technicians.${selectedIndex}.isActive`}
render={({ field }) => (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text variant="description">{field.value ? "Active" : "Inactive"}</Text>
<Switch
checked={Boolean(field.value)}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (field.value && !checked) onRequestDeactivation(vendor);
else field.onChange(checked);
}}
/>
</Stack>
)}
/>
</Stack>
</>
)}
</Stack>
<Stack
direction="row"
spacing={1.5}
sx={{ p: 2, borderTop: 1, borderColor: "divider", justifyContent: "flex-end" }}
>
<Button onClick={onClose} disabled={form.isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
{form.isSaving ? "Saving..." : "Save changes"}
</Button>
</Stack>
</Box>
);
}
function resolveTechnician(
roster: VendorCompanyRoster | undefined,
vendor: VendorListItem | null,
): VendorRosterTechnician | undefined {
if (!roster || !vendor) return undefined;
const match = roster.technicians.find(
(technician) =>
vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id),
);
if (match) return match;
if (!vendor.contactName) return undefined;
return {
id: vendor.id ?? undefined,
contactName: vendor.contactName,
phone: vendor.phone,
email: vendor.email,
tradeSpecialties: vendor.tradeSpecialties,
isActive: vendor.isActive,
totalJobs: vendor.totalJobs,
};
}
export function VendorDetailDrawer({
open,
vendor,
startInEdit = false,
onRequestDeactivation = () => undefined,
onClose,
}: VendorDetailDrawerProps) {
const [isEditing, setIsEditing] = useState(startInEdit);
const vendorId = vendor?.id;
const companyId = vendor?.companyId;
const hasVendorId = vendorId != null;
@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
!hasVendorId ? (companyId ?? undefined) : undefined,
);
const editHref = hasVendorId
? `/vendors/edit/${vendorId}`
: `/vendors/company/${companyId ?? ""}`;
const selectedTechnician = resolveTechnician(roster, vendor);
useEffect(() => {
if (open) setIsEditing(startInEdit);
}, [open, startInEdit, vendor]);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "Escape" && !isEditing) onClose();
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [onClose, open]);
}, [isEditing, onClose, open]);
return (
<Drawer
open={open}
onClose={onClose}
onClose={(_event, reason) => {
if (reason === "escapeKeyDown" && isEditing) return;
onClose();
}}
anchor="right"
aria-labelledby="vendor-detail-drawer-title"
ModalProps={{ keepMounted: false }}
@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
}}
>
<Stack className="flex h-full flex-col">
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
<DrawerHeader
roster={roster}
vendor={vendor}
technician={selectedTechnician}
onClose={onClose}
/>
{isLoading ? (
{isEditing && vendor ? (
<DrawerEditor
vendor={vendor}
onClose={onClose}
onSaved={() => setIsEditing(false)}
onRequestDeactivation={onRequestDeactivation}
/>
) : isLoading ? (
<Box className="flex flex-1 items-center justify-center">
<CircularProgress size={28} />
</Box>
@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
</Alert>
) : roster ? (
<>
<DrawerBody roster={roster} />
<DrawerActions onEdit={() => navigate(editHref)} />
<DrawerBody roster={roster} technician={selectedTechnician} />
<DrawerActions onEdit={() => setIsEditing(true)} />
</>
) : (
<Box className="flex flex-1 items-center justify-center">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
<Text variant="description" tone="muted">
No vendor selected.
</Typography>
</Text>
</Box>
)}
</Stack>

View file

@ -0,0 +1,252 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Stack,
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import type { VendorFilters } from "./vendor-filters";
type DraftSetter = Dispatch<SetStateAction<VendorFilters>>;
function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
function CompanyFilter({
options,
draft,
setDraft,
}: {
options: VendorFacetCompany[];
draft: VendorFilters;
setDraft: DraftSetter;
}) {
const allSelected = draft.companies.length === options.length && options.length > 0;
return (
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Select vendor companies
</Typography>
<Button
size="small"
onClick={() =>
setDraft((current) => ({
...current,
companies: allSelected ? [] : options.map((company) => company.name),
}))
}
>
{allSelected ? "Clear all" : "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={options}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={options.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
);
}
function TradeFilter({ options, draft, setDraft }: FilterProps<string>) {
return (
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={options}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
)}
/>
</FilterSection>
);
}
interface FilterProps<T> {
options: T[];
draft: VendorFilters;
setDraft: DraftSetter;
}
function LocationFilter({ options, draft, setDraft }: FilterProps<VendorFacetLocation>) {
return (
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={options}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={options.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
);
}
function JobCountFilter({ options, draft, setDraft }: FilterProps<VendorFacetJobBucket>) {
return (
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={options}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={options.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
);
}
export function VendorFilterOptions({
facets,
draft,
setDraft,
}: {
facets: VendorFacets | undefined;
draft: VendorFilters;
setDraft: DraftSetter;
}) {
return (
<>
<CompanyFilter options={facets?.companies ?? []} draft={draft} setDraft={setDraft} />
<TradeFilter options={facets?.trades ?? []} draft={draft} setDraft={setDraft} />
<LocationFilter options={facets?.locations ?? []} draft={draft} setDraft={setDraft} />
<JobCountFilter options={facets?.jobBuckets ?? []} draft={draft} setDraft={setDraft} />
</>
);
}

View file

@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
import type { VendorFacets } from "@/domain/vendors/types/vendor";
import { VendorFilterOptions } from "./vendor-filter-options";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
interface VendorFiltersDrawerProps {
open: boolean;
@ -30,11 +30,6 @@ export function VendorFiltersDrawer({
}
}, [open, appliedFilters]);
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
const activeCount =
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
@ -84,16 +79,7 @@ export function VendorFiltersDrawer({
</Stack>
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
<VendorFiltersDrawerFields
draft={draft}
setDraft={setDraft}
options={{
companies: companyOptions,
trades: tradeOptions,
locations: locationOptions,
jobBuckets: jobBucketOptions,
}}
/>
<VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
</Box>
<Stack
@ -109,7 +95,7 @@ export function VendorFiltersDrawer({
Clear all
</Button>
<Button variant="contained" onClick={handleApply} fullWidth>
Apply
Apply filters
</Button>
</Stack>
</Stack>

View file

@ -1,10 +1,4 @@
import {
Controller,
useFieldArray,
useWatch,
type Control,
type FieldErrors,
} from "react-hook-form";
import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form";
import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import {
@ -34,7 +28,7 @@ interface VendorRosterFormFieldsProps {
selectedCompanyId?: string | number | null;
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
onClearSelectedCompany?: (nextName?: string) => void;
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
showTechnicianStatus?: boolean;
}
function CompanyNameField({
@ -61,6 +55,7 @@ function CompanyNameField({
<TextField
{...field}
label="Company"
placeholder="Start typing a company…"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
@ -105,8 +100,9 @@ function CompanyNameField({
{...params}
label="Company"
required
placeholder="Start typing a company…"
error={Boolean(errors.name)}
helperText={errors.name?.message ?? "Choose an existing company or enter a new one"}
helperText={errors.name?.message}
inputRef={field.ref}
/>
)}
@ -126,10 +122,7 @@ function CompanyFields({
}: VendorRosterFormFieldsProps) {
return (
<Box>
<Typography variant="subtitle2" className="font-semibold uppercase">
Company
</Typography>
<Stack spacing={2} className="mt-2">
<Stack spacing={2}>
<CompanyNameField
control={control}
errors={errors}
@ -145,13 +138,13 @@ function CompanyFields({
render={({ field }) => (
<TextField
{...field}
label="Company Phone"
placeholder="(XXX) XXX-XXXX"
label="Company Phone (optional)"
placeholder="(214) 555-0100"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message ?? "Phone or email required"}
helperText={errors.companyPhone?.message}
fullWidth
/>
)}
@ -162,7 +155,8 @@ function CompanyFields({
render={({ field }) => (
<TextField
{...field}
label="Email"
label="Email (optional)"
placeholder="dispatch@vendor.com"
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
@ -174,9 +168,16 @@ function CompanyFields({
<Controller
control={control}
name="address"
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
render={({ field }) => (
<TextField
{...field}
label="Address (optional)"
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
fullWidth
/>
)}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
<Controller
control={control}
name="city"
@ -201,10 +202,10 @@ function CompanyFields({
render={({ field }) => (
<TextField
{...field}
label="Google Maps URL"
placeholder="https://maps.google.com/..."
label="Google Maps URL (optional)"
placeholder="https://maps.google.com/…"
error={Boolean(errors.googleMapsUrl)}
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"}
helperText={errors.googleMapsUrl?.message}
fullWidth
/>
)}
@ -213,7 +214,14 @@ function CompanyFields({
control={control}
name="notes"
render={({ field }) => (
<TextField {...field} label="Notes" multiline minRows={2} fullWidth />
<TextField
{...field}
label="Notes (optional)"
placeholder="Internal notes, scheduling preferences, certifications…"
multiline
minRows={2}
fullWidth
/>
)}
/>
</Stack>
@ -228,7 +236,7 @@ interface TechnicianRowProps {
onRemove: () => void;
canRemove: boolean;
tradeOptions: string[];
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
showStatus: boolean;
}
function TechnicianRow({
@ -238,15 +246,13 @@ function TechnicianRow({
onRemove,
canRemove,
tradeOptions,
onRequestDeactivate,
showStatus,
}: TechnicianRowProps) {
const technician = useWatch({ control, name: `technicians.${index}` });
return (
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="subtitle2" className="font-semibold">
Technician {index + 1}
{index + 1} - TECHNICIAN
</Typography>
<Tooltip title="Remove technician">
<span>
@ -267,7 +273,8 @@ function TechnicianRow({
render={({ field }) => (
<TextField
{...field}
label="Technician Name"
label="Technician name (optional)"
placeholder="Technician name"
error={Boolean(error?.contactName)}
helperText={error?.contactName?.message}
fullWidth
@ -281,8 +288,8 @@ function TechnicianRow({
render={({ field }) => (
<TextField
{...field}
label="Technician Phone"
placeholder="(XXX) XXX-XXXX"
label="Technician phone (optional)"
placeholder="(000) 000-0000"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
@ -303,32 +310,29 @@ function TechnicianRow({
error={Boolean(error?.email)}
helperText={error?.email?.message}
fullWidth
sx={{ display: "none" }}
/>
)}
/>
</Stack>
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
<Controller
control={control}
name={`technicians.${index}.isActive`}
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value}
onChange={(_event, checked) => {
if (!checked && technician?.id != null && onRequestDeactivate != null) {
onRequestDeactivate(technician);
return;
}
field.onChange(checked);
}}
/>
}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
{showStatus && (
<Controller
control={control}
name={`technicians.${index}.isActive`}
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value}
onChange={(_event, checked) => field.onChange(checked)}
/>
}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
)}
</Stack>
);
}
@ -337,12 +341,12 @@ function TechniciansFieldArray({
control,
errors,
tradeOptions,
onRequestDeactivate,
showStatus,
}: {
control: Control<VendorCompanyRosterFormValues>;
errors: FieldErrors<VendorCompanyRosterFormValues>;
tradeOptions: string[];
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
showStatus: boolean;
}) {
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
@ -385,7 +389,7 @@ function TechniciansFieldArray({
onRemove={() => remove(index)}
canRemove
tradeOptions={tradeOptions}
onRequestDeactivate={onRequestDeactivate}
showStatus={showStatus}
/>
))
)}
@ -395,7 +399,7 @@ function TechniciansFieldArray({
}
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
const { control, errors, tradeOptions = [], onRequestDeactivate } = props;
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
return (
<Stack spacing={3}>
<CompanyFields {...props} />
@ -404,7 +408,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
control={control}
errors={errors}
tradeOptions={tradeOptions}
onRequestDeactivate={onRequestDeactivate}
showStatus={showTechnicianStatus}
/>
</Stack>
);

View file

@ -1,44 +1,17 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { VendorDeactivationDialog } from "./vendor-deactivation-dialog";
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorDeactivation } from "./use-vendor-deactivation";
import { useVendorRosterForm } from "./use-vendor-roster-form";
import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
interface VendorRosterPageProps {
vendorId?: string;
companyId?: string;
}
function toDeactivationListItem(
roster: VendorCompanyRoster,
technician: RosterTechnicianValues,
): VendorListItem {
return {
id: technician.id ?? null,
companyId: roster.companyId,
companyName: roster.name,
contactName: technician.contactName,
email: technician.email,
phone: technician.phone,
companyPhone: roster.companyPhone,
googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
totalJobs: 0,
city: roster.city,
state: roster.state,
tradeSpecialties: technician.tradeSpecialties,
isActive: technician.isActive,
preferredContact: technician.preferredContact ?? "Phone",
};
}
function PageShell({
title,
subtitle,
@ -101,9 +74,6 @@ function submitLabel(isSaving: boolean, isEdit: boolean): string {
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined;
const deactivation = useVendorDeactivation({
onSuccess: () => navigate("/vendors"),
});
const form = useVendorRosterForm({
mode: isEdit ? "update" : "create",
vendorId,
@ -111,11 +81,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSuccess: () => navigate("/vendors"),
});
const handleRequestDeactivate = (technician: RosterTechnicianValues) => {
if (!form.roster || technician.id == null) return;
deactivation.open(toDeactivationListItem(form.roster, technician));
};
if (isEdit && form.isLoading) {
return (
<Box className="flex justify-center p-10">
@ -159,7 +124,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
control={form.control}
errors={form.errors}
tradeOptions={form.trades}
onRequestDeactivate={isEdit ? handleRequestDeactivate : undefined}
{...companySelectionProps}
/>
@ -181,17 +145,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
</Stack>
</Stack>
</Box>
<VendorDeactivationDialog
target={deactivation.target}
isLoading={deactivation.isLoading}
isSaving={deactivation.isSaving}
error={deactivation.error}
impact={deactivation.impact}
impactError={deactivation.impactError}
onClose={deactivation.close}
onConfirm={deactivation.confirm}
/>
</PageShell>
);
}

View file

@ -1,13 +1,9 @@
import FilterListIcon from "@mui/icons-material/FilterList";
import {
Button,
Chip,
CircularProgress,
Stack,
TablePagination,
TextField,
Typography,
} from "@mui/material";
import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material";
import { Plus, SlidersHorizontal } from "lucide-react";
import { PageHeader } from "@/components/ui/page-header";
import { SearchInput } from "@/components/ui/search-input";
import { Text } from "@/components/ui/text";
import { ToolbarButton } from "@/components/ui/toolbar-button";
interface VendorsListToolbarProps {
globalCount: number;
@ -33,55 +29,46 @@ export function VendorsListToolbar({
onAddVendor,
}: VendorsListToolbarProps) {
return (
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Vendors / Technicians
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{globalCount} vendor entries · {activeCount} active
</Typography>
{isFetching && !isLoading && (
<CircularProgress size={16} aria-label="Refreshing vendors" role="status" />
)}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
>
<TextField
size="small"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search name, company, trade..."
className="min-w-[260px]"
aria-label="Search vendors"
/>
<Button
variant="outlined"
onClick={onOpenFilters}
startIcon={<FilterListIcon />}
aria-label="Open vendor filters"
>
Filters
{appliedFilterCount > 0 && (
<Chip size="small" color="primary" label={appliedFilterCount} sx={{ ml: 1 }} />
<PageHeader
surface
className="-mx-4 -mt-4"
title="Vendors / Technicians"
subtitle={`${globalCount} technicians · ${activeCount} active`}
actions={
<>
<SearchInput
value={search}
onChange={onSearchChange}
placeholder="Search name, company, trade…"
aria-label="Search vendors"
fullWidth
className="w-full md:w-56"
inputClassName="h-8 py-0 text-[13px]"
/>
<ToolbarButton
variant="outline"
icon={<SlidersHorizontal size={16} />}
onClick={onOpenFilters}
aria-label="Open vendor filters"
badge={appliedFilterCount}
className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
>
Filters
</ToolbarButton>
<ToolbarButton
variant="primary"
icon={<Plus size={16} />}
onClick={onAddVendor}
className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
>
Add Vendor
</ToolbarButton>
{isFetching && !isLoading && (
<CircularProgress size={14} aria-label="Refreshing vendors" role="status" />
)}
</Button>
<Button variant="contained" onClick={onAddVendor}>
+ Add Vendor
</Button>
</Stack>
</Stack>
</>
}
/>
);
}
@ -93,16 +80,21 @@ interface AppliedChipEntry {
interface VendorAppliedFiltersBarProps {
chips: AppliedChipEntry[];
resultCount: number;
onClearAll: () => void;
}
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) {
export function VendorAppliedFiltersBar({
chips,
resultCount,
onClearAll,
}: VendorAppliedFiltersBarProps) {
if (chips.length === 0) return null;
return (
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Applied:
</Typography>
<Text variant="caption" tone="muted">
{resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by
</Text>
{chips.map((chip) => (
<Chip
key={chip.key}
@ -149,23 +141,31 @@ export function VendorsListFooter({
sx={{
alignItems: { xs: "stretch", sm: "center" },
justifyContent: "space-between",
minHeight: 33.5,
mx: -2,
px: 2,
backgroundColor: "var(--color-bg-surface)",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
{totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "vendor entry" : "vendor entries"}
{totalCount === 1 ? "technician" : "technicians"}
{isFiltered && " · filtered"}
{` · ${activeCount} active · ${inactiveCount} inactive`}
</Typography>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
rowsPerPageOptions={[12, 24, 48]}
/>
</Text>
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
{activeCount} active · {inactiveCount} inactive
</Text>
{totalCount > pageSize && (
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
rowsPerPageOptions={[24, 48, 96]}
/>
)}
</Stack>
);
}

View file

@ -1,11 +1,10 @@
import { type KeyboardEvent, type MouseEvent } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import {
Avatar,
Chip,
Box,
CircularProgress,
IconButton,
Link,
@ -18,8 +17,8 @@ import {
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
const TABLE_COLUMNS = [
@ -30,9 +29,6 @@ const TABLE_COLUMNS = [
"Phone",
"Email",
"Total Jobs",
"Status",
"View",
"Edit",
] as const;
function getInitials(name: string): string {
@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void {
event.stopPropagation();
}
function VendorStatus({ isActive }: { isActive: boolean }) {
return (
<Stack direction="row" spacing={1} sx={{ alignItems: "center", pl: 1 }}>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: "9999px",
backgroundColor: isActive ? "success.main" : "text.secondary",
}}
/>
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
</Stack>
);
}
interface VendorTableRowProps {
row: VendorListItem;
onOpenDetail: (row: VendorListItem) => void;
onOpenDeactivation: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
}
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
const navigate = useNavigate();
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
const hasVendorId = row.id != null;
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
const handleKeyDown = (event: KeyboardEvent) => {
@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
onKeyDown={handleKeyDown}
>
<TableCell>
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.companyName || "—"}
</Text>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Avatar
sx={{
width: 28,
height: 28,
fontSize: 12,
backgroundColor: "var(--accent, #1976d2)",
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-primary)",
fontSize: 10.5,
fontWeight: 600,
}}
>
{getInitials(row.contactName)}
</Avatar>
<Typography variant="body2">{row.contactName || "—"}</Typography>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.contactName || "—"}
</Text>
</Stack>
</TableCell>
<TableCell>{locationText}</TableCell>
<TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
{extraTrades > 0 && (
<Box
component="span"
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
)}
</Stack>
)}
</TableCell>
<TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{row.phone ? (
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
<Link
href={`tel:${row.phone}`}
underline="hover"
color="inherit"
onClick={stopPropagation}
>
{row.phone}
</Link>
) : (
@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
</TableCell>
<TableCell>
{row.email ? (
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
<Link
href={`mailto:${row.email}`}
underline="hover"
sx={{ color: "#6b7a96" }}
onClick={stopPropagation}
>
{row.email}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>{row.totalJobs ?? 0}</TableCell>
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
<TableCell>
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
<VendorStatus isActive={row.isActive} />
</TableCell>
<TableCell align="center">
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenDetail(row);
}}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell align="center">
<TableCell align="right">
<Stack
direction="row"
spacing={0.5}
sx={{ alignItems: "center", justifyContent: "center" }}
className="vendor-row-actions"
sx={{ alignItems: "center", justifyContent: "flex-end" }}
>
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenDetail(row);
}}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
navigate(
row.id != null
? `/vendors/edit/${row.id}`
: `/vendors/company/${row.companyId ?? ""}`,
);
onOpenEdit(row);
}}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
{hasVendorId && row.isActive && (
<Tooltip title="Deactivate">
<IconButton
size="small"
aria-label="Deactivate vendor"
onClick={(event) => {
event.stopPropagation();
onOpenDeactivation(row);
}}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
interface VendorsTableProps {
rows: VendorListItem[];
isActive: boolean;
isFiltered: boolean;
isLoading: boolean;
showError: boolean;
onOpenDetail: (row: VendorListItem) => void;
onOpenDeactivation: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
}
export function VendorsTable({
rows,
isActive,
isFiltered,
isLoading,
showError,
onOpenDetail,
onOpenDeactivation,
onOpenEdit,
}: VendorsTableProps) {
const showEmptyRow = !isLoading && !showError && rows.length === 0;
const columnCount = TABLE_COLUMNS.length + 2;
return (
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 960 }}>
<TableContainer
component={Paper}
variant="outlined"
sx={{
mt: 2,
mb: 2,
flex: "1 1 auto",
overflow: "auto",
borderRadius: 1.5,
fontSize: 13,
}}
>
<Table
size="small"
stickyHeader
sx={{
minWidth: 1550,
tableLayout: "fixed",
"& .MuiTableCell-head": {
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-foreground)",
fontSize: 10.5,
fontWeight: 700,
letterSpacing: "0.06em",
lineHeight: "24px",
px: 1.75,
py: 1.375,
textTransform: "uppercase",
whiteSpace: "nowrap",
},
"& .MuiTableCell-body": {
fontSize: 13,
px: 1.5,
py: 1,
height: 52,
lineHeight: 1.43,
whiteSpace: "nowrap",
},
"& .vendor-row-actions": {
opacity: { xs: 1, md: 0 },
transition: "opacity 120ms ease",
},
"& .MuiTableRow-root:hover .vendor-row-actions, & .MuiTableRow-root:focus-within .vendor-row-actions":
{
opacity: 1,
},
}}
>
<colgroup>
<col style={{ width: 196.34375 }} />
<col style={{ width: 207.25 }} />
<col style={{ width: 152.703125 }} />
<col style={{ width: 218.15625 }} />
<col style={{ width: 152.703125 }} />
<col style={{ width: 251.90625 }} />
<col style={{ width: 109.078125 }} />
<col style={{ width: 119.984375 }} />
<col style={{ width: 141.875 }} />
</colgroup>
<TableHead>
<TableRow>
{TABLE_COLUMNS.map((column) => (
<TableCell
key={column}
align={column === "View" || column === "Edit" ? "center" : "left"}
className="font-semibold"
>
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
{column}
</TableCell>
))}
<TableCell>Status</TableCell>
<TableCell align="right" aria-label="Vendor actions" />
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<TableCell colSpan={columnCount} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No vendors match the current filters.
</Typography>
<TableCell colSpan={columnCount} align="center" className="py-10">
<Stack spacing={1} sx={{ alignItems: "center" }}>
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
<Text variant="description" className="font-semibold">
{`No ${isActive ? "active" : "inactive"} technicians found`}
</Text>
<Text variant="caption" tone="muted">
{isFiltered
? "Try a different search, or add a new vendor."
: "Add a vendor to get started."}
</Text>
</Stack>
</TableCell>
</TableRow>
) : (
@ -252,7 +338,7 @@ export function VendorsTable({
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
row={row}
onOpenDetail={onOpenDetail}
onOpenDeactivation={onOpenDeactivation}
onOpenEdit={onOpenEdit}
/>
))
)}

View file

@ -23,16 +23,91 @@ interface AppliedChip {
label: string;
}
function statusTabLabel(label: string, count: number) {
return (
<Box className="flex items-center gap-2">
<Box component="span">{label}</Box>
<Box
component="span"
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
>
{count}
</Box>
</Box>
);
}
function VendorStatusTabs({
isActive,
activeCount,
inactiveCount,
onChange,
}: {
isActive: boolean;
activeCount: number;
inactiveCount: number;
onChange: (isActive: boolean) => void;
}) {
return (
<Tabs
value={isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") => onChange(value === "active")}
aria-label="Vendor status"
sx={{
minHeight: 36,
flexShrink: 0,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: "var(--color-bg-surface)",
mx: -2,
px: 1.5,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .MuiTab-root:first-of-type": { width: 102.84375 },
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", activeCount)}
aria-label={`Active (${activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", inactiveCount)}
aria-label={`Inactive (${inactiveCount})`}
/>
</Tabs>
);
}
function describeChips(
filters: VendorFilters,
jobBucketLabels: Map<string, string>,
): AppliedChip[] {
const chips: AppliedChip[] = [];
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
filters.companies.forEach((value) =>
chips.push({ category: "companies", value, label: `Company: ${value}` }),
);
filters.trades.forEach((value) =>
chips.push({ category: "trades", value, label: `Trade: ${value}` }),
);
filters.locations.forEach((value) =>
chips.push({ category: "locations", value, label: `Location: ${value}` }),
);
filters.jobBuckets.forEach((value) =>
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
chips.push({
category: "jobBuckets",
value,
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
}),
);
return chips;
}
@ -42,7 +117,12 @@ export default function VendorsListPage() {
const [filtersOpen, setFiltersOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
const deactivation = useVendorDeactivation();
const [detailStartsInEdit, setDetailStartsInEdit] = useState(false);
const closeDetail = () => {
setDetailTarget(null);
setDetailStartsInEdit(false);
};
const deactivation = useVendorDeactivation(closeDetail);
const { data: facets } = useVendorFacets(filters.isActive);
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
@ -61,7 +141,7 @@ export default function VendorsListPage() {
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
return (
<Box className="flex flex-col gap-4 p-4">
<Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">
<VendorsListToolbar
globalCount={activeCount + inactiveCount}
activeCount={activeCount}
@ -74,18 +154,15 @@ export default function VendorsListPage() {
onAddVendor={() => setCreateOpen(true)}
/>
<Tabs
value={filters.isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") =>
filters.setIsActiveWithReset(value === "active")
}
aria-label="Vendor status"
>
<Tab value="active" label={`Active (${activeCount})`} />
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
</Tabs>
<VendorStatusTabs
isActive={filters.isActive}
activeCount={activeCount}
inactiveCount={inactiveCount}
onChange={filters.setIsActiveWithReset}
/>
<VendorAppliedFiltersBar
resultCount={totalCount}
chips={appliedChips.map((chip) => ({
key: `${chip.category}-${chip.value}`,
label: chip.label,
@ -102,10 +179,18 @@ export default function VendorsListPage() {
<VendorsTable
rows={rows}
isActive={filters.isActive}
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
isLoading={isLoading}
showError={Boolean(error)}
onOpenDetail={setDetailTarget}
onOpenDeactivation={deactivation.open}
onOpenDetail={(row) => {
setDetailStartsInEdit(false);
setDetailTarget(row);
}}
onOpenEdit={(row) => {
setDetailStartsInEdit(true);
setDetailTarget(row);
}}
/>
<VendorsListFooter
@ -133,7 +218,9 @@ export default function VendorsListPage() {
<VendorDetailDrawer
open={Boolean(detailTarget)}
vendor={detailTarget}
onClose={() => setDetailTarget(null)}
startInEdit={detailStartsInEdit}
onRequestDeactivation={deactivation.open}
onClose={closeDetail}
/>
<VendorDeactivationDialog

View file

@ -0,0 +1,45 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type DuplicateWoNumberDialogProps = {
open: boolean;
existing: WorkOrderTableRow | null;
onDismiss: () => void;
onViewExisting: (row: WorkOrderTableRow) => void;
};
export function DuplicateWoNumberDialog({
open,
existing,
onDismiss,
onViewExisting,
}: DuplicateWoNumberDialogProps) {
const woNumber = existing?.woNumber ?? "";
return (
<Dialog open={open} onClose={onDismiss} aria-labelledby="duplicate-wo-number-dialog-title">
<DialogTitle id="duplicate-wo-number-dialog-title">Duplicate Work Order Number</DialogTitle>
<DialogContent>
<Text variant="body">
Work order number {woNumber} is already assigned to another work order. View the existing
record or dismiss to keep editing.
</Text>
</DialogContent>
<DialogActions>
<Button onClick={onDismiss}>Dismiss</Button>
<Button
variant="contained"
disabled={existing == null}
onClick={() => {
if (existing != null) {
onViewExisting(existing);
}
}}
>
View existing
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -12,12 +12,15 @@ export function buildInlineDraftCreateInput(
const location = sites.find((s) => String(s.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
return {
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber,
type: draft.type,
type: draft.type || "PM",
assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn,
@ -35,7 +38,7 @@ export function buildInlineDraftCreateInput(
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
scheduleWeekOnly: draft.scheduleWeekOnly,
scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
};
}

View file

@ -15,6 +15,8 @@ type DateCellProps = {
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
onClear?: () => void;
};
export function DateCell({
@ -26,6 +28,7 @@ export function DateCell({
targetWeek = "",
onSaveWeek,
defaultMode,
onClear,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
@ -132,6 +135,21 @@ export function DateCell({
}}
/>
)}
{Boolean(onClear) && (
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
<button
type="button"
onClick={() => {
onClear?.();
setAnchorEl(null);
}}
className="text-[11.5px] font-medium transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
× Clear
</button>
</div>
)}
</Popover>
</>
);

View file

@ -1,43 +1,30 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = {
value: string;
rescheduleCount: number;
carriedOver?: number;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) {
return null;
}
if (count < 2) return null;
return (
<span
title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)",
}}
>
@ -46,89 +33,134 @@ function CounterPill({ label, count }: { label: string; count: number }) {
);
}
type WoNumberEditorProps = {
draft: string;
invalid: boolean;
isChecking: boolean;
onDraftChange: (value: string) => void;
onCommit: () => void;
onCancel: () => void;
onBlur: () => void;
};
function WoNumberEditor({
draft,
invalid,
isChecking,
onDraftChange,
onCommit,
onCancel,
onBlur,
}: WoNumberEditorProps) {
return (
<input
autoFocus
disabled={isChecking}
inputMode="numeric"
value={draft}
onChange={(e) => onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))}
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onCommit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
onBlur={onBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/>
);
}
async function resolveDuplicateWoNumber(
draft: string,
allOrders: WorkOrderTableRow[],
currentId: string | number,
) {
const local = findDuplicateWoNumberRow(draft, allOrders, currentId);
const remote = local ?? (await workOrdersApi.findByWoNumber(draft));
return remote && String(remote.id) !== String(currentId) ? remote : undefined;
}
export function EditableWONumberCell({
value,
rescheduleCount,
carriedOver = 0,
q,
currentId,
allOrders,
onSave,
onDuplicateFound,
}: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [isChecking, setIsChecking] = useState(false);
useEffect(() => {
if (!editing) {
setDraft(value);
}
if (!editing) setDraft(value);
}, [value, editing]);
const findDuplicate = (d: string) =>
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
const commit = () => {
if (draft === value) {
if (draft === value || isChecking) {
setEditing(false);
return;
}
if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits");
return;
}
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
setEditing(false);
setIsChecking(true);
void (async () => {
try {
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
if (duplicate) {
if (onDuplicateFound) {
onDuplicateFound(duplicate);
} else {
toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
}
setDraft(value);
return;
}
onSave(draft);
} catch {
toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsChecking(false);
setEditing(false);
}
})();
};
const cancel = () => {
setDraft(value);
setEditing(false);
};
const handleBlur = () => {
if (isChecking) return;
if (draft !== value && draft.length === 11) {
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
commit();
return;
}
setEditing(false);
};
@ -137,75 +169,34 @@ export function EditableWONumberCell({
return (
<div className="flex items-center gap-1.5">
{editing ? (
<input
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))}
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
<WoNumberEditor
draft={draft}
invalid={invalid}
isChecking={isChecking}
onDraftChange={setDraft}
onCommit={commit}
onCancel={cancel}
onBlur={handleBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/>
) : (
<span
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
fontSize: 13,
fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4,
padding: "2px 4px",
}}
>
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
)}
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
</div>
);

View file

@ -17,6 +17,8 @@ type SiteCellProps = {
pocNotes?: string;
sites: LocationOption[];
q?: string;
/** Inline create: editable POC + Site/POC required to confirm the modal. */
createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@ -31,6 +33,7 @@ export function SiteCell({
pocNotes,
sites,
q,
createMode = false,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
@ -70,7 +73,15 @@ export function SiteCell({
)}
</span>
) : (
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
<span className="text-[12.5px] text-[var(--color-text-muted)]">
{createMode ? (
<>
Site <span style={{ color: "var(--destructive)" }}>*</span>
</>
) : (
"—"
)}
</span>
)}
<HoverChevron />
</button>
@ -83,6 +94,7 @@ export function SiteCell({
pocPhone={pocPhone}
pocNotes={pocNotes}
sites={sites}
createMode={createMode}
onSave={onSave}
/>
</>

View file

@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
pocName: string;
pocPhone: string;
pocNotes: string;
editable?: boolean;
showErrors?: boolean;
onPocNameChange?: (value: string) => void;
onPocPhoneChange?: (value: string) => void;
onPocNotesChange?: (value: string) => void;
};
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
export function SiteDialogPocFields({
pocName,
pocPhone,
pocNotes,
editable = false,
showErrors = false,
onPocNameChange,
onPocPhoneChange,
onPocNotesChange,
}: SiteDialogPocFieldsProps) {
const nameMissing = showErrors && !pocName.trim();
const phoneMissing = showErrors && !pocPhone.trim();
return (
<div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Shown from site/work order records. Board edit saves site code only.
{editable
? "Auto-filled from records — edit if outdated"
: "Shown from site/work order records. Board edit saves site code only."}
</Text>
<div className="space-y-2">
<input
value={pocName}
readOnly
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
/>
<input
value={pocPhone}
readOnly
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
/>
<div>
<input
value={pocName}
readOnly={!editable}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: nameMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocNameChange?.(e.target.value)}
/>
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
Contact name is required
</Text>
</div>
<div>
<input
value={pocPhone}
readOnly={!editable}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: phoneMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocPhoneChange?.(e.target.value)}
/>
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
Contact phone is required
</Text>
</div>
<textarea
value={pocNotes}
readOnly
readOnly={!editable}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
onChange={(e) => onPocNotesChange?.(e.target.value)}
/>
</div>
</div>

View file

@ -1,30 +1,20 @@
import { useEffect, useState, type MouseEvent } from "react";
import type { MouseEvent } from "react";
import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import {
extractLocationLabel,
resolveLocationId,
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
import {
useSiteDialogState,
type SitePatch,
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
export type { SitePatch };
type SiteDialogProps = {
open: boolean;
@ -35,6 +25,7 @@ type SiteDialogProps = {
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@ -47,62 +38,26 @@ export function SiteDialog({
pocPhone = "",
pocNotes = "",
sites,
createMode = false,
onSave,
}: SiteDialogProps) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [pocFilledFor, setPocFilledFor] = useState("");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(open && locId ? locId : undefined);
const selected = sites.find((s) => String(s.id) === locId);
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
useEffect(() => {
if (!open) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
setPn(locationDetail.contact ?? "");
setPp(locationDetail.phone ?? "");
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
const s = useSiteDialogState({
open,
onOpenChange,
value,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
createMode,
onSave,
});
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={s.close}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -125,7 +80,7 @@ export function SiteDialog({
<div>
<FieldLabel>Site</FieldLabel>
<WizardFieldSelect
value={locId}
value={s.locId}
placeholder="Select site …"
icon={
<MapPin
@ -139,45 +94,45 @@ export function SiteDialog({
key: String(site.id),
label: site.name,
}))}
onPick={handlePick}
onPick={s.handlePick}
/>
<Text
as="p"
variant="error"
when={Boolean(createMode && s.showErrors && s.siteMissing)}
className="mt-1 text-[11px]"
>
Site is required
</Text>
</div>
{Boolean(locId) && (
{Boolean(s.locId) && (
<SiteDialogDetailPanel
locationDetail={locationDetail}
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
addressPreview={addressPreview}
fallbackLabel={selected?.name || code || "—"}
locationDetail={s.locationDetail}
locationDetailLoading={s.locationDetailLoading}
locationDetailError={s.locationDetailError}
addressPreview={s.addressPreview}
fallbackLabel={s.selected?.name || s.code || "—"}
/>
)}
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} />
<SiteDialogPocFields
pocName={s.pn}
pocPhone={s.pp}
pocNotes={s.notes}
editable={createMode}
showErrors={createMode && s.showErrors}
onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes}
/>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
}}
>
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
Save
</Button>
</div>

View file

@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import {
extractLocationLabel,
resolveLocationId,
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
type UseSiteDialogStateArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
sites: LocationOption[];
createMode: boolean;
onSave: (patch: SitePatch) => void;
};
export function useSiteDialogState({
open,
onOpenChange,
value,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
createMode,
onSave,
}: UseSiteDialogStateArgs) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [pocFilledFor, setPocFilledFor] = useState("");
const [showErrors, setShowErrors] = useState(false);
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(open && locId ? locId : undefined);
const selected = sites.find((s) => String(s.id) === locId);
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
const siteMissing = !locId;
const pocMissing = !pn.trim() || !pp.trim();
const canConfirm = !siteMissing && (!createMode || !pocMissing);
useEffect(() => {
if (!open) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
setShowErrors(false);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
// Only autofill empty fields so typed createMode POC edits are not overwritten.
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
const siteChanged = id !== locId;
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (siteChanged) {
setPn("");
setPp("");
setPocFilledFor("");
} else if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
const attemptSave = () => {
if (createMode && !canConfirm) {
setShowErrors(true);
return;
}
onSave({
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
};
return {
locId,
pn,
pp,
notes,
showErrors,
locationDetail,
locationDetailLoading,
locationDetailError,
selected,
addressPreview,
siteMissing,
canConfirm,
code,
handlePick,
attemptSave,
setPn,
setPp,
setNotes,
close: () => onOpenChange(false),
};
}

View file

@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
type InlineCreateFieldProps = {
showError?: boolean;
error?: string;
children: ReactNode;
};
/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
return (
<div className="flex w-full min-w-0 flex-col gap-1">
<div
className="flex w-full min-w-0 items-center"
style={{
minHeight: 34,
borderRadius: 6,
padding: "0 8px",
background: "var(--color-bg-surface)",
border: `1px solid ${showError ? "var(--destructive)" : "var(--color-border)"}`,
boxShadow: showError ? "0 0 0 1px var(--destructive)" : "none",
}}
>
{children}
</div>
<Text as="p" variant="error" when={Boolean(showError && error)} className="m-0 text-[11px]">
{error}
</Text>
</div>
);
}

View file

@ -16,6 +16,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
location: draft.location,
dispatcherId: draft.dispatcherId,
scheduledOn: draft.scheduledOn,
completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,
serviceNotes: draft.serviceNotes,

View file

@ -0,0 +1,58 @@
import { useState } from "react";
import { toast } from "react-toastify";
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
type UseWoTableInlineCreateArgs = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
users: WorkOrderUser[];
sites: LocationOption[];
onDuplicateFound: (row: WorkOrderTableRow) => void;
};
export function useWoTableInlineCreate({
tableData,
users,
sites,
onDuplicateFound,
}: UseWoTableInlineCreateArgs) {
const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
const createMutation = useCreateWorkOrderFromWizard(() => {
tableData.cancelDraft();
});
const handleInlineSave = () => {
void (async () => {
const draftSnapshot = tableData.draft;
const woNumber = draftSnapshot.woNumber.trim();
if (woNumber) {
setIsCheckingInlineDuplicate(true);
try {
const duplicate =
findDuplicateWoNumberRow(woNumber, tableData.rows, "draft") ??
(await workOrdersApi.findByWoNumber(woNumber));
if (duplicate && String(duplicate.id) !== "draft") {
onDuplicateFound(duplicate);
return;
}
} catch {
toast.error("Unable to verify the work order number. Please try again.");
return;
} finally {
setIsCheckingInlineDuplicate(false);
}
}
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites));
})();
};
return {
handleInlineSave,
saving: createMutation.isPending || isCheckingInlineDuplicate,
};
}

View file

@ -19,6 +19,7 @@ type UseWoTableRowHandlersOptions = {
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function useWoTableRowHandlers({
@ -30,6 +31,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const {
dragState: dragStateRef,
@ -97,8 +99,7 @@ export function useWoTableRowHandlers({
});
},
onDuplicateFound: (existing: WorkOrderTableRow) => {
tableData.setSelectedId(existing.id);
onOpenSlideOver(existing, false);
onDuplicateFound?.(existing);
},
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
@ -138,6 +139,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
onDuplicateFound,
],
);
}

View file

@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
export const WO_TABLE_MIN_WIDTH = 1504;
export const WO_TABLE_MIN_WIDTH = 1634;
export const WO_TABLE_ROW_HEIGHT = 52;

View file

@ -0,0 +1,88 @@
import type { RefObject } from "react";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell";
import { InlineCreateField } from "./inline-create-field";
type WoTableInlineIdentityCellsProps = {
draft: WorkOrderTableDraft;
sites: LocationOption[];
sitePlace?: { city: string; state: string };
allRows: { id: string | number; woNumber: string }[];
siteCellRef: RefObject<HTMLTableCellElement | null>;
woCellRef: RefObject<HTMLTableCellElement | null>;
siteShowError: boolean;
siteError: string;
woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function WoTableInlineIdentityCells({
draft,
sites,
sitePlace,
allRows,
siteCellRef,
woCellRef,
siteShowError,
siteError,
woShowError,
onPatch,
onDuplicateFound,
}: WoTableInlineIdentityCellsProps) {
return (
<>
<td
ref={siteCellRef}
className="group/cell"
style={{ padding: "8px 6px 0", verticalAlign: "top" }}
>
<InlineCreateField showError={siteShowError} error={siteError}>
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
createMode
onSave={(p) =>
onPatch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</InlineCreateField>
</td>
<td
ref={woCellRef}
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
>
<InlineCreateField showError={woShowError} error="Work order number is required">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound}
/>
</InlineCreateField>
</td>
</>
);
}

View file

@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
type WoTableInlineRowActionsProps = {
canSave: boolean;
saving: boolean;
saveHint: string;
onSave: () => void;
onCancel: () => void;
};
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
export function WoTableInlineRowActions({
canSave,
saving,
saveHint,
onSave,
onCancel,
}: WoTableInlineRowActionsProps) {
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
<button
type="button"
onClick={onSave}
disabled={!canSave}
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
disabled={saving}
title={saveHint}
aria-label={saving ? "Saving work order" : "Save work order"}
className="rounded p-1 transition-colors disabled:opacity-30"
style={{ color: "var(--color-primary)" }}
style={{
color: "var(--color-primary)",
...(canSave || saving ? {} : { opacity: 0.5, cursor: "not-allowed" }),
}}
onMouseEnter={(e) => {
if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";

View file

@ -1,20 +1,23 @@
import type { RefObject } from "react";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
type WoTableInlineRowCellsProps = {
draft: WorkOrderTableDraft;
@ -25,9 +28,23 @@ type WoTableInlineRowCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
siteCellRef: RefObject<HTMLTableCellElement | null>;
woCellRef: RefObject<HTMLTableCellElement | null>;
siteShowError: boolean;
siteError: string;
woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function InlineMutedDashCell() {
return (
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
);
}
export function WoTableInlineRowCells({
draft,
weekDefault,
@ -37,45 +54,32 @@ export function WoTableInlineRowCells({
vendors,
pmTypes,
allRows,
siteCellRef,
woCellRef,
siteShowError,
siteError,
woShowError,
onPatch,
onDuplicateFound,
}: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft);
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
return (
<>
<td className="group/cell" style={{ padding: "0 14px" }}>
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
onSave={(p) =>
onPatch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<td style={{ padding: "0 14px" }}>
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
/>
</td>
<WoTableInlineIdentityCells
draft={draft}
sites={sites}
sitePlace={sitePlace}
allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}
siteShowError={siteShowError}
siteError={siteError}
woShowError={woShowError}
onPatch={onPatch}
onDuplicateFound={onDuplicateFound}
/>
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
@ -98,11 +102,15 @@ export function WoTableInlineRowCells({
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) =>
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
}
onClear={
hasSchedule
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
: undefined
}
/>
</td>
@ -170,9 +178,8 @@ export function WoTableInlineRowCells({
/>
</td>
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
<InlineMutedDashCell />
<InlineMutedDashCell />
</>
);
}

View file

@ -1,3 +1,4 @@
import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react";
import type {
@ -5,10 +6,17 @@ import type {
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
import {
draftHasScheduleDate,
getInlineCreateMissing,
inlineCreateSaveHint,
inlineCreateSiteError,
} from "@/domain/work-orders/utils/get-inline-create-missing";
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
@ -16,72 +24,88 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
type WoTableInlineRowProps = {
draft: WorkOrderTableDraft;
draftDayKey: string;
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
saving?: boolean;
onChange: (draft: WorkOrderTableDraft) => void;
onSave: () => void;
onCancel: () => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
const dated = draftHasScheduleDate(next);
if (dated && PRE_SCHEDULE.has(next.status)) {
return { ...next, status: "Scheduled" };
}
if (!dated && next.status === "Scheduled") {
return { ...next, status: "Pending" };
}
return next;
}
export function WoTableInlineRow({
draft,
draftDayKey,
users,
sites,
sitePlace,
vendors,
pmTypes,
allRows,
saving = false,
onChange,
onSave,
onCancel,
onDuplicateFound,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
const missing = getInlineCreateMissing(draft);
const canSave = !saving && missing.length === 0;
const [showErrors, setShowErrors] = useState(false);
const siteCellRef = useRef<HTMLTableCellElement>(null);
const woCellRef = useRef<HTMLTableCellElement>(null);
const canSave =
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
const siteShowError = showErrors && siteCellMissing;
const woShowError = showErrors && missing.includes("woNumber");
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
const patch = (p: Partial<WorkOrderTableDraft>) =>
onChange(withScheduleStatusSync({ ...draft, ...p }));
const focusCell = (r: RefObject<HTMLTableCellElement | null>) =>
r.current?.querySelector<HTMLElement>("input, button, [tabindex]")?.focus();
const attemptSave = () => {
if (saving) {
return;
}
if (canSave) {
onSave();
return;
}
setShowErrors(true);
if (siteCellMissing) {
focusCell(siteCellRef);
} else if (missing.includes("woNumber")) {
focusCell(woCellRef);
}
};
return (
<tr
aria-busy={saving || undefined}
style={{
height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--color-border)",
borderLeft: "2px solid var(--color-primary)",
background: "var(--color-primary-tint)",
opacity: saving ? 0.7 : 1,
pointerEvents: saving ? "none" : undefined,
@ -102,14 +126,21 @@ export function WoTableInlineRow({
vendors={vendors}
pmTypes={pmTypes}
allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}
siteShowError={siteShowError}
siteError={inlineCreateSiteError(missing)}
woShowError={woShowError}
onPatch={patch}
onDuplicateFound={onDuplicateFound}
/>
<td style={{ padding: "0 4px" }}>
<WoTableInlineRowActions
canSave={canSave}
saving={saving}
onSave={onSave}
saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
onSave={attemptSave}
onCancel={onCancel}
/>
</td>

View file

@ -3,6 +3,7 @@ import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { VendorCell } from "./cells/vendor-cell";
@ -102,6 +103,17 @@ export function WoTableRowServiceCells({
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell
value={row.completedDate}
q={search}
onSave={(date) => onPatchRow({ completedDate: date })}
/>
</td>
</>
);
}

View file

@ -9,11 +9,11 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
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";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
import { WoTableBody } from "./wo-table-body";
import { WoTableHead } from "./wo-table-head";
@ -55,10 +55,14 @@ export function WoTable({
onPatch,
}: WoTableProps) {
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
tableData.cancelDraft();
const { handleInlineSave, saving } = useWoTableInlineCreate({
tableData,
users,
sites,
onDuplicateFound: setDuplicateRow,
});
const handlers = useWoTableRowHandlers({
@ -70,6 +74,7 @@ export function WoTable({
onOpenMedia,
onPatch,
onScheduleHighlight: setDueDateHighlightId,
onDuplicateFound: setDuplicateRow,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -107,12 +112,11 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={createMutation.isPending}
saving={saving}
onChange={tableData.setDraft}
onSave={() =>
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/>
);
@ -151,21 +155,33 @@ export function WoTable({
}
return (
<WoTableShell>
<table
style={{
width: "100%",
minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
<>
<WoTableShell>
<table
style={{
width: "100%",
minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
}}
>
<WoTableHead />
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
<DuplicateWoNumberDialog
open={duplicateRow != null}
existing={duplicateRow}
onDismiss={() => setDuplicateRow(null)}
onViewExisting={(row) => {
setDuplicateRow(null);
onOpenSlideOver(row, false);
}}
>
<WoTableHead />
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
/>
</>
);
}

View file

@ -0,0 +1,32 @@
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListWizardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
rows: WorkOrderTableRow[];
onViewExisting: (row: WorkOrderTableRow) => void;
};
export function WorkOrdersListWizard({
open,
onOpenChange,
users,
rows,
onViewExisting,
}: WorkOrdersListWizardProps) {
return (
<NewWoWizard
open={open}
onOpenChange={onOpenChange}
users={users}
existingRows={rows}
onViewExistingWo={(row) => {
onOpenChange(false);
onViewExisting(row);
}}
/>
);
}

View file

@ -1,169 +1,130 @@
import { Dialog } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
import { useWorkOrderVendors } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
import { WizardStepHeader } from "@/app/(protected)/workorders/_components/wizard/wizard-step-header";
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
import { useNewWoWizardController } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller";
type NewWoWizardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
existingRows?: WorkOrderTableRow[];
onViewExistingWo?: (row: WorkOrderTableRow) => void;
};
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
const {
step,
setStep,
draft,
setDraft,
setField,
patchDraft: patchDraftOnly,
svcOpen,
setSvcOpen,
svcSearch,
setSvcSearch,
} = useNewWoWizardDraft(open);
const { data: locations = [] } = useLocationSites();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(draft.locationId || undefined);
const { data: vendors = [] } = useWorkOrderVendors(
draft.pm.trim() || undefined,
locationDetail?.zipCode,
);
const { notifyLocationChanged } = useWizardPocAutofill(
open,
draft.locationId,
locationDetail,
setDraft,
);
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
patchDraftOnly(patch);
if (patch.locationId !== undefined) {
notifyLocationChanged(patch.locationId);
}
};
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const handleCreate = () => createMutation.mutate(draft);
const isCreating = createMutation.isPending;
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
export function NewWoWizard({
open,
onOpenChange,
users,
existingRows = [],
onViewExistingWo,
}: NewWoWizardProps) {
const wizard = useNewWoWizardController({ open, onOpenChange, existingRows });
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex flex-col overflow-hidden p-0",
style: {
borderRadius: 12,
width: 768,
maxWidth: 768,
height: 680,
maxHeight: 680,
pointerEvents: isCreating ? "none" : undefined,
opacity: isCreating ? 0.85 : 1,
<>
<Dialog
open={open}
onClose={wizard.handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex flex-col overflow-hidden p-0",
style: {
borderRadius: 12,
width: 768,
maxWidth: 768,
height: 680,
maxHeight: 680,
pointerEvents: wizard.isCreating ? "none" : undefined,
opacity: wizard.isCreating ? 0.85 : 1,
},
},
},
}}
>
<span className="sr-only">Guided setup for creating a new work order</span>
<div className="flex min-h-0 flex-1" aria-busy={isCreating || undefined}>
<WizardSidebar step={step} draft={draft} />
<div
className="min-h-0 flex-1 overflow-y-auto"
style={{ padding: 32, background: "var(--color-bg-surface)" }}
>
<WizardStepHeader step={step} />
{step === 1 && (
<WizardStepTypeSchedule
draft={draft}
users={users}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
{step === 2 && (
<WizardStepLocationService
draft={draft}
locations={locations}
locationDetail={locationDetail}
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
services={problems.map((p) => ({ id: p.id, value: p.value }))}
svcOpen={svcOpen}
onSvcOpenChange={setSvcOpen}
svcSearch={svcSearch}
onSvcSearchChange={setSvcSearch}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
{step === 3 && (
<WizardStepVendorTime
draft={draft}
vendors={vendors}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
</div>
</div>
<WizardFooter
step={step}
draft={draft}
onCancel={() => {
if (!isCreating) onOpenChange(false);
}}
onBack={() => setStep((s) => s - 1)}
onContinue={() => setStep((s) => s + 1)}
onCreate={handleCreate}
isCreating={isCreating}
>
<span className="sr-only">Guided setup for creating a new work order</span>
<div className="flex min-h-0 flex-1" aria-busy={wizard.isCreating || undefined}>
<WizardSidebar step={wizard.step} draft={wizard.draft} />
<div
className="min-h-0 flex-1 overflow-y-auto"
style={{ padding: 32, background: "var(--color-bg-surface)" }}
>
<WizardStepHeader step={wizard.step} />
{wizard.step === 1 && (
<WizardStepTypeSchedule
draft={wizard.draft}
users={users}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
onDuplicateWoNumber={wizard.handleDuplicateFound}
/>
)}
{wizard.step === 2 && (
<WizardStepLocationService
draft={wizard.draft}
locations={wizard.locations}
locationDetail={wizard.locationDetail}
locationDetailLoading={wizard.locationDetailLoading}
locationDetailError={wizard.locationDetailError}
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
svcOpen={wizard.svcOpen}
onSvcOpenChange={wizard.setSvcOpen}
svcSearch={wizard.svcSearch}
onSvcSearchChange={wizard.setSvcSearch}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
/>
)}
{wizard.step === 3 && (
<WizardStepVendorTime
draft={wizard.draft}
vendors={wizard.vendors}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
/>
)}
</div>
</div>
<WizardFooter
step={wizard.step}
draft={wizard.draft}
onCancel={() => {
if (!wizard.isCreating) onOpenChange(false);
}}
onBack={() => wizard.setStep((s) => s - 1)}
onContinue={() => wizard.setStep((s) => s + 1)}
onCreate={wizard.handleCreate}
isCreating={wizard.isCreating}
/>
</Dialog>
<DuplicateWoNumberDialog
open={wizard.duplicateRow != null}
existing={wizard.duplicateRow}
onDismiss={() => wizard.setDuplicateRow(null)}
onViewExisting={(row) => {
wizard.setDuplicateRow(null);
onOpenChange(false);
onViewExistingWo?.(row);
}}
/>
</Dialog>
</>
);
}

View file

@ -0,0 +1,93 @@
import { useEffect } from "react";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
import { useWorkOrderVendors } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
type UseNewWoWizardControllerArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
existingRows: WorkOrderTableRow[];
};
/** Wires draft state, lookups, POC autofill, and duplicate-WO guards for the create wizard. */
export function useNewWoWizardController({
open,
onOpenChange,
existingRows,
}: UseNewWoWizardControllerArgs) {
const draftState = useNewWoWizardDraft(open);
const { data: locations = [] } = useLocationSites();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(draftState.draft.locationId || undefined);
const { data: vendors = [] } = useWorkOrderVendors(
draftState.draft.pm.trim() || undefined,
locationDetail?.zipCode,
);
const { notifyLocationChanged } = useWizardPocAutofill(
open,
draftState.draft.locationId,
locationDetail,
draftState.setDraft,
);
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
draftState.patchDraft(patch);
if (patch.locationId !== undefined) {
notifyLocationChanged(patch.locationId);
}
};
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,
draft: draftState.draft,
existingRows,
createMutation,
});
useEffect(() => {
if (open) {
setDuplicateRow(null);
}
}, [open, setDuplicateRow]);
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
return {
...draftState,
patchDraft,
locations,
problems,
locationDetail,
locationDetailLoading,
locationDetailError,
vendors,
duplicateRow,
setDuplicateRow,
handleDuplicateFound,
handleCreate,
isCreating,
handleClose,
};
}

View file

@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from "react";
import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
import {
getMockWoWizardLocationDetail,
MOCK_WO_WIZARD_LOCATIONS,
MOCK_WO_WIZARD_SERVICES,
MOCK_WO_WIZARD_USERS,
MOCK_WO_WIZARD_VENDORS,
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { useWizardDuplicateActions } from "./use-wizard-duplicate-actions";
type UseNewWoWizardStateArgs = {
open: boolean;
users: WorkOrderUser[];
existingRows: WorkOrderTableRow[];
onOpenChange: (open: boolean) => void;
};
export function useNewWoWizardState({
open,
users,
existingRows,
onOpenChange,
}: UseNewWoWizardStateArgs) {
const mockFallback = isWoWizardMockEnabled();
const [step, setStep] = useState(1);
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
...EMPTY_WIZARD_DRAFT,
status: "Incomplete",
});
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
const { data: apiLocations = [] } = useWorkOrderLocations();
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
const isMockLocation = draft.locationId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation ? undefined : draft.locationId || undefined,
);
const mockLocationDetail = isMockLocation
? getMockWoWizardLocationDetail(draft.locationId)
: undefined;
const locationDetail = mockLocationDetail ?? apiLocationDetail;
const { data: apiVendors = [] } = useWorkOrderVendors(
undefined,
isMockLocation ? undefined : locationDetail?.zipCode,
);
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,
draft,
existingRows,
createMutation,
});
const wizardUsers = useMemo(() => {
if (users.length > 0) {
return users;
}
return mockFallback ? MOCK_WO_WIZARD_USERS : [];
}, [mockFallback, users]);
const locations =
apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : [];
const problems =
apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : [];
const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : [];
useEffect(() => {
if (open) {
setStep(1);
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setPocAutoFilledFor("");
setDuplicateRow(null);
}
}, [open, setDuplicateRow]);
useEffect(() => {
if (!draft.locationId || !locationDetail) {
return;
}
if (pocAutoFilledFor === draft.locationId) {
return;
}
setDraft((current) => ({
...current,
pocName: locationDetail.contact ?? current.pocName,
pocPhone: locationDetail.phone ?? current.pocPhone,
}));
setPocAutoFilledFor(draft.locationId);
}, [draft.locationId, locationDetail, pocAutoFilledFor]);
const setField = <K extends keyof WorkOrderWizardDraft>(
key: K,
value: WorkOrderWizardDraft[K],
) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
setPocAutoFilledFor("");
}
};
return {
step,
setStep,
draft,
duplicateRow,
setDuplicateRow,
wizardUsers,
locations,
problems,
vendors,
locationDetail,
setField,
patchDraft,
handleDuplicateFound,
handleCreate,
isCreating,
};
}

View file

@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
type UseWizardDuplicateActionsArgs = {
open: boolean;
draft: WorkOrderWizardDraft;
existingRows: WorkOrderTableRow[];
createMutation: {
mutate: (draft: WorkOrderWizardDraft) => void;
isPending: boolean;
};
};
export function useWizardDuplicateActions({
open,
draft,
existingRows,
createMutation,
}: UseWizardDuplicateActionsArgs) {
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
const openRef = useRef(open);
const draftRef = useRef(draft);
useEffect(() => {
openRef.current = open;
}, [open]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const resolveDuplicate = async (woNumber: string) => {
const localDuplicate = findDuplicateWoNumberRow(woNumber, existingRows);
if (localDuplicate) {
return localDuplicate;
}
// The loaded board rows are only an immediate UX hint; the unfiltered API lookup is authoritative.
return workOrdersApi.findByWoNumber(woNumber);
};
const handleDuplicateFound = (woNumber: string) => {
void (async () => {
try {
const duplicate = await resolveDuplicate(woNumber);
// Ignore stale lookups after dismiss or after the user edits the WO#.
if (!openRef.current || woNumber !== draftRef.current.woNumber) {
return;
}
if (duplicate) {
setDuplicateRow(duplicate);
}
} catch {
if (!openRef.current) {
return;
}
toast.error("Unable to verify the work order number. Please try again.");
}
})();
};
const handleCreate = () => {
const woNumberAtSubmit = draft.woNumber;
void (async () => {
setIsCheckingDuplicate(true);
try {
const duplicate = await resolveDuplicate(woNumberAtSubmit);
// Ignore after dismiss or if the WO# changed while the request was in flight.
if (!openRef.current || woNumberAtSubmit !== draftRef.current.woNumber) {
return;
}
if (duplicate) {
setDuplicateRow(duplicate);
return;
}
createMutation.mutate(draftRef.current);
} catch {
if (!openRef.current) {
return;
}
toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsCheckingDuplicate(false);
}
})();
};
return {
duplicateRow,
setDuplicateRow,
handleDuplicateFound,
handleCreate,
isCreating: createMutation.isPending || isCheckingDuplicate,
};
}

View file

@ -118,6 +118,7 @@ type WizardStepTypeScheduleProps = {
users: WorkOrderUser[];
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
onDuplicateWoNumber?: (woNumber: string) => void;
};
export function WizardStepTypeSchedule({
@ -125,6 +126,7 @@ export function WizardStepTypeSchedule({
users,
onUpdate,
onPatch,
onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) {
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
@ -142,6 +144,7 @@ export function WizardStepTypeSchedule({
inputMode="numeric"
value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="e.g. 12345678901"
maxLength={20}
className={DLG_INPUT_CLS}

View file

@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
const startDayDraft = useCallback(
(key: string, scheduledIso?: string) => {
setDraftDayKey(key);
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
if (key === UNSCHEDULED_DAY_KEY) {
setDraft({ ...EMPTY_TABLE_DRAFT });
return;
}
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
setDraft({
...EMPTY_TABLE_DRAFT,
scheduledOn,
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
status: scheduledOn ? "Scheduled" : "Incomplete",
});
},
[weekMonday],

View file

@ -1,16 +1,17 @@
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
@ -18,11 +19,6 @@ export default function WorkOrdersListPage() {
const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page;
const openCompletionDoc = (row: WorkOrderTableRow) => {
if (!hasCompletionDocService(row.pm)) return;
page.setDocRow(row);
};
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
@ -88,42 +84,31 @@ export default function WorkOrdersListPage() {
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={openCompletionDoc}
onOpenCompDoc={page.setDocRow}
onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={page.patchField}
/>
<WorkOrdersListOverlays
wizardOpen={page.wizardOpen}
onWizardOpenChange={page.setWizardOpen}
<WorkOrdersListWizard
open={page.wizardOpen}
onOpenChange={page.setWizardOpen}
users={page.users}
rows={tableData.rows}
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={page.locations}
users={page.users}
pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs}
advOpen={filters.advOpen}
onAdvOpenChange={filters.setAdvOpen}
advInitial={filters.advInitial}
onApplyAdvanced={filters.applyAdvancedFilters}
onClearAdvanced={filters.clearAdvancedFilters}
activeDocRow={activeDocRow}
onDocOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
completionDocs={page.completionDocs}
onSaveCompDoc={async (data, status) => {
if (!activeDocRow) return;
await page.saveCompletionDoc(activeDocRow, data, status);
}}
onDateCompletedChange={async (date) => {
if (!activeDocRow) return;
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
activeMediaRow={page.activeMediaRow}
onMediaOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
<WorkOrderSlideOver
@ -134,8 +119,8 @@ export default function WorkOrdersListPage() {
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={page.patchField}
onOpenCompletionDoc={openCompletionDoc}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
@ -149,6 +134,31 @@ export default function WorkOrdersListPage() {
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
</Box>
);
}

View file

@ -50,10 +50,17 @@ export function PageHeader({
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
eyebrow && "mt-1",
)}
sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }}
>
{title}
</Text>
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
<Text
variant="description"
as="span"
tone="muted"
when={Boolean(subtitle)}
sx={{ mt: 0.75, fontWeight: 500 }}
>
{subtitle}
</Text>
</Stack>

View file

@ -91,11 +91,6 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
? getSingleStatusOnlyChange(originalRoster, values)
: null;
if (statusChange) {
if (statusChange.isActive === false) {
throw new Error(
"Use Deactivate to check open work orders before inactivating a technician.",
);
}
const baseRoster = originalRoster as VendorCompanyRoster;
await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive });
return {

View file

@ -191,6 +191,48 @@ export const workOrdersApi = {
};
},
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
const normalized = woNumber.trim();
// Search every non-empty WO# — backend advanced search accepts 1-character queries, and
// wizard/create allow single-digit numbers (SH-189 must not bypass remote uniqueness).
if (!normalized) {
return undefined;
}
// Safety cap — if hit before exhausting results, fail closed (do not treat as unique).
const safetyMaxPages = 50;
// `board/search` is fuzzy and paginated (0-based); exhaust pages for an exact WO#.
const pageSize = 100;
let page = 0;
let scanned = 0;
let totalCount = Number.POSITIVE_INFINITY;
while (scanned < totalCount) {
if (page >= safetyMaxPages) {
throw new Error(
"Unable to verify the work order number: search result set exceeded the safety page limit.",
);
}
const result = await workOrdersApi.searchBoard({
search: normalized,
page,
pageSize,
});
const exact = result.items.find((row) => row.woNumber === normalized);
if (exact) {
return exact;
}
scanned += result.items.length;
totalCount = result.totalCount;
if (result.items.length === 0) {
break;
}
page += 1;
}
return undefined;
},
getListByLocation: async (
locationId: string | number,
search = "",

View file

@ -18,11 +18,11 @@ export function assignWizardScheduleFields(
const isRange = draft.schedMode === "range";
if (!isRange && draft.scheduleWeekOnly) {
payload.scheduleWeekOnly = true;
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
// Only emit week-mode flags when a concrete week exists (SH-109: undated create).
if (week) {
payload.scheduleWeekOnly = true;
payload.targetWeek = week;
}
} else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {

View file

@ -149,6 +149,8 @@ export interface WorkOrderBoardRowApi {
flagColor?: string | null;
dueDate?: string | null;
scheduledDate?: string | null;
/** ISO date when work was completed; optional on list rows until BE confirms. */
completedDate?: string | null;
targetWeek?: string | null;
scheduleWeekOnly?: boolean | null;
dayGroup?: string | null;

View file

@ -0,0 +1,16 @@
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
export function findDuplicateWoNumberRow(
woNumber: string,
rows: WorkOrderTableRow[],
excludeId?: string | number,
): WorkOrderTableRow | undefined {
const normalized = woNumber.trim();
if (normalized === "") {
return undefined;
}
return rows.find(
(row) => row.woNumber === normalized && String(row.id) !== String(excludeId ?? ""),
);
}

View file

@ -0,0 +1,62 @@
/** Required fields for board inline create (Schedule On is optional — SH-109). */
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
site: "Site",
woNumber: "WO #",
pocName: "POC name",
pocPhone: "POC phone",
};
export type InlineCreateDraftFields = {
site?: string;
locationId?: string | number;
woNumber?: string;
pocName?: string;
pocPhone?: string;
};
export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
const miss: InlineCreateReqField[] = [];
const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
if (!hasSite) {
miss.push("site");
}
if (!d.woNumber?.trim()) {
miss.push("woNumber");
}
if (!d.pocName?.trim()) {
miss.push("pocName");
}
if (!d.pocPhone?.trim()) {
miss.push("pocPhone");
}
return miss;
}
/** Tooltip for Save: "Missing: Site, WO #" or "Save" when ready. */
export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
if (miss.length === 0) {
return "Save";
}
return `Missing: ${miss.map((m) => INLINE_CREATE_REQ_LABELS[m]).join(", ")}`;
}
export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
if (miss.includes("site")) {
return "Site is required";
}
return "Contact name and phone are required";
}
/** Concrete calendar day/range — week-only does not count (design hasScheduleDate). */
export function draftHasScheduleDate(d: {
scheduledOn?: string;
scheduleWeekOnly?: boolean;
schedMode?: string;
}): boolean {
if (d.scheduleWeekOnly || d.schedMode === "week") {
return false;
}
return Boolean(d.scheduledOn?.trim());
}

View file

@ -18,6 +18,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"targetWeek",
"scheduleWeekOnly",
"dueDate",
"completedDate",
"apptTime",
"pm",
"serviceNotes",

View file

@ -5,6 +5,7 @@ import { useController } from "react-hook-form";
import { beforeEach, describe, expect, it, vi } from "vitest";
const rosterGet = vi.fn();
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
@ -24,8 +25,6 @@ vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
}));
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
const actual = await vi.importActual<
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
@ -202,10 +201,47 @@ describe("useVendorRosterForm load-error handling", () => {
});
});
describe("useVendorRosterForm prototype defaults", () => {
it("does not persist the prototype's untouched optional technician card", () => {
const { result } = renderHook(
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
{ wrapper: makeWrapper(createClient()) },
);
act(() => {
result.current.submit({
name: "Independent Vendor LLC",
companyPhone: "(314) 555-0199",
email: "",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
contactName: "",
phone: "",
email: "",
preferredContact: "Phone",
tradeSpecialties: "",
isActive: true,
},
],
});
});
expect(saveMutate).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }),
expect.any(Object),
);
});
});
describe("useVendorRosterForm stale-selection handling", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
it("keeps the newest company roster when two lookups resolve out of order", async () => {
@ -280,97 +316,3 @@ describe("useVendorRosterForm stale-selection handling", () => {
expect(result.current.name.field.value).toBe("Draft Vendor");
});
});
describe("useVendorRosterForm blank technician filtering", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
it("omits blank new technician rows from the save payload", () => {
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
act(() => {
result.current.submit({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
contactName: "Taylor",
phone: "(314) 555-0199",
email: "taylor@gateway.test",
tradeSpecialties: "Plumbing",
isActive: true,
},
{
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
],
});
});
expect(saveMutate).toHaveBeenCalledTimes(1);
const payload = saveMutate.mock.calls[0]?.[0] as {
values: { technicians: Array<{ contactName: string }> };
};
expect(payload.values.technicians).toHaveLength(1);
expect(payload.values.technicians[0]?.contactName).toBe("Taylor");
});
it("keeps existing technicians with an id even when contact fields are blank", () => {
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
act(() => {
result.current.submit({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
id: 9,
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
],
});
});
const payload = saveMutate.mock.calls[0]?.[0] as {
values: { technicians: Array<{ id?: number }> };
};
expect(payload.values.technicians).toEqual([
{
id: 9,
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
]);
});
});

View file

@ -49,11 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
};
}
describe("VendorDetailDrawer preferred-contact display", () => {
describe("VendorDetailDrawer selected-technician display", () => {
it("renders no preference label when preferredContact is absent", () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{ id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
]),
);
@ -62,15 +62,15 @@ describe("VendorDetailDrawer preferred-contact display", () => {
withAuth: false,
});
expect(screen.getByText("Adam")).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
});
it("renders the preference label when an explicit preferredContact is present", () => {
it("keeps the retired preferred-contact value out of the detail view", () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{
id: 7,
id: 1,
contactName: "Adam",
phone: "314-555-0198",
preferredContact: "Email",
@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => {
withAuth: false,
});
expect(screen.getByText("Prefers Email")).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
});
it("falls back to the clicked row when the roster has no matching stable id", () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{
id: 7,
contactName: "Adam Whyte",
phone: "999-999-9999",
isActive: true,
totalJobs: 99,
},
]),
);
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
expect(screen.getByText("0")).toBeInTheDocument();
expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument();
});
it("does not expose a status control for a different technician", () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{
id: 7,
contactName: "Different Technician",
phone: "314-555-0101",
email: "",
tradeSpecialties: "Plumbing",
isActive: true,
totalJobs: 10,
},
]),
);
renderWithProviders(
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
{ route: "/vendors", withAuth: false },
);
expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument();
});
});

View file

@ -1,21 +1,14 @@
import { screen } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import VendorsListPage from "@/app/(protected)/vendors/index";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
import { renderWithProviders } from "@/test/test-utils";
const useVendorsList = vi.fn();
const useVendorCompanyRoster = vi.fn();
const useVendorDeactivationImpact = vi.fn();
const mutate = vi.fn();
const navigate = vi.fn();
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return { ...actual, useNavigate: () => navigate };
});
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
}));
@ -61,6 +54,32 @@ const inactiveVendor = {
isActive: false,
};
const activeRoster: VendorCompanyRoster = {
companyId: "co-1",
rowVersion: "rv-1",
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gatewayplumbing.com",
address: "",
city: "St. Louis",
state: "MO",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
id: 1,
contactName: "Adam Whyte",
phone: "(314) 555-0198",
email: "dispatch@gatewayplumbing.com",
preferredContact: "Email",
tradeSpecialties: "Backflow Preventers",
isActive: true,
totalJobs: 0,
},
],
};
function result(items: VendorListItem[], totalCount: number) {
return {
data: { items, totalCount, page: 1, pageSize: 12 },
@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) {
}
function setupDefaults() {
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null });
useVendorCompanyRoster.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorDeactivationImpact.mockReturnValue({
data: undefined,
isLoading: false,
@ -80,7 +105,7 @@ function setupDefaults() {
}
describe("VendorsListPage", () => {
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
it("renders prototype-aligned status and two row actions in both tabs", async () => {
setupDefaults();
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
@ -91,11 +116,20 @@ describe("VendorsListPage", () => {
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "View vendor Gateway Plumbing" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
});
@ -114,39 +148,18 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument();
expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument();
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
});
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: {
companyId: "co-1",
rowVersion: "rv-1",
name: "Gateway Plumbing",
companyPhone: "(314) 555-0198",
email: "dispatch@gatewayplumbing.com",
address: "",
city: "St. Louis",
state: "MO",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
id: 1,
contactName: "Adam Whyte",
phone: "(314) 555-0198",
email: "dispatch@gatewayplumbing.com",
preferredContact: "Email",
tradeSpecialties: "Backflow Preventers",
isActive: true,
},
],
},
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
@ -161,11 +174,18 @@ describe("VendorsListPage", () => {
row.focus();
await userEvent.keyboard("{Enter}");
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
});
it("blocks deactivation when the preflight reports open work orders", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorDeactivationImpact.mockReturnValue({
data: {
vendorId: 1,
@ -189,7 +209,8 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
expect(
screen.getByText(/cannot be deactivated because it still has open work orders/),
@ -199,9 +220,51 @@ describe("VendorsListPage", () => {
expect(mutate).not.toHaveBeenCalled();
});
it("navigates to the vendor edit route when the table Edit control is clicked", async () => {
it("preserves inline edits when deactivation is cancelled", async () => {
setupDefaults();
navigate.mockClear();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorDeactivationImpact.mockReturnValue({
data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] },
isLoading: false,
error: null,
});
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
const company = screen.getByRole("textbox", { name: "Company" });
await userEvent.clear(company);
await userEvent.type(company, "Draft Company Name");
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
await userEvent.click(
within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", {
name: "Cancel",
}),
);
expect(company).toBeInTheDocument();
expect(company).toHaveValue("Draft Company Name");
}, 10_000);
it("opens inline edit when the table Edit control is clicked", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
@ -211,16 +274,20 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
expect(navigate).toHaveBeenCalledWith("/vendors/edit/1");
expect(
screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
});
it("navigates to the company route when editing a vendor without a vendor id", async () => {
it("opens inline company edit when the row has no vendor id", async () => {
setupDefaults();
navigate.mockClear();
const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" };
useVendorCompanyRoster.mockReturnValue({
data: { ...activeRoster, companyId: "co-9", technicians: [] },
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([companyOnlyVendor], 1) : result([], 0);
@ -230,6 +297,7 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9");
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
});
});

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { buildInlineDraftCreateInput } from "@/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
describe("buildInlineDraftCreateInput", () => {
it("defaults type to PM and omits schedule flags when undated", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
scheduleWeekOnly: true,
targetWeek: "",
scheduledOn: "",
};
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
expect(input.type).toBe("PM");
expect(input.scheduleWeekOnly).toBe(false);
expect(input.scheduledOn).toBe("");
expect(input.targetWeek).toBe("");
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(payload.scheduledDate).toBeUndefined();
expect(payload.targetWeek).toBeUndefined();
expect(payload.scheduleWeekOnly).toBeUndefined();
expect(payload.woNumber).toBe("25001");
expect(payload.pocName).toBe("Jane");
});
it("keeps week-only schedule when targetWeek is set", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25002",
type: "Reactive" as const,
pocName: "Jane",
pocPhone: "555-0100",
scheduleWeekOnly: true,
targetWeek: "2026-06-22",
};
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(payload.scheduleWeekOnly).toBe(true);
expect(payload.targetWeek).toBe("2026-06-22");
expect(payload.scheduledDate).toBeUndefined();
});
});

View file

@ -0,0 +1,58 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
describe("DuplicateWoNumberDialog", () => {
it("shows the duplicate title and work order number when open", () => {
renderWithProviders(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={vi.fn()}
onViewExisting={vi.fn()}
/>,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
expect(screen.getByText(/12345678901/)).toBeInTheDocument();
});
it("calls onViewExisting when View existing is clicked", () => {
const onViewExisting = vi.fn();
renderWithProviders(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={vi.fn()}
onViewExisting={onViewExisting}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "View existing" }));
expect(onViewExisting).toHaveBeenCalledWith(existingRow);
});
it("calls onDismiss when Dismiss is clicked", () => {
const onDismiss = vi.fn();
renderWithProviders(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={onDismiss}
onViewExisting={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,91 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
const findByWoNumber = vi.fn();
vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () => ({
useCreateWorkOrderFromWizard: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderVendors: () => ({ data: [] }),
}));
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
useLocationSites: () => ({ data: [] }),
}));
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: undefined }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [] }),
}));
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
findByWoNumber: (...args: unknown[]) => findByWoNumber(...args),
},
}));
describe("NewWoWizard duplicate wo number", () => {
it("shows the duplicate dialog when the wizard wo number matches an existing row", async () => {
renderWithProviders(
<NewWoWizard
open
onOpenChange={vi.fn()}
users={[]}
existingRows={[existingRow]}
onViewExistingWo={vi.fn()}
/>,
);
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901");
fireEvent.change(woNumberInput, {
target: { value: "12345678901" },
});
fireEvent.blur(woNumberInput);
await waitFor(() => {
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
});
expect(screen.getByText(/is already assigned to another work order/i)).toBeInTheDocument();
});
it("finds a duplicate outside the loaded board rows", async () => {
const offPageRow = {
id: "wo-off-page",
woNumber: "98765432109",
} as WorkOrderTableRow;
findByWoNumber.mockResolvedValueOnce(offPageRow);
renderWithProviders(
<NewWoWizard
open
onOpenChange={vi.fn()}
users={[]}
existingRows={[]}
onViewExistingWo={vi.fn()}
/>,
);
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901");
fireEvent.change(woNumberInput, {
target: { value: "98765432109" },
});
fireEvent.blur(woNumberInput);
await waitFor(() => {
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
});
expect(findByWoNumber).toHaveBeenCalledWith("98765432109");
});
});

View file

@ -0,0 +1,112 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
import type { Location } from "@/domain/locations/types/location";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
const detailById = new Map<string, Location>();
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: (id: string | undefined) => ({
data: id && detailById.has(id) ? detailById.get(id) : undefined,
isLoading: Boolean(id && !detailById.has(id)),
isError: false,
}),
}));
const SITES: LocationOption[] = [
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
];
function locationDetail(id: string, contact: string, phone: string): Location {
return {
id,
name: id,
contact,
phone,
address: "100 Main St",
city: "Dallas",
state: "TX",
zipCode: "75001",
};
}
function renderSiteDialogState() {
return renderHook(() =>
useSiteDialogState({
open: true,
onOpenChange: vi.fn(),
value: "",
locationId: "",
pocName: "",
pocPhone: "",
pocNotes: "",
sites: SITES,
createMode: true,
onSave: vi.fn(),
}),
);
}
describe("useSiteDialogState POC autofill", () => {
beforeEach(() => {
detailById.clear();
});
it("preserves POC typed before deferred location detail resolves", () => {
const { result, rerender } = renderSiteDialogState();
act(() => {
result.current.handlePick("loc-1");
});
act(() => {
result.current.setPn("Dispatcher Typed");
result.current.setPp("999-000-1111");
});
expect(result.current.pn).toBe("Dispatcher Typed");
expect(result.current.pp).toBe("999-000-1111");
detailById.set("loc-1", locationDetail("loc-1", "Site Contact", "214-555-0111"));
act(() => {
rerender();
});
expect(result.current.pn).toBe("Dispatcher Typed");
expect(result.current.pp).toBe("999-000-1111");
});
it("clears prior POC on site switch then autofills from the new site detail", () => {
const { result, rerender } = renderSiteDialogState();
act(() => {
result.current.handlePick("loc-1");
});
detailById.set("loc-1", locationDetail("loc-1", "Contact A", "111-111-1111"));
act(() => {
rerender();
});
expect(result.current.pn).toBe("Contact A");
expect(result.current.pp).toBe("111-111-1111");
act(() => {
result.current.handlePick("loc-2");
});
expect(result.current.locId).toBe("loc-2");
expect(result.current.pn).toBe("");
expect(result.current.pp).toBe("");
detailById.set("loc-2", locationDetail("loc-2", "Contact B", "222-222-2222"));
act(() => {
rerender();
});
expect(result.current.pn).toBe("Contact B");
expect(result.current.pp).toBe("222-222-2222");
});
});

View file

@ -0,0 +1,43 @@
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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
function buildTableData() {
return {
dragState: { current: { dragId: null, overId: null } },
setDragViz: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
setUnschedOpen: vi.fn(),
triggerFlash: vi.fn(),
setSelectedId: vi.fn(),
} as never;
}
describe("useWoTableRowHandlers onDuplicateFound", () => {
it("delegates to onDuplicateFound instead of opening the slide-over", () => {
const onDuplicateFound = vi.fn();
const onOpenSlideOver = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(),
weekMonday: "2026-07-20",
onWeekChange: vi.fn(),
onOpenSlideOver,
onOpenMedia: vi.fn(),
onPatch: vi.fn(),
onScheduleHighlight: vi.fn(),
onDuplicateFound,
}),
);
result.current.onDuplicateFound?.(existingRow);
expect(onDuplicateFound).toHaveBeenCalledWith(existingRow);
expect(onOpenSlideOver).not.toHaveBeenCalled();
});
});

View file

@ -17,6 +17,7 @@ const unscheduledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
@ -38,7 +39,6 @@ const unscheduledRow: WorkOrderTableRow = {
pocPhone: "",
pocNotes: "",
dueDate: "2026-07-15",
completedDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",

View file

@ -0,0 +1,52 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { renderWithProviders } from "@/test/test-utils";
function buildData(
overrides: Partial<VendorPortalDispatchDetail> = {},
): VendorPortalDispatchDetail {
return {
id: 9,
dispatchNumber: "DSP-9",
status: "Verified",
nteAmount: 500,
checklist: [],
comments: [],
upliftRequests: [],
signoffs: [],
documents: [],
...overrides,
};
}
describe("DispatchSnapshotDetails locked banner", () => {
it.each(["Verified", "Cancelled", "Refused"])(
"states that comments remain open when status is %s",
(status) => {
renderWithProviders(<DispatchSnapshotDetails data={buildData({ status })} locked />, {
withAuth: false,
});
expect(
screen.getByText(
new RegExp(
`This dispatch is ${status.toLowerCase()} and locked to further changes\\. Comments remain open\\.`,
"i",
),
),
).toBeInTheDocument();
expect(screen.queryByText(/sign-off/i)).not.toBeInTheDocument();
},
);
it("hides the locked banner when the dispatch is not locked", () => {
renderWithProviders(
<DispatchSnapshotDetails data={buildData({ status: "In Progress" })} locked={false} />,
{ withAuth: false },
);
expect(screen.queryByText(/locked to further changes/i)).not.toBeInTheDocument();
});
});

View file

@ -1,32 +1,7 @@
import { createElement, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getSingleStatusOnlyChange,
useSaveVendorCompanyRoster,
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import { describe, expect, it } from "vitest";
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
const updateVendor = vi.fn();
vi.mock("@/domain/vendors/api/vendors-api", () => ({
vendorsApi: {
update: (...args: unknown[]) => updateVendor(...args),
},
}));
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
vendorCompanyRosterApi: {
update: vi.fn(),
create: vi.fn(),
},
}));
vi.mock("react-toastify", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const roster: VendorCompanyRoster = {
companyId: 10,
rowVersion: "rv-1",
@ -94,49 +69,3 @@ describe("getSingleStatusOnlyChange", () => {
).toBeNull();
});
});
describe("useSaveVendorCompanyRoster status-only deactivation gate", () => {
beforeEach(() => {
updateVendor.mockReset();
updateVendor.mockResolvedValue(undefined);
});
it("rejects sparse Active→Inactive updates and still allows reactivation", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
const { result } = renderHook(() => useSaveVendorCompanyRoster(), { wrapper });
const activeRoster: VendorCompanyRoster = {
...roster,
technicians: [{ ...roster.technicians[0], isActive: true }],
};
await expect(
result.current.mutateAsync({
mode: "update",
values: {
...values,
technicians: [{ ...values.technicians[0], isActive: false }],
},
companyId: 10,
rowVersion: "rv-1",
originalRoster: activeRoster,
}),
).rejects.toThrow("Use Deactivate to check open work orders before inactivating a technician.");
expect(updateVendor).not.toHaveBeenCalled();
await result.current.mutateAsync({
mode: "update",
values: {
...values,
technicians: [{ ...values.technicians[0], isActive: true }],
},
companyId: 10,
rowVersion: "rv-1",
originalRoster: roster,
});
expect(updateVendor).toHaveBeenCalledWith(7, { isActive: true });
});
});

View file

@ -299,6 +299,157 @@ describe("workOrdersApi.searchBoard", () => {
});
});
describe("workOrdersApi.findByWoNumber", () => {
beforeEach(() => {
apiGet.mockReset();
});
it("searches globally without the active board filters", async () => {
apiGet.mockResolvedValue({
items: [
{
id: 505,
woNumber: "10000001235",
lifecycleStatusLabel: "Incomplete",
},
],
totalCount: 1,
page: 0,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("10000001235");
expect(apiGet).toHaveBeenCalledWith(
API_PATHS.workOrder.boardSearch,
expect.objectContaining({
searchParams: expect.any(URLSearchParams),
}),
);
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "10000001235",
page: "0",
pageSize: "100",
});
expect(result).toMatchObject({ id: 505, woNumber: "10000001235" });
});
it("pages through fuzzy search until the exact WO number is found", async () => {
apiGet
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
woNumber: `fuzzy-match-${index}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 101,
page: 0,
pageSize: 100,
})
.mockResolvedValueOnce({
items: [
{
id: 999,
woNumber: "10000009999",
lifecycleStatusLabel: "Incomplete",
},
],
totalCount: 101,
page: 1,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("10000009999");
expect(apiGet).toHaveBeenCalledTimes(2);
const secondParams = apiGet.mock.calls[1]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(secondParams)).toMatchObject({
search: "10000009999",
page: "1",
pageSize: "100",
});
expect(result).toMatchObject({ id: 999, woNumber: "10000009999" });
});
it("skips remote search for empty WO numbers", async () => {
const result = await workOrdersApi.findByWoNumber(" ");
expect(apiGet).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("finds a one-character WO number duplicate outside loaded board rows", async () => {
apiGet.mockResolvedValue({
items: [
{
id: 7,
woNumber: "9",
lifecycleStatusLabel: "Incomplete",
},
],
totalCount: 1,
page: 0,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("9");
expect(apiGet).toHaveBeenCalledTimes(1);
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "9",
page: "0",
pageSize: "100",
});
expect(result).toMatchObject({ id: 7, woNumber: "9" });
});
it("throws when the safety page limit is hit before exhausting results", async () => {
apiGet.mockResolvedValue({
items: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
woNumber: `fuzzy-match-${index}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 10_000,
page: 0,
pageSize: 100,
});
await expect(workOrdersApi.findByWoNumber("10000005555")).rejects.toThrow(/safety page limit/i);
expect(apiGet).toHaveBeenCalledTimes(50);
});
it("returns undefined after exhausting all fuzzy search pages", async () => {
apiGet
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
woNumber: `fuzzy-match-${index}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 150,
page: 0,
pageSize: 100,
})
.mockResolvedValueOnce({
items: Array.from({ length: 50 }, (_, index) => ({
id: index + 101,
woNumber: `fuzzy-match-${index + 100}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 150,
page: 1,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("10000005555");
expect(apiGet).toHaveBeenCalledTimes(2);
expect(result).toBeUndefined();
});
});
describe("workOrdersApi.patchBoardField", () => {
beforeEach(() => {
apiPatchFn.mockReset();

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols";
import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar-board-ops";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
describe("COMPLETED DATE board column wiring", () => {
it("includes COMPLETED DATE column after COMP DOC", () => {
const keys = WO_TABLE_COLS.map((col) => col.key);
expect(keys).toContain("completed");
expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc"));
});
it("expands completedDate patch to board field op", () => {
const ops = expandScalarBoardOps({ completedDate: "2026-07-30" });
expect(ops).toEqual([{ field: "completedDate", value: "2026-07-30" }]);
});
it("maps completedDate returned by a board GET row", () => {
const row = mapWorkOrderTableRow({
id: 42,
completedDate: "2026-07-30T00:00:00Z",
});
expect(row.completedDate).toBe("2026-07-30");
});
});

View file

@ -206,6 +206,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.scheduledDate).toBeUndefined();
});
it("omits week-mode flags when scheduleWeekOnly is set without a week", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
scheduleWeekOnly: true,
scheduledOn: "",
targetWeek: "",
});
expect(payload.scheduleWeekOnly).toBeUndefined();
expect(payload.targetWeek).toBeUndefined();
expect(payload.scheduledDate).toBeUndefined();
});
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const rows = [
{ id: "wo-1", woNumber: "12345678901" },
{ id: "wo-2", woNumber: "99999999999" },
] as WorkOrderTableRow[];
describe("findDuplicateWoNumberRow", () => {
it("returns a matching row when the wo number already exists", () => {
expect(findDuplicateWoNumberRow("12345678901", rows)).toEqual(rows[0]);
});
it("ignores blank values and excluded ids", () => {
expect(findDuplicateWoNumberRow("", rows)).toBeUndefined();
expect(findDuplicateWoNumberRow("12345678901", rows, "wo-1")).toBeUndefined();
});
});

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
draftHasScheduleDate,
getInlineCreateMissing,
inlineCreateSaveHint,
inlineCreateSiteError,
} from "@/domain/work-orders/utils/get-inline-create-missing";
describe("getInlineCreateMissing", () => {
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
expect(
getInlineCreateMissing({
site: "BK5",
locationId: "12",
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
}),
).toEqual([]);
});
it("accepts locationId alone as site presence", () => {
expect(
getInlineCreateMissing({
site: "",
locationId: 42,
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
}),
).toEqual([]);
});
it("lists missing POC fields without requiring schedule", () => {
expect(
getInlineCreateMissing({
site: "BK5",
woNumber: "25001",
pocName: "",
pocPhone: "",
}),
).toEqual(["pocName", "pocPhone"]);
});
it("lists site and woNumber when empty", () => {
expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
});
});
describe("inlineCreateSaveHint", () => {
it("returns Save when ready", () => {
expect(inlineCreateSaveHint([])).toBe("Save");
});
it("lists missing labels", () => {
expect(inlineCreateSaveHint(["site", "woNumber"])).toBe("Missing: Site, WO #");
});
});
describe("inlineCreateSiteError", () => {
it("prefers site-required message when site is missing", () => {
expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
});
it("surfaces contact message when only POC is missing", () => {
expect(inlineCreateSiteError(["pocName", "pocPhone"])).toBe(
"Contact name and phone are required",
);
});
});
describe("draftHasScheduleDate", () => {
it("is true for a concrete day", () => {
expect(draftHasScheduleDate({ scheduledOn: "2026-06-25" })).toBe(true);
});
it("is false for week-only", () => {
expect(
draftHasScheduleDate({ scheduledOn: "", scheduleWeekOnly: true, schedMode: "week" }),
).toBe(false);
});
});