Quotation View Page
Read-only ManpowerHub quotation detail page block for route-based quotation review, PDF, and status actions.
"use client";
import { QuotationViewPage } from "@/registry/new-york/items/manpowerhub-quotation-view/components/quotation-view";
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 ManpowerhubQuotationViewBasic() { return ( <div className="h-[700px] overflow-y-auto rounded-lg border border-border bg-background p-4"> <QuotationViewPage className="p-0" quotation={QUOTATION} onBack={() => undefined} onEditQuotation={() => undefined} onDownloadQuotation={() => undefined} onSendQuotation={(id, values) => console.log("send", id, values)} onStatusChangeQuotation={() => undefined} /> </div> );}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, Download, FileText, Pencil, RefreshCw, Send,} from "lucide-react";
import { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle,} from "@/registry/new-york/items/manpowerhub-page-header/components/page-header";import { SectionHeader, SectionHeaderDescription, SectionHeaderTitle,} from "@/registry/new-york/items/manpowerhub-section-header/components/section-header";import { StatRow, StatRowLabel, StatRowValue,} from "@/registry/new-york/items/manpowerhub-stat-row/components/stat-row";import { SendQuotationModal, type SendQuotationValues,} from "@/registry/new-york/items/manpowerhub-send-quotation-modal/components/send-quotation-modal";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";import { cn } from "@/lib/utils";
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[];}
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, );}
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> );}
export interface QuotationViewPageProps extends React.ComponentProps<"div"> { quotation: Quotation; onBack?: () => void; onEditQuotation?: (quotation: Quotation) => void; onDownloadQuotation?: (quotation: Quotation) => void; onSendQuotation?: ( id: string, values: SendQuotationValues, ) => void | Promise<void>; onStatusChangeQuotation?: ( id: string, status: QuotationStatus, ) => void | Promise<void>;}
function QuotationViewPage({ className, quotation, onBack, onEditQuotation, onDownloadQuotation, onSendQuotation, onStatusChangeQuotation, ...props}: QuotationViewPageProps) { const [sendModalOpen, setSendModalOpen] = React.useState(false); const standardTotal = quotationValue(quotation); const otReferenceTotal = overtimeValue(quotation);
return ( <div className={cn("p-6", className)} {...props}> <PageHeader> <PageHeaderContent> <div className="mb-2 flex items-center gap-2"> {onBack && ( <Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2 text-[12px]" onClick={onBack} > <ArrowLeft className="size-3.5" /> Back </Button> )} <StatusBadge status={quotation.status} /> </div> <PageHeaderTitle>{quotation.quotationNumber}</PageHeaderTitle> <PageHeaderDescription> {quotation.customerName} - {quotation.proposalSubject} </PageHeaderDescription> </PageHeaderContent> <PageHeaderActions> {onSendQuotation && ( <Button variant="outline" size="sm" className="gap-1.5" onClick={() => setSendModalOpen(true)} > <Send className="size-3.5" /> Send quotation </Button> )} {onStatusChangeQuotation && ( <Button variant="outline" size="sm" className="gap-1.5" onClick={() => onStatusChangeQuotation(String(quotation.id), "revised") } > <RefreshCw className="size-3.5" /> Revise </Button> )} {onDownloadQuotation && ( <Button variant="outline" size="sm" className="gap-1.5" onClick={() => onDownloadQuotation(quotation)} > <Download className="size-3.5" /> Download PDF </Button> )} {onEditQuotation && ( <Button size="sm" className="gap-1.5" onClick={() => onEditQuotation(quotation)} > <Pencil className="size-3.5" /> Edit </Button> )} </PageHeaderActions> </PageHeader>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]"> <Card className="overflow-hidden"> <CardHeader className="items-start"> <div> <CardTitle className="text-[14px]"> <FileText className="size-4 text-[var(--brand)]" /> Commercial quotation </CardTitle> <p className="mt-1 text-[12px] text-[var(--text-3)]"> Issue date {quotation.issueDate} - Valid until{" "} {quotation.validUntil} </p> </div> <div className="text-right"> <p className="font-mono text-[12px] text-[var(--text-3)]"> {quotation.customerRefId} </p> <p className="text-[12px] font-semibold text-foreground"> {formatMoney(standardTotal)} </p> </div> </CardHeader> <CardContent className="p-0"> <div className="border-b border-border px-6 py-5"> <div className="grid gap-4 md:grid-cols-[1.1fr_0.9fr]"> <div> <p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--text-3)]"> Quotation To </p> <p className="text-[15px] font-semibold text-foreground"> {quotation.customerName} </p> <p className="mt-1 text-[12.5px] text-[var(--text-2)]"> {quotation.customerEmail} </p> <p className="text-[12.5px] text-[var(--text-2)]"> {quotation.customerPhone} </p> </div> <div className="rounded-[var(--r-md)] border border-[var(--color-border-subtle)] bg-muted/30 p-3"> <StatRow> <StatRowLabel>Quote number</StatRowLabel> <StatRowValue className="font-mono"> {quotation.quotationNumber} </StatRowValue> </StatRow> <StatRow> <StatRowLabel>Status</StatRowLabel> <StatRowValue> {statusConfig[quotation.status].label} </StatRowValue> </StatRow> <StatRow> <StatRowLabel>Validity</StatRowLabel> <StatRowValue>{quotation.validUntil}</StatRowValue> </StatRow> </div> </div> </div>
<div className="grid gap-6 px-6 py-5"> <section> <SectionHeader> <SectionHeaderTitle> {quotation.proposalSubject} </SectionHeaderTitle> <SectionHeaderDescription> Proposal subject and manpower scope </SectionHeaderDescription> </SectionHeader> <p className="rounded-[var(--r-md)] border border-[var(--color-border-subtle)] bg-muted/20 p-3 text-[13px] leading-6 text-[var(--text-2)]"> {quotation.scopeOfWork} </p> </section>
<section> <SectionHeader> <SectionHeaderTitle>Rate card</SectionHeaderTitle> <SectionHeaderDescription> Worker categories, standard rates, and overtime references </SectionHeaderDescription> </SectionHeader> <div className="overflow-hidden rounded-[var(--r-md)] border border-border"> <table className="w-full text-[12.5px]"> <thead> <tr className="border-b border-border bg-muted/50"> <th className="px-3 py-2 text-left font-medium text-[var(--text-3)]"> Category </th> <th className="w-20 px-3 py-2 text-right font-medium text-[var(--text-3)]"> Qty </th> <th className="w-32 px-3 py-2 text-right font-medium text-[var(--text-3)]"> Rate </th> <th className="w-32 px-3 py-2 text-right font-medium text-[var(--text-3)]"> OT </th> <th className="w-36 px-3 py-2 text-right font-medium text-[var(--text-3)]"> Value </th> </tr> </thead> <tbody> {quotation.items.map((item) => ( <tr key={item.id} className="border-b border-border last:border-b-0" > <td className="px-3 py-2 font-medium text-foreground"> {item.category} </td> <td className="px-3 py-2 text-right text-[var(--text-2)]"> {item.quantity} </td> <td className="px-3 py-2 text-right text-[var(--text-2)]"> {formatMoney(item.rateAed)} </td> <td className="px-3 py-2 text-right text-[var(--text-2)]"> {formatMoney(item.otRateAed)} </td> <td className="px-3 py-2 text-right font-medium text-foreground"> {formatMoney(item.quantity * item.rateAed)} </td> </tr> ))} </tbody> <tfoot> <tr className="border-t border-border bg-muted/40"> <td colSpan={4} className="px-3 py-2 text-right font-semibold text-foreground" > Standard total </td> <td className="px-3 py-2 text-right font-semibold text-[var(--brand)]"> {formatMoney(standardTotal)} </td> </tr> </tfoot> </table> </div> </section>
<section className="grid gap-3 md:grid-cols-2"> <div className="rounded-[var(--r-md)] border border-[var(--color-border-subtle)] p-3"> <p className="mb-1 text-[12px] font-semibold text-foreground"> Working hours </p> <p className="text-[12.5px] leading-5 text-[var(--text-2)]"> {quotation.customWorkingHours || "Standard working hours apply as agreed in the quotation."} </p> </div> <div className="rounded-[var(--r-md)] border border-[var(--color-border-subtle)] p-3"> <p className="mb-1 text-[12px] font-semibold text-foreground"> Overtime terms </p> <p className="text-[12.5px] leading-5 text-[var(--text-2)]"> {quotation.customOvertimeTerms || "Overtime is billed only after written client approval."} </p> </div> </section>
<section className="flex items-end justify-between rounded-[var(--r-md)] border border-[var(--color-border-subtle)] bg-muted/20 p-4"> <div> <p className="text-[12px] text-[var(--text-3)]"> Authorized signatory </p> <p className="mt-4 text-[14px] font-semibold text-foreground"> {quotation.signatoryName} </p> <p className="text-[12px] text-[var(--text-3)]"> {quotation.signatoryTitle} </p> </div> <p className="text-right text-[11px] uppercase tracking-[0.18em] text-[var(--text-3)]"> {quotation.customDearSir || "Dear Sir / Madam"} </p> </section> </div> </CardContent> </Card>
<div className="grid gap-4 content-start"> <Card> <CardHeader> <CardTitle>Summary</CardTitle> </CardHeader> <CardContent> <StatRow> <StatRowLabel>Customer ref</StatRowLabel> <StatRowValue>{quotation.customerRefId}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Issue date</StatRowLabel> <StatRowValue>{quotation.issueDate}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Valid until</StatRowLabel> <StatRowValue>{quotation.validUntil}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Categories</StatRowLabel> <StatRowValue>{quotation.items.length}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>OT reference</StatRowLabel> <StatRowValue>{formatMoney(otReferenceTotal)}</StatRowValue> </StatRow> </CardContent> </Card>
<Card> <CardHeader> <CardTitle>Contact</CardTitle> </CardHeader> <CardContent> <StatRow> <StatRowLabel>Name</StatRowLabel> <StatRowValue>{quotation.customerName}</StatRowValue> </StatRow> <StatRow> <StatRowLabel>Email</StatRowLabel> <StatRowValue className="text-right font-normal"> {quotation.customerEmail} </StatRowValue> </StatRow> <StatRow> <StatRowLabel>Phone</StatRowLabel> <StatRowValue>{quotation.customerPhone}</StatRowValue> </StatRow> </CardContent> </Card> </div> </div>
{onSendQuotation && ( <SendQuotationModal open={sendModalOpen} onOpenChange={setSendModalOpen} quotationNumber={quotation.quotationNumber} initialSubject={`Quotation ${quotation.quotationNumber} — ${quotation.proposalSubject}`} onSend={(values) => onSendQuotation(String(quotation.id), values)} /> )} </div> );}
export { QuotationViewPage };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-view.jsonThis installs the read-only quotation route block at components/blocks/manpowerhub-quotation-view.tsx.
Wiring a detail route
Section titled “Wiring a detail route”QuotationViewPage renders one quotation record. Use it for /quotations/:id and keep create/edit in dedicated app routes.
import { QuotationViewPage } from "@/components/blocks/manpowerhub-quotation-view";
export default function QuotationViewRoute({ params }) { const { data: quotation, mutate } = useQuotation(params.id);
return ( <QuotationViewPage quotation={quotation} onBack={() => router.push("/quotations")} onEditQuotation={(quotation) => router.push(`/quotations/${quotation.id}/edit`) } onDownloadQuotation={(quotation) => window.open(`/api/quotations/${quotation.id}/pdf`) } onStatusChangeQuotation={async (id, status) => { await api.quotations.update(id, { status }); mutate(); }} /> );}quotation: required quotation record to render.onBack: optional handler for the Back action.onEditQuotation: optional handler for the Edit action.onDownloadQuotation: optional handler for Download PDF.onStatusChangeQuotation: optional handler for Send/Revise status actions.
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; customDearSir?: string | null; customWorkingHours?: string | null; customOvertimeTerms?: string | null; issueDate: string; validUntil: string; items: QuotationItem[];}