diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts
index b63291be..5870f7db 100644
--- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts
+++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts
@@ -38,13 +38,18 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio
const confirm = () => {
if (!target || target.id == null) return;
setError(null);
- deleteVendor.mutate(target.id, {
- onSuccess: () => {
- setTarget(null);
- onSuccess?.();
+ // The dialog has shown whatever open work orders exist, so confirming here is
+ // the explicit confirmation the API requires to deactivate past them (SH-254).
+ deleteVendor.mutate(
+ { id: target.id, confirmOpenWorkOrders: (impact?.openWorkOrders.length ?? 0) > 0 },
+ {
+ onSuccess: () => {
+ setTarget(null);
+ onSuccess?.();
+ },
+ onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
},
- onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
- });
+ );
};
return {
diff --git a/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx
index cf673b6c..a56ca3b5 100644
--- a/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx
+++ b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx
@@ -8,11 +8,13 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
+ Link,
List,
ListItem,
Stack,
Typography,
} from "@mui/material";
+import { Link as RouterLink } from "react-router";
import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor";
interface VendorDeactivationDialogProps {
@@ -26,6 +28,15 @@ interface VendorDeactivationDialogProps {
onConfirm: () => void;
}
+function deactivationMessage(companyName: string | undefined, openCount: number): string {
+ const name = companyName ? `"${companyName}"` : "This vendor";
+ if (openCount === 0) {
+ return `${name} will no longer be selectable for new work orders.`;
+ }
+ const plural = openCount === 1 ? "work order" : "work orders";
+ return `${name} will no longer be selectable for new work orders. It still has ${openCount} open ${plural} — they'll keep it as-is unless you reassign them.`;
+}
+
export function VendorDeactivationDialog({
target,
isLoading,
@@ -36,16 +47,16 @@ export function VendorDeactivationDialog({
onClose,
onConfirm,
}: VendorDeactivationDialogProps) {
- const hasBlockingImpact = Boolean(impact && !impact.canDeactivate);
+ const openWorkOrders = impact?.openWorkOrders ?? [];
+ const hasOpenWorkOrders = openWorkOrders.length > 0;
return (
diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts
index 467a3a3b..5afbc13e 100644
--- a/src/domain/vendors/api/vendors-api.ts
+++ b/src/domain/vendors/api/vendors-api.ts
@@ -101,8 +101,11 @@ export const vendorsApi = {
return mapVendor(handleApiResponse(data));
},
- delete: async (id: string | number): Promise => {
- await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
+ // SH-254: confirmOpenWorkOrders tells the API the caller has been shown the
+ // vendor's open work orders and chose to proceed. Without it the API still blocks.
+ delete: async (id: string | number, confirmOpenWorkOrders = false): Promise => {
+ const suffix = confirmOpenWorkOrders ? "?confirmOpenWorkOrders=true" : "";
+ await apiDelete(`${API_PATHS.rest.vendors}/${id}${suffix}`);
},
getDeactivationImpact: async (id: string | number): Promise => {
diff --git a/src/domain/vendors/use-cases/use-delete-vendor.ts b/src/domain/vendors/use-cases/use-delete-vendor.ts
index 0457cc7e..17f8ae11 100644
--- a/src/domain/vendors/use-cases/use-delete-vendor.ts
+++ b/src/domain/vendors/use-cases/use-delete-vendor.ts
@@ -3,11 +3,17 @@ import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { queryKeys } from "@/infra/query-key/query-key";
-export function useDeleteVendor(): UseMutationResult {
+export interface DeleteVendorInput {
+ id: string | number;
+ confirmOpenWorkOrders?: boolean;
+}
+
+export function useDeleteVendor(): UseMutationResult {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (id: string | number) => vendorsApi.delete(id),
+ mutationFn: ({ id, confirmOpenWorkOrders = false }: DeleteVendorInput) =>
+ vendorsApi.delete(id, confirmOpenWorkOrders),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor deactivated");
diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx
index cbfa47f9..ae5a18d3 100644
--- a/src/test/app/(protected)/vendors/vendors-list.test.tsx
+++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx
@@ -177,7 +177,7 @@ describe("VendorsListPage", () => {
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
});
- it("blocks deactivation when the preflight reports open work orders", async () => {
+ it("offers deactivate-anyway when the preflight reports open work orders", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
@@ -213,13 +213,52 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
expect(
- screen.getByText(/cannot be deactivated because it still has open work orders/),
+ screen.getByText(
+ /will no longer be selectable for new work orders\. It still has 1 open work order —/,
+ ),
).toBeInTheDocument();
- expect(screen.getByText(/Boiler repair/)).toBeInTheDocument();
- expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
- expect(mutate).not.toHaveBeenCalled();
+ expect(screen.getByRole("link", { name: /Boiler repair/ })).toHaveAttribute(
+ "href",
+ "/workorders/101",
+ );
+
+ const confirm = screen.getByRole("button", { name: "Deactivate anyway" });
+ expect(confirm).toBeEnabled();
+ await userEvent.click(confirm);
+
+ expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: true }, expect.anything());
});
+ it("deactivates without the confirmation flag when nothing is linked", async () => {
+ setupDefaults();
+ useVendorCompanyRoster.mockReturnValue({
+ data: activeRoster,
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: vi.fn(),
+ });
+ useVendorDeactivationImpact.mockReturnValue({
+ data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] },
+ isLoading: false,
+ error: null,
+ });
+ useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
+ if (params.pageSize === 1) return result([], 1);
+ return params.isActive ? result([activeVendor], 1) : result([], 0);
+ });
+
+ renderWithProviders(, { route: "/vendors", withAuth: false });
+
+ await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
+ await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
+
+ expect(screen.queryByText(/It still has/)).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: /^Deactivate$/ }));
+
+ expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: false }, expect.anything());
+ }, 10_000);
+
it("preserves inline edits when deactivation is cancelled", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
@@ -247,7 +286,7 @@ describe("VendorsListPage", () => {
await userEvent.type(company, "Draft Company Name");
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
await userEvent.click(
- within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", {
+ within(screen.getByRole("dialog", { name: "Deactivate this vendor?" })).getByRole("button", {
name: "Cancel",
}),
);