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 (
{item.quote_number}
- {isFollowUp(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 (
+ {item.quote_number}
+ {item.expand?.customer?.customer || "—"}
+ {item.expand.company.company}
+