diff --git a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx
index 2135a77..78c8c16 100644
--- a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx
+++ b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx
@@ -6,6 +6,8 @@ import { updatePo } from "./actions";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
+import { UnsavedChangesDialog } from "@/components/po/unsaved-changes-dialog";
+import { useUnsavedChanges } from "@/components/po/use-unsaved-changes";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
@@ -62,11 +64,13 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
+ const [dirty, setDirty] = useState(false);
+ const { promptOpen, guard, leave, stay } = useUnsavedChanges(dirty);
const canSubmit = po.status === "DRAFT";
const canResubmit = po.status === "EDITS_REQUESTED";
- async function handleSubmit(intent: "save" | "submit" | "resubmit") {
+ async function handleSubmit(intent: "save" | "submit" | "resubmit"): Promise<{ ok: boolean; error?: string }> {
setSubmitting(intent);
setError("");
const form = document.getElementById("edit-po-form") as HTMLFormElement;
@@ -87,9 +91,11 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
if ("error" in result) {
setError(result.error);
setSubmitting(null);
- } else {
- router.push(`/po/${result.id}`);
+ return { ok: false, error: result.error };
}
+ setDirty(false);
+ router.push(`/po/${result.id}`);
+ return { ok: true };
}
const poDateValue = po.poDate
@@ -108,7 +114,13 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
const extPo = po;
return (
-
);
}
diff --git a/App/components/po/unsaved-changes-dialog.tsx b/App/components/po/unsaved-changes-dialog.tsx
new file mode 100644
index 0000000..222c5bf
--- /dev/null
+++ b/App/components/po/unsaved-changes-dialog.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useState } from "react";
+import { AdminDialog } from "@/components/ui/admin-dialog";
+
+interface Props {
+ open: boolean;
+ /** Saves the PO as a draft. Resolves `{ ok: true }` on success. */
+ onSaveDraft: () => Promise<{ ok: boolean; error?: string }>;
+ /** Leave the page without saving. */
+ onDiscard: () => void;
+ /** Cancel and stay on the page. */
+ onStay: () => void;
+ saveLabel?: string;
+}
+
+export function UnsavedChangesDialog({
+ open,
+ onSaveDraft,
+ onDiscard,
+ onStay,
+ saveLabel = "Save as Draft",
+}: Props) {
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ async function handleSave() {
+ setSaving(true);
+ setError("");
+ const result = await onSaveDraft();
+ setSaving(false);
+ // On success the form navigates away; on failure keep the prompt open so
+ // the user can fix the problem (or discard).
+ if (!result.ok) setError(result.error ?? "Could not save draft.");
+ }
+
+ function handleStay() {
+ if (saving) return;
+ setError("");
+ onStay();
+ }
+
+ return (
+
+
+
+ You have unsaved changes to this purchase order. Would you like to
+ save it as a draft before leaving?
+
+ {error && (
+
{error}
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/App/components/po/use-unsaved-changes.ts b/App/components/po/use-unsaved-changes.ts
new file mode 100644
index 0000000..15f572e
--- /dev/null
+++ b/App/components/po/use-unsaved-changes.ts
@@ -0,0 +1,97 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+
+/**
+ * Guards a form against accidental loss of in-progress data.
+ *
+ * While `isDirty` is true it:
+ * - shows the browser's native warning on hard unloads (reload / tab close /
+ * external navigation), and
+ * - intercepts in-app link clicks so the caller can show a custom prompt
+ * offering to save the work as a draft before leaving.
+ *
+ * Programmatic navigations (e.g. a "Cancel" button calling `router.back()`)
+ * should be wrapped with the returned `guard()` so they are intercepted too.
+ */
+export function useUnsavedChanges(isDirty: boolean) {
+ const router = useRouter();
+ const dirtyRef = useRef(isDirty);
+ dirtyRef.current = isDirty;
+ // Set once the user has chosen to leave, so the pending navigation isn't
+ // re-intercepted on the way out.
+ const bypassRef = useRef(false);
+ const [pendingNav, setPendingNav] = useState void)>(null);
+ const pendingRef = useRef void)>(null);
+ pendingRef.current = pendingNav;
+
+ // Native warning for reload / tab-close / external links.
+ useEffect(() => {
+ if (!isDirty) return;
+ function onBeforeUnload(e: BeforeUnloadEvent) {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ window.addEventListener("beforeunload", onBeforeUnload);
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
+ }, [isDirty]);
+
+ // Intercept clicks on in-app links so we can prompt before leaving.
+ useEffect(() => {
+ function onClick(e: MouseEvent) {
+ if (!dirtyRef.current || bypassRef.current) return;
+ if (e.defaultPrevented || e.button !== 0) return;
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+
+ const anchor = (e.target as HTMLElement | null)?.closest?.("a");
+ if (!anchor) return;
+ if (anchor.hasAttribute("download")) return;
+ const target = anchor.getAttribute("target");
+ if (target && target !== "_self") return;
+ const href = anchor.getAttribute("href");
+ if (!href) return;
+
+ let url: URL;
+ try {
+ url = new URL(anchor.href, window.location.href);
+ } catch {
+ return;
+ }
+ if (url.origin !== window.location.origin) return;
+
+ const dest = url.pathname + url.search + url.hash;
+ const here = window.location.pathname + window.location.search;
+ // Same page (e.g. an in-page #anchor) — let it through.
+ if (url.pathname + url.search === here) return;
+
+ e.preventDefault();
+ setPendingNav(() => () => router.push(dest));
+ }
+
+ document.addEventListener("click", onClick, true);
+ return () => document.removeEventListener("click", onClick, true);
+ }, [router]);
+
+ /** Wrap a programmatic navigation so it is intercepted while dirty. */
+ const guard = useCallback((run: () => void) => {
+ if (!dirtyRef.current || bypassRef.current) {
+ run();
+ return;
+ }
+ setPendingNav(() => run);
+ }, []);
+
+ /** Proceed with the pending navigation, discarding changes. */
+ const leave = useCallback(() => {
+ bypassRef.current = true;
+ const run = pendingRef.current;
+ setPendingNav(null);
+ run?.();
+ }, []);
+
+ /** Cancel the prompt and stay on the page. */
+ const stay = useCallback(() => setPendingNav(null), []);
+
+ return { promptOpen: pendingNav !== null, guard, leave, stay };
+}
diff --git a/App/tests/unit/unsaved-changes.test.tsx b/App/tests/unit/unsaved-changes.test.tsx
new file mode 100644
index 0000000..b9290e6
--- /dev/null
+++ b/App/tests/unit/unsaved-changes.test.tsx
@@ -0,0 +1,147 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { UnsavedChangesDialog } from "@/components/po/unsaved-changes-dialog";
+import { useUnsavedChanges } from "@/components/po/use-unsaved-changes";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push, back: vi.fn() }),
+}));
+
+beforeEach(() => {
+ push.mockReset();
+});
+
+// ── Dialog ────────────────────────────────────────────────────────────────────
+
+describe("UnsavedChangesDialog", () => {
+ const noop = async () => ({ ok: true });
+
+ it("renders the three choices when open", () => {
+ render(
+
+ );
+ expect(screen.getByRole("button", { name: /save as draft/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /discard changes/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /stay on page/i })).toBeInTheDocument();
+ });
+
+ it("renders nothing when closed", () => {
+ const { container } = render(
+
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("invokes onDiscard / onStay when those buttons are clicked", () => {
+ const onDiscard = vi.fn();
+ const onStay = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("button", { name: /discard changes/i }));
+ expect(onDiscard).toHaveBeenCalledOnce();
+ fireEvent.click(screen.getByRole("button", { name: /stay on page/i }));
+ expect(onStay).toHaveBeenCalledOnce();
+ });
+
+ it("shows an error and keeps the prompt open when saving the draft fails", async () => {
+ const onSaveDraft = vi.fn(async () => ({ ok: false, error: "Title is required" }));
+ const onStay = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("button", { name: /save as draft/i }));
+ expect(onSaveDraft).toHaveBeenCalledOnce();
+ await waitFor(() => expect(screen.getByText("Title is required")).toBeInTheDocument());
+ // still open — user can recover
+ expect(onStay).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: /stay on page/i })).toBeInTheDocument();
+ });
+
+ it("uses a custom save label when provided", () => {
+ render(
+
+ );
+ expect(screen.getByRole("button", { name: "Save Draft" })).toBeInTheDocument();
+ });
+});
+
+// ── Hook ────────────────────────────────────────────────────────────────────
+
+function Harness({ dirty }: { dirty: boolean }) {
+ const { promptOpen, guard, leave, stay } = useUnsavedChanges(dirty);
+ return (
+
+ );
+}
+
+describe("useUnsavedChanges", () => {
+ it("intercepts internal link clicks while dirty", () => {
+ render();
+ expect(screen.getByTestId("open")).toHaveTextContent("false");
+ fireEvent.click(screen.getByText("internal link"));
+ expect(screen.getByTestId("open")).toHaveTextContent("true");
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it("does not intercept link clicks when clean", () => {
+ render();
+ fireEvent.click(screen.getByText("internal link"));
+ expect(screen.getByTestId("open")).toHaveTextContent("false");
+ });
+
+ it("ignores external links", () => {
+ render();
+ fireEvent.click(screen.getByText("external link"));
+ expect(screen.getByTestId("open")).toHaveTextContent("false");
+ });
+
+ it("intercepts guarded programmatic navigation while dirty, then proceeds on leave", () => {
+ render();
+ fireEvent.click(screen.getByText("cancel"));
+ expect(screen.getByTestId("open")).toHaveTextContent("true");
+ expect(push).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText("leave"));
+ expect(push).toHaveBeenCalledWith("/programmatic");
+ expect(screen.getByTestId("open")).toHaveTextContent("false");
+ });
+
+ it("runs programmatic navigation immediately when clean", () => {
+ render();
+ fireEvent.click(screen.getByText("cancel"));
+ expect(push).toHaveBeenCalledWith("/programmatic");
+ expect(screen.getByTestId("open")).toHaveTextContent("false");
+ });
+
+ it("closes the prompt without navigating on stay", () => {
+ render();
+ fireEvent.click(screen.getByText("cancel"));
+ expect(screen.getByTestId("open")).toHaveTextContent("true");
+ fireEvent.click(screen.getByText("stay"));
+ expect(screen.getByTestId("open")).toHaveTextContent("false");
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it("warns via beforeunload while dirty", () => {
+ render();
+ const evt = new Event("beforeunload", { cancelable: true });
+ window.dispatchEvent(evt);
+ expect(evt.defaultPrevented).toBe(true);
+ });
+
+ it("does not warn via beforeunload when clean", () => {
+ render();
+ const evt = new Event("beforeunload", { cancelable: true });
+ window.dispatchEvent(evt);
+ expect(evt.defaultPrevented).toBe(false);
+ });
+});