Quotation Edit
Full-page create/edit block for ManpowerHub quotations with inline rate card table and live summary card.
"use client";
import * as React from "react";
import { QuotationEditPage } from "@/registry/new-york/items/manpowerhub-quotation-edit/components/quotation-edit";
const SEED_VALUES = { quotationNumber: "KAT/QTN/2026/05/042", customerRefId: "ACME-OPS-201", customerName: "ACME Corporation", customerPhone: "+971 50 123 4567", proposalSubject: "Provision of security manpower for ACME Logistics Hub", scopeOfWork: "Supply of trained security guards and supervisors for 24/7 coverage across 3 entry points at the Al Quoz logistics facility.", signatoryName: "Mohammed Nasser", signatoryTitle: "Commercial Manager", customDearSir: "", customWorkingHours: "Rates assume 10 duty hours per day, six days a week.", customOvertimeTerms: "Overtime billed only after written approval from the site manager.", issueDate: "2026-06-01", validUntil: "2026-07-31", items: [ { id: "a1", category: "Security Guard", quantity: 10, rateAed: 1200, otRateAed: 150, sortOrder: 0, }, { id: "a2", category: "Supervisor", quantity: 2, rateAed: 2500, otRateAed: 312.5, sortOrder: 1, }, ],};
export function ManpowerhubQuotationEditBasic() { return ( <div className="h-[800px] overflow-y-auto rounded-lg border border-border bg-background"> <QuotationEditPage className="p-4" mode="edit" initialValues={SEED_VALUES} onBack={() => undefined} onSaveDraft={(values) => { console.log("save draft", values); }} onSaveAndSend={(values) => { console.log("save and send", values); }} /> </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.
components/blocks/manpowerhub-quotation-edit.tsx
"use client";
import * as React from "react";import { ArrowLeft, Plus, X } from "lucide-react";
import { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle,} from "@/registry/new-york/items/manpowerhub-page-header/components/page-header";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";import { Field, FieldError, FieldLabel,} from "@/registry/new-york/items/manpowerhub-field/components/field";import { Input } from "@/components/ui/input";import { Textarea } from "@/components/ui/textarea";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface QuotationItemFormValues { id: string; quotationItemId?: number; category: string; quantity: number; rateAed: number; otRateAed: number; sortOrder?: number;}
export interface QuotationFormValues { quotationNumber: string; tenantId?: string; customerRefId: string; customerName: string; customerEmail: string; customerPhone: string; proposalSubject: string; scopeOfWork: string; signatoryName: string; signatoryTitle: string; customDearSir?: string; customWorkingHours?: string; customOvertimeTerms?: string; issueDate: string; validUntil: string; items: QuotationItemFormValues[];}
export interface QuotationEditPageProps extends React.ComponentProps<"div"> { mode: "create" | "edit"; initialValues?: Partial<QuotationFormValues>; onBack?: () => void; onSaveDraft: (values: QuotationFormValues) => void | Promise<void>; onSaveAndSend: (values: QuotationFormValues) => void | Promise<void>;}
// ─── Default values ───────────────────────────────────────────────────────────
const DEFAULT_FORM: QuotationFormValues = { quotationNumber: "", customerRefId: "", customerName: "", customerEmail: "", customerPhone: "", proposalSubject: "", scopeOfWork: "", signatoryName: "", signatoryTitle: "", customDearSir: "", customWorkingHours: "", customOvertimeTerms: "", issueDate: "", validUntil: "", items: [],};
// ─── Component ────────────────────────────────────────────────────────────────
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 newItemRow(): QuotationItemFormValues { return { id: crypto.randomUUID(), category: "", quantity: 0, rateAed: 0, otRateAed: 0, sortOrder: 0, };}
const TABLE_CELL_CLS = "h-7 rounded-[var(--r-sm)] border border-[var(--color-border-subtle)] bg-[var(--surface-2)] px-2 text-[12px] text-foreground focus:border-[var(--brand)] focus:outline-none focus:ring-0 w-full";
const TABLE_DRAFT_CELL_CLS = "h-7 rounded-[var(--r-sm)] border border-[var(--brand-border)] bg-[var(--brand-subtle)]/30 px-2 text-[12px] text-foreground focus:outline-none focus:ring-0 w-full placeholder:text-[var(--text-3)]";
const moneyFmt = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2,});
function QuotationEditPage({ className, mode, initialValues, onBack, onSaveDraft, onSaveAndSend, ...props}: QuotationEditPageProps) { const [form, setForm] = React.useState<QuotationFormValues>({ ...DEFAULT_FORM, ...initialValues, }); const [isSaving, setIsSaving] = React.useState(false);
const [errors, setErrors] = React.useState< Partial<Record<keyof QuotationFormValues, string>> >({});
function setField<K extends keyof QuotationFormValues>( key: K, value: QuotationFormValues[K], ) { setForm((prev) => ({ ...prev, [key]: value })); if (errors[key]) setErrors((prev) => ({ ...prev, [key]: undefined })); }
const [draftRow, setDraftRow] = React.useState<QuotationItemFormValues>(newItemRow); const lastCellRef = React.useRef<HTMLInputElement>(null);
const standardTotal = form.items.reduce( (sum, item) => sum + item.quantity * item.rateAed, 0, ); const otTotal = form.items.reduce( (sum, item) => sum + item.quantity * item.otRateAed, 0, );
function validate(): boolean { const next: Partial<Record<keyof QuotationFormValues, string>> = {}; if (!form.customerName.trim()) next.customerName = "Required"; if (!form.customerRefId.trim()) next.customerRefId = "Required"; if (!form.customerEmail.trim()) next.customerEmail = "Required"; if (!form.customerPhone.trim()) next.customerPhone = "Required"; if (!form.proposalSubject.trim()) next.proposalSubject = "Required"; if (!form.scopeOfWork.trim()) next.scopeOfWork = "Required"; if (!form.issueDate) next.issueDate = "Required"; if (!form.validUntil) next.validUntil = "Required"; if (!form.signatoryName.trim()) next.signatoryName = "Required"; if (!form.signatoryTitle.trim()) next.signatoryTitle = "Required"; const hasValidItem = form.items.some( (item) => item.category.trim() && item.quantity > 0 && item.rateAed > 0, ); if (!hasValidItem) next.items = "Add at least one category with quantity and rate."; setErrors(next); return Object.keys(next).length === 0; }
function commitDraftRow() { if ( !draftRow.category.trim() && draftRow.quantity === 0 && draftRow.rateAed === 0 ) return; setForm((prev) => ({ ...prev, items: [...prev.items, { ...draftRow, sortOrder: prev.items.length }], })); setDraftRow(newItemRow()); }
function removeItem(id: string) { setForm((prev) => ({ ...prev, items: prev.items.filter((item) => item.id !== id), })); }
function updateItem(id: string, patch: Partial<QuotationItemFormValues>) { setForm((prev) => ({ ...prev, items: prev.items.map((item) => item.id === id ? { ...item, ...patch } : item, ), })); }
async function handleSaveDraft() { if (!validate()) return; setIsSaving(true); try { await onSaveDraft(form); } finally { setIsSaving(false); } }
async function handleSaveAndSend() { if (!validate()) return; setIsSaving(true); try { await onSaveAndSend(form); } finally { setIsSaving(false); } }
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> )} <Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11.5px] font-medium border-[var(--color-border)] bg-transparent text-[var(--text-3)]" > Draft </Badge> </div> <PageHeaderTitle> {mode === "create" ? "New Quotation" : form.quotationNumber || "Edit Quotation"} </PageHeaderTitle> <PageHeaderDescription> {mode === "create" ? "Fill in customer details, rate card, and terms" : "Update the quotation details below"} </PageHeaderDescription> </PageHeaderContent> <PageHeaderActions> <Button variant="outline" size="sm" className="gap-1.5" disabled={isSaving} onClick={handleSaveDraft} > Save draft </Button> <Button size="sm" className="gap-1.5" disabled={isSaving} onClick={handleSaveAndSend} > Save & send </Button> </PageHeaderActions> </PageHeader>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_260px]"> {/* Left: form sections */} <div className="flex flex-col gap-6"> {/* Customer Details */} <SectionCard> <SectionTitle>Customer Details</SectionTitle> <div className="grid gap-4 sm:grid-cols-2"> <Field> <FieldLabel htmlFor="customerName">Customer name</FieldLabel> <Input id="customerName" value={form.customerName} onChange={(e) => setField("customerName", e.target.value)} placeholder="ACME Corporation" className="h-8 text-[12.5px]" /> {errors.customerName && ( <FieldError>{errors.customerName}</FieldError> )} </Field> <Field> <FieldLabel htmlFor="customerRefId">Customer ref ID</FieldLabel> <Input id="customerRefId" value={form.customerRefId} onChange={(e) => setField("customerRefId", e.target.value)} placeholder="ACME-001" className="h-8 text-[12.5px]" /> {errors.customerRefId && ( <FieldError>{errors.customerRefId}</FieldError> )} </Field> <Field> <FieldLabel htmlFor="customerEmail">Email</FieldLabel> <Input id="customerEmail" type="email" value={form.customerEmail} onChange={(e) => setField("customerEmail", e.target.value)} className="h-8 text-[12.5px]" /> {errors.customerEmail && ( <FieldError>{errors.customerEmail}</FieldError> )} </Field> <Field> <FieldLabel htmlFor="customerPhone">Phone</FieldLabel> <Input id="customerPhone" value={form.customerPhone} onChange={(e) => setField("customerPhone", e.target.value)} placeholder="+971 50 123 4567" className="h-8 text-[12.5px]" /> {errors.customerPhone && ( <FieldError>{errors.customerPhone}</FieldError> )} </Field> </div> </SectionCard>
{/* Proposal */} <SectionCard> <SectionTitle>Proposal</SectionTitle> <div className="flex flex-col gap-4"> <Field> <FieldLabel htmlFor="proposalSubject"> Proposal subject </FieldLabel> <Input id="proposalSubject" value={form.proposalSubject} onChange={(e) => setField("proposalSubject", e.target.value)} placeholder="Provision of security manpower for..." className="h-8 text-[12.5px]" /> {errors.proposalSubject && ( <FieldError>{errors.proposalSubject}</FieldError> )} </Field> <Field> <FieldLabel htmlFor="scopeOfWork">Scope of work</FieldLabel> <Textarea id="scopeOfWork" value={form.scopeOfWork} onChange={(e) => setField("scopeOfWork", e.target.value)} placeholder="Supply trained security guards and supervisors for..." className="min-h-[72px] text-[12.5px] leading-relaxed" /> {errors.scopeOfWork && ( <FieldError>{errors.scopeOfWork}</FieldError> )} </Field> </div> </SectionCard>
{/* Rate Card */} <SectionCard> <SectionTitle>Rate Card</SectionTitle> {errors.items && ( <p className="mb-3 text-[11.5px] text-[var(--red)]"> {errors.items} </p> )} <div className="overflow-hidden rounded-[var(--r-md)] border border-border"> <div className="grid grid-cols-[1fr_52px_96px_96px_28px] gap-1.5 border-b border-border bg-muted/50 px-3 py-2"> {["Category", "Qty", "Rate AED", "OT AED", ""].map((h) => ( <span key={h} className="text-[11px] font-medium text-[var(--text-3)]" > {h} </span> ))} </div> {form.items.map((item) => ( <div key={item.id} className="grid grid-cols-[1fr_52px_96px_96px_28px] items-center gap-1.5 border-b border-border px-3 py-1.5" > <input className={TABLE_CELL_CLS} value={item.category} onChange={(e) => updateItem(item.id, { category: e.target.value }) } /> <input className={TABLE_CELL_CLS} type="number" min={0} value={item.quantity || ""} onChange={(e) => updateItem(item.id, { quantity: Number(e.target.value) }) } /> <input className={TABLE_CELL_CLS} type="number" min={0} step="0.01" value={item.rateAed || ""} onChange={(e) => updateItem(item.id, { rateAed: Number(e.target.value) }) } /> <input className={TABLE_CELL_CLS} type="number" min={0} step="0.01" value={item.otRateAed || ""} onChange={(e) => updateItem(item.id, { otRateAed: Number(e.target.value), }) } /> <button type="button" onClick={() => removeItem(item.id)} className="flex size-6 items-center justify-center rounded text-[var(--text-3)] hover:bg-[var(--red-bg)] hover:text-[var(--red)]" > <X className="size-3" /> </button> </div> ))} <div className="grid grid-cols-[1fr_52px_96px_96px_28px] items-center gap-1.5 border-b border-border bg-[var(--brand-subtle)]/10 px-3 py-1.5"> <input className={TABLE_DRAFT_CELL_CLS} placeholder="Category name..." value={draftRow.category} onChange={(e) => setDraftRow((prev) => ({ ...prev, category: e.target.value, })) } /> <input className={TABLE_DRAFT_CELL_CLS} type="number" min={0} placeholder="0" value={draftRow.quantity || ""} onChange={(e) => setDraftRow((prev) => ({ ...prev, quantity: Number(e.target.value), })) } /> <input className={TABLE_DRAFT_CELL_CLS} type="number" min={0} step="0.01" placeholder="0.00" value={draftRow.rateAed || ""} onChange={(e) => setDraftRow((prev) => ({ ...prev, rateAed: Number(e.target.value), })) } /> <input ref={lastCellRef} className={TABLE_DRAFT_CELL_CLS} type="number" min={0} step="0.01" placeholder="0.00" value={draftRow.otRateAed || ""} onChange={(e) => setDraftRow((prev) => ({ ...prev, otRateAed: Number(e.target.value), })) } onKeyDown={(e) => { if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); commitDraftRow(); } }} onBlur={commitDraftRow} /> <div className="size-6" /> </div> <div className="flex items-center justify-between px-3 py-2"> <button type="button" className="flex items-center gap-1 text-[12px] text-[var(--brand)] hover:underline" onClick={() => lastCellRef.current?.focus()} > <Plus className="size-3" /> Add category </button> <span className="text-[12px] text-[var(--text-3)]"> Standard total:{" "} <span className="font-semibold text-[var(--brand)]"> AED {moneyFmt.format(standardTotal)} </span> </span> </div> </div> </SectionCard> {/* Dates & Signatory */} <SectionCard> <div className="grid gap-6 sm:grid-cols-2"> <div> <SectionTitle>Dates</SectionTitle> <div className="flex flex-col gap-4"> <Field> <FieldLabel htmlFor="issueDate">Issue date</FieldLabel> <Input id="issueDate" type="date" value={form.issueDate} onChange={(e) => setField("issueDate", e.target.value)} className="h-8 text-[12.5px]" /> {errors.issueDate && ( <FieldError>{errors.issueDate}</FieldError> )} </Field> <Field> <FieldLabel htmlFor="validUntil">Valid until</FieldLabel> <Input id="validUntil" type="date" value={form.validUntil} onChange={(e) => setField("validUntil", e.target.value)} className="h-8 text-[12.5px]" /> {errors.validUntil && ( <FieldError>{errors.validUntil}</FieldError> )} </Field> </div> </div> <div> <SectionTitle>Signatory</SectionTitle> <div className="flex flex-col gap-4"> <Field> <FieldLabel htmlFor="signatoryName">Name</FieldLabel> <Input id="signatoryName" value={form.signatoryName} onChange={(e) => setField("signatoryName", e.target.value) } placeholder="Ahmed Al Mansoori" className="h-8 text-[12.5px]" /> {errors.signatoryName && ( <FieldError>{errors.signatoryName}</FieldError> )} </Field> <Field> <FieldLabel htmlFor="signatoryTitle">Title</FieldLabel> <Input id="signatoryTitle" value={form.signatoryTitle} onChange={(e) => setField("signatoryTitle", e.target.value) } placeholder="Director, Operations" className="h-8 text-[12.5px]" /> {errors.signatoryTitle && ( <FieldError>{errors.signatoryTitle}</FieldError> )} </Field> </div> </div> </div> </SectionCard> {/* Custom Terms */} <SectionCard> <SectionTitle> Custom Terms{" "} <span className="ml-1 text-[10px] normal-case tracking-normal text-[var(--text-3)]"> optional overrides </span> </SectionTitle> <div className="grid gap-4 sm:grid-cols-3"> <Field> <FieldLabel htmlFor="customDearSir"> Dear Sir override </FieldLabel> <Input id="customDearSir" value={form.customDearSir ?? ""} onChange={(e) => setField("customDearSir", e.target.value)} placeholder="Dear Sir / Madam" className="h-8 text-[12.5px]" /> </Field> <Field> <FieldLabel htmlFor="customWorkingHours"> Working hours </FieldLabel> <Input id="customWorkingHours" value={form.customWorkingHours ?? ""} onChange={(e) => setField("customWorkingHours", e.target.value) } placeholder="Standard working hours apply..." className="h-8 text-[12.5px]" /> </Field> <Field> <FieldLabel htmlFor="customOvertimeTerms"> Overtime terms </FieldLabel> <Input id="customOvertimeTerms" value={form.customOvertimeTerms ?? ""} onChange={(e) => setField("customOvertimeTerms", e.target.value) } placeholder="OT only after written approval..." className="h-8 text-[12.5px]" /> </Field> </div> </SectionCard> </div>
{/* Right: summary card */} <div className="flex flex-col gap-4 content-start"> {/* Primary summary card */} <div className="rounded-[var(--r-lg)] border border-[var(--brand-border)] bg-[var(--brand-subtle)]/10 p-4"> <div className="mb-3 flex items-center justify-between"> <span className="font-mono text-[12.5px] font-semibold text-foreground"> {form.quotationNumber || "—"} </span> <Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11.5px] font-medium border-[var(--color-border)] bg-transparent text-[var(--text-3)]" > Draft </Badge> </div> <p className="font-mono text-[20px] font-bold text-[var(--brand)]"> AED {moneyFmt.format(standardTotal)} </p> <p className="mt-1 text-[11.5px] text-[var(--text-3)]"> {form.items.length}{" "} {form.items.length === 1 ? "category" : "categories"} {otTotal > 0 && ` · OT AED ${moneyFmt.format(otTotal)}`} </p> <div className="mt-3 flex flex-col gap-2 border-t border-[var(--brand-border)] pt-3"> {( [ { label: "Customer", value: form.customerName || "—" }, { label: "Valid until", value: form.validUntil || "—" }, { label: "Issue date", value: form.issueDate || "—" }, { label: "Signatory", value: form.signatoryName || "—" }, ] as { label: string; value: string }[] ).map(({ label, value }) => ( <div key={label} className="flex justify-between gap-2"> <span className="text-[11.5px] text-[var(--text-3)]"> {label} </span> <span className="text-right text-[11.5px] text-foreground"> {value} </span> </div> ))} </div> </div>
{/* Rate breakdown card */} {form.items.length > 0 && ( <Card> <CardHeader className="pb-2 pt-4"> <CardTitle className="text-[12px]">Rate breakdown</CardTitle> </CardHeader> <CardContent className="pb-4"> <div className="flex flex-col gap-1.5"> {form.items.map((item) => ( <div key={item.id} className="flex justify-between gap-2"> <span className="truncate text-[11.5px] text-[var(--text-2)]"> {item.category || "—"} ×{item.quantity} </span> <span className="shrink-0 text-[11.5px] text-foreground"> {moneyFmt.format(item.quantity * item.rateAed)} </span> </div> ))} <div className="mt-1 flex justify-between border-t border-border pt-2"> <span className="text-[11.5px] text-[var(--text-3)]"> Total </span> <span className="text-[11.5px] font-semibold text-[var(--brand)]"> AED {moneyFmt.format(standardTotal)} </span> </div> </div> </CardContent> </Card> )} </div> </div> </div> );}
export { QuotationEditPage };Update the import paths to match your project setup.
Install the ManpowerHub theme first, then add this block:
npx shadcn@latest add https://woxcn-registry.woxware.io/r/manpowerhub-quotation-edit.json