Review queue
ProThe work waiting for a person, in groups with counts, with one item open and resolved items keeping a record of the decision.
npx prodular-ui add review-queuePart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Beside the detail
Pick an item, decide, and the queue moves it to Done and opens the next one.
Tidewater Freight, INV-2047
Onboarding billed $1,500 over the order
The invoice charges $6,000.00 for onboarding. Purchase order PO-1187 allows $4,500.00.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"import { ReviewQueue, ReviewQueueContent, ReviewQueueDescription, ReviewQueueFooter, ReviewQueueGroup, ReviewQueueHeader, ReviewQueueItem, ReviewQueueItemEyebrow, ReviewQueueItemMeta, ReviewQueueItemResolution, ReviewQueueItemTitle, ReviewQueueTitle,} from "@/components/ui/review-queue"
type Exception = { id: string; customer: string; title: string; age: string; detail: string }
const start: Exception[] = [ { id: "INV-2047", customer: "Tidewater Freight", title: "Onboarding billed $1,500 over the order", age: "2 h", detail: "The invoice charges $6,000.00 for onboarding. Purchase order PO-1187 allows $4,500.00." }, { id: "INV-2045", customer: "Marlow and Finch", title: "Card declined twice", age: "5 h", detail: "The payment failed on October 19 and 21. The billing contact has not replied." }, { id: "INV-2039", customer: "Halden Analytics", title: "Duplicate of INV-2031", age: "1 d", detail: "Same amount, date and purchase order as an invoice that is already paid." },]
export default function ReviewQueueExceptions() { const [activeId, setActiveId] = React.useState(start[0].id) const [resolved, setResolved] = React.useState<Record<string, string>>({}) const open = start.filter((e) => !resolved[e.id]) const done = start.filter((e) => resolved[e.id]) const active = start.find((e) => e.id === activeId) ?? start[0]
const resolve = () => { setResolved((r) => ({ ...r, [active.id]: "Approved by Maya Okafor, 11:37" })) const next = open.find((e) => e.id !== active.id) if (next) setActiveId(next.id) }
const item = (e: Exception, status: "held" | "resolved") => ( <ReviewQueueItem key={e.id} status={status} active={e.id === activeId} onClick={() => setActiveId(e.id)}> <ReviewQueueItemEyebrow>{e.customer}</ReviewQueueItemEyebrow> <ReviewQueueItemMeta>{e.age}</ReviewQueueItemMeta> <ReviewQueueItemTitle>{e.title}</ReviewQueueItemTitle> {resolved[e.id] && <ReviewQueueItemResolution>{resolved[e.id]}</ReviewQueueItemResolution>} </ReviewQueueItem> )
return ( <div className="grid h-[440px] w-full max-w-3xl grid-cols-[300px_minmax(0,1fr)] overflow-hidden rounded-xl border bg-card shadow-xs"> <div className="min-h-0 border-r"> <ReviewQueue aria-label="Invoice exceptions"> <ReviewQueueHeader> <ReviewQueueTitle>Invoice exceptions</ReviewQueueTitle> <ReviewQueueDescription>{open.length ? `${open.length} of ${start.length} left` : "All done"}</ReviewQueueDescription> </ReviewQueueHeader> <ReviewQueueContent> {open.length > 0 && ( <ReviewQueueGroup label="Waiting for you" count={open.length}> {open.map((e) => item(e, "held"))} </ReviewQueueGroup> )} {done.length > 0 && ( <ReviewQueueGroup label="Done" count={done.length}> {done.map((e) => item(e, "resolved"))} </ReviewQueueGroup> )} </ReviewQueueContent> <ReviewQueueFooter>Checked against the October billing rules</ReviewQueueFooter> </ReviewQueue> </div> <div className="flex min-w-0 flex-col gap-2 p-5"> <p className="m-0 text-[13px] text-muted-foreground tabular-nums"> {active.customer}, {active.id} </p> <h3 className="m-0 text-base font-semibold">{active.title}</h3> <p className="m-0 text-sm leading-relaxed text-muted-foreground">{active.detail}</p> <div className="mt-auto"> <Button size="sm" onClick={resolve} disabled={Boolean(resolved[active.id])}> Approve exception </Button> </div> </div> </div> )}States
Held for a decision and open, waiting its turn, and resolved with a record of who decided.
import { ReviewQueue, ReviewQueueContent, ReviewQueueGroup, ReviewQueueItem, ReviewQueueItemEyebrow, ReviewQueueItemMeta, ReviewQueueItemResolution, ReviewQueueItemTitle,} from "@/components/ui/review-queue"
export default function ReviewQueueStates() { return ( <div className="w-full max-w-sm rounded-xl border bg-card py-2 shadow-xs"> <ReviewQueue aria-label="Vendor changes"> <ReviewQueueContent> <ReviewQueueGroup label="Waiting for you" count={1}> <ReviewQueueItem status="held" active> <ReviewQueueItemEyebrow>Northwind Logistics</ReviewQueueItemEyebrow> <ReviewQueueItemMeta>31 min</ReviewQueueItemMeta> <ReviewQueueItemTitle>New bank details on the vendor record</ReviewQueueItemTitle> </ReviewQueueItem> </ReviewQueueGroup> <ReviewQueueGroup label="Next up" count={1}> <ReviewQueueItem status="open"> <ReviewQueueItemEyebrow>Corvid Labs</ReviewQueueItemEyebrow> <ReviewQueueItemMeta>2 h</ReviewQueueItemMeta> <ReviewQueueItemTitle>Payment terms moved to net 60</ReviewQueueItemTitle> </ReviewQueueItem> </ReviewQueueGroup> <ReviewQueueGroup label="Done" count={1}> <ReviewQueueItem status="resolved"> <ReviewQueueItemEyebrow>Pinecrest Health</ReviewQueueItemEyebrow> <ReviewQueueItemMeta>Yesterday</ReviewQueueItemMeta> <ReviewQueueItemTitle>Tax ID updated</ReviewQueueItemTitle> <ReviewQueueItemResolution>Confirmed by Jonas Weber, 16:09</ReviewQueueItemResolution> </ReviewQueueItem> </ReviewQueueGroup> </ReviewQueueContent> </ReviewQueue> </div> )}Anatomy
<ReviewQueue aria-label="Invoice exceptions"> <ReviewQueueHeader> <ReviewQueueTitle>Invoice exceptions</ReviewQueueTitle> <ReviewQueueDescription>3 of 12 left</ReviewQueueDescription> </ReviewQueueHeader> <ReviewQueueContent> <ReviewQueueGroup label="Waiting for you" count={3}> <ReviewQueueItem status="held" active> <ReviewQueueItemEyebrow>Tidewater Freight</ReviewQueueItemEyebrow> <ReviewQueueItemMeta>2 h</ReviewQueueItemMeta> <ReviewQueueItemTitle>Billed over the order</ReviewQueueItemTitle> </ReviewQueueItem> </ReviewQueueGroup> </ReviewQueueContent></ReviewQueue>Guidance
Use it when
- Use it wherever an agent or a rule hands work to a person: flagged clauses, invoice exceptions, updates to confirm.
- Group by what the item needs from the reader, such as Waiting for you and Done, and pass count.
- Say what is left in ReviewQueueDescription, such as 3 of 12 left.
- Keep resolved items in the queue with ReviewQueueItemResolution, so people can see what was decided.
Avoid
- Ordinary navigation. Use Sidebar or Tree.
- Long lists to sort and filter. Use DataTable.
- Removing an item as soon as it is decided. The record is part of the review.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Set status on every ReviewQueueItem: held when it is stopped for a decision, open when it waits its turn, resolved when it is done.
- Set active on the one item shown in the detail. It sets aria-current. Move active to the next open item after a decision.
- Give ReviewQueue an aria-label, because it renders a nav landmark.
- Render items as links with render={<a href="…" />} when each has its own URL; the default is a button.
- Put it in a panel or a MasterDetail list. Do not add a card around each item.
API
ReviewQueueGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | Required. What this group needs from the reader. | |
| count | number | How many items are in the group. |
ReviewQueueItem
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "open" | "held" | "resolved" | "open" | held shows a dot, resolved quiets the title. |
| active | boolean | false | The item open in the detail. Sets aria-current. |
| render | ReactElement | The element to render, for example a link. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add review-queue# or, with the registry in components.jsonnpx shadcn@latest add @prodular/review-queue