diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 97d23975..13c7b1eb 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -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, }) => { diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 29df8921..4dae7254 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -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"); diff --git a/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx new file mode 100644 index 00000000..df8ab433 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx @@ -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, value: string) => void; + onClearFilters: () => void; +} + +export function VendorPosListControls({ + filters, + vendors, + totalCount, + isFetching, + isLoading, + onSearchChange, + onFilterChange, + onClearFilters, +}: VendorPosListControlsProps) { + return ( + <> + + + + Vendor POs + + + {totalCount} total + + {isFetching && !isLoading && } + + + onSearchChange(event.target.value)} + placeholder="Search PO #, dispatch, vendor, WO..." + sx={{ width: { xs: "100%", sm: 360 }, minWidth: 280, flexShrink: 0 }} + /> + + + + + Status + + + + + Vendor + + + + onFilterChange("dateFrom", event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} + /> + onFilterChange("dateTo", event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} + /> + + + + ); +} diff --git a/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx new file mode 100644 index 00000000..28d8f7c6 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx @@ -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 ( + + + + + PO # + Dispatch # + Vendor + WO # + Location + NTE + Status + Uplifts + Dispatched + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No vendor POs found + + + ) : ( + rows.map((row) => ( + onNavigate(row.id)} + > + + + + {row.dispatchNumber || "—"} + {row.vendorName || "—"} + {row.primaryWOInternalNumber || "—"} + {row.locationName || "—"} + {formatMoney(row.nteAmount)} + + + + + {row.pendingUpliftCount > 0 ? ( + + ) : ( + "—" + )} + + {formatDate(row.dispatchedAt)} + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts b/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts new file mode 100644 index 00000000..d88fa2fa --- /dev/null +++ b/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts @@ -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; + +const INITIAL_FILTERS: VendorPosFilters = { + search: "", + status: "", + vendorId: "", + dateFrom: "", + dateTo: "", +}; + +export function useVendorPosFilters() { + const [filters, setFilters] = useState(INITIAL_FILTERS); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const debouncedSearch = useDebounce(filters.search, 300); + + const listParams = useMemo( + () => ({ + 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, + }; +} diff --git a/src/app/(protected)/vendor-pos/index.tsx b/src/app/(protected)/vendor-pos/index.tsx index 81d07243..17c65bce 100644 --- a/src/app/(protected)/vendor-pos/index.tsx +++ b/src/app/(protected)/vendor-pos/index.tsx @@ -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 ( - - - - Vendor POs - - - {totalCount} total - - {isFetching && !isLoading && } - - - { - setSearch(e.target.value); - setPage(0); - }} - placeholder="Search PO #, dispatch, vendor, WO..." - className="min-w-[280px]" - /> - - - - Status - - - - - Vendor - - - - { - setDateFrom(e.target.value); - setPage(0); - }} - slotProps={{ inputLabel: { shrink: true } }} - /> - { - setDateTo(e.target.value); - setPage(0); - }} - slotProps={{ inputLabel: { shrink: true } }} - /> - - + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load vendor POs"} )} - - - - - PO # - Dispatch # - Vendor - WO # - Location - NTE - Status - Uplifts - Dispatched - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No vendor POs found - - - - ) : ( - rows.map((row) => ( - navigate(`/vendor-pos/${row.id}`)} - > - - - - {row.dispatchNumber || "—"} - {row.vendorName || "—"} - {row.primaryWOInternalNumber || "—"} - {row.locationName || "—"} - {formatMoney(row.nteAmount)} - - - - - {row.pendingUpliftCount > 0 ? ( - - ) : ( - "—" - )} - - {formatDate(row.dispatchedAt)} - - )) - )} - -
-
+ navigate(`/vendor-pos/${id}`)} + /> 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]} />
diff --git a/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts b/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts new file mode 100644 index 00000000..56a8fe74 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts @@ -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 { + return error.issues.reduce>((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(null); + const [original, setOriginal] = useState(null); + const originalRef = useRef(null); + const companies = useMemo(() => facets?.companies ?? [], [facets]); + + const resolver = useCallback>(async (values) => { + const result = vendorUpdateSchema.safeParse( + getChangedVendorValues(values, originalRef.current), + ); + return result.success + ? { values, errors: {} } + : { values: {}, errors: buildFieldErrors(result.error) }; + }, []); + + const form = useForm({ + 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, + }; +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx new file mode 100644 index 00000000..e121c63d --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx @@ -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 = { + 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 ( + `1px solid ${theme.palette.divider}`, + }} + > + + + {getInitials(vendor?.contactName ?? "")} + + + + {vendor?.companyName || "Vendor"} + + + {mode === "edit" ? "Editing vendor" : "Vendor details"} + + + + + + + + ); +} + +interface ViewProps { + detail: Vendor; + mapsUrl: string | undefined; + submitError: string | null; + trades: string[]; +} + +export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewProps) { + return ( + + {Boolean(submitError) && {submitError}} + + + {Boolean(trades[0]) && ( + 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]} + /> + )} + + + + + + + + + + + + + + {Boolean(mapsUrl) && ( + + + Open in Google Maps + + )} + + + + + + {Boolean(detail.notes) && ( + + + {detail.notes} + + + )} + + ); +} + +function TradesSection({ trades }: { trades: string[] }) { + return ( + + {trades.length === 0 ? ( + + No trades specified. + + ) : ( + + {trades.map((trade, index) => ( + + ))} + + )} + + ); +} + +interface EditProps { + control: Control; + errors: FieldErrors; + companies: VendorFacetCompany[]; + tradeOptions: string[]; + submitError: string | null; + onCompanySelected: (company: VendorFacetCompany | null) => void; + onSubmit: FormEventHandler; +} + +export function VendorDrawerEdit({ + control, + errors, + companies, + tradeOptions, + submitError, + onCompanySelected, + onSubmit, +}: EditProps) { + return ( + + + {Boolean(submitError) && {submitError}} + ( + + } + label={field.value ? "Active" : "Inactive"} + sx={{ alignSelf: "flex-start" }} + /> + )} + /> + + + + ); +} + +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 ( + `1px solid ${theme.palette.divider}`, + }} + > + {mode === "view" ? ( + <> + {Boolean(mapsUrl) && ( + + + + )} + + + + ) : ( + <> + + + + )} + + ); +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + const display = value && value.trim() !== "" ? value : "—"; + return ( + + + {label} + + {display} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 9dcd5eed..4ade2647 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -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 = { - 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(null); - const [original, setOriginal] = useState(null); - - const companies = useMemo(() => facets?.companies ?? [], [facets]); - - const { - control, - handleSubmit, - reset, - setValue, - trigger, - formState: { errors }, - } = useForm({ - 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 ( - `1px solid ${theme.palette.divider}`, - }} - > - - - {getInitials(vendor?.contactName ?? "")} - - - - {vendor?.companyName || "Vendor"} - - - {mode === "edit" ? "Editing vendor" : "Vendor details"} - - - - - - - - + {isLoading ? ( @@ -263,188 +73,31 @@ export function VendorDetailDrawer({ ) : detail ? ( <> {mode === "view" ? ( - - {Boolean(submitError) && {submitError}} - - - - {Boolean(trades[0]) && ( - 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]} - /> - )} - - - - - - - - - - - - - - - - - {Boolean(mapsUrl) && ( - - - Open in Google Maps - - )} - - - - {trades.length === 0 ? ( - - No trades specified. - - ) : ( - - {trades.map((trade, idx) => ( - - ))} - - )} - - - - - - - {Boolean(detail.notes) && ( - - - {detail.notes} - - - )} - + ) : ( - - - {Boolean(submitError) && {submitError}} - - ( - - } - label={field.value ? "Active" : "Inactive"} - sx={{ - alignSelf: "flex-start", - }} - /> - )} - /> - - - - + )} - - `1px solid ${theme.palette.divider}`, - }} - > - {mode === "view" ? ( - <> - {Boolean(mapsUrl) && ( - - - - )} - - - - ) : ( - <> - - - - )} - + onModeChange("edit")} + /> ) : ( @@ -457,30 +110,3 @@ export function VendorDetailDrawer({ ); } - -function DetailSection({ title, children }: { title: string; children: ReactNode }) { - return ( - - - {title} - - {children} - - ); -} - -function DetailField({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; - return ( - - - {label} - - {display} - - ); -} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 83702dc1..24278149 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -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 ( - - {({ isActive }) => { - const isHover = hovered && !isActive; - const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover); - - return ( + + 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 && ( 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 && ( + /> + )} + + {!collapsed && ( + <> + {label} + {badge != null && ( + > + {badge} + )} - - {!collapsed && ( - <> - {label} - {badge != null && ( - - {badge} - - )} - - )} - - ); - }} - + + )} + + ); } @@ -122,6 +122,8 @@ type AppSidebarProps = { export function AppSidebar({ collapsed }: AppSidebarProps) { const width = getSidebarWidth(collapsed); + const { pathname } = useLocation(); + const activePath = getActiveNavPath(pathname); return ( ))} diff --git a/src/config/menu.ts b/src/config/menu.ts index 03716882..8dc95e9e 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -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]; +} diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index 4f001f04..467a3a3b 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -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 => { const data = await apiPut( `${API_PATHS.rest.vendors}/${id}`, - mapVendorToBackend(payload), + mapVendorUpdateToBackend(payload), ); return mapVendor(handleApiResponse(data)); }, diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 9273ef8b..1b9d664e 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -172,6 +172,49 @@ export function mapVendorToBackend(vendor: unknown): Record { return payload; } +export function mapVendorUpdateToBackend(vendor: unknown): Record { + const item = asRecord(vendor); + const payload: Record = {}; + 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 { diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index 7ce3be5e..c9bb5180 100644 --- a/src/domain/vendors/schemas/vendor-schema.ts +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -40,3 +40,42 @@ export const vendorSchema = z.object({ }); export type VendorFormValues = z.infer; + +export const vendorUpdateSchema = vendorSchema.partial(); + +export type VendorUpdateValues = z.infer; + +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, + original: VendorFormValues | null, +): VendorUpdateValues { + if (!original) return {}; + + return VENDOR_UPDATE_FIELDS.reduce((changed, field) => { + if ( + Object.prototype.hasOwnProperty.call(values, field) && + !Object.is(values[field], original[field]) + ) { + Object.assign(changed, { [field]: values[field] }); + } + return changed; + }, {}); +} diff --git a/src/domain/vendors/use-cases/use-update-vendor.ts b/src/domain/vendors/use-cases/use-update-vendor.ts index cf4f4c84..9567c4b6 100644 --- a/src/domain/vendors/use-cases/use-update-vendor.ts +++ b/src/domain/vendors/use-cases/use-update-vendor.ts @@ -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 { diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts new file mode 100644 index 00000000..ac75b38c --- /dev/null +++ b/src/test/config/menu.test.ts @@ -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(); + }); +}); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index b66ce1bf..e6ff71bc 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -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 () => { diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts index 244c2b8e..726ae030 100644 --- a/src/test/domain/vendors/schemas/vendor-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -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 }); + }); });