2026-08-04 09:53:48 -03:00
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
|
|
|
import { useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
|
|
|
import {
|
2026-08-18 13:09:33 -03:00
|
|
|
emptyRosterTechnician,
|
2026-08-04 09:53:48 -03:00
|
|
|
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,
|
feat(vendors): structure the address into Street/City/State with autocomplete (#176)
* feat(vendors): structure the address into Street/City/State with autocomplete
The Vendor form carried one free-text "Address (optional)" line, with City,
State and Zip already present but hidden behind display:none, and a separate
"Google Maps URL" input someone had to paste into by hand.
Street Address, City and State are now three required fields. Typing three
characters in Street Address offers up to four candidates; picking one fills
all three at once, and typing without picking stays plain free text. The
Google Maps URL input is gone — the location is derived from the address, the
Street Address itself is the link in view mode, and a keyless map preview
renders once all three parts are present. Zip stays in the payload but out of
the form; the ticket scopes the visible set to three.
The suggestion algorithm, candidate cities and copy are ported from the
approved prototype rather than invented, so dev and design agree on what a
dispatcher sees. Suggestions are deterministic for a given input on purpose:
a reshuffling list moves a row out from under the pointer mid-click.
Test fixtures that predate the requirement now carry an address, so each test
still fails for the reason it is about. The two drawer tests asserting the
stored-URL "Open in Google Maps" row are rewritten to the behaviour that
replaced it.
Delivers SH-271.
* feat(work-orders): show the vendor location map in the Vendor dialog
The last of SH-271's five acceptance bullets. The Work Order Vendor dialog
gets one composed address line from the vendor dropdown payload, not the
structured parts the form and detail drawer hold, so the preview takes the
line directly — it is saved data either way, and the completeness rule exists
to stop a map of half-typed input, not to reject a stored address.
The dialog's hand-built maps URL now goes through the shared helper, so the
link and the preview cannot drift apart.
* test(vendors): update the browser specs and pixel baselines for the new address
`npm run verify` does not run Playwright, so the first push went out with the
browser suite still asserting the UI this ticket removes. Three assertions
were stale: the combined "Address (optional)" input, the "Google Maps URL
(optional)" input, and the detail drawer's separate "Open in Google Maps"
row — now replaced by the Street Address itself being the link, checked
against the derived href. A fourth test created a vendor without an address,
which the new requirement blocks; it fills one, so the test still fails only
for the reason it is about.
Baselines regenerated in mcr.microsoft.com/playwright:v1.61.1-noble, the image
CI uses — macOS font rendering produces different pixels. Exactly three of the
sixteen were rewritten (vendor add, edit, detail); the rest, including every
Work Orders shot, are byte-identical, which is the evidence that this change
stays inside the surfaces it claims.
* test(vendors): keep the map preview out of the pixel baselines
The visual suite mocks `**/api/**` and nothing else, so the address map
preview's iframe reached maps.google.com for real. Whether that frame paints,
and what it paints, depends on the network and on what Google serves that
minute — which is why `vendor-edit` failed in CI at 18178 differing pixels
while passing in a container that could not reach Google. Regenerating the
baseline would not have fixed it; it would have moved the flake.
Aborting the request pins the frame to a blank box, so the shot measures our
layout and nothing else. The committed baselines are unchanged by this — they
were already correct — and a second container run with no --update passes
16/16, which is the evidence the shot is now stable rather than merely green
once.
* fix(vendors): complete structured address map flows
* test(vendors): align add visual baseline with CI
---------
Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:13:15 -03:00
|
|
|
// 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.
|
2026-08-04 09:53:48 -03:00
|
|
|
notes: roster.notes,
|
2026-09-16 11:55:40 -03:00
|
|
|
areaId: roster.areaId,
|
2026-08-04 09:53:48 -03:00
|
|
|
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,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 13:09:33 -03:00
|
|
|
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 }],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 09:53:48 -03:00
|
|
|
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 }),
|
2026-08-19 10:26:28 -03:00
|
|
|
// 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,
|
2026-08-04 09:53:48 -03:00
|
|
|
}),
|
|
|
|
|
[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;
|
2026-08-18 13:09:33 -03:00
|
|
|
reset(withCompanyFieldsOnly(roster));
|
2026-08-04 09:53:48 -03:00
|
|
|
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) {
|
2026-08-18 13:09:33 -03:00
|
|
|
reset((current) => ({
|
|
|
|
|
...emptyVendorCompanyRosterForm,
|
|
|
|
|
name: nextName,
|
|
|
|
|
technicians: current.technicians,
|
|
|
|
|
}));
|
2026-08-04 09:53:48 -03:00
|
|
|
}
|
|
|
|
|
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;
|
2026-08-18 17:28:48 -03:00
|
|
|
// 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));
|
2026-08-04 09:53:48 -03:00
|
|
|
setSelectedRoster(roster);
|
|
|
|
|
retryTargetRef.current = null;
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (requestIdRef.current !== requestId) return;
|
|
|
|
|
setLoadErrorMessage(RELOAD_ERROR_MESSAGE);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-08-18 17:28:48 -03:00
|
|
|
}, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]);
|
2026-08-04 09:53:48 -03:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|