Compare commits

...

2 commits

Author SHA1 Message Date
Codex Review Integration
f0f3485bee fix(vendors): preserve status during roster edits 2026-08-20 20:59:27 -03:00
Codex Review Integration
146491fafb fix(vendors): persist technician removal 2026-08-20 20:43:52 -03:00
5 changed files with 158 additions and 43 deletions

View file

@ -97,6 +97,7 @@ export interface VendorRosterForm {
selectedCompanyId: string | number | null;
selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
clearSelectedCompany: (nextName?: string) => void;
setTechnicianActive: (index: number, isActive: boolean) => void;
resetForm: () => void;
}
@ -133,7 +134,7 @@ export function useVendorRosterForm({
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
mode: "onChange",
});
const { control, handleSubmit, reset, formState } = form;
const { control, handleSubmit, reset, setValue, formState } = form;
// react-hook-form exposes formState through a Proxy that only tracks properties read during
// render. Reading dirtyFields inside the submit callback would not subscribe and would come
// back empty, so it is resolved here on every render.
@ -163,6 +164,16 @@ export function useVendorRosterForm({
clearConflict();
}, [clearConflict, createDefaults, reset, resetSelection]);
const setTechnicianActive = useCallback(
(index: number, isActive: boolean) => {
setValue(`technicians.${index}.isActive`, isActive, {
shouldDirty: true,
shouldValidate: true,
});
},
[setValue],
);
const submit = (formValues: VendorCompanyRosterFormValues) => {
setConflict(null);
const values = withoutBlankNewTechnicians(formValues);
@ -228,6 +239,7 @@ export function useVendorRosterForm({
selectedCompanyId: selection.selectedCompanyId,
selectCompany: selection.selectCompany,
clearSelectedCompany: selection.clearSelectedCompany,
setTechnicianActive,
resetForm,
};
}

View file

@ -8,19 +8,35 @@ import {
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
function withoutTechnicianStubs(
values: VendorCompanyRosterFormValues,
): VendorCompanyRosterFormValues {
return {
...values,
technicians: values.technicians.filter((technician) => {
if (technician === null || typeof technician !== "object" || Array.isArray(technician)) {
return true;
}
const candidate = technician as unknown as Record<string, unknown>;
const keys = Object.keys(candidate);
const isGeneratedStatusStub =
keys.length === 1 && keys[0] === "isActive" && typeof candidate.isActive === "boolean";
return !isGeneratedStatusStub;
}),
};
}
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: {} };
const normalizedValues = withoutTechnicianStubs(values);
if (routeRoster && getSingleStatusOnlyChange(routeRoster, normalizedValues)) {
return { values: normalizedValues, errors: {} };
}
return strictResolver(values, context, options);
return strictResolver(normalizedValues, context, options);
},
[routeRoster, strictResolver],
);

View file

@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { Controller, useWatch } from "react-hook-form";
import { useWatch } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
@ -240,6 +240,32 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
);
}
function TechnicianStatusControl({
isActive,
persistedIsActive,
onRequestDeactivation,
onChange,
}: {
isActive: boolean;
persistedIsActive: boolean;
onRequestDeactivation: () => void;
onChange: (checked: boolean) => void;
}) {
return (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
<Switch
checked={isActive}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (persistedIsActive && !checked) onRequestDeactivation();
else onChange(checked);
}}
/>
</Stack>
);
}
function DrawerEditor({
vendor,
onClose,
@ -312,22 +338,11 @@ function DrawerEditor({
Total Jobs
</Text>
</Box>
<Controller
control={form.control}
name={`technicians.${selectedIndex}.isActive`}
render={({ field }) => (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text variant="description">{field.value ? "Active" : "Inactive"}</Text>
<Switch
checked={Boolean(field.value)}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (persistedIsActive && !checked) onRequestDeactivation(vendor);
else field.onChange(checked);
}}
/>
</Stack>
)}
<TechnicianStatusControl
isActive={Boolean(technicians[selectedIndex]?.isActive)}
persistedIsActive={persistedIsActive}
onRequestDeactivation={() => onRequestDeactivation(vendor)}
onChange={(checked) => form.setTechnicianActive(selectedIndex, checked)}
/>
</Stack>
</>

View file

@ -348,23 +348,22 @@ function TechnicianRow({
tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
/>
{showStatus && (
<Controller
control={control}
name={`technicians.${index}.isActive`}
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value}
onChange={(_event, checked) => field.onChange(checked)}
/>
}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
)}
<Controller
control={control}
name={`technicians.${index}.isActive`}
render={({ field }) => (
<FormControlLabel
sx={{ display: showStatus ? undefined : "none" }}
control={
<Switch
checked={Boolean(field.value)}
onChange={(_event, checked) => field.onChange(checked)}
/>
}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
</Stack>
);
}

View file

@ -1,16 +1,27 @@
import { screen } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, 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();
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args),
}));
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: saveMutate, isPending: false }),
};
});
const vendor: VendorListItem = {
id: 1,
companyId: "co-1",
@ -50,7 +61,69 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
};
}
beforeEach(() => {
saveMutate.mockReset();
});
describe("VendorDetailDrawer selected-technician display", () => {
it.each([
{ removedPosition: 1, remainingId: 2, remainingName: "Beth" },
{ removedPosition: 2, remainingId: 1, remainingName: "Adam" },
])(
"reconciles the remaining roster after removing technician $removedPosition",
async ({ removedPosition, remainingId, remainingName }) => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{
id: 1,
contactName: "Adam",
phone: "314-555-0198",
email: "",
tradeSpecialties: "",
isActive: removedPosition === 1,
totalJobs: 5,
},
{
id: 2,
contactName: "Beth",
phone: "314-555-0199",
email: "",
tradeSpecialties: "",
isActive: removedPosition === 2,
totalJobs: 2,
},
]),
);
renderWithProviders(
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
{ route: "/vendors", withAuth: false },
);
await userEvent.click(
screen.getByRole("button", { name: `Remove technician ${removedPosition}` }),
);
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1));
expect(saveMutate).toHaveBeenCalledWith(
expect.objectContaining({
mode: "update",
values: expect.objectContaining({
technicians: [
expect.objectContaining({
id: remainingId,
contactName: remainingName,
isActive: false,
}),
],
}),
}),
expect.any(Object),
);
},
);
it("keeps Save changes available after removing a technician from a legacy roster", async () => {
const roster = rosterWith([
{