diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts new file mode 100644 index 00000000..9b6f3b0d --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor"; +import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact"; +import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; + +export interface VendorDeactivationState { + target: VendorListItem | null; + error: string | null; + impact: VendorDeactivationImpact | undefined; + isLoading: boolean; + impactError: Error | null; + isSaving: boolean; + open: (row: VendorListItem) => void; + close: () => void; + confirm: () => void; +} + +export function useVendorDeactivation(): VendorDeactivationState { + const [target, setTarget] = useState(null); + const [error, setError] = useState(null); + const deleteVendor = useDeleteVendor(); + const { + data: impact, + isLoading, + error: impactError, + } = useVendorDeactivationImpact(target?.id ?? undefined); + + const open = (row: VendorListItem) => { + setTarget(row); + setError(null); + }; + + const close = () => { + setTarget(null); + setError(null); + }; + + const confirm = () => { + if (!target || target.id == null) return; + setError(null); + deleteVendor.mutate(target.id, { + onSuccess: () => setTarget(null), + onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), + }); + }; + + return { + target, + error, + impact, + isLoading, + impactError, + isSaving: deleteVendor.isPending, + open, + close, + confirm, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts new file mode 100644 index 00000000..9385a834 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -0,0 +1,103 @@ +import { useMemo, useState } from "react"; +import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import { useDebounce } from "@/hooks/use-debounce"; + +export interface VendorListState { + search: string; + debouncedSearch: string; + page: number; + pageSize: number; + isActive: boolean; + appliedFilters: VendorFilters; + listParams: { + search: string; + page: number; + pageSize: number; + isActive: boolean; + companies: string[]; + trades: string[]; + locations: string[]; + jobBuckets: string[]; + }; + setSearchWithReset: (value: string) => void; + setPage: (page: number) => void; + setPageSizeWithReset: (size: number) => void; + setIsActiveWithReset: (isActive: boolean) => void; + setAppliedFiltersWithReset: (filters: VendorFilters) => void; + removeFilter: (category: keyof VendorFilters, value: string) => void; + clearFilters: () => void; +} + +export function useVendorListState(): VendorListState { + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [isActive, setIsActive] = useState(true); + const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); + + const debouncedSearch = useDebounce(search, 300); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + isActive, + companies: appliedFilters.companies, + trades: appliedFilters.trades, + locations: appliedFilters.locations, + jobBuckets: appliedFilters.jobBuckets, + }), + [debouncedSearch, page, pageSize, isActive, appliedFilters], + ); + + const setSearchWithReset = (value: string) => { + setSearch(value); + setPage(0); + }; + + const setPageSizeWithReset = (size: number) => { + setPageSize(size); + setPage(0); + }; + + const setIsActiveWithReset = (next: boolean) => { + setIsActive(next); + setPage(0); + }; + + const setAppliedFiltersWithReset = (filters: VendorFilters) => { + setAppliedFilters(filters); + setPage(0); + }; + + const removeFilter = (category: keyof VendorFilters, value: string) => { + setAppliedFilters((current) => ({ + ...current, + [category]: current[category].filter((item) => item !== value), + })); + setPage(0); + }; + + const clearFilters = () => { + setAppliedFilters(emptyVendorFilters); + setPage(0); + }; + + return { + search, + debouncedSearch, + page, + pageSize, + isActive, + appliedFilters, + listParams, + setSearchWithReset, + setPage, + setPageSizeWithReset, + setIsActiveWithReset, + setAppliedFiltersWithReset, + removeFilter, + clearFilters, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index fabfd008..f6ea637a 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -1,21 +1,19 @@ import { useCallback, useMemo, useState } from "react"; -import { useForm, useWatch, type FieldErrors, type Resolver } from "react-hook-form"; -import { useQueryClient } from "@tanstack/react-query"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; -import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; import { getSingleStatusOnlyChange, useSaveVendorCompanyRoster, } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import { useVendorRosterResolver } from "./use-vendor-roster-resolver"; +import { toFormValues, useVendorRosterSelection } from "./use-vendor-roster-selection"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; -import { queryKeys } from "@/infra/query-key/query-key"; import type { VendorCompanyRoster, VendorFacetCompany, @@ -29,29 +27,6 @@ export interface VendorRosterFormProps { onSuccess?: (roster: VendorCompanyRoster) => void; } -export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues { - return { - name: roster.name, - companyPhone: roster.companyPhone, - email: roster.email, - address: roster.address, - city: roster.city, - state: roster.state, - zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, - notes: roster.notes, - technicians: roster.technicians.map((technician) => ({ - id: technician.id, - contactName: technician.contactName, - phone: technician.phone, - email: technician.email, - preferredContact: technician.preferredContact, - tradeSpecialties: technician.tradeSpecialties, - isActive: technician.isActive, - })), - }; -} - export interface VendorRosterForm { control: ReturnType>["control"]; errors: FieldErrors; @@ -62,8 +37,10 @@ export interface VendorRosterForm { isSaving: boolean; isValid: boolean; conflict: VendorRosterConflict | null; + loadErrorMessage: string | null; submit: (values: VendorCompanyRosterFormValues) => void; reload: () => void; + retryLoad: () => void; roster: VendorCompanyRoster | undefined; companies: VendorFacetCompany[]; trades: string[]; @@ -84,42 +61,32 @@ export function useVendorRosterForm({ mode === "update" ? companyId : undefined, ); const { data: facets } = useVendorFacets(); - const queryClient = useQueryClient(); const save = useSaveVendorCompanyRoster(); const [conflict, setConflict] = useState(null); - const [selectedRoster, setSelectedRoster] = useState(null); const routeRoster = mode === "update" ? query.data : undefined; const companies = useMemo(() => facets?.companies ?? [], [facets]); const trades = useMemo(() => facets?.trades ?? [], [facets]); - - const updateValues = useMemo( - () => (mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined), - [mode, routeRoster], - ); - const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []); - const resolver = useMemo>( - () => async (values, context, options) => { - if ( - routeRoster && - getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues) - ) { - return { values, errors: {} }; - } - return strictResolver(values, context, options); - }, - [routeRoster, strictResolver], - ); + const resolver = useVendorRosterResolver(routeRoster); const form = useForm({ resolver, defaultValues: emptyVendorCompanyRosterForm, - values: updateValues, + values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); const { control, handleSubmit, reset, formState } = form; const watched = useWatch({ control }); + const clearConflict = useCallback(() => setConflict(null), []); + const selection = useVendorRosterSelection({ + mode, + reset, + query, + clearConflict, + }); + const { resetSelection } = selection; + const isValid = useMemo(() => { if (vendorCompanyRosterSchema.safeParse(watched).success) return true; return Boolean( @@ -128,48 +95,14 @@ export function useVendorRosterForm({ ); }, [routeRoster, watched]); - const fetchRosterByCompany = useCallback( - async (id: string | number): Promise => - queryClient.fetchQuery({ - queryKey: queryKeys.vendors.roster("companyId", id), - queryFn: () => vendorCompanyRosterApi.get({ companyId: id }), - }), - [queryClient], - ); - - const selectCompany = useCallback( - async (company: VendorFacetCompany | null) => { - if (!company || company.companyId == null) { - setSelectedRoster(null); - return; - } - const roster = await fetchRosterByCompany(company.companyId); - reset(toFormValues(roster)); - setSelectedRoster(roster); - setConflict(null); - }, - [fetchRosterByCompany, reset], - ); - - const clearSelectedCompany = useCallback( - (nextName = "") => { - if (selectedRoster) { - reset({ ...emptyVendorCompanyRosterForm, name: nextName }); - } - setSelectedRoster(null); - setConflict(null); - }, - [reset, selectedRoster], - ); - const resetForm = useCallback(() => { reset(emptyVendorCompanyRosterForm); - setSelectedRoster(null); - setConflict(null); - }, [reset]); + resetSelection(); + clearConflict(); + }, [clearConflict, reset, resetSelection]); - const committedRoster = mode === "update" ? routeRoster : selectedRoster; - const isUpdate = mode === "update" || selectedRoster != null; + const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; + const isUpdate = mode === "update" || selection.selectedRoster != null; const submit = (formValues: VendorCompanyRosterFormValues) => { setConflict(null); @@ -190,20 +123,6 @@ export function useVendorRosterForm({ ); }; - const reload = useCallback(() => { - setConflict(null); - if (mode === "update") { - void query.refetch(); - return; - } - if (selectedRoster?.companyId != null) { - void fetchRosterByCompany(selectedRoster.companyId).then((roster) => { - reset(toFormValues(roster)); - setSelectedRoster(roster); - }); - } - }, [fetchRosterByCompany, mode, query, reset, selectedRoster]); - return { control, errors: formState.errors, @@ -214,14 +133,16 @@ export function useVendorRosterForm({ isSaving: save.isPending, isValid, conflict, + loadErrorMessage: selection.loadErrorMessage, submit, - reload, + reload: selection.reload, + retryLoad: selection.retryLoad, roster: routeRoster, companies, trades, - selectedCompanyId: selectedRoster?.companyId ?? null, - selectCompany, - clearSelectedCompany, + selectedCompanyId: selection.selectedCompanyId, + selectCompany: selection.selectCompany, + clearSelectedCompany: selection.clearSelectedCompany, resetForm, }; } diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts new file mode 100644 index 00000000..12e1810a --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { type Resolver } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; + +export function useVendorRosterResolver( + routeRoster: VendorCompanyRoster | undefined, +): Resolver { + const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []); + return useMemo>( + () => async (values, context, options) => { + if ( + routeRoster && + getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues) + ) { + return { values, errors: {} }; + } + return strictResolver(values, context, options); + }, + [routeRoster, strictResolver], + ); +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts new file mode 100644 index 00000000..c1dfc39f --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -0,0 +1,159 @@ +import { useCallback, useRef, useState } from "react"; +import { useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { + emptyVendorCompanyRosterForm, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import type { UseFormReset } from "react-hook-form"; + +const SELECT_ERROR_MESSAGE = "Couldn't load that company's roster. Try again."; +const RELOAD_ERROR_MESSAGE = "Couldn't reload the latest company. Try again."; + +export interface VendorRosterSelection { + selectedRoster: VendorCompanyRoster | null; + loadErrorMessage: string | null; + selectedCompanyId: string | number | null; + selectCompany: (company: VendorFacetCompany | null) => Promise; + clearSelectedCompany: (nextName?: string) => void; + reload: () => void; + retryLoad: () => void; + resetSelection: () => void; +} + +interface UseVendorRosterSelectionOptions { + mode: "create" | "update"; + reset: UseFormReset; + query: UseQueryResult; + clearConflict: () => void; +} + +export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues { + return { + name: roster.name, + companyPhone: roster.companyPhone, + email: roster.email, + address: roster.address, + city: roster.city, + state: roster.state, + zip: roster.zip, + googleMapsUrl: roster.googleMapsUrl, + notes: roster.notes, + technicians: roster.technicians.map((technician) => ({ + id: technician.id, + contactName: technician.contactName, + phone: technician.phone, + email: technician.email, + preferredContact: technician.preferredContact, + tradeSpecialties: technician.tradeSpecialties, + isActive: technician.isActive, + })), + }; +} + +export function useVendorRosterSelection({ + mode, + reset, + query, + clearConflict, +}: UseVendorRosterSelectionOptions): VendorRosterSelection { + const queryClient = useQueryClient(); + const [selectedRoster, setSelectedRoster] = useState(null); + const [loadErrorMessage, setLoadErrorMessage] = useState(null); + const retryTargetRef = useRef< + { kind: "select"; company: VendorFacetCompany } | { kind: "reload" } | null + >(null); + + const fetchRosterByCompany = useCallback( + async (id: string | number): Promise => + queryClient.fetchQuery({ + queryKey: queryKeys.vendors.roster("companyId", id), + queryFn: () => vendorCompanyRosterApi.get({ companyId: id }), + }), + [queryClient], + ); + + const selectCompany = useCallback( + async (company: VendorFacetCompany | null) => { + if (!company || company.companyId == null) { + setSelectedRoster(null); + setLoadErrorMessage(null); + retryTargetRef.current = null; + return; + } + setLoadErrorMessage(null); + retryTargetRef.current = { kind: "select", company }; + try { + const roster = await fetchRosterByCompany(company.companyId); + reset(toFormValues(roster)); + setSelectedRoster(roster); + clearConflict(); + retryTargetRef.current = null; + } catch { + setLoadErrorMessage(SELECT_ERROR_MESSAGE); + } + }, + [clearConflict, fetchRosterByCompany, reset], + ); + + const clearSelectedCompany = useCallback( + (nextName = "") => { + if (selectedRoster) { + reset({ ...emptyVendorCompanyRosterForm, name: nextName }); + } + setSelectedRoster(null); + clearConflict(); + setLoadErrorMessage(null); + retryTargetRef.current = null; + }, + [clearConflict, reset, selectedRoster], + ); + + const reload = useCallback(() => { + clearConflict(); + setLoadErrorMessage(null); + retryTargetRef.current = { kind: "reload" }; + if (mode === "update") { + void query.refetch().then((result) => { + if (result.isError) setLoadErrorMessage(RELOAD_ERROR_MESSAGE); + else retryTargetRef.current = null; + }); + return; + } + if (selectedRoster?.companyId != null) { + void fetchRosterByCompany(selectedRoster.companyId) + .then((roster) => { + reset(toFormValues(roster)); + setSelectedRoster(roster); + retryTargetRef.current = null; + }) + .catch(() => setLoadErrorMessage(RELOAD_ERROR_MESSAGE)); + } + }, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]); + + const retryLoad = useCallback(() => { + const target = retryTargetRef.current; + if (!target) return; + if (target.kind === "select") void selectCompany(target.company); + else void reload(); + }, [reload, selectCompany]); + + const resetSelection = useCallback(() => { + setSelectedRoster(null); + setLoadErrorMessage(null); + retryTargetRef.current = null; + }, []); + + return { + selectedRoster, + loadErrorMessage, + selectedCompanyId: selectedRoster?.companyId ?? null, + selectCompany, + clearSelectedCompany, + reload, + retryLoad, + resetSelection, + }; +} diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index e4e97293..d0025662 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -12,6 +12,7 @@ import { } from "@mui/material"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; import { useVendorRosterForm } from "./use-vendor-roster-form"; interface VendorCreateModalProps { @@ -44,6 +45,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { + {form.loadErrorMessage !== null && ( + + )} {form.conflict !== null && ( )} diff --git a/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx new file mode 100644 index 00000000..cf673b6c --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx @@ -0,0 +1,126 @@ +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + Stack, + Typography, +} from "@mui/material"; +import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; + +interface VendorDeactivationDialogProps { + target: VendorListItem | null; + isLoading: boolean; + isSaving: boolean; + error: string | null; + impact: VendorDeactivationImpact | undefined; + impactError: Error | null; + onClose: () => void; + onConfirm: () => void; +} + +export function VendorDeactivationDialog({ + target, + isLoading, + isSaving, + error, + impact, + impactError, + onClose, + onConfirm, +}: VendorDeactivationDialogProps) { + const hasBlockingImpact = Boolean(impact && !impact.canDeactivate); + + return ( + + Deactivate Vendor + + + + Deactivate "{target?.companyName}"? Existing work-order and audit history will + be preserved. + + + {isLoading && ( + + + + Checking linked work orders... + + + )} + + {Boolean(impactError) && !isLoading && ( + + Could not verify linked work orders. Close and try again. + + )} + + {impact != null && !isLoading && !impact.canDeactivate && ( + + This vendor cannot be deactivated because it still has open work orders. + + )} + + {impact != null && impact.openWorkOrders.length > 0 && ( + + + Open work orders ({impact.openWorkOrders.length}) + + + {impact.openWorkOrders.map((wo) => ( + + {wo.scheduledDate} + + ) : undefined + } + > + + + {wo.workOrderNumber + ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` + : wo.workOrderTitle || `Work order ${wo.workOrderId}`} + + {Boolean(wo.status) && ( + + {wo.status} + + )} + + + ))} + + + )} + + {Boolean(error) && {error}} + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 2870664a..ad7ffa29 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -19,7 +19,7 @@ import { Typography, } from "@mui/material"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; const DRAWER_WIDTH = 440; @@ -72,7 +72,7 @@ function TechnicianEntry({ contactName: string; phone: string; email: string; - preferredContact: string; + preferredContact?: string; tradeSpecialties: string; isActive: boolean; totalJobs: number; @@ -126,6 +126,140 @@ function TechnicianEntry({ ); } +function DrawerHeader({ + roster, + vendor, + onClose, +}: { + roster?: VendorCompanyRoster; + vendor: VendorListItem | null; + onClose: () => void; +}) { + return ( + `1px solid ${theme.palette.divider}`, + }} + > + + + {roster?.name || vendor?.companyName || "Vendor company"} + + + {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} + + + + + + + ); +} + +function CompanySection({ roster }: { roster: VendorCompanyRoster }) { + const mapsUrl = roster.googleMapsUrl; + return ( + + + + + {Boolean(mapsUrl) && ( + + + Open in Google Maps + + )} + + ); +} + +function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) { + return ( + + Technicians + {roster.technicians.length === 0 ? ( + + No technicians assigned to this company. + + ) : ( + + {roster.technicians.map((technician) => ( + + ))} + + )} + + ); +} + +function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { + return ( + + Company + + + + {Boolean(roster.notes) && ( + <> + + + Notes + + {roster.notes} + + + + )} + + ); +} + +function DrawerActions({ onEdit }: { onEdit: () => void }) { + return ( + `1px solid ${theme.palette.divider}`, + alignItems: "center", + justifyContent: "flex-end", + }} + > + + + ); +} + export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { const navigate = useNavigate(); const vendorId = vendor?.id; @@ -140,7 +274,6 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer !hasVendorId ? (companyId ?? undefined) : undefined, ); - const mapsUrl = roster?.googleMapsUrl; const editHref = hasVendorId ? `/vendors/edit/${vendorId}` : `/vendors/company/${companyId ?? ""}`; @@ -171,33 +304,7 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer }} > - `1px solid ${theme.palette.divider}`, - }} - > - - - {roster?.name || vendor?.companyName || "Vendor company"} - - - {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} - - - - - - + {isLoading ? ( @@ -209,83 +316,8 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer ) : roster ? ( <> - - Company - - - - - {Boolean(mapsUrl) && ( - - - Open in Google Maps - - )} - - - - Technicians - {roster.technicians.length === 0 ? ( - - No technicians assigned to this company. - - ) : ( - - {roster.technicians.map((technician) => ( - - ))} - - )} - - {Boolean(roster.notes) && ( - <> - - - Notes - - {roster.notes} - - - - )} - - `1px solid ${theme.palette.divider}`, - alignItems: "center", - justifyContent: "flex-end", - }} - > - - + + navigate(editHref)} /> ) : ( diff --git a/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx b/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx new file mode 100644 index 00000000..0f9fb3d4 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx @@ -0,0 +1,21 @@ +import { Alert, Button } from "@mui/material"; + +interface VendorRosterLoadErrorAlertProps { + message: string; + onRetry: () => void; +} + +export function VendorRosterLoadErrorAlert({ message, onRetry }: VendorRosterLoadErrorAlertProps) { + return ( + + Try again + + } + > + {message} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx index dff0798e..15f8ff9a 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx @@ -4,6 +4,7 @@ import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from " import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; import { useVendorRosterForm } from "./use-vendor-roster-form"; interface VendorRosterPageProps { @@ -91,6 +92,10 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa + {form.loadErrorMessage !== null && ( + + )} + {form.conflict !== null && ( )} diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx new file mode 100644 index 00000000..e3e7cfa5 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -0,0 +1,168 @@ +import FilterListIcon from "@mui/icons-material/FilterList"; +import { + Button, + Chip, + CircularProgress, + Stack, + TablePagination, + TextField, + Typography, +} from "@mui/material"; + +interface VendorsListToolbarProps { + globalCount: number; + activeCount: number; + isFetching: boolean; + isLoading: boolean; + search: string; + onSearchChange: (value: string) => void; + appliedFilterCount: number; + onOpenFilters: () => void; + onAddVendor: () => void; +} + +export function VendorsListToolbar({ + globalCount, + activeCount, + isFetching, + isLoading, + search, + onSearchChange, + appliedFilterCount, + onOpenFilters, + onAddVendor, +}: VendorsListToolbarProps) { + return ( + + + + Vendors / Technicians + + + {globalCount} vendor entries · {activeCount} active + + {isFetching && !isLoading && } + + + + onSearchChange(e.target.value)} + placeholder="Search name, company, trade..." + className="min-w-[260px]" + /> + + + + + ); +} + +interface AppliedChipEntry { + key: string; + label: string; + onRemove: () => void; +} + +interface VendorAppliedFiltersBarProps { + chips: AppliedChipEntry[]; + onClearAll: () => void; +} + +export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { + if (chips.length === 0) return null; + return ( + + + Applied: + + {chips.map((chip) => ( + + ))} + + + ); +} + +interface VendorsListFooterProps { + totalCount: number; + isActive: boolean; + activeCount: number; + inactiveCount: number; + isFiltered: boolean; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +export function VendorsListFooter({ + totalCount, + isActive, + activeCount, + inactiveCount, + isFiltered, + page, + pageSize, + onPageChange, + onPageSizeChange, +}: VendorsListFooterProps) { + return ( + + + {totalCount} {isActive ? "active" : "inactive"}{" "} + {totalCount === 1 ? "vendor entry" : "vendor entries"} + {isFiltered && " · filtered"} + {` · ${activeCount} active · ${inactiveCount} inactive`} + + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[12, 24, 48]} + /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx new file mode 100644 index 00000000..802fff8a --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -0,0 +1,263 @@ +import { type KeyboardEvent, type MouseEvent } from "react"; +import { useNavigate } from "react-router"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { + Avatar, + Chip, + CircularProgress, + IconButton, + Link, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; + +const TABLE_COLUMNS = [ + "Company", + "Technician", + "Location", + "Trade", + "Phone", + "Email", + "Total Jobs", + "Status", + "View", + "Edit", +] as const; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); +} + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +function stopPropagation(event: MouseEvent): void { + event.stopPropagation(); +} + +interface VendorTableRowProps { + row: VendorListItem; + onOpenDetail: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; +} + +function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { + const navigate = useNavigate(); + const trades = splitTrades(row.tradeSpecialties); + const primaryTrade = trades[0] ?? "—"; + const extraTrades = trades.length > 1 ? trades.length - 1 : 0; + const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; + const hasVendorId = row.id != null; + const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + onOpenDetail(row); + }; + + return ( + onOpenDetail(row)} + onKeyDown={handleKeyDown} + > + + {row.companyName || "—"} + + + + + {getInitials(row.contactName)} + + {row.contactName || "—"} + + + {locationText} + + {primaryTrade === "—" ? ( + "—" + ) : ( + + {primaryTrade} + {extraTrades > 0 && } + + )} + + + {row.phone ? ( + + {row.phone} + + ) : ( + "—" + )} + + + {row.email ? ( + + {row.email} + + ) : ( + "—" + )} + + {row.totalJobs ?? 0} + + + + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + + + + + + { + event.stopPropagation(); + navigate( + row.id != null + ? `/vendors/edit/${row.id}` + : `/vendors/company/${row.companyId ?? ""}`, + ); + }} + > + + + + {hasVendorId && row.isActive && ( + + { + event.stopPropagation(); + onOpenDeactivation(row); + }} + > + + + + )} + + + + ); +} + +interface VendorsTableProps { + rows: VendorListItem[]; + isLoading: boolean; + showError: boolean; + onOpenDetail: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; +} + +export function VendorsTable({ + rows, + isLoading, + showError, + onOpenDetail, + onOpenDeactivation, +}: VendorsTableProps) { + const showEmptyRow = !isLoading && !showError && rows.length === 0; + + return ( + + + + + {TABLE_COLUMNS.map((column) => ( + + {column} + + ))} + + + + {isLoading ? ( + + + + + + ) : showEmptyRow ? ( + + + + No vendors match the current filters. + + + + ) : ( + rows.map((row) => ( + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 05ddcac8..a084b59b 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -1,77 +1,21 @@ -import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import FilterListIcon from "@mui/icons-material/FilterList"; -import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; -import { - Alert, - Avatar, - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - IconButton, - Link, - List, - ListItem, - Paper, - Stack, - Tab, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TablePagination, - TableRow, - Tabs, - TextField, - Tooltip, - Typography, -} from "@mui/material"; +import { useMemo, useState } from "react"; +import { Alert, Box, Tab, Tabs } from "@mui/material"; import { VendorCreateModal } from "./_components/vendor-create-modal"; import { VendorDetailDrawer } from "./_components/vendor-detail-drawer"; +import { VendorDeactivationDialog } from "./_components/vendor-deactivation-dialog"; import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer"; -import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters"; +import { + VendorAppliedFiltersBar, + VendorsListFooter, + VendorsListToolbar, +} from "./_components/vendors-list-toolbar"; +import { VendorsTable } from "./_components/vendors-table"; +import { useVendorDeactivation } from "./_components/use-vendor-deactivation"; +import { useVendorListState } from "./_components/use-vendor-list-state"; +import type { VendorFilters } from "./_components/vendor-filters"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; -import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor"; -import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; -import { useDebounce } from "@/hooks/use-debounce"; - -const TABLE_COLUMNS = [ - "Company", - "Technician", - "Location", - "Trade", - "Phone", - "Email", - "Total Jobs", - "Status", - "View", - "Edit", -] as const; - -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "?"; - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; - return (first + last).toUpperCase(); -} - -function splitTrades(value: string | undefined): string[] { - return (value ?? "") - .split(",") - .map((trade) => trade.trim()) - .filter(Boolean); -} interface AppliedChip { category: keyof VendorFilters; @@ -94,205 +38,61 @@ function describeChips( } export default function VendorsListPage() { - const [search, setSearch] = useState(""); - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); - const [isActive, setIsActive] = useState(true); - const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); + const filters = useVendorListState(); const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleteError, setDeleteError] = useState(null); const [detailTarget, setDetailTarget] = useState(null); + const deactivation = useVendorDeactivation(); - const debouncedSearch = useDebounce(search, 300); - const deleteVendor = useDeleteVendor(); - const { - data: deactivationImpact, - isLoading: isLoadingImpact, - error: impactError, - } = useVendorDeactivationImpact(deleteTarget?.id ?? undefined); - const { data: facets } = useVendorFacets(isActive); - - const listParams = useMemo( - () => ({ - search: debouncedSearch, - page: page + 1, - pageSize, - isActive, - companies: appliedFilters.companies, - trades: appliedFilters.trades, - locations: appliedFilters.locations, - jobBuckets: appliedFilters.jobBuckets, - }), - [debouncedSearch, page, pageSize, isActive, appliedFilters], - ); - - const { data, isLoading, isFetching, error } = useVendorsList(listParams); + const { data: facets } = useVendorFacets(filters.isActive); + const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 }); const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 }); + const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; const activeCount = activeCountData?.totalCount ?? 0; const inactiveCount = inactiveCountData?.totalCount ?? 0; - const globalCount = activeCount + inactiveCount; const jobBucketLabels = useMemo( () => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])), [facets], ); - const appliedChips = useMemo( - () => describeChips(appliedFilters, jobBucketLabels), - [appliedFilters, jobBucketLabels], - ); - - const handleApplyFilters = (next: VendorFilters) => { - setAppliedFilters(next); - setPage(0); - }; - - const handleClearFilters = () => { - setAppliedFilters(emptyVendorFilters); - setPage(0); - }; - - const handleRemoveChip = (chip: AppliedChip) => { - setAppliedFilters((current) => ({ - ...current, - [chip.category]: current[chip.category].filter((value) => value !== chip.value), - })); - setPage(0); - }; - - const openDetail = (row: VendorListItem) => { - setDetailTarget(row); - }; - - const closeDetail = () => { - setDetailTarget(null); - }; - - const openDeactivation = (row: VendorListItem) => { - setDeleteTarget(row); - setDeleteError(null); - }; - - const closeDeactivation = () => { - setDeleteTarget(null); - setDeleteError(null); - }; - - const handleDeleteConfirm = () => { - if (!deleteTarget || deleteTarget.id == null) return; - setDeleteError(null); - deleteVendor.mutate(deleteTarget.id, { - onSuccess: () => { - setDeleteTarget(null); - }, - onError: (error: Error) => { - setDeleteError(error.message || "Failed to deactivate vendor"); - }, - }); - }; - - const stopAnd = (handler: () => void) => (event: MouseEvent) => { - event.stopPropagation(); - handler(); - }; - - const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); - openDetail(row); - }; - - const showLoadingRow = isLoading; - const showEmptyRow = !isLoading && !error && rows.length === 0; + const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - - - - Vendors / Technicians - - - {globalCount} vendor entries · {activeCount} active - - {isFetching && !isLoading && } - - - - { - setSearch(e.target.value); - setPage(0); - }} - placeholder="Search name, company, trade..." - className="min-w-[260px]" - /> - - - - + setFiltersOpen(true)} + onAddVendor={() => setCreateOpen(true)} + /> { - setIsActive(value === "active"); - setPage(0); - }} + value={filters.isActive ? "active" : "inactive"} + onChange={(_event, value: "active" | "inactive") => + filters.setIsActiveWithReset(value === "active") + } aria-label="Vendor status" > - {appliedChips.length > 0 && ( - - - Applied: - - {appliedChips.map((chip) => ( - handleRemoveChip(chip)} - aria-label={`Remove filter ${chip.label}`} - /> - ))} - - - )} + ({ + key: `${chip.category}-${chip.value}`, + label: chip.label, + onRemove: () => filters.removeFilter(chip.category, chip.value), + }))} + onClearAll={filters.clearFilters} + /> {Boolean(error) && ( @@ -300,192 +100,25 @@ export default function VendorsListPage() { )} - - - - - {TABLE_COLUMNS.map((column) => ( - - {column} - - ))} - - - - {showLoadingRow ? ( - - - - - - ) : showEmptyRow ? ( - - - - No vendors match the current filters. - - - - ) : ( - rows.map((row) => { - const trades = splitTrades(row.tradeSpecialties); - const primaryTrade = trades[0] ?? "—"; - const extraTrades = trades.length > 1 ? trades.length - 1 : 0; - const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; - const hasVendorId = row.id != null; - const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); - return ( - openDetail(row)} - onKeyDown={handleRowKeyDown(row)} - > - - {row.companyName || "—"} - - - - - {getInitials(row.contactName)} - - {row.contactName || "—"} - - - {locationText} - - {primaryTrade === "—" ? ( - "—" - ) : ( - - {primaryTrade} - {extraTrades > 0 && } - - )} - - - {row.phone ? ( - event.stopPropagation()} - > - {row.phone} - - ) : ( - "—" - )} - - - {row.email ? ( - event.stopPropagation()} - > - {row.email} - - ) : ( - "—" - )} - - {row.totalJobs ?? 0} - - - - - - openDetail(row))} - > - - - - - - - - openDetail(row))} - > - - - - {hasVendorId && row.isActive && ( - - openDeactivation(row))} - > - - - - )} - - - - ); - }) - )} - -
-
+ - - - {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "vendor entry" : "vendor entries"} - {(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"} - {` · ${activeCount} active · ${inactiveCount} inactive`} - - setPage(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => { - setPageSize(Number(e.target.value)); - setPage(0); - }} - rowsPerPageOptions={[12, 24, 48]} - /> - + 0 || Boolean(filters.debouncedSearch)} + page={filters.page} + pageSize={filters.pageSize} + onPageChange={filters.setPage} + onPageSizeChange={filters.setPageSizeWithReset} + /> setCreateOpen(false)} /> @@ -493,109 +126,26 @@ export default function VendorsListPage() { open={filtersOpen} onClose={() => setFiltersOpen(false)} facets={facets} - appliedFilters={appliedFilters} - onApply={handleApplyFilters} + appliedFilters={filters.appliedFilters} + onApply={filters.setAppliedFiltersWithReset} /> setDetailTarget(null)} /> - - Deactivate Vendor - - - - Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit - history will be preserved. - - - {isLoadingImpact && ( - - - - Checking linked work orders... - - - )} - - {Boolean(impactError) && !isLoadingImpact && ( - - Could not verify linked work orders. Close and try again. - - )} - - {deactivationImpact != null && - !isLoadingImpact && - !deactivationImpact.canDeactivate && ( - - This vendor cannot be deactivated because it still has open work orders. - - )} - - {deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && ( - - - Open work orders ({deactivationImpact.openWorkOrders.length}) - - - {deactivationImpact.openWorkOrders.map((wo) => ( - - {wo.scheduledDate} - - ) : undefined - } - > - - - {wo.workOrderNumber - ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` - : wo.workOrderTitle || `Work order ${wo.workOrderId}`} - - {Boolean(wo.status) && ( - - {wo.status} - - )} - - - ))} - - - )} - - {Boolean(deleteError) && {deleteError}} - - - - - - - +
); } diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index 00ab6623..8dd450c4 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -31,7 +31,7 @@ function readBool(record: Record, ...keys: string[]): boolean { return true; } -function readPreferredContact(record: Record): VendorPreferredContact { +function readPreferredContact(record: Record): VendorPreferredContact | undefined { const raw = record.preferredContact ?? record.PreferredContact; if (typeof raw === "string") { const normalized = raw.trim(); @@ -39,10 +39,11 @@ function readPreferredContact(record: Record): VendorPreferredC return normalized as VendorPreferredContact; } const lowered = normalized.toLowerCase(); + if (lowered === "phone" || lowered === "call") return "Phone"; if (lowered === "email" || lowered === "e-mail") return "Email"; if (lowered === "text" || lowered === "sms") return "Text"; } - return "Phone"; + return undefined; } function readId(record: Record, ...keys: string[]): string | number | undefined { @@ -101,10 +102,11 @@ export function mapRosterTechnicianToBackend(technician: unknown): Record ({ + vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) }, +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ + useVendorCompanyRoster: () => ({ + data: undefined, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ + useVendorFacets: () => ({ data: { companies: [], trades: [] } }), +})); + +vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => { + const actual = await vi.importActual< + typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") + >("@/domain/vendors/use-cases/use-save-vendor-company-roster"); + return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) }; +}); + +import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; +import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor"; + +const company: VendorFacetCompany = { + companyId: 5, + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + address: "1 Main St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "", +}; + +const roster: VendorCompanyRoster = { + companyId: 5, + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + address: "1 Main St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "", + notes: "", + technicians: [], +}; + +function createClient(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useVendorRosterForm load-error handling", () => { + beforeEach(() => { + rosterGet.mockReset(); + }); + + it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => { + rosterGet.mockRejectedValueOnce(new Error("Internal server error")); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + expect(result.current.loadErrorMessage).toBeNull(); + + await act(async () => { + await result.current.selectCompany(company); + }); + + expect(result.current.loadErrorMessage).toBe("Couldn't load that company's roster. Try again."); + expect(result.current.selectedCompanyId).toBeNull(); + }); + + it("recovers and populates the form on retry after a rejected fetch", async () => { + rosterGet.mockRejectedValueOnce(new Error("boom")); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + await act(async () => { + await result.current.selectCompany(company); + }); + expect(result.current.loadErrorMessage).not.toBeNull(); + + rosterGet.mockResolvedValueOnce(roster); + await act(async () => { + result.current.retryLoad(); + }); + await waitFor(() => expect(result.current.loadErrorMessage).toBeNull()); + + expect(result.current.selectedCompanyId).toBe(5); + }); + + it("clears the selected roster when the form is reset", async () => { + rosterGet.mockResolvedValueOnce(roster); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + await act(async () => { + await result.current.selectCompany(company); + }); + expect(result.current.selectedCompanyId).toBe(5); + + act(() => result.current.resetForm()); + + expect(result.current.selectedCompanyId).toBeNull(); + }); + + it("does not expose raw server details in the error message", async () => { + rosterGet.mockRejectedValueOnce(new Error("Database connection failed at 10.0.0.1")); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + await act(async () => { + await result.current.selectCompany(company); + }); + + expect(result.current.loadErrorMessage).not.toContain("Database"); + expect(result.current.loadErrorMessage).not.toContain("10.0.0.1"); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx new file mode 100644 index 00000000..a55ddaf0 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -0,0 +1,90 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer"; +import { renderWithProviders } from "@/test/test-utils"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; + +const useVendorCompanyRoster = vi.fn(); + +vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ + useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args), +})); + +const vendor: VendorListItem = { + id: 1, + companyId: "co-1", + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + email: "", + phone: "", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, + city: "", + state: "", + tradeSpecialties: "", + isActive: true, + preferredContact: "Phone", +}; + +function rosterWith(technicians: Array>) { + return { + data: { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes: "", + technicians, + }, + isLoading: false, + error: null, + }; +} + +describe("VendorDetailDrawer preferred-contact display", () => { + it("renders no preference label when preferredContact is absent", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, + ]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByText("Adam")).toBeInTheDocument(); + expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); + }); + + it("renders the preference label when an explicit preferredContact is present", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Adam", + phone: "314-555-0198", + preferredContact: "Email", + isActive: true, + totalJobs: 5, + }, + ]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByText("Prefers Email")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 4468dea4..64c71c69 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -2,12 +2,19 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); +const navigate = vi.fn(); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { ...actual, useNavigate: () => navigate }; +}); vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), @@ -29,13 +36,17 @@ vi.mock("@/hooks/use-debounce", () => ({ useDebounce: (value: string) => value, })); -const activeVendor = { +const activeVendor: VendorListItem = { id: 1, companyId: "co-1", companyName: "Gateway Plumbing", contactName: "Adam Whyte", email: "dispatch@gatewayplumbing.com", phone: "(314) 555-0198", + companyPhone: "(314) 555-0100", + googleMapsUrl: "", + notes: "", + totalJobs: 0, city: "St. Louis", state: "MO", tradeSpecialties: "Backflow Preventers", @@ -50,7 +61,7 @@ const inactiveVendor = { isActive: false, }; -function result(items: (typeof activeVendor)[], totalCount: number) { +function result(items: VendorListItem[], totalCount: number) { return { data: { items, totalCount, page: 1, pageSize: 12 }, isLoading: false, @@ -187,4 +198,38 @@ describe("VendorsListPage", () => { expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); expect(mutate).not.toHaveBeenCalled(); }); + + it("navigates to the vendor edit route when the table Edit control is clicked", async () => { + setupDefaults(); + navigate.mockClear(); + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + + expect(navigate).toHaveBeenCalledWith("/vendors/edit/1"); + expect( + screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }), + ).not.toBeInTheDocument(); + }); + + it("navigates to the company route when editing a vendor without a vendor id", async () => { + setupDefaults(); + navigate.mockClear(); + const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" }; + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + + expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); + }); }); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index d57f2678..66e8d28b 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -113,8 +113,45 @@ describe("vendor roster mapper", () => { expect(conflict.message).toBe("stale rowversion"); }); - it("coerces an unknown preferred contact to Phone on read", () => { + it("preserves an explicit Phone/Email/Text preferred contact on read", () => { + expect( + mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact, + ).toBe("Phone"); + expect( + mapRosterTechnician({ contactName: "B", preferredContact: "Email" }).preferredContact, + ).toBe("Email"); + expect( + mapRosterTechnician({ contactName: "C", preferredContact: "Text" }).preferredContact, + ).toBe("Text"); + }); + + it("preserves an absent preferred contact as absent on read", () => { + const tech = mapRosterTechnician({ contactName: "Adam" }); + expect(tech.preferredContact).toBeUndefined(); + }); + + it("preserves an unknown preferred contact as absent on read", () => { const tech = mapRosterTechnician({ contactName: "Adam", preferredContact: "Fax" }); - expect(tech.preferredContact).toBe("Phone"); + expect(tech.preferredContact).toBeUndefined(); + }); + + it("omits preferredContact from the backend payload when absent", () => { + const payload = mapVendorRosterToBackend({ + name: "Solo Co", + companyPhone: "314-555-0100", + technicians: [{ contactName: "Adam", phone: "3145550198" }], + }); + const tech = (payload.technicians as Array>)[0]; + expect(tech).not.toHaveProperty("preferredContact"); + }); + + it("keeps an explicit preferredContact in the backend payload", () => { + const payload = mapVendorRosterToBackend({ + name: "Solo Co", + companyPhone: "314-555-0100", + technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Email" }], + }); + const tech = (payload.technicians as Array>)[0]; + expect(tech.preferredContact).toBe("Email"); }); }); diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts index 2472eebe..6b94dd90 100644 --- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -89,6 +89,16 @@ describe("vendorCompanyRosterSchema", () => { expect(result.success).toBe(false); }); + it("accepts a technician with an absent preferred contact", () => { + const techWithoutPreference = { ...emptyRosterTechnician }; + delete techWithoutPreference.preferredContact; + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + technicians: [{ ...techWithoutPreference, contactName: "Adam", phone: "314-555-0198" }], + }); + expect(result.success).toBe(true); + }); + it("update schema requires a non-empty row version and company id", () => { const result = vendorCompanyRosterUpdateSchema.safeParse({ ...baseCompany,