fix(vendors): address roster review feedback

This commit is contained in:
Alexandre Brandizzi 2026-08-03 17:48:53 -03:00
parent 3d8b2951eb
commit 6395905232
21 changed files with 1508 additions and 744 deletions

View file

@ -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<VendorListItem | null>(null);
const [error, setError] = useState<string | null>(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,
};
}

View file

@ -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<VendorFilters>(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,
};
}

View file

@ -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<typeof useForm<VendorCompanyRosterFormValues>>["control"];
errors: FieldErrors<VendorCompanyRosterFormValues>;
@ -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<VendorRosterConflict | null>(null);
const [selectedRoster, setSelectedRoster] = useState<VendorCompanyRoster | null>(null);
const routeRoster = mode === "update" ? query.data : undefined;
const companies = useMemo(() => facets?.companies ?? [], [facets]);
const trades = useMemo(() => facets?.trades ?? [], [facets]);
const updateValues = useMemo<VendorCompanyRosterFormValues | undefined>(
() => (mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined),
[mode, routeRoster],
);
const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []);
const resolver = useMemo<Resolver<VendorCompanyRosterFormValues>>(
() => 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<VendorCompanyRosterFormValues>({
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<VendorCompanyRoster> =>
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,
};
}

View file

@ -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<VendorCompanyRosterFormValues> {
const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []);
return useMemo<Resolver<VendorCompanyRosterFormValues>>(
() => async (values, context, options) => {
if (
routeRoster &&
getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues)
) {
return { values, errors: {} };
}
return strictResolver(values, context, options);
},
[routeRoster, strictResolver],
);
}

View file

@ -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<void>;
clearSelectedCompany: (nextName?: string) => void;
reload: () => void;
retryLoad: () => void;
resetSelection: () => void;
}
interface UseVendorRosterSelectionOptions {
mode: "create" | "update";
reset: UseFormReset<VendorCompanyRosterFormValues>;
query: UseQueryResult<VendorCompanyRoster, Error>;
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<VendorCompanyRoster | null>(null);
const [loadErrorMessage, setLoadErrorMessage] = useState<string | null>(null);
const retryTargetRef = useRef<
{ kind: "select"; company: VendorFacetCompany } | { kind: "reload" } | null
>(null);
const fetchRosterByCompany = useCallback(
async (id: string | number): Promise<VendorCompanyRoster> =>
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,
};
}

View file

@ -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) {
</DialogTitle>
<DialogContent>
<Stack spacing={2} className="mt-1">
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
)}
{form.conflict !== null && (
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
)}

View file

@ -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 (
<Dialog open={Boolean(target)} onClose={isSaving ? undefined : onClose}>
<DialogTitle>Deactivate Vendor</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<DialogContentText>
Deactivate &quot;{target?.companyName}&quot;? Existing work-order and audit history will
be preserved.
</DialogContentText>
{isLoading && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Checking linked work orders...
</Typography>
</Stack>
)}
{Boolean(impactError) && !isLoading && (
<Alert severity="warning">
Could not verify linked work orders. Close and try again.
</Alert>
)}
{impact != null && !isLoading && !impact.canDeactivate && (
<Alert severity="error">
This vendor cannot be deactivated because it still has open work orders.
</Alert>
)}
{impact != null && impact.openWorkOrders.length > 0 && (
<Box>
<Typography variant="subtitle2" className="font-semibold uppercase">
Open work orders ({impact.openWorkOrders.length})
</Typography>
<List dense sx={{ mt: 0.5 }}>
{impact.openWorkOrders.map((wo) => (
<ListItem
key={String(wo.workOrderId)}
sx={{ px: 0, py: 0.25 }}
secondaryAction={
wo.scheduledDate ? (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.scheduledDate}
</Typography>
) : undefined
}
>
<Stack>
<Typography variant="body2">
{wo.workOrderNumber
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
</Typography>
{Boolean(wo.status) && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.status}
</Typography>
)}
</Stack>
</ListItem>
))}
</List>
</Box>
)}
{Boolean(error) && <Alert severity="error">{error}</Alert>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={onConfirm}
disabled={isSaving || isLoading || hasBlockingImpact || Boolean(impactError)}
>
{isSaving ? "Deactivating..." : "Deactivate"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -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 (
<Stack
direction="row"
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 3,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box>
<Typography
id="vendor-detail-drawer-title"
variant="subtitle1"
component="h2"
className="font-semibold"
>
{roster?.name || vendor?.companyName || "Vendor company"}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
</Typography>
</Box>
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
);
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
const mapsUrl = roster.googleMapsUrl;
return (
<Stack spacing={1.5}>
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Email" value={roster.email} />
<Field
label="Address"
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
/>
{Boolean(mapsUrl) && (
<Link
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
>
<LaunchIcon fontSize="small" />
Open in Google Maps
</Link>
)}
</Stack>
);
}
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
return (
<Box>
<SectionTitle>Technicians</SectionTitle>
{roster.technicians.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No technicians assigned to this company.
</Typography>
) : (
<List disablePadding>
{roster.technicians.map((technician) => (
<TechnicianEntry
key={String(technician.id ?? technician.contactName)}
contactName={technician.contactName}
phone={technician.phone}
email={technician.email}
preferredContact={technician.preferredContact}
tradeSpecialties={technician.tradeSpecialties}
isActive={technician.isActive}
totalJobs={technician.totalJobs}
/>
))}
</List>
)}
</Box>
);
}
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
return (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
<SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
<Divider />
<TechniciansSection roster={roster} />
{Boolean(roster.notes) && (
<>
<Divider />
<Box>
<SectionTitle>Notes</SectionTitle>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{roster.notes}
</Typography>
</Box>
</>
)}
</Stack>
);
}
function DrawerActions({ onEdit }: { onEdit: () => void }) {
return (
<Stack
direction="row"
sx={{
px: 3,
py: 2,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
alignItems: "center",
justifyContent: "flex-end",
}}
>
<Button variant="contained" onClick={onEdit} startIcon={<EditOutlinedIcon />}>
Edit
</Button>
</Stack>
);
}
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
}}
>
<Stack className="flex h-full flex-col">
<Stack
direction="row"
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 3,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box>
<Typography
id="vendor-detail-drawer-title"
variant="subtitle1"
component="h2"
className="font-semibold"
>
{roster?.name || vendor?.companyName || "Vendor company"}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
</Typography>
</Box>
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
{isLoading ? (
<Box className="flex flex-1 items-center justify-center">
@ -209,83 +316,8 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
</Alert>
) : roster ? (
<>
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
<SectionTitle>Company</SectionTitle>
<Stack spacing={1.5}>
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Email" value={roster.email} />
<Field
label="Address"
value={[roster.address, roster.city, roster.state, roster.zip]
.filter(Boolean)
.join(", ")}
/>
{Boolean(mapsUrl) && (
<Link
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
>
<LaunchIcon fontSize="small" />
Open in Google Maps
</Link>
)}
</Stack>
<Divider />
<Box>
<SectionTitle>Technicians</SectionTitle>
{roster.technicians.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No technicians assigned to this company.
</Typography>
) : (
<List disablePadding>
{roster.technicians.map((technician) => (
<TechnicianEntry
key={String(technician.id ?? technician.contactName)}
contactName={technician.contactName}
phone={technician.phone}
email={technician.email}
preferredContact={technician.preferredContact}
tradeSpecialties={technician.tradeSpecialties}
isActive={technician.isActive}
totalJobs={technician.totalJobs}
/>
))}
</List>
)}
</Box>
{Boolean(roster.notes) && (
<>
<Divider />
<Box>
<SectionTitle>Notes</SectionTitle>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{roster.notes}
</Typography>
</Box>
</>
)}
</Stack>
<Stack
direction="row"
sx={{
px: 3,
py: 2,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
alignItems: "center",
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
onClick={() => navigate(editHref)}
startIcon={<EditOutlinedIcon />}
>
Edit
</Button>
</Stack>
<DrawerBody roster={roster} />
<DrawerActions onEdit={() => navigate(editHref)} />
</>
) : (
<Box className="flex flex-1 items-center justify-center">

View file

@ -0,0 +1,21 @@
import { Alert, Button } from "@mui/material";
interface VendorRosterLoadErrorAlertProps {
message: string;
onRetry: () => void;
}
export function VendorRosterLoadErrorAlert({ message, onRetry }: VendorRosterLoadErrorAlertProps) {
return (
<Alert
severity="error"
action={
<Button color="inherit" size="small" onClick={onRetry}>
Try again
</Button>
}
>
{message}
</Alert>
);
}

View file

@ -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
<PageShell title={title} subtitle={subtitle}>
<Box component="form" onSubmit={form.handleSubmit(form.submit)}>
<Stack spacing={3}>
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
)}
{form.conflict !== null && (
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
)}

View file

@ -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 (
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Vendors / Technicians
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{globalCount} vendor entries · {activeCount} active
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
>
<TextField
size="small"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search name, company, trade..."
className="min-w-[260px]"
/>
<Button
variant="outlined"
onClick={onOpenFilters}
startIcon={<FilterListIcon />}
aria-label="Open vendor filters"
>
Filters
{appliedFilterCount > 0 && (
<Chip size="small" color="primary" label={appliedFilterCount} sx={{ ml: 1 }} />
)}
</Button>
<Button variant="contained" onClick={onAddVendor}>
+ Add Vendor
</Button>
</Stack>
</Stack>
);
}
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 (
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Applied:
</Typography>
{chips.map((chip) => (
<Chip
key={chip.key}
size="small"
label={chip.label}
onDelete={chip.onRemove}
aria-label={`Remove filter ${chip.label}`}
/>
))}
<Button size="small" onClick={onClearAll}>
Clear all
</Button>
</Stack>
);
}
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 (
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", sm: "center" },
justifyContent: "space-between",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "vendor entry" : "vendor entries"}
{isFiltered && " · filtered"}
{` · ${activeCount} active · ${inactiveCount} inactive`}
</Typography>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
rowsPerPageOptions={[12, 24, 48]}
/>
</Stack>
);
}

View file

@ -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 (
<TableRow
key={rowKey}
hover
tabIndex={0}
role="button"
aria-label={`Open vendor details for ${row.companyName}`}
sx={{ cursor: "pointer" }}
onClick={() => onOpenDetail(row)}
onKeyDown={handleKeyDown}
>
<TableCell>
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Avatar
sx={{
width: 28,
height: 28,
fontSize: 12,
backgroundColor: "var(--accent, #1976d2)",
}}
>
{getInitials(row.contactName)}
</Avatar>
<Typography variant="body2">{row.contactName || "—"}</Typography>
</Stack>
</TableCell>
<TableCell>{locationText}</TableCell>
<TableCell>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
</Stack>
)}
</TableCell>
<TableCell>
{row.phone ? (
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
{row.phone}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>
{row.email ? (
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
{row.email}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>{row.totalJobs ?? 0}</TableCell>
<TableCell>
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell>
<TableCell align="center">
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenDetail(row);
}}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
sx={{ alignItems: "center", justifyContent: "center" }}
>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
navigate(
row.id != null
? `/vendors/edit/${row.id}`
: `/vendors/company/${row.companyId ?? ""}`,
);
}}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
{hasVendorId && row.isActive && (
<Tooltip title="Deactivate">
<IconButton
size="small"
aria-label="Deactivate vendor"
onClick={(event) => {
event.stopPropagation();
onOpenDeactivation(row);
}}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
);
}
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 (
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 960 }}>
<TableHead>
<TableRow>
{TABLE_COLUMNS.map((column) => (
<TableCell
key={column}
align={column === "View" || column === "Edit" ? "center" : "left"}
className="font-semibold"
>
{column}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No vendors match the current filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<VendorTableRow
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
row={row}
onOpenDetail={onOpenDetail}
onOpenDeactivation={onOpenDeactivation}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View file

@ -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<VendorFilters>(emptyVendorFilters);
const filters = useVendorListState();
const [filtersOpen, setFiltersOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(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 (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Vendors / Technicians
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{globalCount} vendor entries · {activeCount} active
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
>
<TextField
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search name, company, trade..."
className="min-w-[260px]"
/>
<Button
variant="outlined"
onClick={() => setFiltersOpen(true)}
startIcon={<FilterListIcon />}
aria-label="Open vendor filters"
>
Filters
{appliedChips.length > 0 && (
<Chip size="small" color="primary" label={appliedChips.length} sx={{ ml: 1 }} />
)}
</Button>
<Button variant="contained" onClick={() => setCreateOpen(true)}>
+ Add Vendor
</Button>
</Stack>
</Stack>
<VendorsListToolbar
globalCount={activeCount + inactiveCount}
activeCount={activeCount}
isFetching={isFetching}
isLoading={isLoading}
search={filters.search}
onSearchChange={filters.setSearchWithReset}
appliedFilterCount={appliedChips.length}
onOpenFilters={() => setFiltersOpen(true)}
onAddVendor={() => setCreateOpen(true)}
/>
<Tabs
value={isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") => {
setIsActive(value === "active");
setPage(0);
}}
value={filters.isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") =>
filters.setIsActiveWithReset(value === "active")
}
aria-label="Vendor status"
>
<Tab value="active" label={`Active (${activeCount})`} />
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
</Tabs>
{appliedChips.length > 0 && (
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Applied:
</Typography>
{appliedChips.map((chip) => (
<Chip
key={`${chip.category}-${chip.value}`}
size="small"
label={chip.label}
onDelete={() => handleRemoveChip(chip)}
aria-label={`Remove filter ${chip.label}`}
/>
))}
<Button size="small" onClick={handleClearFilters}>
Clear all
</Button>
</Stack>
)}
<VendorAppliedFiltersBar
chips={appliedChips.map((chip) => ({
key: `${chip.category}-${chip.value}`,
label: chip.label,
onRemove: () => filters.removeFilter(chip.category, chip.value),
}))}
onClearAll={filters.clearFilters}
/>
{Boolean(error) && (
<Alert severity="error">
@ -300,192 +100,25 @@ export default function VendorsListPage() {
</Alert>
)}
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 960 }}>
<TableHead>
<TableRow>
{TABLE_COLUMNS.map((column) => (
<TableCell
key={column}
align={column === "View" || column === "Edit" ? "center" : "left"}
className="font-semibold"
>
{column}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{showLoadingRow ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No vendors match the current filters.
</Typography>
</TableCell>
</TableRow>
) : (
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 (
<TableRow
key={rowKey}
hover
tabIndex={0}
role="button"
aria-label={`Open vendor details for ${row.companyName}`}
sx={{ cursor: "pointer" }}
onClick={() => openDetail(row)}
onKeyDown={handleRowKeyDown(row)}
>
<TableCell>
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Avatar
sx={{
width: 28,
height: 28,
fontSize: 12,
backgroundColor: "var(--accent, #1976d2)",
}}
>
{getInitials(row.contactName)}
</Avatar>
<Typography variant="body2">{row.contactName || "—"}</Typography>
</Stack>
</TableCell>
<TableCell>{locationText}</TableCell>
<TableCell>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
</Stack>
)}
</TableCell>
<TableCell>
{row.phone ? (
<Link
href={`tel:${row.phone}`}
underline="hover"
onClick={(event) => event.stopPropagation()}
>
{row.phone}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>
{row.email ? (
<Link
href={`mailto:${row.email}`}
underline="hover"
onClick={(event) => event.stopPropagation()}
>
{row.email}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>{row.totalJobs ?? 0}</TableCell>
<TableCell>
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell>
<TableCell align="center">
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={stopAnd(() => openDetail(row))}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
sx={{ alignItems: "center", justifyContent: "center" }}
>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={stopAnd(() => openDetail(row))}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
{hasVendorId && row.isActive && (
<Tooltip title="Deactivate">
<IconButton
size="small"
aria-label="Deactivate vendor"
onClick={stopAnd(() => openDeactivation(row))}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<VendorsTable
rows={rows}
isLoading={isLoading}
showError={Boolean(error)}
onOpenDetail={setDetailTarget}
onOpenDeactivation={deactivation.open}
/>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", sm: "center" },
justifyContent: "space-between",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "vendor entry" : "vendor entries"}
{(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"}
{` · ${activeCount} active · ${inactiveCount} inactive`}
</Typography>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
</Stack>
<VendorsListFooter
totalCount={totalCount}
isActive={filters.isActive}
activeCount={activeCount}
inactiveCount={inactiveCount}
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
page={filters.page}
pageSize={filters.pageSize}
onPageChange={filters.setPage}
onPageSizeChange={filters.setPageSizeWithReset}
/>
<VendorCreateModal open={createOpen} onClose={() => 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}
/>
<VendorDetailDrawer
open={Boolean(detailTarget)}
vendor={detailTarget}
onClose={closeDetail}
onClose={() => setDetailTarget(null)}
/>
<Dialog
open={Boolean(deleteTarget)}
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
>
<DialogTitle>Deactivate Vendor</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<DialogContentText>
Deactivate &quot;{deleteTarget?.companyName}&quot;? Existing work-order and audit
history will be preserved.
</DialogContentText>
{isLoadingImpact && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Checking linked work orders...
</Typography>
</Stack>
)}
{Boolean(impactError) && !isLoadingImpact && (
<Alert severity="warning">
Could not verify linked work orders. Close and try again.
</Alert>
)}
{deactivationImpact != null &&
!isLoadingImpact &&
!deactivationImpact.canDeactivate && (
<Alert severity="error">
This vendor cannot be deactivated because it still has open work orders.
</Alert>
)}
{deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && (
<Box>
<Typography variant="subtitle2" className="font-semibold uppercase">
Open work orders ({deactivationImpact.openWorkOrders.length})
</Typography>
<List dense sx={{ mt: 0.5 }}>
{deactivationImpact.openWorkOrders.map((wo) => (
<ListItem
key={String(wo.workOrderId)}
sx={{ px: 0, py: 0.25 }}
secondaryAction={
wo.scheduledDate ? (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.scheduledDate}
</Typography>
) : undefined
}
>
<Stack>
<Typography variant="body2">
{wo.workOrderNumber
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
</Typography>
{Boolean(wo.status) && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.status}
</Typography>
)}
</Stack>
</ListItem>
))}
</List>
</Box>
)}
{Boolean(deleteError) && <Alert severity="error">{deleteError}</Alert>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={
deleteVendor.isPending ||
isLoadingImpact ||
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
Boolean(impactError)
}
>
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
</Button>
</DialogActions>
</Dialog>
<VendorDeactivationDialog
target={deactivation.target}
isLoading={deactivation.isLoading}
isSaving={deactivation.isSaving}
error={deactivation.error}
impact={deactivation.impact}
impactError={deactivation.impactError}
onClose={deactivation.close}
onConfirm={deactivation.confirm}
/>
</Box>
);
}

View file

@ -31,7 +31,7 @@ function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
return true;
}
function readPreferredContact(record: Record<string, unknown>): VendorPreferredContact {
function readPreferredContact(record: Record<string, unknown>): VendorPreferredContact | undefined {
const raw = record.preferredContact ?? record.PreferredContact;
if (typeof raw === "string") {
const normalized = raw.trim();
@ -39,10 +39,11 @@ function readPreferredContact(record: Record<string, unknown>): 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<string, unknown>, ...keys: string[]): string | number | undefined {
@ -101,10 +102,11 @@ export function mapRosterTechnicianToBackend(technician: unknown): Record<string
contactName: readString(item, "contactName", "ContactName"),
phone: toCanonicalPhone(item.phone ?? item.Phone),
email: readString(item, "email", "Email"),
preferredContact: readPreferredContact(item),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
};
const preferredContact = readPreferredContact(item);
if (preferredContact) payload.preferredContact = preferredContact;
const id = readId(item, "id", "Id");
if (id !== undefined) payload.id = id;
return payload;

View file

@ -40,7 +40,7 @@ export const rosterTechnicianSchema = z.object({
contactName: z.string(),
phone: northAmericanPhone,
email: optionalEmail,
preferredContact: preferredContactEnum,
preferredContact: preferredContactEnum.optional(),
tradeSpecialties: z.string(),
isActive: z.boolean(),
});

View file

@ -115,7 +115,7 @@ export interface VendorRosterTechnician {
contactName: string;
phone: string;
email: string;
preferredContact: VendorPreferredContact;
preferredContact?: VendorPreferredContact;
tradeSpecialties: string;
isActive: boolean;
totalJobs: number;

View file

@ -0,0 +1,143 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const rosterGet = vi.fn();
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
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");
});
});

View file

@ -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<Record<string, unknown>>) {
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(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
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(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
expect(screen.getByText("Prefers Email")).toBeInTheDocument();
});
});

View file

@ -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(<VendorsListPage />, { 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(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9");
});
});

View file

@ -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<Record<string, unknown>>)[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<Record<string, unknown>>)[0];
expect(tech.preferredContact).toBe("Email");
});
});

View file

@ -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,