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:
Dave 2026-08-27 00:53:45 +02:00
parent c0eca10b51
commit a499329a4e

View file

@ -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 &amp; 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>
);
}