diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 1d44283..9b9ed13 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -113,6 +113,14 @@ Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a * - **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows. - **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer). +### Unsaved-changes prompt (issue #18) + +The PO **create** (`new-po-form`) and **edit** (`edit-po-form`) screens guard against losing in-progress work. `components/po/unsaved-changes-guard.tsx` `` arms once the form is `dirty` (any `onInput`/`onChange` on the form, plus the React-state editors — line items, terms, files, accounting code) and: +- **Hard navigations** (refresh, tab close, external link) → the browser's native "Leave site?" prompt (`beforeunload`; browsers can't render custom buttons here, so save-as-draft isn't offered on this path). +- **In-app navigations** (sidebar / header / any internal ``) → a capture-phase click interceptor opens an `AdminDialog` offering **Save as draft** (runs the form's draft save, which redirects to the PO) / **Discard changes** (navigates to the intended URL) / **Stay on page**. + +`dirty` is reset before the form's own successful-submit redirect so saving never trips the guard. The SPA **back button** (popstate) is not intercepted — only `beforeunload` covers it. The manager inline-edit panel on `/approvals/[id]` is out of scope (it saves in place via `router.refresh()` with no draft concept). + ### PO Numbering (`lib/po-number.ts`) Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import. 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 fb03245..9d4a67f 100644 --- a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx +++ b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -10,6 +10,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select"; import { VendorSelect } from "@/components/ui/vendor-select"; import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor"; +import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { LineItemInput } from "@/lib/validations/po"; @@ -69,6 +70,8 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts); const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? ""); const [terms, setTerms] = useState(initialTerms); + const [dirty, setDirty] = useState(false); + const markDirty = () => setDirty(true); const canSubmit = po.status === "DRAFT"; const canResubmit = po.status === "EDITS_REQUESTED"; @@ -96,6 +99,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery setError(result.error); setSubmitting(null); } else { + setDirty(false); // saved — don't warn on the redirect router.push(`/po/${result.id}`); } } @@ -116,7 +120,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery const extPo = po; return ( -
e.preventDefault()}> + e.preventDefault()} onInput={markDirty} onChange={markDirty}> {canResubmit && (

@@ -180,7 +184,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery { setDefaultAccountId(v); markDirty(); }} groups={accounts} placeholder="Search accounting code…" required @@ -246,7 +250,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery

Line Items

{ setLineItems(v); markDirty(); }} multiAccount={multiAccount} accounts={accounts} defaultAccountId={defaultAccountId || undefined} @@ -263,7 +267,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery

Terms & Conditions

Add a category and pick (or type) a clause.

- + { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
{error && ( @@ -292,6 +296,12 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery )}
+ + handleSubmit("save")} + saving={submitting === "save"} + /> ); } diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index d8aca40..782275f 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -10,6 +10,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select"; import { VendorSelect } from "@/components/ui/vendor-select"; import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor"; +import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import { uploadAndLinkFiles } from "@/lib/upload-files"; import type { LineItemInput } from "@/lib/validations/po"; @@ -48,6 +49,8 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio const [multiAccount, setMultiAccount] = useState(false); const [defaultAccountId, setDefaultAccountId] = useState(""); const [terms, setTerms] = useState(defaultTerms); + const [dirty, setDirty] = useState(false); + const markDirty = () => setDirty(true); async function handleSubmit(intent: "draft" | "submit") { setSubmitting(intent); @@ -82,11 +85,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio return; } } + setDirty(false); // saved — don't warn on the redirect router.push(`/po/${result.id}`); } return ( -
e.preventDefault()}> + e.preventDefault()} onInput={markDirty} onChange={markDirty}> {/* Order Information */}

Order Information

@@ -143,7 +147,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio { setDefaultAccountId(v); markDirty(); }} groups={accounts} placeholder="Search accounting code…" required @@ -215,7 +219,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio

Line Items

{ setLineItems(v); markDirty(); }} multiAccount={multiAccount} accounts={accounts} defaultAccountId={defaultAccountId || undefined} @@ -237,13 +241,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio

Terms & Conditions

Add a category and pick (or type) a clause. Manage the catalogue under Administration → Terms & Conditions.

- + { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
{/* Attachments */}

Attachments (optional)

- + { setFiles(v); markDirty(); }} disabled={!!submitting} />
{error && ( @@ -268,6 +272,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio {submitting === "submit" ? "Submitting…" : "Submit for Approval"} + + handleSubmit("draft")} + saving={submitting === "draft"} + /> ); } diff --git a/App/components/po/unsaved-changes-guard.tsx b/App/components/po/unsaved-changes-guard.tsx new file mode 100644 index 0000000..fb2bf62 --- /dev/null +++ b/App/components/po/unsaved-changes-guard.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AdminDialog } from "@/components/ui/admin-dialog"; + +interface Props { + /** Arm the guard — true once the form has unsaved changes. */ + enabled: boolean; + /** Persist the in-progress PO as a draft. Should navigate away on success. */ + onSaveDraft: () => void; + /** True while the draft save is in flight (drives the button label/disable). */ + saving: boolean; +} + +// Warns the user before they leave a PO form with unsaved changes (issue #18). +// Two paths are covered: +// • Hard navigations (refresh, tab close, external links) → the browser's own +// "Leave site?" prompt (browsers can't render custom buttons here, so the +// save-as-draft option isn't offered on this path). +// • In-app navigations (sidebar / header / any internal
) → intercepted and +// replaced with our own modal offering Save as draft / Discard / Stay. +export function UnsavedChangesGuard({ enabled, onSaveDraft, saving }: Props) { + const router = useRouter(); + const [pendingHref, setPendingHref] = useState(null); + // Listeners are attached once; read `enabled` through a ref so they always see + // the latest value without re-binding on every keystroke. + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + + useEffect(() => { + function onBeforeUnload(e: BeforeUnloadEvent) { + if (!enabledRef.current) return; + e.preventDefault(); + e.returnValue = ""; + } + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, []); + + useEffect(() => { + function onClick(e: MouseEvent) { + if (!enabledRef.current || e.defaultPrevented) return; + // Ignore non-primary clicks and modifier-clicks (new tab / download etc.). + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const anchor = (e.target as HTMLElement | null)?.closest("a"); + const href = anchor?.getAttribute("href"); + if (!anchor || !href || href.startsWith("#")) return; + if (anchor.hasAttribute("download")) return; + if (anchor.target && anchor.target !== "_self") return; + + const url = new URL(href, window.location.href); + // External origin → let the browser's beforeunload prompt handle it. + if (url.origin !== window.location.origin) return; + // Same page (e.g. a no-op link) → nothing to guard. + if (url.pathname === window.location.pathname && url.search === window.location.search) return; + + e.preventDefault(); + e.stopPropagation(); + setPendingHref(url.pathname + url.search + url.hash); + } + // Capture phase so we run before Next's click handler. + document.addEventListener("click", onClick, true); + return () => document.removeEventListener("click", onClick, true); + }, []); + + const discard = useCallback(() => { + const href = pendingHref; + setPendingHref(null); + enabledRef.current = false; // let this navigation through + if (href) router.push(href); + }, [pendingHref, router]); + + const stay = useCallback(() => { + if (saving) return; + setPendingHref(null); + }, [saving]); + + function saveDraft() { + // Close the prompt so any inline save error is visible; the save action + // navigates to the PO on success. + setPendingHref(null); + onSaveDraft(); + } + + return ( + +
+

+ You have unsaved changes on this purchase order. Save it as a draft before leaving, or discard your changes? +

+
+ + + +
+
+
+ ); +} diff --git a/App/tests/unit/unsaved-changes-guard.test.tsx b/App/tests/unit/unsaved-changes-guard.test.tsx new file mode 100644 index 0000000..a20892a --- /dev/null +++ b/App/tests/unit/unsaved-changes-guard.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) })); + +import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; + +// Each test adds a real
to the document so the capture-phase click +// interceptor has something to catch. +const links: HTMLAnchorElement[] = []; +function addLink(href: string) { + const a = document.createElement("a"); + a.setAttribute("href", href); + a.textContent = "go"; + document.body.appendChild(a); + links.push(a); + return a; +} + +beforeEach(() => pushMock.mockClear()); +afterEach(() => { + cleanup(); + links.splice(0).forEach((a) => a.remove()); +}); + +const noop = () => {}; + +describe("UnsavedChangesGuard", () => { + it("does not intercept navigation when there are no unsaved changes", () => { + render(); + const link = addLink("/catalogue/vendors"); + const notCanceled = fireEvent.click(link); + expect(notCanceled).toBe(true); // default not prevented → navigation proceeds + expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument(); + }); + + it("intercepts an internal link click and opens the prompt when dirty", () => { + render(); + const link = addLink("/catalogue/vendors"); + const notCanceled = fireEvent.click(link); + expect(notCanceled).toBe(false); // navigation blocked + expect(pushMock).not.toHaveBeenCalled(); + expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save as draft" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Discard changes" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Stay on page" })).toBeInTheDocument(); + }); + + it("does not intercept external links (left to the browser's own prompt)", () => { + render(); + const link = addLink("https://example.com/elsewhere"); + const notCanceled = fireEvent.click(link); + expect(notCanceled).toBe(true); + expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument(); + }); + + it("'Stay on page' closes the prompt without navigating", () => { + render(); + fireEvent.click(addLink("/dashboard")); + fireEvent.click(screen.getByRole("button", { name: "Stay on page" })); + expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument(); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("'Discard changes' navigates to the intended destination", () => { + render(); + fireEvent.click(addLink("/catalogue/vendors")); + fireEvent.click(screen.getByRole("button", { name: "Discard changes" })); + expect(pushMock).toHaveBeenCalledWith("/catalogue/vendors"); + }); + + it("'Save as draft' runs the save handler and closes the prompt", () => { + const onSaveDraft = vi.fn(); + render(); + fireEvent.click(addLink("/dashboard")); + fireEvent.click(screen.getByRole("button", { name: "Save as draft" })); + expect(onSaveDraft).toHaveBeenCalledTimes(1); + expect(pushMock).not.toHaveBeenCalled(); // the save action does its own redirect + expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument(); + }); + + it("arms the browser beforeunload prompt only while dirty", () => { + const { rerender } = render(); + const dirtyEvt = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(dirtyEvt); + expect(dirtyEvt.defaultPrevented).toBe(true); + + rerender(); + const cleanEvt = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(cleanEvt); + expect(cleanEvt.defaultPrevented).toBe(false); + }); +});