From bcf63dffad98b2eaf3da2b1a1e8eef910522ced7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 28 Jul 2026 17:27:34 -0300 Subject: [PATCH] refactor(vendors): satisfy maintainability gates --- .../_components/vendor-pos-list-controls.tsx | 157 ++++++ .../_components/vendor-pos-list-table.tsx | 111 ++++ .../_hooks/use-vendor-pos-filters.ts | 87 ++++ src/app/(protected)/vendor-pos/index.tsx | 327 ++---------- .../_components/use-vendor-detail-drawer.ts | 166 ++++++ .../vendor-detail-drawer-content.tsx | 338 ++++++++++++ .../_components/vendor-detail-drawer.tsx | 479 ++---------------- .../vendors/use-cases/use-update-vendor.ts | 2 +- 8 files changed, 927 insertions(+), 740 deletions(-) create mode 100644 src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx create mode 100644 src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx create mode 100644 src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts create mode 100644 src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts create mode 100644 src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx 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 a597a866..17c65bce 100644 --- a/src/app/(protected)/vendor-pos/index.tsx +++ b/src/app/(protected)/vendor-pos/index.tsx @@ -1,322 +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..." - sx={{ - width: { xs: "100%", sm: 360 }, - minWidth: 280, - flexShrink: 0, - }} - /> - - - - Status - - - - - Vendor - - - - { - setDateFrom(e.target.value); - setPage(0); - }} - slotProps={{ inputLabel: { shrink: true } }} - sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} - /> - { - setDateTo(e.target.value); - setPage(0); - }} - slotProps={{ inputLabel: { shrink: true } }} - sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} - /> - - + {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 68f09a35..4ade2647 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,50 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { useForm, useWatch, Controller, type FieldErrors, type Resolver } 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, 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 { - getChangedVendorValues, - vendorUpdateSchema, - 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(",") @@ -52,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; @@ -91,141 +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 originalRef = useRef(null); - - const companies = useMemo(() => facets?.companies ?? [], [facets]); - - const resolver = useCallback>(async (values) => { - const result = vendorUpdateSchema.safeParse( - getChangedVendorValues(values, originalRef.current), - ); - if (result.success) return { values, errors: {} }; - - const errors = result.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; - }, - {}, - ); - return { values: {}, errors }; - }, []); - - const { - control, - handleSubmit, - reset, - setValue, - trigger, - formState: { errors }, - } = useForm({ - resolver, - 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 changedValues = useMemo( - () => getChangedVendorValues(currentValues, original), - [currentValues, original], - ); - const hasChanges = Object.keys(changedValues).length > 0; - const isFormValid = vendorUpdateSchema.safeParse(changedValues).success; - - useEffect(() => { - if (open && detail) { - const values = toFormValues(detail); - reset(values); - setOriginal(values); - originalRef.current = values; - setSubmitError(null); - } - if (!open) { - setOriginal(null); - originalRef.current = 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; - const changed = getChangedVendorValues(values, original); - if (Object.keys(changed).length === 0) return; - setSubmitError(null); - updateVendor.mutate( - { id: vendorId, values: changed }, - { - 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 ? ( @@ -298,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")} + /> ) : ( @@ -492,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/domain/vendors/use-cases/use-update-vendor.ts b/src/domain/vendors/use-cases/use-update-vendor.ts index 836b0e45..9567c4b6 100644 --- a/src/domain/vendors/use-cases/use-update-vendor.ts +++ b/src/domain/vendors/use-cases/use-update-vendor.ts @@ -5,7 +5,7 @@ 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: VendorUpdateValues; }