fix(vendor-portal): clear stale refusal state

This commit is contained in:
Alexandre Brandizzi 2026-08-10 18:25:57 -03:00
parent 3f4d64d7b6
commit bc5659eb2a
6 changed files with 195 additions and 9 deletions

View file

@ -76,7 +76,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="customer"
nameLabel="Customer name"
submissionLocked={data.status === "Refused"}
submissionLocked={locked}
onAdded={() => void refresh()}
/>
</div>
@ -89,7 +89,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="vendor"
nameLabel="Your name"
submissionLocked={data.status === "Refused"}
submissionLocked={locked}
onAdded={() => void refresh()}
/>
</div>

View file

@ -45,12 +45,6 @@ export function RefuseDispatchDialog({
const reasonValue = useWatch({ control, name: "reason" }) ?? "";
const isOverLimit = reasonValue.length > REFUSE_REASON_MAX_LENGTH;
useEffect(() => {
if (!open) {
reset({ reason: "" });
}
}, [open, reset]);
const refuseMutation = useMutation({
mutationFn: (values: RefuseDispatchValues) => {
const trimmed = values.reason.trim();
@ -70,6 +64,15 @@ export function RefuseDispatchDialog({
},
});
const { reset: resetMutation } = refuseMutation;
useEffect(() => {
if (!open) {
reset({ reason: "" });
resetMutation();
}
}, [open, reset, resetMutation]);
const submit = handleSubmit((values) => refuseMutation.mutate(values));
const errorMessage = refuseMutation.error
? "Unable to refuse this dispatch. Please try again."

View file

@ -53,7 +53,7 @@ export function SignoffSection({
}
if (submissionLocked) {
return <Text variant="feedback">Signoff is unavailable after this dispatch was refused.</Text>;
return <Text variant="feedback">Signoff is unavailable for this dispatch.</Text>;
}
const submit = async (event: React.FormEvent) => {

View file

@ -0,0 +1,99 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
import type {
VendorPortalDispatchDetail,
VendorPortalSignoff,
} from "@/domain/vendor-portal/types/vendor-portal";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/v/[token]/dispatch/_components/checklist-section", () => ({
ChecklistSection: () => <div data-testid="checklist-mock" />,
}));
vi.mock("@/app/v/_components/vendor-portal-documents", () => ({
VendorPortalDocuments: () => <div data-testid="documents-mock" />,
}));
vi.mock("@/app/v/[token]/dispatch/_components/uplift-requests-section", () => ({
UpliftRequestsSection: () => <div data-testid="uplift-mock" />,
}));
vi.mock("@/app/v/[token]/dispatch/_components/comments-section", () => ({
CommentsSection: () => <div data-testid="comments-mock" />,
}));
function buildData(
overrides: Partial<VendorPortalDispatchDetail> = {},
): VendorPortalDispatchDetail {
return {
id: 9,
dispatchNumber: "DSP-9",
status: "In Progress",
nteAmount: 500,
checklist: [],
comments: [],
upliftRequests: [],
signoffs: [],
documents: [],
...overrides,
};
}
function renderSections({
status,
signoffs = [],
}: {
status: string;
signoffs?: VendorPortalSignoff[];
}) {
const data = buildData({ status, signoffs });
const locked = isVendorPortalLockedStatus(status);
return renderWithProviders(
<DispatchDetailSections
token="portal-token"
dispatchId={9}
data={data}
locked={locked}
refresh={vi.fn()}
updateLocal={vi.fn()}
/>,
{ withAuth: false },
);
}
describe("DispatchDetailSections signoff locking", () => {
it.each(["Verified", "Cancelled", "Refused"])(
"locks both customer and vendor signoff forms when status is %s",
(status) => {
renderSections({ status });
expect(screen.getAllByText(/Signoff is unavailable for this dispatch/i)).toHaveLength(2);
expect(screen.queryAllByRole("button", { name: "Submit Signoff" })).toHaveLength(0);
},
);
it.each(["Completed", "In Progress"])(
"keeps both signoff forms available when status is %s",
(status) => {
renderSections({ status });
expect(screen.getAllByRole("button", { name: "Submit Signoff" })).toHaveLength(2);
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
},
);
it("keeps an existing signoff visible while the dispatch is locked", () => {
const customerSignoff: VendorPortalSignoff = {
signoffType: "customer",
name: "Ada Lovelace",
signatureMethod: "drawn",
signedAt: "2024-01-02T03:04:05Z",
};
renderSections({ status: "Refused", signoffs: [customerSignoff] });
expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
expect(screen.getByText("drawn")).toBeInTheDocument();
expect(screen.getByText(/Signoff is unavailable/i)).toBeInTheDocument();
expect(screen.queryAllByRole("button", { name: "Submit Signoff" })).toHaveLength(0);
});
});

View file

@ -169,4 +169,42 @@ describe("RefuseDispatchDialog", () => {
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("clears the stale error and reason after closing and reopening", async () => {
vi.spyOn(vendorPortalApi, "refuse").mockRejectedValueOnce(new Error("nope"));
const onClose = vi.fn();
const onRefused = vi.fn();
const portalValue: VendorPortalContextValue = {
token: "portal-token",
vendor: { companyName: "Gateway Plumbing", vendorId: 101 },
status: "ready",
error: null,
};
const renderEl = (open: boolean) => (
<VendorPortalContext.Provider value={portalValue}>
<RefuseDispatchDialog open={open} dispatchId={9} onClose={onClose} onRefused={onRefused} />
</VendorPortalContext.Provider>
);
const { rerender } = renderWithProviders(renderEl(true), { withAuth: false });
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: "too busy" } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
});
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Unable to refuse/i));
expect(screen.getByText(`8/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
await act(async () => {
rerender(renderEl(false));
});
await act(async () => {
rerender(renderEl(true));
});
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByText(`0/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
function renderSection(overrides: Partial<React.ComponentProps<typeof SignoffSection>> = {}) {
return render(
<SignoffSection
token="portal-token"
dispatchId={9}
existing={[]}
signoffType="customer"
nameLabel="Customer name"
onAdded={vi.fn()}
{...overrides}
/>,
);
}
describe("SignoffSection", () => {
it("shows status-neutral locked copy when submission is locked", () => {
renderSection({ submissionLocked: true });
expect(screen.getByText(/Signoff is unavailable for this dispatch/i)).toBeInTheDocument();
expect(screen.queryByText(/refused/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
});
it("shows the submission form when unlocked", () => {
renderSection();
expect(screen.getByRole("button", { name: "Submit Signoff" })).toBeInTheDocument();
});
it("shows an existing signoff even while locked", () => {
renderSection({
signoffType: "vendor",
nameLabel: "Your name",
submissionLocked: true,
existing: [{ signoffType: "vendor", name: "Grace Hopper", signatureMethod: "typed" }],
});
expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
});
});