diff --git a/src/app/(app)/quotes/approvals/page.tsx b/src/app/(app)/quotes/approvals/page.tsx index 054cf05..b120a24 100644 --- a/src/app/(app)/quotes/approvals/page.tsx +++ b/src/app/(app)/quotes/approvals/page.tsx @@ -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>({}); + // 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(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 + >({}); 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({ + 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 )} - {/* 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 ? ( - - ) : ( - - )} + {/* 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). */} +