Merge pull request #59 from Sea-Haven-Industries/codex/vendor-nav-layout-fixes
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
Deploy / deploy (push) Waiting to run

fix(vendors): close reactivation and navigation QA regressions
This commit is contained in:
Alexandre Brandizzi 2026-07-29 21:59:09 -03:00 • committed by GitHub
commit 978e56b7c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1268 additions and 769 deletions

View file

@ -16,9 +16,68 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
async function authenticate(page: Page) {
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/Dashboard/Stats", (route) =>
fulfillJson(route, {
data: {
openWorkOrders: 0,
pendingApprovals: 0,
scheduledPMs: 0,
activeAssets: 0,
},
}),
);
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
}
test.describe("Vendor operations", () => {
test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => {
await authenticate(page);
await page.route("**/api/vendor-operations/insights*", (route) =>
fulfillJson(route, { data: [] }),
);
await page.goto("/vendors/insights");
const vendorLinks = page.locator('a[href^="/vendors"]');
await expect(
vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }),
).toHaveAttribute("aria-current", "page");
await expect(
vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }),
).not.toHaveAttribute("aria-current", "page");
await expect(
vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }),
).not.toHaveAttribute("aria-current", "page");
});
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 });
await authenticate(page);
await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 }));
await page.route("**/api/Vendor/GetVendorList*", (route) =>
fulfillJson(route, { Data: [], TotalCount: 0 }),
);
await page.goto("/vendor-pos");
await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible();
await expect(page.getByText("0 total", { exact: true })).toBeVisible();
await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible();
await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible();
await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible();
await expect(page.getByLabel("From", { exact: true })).toBeVisible();
await expect(page.getByLabel("To", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible();
await expect
.poll(() =>
page
.getByPlaceholder("Search PO #, dispatch, vendor, WO...")
.evaluate((element) => element.clientWidth),
)
.toBeGreaterThanOrEqual(280);
});
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
page,
}) => {

View file

@ -80,9 +80,19 @@ async function captureVisual(page: Page, path: string) {
async function mockVendorApi(
page: Page,
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
options: {
listError?: boolean;
deactivationBlocked?: boolean;
deleteConflict?: boolean;
legacyGateway?: boolean;
} = {},
) {
const state: MockState = { listUrls: [] };
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
if (options.legacyGateway) {
vendorRecords[0].Phone = "555-1234 ext 9";
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
}
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
@ -131,7 +141,7 @@ async function mockVendorApi(
}
const isActive = url.searchParams.get("isActive") !== "false";
const source = isActive ? activeVendors : inactiveVendors;
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
const search = (url.searchParams.get("search") ?? "").toLowerCase();
const company = url.searchParams.get("companies[0]");
const filtered = source.filter(
@ -175,8 +185,12 @@ async function mockVendorApi(
const id = route.request().url().split("/").pop() ?? "";
if (route.request().method() === "PUT") {
state.updatedBody = route.request().postDataJSON();
const vendor = vendorRecords.find((item) => String(item.Id) === id);
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
vendor.IsActive = state.updatedBody.isActive;
}
await fulfillJson(route, {
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) },
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
});
return;
}
@ -190,10 +204,12 @@ async function mockVendorApi(
return;
}
state.deletedId = id;
const vendor = vendorRecords.find((item) => String(item.Id) === id);
if (options.legacyGateway && vendor) vendor.IsActive = false;
await fulfillJson(route, { status: "200" });
return;
}
const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id);
const vendor = vendorRecords.find((item) => String(item.Id) === id);
await fulfillJson(route, { data: vendor });
});
await page.route(/\/api\/vendors\/?$/, async (route) => {
@ -336,6 +352,30 @@ test.describe("Vendor directory prototype parity", () => {
expect(state.updatedBody?.isActive).toBe(false);
});
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
const state = await mockVendorApi(page, { legacyGateway: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (2)" }).click();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled();
await detailDrawer.getByRole("switch", { name: "Inactive" }).click();
await detailDrawer.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await expect(detailDrawer.getByText("Active").first()).toBeVisible();
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
await page.getByRole("tab", { name: "Active (2)" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");

View file

@ -0,0 +1,157 @@
import {
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
import type { VendorPosFilters } from "../_hooks/use-vendor-pos-filters";
const STATUS_OPTIONS = [
"",
"Sent",
"Acknowledged",
"In Progress",
"Completed",
"Verified",
"Cancelled",
];
interface VendorPosListControlsProps {
filters: VendorPosFilters;
vendors: VendorListItem[];
totalCount: number;
isFetching: boolean;
isLoading: boolean;
onSearchChange: (value: string) => void;
onFilterChange: (name: Exclude<keyof VendorPosFilters, "search">, value: string) => void;
onClearFilters: () => void;
}
export function VendorPosListControls({
filters,
vendors,
totalCount,
isFetching,
isLoading,
onSearchChange,
onFilterChange,
onClearFilters,
}: VendorPosListControlsProps) {
return (
<>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
flexShrink: 0,
flexWrap: "wrap",
minWidth: 0,
}}
>
<Typography variant="h5" component="h1" sx={{ flexShrink: 0, whiteSpace: "nowrap" }}>
Vendor POs
</Typography>
<Typography
variant="body2"
sx={{ color: "text.secondary", flexShrink: 0, whiteSpace: "nowrap" }}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<TextField
size="small"
value={filters.search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search PO #, dispatch, vendor, WO..."
sx={{ width: { xs: "100%", sm: 360 }, minWidth: 280, flexShrink: 0 }}
/>
</Stack>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{ flexWrap: "wrap", alignItems: "flex-start" }}
>
<FormControl
size="small"
sx={{ width: { xs: "100%", sm: 160 }, minWidth: 140, flexShrink: 0 }}
>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={filters.status}
onChange={(event) => onFilterChange("status", event.target.value)}
>
{STATUS_OPTIONS.map((status) => (
<MenuItem key={status || "all"} value={status}>
{status || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl
size="small"
sx={{ width: { xs: "100%", sm: 240 }, minWidth: 180, flexShrink: 0 }}
>
<InputLabel>Vendor</InputLabel>
<Select
label="Vendor"
value={filters.vendorId}
onChange={(event) => onFilterChange("vendorId", event.target.value)}
>
<MenuItem value="">All</MenuItem>
{vendors.map((vendor) => (
<MenuItem key={String(vendor.id)} value={String(vendor.id)}>
{vendor.companyName}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="date"
label="From"
value={filters.dateFrom}
onChange={(event) => onFilterChange("dateFrom", event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
/>
<TextField
size="small"
type="date"
label="To"
value={filters.dateTo}
onChange={(event) => onFilterChange("dateTo", event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
/>
<Button
variant="text"
onClick={onClearFilters}
sx={{ minWidth: "max-content", flexShrink: 0 }}
>
Clear filters
</Button>
</Stack>
</>
);
}

View file

@ -0,0 +1,111 @@
import {
Button,
Chip,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import type { VendorPoListItem } from "@/domain/vendor-pos/types/vendor-po";
import { toDateInputValue } from "@/lib/time-utils";
interface VendorPosListTableProps {
rows: VendorPoListItem[];
isLoading: boolean;
onNavigate: (id: string | number) => void;
}
function formatMoney(value: number | null): string {
if (value == null) return "—";
return `$${value.toFixed(2)}`;
}
function formatDate(value: string): string {
if (!value) return "—";
return toDateInputValue(value) || "—";
}
export function VendorPosListTable({ rows, isLoading, onNavigate }: VendorPosListTableProps) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">PO #</TableCell>
<TableCell className="font-semibold">Dispatch #</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">WO #</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">NTE</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Uplifts</TableCell>
<TableCell className="font-semibold">Dispatched</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No vendor POs found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={String(row.id)}
hover
className="cursor-pointer"
onClick={() => onNavigate(row.id)}
>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={(event) => {
event.stopPropagation();
onNavigate(row.id);
}}
>
{row.poNumber || "—"}
</Button>
</TableCell>
<TableCell>{row.dispatchNumber || "—"}</TableCell>
<TableCell>{row.vendorName || "—"}</TableCell>
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
<TableCell>{row.locationName || "—"}</TableCell>
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
<TableCell>
<Chip size="small" label={row.status || "—"} />
</TableCell>
<TableCell>
{row.pendingUpliftCount > 0 ? (
<Chip
size="small"
color="warning"
label={`${row.pendingUpliftCount} pending`}
/>
) : (
"—"
)}
</TableCell>
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View file

@ -0,0 +1,87 @@
import { useMemo, useState } from "react";
import type { VendorPoListParams } from "@/domain/vendor-pos/types/vendor-po";
import { useDebounce } from "@/hooks/use-debounce";
export interface VendorPosFilters {
search: string;
status: string;
vendorId: string;
dateFrom: string;
dateTo: string;
}
type VendorPosFilterName = Exclude<keyof VendorPosFilters, "search">;
const INITIAL_FILTERS: VendorPosFilters = {
search: "",
status: "",
vendorId: "",
dateFrom: "",
dateTo: "",
};
export function useVendorPosFilters() {
const [filters, setFilters] = useState<VendorPosFilters>(INITIAL_FILTERS);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const debouncedSearch = useDebounce(filters.search, 300);
const listParams = useMemo<VendorPoListParams>(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
status: filters.status,
vendorId: filters.vendorId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
}),
[
debouncedSearch,
filters.dateFrom,
filters.dateTo,
filters.status,
filters.vendorId,
page,
pageSize,
],
);
const setSearch = (search: string) => {
setFilters((current) => ({ ...current, search }));
setPage(0);
};
const setFilter = (name: VendorPosFilterName, value: string) => {
setFilters((current) => ({ ...current, [name]: value }));
setPage(0);
};
const clearFilters = () => {
setFilters((current) => ({
...current,
status: "",
vendorId: "",
dateFrom: "",
dateTo: "",
}));
setPage(0);
};
const changePageSize = (nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(0);
};
return {
filters,
page,
pageSize,
listParams,
setSearch,
setFilter,
clearFilters,
setPage,
changePageSize,
};
}

View file

@ -1,300 +1,59 @@
import { useMemo, useState } from "react";
import { Alert, Box, TablePagination } from "@mui/material";
import { useNavigate } from "react-router";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { VendorPosListControls } from "./_components/vendor-pos-list-controls";
import { VendorPosListTable } from "./_components/vendor-pos-list-table";
import { useVendorPosFilters } from "./_hooks/use-vendor-pos-filters";
import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list";
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
import { useDebounce } from "@/hooks/use-debounce";
import { toDateInputValue } from "@/lib/time-utils";
const STATUS_OPTIONS = [
"",
"Sent",
"Acknowledged",
"In Progress",
"Completed",
"Verified",
"Cancelled",
];
function formatMoney(value: number | null): string {
if (value == null) return "—";
return `$${value.toFixed(2)}`;
}
function formatDate(value: string): string {
if (!value) return "—";
return toDateInputValue(value) || "—";
}
export default function VendorPosListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [status, setStatus] = useState("");
const [vendorId, setVendorId] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const debouncedSearch = useDebounce(search, 300);
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
status,
vendorId,
dateFrom,
dateTo,
}),
[debouncedSearch, page, pageSize, status, vendorId, dateFrom, dateTo],
);
const {
filters,
page,
pageSize,
listParams,
setSearch,
setFilter,
clearFilters,
setPage,
changePageSize,
} = useVendorPosFilters();
const { data, isLoading, isFetching, error } = useVendorPosList(listParams);
const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 });
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const vendors = vendorsData?.items ?? [];
const clearFilters = () => {
setStatus("");
setVendorId("");
setDateFrom("");
setDateTo("");
setPage(0);
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Vendor POs
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<TextField
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search PO #, dispatch, vendor, WO..."
className="min-w-[280px]"
/>
</Stack>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
flexWrap: "wrap",
}}
>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[180px]">
<InputLabel>Vendor</InputLabel>
<Select
label="Vendor"
value={vendorId}
onChange={(e) => {
setVendorId(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
{vendors.map((v) => (
<MenuItem key={String(v.id)} value={String(v.id)}>
{v.companyName}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="date"
label="From"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setPage(0);
}}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
size="small"
type="date"
label="To"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setPage(0);
}}
slotProps={{ inputLabel: { shrink: true } }}
/>
<Button variant="text" onClick={clearFilters}>
Clear filters
</Button>
</Stack>
<VendorPosListControls
filters={filters}
vendors={vendors}
totalCount={totalCount}
isFetching={isFetching}
isLoading={isLoading}
onSearchChange={setSearch}
onFilterChange={setFilter}
onClearFilters={clearFilters}
/>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor POs"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">PO #</TableCell>
<TableCell className="font-semibold">Dispatch #</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">WO #</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">NTE</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Uplifts</TableCell>
<TableCell className="font-semibold">Dispatched</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No vendor POs found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={String(row.id)}
hover
className="cursor-pointer"
onClick={() => navigate(`/vendor-pos/${row.id}`)}
>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={(e) => {
e.stopPropagation();
navigate(`/vendor-pos/${row.id}`);
}}
>
{row.poNumber || "—"}
</Button>
</TableCell>
<TableCell>{row.dispatchNumber || "—"}</TableCell>
<TableCell>{row.vendorName || "—"}</TableCell>
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
<TableCell>{row.locationName || "—"}</TableCell>
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
<TableCell>
<Chip size="small" label={row.status || "—"} />
</TableCell>
<TableCell>
{row.pendingUpliftCount > 0 ? (
<Chip
size="small"
color="warning"
label={`${row.pendingUpliftCount} pending`}
/>
) : (
"—"
)}
</TableCell>
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<VendorPosListTable
rows={rows}
isLoading={isLoading}
onNavigate={(id) => navigate(`/vendor-pos/${id}`)}
/>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
}}
onRowsPerPageChange={(event) => changePageSize(Number(event.target.value))}
rowsPerPageOptions={[12, 24, 48]}
/>
</Box>

View file

@ -0,0 +1,166 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm, useWatch, type FieldErrors, type Resolver } from "react-hook-form";
import type { ZodError } from "zod";
import {
getChangedVendorValues,
vendorUpdateSchema,
type VendorFormValues,
} from "@/domain/vendors/schemas/vendor-schema";
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { Vendor, VendorFacetCompany } from "@/domain/vendors/types/vendor";
const DEFAULT_VALUES: VendorFormValues = {
companyName: "",
contactName: "",
email: "",
phone: "",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
companyId: null,
preferredContact: "Phone",
};
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",
};
}
function buildFieldErrors(error: ZodError): FieldErrors<VendorFormValues> {
return error.issues.reduce<FieldErrors<VendorFormValues>>((fieldErrors, issue) => {
const field = issue.path[0];
if (typeof field === "string") {
fieldErrors[field as keyof VendorFormValues] = {
type: issue.code,
message: issue.message,
};
}
return fieldErrors;
}, {});
}
interface UseVendorDrawerFormProps {
detail: Vendor | undefined;
open: boolean;
vendorId: string | number | undefined;
onModeChange: (mode: "view" | "edit") => void;
}
export function useVendorDetailDrawerForm({
detail,
open,
vendorId,
onModeChange,
}: UseVendorDrawerFormProps) {
const { data: facets } = useVendorFacets();
const updateVendor = useUpdateVendor();
const [submitError, setSubmitError] = useState<string | null>(null);
const [original, setOriginal] = useState<VendorFormValues | null>(null);
const originalRef = useRef<VendorFormValues | null>(null);
const companies = useMemo(() => facets?.companies ?? [], [facets]);
const resolver = useCallback<Resolver<VendorFormValues>>(async (values) => {
const result = vendorUpdateSchema.safeParse(
getChangedVendorValues(values, originalRef.current),
);
return result.success
? { values, errors: {} }
: { values: {}, errors: buildFieldErrors(result.error) };
}, []);
const form = useForm<VendorFormValues>({
resolver,
mode: "onChange",
defaultValues: DEFAULT_VALUES,
});
const { control, reset, setValue, trigger } = form;
const currentValues = useWatch({ control });
const changedValues = useMemo(
() => getChangedVendorValues(currentValues, original),
[currentValues, original],
);
useEffect(() => {
if (open && detail) {
const values = toFormValues(detail);
reset(values);
setOriginal(values);
originalRef.current = values;
setSubmitError(null);
} else if (!open) {
setOriginal(null);
originalRef.current = null;
setSubmitError(null);
}
}, [open, detail, 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 cancel = () => {
if (original) reset(original);
setSubmitError(null);
onModeChange("view");
};
const submit = (values: VendorFormValues) => {
if (vendorId === undefined || vendorId === "") return;
const changed = getChangedVendorValues(values, original);
if (Object.keys(changed).length === 0) return;
setSubmitError(null);
updateVendor.mutate(
{ id: vendorId, values: changed },
{
onSuccess: () => onModeChange("view"),
onError: (error: Error) => setSubmitError(error.message || "Failed to save vendor"),
},
);
};
return {
...form,
cancel,
companies,
hasChanges: Object.keys(changedValues).length > 0,
isFormValid: vendorUpdateSchema.safeParse(changedValues).success,
onCompanySelected,
submit,
submitError,
updateVendor,
};
}

View file

@ -0,0 +1,338 @@
import type { FormEventHandler, ReactNode } from "react";
import { Controller, type Control, type FieldErrors } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
import {
Alert,
Box,
Button,
Chip,
FormControlLabel,
IconButton,
Link,
Stack,
Switch,
Tooltip,
Typography,
} from "@mui/material";
import { VendorFormFields } from "./vendor-form-fields";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type {
Vendor,
VendorFacetCompany,
VendorListItem,
VendorPreferredContact,
} from "@/domain/vendors/types/vendor";
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();
}
interface HeaderProps {
vendor: VendorListItem | null;
mode: "view" | "edit";
pending: boolean;
onClose: () => void;
}
export function VendorDrawerHeader({ vendor, mode, pending, onClose }: HeaderProps) {
return (
<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={pending}>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
);
}
interface ViewProps {
detail: Vendor;
mapsUrl: string | undefined;
submitError: string | null;
trades: string[];
}
export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewProps) {
return (
<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>
<TradesSection trades={trades} />
<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>
);
}
function TradesSection({ trades }: { trades: string[] }) {
return (
<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, index) => (
<Chip
key={`${trade}-${index}`}
size="small"
label={index === 0 ? `${trade} (primary)` : trade}
/>
))}
</Box>
)}
</DetailSection>
);
}
interface EditProps {
control: Control<VendorFormValues>;
errors: FieldErrors<VendorFormValues>;
companies: VendorFacetCompany[];
tradeOptions: string[];
submitError: string | null;
onCompanySelected: (company: VendorFacetCompany | null) => void;
onSubmit: FormEventHandler<HTMLFormElement>;
}
export function VendorDrawerEdit({
control,
errors,
companies,
tradeOptions,
submitError,
onCompanySelected,
onSubmit,
}: EditProps) {
return (
<Box
component="form"
id="vendor-edit-form"
onSubmit={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>
);
}
interface ActionsProps {
mode: "view" | "edit";
mapsUrl: string | undefined;
pending: boolean;
canSave: boolean;
onCancel: () => void;
onEdit: () => void;
}
export function VendorDrawerActions({
mode,
mapsUrl,
pending,
canSave,
onCancel,
onEdit,
}: ActionsProps) {
return (
<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={onEdit} startIcon={<EditOutlinedIcon />}>
Edit
</Button>
</>
) : (
<>
<Button variant="outlined" onClick={onCancel} disabled={pending} fullWidth>
Cancel
</Button>
<Button
variant="contained"
type="submit"
form="vendor-edit-form"
disabled={pending || !canSave}
fullWidth
>
{pending ? "Saving..." : "Save"}
</Button>
</>
)}
</Stack>
);
}
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

@ -1,47 +1,16 @@
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, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
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";
VendorDrawerActions,
VendorDrawerEdit,
VendorDrawerHeader,
VendorDrawerView,
} from "./vendor-detail-drawer-content";
import { useVendorDetailDrawerForm } from "./use-vendor-detail-drawer";
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";
import type { VendorListItem } 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(",")
@ -49,27 +18,6 @@ function splitTrades(value: string | undefined): string[] {
.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;
@ -88,109 +36,20 @@ export function VendorDetailDrawer({
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 drawerForm = useVendorDetailDrawerForm({ detail, open, vendorId, onModeChange });
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
const mapsUrl = detail?.googleMapsUrl;
const pending = drawerForm.updateVendor.isPending;
return (
<Drawer
open={open}
onClose={updateVendor.isPending ? undefined : onClose}
onClose={pending ? undefined : onClose}
anchor="right"
aria-labelledby="vendor-detail-drawer-title"
ModalProps={{ keepMounted: false }}
@ -202,56 +61,7 @@ export function VendorDetailDrawer({
}}
>
<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>
<VendorDrawerHeader vendor={vendor} mode={mode} pending={pending} onClose={onClose} />
{isLoading ? (
<Box className="flex flex-1 items-center justify-center">
<CircularProgress size={28} />
@ -263,188 +73,31 @@ export function VendorDetailDrawer({
) : 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>
<VendorDrawerView
detail={detail}
mapsUrl={mapsUrl}
submitError={drawerForm.submitError}
trades={trades}
/>
) : (
<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>
<VendorDrawerEdit
control={drawerForm.control}
errors={drawerForm.formState.errors}
companies={drawerForm.companies}
tradeOptions={tradeOptions}
submitError={drawerForm.submitError}
onCompanySelected={drawerForm.onCompanySelected}
onSubmit={drawerForm.handleSubmit(drawerForm.submit)}
/>
)}
<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>
<VendorDrawerActions
mode={mode}
mapsUrl={mapsUrl}
pending={pending}
canSave={drawerForm.hasChanges && drawerForm.isFormValid}
onCancel={drawerForm.cancel}
onEdit={() => onModeChange("edit")}
/>
</>
) : (
<Box className="flex flex-1 items-center justify-center">
@ -457,30 +110,3 @@ export function VendorDetailDrawer({
</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

@ -1,9 +1,9 @@
import { useState } from "react";
import { NavLink } from "react-router";
import { Link, useLocation } from "react-router";
import { Box, Drawer } from "@mui/material";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
if (isActive) {
@ -31,88 +31,88 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
type NavItemProps = NavItemDef & {
collapsed: boolean;
end?: boolean;
active: boolean;
};
function NavItem({ label, icon: Icon, path, collapsed, end, badge }: NavItemProps) {
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
const [hovered, setHovered] = useState(false);
const isHover = hovered && !active;
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
return (
<NavLink to={path} end={end} className="no-underline" title={collapsed ? label : undefined}>
{({ isActive }) => {
const isHover = hovered && !isActive;
const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover);
return (
<Link
to={path}
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
>
<Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
position: "relative",
display: "flex",
alignItems: "center",
width: "100%",
height: 40,
gap: "12px",
px: collapsed ? 0 : "12px",
justifyContent: collapsed ? "center" : "flex-start",
borderRadius: "6px",
cursor: "pointer",
fontFamily: "var(--font-sans)",
fontSize: 14,
fontWeight: active ? 600 : 500,
color: itemColor,
backgroundColor: itemBg,
boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none",
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
mb: "4px",
textAlign: "left",
}}
>
{active && (
<Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
component="span"
sx={{
position: "relative",
display: "flex",
alignItems: "center",
width: "100%",
height: 40,
gap: "12px",
px: collapsed ? 0 : "12px",
justifyContent: collapsed ? "center" : "flex-start",
borderRadius: "6px",
cursor: "pointer",
fontFamily: "var(--font-sans)",
fontSize: 14,
fontWeight: isActive ? 600 : 500,
color: itemColor,
backgroundColor: itemBg,
boxShadow: isActive ? "var(--color-sidebar-item-active-shadow)" : "none",
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
mb: "4px",
textAlign: "left",
position: "absolute",
left: 0,
top: 8,
bottom: 8,
width: 3,
borderRadius: "0 3px 3px 0",
backgroundColor: "var(--primary)",
}}
>
{isActive && (
/>
)}
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
{!collapsed && (
<>
<Box className="truncate flex-1">{label}</Box>
{badge != null && (
<Box
component="span"
className="inline-flex shrink-0 items-center justify-center"
sx={{
position: "absolute",
left: 0,
top: 8,
bottom: 8,
width: 3,
borderRadius: "0 3px 3px 0",
backgroundColor: "var(--primary)",
height: 18,
minWidth: 18,
px: "6px",
borderRadius: "9999px",
backgroundColor: "var(--accent)",
color: "#ffffff",
fontFamily: "var(--font-sans)",
fontSize: 10,
fontWeight: 700,
lineHeight: 1,
}}
/>
>
{badge}
</Box>
)}
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
{!collapsed && (
<>
<Box className="truncate flex-1">{label}</Box>
{badge != null && (
<Box
component="span"
className="inline-flex shrink-0 items-center justify-center"
sx={{
height: 18,
minWidth: 18,
px: "6px",
borderRadius: "9999px",
backgroundColor: "var(--accent)",
color: "#ffffff",
fontFamily: "var(--font-sans)",
fontSize: 10,
fontWeight: 700,
lineHeight: 1,
}}
>
{badge}
</Box>
)}
</>
)}
</Box>
);
}}
</NavLink>
</>
)}
</Box>
</Link>
);
}
@ -122,6 +122,8 @@ type AppSidebarProps = {
export function AppSidebar({ collapsed }: AppSidebarProps) {
const width = getSidebarWidth(collapsed);
const { pathname } = useLocation();
const activePath = getActiveNavPath(pathname);
return (
<Drawer
@ -195,7 +197,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
path={path}
badge={badge}
collapsed={collapsed}
end={path === "/"}
active={path === activePath}
/>
</Box>
))}

View file

@ -59,3 +59,13 @@ export const navGroups: NavGroup[] = [
// items: [{ label: "Documents", icon: FileText, path: "/documents" }],
// },
];
export function getActiveNavPath(pathname: string): string | undefined {
return navGroups
.flatMap((group) => group.items)
.map((item) => item.path)
.filter((path) =>
path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`),
)
.sort((firstPath, secondPath) => secondPath.length - firstPath.length)[0];
}

View file

@ -8,6 +8,7 @@ import {
mapVendorListItems,
mapVendorPortalToken,
mapVendorToBackend,
mapVendorUpdateToBackend,
} from "@/domain/vendors/mappers/vendor-mapper";
import type {
Vendor,
@ -95,7 +96,7 @@ export const vendorsApi = {
update: async (id: string | number, payload: unknown): Promise<Vendor> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.vendors}/${id}`,
mapVendorToBackend(payload),
mapVendorUpdateToBackend(payload),
);
return mapVendor(handleApiResponse(data));
},

View file

@ -172,6 +172,49 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
return payload;
}
export function mapVendorUpdateToBackend(vendor: unknown): Record<string, unknown> {
const item = asRecord(vendor);
const payload: Record<string, unknown> = {};
const fieldMappings = [
["companyName", "CompanyName"],
["contactName", "ContactName"],
["email", "Email"],
["googleMapsUrl", "GoogleMapsUrl"],
["notes", "Notes"],
["address", "Address"],
["city", "City"],
["state", "State"],
["zip", "Zip"],
["tradeSpecialties", "TradeSpecialties"],
["isActive", "IsActive"],
["preferredContact", "PreferredContact"],
] as const;
fieldMappings.forEach(([target, source]) => {
if (Object.prototype.hasOwnProperty.call(item, target)) payload[target] = item[target];
if (Object.prototype.hasOwnProperty.call(item, source)) payload[target] = item[source];
});
if (
Object.prototype.hasOwnProperty.call(item, "companyId") ||
Object.prototype.hasOwnProperty.call(item, "CompanyId")
) {
payload.companyId = readNumericCompanyId(item);
}
if (
Object.prototype.hasOwnProperty.call(item, "phone") ||
Object.prototype.hasOwnProperty.call(item, "Phone")
) {
payload.phone = toCanonicalPhone(item.phone ?? item.Phone);
}
if (
Object.prototype.hasOwnProperty.call(item, "companyPhone") ||
Object.prototype.hasOwnProperty.call(item, "CompanyPhone")
) {
payload.companyPhone = toCanonicalPhone(item.companyPhone ?? item.CompanyPhone);
}
return payload;
}
function mapFacetCompany(raw: unknown): VendorFacetCompany {
const item = asRecord(raw);
return {

View file

@ -40,3 +40,42 @@ export const vendorSchema = z.object({
});
export type VendorFormValues = z.infer<typeof vendorSchema>;
export const vendorUpdateSchema = vendorSchema.partial();
export type VendorUpdateValues = z.infer<typeof vendorUpdateSchema>;
const VENDOR_UPDATE_FIELDS = [
"companyName",
"contactName",
"email",
"phone",
"companyPhone",
"googleMapsUrl",
"notes",
"address",
"city",
"state",
"zip",
"tradeSpecialties",
"isActive",
"companyId",
"preferredContact",
] as const satisfies readonly (keyof VendorFormValues)[];
export function getChangedVendorValues(
values: Partial<VendorFormValues>,
original: VendorFormValues | null,
): VendorUpdateValues {
if (!original) return {};
return VENDOR_UPDATE_FIELDS.reduce<VendorUpdateValues>((changed, field) => {
if (
Object.prototype.hasOwnProperty.call(values, field) &&
!Object.is(values[field], original[field])
) {
Object.assign(changed, { [field]: values[field] });
}
return changed;
}, {});
}

View file

@ -1,13 +1,13 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorUpdateValues } from "@/domain/vendors/schemas/vendor-schema";
import type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
interface UpdateVendorInput {
export interface UpdateVendorInput {
id: string | number;
values: VendorFormValues;
values: VendorUpdateValues;
}
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {

View file

@ -0,0 +1,20 @@
import { getActiveNavPath } from "@/config/menu";
import { describe, expect, it } from "vitest";
describe("getActiveNavPath", () => {
it.each([
["/", "/"],
["/vendors", "/vendors"],
["/vendors/42", "/vendors"],
["/vendors/42/edit", "/vendors"],
["/vendors/insights", "/vendors/insights"],
["/vendors/preferences", "/vendors/preferences"],
["/vendor-pos/42", "/vendor-pos"],
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
expect(getActiveNavPath(pathname)).toBe(expectedPath);
});
it("does not match partial route segments", () => {
expect(getActiveNavPath("/vendor-posts")).toBeUndefined();
});
});

View file

@ -197,7 +197,7 @@ describe("vendorsApi.getList", () => {
);
});
it("preserves a legacy phone on read and unrelated write", async () => {
it("preserves a legacy phone on read and omits it from an unrelated update", async () => {
apiGet.mockResolvedValueOnce({
data: {
id: 7,
@ -211,11 +211,26 @@ describe("vendorsApi.getList", () => {
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" }),
);
await vendorsApi.update(7, { notes: "Updated only the notes" });
expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`, {
notes: "Updated only the notes",
});
});
it("sends only changed fields and canonicalizes a supplied phone", async () => {
apiPut.mockResolvedValueOnce({ data: {} });
await vendorsApi.update(7, {
isActive: false,
companyId: null,
phone: "314-555-0198",
});
expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`, {
isActive: false,
companyId: null,
phone: "(314) 555-0198",
});
});
it("maps the deactivation-impact payload including linked work orders", async () => {

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema";
import {
getChangedVendorValues,
vendorSchema,
vendorUpdateSchema,
} from "@/domain/vendors/schemas/vendor-schema";
const validVendor = {
companyName: "Gateway Plumbing",
@ -59,4 +63,26 @@ describe("vendorSchema", () => {
it("rejects an unknown preferred contact value", () => {
expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false);
});
it("validates only edited fields while retaining required and format checks", () => {
expect(vendorUpdateSchema.safeParse({ isActive: true }).success).toBe(true);
expect(vendorUpdateSchema.safeParse({ companyName: "" }).success).toBe(false);
expect(vendorUpdateSchema.safeParse({ phone: "555-1234 ext 9" }).success).toBe(false);
});
it("returns a sparse update that preserves false and null and omits totalJobs", () => {
const original = {
...validVendor,
totalJobs: 12,
isActive: false,
companyId: "co-1",
preferredContact: "Phone" as const,
};
const changed = getChangedVendorValues(
{ ...original, isActive: true, companyId: null, totalJobs: 13 },
original,
);
expect(changed).toEqual({ isActive: true, companyId: null });
});
});