chore(work-orders): merge origin/dev into pr39 integration branch

Integrate current origin/dev (vendor-core completion plus frontend
maintainability) into the work-order board shell branch.

Resolved semantic conflicts against the new frontend-maintainability
ESLint conventions introduced on dev: guard non-boolean && left
operands with Boolean()/!=null and replace raw <p> with the shared
Text component (media-upload-modal). PR #39 board shell/detail modules
and hooks preserved; merged vendor-core behavior and Text/ESLint
conventions preserved.
This commit is contained in:
Alexandre Brandizzi 2026-07-24 13:45:16 -03:00
commit 4f99406388
82 changed files with 4541 additions and 337 deletions

View file

@ -0,0 +1,115 @@
# Frontend maintainability conventions
## Conditional rendering
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
state. Use a ternary only when both branches render meaningful alternatives.
```tsx
{
error && <Alert severity="error">{error.message}</Alert>;
}
<Text variant="description" when={Boolean(description)}>
{description}
</Text>;
```
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
distinct from real either-or UI decisions.
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
TypeScript checker for the type of the left operand and reports unless every union constituent is
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
fails closed: if type services are unavailable in a governed TSX file it reports rather than
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
same constraint at that component boundary.
Approved guard forms (choose by semantics, not as a blind codemod):
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
narrowing.
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
that needs a non-boolean operand must be rewritten into one of the approved forms above.
## Typography and feedback
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
code, and asynchronous feedback. It owns:
- semantic HTML for each visual variant;
- the display, body, and monospace font families;
- default, muted, error, success, and warning tones;
- accessible `alert` and `status` live regions for error and feedback text;
- one-sided conditional text through `when`.
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
`aria-live` directly.
Live-region behavior:
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
the polite region exists before its content changes and is announced reliably. Mounting an
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
static surface only. It cannot resolve dynamic or composed class values
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
## Forms and mutations
Use the libraries already established in the application:
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
- Zod owns form validation and inferred form value types.
- TanStack Query owns server reads and mutations, including pending/error state, cache
invalidation, and retries where safe.
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
benchmarks, a codemod plan, and an approved deprecation path.
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
retry state. Do not place upload orchestration in a route-sized page component.
## Page state
Pages should compose focused state components instead of accumulating unrelated booleans:
- query loading, error, and empty states stay adjacent to the query result;
- mutation pending/error state belongs to the component that initiated the mutation;
- route pages coordinate sections and navigation;
- reusable sections own their interaction details;
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
## Enforcement and rollout
The lint rules are repository-wide and the initial violations were migrated in the same change.
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
maintainability rules must also land with a green migration rather than a warning-only backlog.

410
e2e/vendors/vendors.spec.ts vendored Normal file
View file

@ -0,0 +1,410 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor E2E Admin",
id: "1",
};
const activeVendors = [
{
Id: 1,
CompanyId: 101,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
Email: "dispatch@gateway.test",
Phone: "314-555-0198",
CompanyPhone: "314-555-0100",
Address: "1 Market St",
City: "St. Louis",
State: "MO",
Zip: "63101",
TradeSpecialties: "Plumbing, Backflow Preventers",
GoogleMapsUrl: "https://maps.google.com/gateway",
Notes: "Preferred after-hours vendor",
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
},
{
Id: 2,
CompanyId: 102,
CompanyName: "Metro HVAC",
ContactName: "Maya Chen",
Email: "maya@metro.test",
Phone: "314-555-0112",
CompanyPhone: "314-555-0110",
Address: "2 Pine St",
City: "Clayton",
State: "MO",
Zip: "63105",
TradeSpecialties: "HVAC",
GoogleMapsUrl: "",
Notes: "",
TotalJobs: 18,
IsActive: true,
},
];
const inactiveVendors = [
{
...activeVendors[0],
Id: 3,
CompanyName: "Drain Cleaning Bros",
ContactName: "Drew Banks",
TotalJobs: 151,
IsActive: false,
},
];
interface MockState {
listUrls: string[];
createdBody?: Record<string, unknown>;
updatedBody?: Record<string, unknown>;
deletedId?: string;
}
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function captureVisual(page: Page, path: string) {
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
await page.waitForTimeout(400);
await page.screenshot({ path, fullPage: true });
}
async function mockVendorApi(
page: Page,
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
) {
const state: MockState = { listUrls: [] };
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
fulfillJson(route, {
data: [
{ id: 1, value: "Plumbing" },
{ id: 2, value: "HVAC" },
],
}),
);
await page.route("**/api/Vendor/facets*", (route) =>
fulfillJson(route, {
data: {
companies: activeVendors.map((vendor) => ({
companyId: vendor.CompanyId,
name: vendor.CompanyName,
companyPhone: vendor.CompanyPhone,
email: vendor.Email,
address: vendor.Address,
city: vendor.City,
state: vendor.State,
zip: vendor.Zip,
googleMapsUrl: vendor.GoogleMapsUrl,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
],
jobBuckets: [
{ id: "under-50", label: "Under 50" },
{ id: "50-99", label: "50–99" },
{ id: "100-149", label: "100–149" },
{ id: "150-plus", label: "150+" },
],
},
}),
);
await page.route("**/api/Vendor/GetVendorList*", async (route) => {
const url = new URL(route.request().url());
state.listUrls.push(url.toString());
if (options.listError && url.searchParams.get("pageSize") !== "1") {
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
return;
}
const isActive = url.searchParams.get("isActive") !== "false";
const source = isActive ? activeVendors : inactiveVendors;
const search = (url.searchParams.get("search") ?? "").toLowerCase();
const company = url.searchParams.get("companies[0]");
const filtered = source.filter(
(vendor) =>
(!search ||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}`
.toLowerCase()
.includes(search)) &&
(!company || vendor.CompanyName === company),
);
const isCount = url.searchParams.get("pageSize") === "1";
await fulfillJson(route, {
Data: isCount ? filtered.slice(0, 1) : filtered,
TotalCount: filtered.length,
PageNumber: 1,
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
});
});
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
const vendorId = Number(route.request().url().split("/").at(-2));
await fulfillJson(route, {
data: {
vendorId,
canDeactivate: !options.deactivationBlocked,
openWorkOrders: options.deactivationBlocked
? [
{
workOrderId: 501,
workOrderNumber: "WO-501",
workOrderTitle: "Emergency boiler repair",
status: "Dispatched",
scheduledDate: "2026-07-24T14:00:00Z",
dispatchId: 801,
},
]
: [],
},
});
});
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
const id = route.request().url().split("/").pop() ?? "";
if (route.request().method() === "PUT") {
state.updatedBody = route.request().postDataJSON();
await fulfillJson(route, {
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) },
});
return;
}
if (route.request().method() === "DELETE") {
if (options.deleteConflict) {
await fulfillJson(
route,
{ message: "Vendor has open work orders and cannot be deactivated." },
409,
);
return;
}
state.deletedId = id;
await fulfillJson(route, { status: "200" });
return;
}
const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id);
await fulfillJson(route, { data: vendor });
});
await page.route(/\/api\/vendors\/?$/, async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
state.createdBody = route.request().postDataJSON();
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
});
return state;
}
test.describe("Vendor directory prototype parity", () => {
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
page,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
for (const heading of [
"Company",
"Technician",
"Location",
"Trade",
"Phone",
"Email",
"Total Jobs",
"Status",
]) {
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
}
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
await expect(page.getByText("Metro HVAC")).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...").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 expect(page.getByText("Gateway Plumbing").first()).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 page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
test("creates with company autofill, then views and edits every detail field", async ({
page,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "+ Add Vendor" }).click();
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
await page.getByLabel("Technician Name").fill("New Technician");
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" })).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 page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
await page.getByRole("button", { name: "Add trade" }).click();
await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC");
await page.getByRole("button", { name: "Add trade" }).click();
await page.getByRole("button", { name: "Move HVAC up" }).click();
await page.getByLabel("Notes").fill("Created in browser E2E");
await page.getByRole("button", { name: "Text" }).click();
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Add Vendor" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
expect(state.createdBody).toMatchObject({
companyName: "Gateway Plumbing",
companyId: 101,
contactName: "New Technician",
companyPhone: "(314) 555-0100",
preferredContact: "Text",
tradeSpecialties: "HVAC, Plumbing",
notes: "Created in browser E2E",
});
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.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
).toHaveAttribute("target", "_blank");
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72");
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly");
await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E");
await detailDrawer.getByRole("switch", { name: "Active" }).click();
await detailDrawer.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
expect(state.updatedBody?.isActive).toBe(false);
});
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
await addButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
await expect(addButton).toBeFocused();
await addButton.click();
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
await page.getByLabel("Technician Name").fill("Independent Tech");
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByRole("button", { name: "Add Vendor" }).last().click();
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
expect(state.createdBody?.companyId).toBeNull();
});
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
page,
}) => {
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().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();
expect(blockedState.deletedId).toBeUndefined();
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
await page.unrouteAll({ behavior: "wait" });
const racedState = await mockVendorApi(page, { deleteConflict: true });
await page.reload();
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page
.getByRole("dialog", { name: "Deactivate Vendor" })
.getByRole("button", {
name: /^Deactivate$/,
})
.click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
/open work orders|conflict/i,
);
expect(racedState.deletedId).toBeUndefined();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockVendorApi(page, { listError: true });
await page.goto("/vendors");
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await page.getByRole("button", { name: "Open vendor filters" }).click();
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
await expect(filterDrawer).toBeVisible();
const box = await filterDrawer.boundingBox();
expect(box?.width).toBeLessThanOrEqual(391);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
await page.getByRole("button", { name: "Close filters" }).click();
});
});

View file

@ -0,0 +1,90 @@
import { expect, test, type Route } from "@playwright/test";
const TEST_USER = {
token: "work-order-vendor-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
test("edits only the vendor assigned to the current work order", async ({ page }) => {
let updateRequest: { url: string; body: Record<string, unknown> } | undefined;
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, {
Id: 501,
InternalWONumber: "WO-501",
WorkerOrderNumber: "CUSTOMER-501",
WorkerOrderTitle: "Emergency boiler repair",
Status: "Dispatched",
Comments: [],
AuditLog: [],
Dispatches: [
{
Id: 801,
DispatchNumber: "D-801",
VendorId: 7,
VendorName: "Gateway Plumbing",
VendorEmail: "dispatch@gateway.test",
Status: "Sent",
DispatchedAt: "2026-07-23T12:00:00Z",
},
],
}),
);
await page.route("**/api/vendors/7/work-order-update", async (route) => {
updateRequest = {
url: route.request().url(),
body: route.request().postDataJSON() as Record<string, unknown>,
};
await fulfillJson(route, { data: { id: 7 } });
});
await page.route("**/api/vendors/7", (route) =>
fulfillJson(route, {
data: {
Id: 7,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
PreferredContact: "Email",
Phone: "3145550198",
Email: "dispatch@gateway.test",
Notes: "Preferred after-hours vendor",
IsActive: true,
},
}),
);
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
await page.goto("/workorders/501");
await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible();
await page.getByRole("button", { name: "Edit vendor" }).click();
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
await page.getByLabel("Technician name").fill("Taylor Tech");
await page.getByLabel("Preferred contact").click();
await page.getByRole("option", { name: "Text" }).click();
await page.getByLabel("Phone").fill("3145550111");
await page.getByLabel("Email").fill("taylor@example.com");
await page.getByLabel("Notes").fill("Text before arrival");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden();
expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/);
expect(updateRequest?.body).toEqual({
workOrderId: 501,
contactName: "Taylor Tech",
preferredContact: "Text",
phone: "(314) 555-0111",
email: "taylor@example.com",
notes: "Text before arrival",
});
});

View file

@ -0,0 +1,95 @@
import ts from "typescript";
/**
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
* boolean.
*
* `{value && <Element />}` renders its left operand when it is falsy and
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
* must be `boolean` (or boolean literals) across the whole union. The rule
* asks the TypeScript checker for the type of the left operand and reports
* unless every union constituent is boolean-like.
*
* Type-aware by design: a selector that cannot see operand types would give
* false confidence rather than enforcement. If type services are unexpectedly
* unavailable in a governed TSX file, the rule fails closed (reports) instead
* of silently claiming the expression is safe.
*/
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
function isBooleanLikeType(type) {
return (type.flags & booleanLikeFlags) !== 0;
}
function isEntirelyBoolean(type) {
if (type.isUnion()) {
return type.types.every((constituent) => isBooleanLikeType(constituent));
}
return isBooleanLikeType(type);
}
const transparentExpressionTypes = new Set([
"ChainExpression",
"ParenthesizedExpression",
"TSAsExpression",
"TSNonNullExpression",
"TSTypeAssertion",
]);
function isInRenderedPosition(node) {
let current = node;
while (current.parent) {
const parent = current.parent;
if (parent.type === "JSXExpressionContainer") {
return parent.parent?.type !== "JSXAttribute";
}
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
current = parent;
continue;
}
return false;
}
return false;
}
const rule = {
meta: {
type: "problem",
docs: {
description: "Require the left operand of `&&` in JSX to be entirely boolean",
},
schema: [],
messages: {
nonBooleanLeft:
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
typeServicesUnavailable:
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
},
},
create(context) {
const services = context.sourceCode?.parserServices ?? context.parserServices;
return {
LogicalExpression(node) {
if (node.operator !== "&&") {
return;
}
if (!isInRenderedPosition(node)) {
return;
}
if (!services || services.program === null) {
context.report({ node, messageId: "typeServicesUnavailable" });
return;
}
const leftType = services.getTypeAtLocation(node.left);
if (!isEntirelyBoolean(leftType)) {
context.report({ node: node.left, messageId: "nonBooleanLeft" });
}
},
};
},
};
export default rule;

View file

@ -0,0 +1,55 @@
function containsVpErrorToken(node) {
if (!node || typeof node !== "object") {
return false;
}
if (node.type === "Literal" && typeof node.value === "string") {
return node.value.split(/\s+/u).includes("vp-error");
}
if (node.type === "TemplateElement") {
return node.value.raw.split(/\s+/u).includes("vp-error");
}
return Object.entries(node).some(([key, value]) => {
if (key === "parent") {
return false;
}
if (Array.isArray(value)) {
return value.some(containsVpErrorToken);
}
return containsVpErrorToken(value);
});
}
const rule = {
meta: {
type: "problem",
docs: {
description: "Require the Text component for legacy vp-error styling",
},
schema: [],
messages: {
useText:
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
},
},
create(context) {
return {
JSXAttribute(node) {
if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
return;
}
const elementName = node.parent?.name;
if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
return;
}
context.report({ node, messageId: "useText" });
},
};
},
};
export default rule;

View file

@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js";
import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js";
const localRules = {
rules: {
"no-non-boolean-jsx-and": noNonBooleanJsxAnd,
"no-vp-error-outside-text": noVpErrorOutsideText,
},
};
const legacyIgnores = [
"src/pages/**",
"src/app/store.js",
@ -54,6 +64,7 @@ export default tseslint.config(
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
seahaven: localRules,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
@ -62,6 +73,23 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"seahaven/no-non-boolean-jsx-and": "error",
"seahaven/no-vp-error-outside-text": "error",
"no-restricted-syntax": [
"error",
{
selector:
"JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]",
message:
"Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.",
},
{
selector:
":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])",
message:
"Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.",
},
],
},
},
{

View file

@ -11,8 +11,8 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"

View file

@ -109,6 +109,8 @@ export const API_PATHS = {
update: "Vendor/Update",
delete: "Vendor/Delete",
dropdown: "Vendor/Dropdown",
facets: "Vendor/facets",
deactivationImpact: "deactivation-impact",
},
calendar: {
getEventsForCalendar: "Calendar/GetEventsForCalendar",

View file

@ -1,5 +1,6 @@
import type { ComponentPropsWithoutRef } from "react";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return (
<div className={cn(className)} {...props}>
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
{subtitle && (
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
{subtitle}
</p>
)}
<Text variant="title" className="m-0 text-center font-bold text-foreground">
{title}
</Text>
<Text
variant="description"
tone="muted"
when={Boolean(subtitle)}
className="mb-6! mt-1.5! text-center font-normal"
>
{subtitle}
</Text>
</div>
);
}

View file

@ -75,7 +75,9 @@ export function LoginForm({
disabled={isLoggingIn}
error={Boolean(errors.password)}
/>
{loginError && <FormErrorMessage message="Invalid email or password. Please try again." />}
{Boolean(loginError) && (
<FormErrorMessage message="Invalid email or password. Please try again." />
)}
</FormField>
<Button

View file

@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { Text } from "@/components/ui/text";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
@ -37,9 +38,13 @@ export default function LoginPage() {
isLoggingIn={isLoggingIn}
loginError={loginError}
/>
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
<Text
variant="caption"
tone="muted"
className="mt-5! block text-center text-(length:--text-xs)"
>
Having trouble? Contact your administrator.
</p>
</Text>
</AuthPageShell>
);
}

View file

@ -110,7 +110,7 @@ export default function AccountFormPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load account"}
</Alert>

View file

@ -118,7 +118,7 @@ export default function AccountsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load accounts"}
</Alert>

View file

@ -91,7 +91,7 @@ export default function AssetFormPage() {
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
{(error || optionsError) && (
{Boolean(error || optionsError) && (
<Alert severity="error">
{error instanceof Error
? error.message

View file

@ -116,7 +116,7 @@ export default function AssetsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load assets"}
</Alert>

View file

@ -120,7 +120,7 @@ export default function EventFormPage() {
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
{error && <Alert severity="error">Failed to load event</Alert>}
{Boolean(error) && <Alert severity="error">Failed to load event</Alert>}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>

View file

@ -127,7 +127,7 @@ export default function ContactFormPage() {
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contact"}
</Alert>

View file

@ -103,7 +103,7 @@ export default function ContactsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contacts"}
</Alert>

View file

@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{description && (
{Boolean(description) && (
<Typography
variant="body2"
sx={{
@ -106,7 +106,7 @@ export default function DashboardPage() {
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>

View file

@ -83,7 +83,7 @@ function SelectField({
</MenuItem>
))}
</Select>
{helperText && (
{Boolean(helperText) && (
<Typography variant="caption" color="error" className="mt-1">
{helperText}
</Typography>
@ -193,7 +193,7 @@ export default function EmployeeFormPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employee"}
</Alert>

View file

@ -124,7 +124,7 @@ export default function EmployeesListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employees"}
</Alert>

View file

@ -102,7 +102,7 @@ export default function FollowUpFormPage() {
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-up"}
</Alert>

View file

@ -129,7 +129,7 @@ export default function FollowUpsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-ups"}
</Alert>

View file

@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{description && (
{Boolean(description) && (
<Typography
variant="body2"
sx={{
@ -106,7 +106,7 @@ export default function DashboardPage() {
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>

View file

@ -114,7 +114,7 @@ export default function LocationFormPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"}
</Alert>

View file

@ -118,7 +118,7 @@ export default function LocationsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"}
</Alert>

View file

@ -80,8 +80,8 @@ export default function PmScheduleFormPage() {
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
{optionsError && (
{Boolean(error) && <Alert severity="error">Failed to load PM schedule</Alert>}
{Boolean(optionsError) && (
<Alert severity="error">
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
</Alert>

View file

@ -122,7 +122,7 @@ export default function PmSchedulesListPage() {
)}
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load PM schedules"}
</Alert>

View file

@ -158,7 +158,7 @@ export default function DropdownOptionsPage() {
alignItems: "stretch",
}}
>
{parentCategory && (
{Boolean(parentCategory) && (
<FormControl size="small" className="min-w-[160px]">
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
<Select
@ -201,7 +201,7 @@ export default function DropdownOptionsPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load options"}
</Alert>
@ -262,7 +262,7 @@ export default function DropdownOptionsPage() {
<Typography variant="body2" className="truncate">
{item.value}
</Typography>
{item.parentValue && (
{Boolean(item.parentValue) && (
<Chip size="small" label={item.parentValue} variant="outlined" />
)}
</Stack>

View file

@ -126,7 +126,7 @@ export default function TaskTemplatesPage() {
Create reusable checklists for work order dispatches
</Typography>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error" className="mb-4">
{error instanceof Error ? error.message : "Failed to load templates"}
</Alert>

View file

@ -157,7 +157,7 @@ export default function UpliftQueuePage() {
</FormControl>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
@ -215,7 +215,7 @@ export default function UpliftQueuePage() {
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{row.workOrderId && (
{Boolean(row.workOrderId) && (
<Button
size="small"
variant="outlined"

View file

@ -127,7 +127,7 @@ export default function VendorPoViewPage() {
Dispatch {dispatch.dispatchNumber || "—"}
</Typography>
<Chip size="small" label={dispatch.status || "—"} />
{vendor?.companyName && (
{vendor != null && Boolean(vendor.companyName) && (
<Typography variant="body2" color="text.secondary">
· {vendor.companyName}
</Typography>
@ -177,7 +177,7 @@ export default function VendorPoViewPage() {
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{dispatch.description && (
{Boolean(dispatch.description) && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
@ -189,7 +189,7 @@ export default function VendorPoViewPage() {
)}
</Paper>
{dispatch.primaryLocation && (
{dispatch.primaryLocation != null && (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
@ -277,7 +277,7 @@ export default function VendorPoViewPage() {
</Typography>
<Chip size="small" label={u.status} />
</Stack>
{u.vendorReason && (
{Boolean(u.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {u.vendorReason}
</Typography>

View file

@ -199,7 +199,7 @@ export default function VendorPosListPage() {
Clear filters
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor POs"}
</Alert>

View file

@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { VendorFormFields } from "./vendor-form-fields";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
interface VendorCreateModalProps {
open: boolean;
onClose: () => void;
tradeOptions: string[];
}
const defaultValues: VendorFormValues = {
companyName: "",
contactName: "",
email: "",
phone: "",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
companyId: null,
preferredContact: "Phone",
};
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
const { data: facets, isLoading: isLoadingFacets, error: facetsError } = useVendorFacets();
const createVendor = useCreateVendor();
const [submitError, setSubmitError] = useState<string | null>(null);
const {
control,
handleSubmit,
reset,
setValue,
trigger,
formState: { errors },
} = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema),
defaultValues,
mode: "onChange",
});
const currentValues = useWatch({ control });
const isFormValid = vendorSchema.safeParse(currentValues).success;
useEffect(() => {
if (open) {
reset(defaultValues);
setSubmitError(null);
}
}, [open, reset]);
const onCompanySelected = (company: VendorFacetCompany | null) => {
if (!company) return;
const options = { shouldDirty: true, shouldValidate: true } as const;
setValue("companyId", company.companyId, options);
setValue("companyPhone", company.companyPhone, options);
setValue("email", company.email, options);
setValue("address", company.address, options);
setValue("city", company.city, options);
setValue("state", company.state, options);
setValue("zip", company.zip, options);
setValue("googleMapsUrl", company.googleMapsUrl, options);
void trigger();
};
const onSubmit = (values: VendorFormValues) => {
setSubmitError(null);
createVendor.mutate(values, {
onSuccess: () => {
onClose();
},
onError: (error: Error) => {
setSubmitError(error.message || "Failed to create vendor");
},
});
};
const companies = facets?.companies ?? [];
return (
<Dialog
open={open}
onClose={createVendor.isPending ? undefined : onClose}
fullWidth
maxWidth="sm"
aria-labelledby="vendor-create-dialog-title"
slotProps={{ paper: { role: "dialog" } }}
>
<DialogTitle id="vendor-create-dialog-title">
Add Vendor
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Register a technician and their vendor company.
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={2} className="mt-1">
{Boolean(facetsError) && (
<Alert severity="warning">
Company autocomplete unavailable. You can still type a company manually.
</Alert>
)}
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
{isLoadingFacets ? (
<CircularProgress size={24} />
) : (
<Box>
<VendorFormFields
control={control}
errors={errors}
companies={companies}
tradeOptions={tradeOptions}
onCompanySelected={onCompanySelected}
/>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={createVendor.isPending}>
Cancel
</Button>
<Button
color="primary"
variant="contained"
onClick={handleSubmit(onSubmit)}
disabled={createVendor.isPending || !isFormValid}
>
{createVendor.isPending ? "Saving..." : "Add Vendor"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,486 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useForm, useWatch, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Drawer,
FormControlLabel,
IconButton,
Link,
Stack,
Switch,
Tooltip,
Typography,
} from "@mui/material";
import { VendorFormFields } from "./vendor-form-fields";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
const DRAWER_WIDTH = 420;
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
Phone: "Phone",
Email: "Email",
Text: "Text Message",
};
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "—";
const first = parts[0]?.[0] ?? "";
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
return (first + last).toUpperCase();
}
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
}
function toFormValues(vendor: Vendor): VendorFormValues {
return {
companyName: vendor.companyName ?? "",
contactName: vendor.contactName ?? "",
email: vendor.email ?? "",
phone: vendor.phone ?? "",
companyPhone: vendor.companyPhone ?? "",
googleMapsUrl: vendor.googleMapsUrl ?? "",
notes: vendor.notes ?? "",
totalJobs: vendor.totalJobs ?? 0,
address: vendor.address ?? "",
city: vendor.city ?? "",
state: vendor.state ?? "",
zip: vendor.zip ?? "",
tradeSpecialties: vendor.tradeSpecialties ?? "",
isActive: vendor.isActive,
companyId: vendor.companyId ?? null,
preferredContact: vendor.preferredContact ?? "Phone",
};
}
interface VendorDetailDrawerProps {
open: boolean;
vendor: VendorListItem | null;
mode: "view" | "edit";
onClose: () => void;
onModeChange: (mode: "view" | "edit") => void;
tradeOptions: string[];
}
export function VendorDetailDrawer({
open,
vendor,
mode,
onClose,
onModeChange,
tradeOptions,
}: VendorDetailDrawerProps) {
const vendorId = vendor?.id;
const { data: facets } = useVendorFacets();
const {
data: detail,
isLoading,
error,
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
const updateVendor = useUpdateVendor();
const [submitError, setSubmitError] = useState<string | null>(null);
const [original, setOriginal] = useState<VendorFormValues | null>(null);
const companies = useMemo(() => facets?.companies ?? [], [facets]);
const {
control,
handleSubmit,
reset,
setValue,
trigger,
formState: { errors },
} = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema),
mode: "onChange",
defaultValues: {
companyName: "",
contactName: "",
email: "",
phone: "",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
companyId: null,
preferredContact: "Phone",
},
});
const currentValues = useWatch({ control });
const isFormValid = vendorSchema.safeParse(currentValues).success;
useEffect(() => {
if (open && detail) {
const values = toFormValues(detail);
reset(values);
setOriginal(values);
setSubmitError(null);
}
if (!open) {
setOriginal(null);
setSubmitError(null);
}
}, [open, detail, reset]);
const onCompanySelected = (company: (typeof companies)[number] | null) => {
if (!company) return;
const options = { shouldDirty: true, shouldValidate: true } as const;
setValue("companyId", company.companyId, options);
setValue("companyPhone", company.companyPhone, options);
setValue("email", company.email, options);
setValue("address", company.address, options);
setValue("city", company.city, options);
setValue("state", company.state, options);
setValue("zip", company.zip, options);
setValue("googleMapsUrl", company.googleMapsUrl, options);
void trigger();
};
const handleCancel = () => {
if (original) {
reset(original);
}
setSubmitError(null);
onModeChange("view");
};
const onSubmit = (values: VendorFormValues) => {
if (vendorId === undefined || vendorId === null || vendorId === "") return;
setSubmitError(null);
updateVendor.mutate(
{ id: vendorId, values },
{
onSuccess: () => {
onModeChange("view");
},
onError: (err: Error) => {
setSubmitError(err.message || "Failed to save vendor");
},
},
);
};
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
const mapsUrl = detail?.googleMapsUrl;
return (
<Drawer
open={open}
onClose={updateVendor.isPending ? undefined : onClose}
anchor="right"
aria-labelledby="vendor-detail-drawer-title"
ModalProps={{ keepMounted: false }}
sx={{
"& .MuiDrawer-paper": {
width: { xs: "100%", sm: DRAWER_WIDTH },
maxWidth: "100%",
},
}}
>
<Stack className="flex h-full flex-col">
<Stack
direction="row"
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 3,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box
className="flex items-center justify-center"
sx={{
width: 36,
height: 36,
borderRadius: "50%",
backgroundColor: "var(--accent, #1976d2)",
color: "#fff",
fontSize: 13,
fontWeight: 700,
letterSpacing: 0.5,
}}
>
{getInitials(vendor?.contactName ?? "")}
</Box>
<Box>
<Typography
id="vendor-detail-drawer-title"
variant="subtitle1"
component="h2"
className="font-semibold"
>
{vendor?.companyName || "Vendor"}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{mode === "edit" ? "Editing vendor" : "Vendor details"}
</Typography>
</Box>
</Stack>
<IconButton
aria-label="Close drawer"
onClick={onClose}
size="small"
disabled={updateVendor.isPending}
>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
{isLoading ? (
<Box className="flex flex-1 items-center justify-center">
<CircularProgress size={28} />
</Box>
) : error ? (
<Alert severity="error" sx={{ m: 2 }}>
{error instanceof Error ? error.message : "Failed to load vendor"}
</Alert>
) : detail ? (
<>
{mode === "view" ? (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip
size="small"
label={detail.isActive ? "Active" : "Inactive"}
color={detail.isActive ? "success" : "default"}
/>
{Boolean(trades[0]) && (
<Chip
size="small"
variant="outlined"
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
/>
)}
</Stack>
<DetailSection title="Technician">
<DetailField label="Technician Name" value={detail.contactName} />
<DetailField label="Technician Phone" value={detail.phone} />
<DetailField
label="Preferred Contact"
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
/>
</DetailSection>
<DetailSection title="Company">
<DetailField label="Company" value={detail.companyName} />
<DetailField label="Company Phone" value={detail.companyPhone} />
<DetailField label="Email" value={detail.email} />
</DetailSection>
<DetailSection title="Location">
<DetailField
label="Address"
value={[detail.address, detail.city, detail.state, detail.zip]
.filter(Boolean)
.join(", ")}
/>
{Boolean(mapsUrl) && (
<Link
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
>
<LaunchIcon fontSize="small" />
Open in Google Maps
</Link>
)}
</DetailSection>
<DetailSection title="Trades">
{trades.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No trades specified.
</Typography>
) : (
<Box className="flex flex-wrap gap-1">
{trades.map((trade, idx) => (
<Chip
key={`${trade}-${idx}`}
size="small"
label={idx === 0 ? `${trade} (primary)` : trade}
/>
))}
</Box>
)}
</DetailSection>
<DetailSection title="Activity">
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
</DetailSection>
{Boolean(detail.notes) && (
<DetailSection title="Notes">
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{detail.notes}
</Typography>
</DetailSection>
)}
</Stack>
) : (
<Box
component="form"
id="vendor-edit-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-1 flex-col overflow-y-auto"
sx={{ px: 3, py: 3 }}
>
<Stack spacing={3}>
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
<Controller
control={control}
name="isActive"
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={Boolean(field.value)}
onChange={field.onChange}
color="success"
/>
}
label={field.value ? "Active" : "Inactive"}
sx={{
alignSelf: "flex-start",
}}
/>
)}
/>
<VendorFormFields
control={control}
errors={errors}
companies={companies}
tradeOptions={tradeOptions}
onCompanySelected={onCompanySelected}
showTotalJobs
/>
</Stack>
</Box>
)}
<Stack
direction="row"
spacing={2}
sx={{
px: 3,
py: 2,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
{mode === "view" ? (
<>
{Boolean(mapsUrl) && (
<Tooltip title="Open in Google Maps">
<Button
component="a"
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
startIcon={<LaunchIcon />}
>
Maps
</Button>
</Tooltip>
)}
<Box className="flex-1" />
<Button
variant="contained"
onClick={() => onModeChange("edit")}
startIcon={<EditOutlinedIcon />}
>
Edit
</Button>
</>
) : (
<>
<Button
variant="outlined"
onClick={handleCancel}
disabled={updateVendor.isPending}
fullWidth
>
Cancel
</Button>
<Button
variant="contained"
type="submit"
form="vendor-edit-form"
disabled={updateVendor.isPending || !isFormValid}
fullWidth
>
{updateVendor.isPending ? "Saving..." : "Save"}
</Button>
</>
)}
</Stack>
</>
) : (
<Box className="flex flex-1 items-center justify-center">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No vendor selected.
</Typography>
</Box>
)}
</Stack>
</Drawer>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<Box>
<Typography
variant="subtitle2"
className="font-semibold uppercase"
sx={{ color: "text.secondary", mb: 1 }}
>
{title}
</Typography>
<Stack spacing={1.5}>{children}</Stack>
</Box>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
const display = value && value.trim() !== "" ? value : "—";
return (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
{label}
</Typography>
<Typography variant="body2">{display}</Typography>
</Box>
);
}

View file

@ -0,0 +1,316 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import CloseIcon from "@mui/icons-material/Close";
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,
Drawer,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
interface VendorFiltersDrawerProps {
open: boolean;
onClose: () => void;
facets: VendorFacets | undefined;
appliedFilters: VendorFilters;
onApply: (filters: VendorFilters) => void;
}
const DRAWER_WIDTH = 360;
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>
);
}
export function VendorFiltersDrawer({
open,
onClose,
facets,
appliedFilters,
onApply,
}: VendorFiltersDrawerProps) {
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
useEffect(() => {
if (open) {
setDraft(appliedFilters);
}
}, [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;
const handleClear = () => {
setDraft(emptyVendorFilters);
};
const handleApply = () => {
onApply(draft);
onClose();
};
return (
<Drawer
open={open}
onClose={onClose}
anchor="right"
aria-labelledby="vendor-filters-drawer-title"
ModalProps={{ keepMounted: false }}
sx={{
"& .MuiDrawer-paper": {
width: { xs: "100%", sm: DRAWER_WIDTH },
maxWidth: "100%",
},
}}
>
<Stack className="flex h-full flex-col">
<Stack
direction="row"
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 3,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
Filters
</Typography>
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
</Stack>
<IconButton aria-label="Close filters" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
<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:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.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 tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.trades.length === 0 ? "All trades" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.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 tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.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 tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
</Box>
<Stack
direction="row"
spacing={2}
sx={{
px: 3,
py: 2,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
Clear all
</Button>
<Button variant="contained" onClick={handleApply} fullWidth>
Apply
</Button>
</Stack>
</Stack>
</Drawer>
);
}

View file

@ -0,0 +1,13 @@
export interface VendorFilters {
companies: string[];
trades: string[];
locations: string[];
jobBuckets: string[];
}
export const emptyVendorFilters: VendorFilters = {
companies: [],
trades: [],
locations: [],
jobBuckets: [],
};

View file

@ -0,0 +1,420 @@
import { useState } from "react";
import { Controller, type Control, type FieldErrors } from "react-hook-form";
import AddIcon from "@mui/icons-material/Add";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import CloseIcon from "@mui/icons-material/Close";
import {
Autocomplete,
Box,
Button,
Chip,
Divider,
FormLabel,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import {
VENDOR_PREFERRED_CONTACT_VALUES,
type VendorFacetCompany,
type VendorPreferredContact,
} from "@/domain/vendors/types/vendor";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
interface VendorFormFieldsProps {
control: Control<VendorFormValues>;
errors: FieldErrors<VendorFormValues>;
companies: VendorFacetCompany[];
tradeOptions: string[];
onCompanySelected: (company: VendorFacetCompany | null) => void;
showTotalJobs?: boolean;
}
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
}
function joinTrades(trades: string[]): string {
return trades.join(", ");
}
function formatPhoneMask(input: string): string {
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
if (digits.length === 0) return "";
if (digits.length < 4) return `(${digits}`;
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
Phone: "Phone",
Email: "Email",
Text: "Text",
};
export function VendorFormFields({
control,
errors,
companies,
tradeOptions,
onCompanySelected,
showTotalJobs = false,
}: VendorFormFieldsProps) {
const [tradeInput, setTradeInput] = useState("");
return (
<Stack spacing={3}>
<Box>
<Typography variant="subtitle2" className="font-semibold uppercase">
Technician
</Typography>
<Stack spacing={2} className="mt-2">
<Controller
control={control}
name="contactName"
render={({ field }) => (
<TextField
{...field}
label="Technician Name"
required
error={Boolean(errors.contactName)}
helperText={errors.contactName?.message}
fullWidth
/>
)}
/>
<Controller
control={control}
name="phone"
render={({ field }) => (
<TextField
{...field}
label="Technician Phone"
placeholder="(XXX) XXX-XXXX"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
fullWidth
/>
)}
/>
<Box>
<FormLabel component="legend" sx={{ mb: 0.5 }}>
Preferred Contact
</FormLabel>
<Controller
control={control}
name="preferredContact"
render={({ field }) => (
<ToggleButtonGroup
exclusive
size="small"
value={field.value ?? "Phone"}
onChange={(_event, value: VendorPreferredContact | null) => {
if (value) field.onChange(value);
}}
aria-label="Preferred contact"
>
{VENDOR_PREFERRED_CONTACT_VALUES.map((option) => (
<ToggleButton key={option} value={option} aria-label={option}>
{PREFERRED_CONTACT_LABELS[option]}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
/>
</Box>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" className="font-semibold uppercase">
Company
</Typography>
<Stack spacing={2} className="mt-2">
<Controller
control={control}
name="companyName"
render={({ field }) => (
<Controller
control={control}
name="companyId"
render={({ field: companyIdField }) => (
<Autocomplete
freeSolo
options={companies}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={field.value ?? ""}
inputValue={field.value ?? ""}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
field.onChange(value);
companyIdField.onChange(null);
}
}}
onChange={(_event, value, reason) => {
if (typeof value === "string") {
field.onChange(value);
companyIdField.onChange(null);
return;
}
if (value) {
field.onChange(value.name);
companyIdField.onChange(value.companyId);
if (reason === "selectOption") {
onCompanySelected(value);
}
} else {
field.onChange("");
companyIdField.onChange(null);
}
}}
renderInput={(params) => (
<TextField
{...params}
label="Company"
required
placeholder="Select or type a company"
error={Boolean(errors.companyName)}
helperText={errors.companyName?.message}
/>
)}
/>
)}
/>
)}
/>
<Controller
control={control}
name="companyPhone"
render={({ field }) => (
<TextField
{...field}
label="Company Phone"
placeholder="(XXX) XXX-XXXX"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}
fullWidth
/>
)}
/>
<Controller
control={control}
name="email"
render={({ field }) => (
<TextField
{...field}
label="Email"
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
fullWidth
/>
)}
/>
<Controller
control={control}
name="address"
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Controller
control={control}
name="city"
render={({ field }) => <TextField {...field} label="City" fullWidth />}
/>
<Controller
control={control}
name="state"
render={({ field }) => (
<TextField {...field} label="State" placeholder="e.g., IL" fullWidth />
)}
/>
<Controller
control={control}
name="zip"
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
/>
</Stack>
<Controller
control={control}
name="googleMapsUrl"
render={({ field }) => (
<TextField
{...field}
label="Google Maps URL"
placeholder="https://maps.google.com/..."
error={Boolean(errors.googleMapsUrl)}
helperText={errors.googleMapsUrl?.message ?? "Must use HTTPS"}
fullWidth
/>
)}
/>
</Stack>
</Box>
<Divider />
<Box>
<FormLabel component="legend">Trade Specialties</FormLabel>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
First trade is primary. Reorder with the arrows.
</Typography>
<Controller
control={control}
name="tradeSpecialties"
render={({ field }) => {
const trades = splitTrades(field.value);
const remove = (trade: string) => {
field.onChange(joinTrades(trades.filter((entry) => entry !== trade)));
};
const move = (index: number, direction: -1 | 1) => {
const next = [...trades];
const target = index + direction;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target], next[index]];
field.onChange(joinTrades(next));
};
const add = (trade: string) => {
const normalized = trade.trim();
if (!normalized || trades.includes(normalized)) {
setTradeInput("");
return;
}
field.onChange(joinTrades([...trades, normalized]));
setTradeInput("");
};
return (
<Box className="mt-2">
<Box className="flex flex-wrap gap-1.5">
{trades.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No trades selected.
</Typography>
) : (
trades.map((trade, index) => (
<Chip
key={`${trade}-${index}`}
label={index === 0 ? `${trade} (primary)` : trade}
onDelete={() => remove(trade)}
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
sx={{ px: 0.5 }}
/>
))
)}
</Box>
<Stack direction="row" spacing={1} className="mt-2" sx={{ alignItems: "center" }}>
<Autocomplete
freeSolo
options={tradeOptions}
value={null}
inputValue={tradeInput}
onInputChange={(_event, value, reason) => {
if (reason === "input") setTradeInput(value);
}}
onChange={(_event, value) => {
if (typeof value === "string") add(value);
}}
renderInput={(params) => (
<TextField
{...params}
label="Add Trade"
placeholder="Type or select a trade"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
add(tradeInput);
}
}}
/>
)}
className="flex-1"
/>
<Button
color="primary"
onClick={() => add(tradeInput)}
aria-label="Add trade"
disabled={!tradeInput.trim()}
>
<AddIcon />
</Button>
</Stack>
{trades.length > 1 && (
<Stack direction="row" spacing={1} className="mt-2 flex-wrap">
{trades.map((trade, index) => (
<Stack key={`reorder-${trade}-${index}`} direction="row" spacing={0.5}>
<Button
size="small"
aria-label={`Move ${trade} up`}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<ArrowUpwardIcon fontSize="small" />
</Button>
<Button
size="small"
aria-label={`Move ${trade} down`}
disabled={index === trades.length - 1}
onClick={() => move(index, 1)}
>
<ArrowDownwardIcon fontSize="small" />
</Button>
</Stack>
))}
</Stack>
)}
</Box>
);
}}
/>
</Box>
<Divider />
{showTotalJobs && (
<Controller
control={control}
name="totalJobs"
render={({ field }) => (
<TextField
{...field}
value={field.value ?? 0}
label="Total Jobs"
type="number"
slotProps={{ input: { readOnly: true } }}
helperText="Calculated from work-order history"
fullWidth
/>
)}
/>
)}
<Controller
control={control}
name="notes"
render={({ field }) => (
<TextField {...field} label="Notes" multiline minRows={3} fullWidth />
)}
/>
</Stack>
);
}

View file

@ -6,34 +6,40 @@ import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
FormControlLabel,
FormGroup,
FormLabel,
Paper,
Stack,
TextField,
Switch,
Typography,
} from "@mui/material";
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
import { VendorFormFields } from "./vendor-form-fields";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
const defaultValues: VendorFormValues = {
companyName: "",
contactName: "",
email: "",
phone: "",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
companyId: null,
preferredContact: "Phone",
};
export default function VendorFormPage() {
@ -42,21 +48,25 @@ export default function VendorFormPage() {
const isEdit = Boolean(id);
const { data: vendor, isLoading, error } = useVendorDetail(id);
const { data: facets } = useVendorFacets();
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
const createVendor = useCreateVendor();
const updateVendor = useUpdateVendor();
const {
register,
handleSubmit,
reset,
setValue,
control,
trigger,
formState: { errors },
} = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema),
defaultValues,
mode: "onChange",
});
const currentValues = useWatch({ control });
const isFormValid = vendorSchema.safeParse(currentValues).success;
useEffect(() => {
if (vendor && isEdit) {
@ -65,37 +75,44 @@ export default function VendorFormPage() {
contactName: vendor.contactName,
email: vendor.email,
phone: vendor.phone,
companyPhone: vendor.companyPhone,
googleMapsUrl: vendor.googleMapsUrl,
notes: vendor.notes,
totalJobs: vendor.totalJobs,
address: vendor.address,
city: vendor.city,
state: vendor.state,
zip: vendor.zip,
tradeSpecialties: vendor.tradeSpecialties,
isActive: vendor.isActive,
companyId: vendor.companyId ?? null,
preferredContact: vendor.preferredContact ?? "Phone",
});
}
}, [vendor, isEdit, reset]);
const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" });
const selectedTrades = (tradeSpecialties || "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
const toggleTrade = (tradeValue: string) => {
const updated = selectedTrades.includes(tradeValue)
? selectedTrades.filter((trade) => trade !== tradeValue)
: [...selectedTrades, tradeValue];
setValue("tradeSpecialties", updated.join(", "));
const onCompanySelected = (company: VendorFacetCompany | null) => {
if (!company) return;
const options = { shouldDirty: true, shouldValidate: true } as const;
setValue("companyId", company.companyId, options);
setValue("companyPhone", company.companyPhone, options);
setValue("email", company.email, options);
setValue("address", company.address, options);
setValue("city", company.city, options);
setValue("state", company.state, options);
setValue("zip", company.zip, options);
setValue("googleMapsUrl", company.googleMapsUrl, options);
void trigger();
};
const isSaving = createVendor.isPending || updateVendor.isPending;
const onSubmit = (values: VendorFormValues) => {
if (isEdit && id) {
updateVendor.mutate({ id, values });
updateVendor.mutate({ id, values }, { onSuccess: () => navigate("/vendors") });
return;
}
createVendor.mutate(values);
createVendor.mutate(values, { onSuccess: () => navigate("/vendors") });
};
if (isEdit && isLoading) {
@ -112,7 +129,7 @@ export default function VendorFormPage() {
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor"}
</Alert>
@ -120,73 +137,27 @@ export default function VendorFormPage() {
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<Typography variant="subtitle2" className="font-semibold uppercase">
General Details
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Company Name"
required
fullWidth
error={Boolean(errors.companyName)}
helperText={errors.companyName?.message}
{...register("companyName")}
/>
<TextField label="Contact Name" fullWidth {...register("contactName")} />
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Email"
required
fullWidth
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
{...register("email")}
/>
<TextField label="Phone" fullWidth {...register("phone")} />
</Stack>
<Typography variant="subtitle2" className="font-semibold uppercase">
Address
</Typography>
<TextField label="Address" fullWidth {...register("address")} />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField label="City" fullWidth {...register("city")} />
<TextField label="State" fullWidth placeholder="e.g., IL" {...register("state")} />
<TextField label="Zip" fullWidth {...register("zip")} />
</Stack>
<FormGroup>
<FormLabel component="legend">Trade Specialties</FormLabel>
<Box className="mt-2 flex flex-wrap gap-2">
{trades.map((trade) => (
<FormControlLabel
key={String(trade.id)}
control={
<Checkbox
checked={selectedTrades.includes(trade.value)}
onChange={() => toggleTrade(trade.value)}
/>
}
label={trade.value}
/>
))}
</Box>
</FormGroup>
<Controller
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox checked={field.value} onChange={field.onChange} />}
label="Active"
control={<Switch checked={Boolean(field.value)} onChange={field.onChange} />}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
<VendorFormFields
control={control}
errors={errors}
companies={facets?.companies ?? []}
tradeOptions={trades.map((trade) => trade.value)}
onCompanySelected={onCompanySelected}
showTotalJobs={isEdit}
/>
{isEdit && id != null && <VendorPortalTokenPanel vendorId={id} />}
<Stack
direction="row"
@ -198,7 +169,7 @@ export default function VendorFormPage() {
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
<Button type="submit" variant="contained" disabled={isSaving || !isFormValid}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>

View file

@ -1,9 +1,11 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import FilterListIcon from "@mui/icons-material/FilterList";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import {
Alert,
Avatar,
Box,
Button,
Chip,
@ -14,8 +16,12 @@ import {
DialogContentText,
DialogTitle,
IconButton,
Link,
List,
ListItem,
Paper,
Stack,
Tab,
Table,
TableBody,
TableCell,
@ -23,39 +29,193 @@ import {
TableHead,
TablePagination,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { VendorCreateModal } from "./_components/vendor-create-modal";
import { VendorDetailDrawer } from "./_components/vendor-detail-drawer";
import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { useDebounce } from "@/hooks/use-debounce";
const TABLE_COLUMNS = [
"Company",
"Technician",
"Location",
"Trade",
"Phone",
"Email",
"Total Jobs",
"Status",
"View",
"Edit",
] as const;
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
const first = parts[0]?.[0] ?? "";
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
return (first + last).toUpperCase();
}
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
}
interface AppliedChip {
category: keyof VendorFilters;
value: string;
label: string;
}
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.jobBuckets.forEach((value) =>
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
);
return chips;
}
export default function VendorsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [isActive, setIsActive] = useState(true);
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
const [filtersOpen, setFiltersOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
const debouncedSearch = useDebounce(search, 300);
const deleteVendor = useDeleteVendor();
const {
data: deactivationImpact,
isLoading: isLoadingImpact,
error: impactError,
} = useVendorDeactivationImpact(deleteTarget?.id);
const { data: facets } = useVendorFacets(isActive);
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
const listParams = useMemo(
() => ({ search: debouncedSearch, page: page + 1, pageSize }),
[debouncedSearch, page, pageSize],
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
isActive,
companies: appliedFilters.companies,
trades: appliedFilters.trades,
locations: appliedFilters.locations,
jobBuckets: appliedFilters.jobBuckets,
}),
[debouncedSearch, page, pageSize, isActive, appliedFilters],
);
const { data, isLoading, isFetching, error } = useVendorsList(listParams);
const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 });
const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 });
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const activeCount = activeCountData?.totalCount ?? 0;
const inactiveCount = inactiveCountData?.totalCount ?? 0;
const globalCount = activeCount + inactiveCount;
const jobBucketLabels = useMemo(
() => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])),
[facets],
);
const appliedChips = useMemo(
() => describeChips(appliedFilters, jobBucketLabels),
[appliedFilters, jobBucketLabels],
);
const handleApplyFilters = (next: VendorFilters) => {
setAppliedFilters(next);
setPage(0);
};
const handleClearFilters = () => {
setAppliedFilters(emptyVendorFilters);
setPage(0);
};
const handleRemoveChip = (chip: AppliedChip) => {
setAppliedFilters((current) => ({
...current,
[chip.category]: current[chip.category].filter((value) => value !== chip.value),
}));
setPage(0);
};
const openDetail = (row: VendorListItem, mode: "view" | "edit") => {
setDetailTarget(row);
setDetailMode(mode);
};
const closeDetail = () => {
setDetailTarget(null);
setDetailMode("view");
};
const openDeactivation = (row: VendorListItem) => {
setDeleteTarget(row);
setDeleteError(null);
};
const closeDeactivation = () => {
setDeleteTarget(null);
setDeleteError(null);
};
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
setDeleteError(null);
deleteVendor.mutate(deleteTarget.id, {
onSuccess: () => {
setDeleteTarget(null);
},
onError: (error: Error) => {
setDeleteError(error.message || "Failed to deactivate vendor");
},
});
};
const stopAnd = (handler: () => void) => (event: MouseEvent) => {
event.stopPropagation();
handler();
};
const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
openDetail(row, "view");
};
const showLoadingRow = isLoading;
const showEmptyRow = !isLoading && !error && rows.length === 0;
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
@ -66,23 +226,12 @@ export default function VendorsListPage() {
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Vendors
Vendors / Technicians
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{globalCount} technicians · {activeCount} active
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
@ -90,9 +239,7 @@ export default function VendorsListPage() {
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
>
<TextField
size="small"
@ -101,133 +248,362 @@ export default function VendorsListPage() {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search vendors..."
className="min-w-sidebar"
placeholder="Search name, company, trade..."
className="min-w-[260px]"
/>
<Button variant="contained" onClick={() => navigate("/vendors/new")}>
+ New Vendor
<Button
variant="outlined"
onClick={() => setFiltersOpen(true)}
startIcon={<FilterListIcon />}
aria-label="Open vendor filters"
>
Filters
{appliedChips.length > 0 && (
<Chip size="small" color="primary" label={appliedChips.length} sx={{ ml: 1 }} />
)}
</Button>
<Button variant="contained" onClick={() => setCreateOpen(true)}>
+ Add Vendor
</Button>
</Stack>
</Stack>
{error && (
<Tabs
value={isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") => {
setIsActive(value === "active");
setPage(0);
}}
aria-label="Vendor status"
>
<Tab value="active" label={`Active (${activeCount})`} />
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
</Tabs>
{appliedChips.length > 0 && (
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Applied:
</Typography>
{appliedChips.map((chip) => (
<Chip
key={`${chip.category}-${chip.value}`}
size="small"
label={chip.label}
onDelete={() => handleRemoveChip(chip)}
aria-label={`Remove filter ${chip.label}`}
/>
))}
<Button size="small" onClick={handleClearFilters}>
Clear all
</Button>
</Stack>
)}
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendors"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 960 }}>
<TableHead>
<TableRow>
<TableCell className="font-semibold">Company</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Email</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Trades</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
{TABLE_COLUMNS.map((column) => (
<TableCell
key={column}
align={column === "View" || column === "Edit" ? "center" : "left"}
className="font-semibold"
>
{column}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
{showLoadingRow ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No vendors match the current filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/vendors/edit/${row.id}`)}
>
{row.companyName || "—"}
</Button>
</TableCell>
<TableCell>{row.contactName || "—"}</TableCell>
<TableCell>{row.email || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.tradeSpecialties || "—"}</TableCell>
<TableCell>
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
rows.map((row) => {
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(", ") || "—";
return (
<TableRow
key={String(row.id)}
hover
tabIndex={0}
role="button"
aria-label={`Open vendor details for ${row.companyName}`}
sx={{ cursor: "pointer" }}
onClick={() => openDetail(row, "view")}
onKeyDown={handleRowKeyDown(row)}
>
<TableCell>
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Avatar
sx={{
width: 28,
height: 28,
fontSize: 12,
backgroundColor: "var(--accent, #1976d2)",
}}
>
{getInitials(row.contactName)}
</Avatar>
<Typography variant="body2">{row.contactName || "—"}</Typography>
</Stack>
</TableCell>
<TableCell>{locationText}</TableCell>
<TableCell>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
</Stack>
)}
</TableCell>
<TableCell>
{row.phone ? (
<Link
href={`tel:${row.phone}`}
underline="hover"
onClick={(event) => event.stopPropagation()}
>
{row.phone}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>
{row.email ? (
<Link
href={`mailto:${row.email}`}
underline="hover"
onClick={(event) => event.stopPropagation()}
>
{row.email}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>{row.totalJobs ?? 0}</TableCell>
<TableCell>
<Chip
size="small"
aria-label="Edit vendor"
onClick={() => navigate(`/vendors/edit/${row.id}`)}
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell>
<TableCell align="center">
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={stopAnd(() => openDetail(row, "view"))}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
sx={{ alignItems: "center", justifyContent: "center" }}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete vendor"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
<Tooltip title="Edit">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={stopAnd(() => openDetail(row, "edit"))}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
{row.isActive && (
<Tooltip title="Deactivate">
<IconButton
size="small"
aria-label="Deactivate vendor"
onClick={stopAnd(() => openDeactivation(row))}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", sm: "center" },
justifyContent: "space-between",
}}
rowsPerPageOptions={[12, 24, 48]}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "technician" : "technicians"}
{(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"}
{` · ${activeCount} active · ${inactiveCount} inactive`}
</Typography>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
</Stack>
<VendorCreateModal
open={createOpen}
onClose={() => setCreateOpen(false)}
tradeOptions={tradeOptions}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Vendor</DialogTitle>
<VendorFiltersDrawer
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
facets={facets}
appliedFilters={appliedFilters}
onApply={handleApplyFilters}
/>
<VendorDetailDrawer
open={Boolean(detailTarget)}
vendor={detailTarget}
mode={detailMode}
onClose={closeDetail}
onModeChange={setDetailMode}
tradeOptions={tradeOptions}
/>
<Dialog
open={Boolean(deleteTarget)}
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
>
<DialogTitle>Deactivate Vendor</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.companyName}&quot;?
</DialogContentText>
<Stack spacing={2}>
<DialogContentText>
Deactivate &quot;{deleteTarget?.companyName}&quot;? Existing work-order and audit
history will be preserved.
</DialogContentText>
{isLoadingImpact && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Checking linked work orders...
</Typography>
</Stack>
)}
{Boolean(impactError) && !isLoadingImpact && (
<Alert severity="warning">
Could not verify linked work orders. Close and try again.
</Alert>
)}
{deactivationImpact != null &&
!isLoadingImpact &&
!deactivationImpact.canDeactivate && (
<Alert severity="error">
This vendor cannot be deactivated because it still has open work orders.
</Alert>
)}
{deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && (
<Box>
<Typography variant="subtitle2" className="font-semibold uppercase">
Open work orders ({deactivationImpact.openWorkOrders.length})
</Typography>
<List dense sx={{ mt: 0.5 }}>
{deactivationImpact.openWorkOrders.map((wo) => (
<ListItem
key={String(wo.workOrderId)}
sx={{ px: 0, py: 0.25 }}
secondaryAction={
wo.scheduledDate ? (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.scheduledDate}
</Typography>
) : undefined
}
>
<Stack>
<Typography variant="body2">
{wo.workOrderNumber
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
</Typography>
{Boolean(wo.status) && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.status}
</Typography>
)}
</Stack>
</ListItem>
))}
</List>
</Box>
)}
{Boolean(deleteError) && <Alert severity="error">{deleteError}</Alert>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteVendor.isPending}
disabled={
deleteVendor.isPending ||
isLoadingImpact ||
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
Boolean(impactError)
}
>
{deleteVendor.isPending ? "Deleting..." : "Delete"}
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
</Button>
</DialogActions>
</Dialog>

View file

@ -10,6 +10,10 @@ import {
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Link,
Menu,
MenuItem,
@ -36,8 +40,11 @@ import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
useUpdateWorkOrderVendor,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
const STATUS_COLORS: Record<
@ -53,6 +60,31 @@ const STATUS_COLORS: Record<
type ActivityTab = "customer" | "internal" | "audit";
interface VendorEditorState {
vendorId: string | number;
vendorName: string;
contactName: string;
preferredContact: VendorPreferredContact;
phone: string;
email: string;
notes: string;
}
function formatPhoneMask(input: string): string {
const digits = input.replace(/\D/g, "").slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
function isVendorEditorValid(editor: VendorEditorState): boolean {
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
return (
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
);
}
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
const sorted = useMemo(
() =>
@ -167,6 +199,7 @@ export default function WorkOrderViewPage() {
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
@ -174,6 +207,9 @@ export default function WorkOrderViewPage() {
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState("");
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
@ -200,6 +236,74 @@ export default function WorkOrderViewPage() {
);
};
const openVendorEditor = async (
event: React.MouseEvent,
vendorId: string | number,
vendorName: string,
) => {
event.stopPropagation();
setVendorEditorError("");
setVendorEditorLoading(true);
setVendorEditor({
vendorId,
vendorName,
contactName: "",
preferredContact: "Phone",
phone: "",
email: "",
notes: "",
});
try {
const vendor = await vendorsApi.getById(vendorId);
setVendorEditor({
vendorId,
vendorName: vendor.companyName || vendorName,
contactName: vendor.contactName,
preferredContact: vendor.preferredContact,
phone: vendor.phone,
email: vendor.email,
notes: vendor.notes,
});
} catch (vendorError) {
setVendorEditorError(
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
);
} finally {
setVendorEditorLoading(false);
}
};
const saveVendorEditor = () => {
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
const numericWorkOrderId = Number(workOrderId);
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
setVendorEditorError("A valid work order is required");
return;
}
vendorMutation.mutate(
{
vendorId: vendorEditor.vendorId,
payload: {
workOrderId: numericWorkOrderId,
contactName: vendorEditor.contactName.trim(),
preferredContact: vendorEditor.preferredContact,
phone: vendorEditor.phone,
email: vendorEditor.email.trim(),
notes: vendorEditor.notes.trim(),
},
},
{
onSuccess: () => {
setVendorEditor(null);
void refetch();
},
onError: (vendorError) => setVendorEditorError(vendorError.message),
},
);
};
if (isLoading) {
return (
<Box className="flex justify-center p-12">
@ -426,6 +530,7 @@ export default function WorkOrderViewPage() {
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
@ -465,6 +570,17 @@ export default function WorkOrderViewPage() {
"—"
)}
</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={(event) =>
void openVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
}
disabled={dispatch.vendorId === ""}
>
Edit vendor
</Button>
</TableCell>
</TableRow>
);
})}
@ -542,7 +658,7 @@ export default function WorkOrderViewPage() {
onDispatched={() => void refetch()}
/>
)}
{dispatchDetailId && (
{dispatchDetailId != null && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
workOrderId={workOrderId}
@ -550,6 +666,115 @@ export default function WorkOrderViewPage() {
onUpdated={() => void refetch()}
/>
)}
<Dialog
open={vendorEditor !== null}
onClose={() => {
if (!vendorMutation.isPending) setVendorEditor(null);
}}
fullWidth
maxWidth="sm"
>
<DialogTitle>Edit assigned vendor</DialogTitle>
<DialogContent>
<Stack spacing={2} className="pt-2">
<Typography variant="body2" color="text.secondary">
{vendorEditor?.vendorName}
</Typography>
{Boolean(vendorEditorError) && <Alert severity="error">{vendorEditorError}</Alert>}
{vendorEditorLoading ? (
<Box className="flex justify-center p-6">
<CircularProgress size={28} />
</Box>
) : (
<>
<TextField
label="Technician name"
value={vendorEditor?.contactName ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, contactName: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 200 } }}
/>
<TextField
select
label="Preferred contact"
value={vendorEditor?.preferredContact ?? "Phone"}
onChange={(event) =>
setVendorEditor((current) =>
current
? {
...current,
preferredContact: event.target.value as VendorPreferredContact,
}
: current,
)
}
>
<MenuItem value="Phone">Phone</MenuItem>
<MenuItem value="Email">Email</MenuItem>
<MenuItem value="Text">Text</MenuItem>
</TextField>
<TextField
label="Phone"
value={vendorEditor?.phone ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current
? { ...current, phone: formatPhoneMask(event.target.value) }
: current,
)
}
error={Boolean(
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
)}
helperText="Use (XXX) XXX-XXXX"
/>
<TextField
label="Email"
type="email"
value={vendorEditor?.email ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, email: event.target.value } : current,
)
}
/>
<TextField
label="Notes"
multiline
minRows={3}
value={vendorEditor?.notes ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, notes: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 2000 } }}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setVendorEditor(null)} disabled={vendorMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={saveVendorEditor}
disabled={
!vendorEditor ||
vendorEditorLoading ||
vendorMutation.isPending ||
!isVendorEditorValid(vendorEditor)
}
>
{vendorMutation.isPending ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -242,7 +242,7 @@ export function DispatchCreateModal({
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{vendor.tradeSpecialties && (
{Boolean(vendor.tradeSpecialties) && (
<Typography
variant="caption"
sx={{
@ -253,7 +253,7 @@ export function DispatchCreateModal({
{vendor.tradeSpecialties}
</Typography>
)}
{vendor.address && (
{Boolean(vendor.address) && (
<Typography
variant="caption"
sx={{

View file

@ -331,7 +331,7 @@ export function DispatchDetailModal({
}
/>
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
{dispatch.description && (
{Boolean(dispatch.description) && (
<DetailField
label="Description"
value={dispatch.description}
@ -448,7 +448,7 @@ export function DispatchDetailModal({
<Typography variant="subtitle2">{type}</Typography>
{signoff ? (
<Box className="mt-2">
{signoff.signatureMethod === "drawn" && signoff.signature && (
{signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && (
<img
src={signoff.signature}
alt="Signature"
@ -527,12 +527,12 @@ export function DispatchDetailModal({
{Number(uplift.delta).toFixed(2)})
</Typography>
</Stack>
{uplift.vendorReason && (
{Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason}
</Typography>
)}
{(uplift.requestedByVendorName || uplift.requestedAt) && (
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography
variant="body2"
className="mt-1"
@ -545,9 +545,11 @@ export function DispatchDetailModal({
</Typography>
)}
{uplift.status !== "Pending" &&
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
Boolean(
uplift.decidedByName || uplift.decidedAt || uplift.decisionNote,
) && (
<Box className="mt-1">
{(uplift.decidedByName || uplift.decidedAt) && (
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography
variant="body2"
sx={{
@ -558,7 +560,7 @@ export function DispatchDetailModal({
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
</Typography>
)}
{uplift.decisionNote && (
{Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-0.5">
<strong>Note:</strong> {uplift.decisionNote}
</Typography>
@ -720,7 +722,7 @@ export function DispatchDetailModal({
)}
</DialogContent>
</Dialog>
{sigCaptureType && (
{sigCaptureType != null && (
<SignatureCapture
title={`${sigCaptureType} Sign-off`}
onClose={() => setSigCaptureType(null)}

View file

@ -128,7 +128,7 @@ export function DispatcherFilter({
onClick={() => onChange(new Set())}
/>
{meId && (
{meId != null && (
<CheckboxMenuItem
checked={meOnly}
label="My WOs only"

View file

@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { MediaUploader } from "./media-uploader";
@ -43,7 +44,9 @@ export function MediaUploadModal({
</DialogTitle>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
</div>
<p className="sr-only">Upload photos and videos for this work order.</p>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order.
</Text>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />

View file

@ -219,7 +219,7 @@ export default function WorkOrdersListPage() {
</Select>
</FormControl>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load work orders"}
</Alert>

View file

@ -1,6 +1,7 @@
import { NavLink, Outlet, useLocation, useParams } from "react-router";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
import { Text } from "@/components/ui/text";
import "@/app/v/_components/vendor-portal.css";
function VendorPortalHeader() {
@ -9,7 +10,7 @@ function VendorPortalHeader() {
return (
<div className="vp-header">
<div className="vp-brand">Sea Haven Industries — Vendor Portal</div>
{status === "ready" && vendor?.companyName && (
{status === "ready" && vendor != null && Boolean(vendor.companyName) && (
<div className="vp-vendor-name">{vendor.companyName}</div>
)}
</div>
@ -55,13 +56,13 @@ function VendorPortalBody() {
if (status === "error") {
return (
<div className="vp-container">
<div className="vp-error">
<h2>Access Denied</h2>
<p>
<Text as="section" variant="body" className="vp-error">
<Text variant="title">Access Denied</Text>
<Text variant="error">
{error?.message ||
"This link is invalid or has expired. Please contact your dispatcher for a new link."}
</p>
</div>
</Text>
</Text>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -54,7 +55,7 @@ export default function VendorPortalDashboardPage() {
return (
<>
<div className="vp-card">
<h2>Your Work</h2>
<Text variant="title">Your Work</Text>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Active dispatches and work orders assigned to your company.
</div>
@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
</div>
{isLoading && <div className="vp-card vp-muted">Loading dispatches…</div>}
{error && <div className="vp-error">{error.message}</div>}
<Text variant="error" when={Boolean(error)} className="vp-error">
{error?.message}
</Text>
{!isLoading && !error && filtered.length === 0 && (
<div className="vp-card vp-empty">No dispatches in this view.</div>

View file

@ -3,6 +3,7 @@ import { Link, useParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SignaturePad } from "@/app/v/_components/signature-pad";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -63,7 +64,7 @@ function ChecklistSection({
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
{item.itemText}
</label>
{item.isCompleted && item.completedAt && (
{item.isCompleted && Boolean(item.completedAt) && (
<span className="vp-muted" style={{ fontSize: 12 }}>
{formatVendorPortalDateTime(item.completedAt)}
</span>
@ -224,7 +225,7 @@ function UpliftRequestsSection({
</span>
</span>
</div>
{request.vendorReason && (
{Boolean(request.vendorReason) && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() {
}
if (error && !data) {
return <div className="vp-error">{error.message}</div>;
return (
<Text variant="error" className="vp-error">
{error.message}
</Text>
);
}
if (!data) return null;
@ -457,20 +462,20 @@ export default function VendorPortalDispatchPage() {
<div className="vp-card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h2>{data.dispatchNumber}</h2>
<Text variant="title">{data.dispatchNumber}</Text>
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
</div>
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div>
<div style={{ marginTop: 12 }}>
{data.workOrder?.internalWONumber && (
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{data.poNumber && (
{Boolean(data.poNumber) && (
<div className="vp-row">
<strong>PO #</strong>
<span>{data.poNumber}</span>
@ -482,7 +487,7 @@ export default function VendorPortalDispatchPage() {
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
{data.scheduledDate && (
{Boolean(data.scheduledDate) && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
@ -490,9 +495,11 @@ export default function VendorPortalDispatchPage() {
)}
</div>
{data.location && (
{data.location != null && (
<>
<h3 style={{ marginTop: 16 }}>Location</h3>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
<div>{data.location.name}</div>
<div className="vp-muted">
{[
@ -507,9 +514,11 @@ export default function VendorPortalDispatchPage() {
</>
)}
{(data.description || data.workOrder?.description) && (
{Boolean(data.description || data.workOrder?.description) && (
<>
<h3 style={{ marginTop: 16 }}>Description</h3>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Checklist</h3>
<Text variant="heading">Checklist</Text>
<ChecklistSection
token={token}
dispatchId={dispatchId}
@ -588,7 +597,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>NTE Uplift Requests</h3>
<Text variant="heading">NTE Uplift Requests</Text>
<UpliftRequestsSection
token={token}
dispatchId={dispatchId}
@ -600,7 +609,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Customer Signoff</h3>
<Text variant="heading">Customer Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
@ -612,7 +621,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Vendor Signoff</h3>
<Text variant="heading">Vendor Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
@ -624,7 +633,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Comments</h3>
<Text variant="heading">Comments</Text>
<CommentsSection
token={token}
dispatchId={dispatchId}

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -81,7 +82,7 @@ export default function VendorPortalPosPage() {
return (
<>
<div className="vp-card">
<h2>Your Purchase Orders</h2>
<Text variant="title">Your Purchase Orders</Text>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Every PO issued to your company, with current status and dollar value.
</div>
@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
</div>
{isLoading && <div className="vp-card vp-muted">Loading POs…</div>}
{error && <div className="vp-error">{error.message}</div>}
<Text variant="error" when={Boolean(error)} className="vp-error">
{error?.message}
</Text>
{!isLoading && !error && visible.length === 0 && (
<div className="vp-card vp-empty">No POs match this view.</div>

View file

@ -22,7 +22,7 @@ export const EventCalendar = forwardRef<FullCalendar, EventCalendarProps>(functi
) {
return (
<Box className="relative rounded-lg border border-border bg-card p-3">
{loading && (
{Boolean(loading) && (
<Box className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-white/70">
<CircularProgress size={32} />
</Box>

View file

@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
fullWidth
size="small"
/>
{typedName && (
{Boolean(typedName) && (
<Typography
variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic"

View file

@ -32,7 +32,7 @@ export function CollapsibleSection({
aria-expanded={open}
>
<div className="flex items-center gap-2">
{Icon && <Icon size={16} className="text-primary" />}
{Icon != null && <Icon size={16} className="text-primary" />}
<span className="text-sm font-semibold text-foreground">{label}</span>
</div>
<div className="flex items-center gap-2">

View file

@ -1,13 +1,13 @@
import type { ReactNode } from "react";
import type { ReactElement } from "react";
import { Box, Typography } from "@mui/material";
import { cn } from "@/lib/utils";
type EmptyStateProps = {
icon?: ReactNode;
icon?: ReactElement;
title: string;
description?: string;
action?: ReactNode;
action?: ReactElement;
className?: string;
};
@ -19,11 +19,11 @@ export function EmptyState({ icon, title, description, action, className }: Empt
className,
)}
>
{icon && <Box className="text-muted-foreground">{icon}</Box>}
{Boolean(icon) && <Box className="text-muted-foreground">{icon}</Box>}
<Typography variant="subtitle1" className="text-foreground">
{title}
</Typography>
{description && (
{Boolean(description) && (
<Typography
variant="body2"
className="max-w-sm"
@ -34,7 +34,7 @@ export function EmptyState({ icon, title, description, action, className }: Empt
{description}
</Typography>
)}
{action && <Box className="mt-2">{action}</Box>}
{Boolean(action) && <Box className="mt-2">{action}</Box>}
</Box>
);
}

View file

@ -34,7 +34,7 @@ export function FilterPopover({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-foreground">Filters</span>
{activeCount > 0 && onClear && (
{activeCount > 0 && Boolean(onClear) && (
<button
type="button"
onClick={onClear}

View file

@ -64,7 +64,7 @@ export function FormField({
{label}
</FormLabel>
{children}
{helperText && <FormHelperText>{helperText}</FormHelperText>}
{Boolean(helperText) && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
);
}

View file

@ -1,13 +1,14 @@
import type { ReactNode } from "react";
import { Stack, Typography } from "@mui/material";
import type { ReactElement } from "react";
import { Stack } from "@mui/material";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
type PageHeaderProps = {
eyebrow?: string;
title: string;
subtitle?: string;
actions?: ReactNode;
actions?: ReactElement;
className?: string;
/** White surface bar (list page headers). */
surface?: boolean;
@ -35,26 +36,27 @@ export function PageHeader({
}}
>
<Stack spacing={0}>
{eyebrow && (
<p className="m-0 font-sans text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase">
{eyebrow}
</p>
)}
<h1
<Text
variant="label"
when={Boolean(eyebrow)}
className="m-0 text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase"
>
{eyebrow}
</Text>
<Text
variant="display"
className={cn(
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
eyebrow && "mt-1",
)}
>
{title}
</h1>
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Text>
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
{subtitle}
</Text>
</Stack>
{actions && (
{Boolean(actions) && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
{actions}
</Stack>

104
src/components/ui/text.tsx Normal file
View file

@ -0,0 +1,104 @@
import type { ElementType, ReactNode } from "react";
import {
Typography as MuiTypography,
type TypographyProps as MuiTypographyProps,
} from "@mui/material";
type TextVariant =
| "display"
| "title"
| "heading"
| "body"
| "description"
| "label"
| "feedback"
| "error"
| "caption"
| "code";
type TextTone = "default" | "muted" | "error" | "success" | "warning";
type TextFamily = "display" | "body" | "mono";
export type TextProps = Omit<
MuiTypographyProps,
"children" | "color" | "variant" | "component" | "role" | "aria-live" | "aria-atomic"
> & {
children: ReactNode;
as?: ElementType;
family?: TextFamily;
tone?: TextTone;
variant?: TextVariant;
when?: boolean;
};
const variantConfig: Record<
TextVariant,
{ element: ElementType; muiVariant: MuiTypographyProps["variant"]; family: TextFamily }
> = {
display: { element: "h1", muiVariant: "h3", family: "display" },
title: { element: "h2", muiVariant: "h5", family: "display" },
heading: { element: "h3", muiVariant: "h6", family: "display" },
body: { element: "p", muiVariant: "body1", family: "body" },
description: { element: "p", muiVariant: "body2", family: "body" },
label: { element: "span", muiVariant: "subtitle2", family: "body" },
feedback: { element: "p", muiVariant: "body2", family: "body" },
error: { element: "p", muiVariant: "body2", family: "body" },
caption: { element: "span", muiVariant: "caption", family: "body" },
code: { element: "code", muiVariant: "body2", family: "mono" },
};
const toneColor: Record<TextTone, MuiTypographyProps["color"]> = {
default: "text.primary",
muted: "text.secondary",
error: "error",
success: "success.main",
warning: "warning.main",
};
const familyValue: Record<TextFamily, string> = {
display: "var(--font-display)",
body: "var(--font-sans)",
mono: "var(--font-mono)",
};
export function Text({
as,
children,
family,
tone,
variant = "body",
when = true,
sx,
...props
}: TextProps) {
const persistRegion = variant === "feedback";
if (!when && !persistRegion) {
return null;
}
const config = variantConfig[variant];
const resolvedTone = tone ?? (variant === "error" ? "error" : "default");
let liveProps = {};
if (variant === "error") {
liveProps = { role: "alert", "aria-live": "assertive" as const, "aria-atomic": true as const };
} else if (variant === "feedback") {
liveProps = { role: "status", "aria-live": "polite" as const, "aria-atomic": true as const };
}
return (
<MuiTypography
{...props}
component={as ?? config.element}
variant={config.muiVariant}
color={toneColor[resolvedTone]}
sx={[
{ fontFamily: familyValue[family ?? config.family] },
...(Array.isArray(sx) ? sx : [sx]),
]}
{...liveProps}
>
{when && children}
</MuiTypography>
);
}

View file

@ -3,23 +3,59 @@ import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapVendor,
mapVendorDeactivationImpact,
mapVendorFacets,
mapVendorListItems,
mapVendorPortalToken,
mapVendorToBackend,
} from "@/domain/vendors/mappers/vendor-mapper";
import type {
Vendor,
VendorDeactivationImpact,
VendorFacets,
VendorPortalToken,
VendorsListParams,
VendorsListResult,
} from "@/domain/vendors/types/vendor";
function pushRepeated(
searchParams: Record<string, string | number>,
key: string,
values: string[] | undefined,
): void {
if (!values || values.length === 0) return;
values.forEach((value, index) => {
if (!value) return;
searchParams[`${key}[${index}]`] = value;
});
}
export interface VendorFacetsParams {
isActive?: boolean;
}
function buildFacetsParams(params?: VendorFacetsParams): Record<string, string> {
const searchParams: Record<string, string> = {};
if (params?.isActive !== undefined) {
searchParams.isActive = String(params.isActive);
}
return searchParams;
}
function buildListParams(params: VendorsListParams): Record<string, string | number> {
return {
const searchParams: Record<string, string | number> = {
search: params.search ?? "",
page: params.page ?? 1,
pageSize: params.pageSize ?? 12,
};
if (params.isActive !== undefined) {
searchParams.isActive = String(params.isActive);
}
pushRepeated(searchParams, "companies", params.companies);
pushRepeated(searchParams, "trades", params.trades);
pushRepeated(searchParams, "locations", params.locations);
pushRepeated(searchParams, "jobBuckets", params.jobBuckets);
return searchParams;
}
export const vendorsApi = {
@ -39,6 +75,13 @@ export const vendorsApi = {
};
},
getFacets: async (params?: VendorFacetsParams): Promise<VendorFacets> => {
const data = await apiGet<unknown>(API_PATHS.vendor.facets, {
searchParams: buildFacetsParams(params),
});
return mapVendorFacets(handleApiResponse(data));
},
getById: async (id: string | number): Promise<Vendor> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}`);
return mapVendor(handleApiResponse(data));
@ -61,6 +104,13 @@ export const vendorsApi = {
await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
},
getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => {
const data = await apiGet<unknown>(
`${API_PATHS.rest.vendors}/${id}/${API_PATHS.vendor.deactivationImpact}`,
);
return mapVendorDeactivationImpact(handleApiResponse(data));
},
getPortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
return mapVendorPortalToken(handleApiResponse(data));

View file

@ -1,4 +1,17 @@
import type { Vendor, VendorListItem, VendorPortalToken } from "@/domain/vendors/types/vendor";
import type {
Vendor,
VendorDeactivationImpact,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
VendorFacets,
VendorListItem,
VendorOpenWorkOrderSummary,
VendorPortalToken,
VendorPreferredContact,
} from "@/domain/vendors/types/vendor";
const PREFERRED_CONTACT_VALUES = new Set<VendorPreferredContact>(["Phone", "Email", "Text"]);
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
@ -13,12 +26,69 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
return "";
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
}
return 0;
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") return value;
return "";
}
function readCompanyId(record: Record<string, unknown>): string | number | null {
const value = record.companyId ?? record.CompanyId;
if (typeof value === "string") return value.trim() === "" ? null : value;
if (typeof value === "number" && Number.isFinite(value)) return value;
return null;
}
function readNumericCompanyId(record: Record<string, unknown>): number | null {
const value = readCompanyId(record);
if (typeof value === "number") return value;
if (typeof value !== "string" || value.trim() === "") return null;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function readPreferredContact(
record: Record<string, unknown>,
...keys: string[]
): VendorPreferredContact {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
const normalized = value.trim();
if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) {
return normalized as VendorPreferredContact;
}
const lowered = normalized.toLowerCase();
if (lowered === "phone" || lowered === "call") return "Phone";
if (lowered === "email" || lowered === "e-mail") return "Email";
if (lowered === "text" || lowered === "sms") return "Text";
}
}
return "Phone";
}
export function toCanonicalPhone(value: unknown): string {
const original = String(value ?? "");
let digits = original.replace(/\D+/g, "");
if (digits.length === 11 && digits.startsWith("1")) {
digits = digits.slice(1);
}
if (digits.length !== 10) return original;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
@ -31,12 +101,20 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
const item = asRecord(raw);
return {
id: readId(item),
companyId: readCompanyId(item),
companyName: readString(item, "companyName", "CompanyName"),
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: readString(item, "phone", "Phone"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
};
}
@ -48,32 +126,44 @@ export function mapVendor(raw: unknown): Vendor {
const item = asRecord(raw);
return {
id: readId(item) || undefined,
companyId: readCompanyId(item),
companyName: readString(item, "companyName", "CompanyName"),
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: readString(item, "phone", "Phone"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
};
}
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
const item = asRecord(vendor);
const companyName = readString(item, "companyName", "CompanyName");
const payload: Record<string, unknown> = {
companyName: item.companyName ?? "",
contactName: item.contactName ?? "",
email: item.email ?? "",
phone: item.phone ?? "",
address: item.address ?? "",
city: item.city ?? "",
state: item.state ?? "",
zip: item.zip ?? "",
tradeSpecialties: item.tradeSpecialties ?? "",
isActive: item.isActive ?? true,
companyId: readNumericCompanyId(item),
companyName,
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: toCanonicalPhone(item.phone ?? item.Phone),
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
};
const id = item.id ?? item.Id;
if (typeof id === "string" || typeof id === "number") {
@ -82,6 +172,63 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
return payload;
}
function mapFacetCompany(raw: unknown): VendorFacetCompany {
const item = asRecord(raw);
return {
companyId: readCompanyId(item),
name: readString(item, "name", "Name"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
email: readString(item, "email", "Email"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
};
}
function mapFacetLocation(raw: unknown): VendorFacetLocation {
const item = asRecord(raw);
const city = readString(item, "city", "City");
const state = readString(item, "state", "State");
const label = readString(item, "label", "Label");
return {
city,
state,
label: label || [city, state].filter(Boolean).join(", "),
};
}
function mapFacetJobBucket(raw: unknown): VendorFacetJobBucket {
const item = asRecord(raw);
return {
id: readString(item, "id", "Id"),
label: readString(item, "label", "Label"),
};
}
export function mapVendorFacets(raw: unknown): VendorFacets {
const item = asRecord(raw);
const companiesRaw = item.companies ?? item.Companies;
const tradesRaw = item.trades ?? item.Trades;
const locationsRaw = item.locations ?? item.Locations;
const jobBucketsRaw = item.jobBuckets ?? item.JobBuckets;
return {
companies: Array.isArray(companiesRaw) ? companiesRaw.map(mapFacetCompany) : [],
trades: Array.isArray(tradesRaw)
? tradesRaw
.map((entry) => {
if (typeof entry === "string") return entry;
return readString(asRecord(entry), "name", "Name", "value", "Value", "label", "Label");
})
.filter(Boolean)
: [],
locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [],
jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [],
};
}
export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
const item = asRecord(raw);
const portalUrl = readString(item, "portalUrl", "PortalUrl");
@ -93,3 +240,32 @@ export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined,
};
}
function mapOpenWorkOrder(raw: unknown): VendorOpenWorkOrderSummary {
const item = asRecord(raw);
const workOrderId = item.workOrderId ?? item.WorkOrderId;
const dispatchId = item.dispatchId ?? item.DispatchId;
return {
workOrderId:
typeof workOrderId === "string" || typeof workOrderId === "number" ? workOrderId : "",
workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"),
workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle"),
status: readString(item, "status", "Status"),
scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined,
dispatchId:
typeof dispatchId === "string" || typeof dispatchId === "number" ? dispatchId : undefined,
};
}
export function mapVendorDeactivationImpact(raw: unknown): VendorDeactivationImpact {
const item = asRecord(raw);
const openWorkOrdersRaw = item.openWorkOrders ?? item.OpenWorkOrders;
const rawId = item.vendorId ?? item.VendorId ?? item.id ?? item.Id;
const vendorId: string | number =
typeof rawId === "string" || typeof rawId === "number" ? rawId : "";
return {
vendorId,
canDeactivate: readBool(item, "canDeactivate", "CanDeactivate"),
openWorkOrders: Array.isArray(openWorkOrdersRaw) ? openWorkOrdersRaw.map(mapOpenWorkOrder) : [],
};
}

View file

@ -1,16 +1,42 @@
import { z } from "zod";
import {
VENDOR_PREFERRED_CONTACT_VALUES,
type VendorPreferredContact,
} from "@/domain/vendors/types/vendor";
const httpsUrl = z
.string()
.trim()
.refine(
(value) => value === "" || /^https:\/\/.+/i.test(value),
"Google Maps URL must use HTTPS",
);
const northAmericanPhone = z.string().refine((value) => {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
return digits.length === 10 || (digits.length === 11 && digits.startsWith("1"));
}, "Enter a 10-digit phone number");
export const vendorSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
contactName: z.string().optional(),
email: z.string().min(1, "Email is required").email("Invalid email"),
phone: z.string().optional(),
companyName: z.string().min(1, "Company is required"),
contactName: z.string().min(1, "Technician name is required"),
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
phone: northAmericanPhone.optional(),
companyPhone: northAmericanPhone.optional(),
googleMapsUrl: httpsUrl.optional(),
notes: z.string().optional(),
totalJobs: z.number().optional(),
address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
zip: z.string().optional(),
tradeSpecialties: z.string().optional(),
isActive: z.boolean().optional(),
companyId: z.union([z.string(), z.number()]).nullable().optional(),
preferredContact: z
.enum(VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]])
.optional(),
});
export type VendorFormValues = z.infer<typeof vendorSchema>;

View file

@ -1,31 +1,54 @@
export type VendorPreferredContact = "Phone" | "Email" | "Text";
export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"];
export interface VendorListItem {
id: string | number;
companyId: string | number | null;
companyName: string;
contactName: string;
email: string;
phone: string;
companyPhone: string;
googleMapsUrl: string;
notes: string;
totalJobs: number;
city: string;
state: string;
tradeSpecialties: string;
isActive: boolean;
preferredContact: VendorPreferredContact;
}
export interface Vendor {
id?: string | number;
companyId: string | number | null;
companyName: string;
contactName: string;
email: string;
phone: string;
companyPhone: string;
googleMapsUrl: string;
notes: string;
totalJobs: number;
address: string;
city: string;
state: string;
zip: string;
tradeSpecialties: string;
isActive: boolean;
preferredContact: VendorPreferredContact;
}
export interface VendorsListParams {
search?: string;
isActive?: boolean;
page?: number;
pageSize?: number;
companies?: string[];
trades?: string[];
locations?: string[];
jobBuckets?: string[];
}
export interface VendorsListResult {
@ -35,9 +58,54 @@ export interface VendorsListResult {
pageSize: number;
}
export interface VendorFacetCompany {
companyId: string | number | null;
name: string;
companyPhone: string;
email: string;
address: string;
city: string;
state: string;
zip: string;
googleMapsUrl: string;
}
export interface VendorFacetLocation {
city: string;
state: string;
label: string;
}
export interface VendorFacetJobBucket {
id: string;
label: string;
}
export interface VendorFacets {
companies: VendorFacetCompany[];
trades: string[];
locations: VendorFacetLocation[];
jobBuckets: VendorFacetJobBucket[];
}
export interface VendorPortalToken {
portalUrl: string;
issuedAt: string;
expiresAt: string;
lastUsedAt?: string;
}
export interface VendorOpenWorkOrderSummary {
workOrderId: string | number;
workOrderNumber: string;
workOrderTitle: string;
status: string;
scheduledDate?: string;
dispatchId?: string | number;
}
export interface VendorDeactivationImpact {
vendorId: string | number;
canDeactivate: boolean;
openWorkOrders: VendorOpenWorkOrderSummary[];
}

View file

@ -1,5 +1,4 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
@ -7,7 +6,6 @@ import type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
@ -15,7 +13,6 @@ export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormVa
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor created");
navigate("/vendors");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vendor");

View file

@ -10,10 +10,10 @@ export function useDeleteVendor(): UseMutationResult<void, Error, string | numbe
mutationFn: (id: string | number) => vendorsApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor deleted");
toast.success("Vendor deactivated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete vendor");
toast.error(error.message || "Failed to deactivate vendor");
},
});
}

View file

@ -1,5 +1,4 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
@ -12,7 +11,6 @@ interface UpdateVendorInput {
}
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
@ -21,7 +19,6 @@ export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendor
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.detail(variables.id) });
toast.success("Vendor updated");
navigate("/vendors");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update vendor");

View file

@ -0,0 +1,22 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorDeactivationImpact } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useVendorDeactivationImpact(
id: string | number | undefined,
): UseQueryResult<VendorDeactivationImpact, Error> {
return useQuery({
queryKey: queryKeys.vendors.deactivationImpact(id ?? ""),
queryFn: () => vendorsApi.getDeactivationImpact(requireParam(id)),
enabled: id !== undefined && id !== null && id !== "",
staleTime: 0,
});
}
function requireParam(id: string | number | undefined): string | number {
if (id === undefined || id === null || id === "") {
throw new Error("Vendor id is required");
}
return id;
}

View file

@ -0,0 +1,13 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { vendorsApi, type VendorFacetsParams } from "@/domain/vendors/api/vendors-api";
import type { VendorFacets } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useVendorFacets(isActive?: boolean): UseQueryResult<VendorFacets, Error> {
const params: VendorFacetsParams | undefined = isActive !== undefined ? { isActive } : undefined;
return useQuery({
queryKey: queryKeys.vendors.facets(isActive),
queryFn: () => vendorsApi.getFacets(params),
staleTime: 60_000,
});
}

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
isWoTableMockEnabled,
@ -37,6 +37,7 @@ import type {
LocationOption,
TaskListTemplate,
UpdateDispatchPayload,
WorkOrderVendorUpdatePayload,
VendorDropdownItem,
WorkOrderComment,
WorkOrderDetail,
@ -294,6 +295,13 @@ export const workOrdersApi = {
await apiPost<unknown>(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
},
updateVendorFromWorkOrder: async (
vendorId: string | number,
payload: WorkOrderVendorUpdatePayload,
): Promise<void> => {
await apiPut<unknown>(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload);
},
addDispatchComment: async (
dispatchId: string | number,
text: string,

View file

@ -250,9 +250,11 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
return {
id: readId(item),
vendorId: readId({ id: item.vendorId ?? item.VendorId }),
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
vendorName: readString(item, "vendorName", "VendorName"),
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
status: readString(item, "status", "Status"),
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),

View file

@ -1,13 +1,24 @@
export interface WorkOrderDispatchSummary {
id: string | number;
vendorId: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
vendorEmail: string;
nteAmount: number | null;
status: string;
dispatchedAt: string;
}
export interface WorkOrderVendorUpdatePayload {
workOrderId: number;
contactName?: string;
preferredContact: "Phone" | "Email" | "Text";
phone?: string;
email?: string;
notes?: string;
}
export interface DispatchChecklistItem {
id: string | number;
itemText: string;

View file

@ -7,6 +7,7 @@ export type {
DispatchWorkOrderRef,
UpdateDispatchPayload,
WorkOrderDispatchSummary,
WorkOrderVendorUpdatePayload,
} from "./dispatch";
export type {

View file

@ -11,6 +11,7 @@ import type {
WorkOrderComment,
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderVendorUpdatePayload,
} from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
@ -46,6 +47,11 @@ interface ChangeWorkOrderAssignmentInput {
userName: string;
}
interface UpdateWorkOrderVendorInput {
vendorId: string | number;
payload: WorkOrderVendorUpdatePayload;
}
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,
@ -89,6 +95,26 @@ export function useUpdateWorkOrder(
});
}
export function useUpdateWorkOrderVendor(
workOrderId: string | number,
): UseMutationResult<void, Error, UpdateWorkOrderVendorInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ vendorId, payload }) =>
workOrdersApi.updateVendorFromWorkOrder(vendorId, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor contact updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update vendor contact");
},
});
}
export function useAddWorkOrderComment(
workOrderId: string | number,
): UseMutationResult<WorkOrderComment, Error, AddWorkOrderCommentInput> {

View file

@ -56,8 +56,11 @@ export const queryKeys = {
list: (params?: Record<string, unknown>) =>
[...queryKeys.vendors.all, "list", params ?? {}] as const,
detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const,
facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const,
portalToken: (vendorId: string | number) =>
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
deactivationImpact: (vendorId: string | number) =>
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
},
calendar: {
all: ["calendar"] as const,

View file

@ -4,28 +4,28 @@ import type { RouteObject } from "react-router";
import type { RouteModule } from "@/routing/route-module";
function Modals({ modalRoutes }: { modalRoutes: Record<string, ComponentType> }) {
const modalPath = useLocation().state?.modal as string | undefined;
const Modal = modalPath ? modalRoutes[modalPath] : undefined;
if (!Modal) {
return null;
}
return <Modal />;
}
export function createAppShellRoute(
appLayout: RouteModule | undefined,
modalRoutes: Record<string, ComponentType>,
): Pick<RouteObject, "Component" | "ErrorBoundary" | "loader"> {
const AppLayout = appLayout?.default ?? Outlet;
function Modals() {
const modalPath = useLocation().state?.modal as string | undefined;
const Modal = modalPath ? modalRoutes[modalPath] : undefined;
if (!Modal) {
return null;
}
return <Modal />;
}
function Layout() {
return (
<>
<AppLayout />
<Modals modalRoutes={modalRoutes} />
<Modals />
</>
);
}

View file

@ -0,0 +1,205 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
import { renderWithProviders } from "@/test/test-utils";
const companies: VendorFacetCompany[] = [
{
companyId: "co-1",
name: "Gateway Plumbing",
companyPhone: "(314) 555-0198",
email: "dispatch@gatewayplumbing.com",
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
},
];
const emptyForm: VendorFormValues = {
companyName: "",
contactName: "Tech Person",
email: "",
phone: "",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
companyId: null,
preferredContact: "Phone",
};
function Harness({
defaultValues,
resetTarget,
}: {
defaultValues: VendorFormValues;
resetTarget: VendorFormValues;
}) {
const { control, reset, setValue } = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema),
defaultValues,
});
const onCompanySelected = (company: VendorFacetCompany | null) => {
if (!company) return;
setValue("companyId", company.companyId);
setValue("companyPhone", company.companyPhone);
setValue("email", company.email);
setValue("address", company.address);
setValue("city", company.city);
setValue("state", company.state);
setValue("zip", company.zip);
setValue("googleMapsUrl", company.googleMapsUrl);
};
const companyId = useWatch({ control, name: "companyId" });
return (
<>
<VendorFormFields
control={control}
errors={{} as FieldErrors<VendorFormValues>}
companies={companies}
tradeOptions={[]}
onCompanySelected={onCompanySelected}
/>
<output data-testid="company-id">{String(companyId ?? "")}</output>
<button type="button" onClick={() => reset(resetTarget)}>
reset-form
</button>
</>
);
}
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
const companyIdOutput = () => screen.getByTestId("company-id");
describe("VendorFormFields company autocomplete state", () => {
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Leftover Co");
expect(companyField()).toHaveValue("Leftover Co");
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
expect(companyField()).toHaveValue("");
});
it("shows the switched company value instead of previously typed text when the form is reset", async () => {
renderWithProviders(
<Harness
defaultValues={emptyForm}
resetTarget={{ ...emptyForm, companyName: "Beta LLC", contactName: "Other Tech" }}
/>,
{ withAuth: false },
);
await userEvent.type(companyField(), "Acme");
expect(companyField()).toHaveValue("Acme");
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
expect(companyField()).toHaveValue("Beta LLC");
});
it("reflects the company provided by an external reset even when nothing was typed", async () => {
renderWithProviders(
<Harness
defaultValues={{ ...emptyForm, companyName: "Acme Corp" }}
resetTarget={{ ...emptyForm, companyName: "Beta LLC" }}
/>,
{ withAuth: false },
);
expect(companyField()).toHaveValue("Acme Corp");
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
expect(companyField()).toHaveValue("Beta LLC");
});
it("updates the displayed company as the user types (freeSolo)", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Acme");
expect(companyField()).toHaveValue("Acme");
});
it("selects a company option, autofills company details, and shows the company name", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Gate");
const option = await screen.findByRole("option", { name: "Gateway Plumbing" });
await userEvent.click(option);
expect(companyField()).toHaveValue("Gateway Plumbing");
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
});
it("sets companyId when a known company is selected and clears it when the text changes", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Gate");
await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" }));
expect(companyIdOutput()).toHaveTextContent("co-1");
await userEvent.type(companyField(), "!");
expect(companyIdOutput()).toHaveTextContent("");
});
it("keeps companyId null for free-typed company text that matches no option", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Independent Vendor LLC");
expect(companyIdOutput()).toHaveTextContent("");
});
it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
const phone = screen.getByRole("textbox", { name: "Technician Phone" });
await userEvent.type(phone, "3145550198");
expect(phone).toHaveValue("(314) 555-0198");
const companyPhone = screen.getByRole("textbox", { name: "Company Phone" });
await userEvent.type(companyPhone, "3145550100");
expect(companyPhone).toHaveValue("(314) 555-0100");
});
it("exposes a preferred contact selector with Phone, Email, and Text options", () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,163 @@
import { screen } 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 { renderWithProviders } from "@/test/test-utils";
const useVendorsList = vi.fn();
const useVendorDetail = vi.fn();
const useVendorDeactivationImpact = vi.fn();
const mutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({
useVendorDetail: (...args: unknown[]) => useVendorDetail(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({
useVendorDeactivationImpact: (...args: unknown[]) => useVendorDeactivationImpact(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({
useDeleteVendor: () => ({ mutate, isPending: false }),
}));
vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const activeVendor = {
id: 1,
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
email: "dispatch@gatewayplumbing.com",
phone: "(314) 555-0198",
city: "St. Louis",
state: "MO",
tradeSpecialties: "Backflow Preventers",
isActive: true,
preferredContact: "Email",
};
const inactiveVendor = {
...activeVendor,
id: 2,
companyName: "Drain Cleaning Bros",
isActive: false,
};
function result(items: (typeof activeVendor)[], totalCount: number) {
return {
data: { items, totalCount, page: 1, pageSize: 12 },
isLoading: false,
isFetching: false,
error: null,
};
}
function setupDefaults() {
useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null });
useVendorDeactivationImpact.mockReturnValue({
data: undefined,
isLoading: false,
error: null,
});
}
describe("VendorsListPage", () => {
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
setupDefaults();
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
});
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
...activeVendor,
id: 100 + index,
companyName: `Vendor ${index + 1}`,
}));
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], params.isActive ? 25 : 0);
return result(pageVendors, 25);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
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();
useVendorDetail.mockReturnValue({ data: activeVendor, 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 });
const row = screen.getByRole("button", {
name: "Open vendor details for Gateway Plumbing",
});
row.focus();
await userEvent.keyboard("{Enter}");
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
});
it("blocks deactivation when the preflight reports open work orders", async () => {
setupDefaults();
useVendorDeactivationImpact.mockReturnValue({
data: {
vendorId: 1,
canDeactivate: false,
openWorkOrders: [
{
workOrderId: 101,
workOrderNumber: "WO-101",
workOrderTitle: "Boiler repair",
status: "Dispatched",
},
],
},
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: "Deactivate vendor" }));
expect(
screen.getByText(/cannot be deactivated because it still has open work orders/),
).toBeInTheDocument();
expect(screen.getByText(/Boiler repair/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
expect(mutate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,82 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Text } from "@/components/ui/text";
import { renderWithProviders } from "@/test/test-utils";
describe("Text", () => {
it("maps visual variants to semantic elements", () => {
renderWithProviders(
<>
<Text variant="display">Page title</Text>
<Text variant="description">Supporting copy</Text>
</>,
{ withAuth: false },
);
expect(screen.getByRole("heading", { level: 1, name: "Page title" })).toBeInTheDocument();
expect(screen.getByText("Supporting copy").tagName).toBe("P");
});
it("does not render conditional text when its condition is false", () => {
renderWithProviders(
<Text when={false} variant="body">
Saved
</Text>,
{ withAuth: false },
);
expect(screen.queryByText("Saved")).not.toBeInTheDocument();
});
it("keeps the feedback live region mounted so toggling its content is announceable", () => {
const { rerender } = renderWithProviders(
<Text variant="feedback" when={false}>
Saved
</Text>,
{ withAuth: false },
);
const region = screen.getByRole("status");
expect(region).toHaveAttribute("aria-live", "polite");
expect(region).toHaveAttribute("aria-atomic", "true");
expect(region).toBeEmptyDOMElement();
rerender(
<Text variant="feedback" when={true}>
Saved
</Text>,
);
expect(screen.getByRole("status")).toBe(region);
expect(region).toHaveTextContent("Saved");
});
it("gives error and feedback messages accessible live-region semantics", () => {
renderWithProviders(
<>
<Text variant="error">Upload failed</Text>
<Text variant="feedback">Uploading</Text>
</>,
{ withAuth: false },
);
const alert = screen.getByRole("alert");
const status = screen.getByRole("status");
expect(alert).toHaveTextContent("Upload failed");
expect(status).toHaveTextContent("Uploading");
expect(alert).toHaveAttribute("aria-live", "assertive");
expect(alert).toHaveAttribute("aria-atomic", "true");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveAttribute("aria-atomic", "true");
});
it("does not attach live-region semantics to non-feedback variants", () => {
renderWithProviders(<Text variant="body">Saved</Text>, { withAuth: false });
const node = screen.getByText("Saved");
expect(node).not.toHaveAttribute("role");
expect(node).not.toHaveAttribute("aria-live");
expect(node).not.toHaveAttribute("aria-atomic");
});
});

View file

@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiDelete = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
apiDelete: (...args: unknown[]) => apiDelete(...args),
}));
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
describe("vendorsApi.getList", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPut.mockReset();
apiDelete.mockReset();
apiGet.mockResolvedValue({
Data: [
{
Id: 7,
CompanyId: "co-1",
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
City: "St. Louis",
State: "MO",
IsActive: false,
PreferredContact: "Email",
},
],
TotalCount: 1,
});
});
it("forwards the status filter and maps list location fields", async () => {
const result = await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 });
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
});
expect(result.items[0]).toMatchObject({
city: "St. Louis",
state: "MO",
isActive: false,
companyId: "co-1",
preferredContact: "Email",
});
});
it("forwards all selected facets as repeated indexed query parameters", async () => {
await vendorsApi.getList({
companies: ["Gateway Plumbing", "Metro HVAC"],
trades: ["Plumbing"],
locations: ["St. Louis, MO"],
jobBuckets: ["50-99"],
});
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
searchParams: {
search: "",
page: 1,
pageSize: 12,
"companies[0]": "Gateway Plumbing",
"companies[1]": "Metro HVAC",
"trades[0]": "Plumbing",
"locations[0]": "St. Louis, MO",
"jobBuckets[0]": "50-99",
},
});
});
it("maps company profiles and labels returned by the facets endpoint", async () => {
apiGet.mockResolvedValueOnce({
data: {
companies: [
{
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
},
],
trades: ["Plumbing"],
locations: [{ city: "St. Louis", state: "MO" }],
jobBuckets: [{ id: "50-99", label: "50–99" }],
},
});
const facets = await vendorsApi.getFacets();
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} });
expect(facets.companies[0]).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
googleMapsUrl: "https://maps.google.com/gateway",
});
expect(facets.locations[0]?.label).toBe("St. Louis, MO");
expect(facets.jobBuckets[0]).toEqual({ id: "50-99", label: "50–99" });
});
it("serializes the requested facet scope as an isActive query parameter", async () => {
apiGet.mockResolvedValue({
data: { companies: [], trades: [], locations: [], jobBuckets: [] },
});
await vendorsApi.getFacets({ isActive: true });
await vendorsApi.getFacets({ isActive: false });
expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.vendor.facets, {
searchParams: { isActive: "true" },
});
expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.vendor.facets, {
searchParams: { isActive: "false" },
});
});
it("sends all editable prototype fields when creating and updating", async () => {
const values = {
companyId: "42",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
phone: "+1 314-555-0198",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
tradeSpecialties: "Plumbing, HVAC",
googleMapsUrl: "https://maps.google.com/gateway",
notes: "Preferred after-hours vendor",
totalJobs: 72,
isActive: true,
preferredContact: "Text",
};
apiPost.mockResolvedValueOnce({ data: values });
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
await vendorsApi.create(values);
await vendorsApi.update(7, { ...values, isActive: false });
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({
companyName: "Gateway Plumbing",
notes: values.notes,
companyId: 42,
preferredContact: "Text",
phone: "(314) 555-0198",
companyPhone: "(314) 555-0100",
}),
);
expect(apiPut).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7`,
expect.objectContaining({ companyName: "Gateway Plumbing", isActive: false }),
);
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
});
it("sends a null companyId when free-text company has no company link", async () => {
apiPost.mockResolvedValueOnce({ data: {} });
await vendorsApi.create({
companyName: "Independent Vendor LLC",
contactName: "Indy Tech",
preferredContact: "Phone",
});
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({ companyName: "Independent Vendor LLC", companyId: null }),
);
});
it("does not send a non-numeric company id to the integer backend contract", async () => {
apiPost.mockResolvedValueOnce({ data: {} });
await vendorsApi.create({
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
});
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({ companyId: null }),
);
});
it("preserves a legacy phone on read and unrelated write", async () => {
apiGet.mockResolvedValueOnce({
data: {
id: 7,
companyName: "Legacy Vendor",
contactName: "Taylor",
phone: "555-1234 ext 9",
},
});
apiPut.mockResolvedValueOnce({ data: {} });
const vendor = await vendorsApi.getById(7);
expect(vendor.phone).toBe("555-1234 ext 9");
await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" });
expect(apiPut).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7`,
expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }),
);
});
it("maps the deactivation-impact payload including linked work orders", async () => {
apiGet.mockResolvedValueOnce({
data: {
vendorId: 7,
canDeactivate: false,
openWorkOrders: [
{
workOrderId: 101,
workOrderNumber: "WO-101",
workOrderTitle: "Boiler repair",
status: "Dispatched",
scheduledDate: "2025-01-02",
dispatchId: 501,
},
],
},
});
const impact = await vendorsApi.getDeactivationImpact(7);
expect(apiGet).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7/${API_PATHS.vendor.deactivationImpact}`,
);
expect(impact).toEqual({
vendorId: 7,
canDeactivate: false,
openWorkOrders: [
{
workOrderId: 101,
workOrderNumber: "WO-101",
workOrderTitle: "Boiler repair",
status: "Dispatched",
scheduledDate: "2025-01-02",
dispatchId: 501,
},
],
});
});
it("deletes a vendor by id and surfaces the 409 error without retry swallowing", async () => {
apiDelete.mockResolvedValueOnce(undefined);
await vendorsApi.delete(7);
expect(apiDelete).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`);
const conflict = Object.assign(new Error("Conflict"), { response: { status: 409 } });
apiDelete.mockRejectedValueOnce(conflict);
await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict");
});
});

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema";
const validVendor = {
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
email: "dispatch@gateway.test",
phone: "(314) 555-0198",
googleMapsUrl: "https://maps.google.com/gateway",
};
describe("vendorSchema", () => {
it("requires both the company and technician identity", () => {
const result = vendorSchema.safeParse({ ...validVendor, companyName: "", contactName: "" });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.flatten().fieldErrors.companyName).toContain("Company is required");
expect(result.error.flatten().fieldErrors.contactName).toContain(
"Technician name is required",
);
}
});
it("accepts an empty optional email and HTTPS map URL", () => {
expect(vendorSchema.safeParse({ ...validVendor, email: "" }).success).toBe(true);
});
it("rejects malformed email and non-HTTPS map links", () => {
expect(
vendorSchema.safeParse({
...validVendor,
email: "not-an-email",
googleMapsUrl: "http://maps.google.com/gateway",
}).success,
).toBe(false);
});
it("accepts canonicalizable North American phones and rejects partials", () => {
expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe(
true,
);
expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe(
true,
);
expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(true);
expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true);
expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false);
});
it("accepts a nullable companyId and a known preferred contact value", () => {
expect(
vendorSchema.safeParse({ ...validVendor, companyId: null, preferredContact: "Text" }).success,
).toBe(true);
expect(
vendorSchema.safeParse({ ...validVendor, companyId: 42, preferredContact: "Email" }).success,
).toBe(true);
});
it("rejects an unknown preferred contact value", () => {
expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false);
});
});

View file

@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const getFacets = vi.fn();
vi.mock("@/domain/vendors/api/vendors-api", () => ({
vendorsApi: { getFacets: (...args: unknown[]) => getFacets(...args) },
}));
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import { queryKeys } from "@/infra/query-key/query-key";
const emptyFacets = { companies: [], trades: [], locations: [], jobBuckets: [] };
const activeFacets = { ...emptyFacets, companies: [{ name: "Active Co" }] };
const inactiveFacets = { ...emptyFacets, companies: [{ name: "Inactive Co" }] };
function createClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
});
}
function makeWrapper(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe("useVendorFacets", () => {
beforeEach(() => {
getFacets.mockReset();
});
it("switches facet requests true/false as the tab scope changes", async () => {
getFacets.mockResolvedValue(activeFacets);
const { rerender } = renderHook(({ isActive }) => useVendorFacets(isActive), {
initialProps: { isActive: true as boolean | undefined },
wrapper: makeWrapper(createClient()),
});
await waitFor(() => expect(getFacets).toHaveBeenCalledWith({ isActive: true }));
getFacets.mockResolvedValue(inactiveFacets);
rerender({ isActive: false });
await waitFor(() => expect(getFacets).toHaveBeenCalledWith({ isActive: false }));
getFacets.mockResolvedValue(activeFacets);
rerender({ isActive: undefined });
await waitFor(() => expect(getFacets).toHaveBeenCalledWith(undefined));
});
it("scopes the cache so active, inactive, and unscoped keys cannot collide", () => {
const active = queryKeys.vendors.facets(true);
const inactive = queryKeys.vendors.facets(false);
const unscoped = queryKeys.vendors.facets();
expect(active).not.toEqual(inactive);
expect(active).not.toEqual(unscoped);
expect(inactive).not.toEqual(unscoped);
expect(active[active.length - 1]).toBe(true);
expect(inactive[inactive.length - 1]).toBe(false);
expect(unscoped[unscoped.length - 1]).toBeNull();
});
it("reuses the cached facet entry when returning to a previous scope", async () => {
getFacets.mockResolvedValue(activeFacets);
const client = createClient();
const { result, rerender } = renderHook(({ isActive }) => useVendorFacets(isActive), {
initialProps: { isActive: true as boolean | undefined },
wrapper: makeWrapper(client),
});
await waitFor(() => expect(getFacets).toHaveBeenCalledTimes(1));
getFacets.mockResolvedValue(inactiveFacets);
rerender({ isActive: false });
await waitFor(() => expect(getFacets).toHaveBeenCalledTimes(2));
getFacets.mockClear();
rerender({ isActive: true });
await waitFor(() => expect(result.current.data).toEqual(activeFacets));
expect(getFacets).not.toHaveBeenCalled();
});
});

View file

@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
const apiGet = vi.fn();
const apiPut = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
apiPost: vi.fn(),
apiDelete: vi.fn(),
apiPostNoContent: vi.fn(),
@ -15,6 +17,7 @@ import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
describe("workOrdersApi.getById", () => {
beforeEach(() => {
apiGet.mockReset();
apiPut.mockReset();
apiGet.mockResolvedValue({
Id: 10,
title: "Test WO",
@ -22,6 +25,22 @@ describe("workOrdersApi.getById", () => {
});
});
it("updates only the assigned vendor through the work-order-scoped endpoint", async () => {
apiPut.mockResolvedValueOnce(undefined);
const payload = {
workOrderId: 10,
contactName: "Taylor Tech",
preferredContact: "Text" as const,
phone: "(314) 555-0199",
email: "taylor@example.com",
notes: "Text before arrival",
};
await workOrdersApi.updateVendorFromWorkOrder(7, payload);
expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7/work-order-update`, payload);
});
it("calls GetWorkorderById with id query param, not path suffix", async () => {
await workOrdersApi.getById(10);

View file

@ -1,5 +1,26 @@
import { describe, expect, it } from "vitest";
import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper";
import {
mapUpliftRequest,
mapWorkOrderDispatchSummary,
} from "@/domain/work-orders/mappers/work-order-mapper";
describe("mapWorkOrderDispatchSummary", () => {
it("preserves the assigned vendor identity needed by inline editing", () => {
expect(
mapWorkOrderDispatchSummary({
Id: 21,
VendorId: 7,
VendorName: "Gateway Plumbing",
VendorEmail: "dispatch@gateway.test",
}),
).toMatchObject({
id: 21,
vendorId: 7,
vendorName: "Gateway Plumbing",
vendorEmail: "dispatch@gateway.test",
});
});
});
describe("mapUpliftRequest", () => {
it("maps decided uplift fields from PascalCase payload", () => {