Both the PO create and edit screens now guard against accidental loss of in-progress data. While a form has unsaved changes: - in-app link clicks and the edit screen's Cancel button are intercepted, showing a dialog offering Save as Draft / Discard changes / Stay on page - hard unloads (reload, tab close, external navigation) trigger the browser's native warning via beforeunload "Save as Draft" reuses the existing draft-save action; validation errors are surfaced in the dialog so the user can recover or discard. Dirty state is tracked from any field edit, line-item change, or attachment change. Adds useUnsavedChanges hook + UnsavedChangesDialog component and unit tests. Fixes #18 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
"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 (
|
|
<AdminDialog title="Unsaved changes" open={open} onClose={handleStay}>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-neutral-600">
|
|
You have unsaved changes to this purchase order. Would you like to
|
|
save it as a draft before leaving?
|
|
</p>
|
|
{error && (
|
|
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
|
)}
|
|
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end sm:gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleStay}
|
|
disabled={saving}
|
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
|
|
>
|
|
Stay on page
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (!saving) onDiscard(); }}
|
|
disabled={saving}
|
|
className="rounded-lg border border-danger-200 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60"
|
|
>
|
|
Discard changes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
|
|
>
|
|
{saving ? "Saving…" : saveLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</AdminDialog>
|
|
);
|
|
}
|