Upload photos and videos for this work order.
+
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index dd5b0df8..d45464c5 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -219,7 +219,7 @@ export default function WorkOrdersListPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load work orders"}
diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx
index 548f4ed3..cd3bb545 100644
--- a/src/app/v/[token]/_layout.tsx
+++ b/src/app/v/[token]/_layout.tsx
@@ -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 (
Sea Haven Industries — Vendor Portal
- {status === "ready" && vendor?.companyName && (
+ {status === "ready" && vendor != null && Boolean(vendor.companyName) && (
{vendor.companyName}
)}
@@ -55,13 +56,13 @@ function VendorPortalBody() {
if (status === "error") {
return (
-
-
Access Denied
-
+
+ Access Denied
+
{error?.message ||
"This link is invalid or has expired. Please contact your dispatcher for a new link."}
-
-
+
+
);
}
diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx
index a7dc74f9..eeb222a2 100644
--- a/src/app/v/[token]/dashboard.tsx
+++ b/src/app/v/[token]/dashboard.tsx
@@ -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 (
<>
-
Your Work
+
Your Work
Active dispatches and work orders assigned to your company.
@@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
{isLoading &&
Loading dispatches…
}
- {error &&
{error.message}
}
+
+ {error?.message}
+
{!isLoading && !error && filtered.length === 0 && (
No dispatches in this view.
diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx
index 2edae8a1..a7000843 100644
--- a/src/app/v/[token]/dispatch/[id].tsx
+++ b/src/app/v/[token]/dispatch/[id].tsx
@@ -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({
{item.itemText}
- {item.isCompleted && item.completedAt && (
+ {item.isCompleted && Boolean(item.completedAt) && (
{formatVendorPortalDateTime(item.completedAt)}
@@ -224,7 +225,7 @@ function UpliftRequestsSection({
- {request.vendorReason && (
+ {Boolean(request.vendorReason) && (
Reason
{request.vendorReason}
@@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() {
}
if (error && !data) {
- return
{error.message}
;
+ return (
+
+ {error.message}
+
+ );
}
if (!data) return null;
@@ -457,20 +462,20 @@ export default function VendorPortalDispatchPage() {
-
{data.dispatchNumber}
+
{data.dispatchNumber}
{data.workOrder?.workerOrderTitle}
{data.status}
- {data.workOrder?.internalWONumber && (
+ {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
WO #
{data.workOrder.internalWONumber}
)}
- {data.poNumber && (
+ {Boolean(data.poNumber) && (
PO #
{data.poNumber}
@@ -482,7 +487,7 @@ export default function VendorPortalDispatchPage() {
${Number(data.nteAmount).toFixed(2)}
)}
- {data.scheduledDate && (
+ {Boolean(data.scheduledDate) && (
Scheduled
{formatVendorPortalDateTime(data.scheduledDate)}
@@ -490,9 +495,11 @@ export default function VendorPortalDispatchPage() {
)}
- {data.location && (
+ {data.location != null && (
<>
-
Location
+
+ Location
+
{data.location.name}
{[
@@ -507,9 +514,11 @@ export default function VendorPortalDispatchPage() {
>
)}
- {(data.description || data.workOrder?.description) && (
+ {Boolean(data.description || data.workOrder?.description) && (
<>
-
Description
+
+ Description
+
{data.description || data.workOrder?.description}
>
)}
@@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
-
Checklist
+
Checklist
-
NTE Uplift Requests
+
NTE Uplift Requests
-
Customer Signoff
+
Customer Signoff
-
Vendor Signoff
+
Vendor Signoff
-
Comments
+
Comments
-
Your Purchase Orders
+
Your Purchase Orders
Every PO issued to your company, with current status and dollar value.
@@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
{isLoading && Loading POs…
}
- {error && {error.message}
}
+
+ {error?.message}
+
{!isLoading && !error && visible.length === 0 && (
No POs match this view.
diff --git a/src/components/common/calendar/event-calendar.tsx b/src/components/common/calendar/event-calendar.tsx
index ca7d11aa..0616c81b 100644
--- a/src/components/common/calendar/event-calendar.tsx
+++ b/src/components/common/calendar/event-calendar.tsx
@@ -22,7 +22,7 @@ export const EventCalendar = forwardRef(functi
) {
return (
- {loading && (
+ {Boolean(loading) && (
diff --git a/src/components/common/signature-capture.tsx b/src/components/common/signature-capture.tsx
index c08123c2..9c14cf18 100644
--- a/src/components/common/signature-capture.tsx
+++ b/src/components/common/signature-capture.tsx
@@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
fullWidth
size="small"
/>
- {typedName && (
+ {Boolean(typedName) && (
- {Icon && }
+ {Icon != null && }
{label}
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx
index 944591a7..5cf52b5f 100644
--- a/src/components/ui/empty-state.tsx
+++ b/src/components/ui/empty-state.tsx
@@ -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 &&
{icon} }
+ {Boolean(icon) &&
{icon} }
{title}
- {description && (
+ {Boolean(description) && (
)}
- {action && {action} }
+ {Boolean(action) && {action} }
);
}
diff --git a/src/components/ui/filter-popover.tsx b/src/components/ui/filter-popover.tsx
index 59824344..2d8d5e8a 100644
--- a/src/components/ui/filter-popover.tsx
+++ b/src/components/ui/filter-popover.tsx
@@ -34,7 +34,7 @@ export function FilterPopover({
Filters
- {activeCount > 0 && onClear && (
+ {activeCount > 0 && Boolean(onClear) && (
{children}
- {helperText && {helperText} }
+ {Boolean(helperText) && {helperText} }
);
}
diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx
index a43b99e3..566ae837 100644
--- a/src/components/ui/page-header.tsx
+++ b/src/components/ui/page-header.tsx
@@ -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({
}}
>
- {eyebrow && (
-
- {eyebrow}
-
- )}
-
+ {eyebrow}
+
+
{title}
-
- {subtitle && (
-
- {subtitle}
-
- )}
+
+
+ {subtitle}
+
- {actions && (
+ {Boolean(actions) && (
{actions}
diff --git a/src/components/ui/text.tsx b/src/components/ui/text.tsx
new file mode 100644
index 00000000..f7041792
--- /dev/null
+++ b/src/components/ui/text.tsx
@@ -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 = {
+ default: "text.primary",
+ muted: "text.secondary",
+ error: "error",
+ success: "success.main",
+ warning: "warning.main",
+};
+
+const familyValue: Record = {
+ 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 (
+
+ {when && children}
+
+ );
+}
diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts
index 42ac4ac2..4f001f04 100644
--- a/src/domain/vendors/api/vendors-api.ts
+++ b/src/domain/vendors/api/vendors-api.ts
@@ -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,
+ 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 {
+ const searchParams: Record = {};
+ if (params?.isActive !== undefined) {
+ searchParams.isActive = String(params.isActive);
+ }
+ return searchParams;
+}
+
function buildListParams(params: VendorsListParams): Record {
- return {
+ const searchParams: Record = {
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 => {
+ const data = await apiGet(API_PATHS.vendor.facets, {
+ searchParams: buildFacetsParams(params),
+ });
+ return mapVendorFacets(handleApiResponse(data));
+ },
+
getById: async (id: string | number): Promise => {
const data = await apiGet(`${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 => {
+ const data = await apiGet(
+ `${API_PATHS.rest.vendors}/${id}/${API_PATHS.vendor.deactivationImpact}`,
+ );
+ return mapVendorDeactivationImpact(handleApiResponse(data));
+ },
+
getPortalToken: async (id: string | number): Promise => {
const data = await apiGet(`${API_PATHS.rest.vendors}/${id}/portal-token`);
return mapVendorPortalToken(handleApiResponse(data));
diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts
index 695a0b44..9273ef8b 100644
--- a/src/domain/vendors/mappers/vendor-mapper.ts
+++ b/src/domain/vendors/mappers/vendor-mapper.ts
@@ -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(["Phone", "Email", "Text"]);
function asRecord(raw: unknown): Record {
return typeof raw === "object" && raw !== null ? (raw as Record) : {};
@@ -13,12 +26,69 @@ function readString(record: Record, ...keys: string[]): string
return "";
}
+function readNumber(record: Record, ...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 | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") return value;
return "";
}
+function readCompanyId(record: Record): 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): 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,
+ ...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, ...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 {
const item = asRecord(vendor);
+ const companyName = readString(item, "companyName", "CompanyName");
const payload: Record = {
- 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 {
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) : [],
+ };
+}
diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts
index cb33e82d..7ce3be5e 100644
--- a/src/domain/vendors/schemas/vendor-schema.ts
+++ b/src/domain/vendors/schemas/vendor-schema.ts
@@ -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;
diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts
index 71a41551..6c67847c 100644
--- a/src/domain/vendors/types/vendor.ts
+++ b/src/domain/vendors/types/vendor.ts
@@ -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[];
+}
diff --git a/src/domain/vendors/use-cases/use-create-vendor.ts b/src/domain/vendors/use-cases/use-create-vendor.ts
index 7e3283a4..fa1c730f 100644
--- a/src/domain/vendors/use-cases/use-create-vendor.ts
+++ b/src/domain/vendors/use-cases/use-create-vendor.ts
@@ -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 {
- const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
@@ -15,7 +13,6 @@ export function useCreateVendor(): UseMutationResult {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor created");
- navigate("/vendors");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vendor");
diff --git a/src/domain/vendors/use-cases/use-delete-vendor.ts b/src/domain/vendors/use-cases/use-delete-vendor.ts
index 20c7e5a5..0457cc7e 100644
--- a/src/domain/vendors/use-cases/use-delete-vendor.ts
+++ b/src/domain/vendors/use-cases/use-delete-vendor.ts
@@ -10,10 +10,10 @@ export function useDeleteVendor(): UseMutationResult 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");
},
});
}
diff --git a/src/domain/vendors/use-cases/use-update-vendor.ts b/src/domain/vendors/use-cases/use-update-vendor.ts
index 34c61c9a..cf4f4c84 100644
--- a/src/domain/vendors/use-cases/use-update-vendor.ts
+++ b/src/domain/vendors/use-cases/use-update-vendor.ts
@@ -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 {
- const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
@@ -21,7 +19,6 @@ export function useUpdateVendor(): UseMutationResult {
toast.error(error.message || "Failed to update vendor");
diff --git a/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts b/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts
new file mode 100644
index 00000000..7c59c970
--- /dev/null
+++ b/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts
@@ -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 {
+ 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;
+}
diff --git a/src/domain/vendors/use-cases/use-vendor-facets.ts b/src/domain/vendors/use-cases/use-vendor-facets.ts
new file mode 100644
index 00000000..2aef7d19
--- /dev/null
+++ b/src/domain/vendors/use-cases/use-vendor-facets.ts
@@ -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 {
+ const params: VendorFacetsParams | undefined = isActive !== undefined ? { isActive } : undefined;
+ return useQuery({
+ queryKey: queryKeys.vendors.facets(isActive),
+ queryFn: () => vendorsApi.getFacets(params),
+ staleTime: 60_000,
+ });
+}
diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts
index c17f207d..a3ede840 100644
--- a/src/domain/work-orders/api/work-orders-api.ts
+++ b/src/domain/work-orders/api/work-orders-api.ts
@@ -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(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
},
+ updateVendorFromWorkOrder: async (
+ vendorId: string | number,
+ payload: WorkOrderVendorUpdatePayload,
+ ): Promise => {
+ await apiPut(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload);
+ },
+
addDispatchComment: async (
dispatchId: string | number,
text: string,
diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts
index 687aa3b1..c424c26e 100644
--- a/src/domain/work-orders/mappers/work-order-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-mapper.ts
@@ -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"),
diff --git a/src/domain/work-orders/types/dispatch.ts b/src/domain/work-orders/types/dispatch.ts
index d5df044c..fdb0175c 100644
--- a/src/domain/work-orders/types/dispatch.ts
+++ b/src/domain/work-orders/types/dispatch.ts
@@ -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;
diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts
index c1440bff..f266bc6a 100644
--- a/src/domain/work-orders/types/work-order.ts
+++ b/src/domain/work-orders/types/work-order.ts
@@ -7,6 +7,7 @@ export type {
DispatchWorkOrderRef,
UpdateDispatchPayload,
WorkOrderDispatchSummary,
+ WorkOrderVendorUpdatePayload,
} from "./dispatch";
export type {
diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
index ed877ab5..5c69e3a4 100644
--- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
@@ -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 {
+ 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 {
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index 21544914..4548791b 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -56,8 +56,11 @@ export const queryKeys = {
list: (params?: Record) =>
[...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,
diff --git a/src/routing/app-shell.tsx b/src/routing/app-shell.tsx
index 956485f3..4aab96e0 100644
--- a/src/routing/app-shell.tsx
+++ b/src/routing/app-shell.tsx
@@ -4,28 +4,28 @@ import type { RouteObject } from "react-router";
import type { RouteModule } from "@/routing/route-module";
-function Modals({ modalRoutes }: { modalRoutes: Record }) {
- const modalPath = useLocation().state?.modal as string | undefined;
- const Modal = modalPath ? modalRoutes[modalPath] : undefined;
-
- if (!Modal) {
- return null;
- }
-
- return ;
-}
-
export function createAppShellRoute(
appLayout: RouteModule | undefined,
modalRoutes: Record,
): Pick {
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 ;
+ }
+
function Layout() {
return (
<>
-
+
>
);
}
diff --git a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx
new file mode 100644
index 00000000..24d4c790
--- /dev/null
+++ b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx
@@ -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({
+ 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 (
+ <>
+ }
+ companies={companies}
+ tradeOptions={[]}
+ onCompanySelected={onCompanySelected}
+ />
+ {String(companyId ?? "")}
+ reset(resetTarget)}>
+ reset-form
+
+ >
+ );
+}
+
+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( , {
+ 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(
+ ,
+ { 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(
+ ,
+ { 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( , {
+ 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( , {
+ 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( , {
+ 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( , {
+ 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( , {
+ 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( , {
+ 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();
+ });
+});
diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx
new file mode 100644
index 00000000..995dd5dc
--- /dev/null
+++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx
@@ -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( , { 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( , { 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( , { 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( , { 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();
+ });
+});
diff --git a/src/test/components/ui/text.test.tsx b/src/test/components/ui/text.test.tsx
new file mode 100644
index 00000000..04973d75
--- /dev/null
+++ b/src/test/components/ui/text.test.tsx
@@ -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(
+ <>
+ Page title
+ Supporting copy
+ >,
+ { 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(
+
+ Saved
+ ,
+ { withAuth: false },
+ );
+
+ expect(screen.queryByText("Saved")).not.toBeInTheDocument();
+ });
+
+ it("keeps the feedback live region mounted so toggling its content is announceable", () => {
+ const { rerender } = renderWithProviders(
+
+ Saved
+ ,
+ { withAuth: false },
+ );
+
+ const region = screen.getByRole("status");
+ expect(region).toHaveAttribute("aria-live", "polite");
+ expect(region).toHaveAttribute("aria-atomic", "true");
+ expect(region).toBeEmptyDOMElement();
+
+ rerender(
+
+ Saved
+ ,
+ );
+
+ expect(screen.getByRole("status")).toBe(region);
+ expect(region).toHaveTextContent("Saved");
+ });
+
+ it("gives error and feedback messages accessible live-region semantics", () => {
+ renderWithProviders(
+ <>
+ Upload failed
+ Uploading
+ >,
+ { 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(Saved , { withAuth: false });
+
+ const node = screen.getByText("Saved");
+ expect(node).not.toHaveAttribute("role");
+ expect(node).not.toHaveAttribute("aria-live");
+ expect(node).not.toHaveAttribute("aria-atomic");
+ });
+});
diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts
new file mode 100644
index 00000000..b66ce1bf
--- /dev/null
+++ b/src/test/domain/vendors/api/vendors-api.test.ts
@@ -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");
+ });
+});
diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts
new file mode 100644
index 00000000..244c2b8e
--- /dev/null
+++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts
@@ -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);
+ });
+});
diff --git a/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx b/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx
new file mode 100644
index 00000000..c732e116
--- /dev/null
+++ b/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx
@@ -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 {children} ;
+ };
+}
+
+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();
+ });
+});
diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts
index 32040a19..f44e7b30 100644
--- a/src/test/domain/work-orders/api/work-orders-api.test.ts
+++ b/src/test/domain/work-orders/api/work-orders-api.test.ts
@@ -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);
diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
index a9dfc9bf..632fa346 100644
--- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
@@ -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", () => {