Quotation Approval Page
Full-page ManpowerHub block for internal approval of quotations — PDF viewer area, approve/decline actions, optional note, and quotation summary sidebar.
"use client";
import { QuotationApprovalPage } from "@/registry/new-york/items/manpowerhub-quotation-approval/components/quotation-approval";
const QUOTATION = { id: "1", quotationNumber: "KAT/QTN/2026/05/041", customerRefId: "ANB-OPS-118", status: "sent" as const, customerName: "Al Naboodah Contracting", customerPhone: "+971 4 555 0188", proposalSubject: "Manpower supply for Al Quoz industrial expansion", scopeOfWork: "Supply site supervisors, heavy equipment operators, and general helpers for day-shift civil works at Al Quoz Industrial Site.", signatoryName: "Mohammed Nasser", signatoryTitle: "Commercial Manager", customDearSir: "Dear Sir", customWorkingHours: "Rates assume 10 duty hours per day, six days a week.", customOvertimeTerms: "Overtime will be billed only after written approval from the site manager.", issueDate: "2026-05-20", validUntil: "2026-06-19", items: [ { id: "a", category: "Site supervisor", quantity: 2, rateAed: 950, otRateAed: 95, sortOrder: 0, }, { id: "b", category: "Heavy equipment operator", quantity: 4, rateAed: 1050, otRateAed: 105, sortOrder: 1, }, { id: "c", category: "General helper", quantity: 16, rateAed: 290, otRateAed: 32, sortOrder: 2, }, ],};
export function ManpowerhubQuotationApprovalBasic() { return ( <QuotationApprovalPage quotation={QUOTATION} onBack={() => console.log("back")} onApprove={(id, note) => console.log("approve", id, note)} onDecline={(id, note) => console.log("decline", id, note)} /> );}Installation
Section titled “Installation”This installation provides support for all official Shadcn icon libraries. The component is otherwise identical to the non-experimental installation.
Icon support is experimental and may not be fully stable since it uses internal Shadcn APIs.
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
"use client";
import * as React from "react";import { ArrowLeft, CheckCircle2, FileText, XCircle } from "lucide-react";
import { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle,} from "@/registry/new-york/items/manpowerhub-page-header/components/page-header";import { StatRow, StatRowLabel, StatRowValue,} from "@/registry/new-york/items/manpowerhub-stat-row/components/stat-row";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";import { Textarea } from "@/components/ui/textarea";import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
type QuotationStatus = "draft" | "sent" | "accepted" | "expired" | "revised";
interface QuotationItem { id: number | string; category: string; quantity: number; rateAed: number; otRateAed: number; sortOrder?: number;}
interface Quotation { id: number | string; tenantId?: string; quotationNumber: string; customerRefId: string; status: QuotationStatus; customerName: string; customerEmail: string; customerPhone: string; proposalSubject: string; scopeOfWork: string; signatoryName: string; signatoryTitle: string; customDearSir?: string | null; customWorkingHours?: string | null; customOvertimeTerms?: string | null; issueDate: string; validUntil: string; items: QuotationItem[];}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const moneyFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2,});
const statusConfig: Record< QuotationStatus, { label: string; badgeClassName: string }> = { draft: { label: "Draft", badgeClassName: "border-[var(--color-border)] bg-transparent text-[var(--text-3)]", }, sent: { label: "Sent", badgeClassName: "border-[var(--amber-border)] bg-[var(--amber-bg)] text-[var(--amber)]", }, accepted: { label: "Accepted", badgeClassName: "border-[var(--brand-border)] bg-[var(--brand-subtle)] text-[var(--brand)]", }, expired: { label: "Expired", badgeClassName: "border-[var(--red-border)] bg-[var(--red-bg)] text-[var(--red)]", }, revised: { label: "Revised", badgeClassName: "border-[var(--blue-border)] bg-[var(--blue-bg)] text-[var(--blue)]", },};
function formatMoney(amount: number) { return `AED ${moneyFormatter.format(amount)}`;}
function quotationValue(quotation: Pick<Quotation, "items">) { return quotation.items.reduce( (sum, item) => sum + item.quantity * item.rateAed, 0, );}
function overtimeValue(quotation: Pick<Quotation, "items">) { return quotation.items.reduce( (sum, item) => sum + item.quantity * item.otRateAed, 0, );}
// ─── Sub-components ───────────────────────────────────────────────────────────
function SectionTitle({ children }: { children: React.ReactNode }) { return ( <p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.15em] text-[var(--brand)]"> {children} </p> );}
function SectionCard({ className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "rounded-[var(--r-lg)] border border-border bg-card p-5", className, )} {...props} /> );}
function StatusBadge({ status }: { status: QuotationStatus }) { const cfg = statusConfig[status]; return ( <Badge variant="outline" className={cn( "rounded-full px-2.5 py-0.5 text-[11.5px] font-medium", cfg.badgeClassName, )} > {cfg.label} </Badge> );}
// ─── Props ────────────────────────────────────────────────────────────────────
export interface QuotationApprovalPageProps extends React.ComponentProps<"div"> { quotation: Quotation; onBack?: () => void; onApprove: (id: string, note: string) => void | Promise<void>; onDecline: (id: string, note: string) => void | Promise<void>;}
// ─── Component ────────────────────────────────────────────────────────────────
function QuotationApprovalPage({ className, quotation, onBack, onApprove, onDecline, ...props}: QuotationApprovalPageProps) { const [note, setNote] = React.useState(""); const [isPending, setIsPending] = React.useState< "approve" | "decline" | null >(null);
const totalStandard = quotationValue(quotation); const totalOt = overtimeValue(quotation); const sortedItems = [...quotation.items].sort( (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), );
async function handleApprove() { setIsPending("approve"); try { await onApprove(String(quotation.id), note); } finally { setIsPending(null); } }
async function handleDecline() { setIsPending("decline"); try { await onDecline(String(quotation.id), note); } finally { setIsPending(null); } }
return ( <div className={cn("p-6", className)} {...props}> <PageHeader> <PageHeaderContent> <div className="flex items-center gap-2"> {onBack && ( <Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={onBack} > <ArrowLeft className="size-4" /> </Button> )} <div> <PageHeaderTitle className="flex items-center gap-2"> <FileText className="size-4 text-[var(--text-3)]" /> {quotation.quotationNumber} <StatusBadge status={quotation.status} /> </PageHeaderTitle> <PageHeaderDescription> {quotation.proposalSubject} </PageHeaderDescription> </div> </div> </PageHeaderContent> <PageHeaderActions> <Button variant="outline" size="sm" className="gap-1.5 border-[var(--red-border)] text-[var(--red)] hover:bg-[var(--red-bg)] hover:text-[var(--red)]" disabled={isPending !== null} onClick={handleDecline} > <XCircle className="size-3.5" /> {isPending === "decline" ? "Declining..." : "Decline"} </Button> <Button size="sm" className="gap-1.5" disabled={isPending !== null} onClick={handleApprove} > <CheckCircle2 className="size-3.5" /> {isPending === "approve" ? "Approving..." : "Approve"} </Button> </PageHeaderActions> </PageHeader>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3"> {/* Left column — PDF viewer area + approval note */} <div className="flex flex-col gap-4 lg:col-span-2"> {/* PDF viewer placeholder */} <SectionCard className="flex min-h-[520px] flex-col items-center justify-center gap-3 bg-[var(--surface-2)]"> <FileText className="size-10 text-[var(--text-3)]" /> <p className="text-[13px] font-medium text-foreground"> Quotation PDF </p> <p className="max-w-xs text-center text-[12px] text-[var(--text-3)]"> Render the quotation PDF here. Use an{" "} <code className="rounded bg-[var(--surface-3)] px-1 py-0.5 text-[11px]"> iframe </code>{" "} pointing to your PDF endpoint, or embed a document viewer. </p> </SectionCard>
{/* Approval note */} <SectionCard> <SectionTitle>Approval note</SectionTitle> <Textarea placeholder="Add an optional note for the submitter — visible on approve or decline…" value={note} onChange={(e) => setNote(e.target.value)} className="min-h-[90px] resize-none text-[13px]" /> <div className="mt-3 flex justify-end gap-2"> <Button variant="outline" size="sm" className="gap-1.5 border-[var(--red-border)] text-[var(--red)] hover:bg-[var(--red-bg)] hover:text-[var(--red)]" disabled={isPending !== null} onClick={handleDecline} > <XCircle className="size-3.5" /> {isPending === "decline" ? "Declining..." : "Decline"} </Button> <Button size="sm" className="gap-1.5" disabled={isPending !== null} onClick={handleApprove} > <CheckCircle2 className="size-3.5" /> {isPending === "approve" ? "Approving..." : "Approve"} </Button> </div> </SectionCard> </div>
{/* Right column — quotation summary */} <div className="flex flex-col gap-4"> {/* Customer details */} <SectionCard> <SectionTitle>Customer</SectionTitle> <div className="flex flex-col gap-2.5"> <StatRow> <StatRowLabel>Name</StatRowLabel> <StatRowValue>{quotation.customerName}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Ref ID</StatRowLabel> <StatRowValue className="font-mono text-[12px]"> {quotation.customerRefId} </StatRowValue> </StatRow> <StatRow> <StatRowLabel>Email</StatRowLabel> <StatRowValue>{quotation.customerEmail}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Phone</StatRowLabel> <StatRowValue>{quotation.customerPhone}</StatRowValue> </StatRow> </div> </SectionCard>
{/* Quotation details */} <SectionCard> <SectionTitle>Details</SectionTitle> <div className="flex flex-col gap-2.5"> <StatRow> <StatRowLabel>Issue date</StatRowLabel> <StatRowValue>{quotation.issueDate}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Valid until</StatRowLabel> <StatRowValue>{quotation.validUntil}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Signatory</StatRowLabel> <StatRowValue> {quotation.signatoryName} {quotation.signatoryTitle ? `, ${quotation.signatoryTitle}` : ""} </StatRowValue> </StatRow> </div> </SectionCard>
{/* Rate card summary */} <Card className="rounded-[var(--r-lg)]"> <CardHeader className="pb-2 pt-4"> <CardTitle className="text-[12px] font-semibold uppercase tracking-[0.15em] text-[var(--brand)]"> Rate card </CardTitle> </CardHeader> <CardContent className="pb-4"> <div className="flex flex-col gap-0.5"> {sortedItems.map((item) => ( <div key={item.id} className="flex items-center justify-between py-1.5" > <div className="flex flex-col"> <span className="text-[12.5px] text-foreground"> {item.category} </span> <span className="text-[11px] text-[var(--text-3)]"> {item.quantity} × AED {item.rateAed.toLocaleString()} </span> </div> <span className="text-[12.5px] font-medium text-foreground"> {formatMoney(item.quantity * item.rateAed)} </span> </div> ))} </div> <div className="mt-3 flex flex-col gap-1 border-t border-border pt-3"> <div className="flex items-center justify-between"> <span className="text-[12px] text-[var(--text-3)]"> Standard total </span> <span className="text-[13px] font-semibold text-foreground"> {formatMoney(totalStandard)} </span> </div> <div className="flex items-center justify-between"> <span className="text-[12px] text-[var(--text-3)]"> OT reference </span> <span className="text-[12px] text-[var(--text-3)]"> {formatMoney(totalOt)} </span> </div> </div> </CardContent> </Card>
{/* Scope of work */} {quotation.scopeOfWork && ( <SectionCard> <SectionTitle>Scope of work</SectionTitle> <p className="text-[12.5px] leading-relaxed text-[var(--text-2)]"> {quotation.scopeOfWork} </p> </SectionCard> )} </div> </div> </div> );}
export { QuotationApprovalPage };Update the import paths to match your project setup.
Install the ManpowerHub theme first, then add the block:
npx shadcn@latest add https://woxcn-registry.woxware.io/r/manpowerhub-quotation-approval.jsonThis installs the approval page block at components/blocks/manpowerhub-quotation-approval.tsx.
Wiring an approval route
Section titled “Wiring an approval route”QuotationApprovalPage renders a single quotation for review. Use it for /quotations/:id/approve.
import { QuotationApprovalPage } from "@/components/blocks/manpowerhub-quotation-approval";
export default function QuotationApprovalRoute({ params }) { const { data: quotation } = useQuotation(params.id);
return ( <QuotationApprovalPage quotation={quotation} onBack={() => router.push("/quotations")} onApprove={async (id, note) => { await api.quotations.approve(id, { note }); router.push("/quotations"); }} onDecline={async (id, note) => { await api.quotations.decline(id, { note }); router.push("/quotations"); }} /> );}Embedding the PDF
Section titled “Embedding the PDF”The PDF viewer area is a placeholder. Replace the placeholder with an iframe or document viewer:
// Inside your app, extend with a pdfUrl prop and render:<iframe src={`/api/quotations/${quotation.id}/pdf`} className="h-full w-full rounded" />quotation: required quotation record to render.onBack: optional handler for the Back button.onApprove(id, note): called with quotation ID and optional approver note on approve.onDecline(id, note): called with quotation ID and optional note on decline.
type QuotationStatus = "draft" | "sent" | "accepted" | "expired" | "revised";
interface QuotationItem { id: string; category: string; quantity: number; rateAed: number; otRateAed: number; sortOrder?: number;}
interface Quotation { id: string; quotationNumber: string; customerRefId: string; status: QuotationStatus; customerName: string; customerEmail: string; customerPhone: string; proposalSubject: string; scopeOfWork: string; signatoryName: string; signatoryTitle: string; issueDate: string; validUntil: string; items: QuotationItem[];}