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:
parent
7e8cdb2119
commit
72fede1891
1 changed files with 207 additions and 55 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue