mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 04:52:07 +00:00
Keep the slide-over uplift tab and the evidence upload while taking the current board, dashboard, and vendor changes from main.
204 lines
7.2 KiB
TypeScript
204 lines
7.2 KiB
TypeScript
import { useCallback, useRef, useState } from "react";
|
|
import { useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
import {
|
|
emptyRosterTechnician,
|
|
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,
|
|
// why: The deprecated stored Maps URL is intentionally not seeded: the form has no input for it, and
|
|
// carrying it would re-emit a legacy value on write.
|
|
notes: roster.notes,
|
|
areaId: roster.areaId,
|
|
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 toCompanyFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues {
|
|
return { ...toFormValues(roster), technicians: [] };
|
|
}
|
|
|
|
function withCompanyFieldsOnly(
|
|
roster: VendorCompanyRoster,
|
|
): (current: VendorCompanyRosterFormValues) => VendorCompanyRosterFormValues {
|
|
return (current) => ({
|
|
...toCompanyFormValues(roster),
|
|
technicians:
|
|
current.technicians.length > 0 ? current.technicians : [{ ...emptyRosterTechnician }],
|
|
});
|
|
}
|
|
|
|
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 requestIdRef = useRef(0);
|
|
|
|
const fetchRosterByCompany = useCallback(
|
|
async (id: string | number): Promise<VendorCompanyRoster> =>
|
|
queryClient.fetchQuery({
|
|
queryKey: queryKeys.vendors.roster("companyId", id),
|
|
queryFn: () => vendorCompanyRosterApi.get({ companyId: id }),
|
|
// SH-250: this fetch exists to obtain the freshest rowVersion, so it must not
|
|
// serve the global 5-minute staleTime cache. A conflict never invalidates this
|
|
// key (useSaveVendorCompanyRoster invalidates only on success, and its onError
|
|
// returns early for conflicts), and the Add flow has no mounted observer on it.
|
|
// Without staleTime: 0 the Reload button re-seeds the same stale rowVersion and
|
|
// the retry 409s again indefinitely.
|
|
staleTime: 0,
|
|
}),
|
|
[queryClient],
|
|
);
|
|
|
|
const selectCompany = useCallback(
|
|
async (company: VendorFacetCompany | null) => {
|
|
if (!company || company.companyId == null) {
|
|
requestIdRef.current += 1;
|
|
setSelectedRoster(null);
|
|
setLoadErrorMessage(null);
|
|
retryTargetRef.current = null;
|
|
return;
|
|
}
|
|
const requestId = (requestIdRef.current += 1);
|
|
setLoadErrorMessage(null);
|
|
retryTargetRef.current = { kind: "select", company };
|
|
try {
|
|
const roster = await fetchRosterByCompany(company.companyId);
|
|
if (requestIdRef.current !== requestId) return;
|
|
reset(withCompanyFieldsOnly(roster));
|
|
setSelectedRoster(roster);
|
|
clearConflict();
|
|
retryTargetRef.current = null;
|
|
} catch {
|
|
if (requestIdRef.current !== requestId) return;
|
|
setLoadErrorMessage(SELECT_ERROR_MESSAGE);
|
|
}
|
|
},
|
|
[clearConflict, fetchRosterByCompany, reset],
|
|
);
|
|
|
|
const clearSelectedCompany = useCallback(
|
|
(nextName = "") => {
|
|
requestIdRef.current += 1;
|
|
if (selectedRoster) {
|
|
reset((current) => ({
|
|
...emptyVendorCompanyRosterForm,
|
|
name: nextName,
|
|
technicians: current.technicians,
|
|
}));
|
|
}
|
|
setSelectedRoster(null);
|
|
clearConflict();
|
|
setLoadErrorMessage(null);
|
|
retryTargetRef.current = null;
|
|
},
|
|
[clearConflict, reset, selectedRoster],
|
|
);
|
|
|
|
const reload = useCallback(() => {
|
|
const requestId = (requestIdRef.current += 1);
|
|
clearConflict();
|
|
setLoadErrorMessage(null);
|
|
retryTargetRef.current = { kind: "reload" };
|
|
if (mode === "update") {
|
|
void query.refetch().then((result) => {
|
|
if (requestIdRef.current !== requestId) return;
|
|
if (result.isError) setLoadErrorMessage(RELOAD_ERROR_MESSAGE);
|
|
else retryTargetRef.current = null;
|
|
});
|
|
return;
|
|
}
|
|
if (selectedRoster?.companyId != null) {
|
|
void fetchRosterByCompany(selectedRoster.companyId)
|
|
.then((roster) => {
|
|
if (requestIdRef.current !== requestId) return;
|
|
// SH-250: refresh the visible company fields alongside rowVersion.
|
|
// Refreshing only selectedRoster left the form on pre-conflict values,
|
|
// so a retry diffed stale fields against the reloaded roster and could
|
|
// overwrite the concurrent company update that caused the 409.
|
|
reset(withCompanyFieldsOnly(roster));
|
|
setSelectedRoster(roster);
|
|
retryTargetRef.current = null;
|
|
})
|
|
.catch(() => {
|
|
if (requestIdRef.current !== requestId) return;
|
|
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(() => {
|
|
requestIdRef.current += 1;
|
|
setSelectedRoster(null);
|
|
setLoadErrorMessage(null);
|
|
retryTargetRef.current = null;
|
|
}, []);
|
|
|
|
return {
|
|
selectedRoster,
|
|
loadErrorMessage,
|
|
selectedCompanyId: selectedRoster?.companyId ?? null,
|
|
selectCompany,
|
|
clearSelectedCompany,
|
|
reload,
|
|
retryLoad,
|
|
resetSelection,
|
|
};
|
|
}
|