From 72fede1891c2f93635c385031041187370d308e7 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 27 Jul 2026 10:43:24 +0200 Subject: [PATCH] Add one-click "Process" action to quotes list skipping detail page - New `handleProcess` claims new quotes inline and navigates straight to the process workspace - Primary "Process" button with secondary "Open" for claimable quotes; other statuses show "Open" only - Mobile card includes a full-width Process shortcut for new/claimed-by-me quotes --- src/app/(app)/quotes/page.tsx | 262 +++++++++++++++++++++++++++------- 1 file changed, 207 insertions(+), 55 deletions(-) diff --git a/src/app/(app)/quotes/page.tsx b/src/app/(app)/quotes/page.tsx index 75d293f..77520c2 100644 --- a/src/app/(app)/quotes/page.tsx +++ b/src/app/(app)/quotes/page.tsx @@ -331,6 +331,53 @@ export default function QuotesPage() { } }; + /** + * Process a quote directly from the list row — claim it (if still + * `new`) and jump to the process page in one click. This skips the + * quote detail page for the common "new → processing" workflow so + * the operator gets to the workspace (items table, pricing, AI + * rewrite, PDF) without detouring through the metadata view. + * + * Already-claimed-by-me quotes skip the write and just navigate — + * the operator is already the owner and just needs to land on the + * workspace. Quotes in any other status (`claimed` by someone else, + * `processed`, `approval_pending`, etc.) don't show this button — + * they need to go through the detail page first. + * + * Note: this duplicates the "claim" half of the detail page's + * status-flip logic. We deliberately call the PB update directly + * (rather than routing through an API) to match the existing + * `handleDelete` pattern in this file and keep the round-trip + * count to one for the common case. + */ + const handleProcess = async (quote: Quote) => { + setLoadingActionIds((prev) => new Set(prev).add(quote.id)); + try { + const pb = getPocketBase(); + const currentUserId = pb.authStore.model?.id || ""; + // If the quote is still `new`, claim it inline so the operator + // shows up as the claimant before landing on the process page. + // Already-claimed-by-me quotes skip the write — the operator is + // already the owner and just needs to navigate. + if (quote.status === "new") { + await pb.collection("quotes").update(quote.id, { + status: "claimed", + claimed_by: currentUserId, + }); + toast.success(`Claimed ${quote.quote_number} — opening process…`); + } + router.push(`/quotes/${quote.id}/process`); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to process quote"); + } finally { + setLoadingActionIds((prev) => { + const next = new Set(prev); + next.delete(quote.id); + return next; + }); + } + }; + /** * Clone a real quote into a fresh new quote. Used by the Actions * column on the workflow tabs. Same items, same customer, fresh @@ -486,20 +533,38 @@ export default function QuotesPage() { // Inline row action. Contextual per row type so the operator // doesn't have to think: // - Template row → "Create quote" (spawn a real quote) - // - Real quote → "Open" (navigate to the detail page) + // - Real quote → "Process" + "Open" pair when the + // operator can claim/work it from the + // list (status="new" or claimed by + // the current user). Process is the + // primary action — it claims inline + // (if new) and jumps straight to the + // process workspace, skipping the + // detail page. Open stays as a + // secondary outline for the read-only + // metadata view (history, attachments, + // etc.). + // - Real quote, other statuses → "Open" only. Anything + // past `claimed` (claimed by someone else, processed, + // approval_pending, emailed, complete, ...) needs the + // detail page for the workflow actions that live there. // // The QUOTE LIST PAGE'S WHOLE ROW IS NO LONGER CLICKABLE. // Operators were accidentally navigating to the detail by // misclicks; row click (onRowClick) is intentionally OFF. - // To enter the quote, click this button or the chevron to - // peek at items first. + // To enter the quote, click these buttons or the chevron + // to peek at items first. key: "actions", label: "", - className: "w-20 text-right", + // Width bumped from w-20 to w-44 to fit the contextual + // "Process + Open" pair for new/claimed-by-me rows. The + // Open-only fallback still fits but the extra space gives + // breathing room and keeps both buttons on the same line. + className: "w-44 text-right", render: (item: Quote) => { const isTemplate = item.is_template === true; + const isLoading = loadingActionIds.has(item.id); if (isTemplate) { - const isLoading = loadingActionIds.has(item.id); return ( @@ -526,8 +591,55 @@ export default function QuotesPage() { ); } - // Real quote — explicit "Open" button so navigating to - // the detail page is a deliberate gesture, not a misclick. + // Real quote — pick the primary action based on status. + // `new` → Process claims inline + opens the workspace. + // `claimed` by me → Process just opens the workspace. + // Anything else → Open only (worker needs the detail page). + const currentUserId = getPocketBase().authStore.model?.id || ""; + const canProcess = + item.status === "new" || + (item.status === "claimed" && item.claimed_by === currentUserId); + if (canProcess) { + return ( +
+ + +
+ ); + } + // Other statuses — fallback to the detail page. return ( @@ -611,59 +724,98 @@ export default function QuotesPage() { } return Object.keys(style).length > 0 ? style : undefined; }} - mobileCardRender={(item) => ( - router.push(`/quotes/${item.id}`)}> - -
-
-
-
-

{item.quote_number}

- {isFollowUp(item) && } - {hasCustomerReply(item) && } - {isAged(item) && } -
-
-

- {item.expand?.customer?.customer || "—"} -

- {item.expand?.company?.company ? ( -

- {item.expand.company.company} + mobileCardRender={(item) => { + const isLoading = loadingActionIds.has(item.id); + const currentUserId = getPocketBase().authStore.model?.id || ""; + const canProcess = + item.status === "new" || + (item.status === "claimed" && item.claimed_by === currentUserId); + return ( + router.push(`/quotes/${item.id}`)}> + +

+
+
+
+

{item.quote_number}

+ {isFollowUp(item) && } + {hasCustomerReply(item) && } + {isAged(item) && } +
+
+

+ {item.expand?.customer?.customer || "—"}

- ) : null} + {item.expand?.company?.company ? ( +

+ {item.expand.company.company} +

+ ) : null} +
+ + {formatQuoteStatus(item.status)} +
- - {formatQuoteStatus(item.status)} - -
-
-
- Rep: - {(() => { - const rep = item.expand?.customer?.expand?.rep; - return rep?.name || rep?.email ? ( - {rep.name || rep.email} +
+
+ Rep: + {(() => { + const rep = item.expand?.customer?.expand?.rep; + return rep?.name || rep?.email ? ( + {rep.name || rep.email} + ) : ( + Unassigned + ); + })()} +
+
+ Claimed: + + {item.claimed_by ? userDisplayName(userById.get(item.claimed_by) ?? null) : "—"} + +
+
+
+ {formatDate(item.created)} +
+ {/* + * Process shortcut — mirrors the desk-top row's "Process" + * button. Lives at the bottom of the card so the existing + * (entire-card-clickable) Open detail-page gesture stays + * intact for the read-only review case. The button stops + * propagation so a tap doesn't double-fire as a card click. + */} + {canProcess && ( +
-
- Claimed: - - {item.claimed_by ? userDisplayName(userById.get(item.claimed_by) ?? null) : "—"} - -
-
-
- {formatDate(item.created)} -
- - - )} + <> + Process + + )} + + )} + + + ); + }} />