mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(vendors): address roster review feedback
This commit is contained in:
parent
3d8b2951eb
commit
6395905232
21 changed files with 1508 additions and 744 deletions
58
src/app/(protected)/vendors/_components/use-vendor-deactivation.ts
vendored
Normal file
58
src/app/(protected)/vendors/_components/use-vendor-deactivation.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
103
src/app/(protected)/vendors/_components/use-vendor-list-state.ts
vendored
Normal file
103
src/app/(protected)/vendors/_components/use-vendor-list-state.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
27
src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts
vendored
Normal file
27
src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts
vendored
Normal 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],
|
||||
);
|
||||
}
|
||||
159
src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts
vendored
Normal file
159
src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
126
src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx
vendored
Normal file
126
src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx
vendored
Normal 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 "{target?.companyName}"? 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
21
src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx
vendored
Normal file
21
src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
168
src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx
vendored
Normal file
168
src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
263
src/app/(protected)/vendors/_components/vendors-table.tsx
vendored
Normal file
263
src/app/(protected)/vendors/_components/vendors-table.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
594
src/app/(protected)/vendors/index.tsx
vendored
594
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -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 "{deleteTarget?.companyName}"? 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
2
src/domain/vendors/types/vendor.ts
vendored
2
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -115,7 +115,7 @@ export interface VendorRosterTechnician {
|
|||
contactName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
preferredContact: VendorPreferredContact;
|
||||
preferredContact?: VendorPreferredContact;
|
||||
tradeSpecialties: string;
|
||||
isActive: boolean;
|
||||
totalJobs: number;
|
||||
|
|
|
|||
143
src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
vendored
Normal file
143
src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
vendored
Normal 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");
|
||||
});
|
||||
});
|
||||
90
src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
vendored
Normal file
90
src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue