diff --git a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx
index f400f8f1..0712f6ef 100644
--- a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx
+++ b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx
@@ -76,7 +76,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="customer"
nameLabel="Customer name"
- submissionLocked={data.status === "Refused"}
+ submissionLocked={locked}
onAdded={() => void refresh()}
/>
@@ -89,7 +89,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="vendor"
nameLabel="Your name"
- submissionLocked={data.status === "Refused"}
+ submissionLocked={locked}
onAdded={() => void refresh()}
/>
diff --git a/src/app/v/[token]/dispatch/_components/refuse-dispatch-dialog.tsx b/src/app/v/[token]/dispatch/_components/refuse-dispatch-dialog.tsx
index f0cb4cf4..89e053f3 100644
--- a/src/app/v/[token]/dispatch/_components/refuse-dispatch-dialog.tsx
+++ b/src/app/v/[token]/dispatch/_components/refuse-dispatch-dialog.tsx
@@ -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."
diff --git a/src/app/v/[token]/dispatch/_components/signoff-section.tsx b/src/app/v/[token]/dispatch/_components/signoff-section.tsx
index 58e8eaf4..298e1737 100644
--- a/src/app/v/[token]/dispatch/_components/signoff-section.tsx
+++ b/src/app/v/[token]/dispatch/_components/signoff-section.tsx
@@ -53,7 +53,7 @@ export function SignoffSection({
}
if (submissionLocked) {
- return Signoff is unavailable after this dispatch was refused.;
+ return Signoff is unavailable for this dispatch.;
}
const submit = async (event: React.FormEvent) => {
diff --git a/src/test/app/v/dispatch-detail-sections.test.tsx b/src/test/app/v/dispatch-detail-sections.test.tsx
new file mode 100644
index 00000000..fad2f8ae
--- /dev/null
+++ b/src/test/app/v/dispatch-detail-sections.test.tsx
@@ -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: () =>
,
+}));
+vi.mock("@/app/v/_components/vendor-portal-documents", () => ({
+ VendorPortalDocuments: () => ,
+}));
+vi.mock("@/app/v/[token]/dispatch/_components/uplift-requests-section", () => ({
+ UpliftRequestsSection: () => ,
+}));
+vi.mock("@/app/v/[token]/dispatch/_components/comments-section", () => ({
+ CommentsSection: () => ,
+}));
+
+function buildData(
+ overrides: Partial = {},
+): 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(
+ ,
+ { 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);
+ });
+});
diff --git a/src/test/app/v/refuse-dispatch-dialog.test.tsx b/src/test/app/v/refuse-dispatch-dialog.test.tsx
index 5850a511..ef0c9fe7 100644
--- a/src/test/app/v/refuse-dispatch-dialog.test.tsx
+++ b/src/test/app/v/refuse-dispatch-dialog.test.tsx
@@ -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) => (
+
+
+
+ );
+
+ 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();
+ });
});
diff --git a/src/test/app/v/signoff-section.test.tsx b/src/test/app/v/signoff-section.test.tsx
new file mode 100644
index 00000000..b83b0b7e
--- /dev/null
+++ b/src/test/app/v/signoff-section.test.tsx
@@ -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> = {}) {
+ return render(
+ ,
+ );
+}
+
+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();
+ });
+});