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
This commit is contained in:
SJCoetz 2026-07-27 10:43:24 +02:00
parent 7e8cdb2119
commit 72fede1891

View file

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
@ -526,8 +591,55 @@ export default function QuotesPage() {
</Tooltip>
);
}
// 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 (
<div className="flex items-center justify-end gap-1">
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
void handleProcess(item);
}}
disabled={isLoading}
aria-label={`Process quote ${item.quote_number}`}
data-testid={`row-process-${item.id}`}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
Process <ArrowRight className="ml-1 h-3 w-3" />
</>
)}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
router.push(`/quotes/${item.id}`);
}}
aria-label={`Open quote ${item.quote_number}`}
data-testid={`row-open-${item.id}`}
disabled={isLoading}
>
Open
</Button>
</div>
);
}
// Other statuses — fallback to the detail page.
return (
<Button
variant="outline"
@ -539,6 +651,7 @@ export default function QuotesPage() {
}}
aria-label={`Open quote ${item.quote_number}`}
data-testid={`row-open-${item.id}`}
disabled={isLoading}
>
Open <ArrowRight className="ml-1 h-3 w-3" />
</Button>
@ -611,59 +724,98 @@ export default function QuotesPage() {
}
return Object.keys(style).length > 0 ? style : undefined;
}}
mobileCardRender={(item) => (
<Card className="cursor-pointer border-0 shadow-none" onClick={() => router.push(`/quotes/${item.id}`)}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{item.quote_number}</p>
{isFollowUp(item) && <FollowUpChip due={item.follow_up_due} />}
{hasCustomerReply(item) && <RepliedChip repliedAt={item.last_reply_at} />}
{isAged(item) && <AgedBadge created={item.created} />}
</div>
<div className="leading-tight">
<p className="text-sm font-medium truncate">
{item.expand?.customer?.customer || "—"}
</p>
{item.expand?.company?.company ? (
<p className="text-xs text-muted-foreground truncate">
{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 (
<Card className="cursor-pointer border-0 shadow-none" onClick={() => router.push(`/quotes/${item.id}`)}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{item.quote_number}</p>
{isFollowUp(item) && <FollowUpChip due={item.follow_up_due} />}
{hasCustomerReply(item) && <RepliedChip repliedAt={item.last_reply_at} />}
{isAged(item) && <AgedBadge created={item.created} />}
</div>
<div className="leading-tight">
<p className="text-sm font-medium truncate">
{item.expand?.customer?.customer || "—"}
</p>
) : null}
{item.expand?.company?.company ? (
<p className="text-xs text-muted-foreground truncate">
{item.expand.company.company}
</p>
) : null}
</div>
</div>
</div>
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
</div>
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
</div>
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<div className="flex justify-between gap-2 min-w-0">
<span className="text-muted-foreground shrink-0">Rep:</span>
{(() => {
const rep = item.expand?.customer?.expand?.rep;
return rep?.name || rep?.email ? (
<span className="font-medium truncate">{rep.name || rep.email}</span>
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<div className="flex justify-between gap-2 min-w-0">
<span className="text-muted-foreground shrink-0">Rep:</span>
{(() => {
const rep = item.expand?.customer?.expand?.rep;
return rep?.name || rep?.email ? (
<span className="font-medium truncate">{rep.name || rep.email}</span>
) : (
<span className="text-xs text-muted-foreground truncate">Unassigned</span>
);
})()}
</div>
<div className="flex justify-between gap-2 min-w-0">
<span className="text-muted-foreground shrink-0">Claimed:</span>
<span className="font-medium truncate">
{item.claimed_by ? userDisplayName(userById.get(item.claimed_by) ?? null) : "—"}
</span>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{formatDate(item.created)}
</div>
{/*
* 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 && (
<Button
variant="default"
size="sm"
className="mt-3 w-full"
onClick={(e) => {
e.stopPropagation();
void handleProcess(item);
}}
disabled={isLoading}
aria-label={`Process quote ${item.quote_number}`}
data-testid={`mobile-row-process-${item.id}`}
>
{isLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
Processing…
</>
) : (
<span className="text-xs text-muted-foreground truncate">Unassigned</span>
);
})()}
</div>
<div className="flex justify-between gap-2 min-w-0">
<span className="text-muted-foreground shrink-0">Claimed:</span>
<span className="font-medium truncate">
{item.claimed_by ? userDisplayName(userById.get(item.claimed_by) ?? null) : "—"}
</span>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{formatDate(item.created)}
</div>
</CardContent>
</Card>
)}
<>
Process <ArrowRight className="ml-1 h-3.5 w-3.5" />
</>
)}
</Button>
)}
</CardContent>
</Card>
);
}}
/>
<QuoteForm open={formOpen} onOpenChange={setFormOpen} onSaved={fetchQuotes} />