feat(approvals): Approve & send from list rows
The pending-approvals list now offers the same one-step approve-and-dispatch affordance as the detail page, instead of forcing the rep through a two-step approve-then-drill-in flow. - Replace the list's standalone 'Approve' button (direct PB write) and 'Approve partial' button (POST /partial-approve) with a single 'Approve & send' button that opens SendQuoteModal. - Lift the modal and sendApprove callback to page level, keyed by row id, callable per row. - Submit posts to POST /api/v1/quotes/[id]/review-approve with action: 'approve' and the modal's email payload. The server atomically approves + dispatches. - Same handler works for partial_approval_pending rows — server flips partial_approved -> sending -> emailed natively. - Toast wording matches the detail page: 'Approved - email sent...' for full, 'Partial approved - email sent...' for partial, with invalid_status and mail-failed branches surfacing their own toasts. - Reject path untouched. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c0eca10b51
commit
a499329a4e
1 changed files with 169 additions and 57 deletions
|
|
@ -13,7 +13,9 @@ import { quoteStatusClassName, formatQuoteStatus } from "@/lib/quote-status";
|
|||
import { ArrowLeft, CheckCircle, XCircle, Loader2, FileText, ListChecks } from "lucide-react";
|
||||
import { getClientIdentity } from "@/lib/dev-identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Quote, QuoteItem } from "@/lib/types";
|
||||
import type { Quote, QuoteItem, QuoteAttachment } from "@/lib/types";
|
||||
import { SendQuoteModal } from "@/components/quotes/send-quote-modal";
|
||||
import { isPlaceholderCustomer } from "@/lib/placeholder-customers";
|
||||
|
||||
/**
|
||||
* /quotes/approvals — queue of quotes awaiting the rep's eyes.
|
||||
|
|
@ -26,16 +28,16 @@ import type { Quote, QuoteItem } from "@/lib/types";
|
|||
* without leaving the queue.
|
||||
*
|
||||
* Actions:
|
||||
* - Approve (full): flips to `approved`. The email send is a
|
||||
* separate step on the quote detail page (the existing flow).
|
||||
* - Approve (partial): calls /partial-approve → /send-email →
|
||||
* /split. The continuation is created in the background and
|
||||
* surfaces as a toast.
|
||||
* - Approve & send (full): opens SendQuoteModal and submits via
|
||||
* /review-approve action: "approve". The server atomically flips
|
||||
* status and dispatches the email.
|
||||
* - Approve & send (partial): same modal + same endpoint. The
|
||||
* server handles partial_approval_pending → partial_approved →
|
||||
* sending → emailed natively.
|
||||
* - Reject: bounces the quote back to the operator's queue —
|
||||
* flips `status` to `new`, clears `claimed_by`, and appends
|
||||
* the rejection reason (with reviewer + timestamp) to
|
||||
* `quotes.notes`. Works for both full and partial
|
||||
* submissions.
|
||||
* `quotes.notes`. Works for both full and partial submissions.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -92,6 +94,16 @@ export default function ApprovalsPage() {
|
|||
// them at display time. We only fetch once per toggle-on to
|
||||
// avoid hammering PB.
|
||||
const [itemsByQuote, setItemsByQuote] = useState<Record<string, QuoteItem[]>>({});
|
||||
// Modal state — which row's "Approve & send" modal is currently open.
|
||||
// `null` means no modal is rendered. The modal stays mounted across
|
||||
// rows so we don't unmount mid-edit; switching rows closes the current
|
||||
// modal and opens the new one via the onOpenChange callback.
|
||||
const [sendModalQuoteId, setSendModalQuoteId] = useState<string | null>(null);
|
||||
// Per-quote outbound attachments cache. Keyed by quote id; only the
|
||||
// modal needs them, so we fetch lazily when the modal first opens.
|
||||
const [outboundAttachments, setOutboundAttachments] = useState<
|
||||
Record<string, QuoteAttachment[]>
|
||||
>({});
|
||||
|
||||
const fetchApprovals = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -150,23 +162,108 @@ export default function ApprovalsPage() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
// Lifted from /quotes/approvals/[id]/page.tsx — the modal's onSend
|
||||
// handler. Called when the rep confirms the email composition and
|
||||
// hits the modal's primary button ("Approve & send").
|
||||
//
|
||||
// Diff from the detail page version:
|
||||
// - We use `router.refresh()` instead of `router.push("/quotes/approvals")`
|
||||
// because we're already on the list; refresh re-fetches the page's
|
||||
// server data, which re-renders the list and drops the now-approved
|
||||
// row naturally.
|
||||
// - `itemChanges` is always `[]` — the list page has no inline markup
|
||||
// editor (that's a detail-page affordance). The rep nudges markup
|
||||
// by drilling into the quote, which is the explicit out-of-scope
|
||||
// escape hatch in the spec.
|
||||
const sendApprove = async (
|
||||
quote: Quote,
|
||||
email: {
|
||||
to: string;
|
||||
cc: string;
|
||||
bcc: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
selectedAttachmentIds: string[];
|
||||
}
|
||||
) => {
|
||||
setActionLoading(quote.id);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
await pb.collection("quotes").update(id, {
|
||||
status: "approved",
|
||||
approved_by: pb.authStore.model?.id || "",
|
||||
const res = await fetch(`/api/v1/quotes/${quote.id}/review-approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "approve", itemChanges: [], email }),
|
||||
});
|
||||
toast.success("Quote approved — proceed to send from the quote page");
|
||||
fetchApprovals();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to approve");
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
email?: { failure?: { code: string; message: string } };
|
||||
};
|
||||
if (!res.ok) {
|
||||
// Three branches on the !ok path:
|
||||
// 1) invalid_status — another rep already actioned this quote.
|
||||
// Surface a toast that explains the refresh and refresh the list.
|
||||
// 2) Mail-send failure — the approve part succeeded server-side
|
||||
// but the SMTP send failed. The quote is now in `mail_failed`;
|
||||
// tell the rep to retry from the quote page.
|
||||
// 3) Anything else (placeholder customer, validation, 5xx) —
|
||||
// generic error toast.
|
||||
if (body.code === "invalid_status") {
|
||||
toast("This quote was already actioned — refreshing.", { type: "info" });
|
||||
router.refresh();
|
||||
setSendModalQuoteId(null);
|
||||
return;
|
||||
}
|
||||
if (body.email?.failure) {
|
||||
toast.error(
|
||||
`Saved — but email send failed: ${body.email.failure.message}. Retry from the quote page.`
|
||||
);
|
||||
setSendModalQuoteId(null);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
toast.error(body.error ?? `Approve failed (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
// 200 path — branch on partial vs full based on the quote we sent.
|
||||
toast.success(
|
||||
quote.status === "partial_approval_pending"
|
||||
? "Partial approved — email sent for the priced items."
|
||||
: "Approved — email sent to the customer."
|
||||
);
|
||||
setSendModalQuoteId(null);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Approve failed");
|
||||
} finally {
|
||||
setActionLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
// Outbound attachments loader — only called when the modal opens for
|
||||
// the first time on a row. The detail page does this fetch inside the
|
||||
// modal mount; we mirror the pattern.
|
||||
const ensureOutboundAttachments = useCallback(async (quoteId: string) => {
|
||||
if (outboundAttachments[quoteId]) return;
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
const items = await pb.collection("quote_attachments").getFullList<QuoteAttachment>({
|
||||
filter: `quote = "${quoteId}" && direction = "outbound"`,
|
||||
sort: "-created",
|
||||
});
|
||||
setOutboundAttachments((prev) => ({ ...prev, [quoteId]: items }));
|
||||
} catch {
|
||||
// Silent — modal will just show zero outbound attachments. The
|
||||
// rep can still send the email with just the PDF.
|
||||
}
|
||||
}, [outboundAttachments]);
|
||||
|
||||
// Thin wrapper: opens the SendQuoteModal for the given row and
|
||||
// kicks off the outbound-attachments fetch in the background.
|
||||
const openSendModal = (quoteId: string) => {
|
||||
setSendModalQuoteId(quoteId);
|
||||
void ensureOutboundAttachments(quoteId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Approve a partial quote. Marks priced items as
|
||||
* `approved_in_batch`, flips status to `partial_approved`, then
|
||||
|
|
@ -382,45 +479,26 @@ export default function ApprovalsPage() {
|
|||
{isShowingOutstanding ? "Hide" : "Show"} outstanding
|
||||
</Button>
|
||||
)}
|
||||
{/* Approve button — variant depends on
|
||||
partial vs full. Partial-approve routes
|
||||
through the dedicated endpoint which
|
||||
marks items + flips status. Full approve
|
||||
uses the existing PATCH path. Both
|
||||
surface a toast telling the rep what
|
||||
happened. */}
|
||||
{isPartial ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleApprovePartial(quote.id);
|
||||
}}
|
||||
disabled={actionLoading !== ""}
|
||||
>
|
||||
{actionLoading === quote.id && (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Approve partial
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleApprove(quote.id);
|
||||
}}
|
||||
disabled={actionLoading !== ""}
|
||||
>
|
||||
{actionLoading === quote.id && (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{/* Approve & send — same button for both full and partial rows.
|
||||
Opens the same SendQuoteModal the detail page uses, then submits
|
||||
via POST /api/v1/quotes/[id]/review-approve action: "approve".
|
||||
The server handles partials natively (flips through
|
||||
partial_approved → sending → emailed). */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openSendModal(quote.id);
|
||||
}}
|
||||
disabled={actionLoading !== ""}
|
||||
>
|
||||
{actionLoading === quote.id && (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Approve & send
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
|
|
@ -500,6 +578,40 @@ export default function ApprovalsPage() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder-customer UX (disable submit) is delegated to the
|
||||
server's 422 response. The detail page passes a customerIsPlaceholder
|
||||
prop to SendQuoteModal; the list page deliberately doesn't bother —
|
||||
placeholder customers shouldn't reach the approval queue in normal
|
||||
flow, and the existing 422 handler shows the failure cleanly. */}
|
||||
{(() => {
|
||||
const openQuote = quotes.find((q) => q.id === sendModalQuoteId);
|
||||
if (!openQuote) return null;
|
||||
const customerIsPlaceholder = isPlaceholderCustomer(
|
||||
openQuote.expand?.customer?.customer ?? ""
|
||||
);
|
||||
return (
|
||||
<SendQuoteModal
|
||||
open={true}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setSendModalQuoteId(null);
|
||||
}}
|
||||
quote={openQuote}
|
||||
outboundAttachments={outboundAttachments[openQuote.id] ?? []}
|
||||
// The detail page passes the rep's display name; on the list we
|
||||
// don't have a per-quote claimer lookup, so the modal just shows
|
||||
// its own default signature.
|
||||
claimerName={null}
|
||||
submitting={actionLoading === openQuote.id}
|
||||
submitLabel="Approve & send"
|
||||
onSend={(email) => sendApprove(openQuote, email)}
|
||||
// No onRetry: a mail-failed retry needs the quote's persisted
|
||||
// composition, which the list page doesn't keep. The rep can
|
||||
// retry from the quote page (the same path the toast points them
|
||||
// at).
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue