Quotation Page
Full ManpowerHub quotations list page block with KPI cards, validity tracking, filters, and route callbacks.
"use client";
import * as React from "react";
import { QuotationPage } from "@/registry/new-york/items/manpowerhub-quotation/components/quotation";
const SEED_QUOTATIONS = [ { 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.", 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: "2", quotationNumber: "KAT/QTN/2026/05/040", customerRefId: "EMAAR-DTB-903", status: "accepted" as const, customerName: "Emaar Properties PJSC", customerPhone: "+971 4 555 0230", proposalSubject: "Downtown Burj Phase 3 night-shift manpower", scopeOfWork: "Provide general labourers and skilled technicians for finishing works.", signatoryName: "Mohammed Nasser", signatoryTitle: "Commercial Manager", customDearSir: null, customWorkingHours: "Normal working hours are 8 hours per shift.", customOvertimeTerms: "OT applies after normal shift completion.", issueDate: "2026-05-18", validUntil: "2026-06-17", items: [ { id: "c", category: "General labourer", quantity: 25, rateAed: 290, otRateAed: 32, sortOrder: 0, }, { id: "d", category: "Skilled technician", quantity: 8, rateAed: 665, otRateAed: 70, sortOrder: 1, }, ], }, { id: "3", quotationNumber: "KAT/QTN/2026/05/039", customerRefId: "SOBHA-HQ-221", status: "draft" as const, customerName: "Sobha Realty Corporate Office", customerPhone: "+971 4 555 0144", proposalSubject: "Corporate office maintenance support", scopeOfWork: "Standby maintenance helpers for common area snagging and handover checks.", signatoryName: "Mohammed Nasser", signatoryTitle: "Commercial Manager", customDearSir: null, customWorkingHours: null, customOvertimeTerms: null, issueDate: "2026-05-21", validUntil: "2026-05-28", items: [ { id: "e", category: "Maintenance helper", quantity: 6, rateAed: 340, otRateAed: 38, sortOrder: 0, }, ], }, { id: "4", quotationNumber: "KAT/QTN/2026/05/038", customerRefId: "AFC-FST-510", status: "revised" as const, customerName: "Al Futtaim Carillion", customerPhone: "+971 4 555 0151", proposalSubject: "Festival City expansion revised manpower schedule", scopeOfWork: "Revised category mix for mechanical helpers and lifting support.", signatoryName: "Mohammed Nasser", signatoryTitle: "Commercial Manager", customDearSir: "Dear Procurement Team", customWorkingHours: "Mobilization expected within 72 hours of LPO.", customOvertimeTerms: "Ramadan OT terms to be confirmed separately.", issueDate: "2026-05-16", validUntil: "2026-05-23", items: [ { id: "f", category: "Mechanical helper", quantity: 12, rateAed: 410, otRateAed: 45, sortOrder: 0, }, ], }, { id: "5", quotationNumber: "KAT/QTN/2026/05/037", customerRefId: "YAS-VIL-778", status: "expired" as const, customerName: "Shapoorji Pallonji M.E.", customerPhone: "+971 2 555 0182", proposalSubject: "Yas Island villas civil manpower package", scopeOfWork: "General labour and mason categories for villa block works.", signatoryName: "Mohammed Nasser", signatoryTitle: "Commercial Manager", customDearSir: null, customWorkingHours: "Ten-hour duty including meal break.", customOvertimeTerms: "Friday work subject to revised rate card.", issueDate: "2026-04-20", validUntil: "2026-05-20", items: [ { id: "g", category: "Mason", quantity: 10, rateAed: 480, otRateAed: 55, sortOrder: 0, }, { id: "h", category: "General labourer", quantity: 20, rateAed: 290, otRateAed: 32, sortOrder: 1, }, ], },];
const KPI_TREND = { total: [14, 15, 15, 16, 18, 19, 21], pipeline: [8, 9, 8, 10, 11, 12, 13], accepted: [22000, 28000, 32000, 32000, 41000, 46000, 54210], expiring: [3, 3, 4, 4, 5, 4, 3],};
export function ManpowerhubQuotationBasic() { const [quotations, setQuotations] = React.useState<React.ComponentProps<typeof QuotationPage>["quotations"]>( SEED_QUOTATIONS, );
return ( <div className="h-[700px] overflow-y-auto rounded-lg border border-border bg-background p-4"> <QuotationPage className="p-0" quotations={quotations} kpiTrend={KPI_TREND} onDeleteQuotation={(id) => setQuotations((prev) => prev.filter((quotation) => quotation.id !== id), ) } onStatusChangeQuotation={(id, status) => setQuotations((prev) => prev.map((quotation) => quotation.id === id ? { ...quotation, status } : quotation, ), ) } onCreateQuotation={() => undefined} onEditQuotation={() => undefined} onViewQuotation={() => undefined} onDownloadQuotation={() => undefined} onSubmitForApproval={(id, values) => console.log("submit for approval", id, 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.
Install the following dependencies.
Copy and paste the following code into your project.
"use client";
import * as React from "react";import { ClipboardCheck, Download, Eye, FileText, MoreHorizontal, Pencil, Plus, RefreshCw, Search, Send, Trash2,} from "lucide-react";
import { InvoiceKpiCard } from "@/registry/new-york/items/manpowerhub-invoice-kpi/components/invoice-kpi";import { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle,} from "@/registry/new-york/items/manpowerhub-page-header/components/page-header";import { DataTable, DataTableBody, DataTableCell, DataTableCellPrimary, DataTableHead, DataTableHeaderCell, DataTableRoot, DataTableRow,} from "@/registry/new-york/items/manpowerhub-data-table/components/data-table";import { FilterBar } from "@/registry/new-york/items/manpowerhub-filter-bar/components/filter-bar";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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,} from "@/components/ui/alert-dialog";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu";import { Input } from "@/components/ui/input";import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";import { cn } from "@/lib/utils";
type QuotationStatus = "draft" | "sent" | "accepted" | "expired" | "revised";type FilterTab = "all" | QuotationStatus;
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[];}
interface KpiTrend { total: number[]; pipeline: number[]; accepted: number[]; expiring: number[];}
type DialogState = | { type: "idle" } | { type: "delete"; quotation: Quotation } | { type: "sendForApproval"; quotation: Quotation };
const FLAT_TREND: KpiTrend = { total: [0, 0, 0, 0, 0, 0, 0], pipeline: [0, 0, 0, 0, 0, 0, 0], accepted: [0, 0, 0, 0, 0, 0, 0], expiring: [0, 0, 0, 0, 0, 0, 0],};
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 daysUntil(date: string) { const today = new Date(); today.setHours(0, 0, 0, 0); const target = new Date(date); target.setHours(0, 0, 0, 0); return Math.ceil((target.getTime() - today.getTime()) / 86_400_000);}
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> );}
function ValidityCell({ status, validUntil,}: { status: QuotationStatus; validUntil: string;}) { const remaining = daysUntil(validUntil); const label = status === "expired" ? "Expired" : remaining < 0 ? `${Math.abs(remaining)} days overdue` : remaining === 0 ? "Expires today" : `${remaining} days left`;
return ( <div className="flex flex-col gap-0.5"> <span className="text-[12.5px] text-foreground">{validUntil}</span> <span className={cn( "text-[11px]", remaining <= 7 && status !== "accepted" ? "text-[var(--amber)]" : "text-[var(--text-3)]", )} > {label} </span> </div> );}
function DeleteConfirmDialog({ open, quotation, onClose, onConfirm,}: { open: boolean; quotation: Quotation | null; onClose: () => void; onConfirm: (id: string) => void | Promise<void>;}) { const [isDeleting, setIsDeleting] = React.useState(false);
async function handleConfirm() { if (!quotation) return; setIsDeleting(true); try { await onConfirm(String(quotation.id)); onClose(); } finally { setIsDeleting(false); } }
return ( <AlertDialog open={open} onOpenChange={(value) => !value && onClose()}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> Delete {quotation?.quotationNumber}? </AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. The quotation and its worker line items will be permanently deleted. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={handleConfirm} disabled={isDeleting} className="bg-[var(--red)] text-white hover:bg-[var(--red)]/90" > {isDeleting ? "Deleting..." : "Delete"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}
interface ActionsMenuProps { quotation: Quotation; onView?: (quotation: Quotation) => void; onEdit?: (quotation: Quotation) => void; onDownload?: (quotation: Quotation) => void; onSubmitForApproval?: (quotation: Quotation) => void; onStatusChange: (id: string, status: QuotationStatus) => void | Promise<void>; onDelete: (quotation: Quotation) => void;}
function ActionsMenu({ quotation, onView, onEdit, onDownload, onSubmitForApproval: onSubmitForApprovalProp, onStatusChange, onDelete,}: ActionsMenuProps) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className="size-7"> <MoreHorizontal className="size-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-52"> <div className="px-2 py-1.5 text-[11px] font-medium text-[var(--text-3)]"> {quotation.quotationNumber} </div> <DropdownMenuSeparator /> {onView && ( <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onView(quotation)} > <Eye className="size-3.5" /> View </DropdownMenuItem> )} {onEdit && ( <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onEdit(quotation)} > <Pencil className="size-3.5" /> Edit quotation </DropdownMenuItem> )} {onDownload && ( <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onDownload(quotation)} > <Download className="size-3.5" /> Download PDF </DropdownMenuItem> )} {onSubmitForApprovalProp && ( <> <DropdownMenuSeparator /> <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onSubmitForApprovalProp(quotation)} > <ClipboardCheck className="size-3.5" /> Submit for approval </DropdownMenuItem> </> )} <DropdownMenuSeparator /> <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onStatusChange(String(quotation.id), "sent")} > <Send className="size-3.5" /> Mark as sent </DropdownMenuItem> <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onStatusChange(String(quotation.id), "revised")} > <RefreshCw className="size-3.5" /> Mark revised </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem className="gap-2 text-[13px] text-[var(--red)] focus:text-[var(--red)]" onClick={() => onDelete(quotation)} > <Trash2 className="size-3.5" /> Delete quotation </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> );}
export interface QuotationPageProps extends React.ComponentProps<"div"> { quotations: Quotation[]; kpiTrend?: KpiTrend; onDeleteQuotation: (id: string) => void | Promise<void>; onStatusChangeQuotation: ( id: string, status: QuotationStatus, ) => void | Promise<void>; onCreateQuotation?: () => void; onEditQuotation?: (quotation: Quotation) => void; onDownloadQuotation?: (quotation: Quotation) => void; onViewQuotation?: (quotation: Quotation) => void; onSubmitForApproval?: ( id: string, values: SendQuotationValues, ) => void | Promise<void>;}
function QuotationPage({ className, quotations, kpiTrend, onDeleteQuotation, onStatusChangeQuotation, onCreateQuotation, onEditQuotation, onDownloadQuotation, onViewQuotation, onSubmitForApproval, ...props}: QuotationPageProps) { const [tab, setTab] = React.useState<FilterTab>("all"); const [search, setSearch] = React.useState(""); const [dialogState, setDialogState] = React.useState<DialogState>({ type: "idle", });
const trend = kpiTrend ?? FLAT_TREND; const pipelineCount = React.useMemo( () => quotations.filter((quote) => ["draft", "sent", "revised"].includes(quote.status), ).length, [quotations], ); const acceptedValue = React.useMemo( () => quotations .filter((quote) => quote.status === "accepted") .reduce((sum, quote) => sum + quotationValue(quote), 0), [quotations], ); const expiringCount = React.useMemo( () => quotations.filter((quote) => { if (quote.status === "accepted") return false; const remaining = daysUntil(quote.validUntil); return remaining <= 7; }).length, [quotations], );
const filtered = quotations.filter((quotation) => { const matchTab = tab === "all" || quotation.status === tab; const query = search.toLowerCase(); const matchSearch = !query || quotation.quotationNumber.toLowerCase().includes(query) || quotation.customerName.toLowerCase().includes(query) || quotation.proposalSubject.toLowerCase().includes(query); return matchTab && matchSearch; });
function closeDialog() { setDialogState({ type: "idle" }); }
return ( <div className={cn("p-6", className)} {...props}> <PageHeader> <PageHeaderContent> <PageHeaderTitle>Quotations</PageHeaderTitle> <PageHeaderDescription> Draft, send, revise, and accept manpower quotations </PageHeaderDescription> </PageHeaderContent> <PageHeaderActions> {onCreateQuotation && ( <Button size="sm" className="gap-1.5" onClick={onCreateQuotation}> <Plus className="size-3.5" /> New quote </Button> )} </PageHeaderActions> </PageHeader>
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4"> <InvoiceKpiCard label="Total quotations" value={quotations.length} sublabel="All quote records in the workspace" trendData={trend.total} accent="neutral" /> <InvoiceKpiCard label="Pipeline" value={pipelineCount} sublabel="Draft, sent, and revised quotes" trendData={trend.pipeline} accent="amber" /> <InvoiceKpiCard label="Accepted value" value={ <span className="text-[var(--brand)]"> {formatMoney(acceptedValue)} </span> } sublabel="Sum of accepted standard rates" trendData={trend.accepted} accent="brand" /> <InvoiceKpiCard label="Needs attention" value={expiringCount} sublabel="Expired or valid within 7 days" trendData={trend.expiring} accent="neutral" /> </div>
<div className="rounded-[var(--r-lg)] border border-border bg-card"> <div className="border-b border-[var(--color-border-subtle)] px-4 py-3.5"> <p className="text-[13px] font-semibold text-foreground"> Recent quotations </p> <p className="text-[11.5px] text-[var(--text-3)]"> {quotations.length} quotations - Validity, rate cards, and customer scope </p> </div>
<div className="px-4 pt-3"> <FilterBar className="mb-3 justify-between"> <Tabs value={tab} onValueChange={(value) => setTab(value as FilterTab)} className="w-auto" > <TabsList className="h-8 gap-0.5 bg-transparent p-0"> {( [ { value: "all", label: "All" }, { value: "draft", label: "Draft" }, { value: "sent", label: "Sent" }, { value: "accepted", label: "Accepted" }, { value: "expired", label: "Expired" }, { value: "revised", label: "Revised" }, ] as { value: FilterTab; label: string }[] ).map(({ value, label }) => ( <TabsTrigger key={value} value={value} className="h-8 rounded-[var(--r-md)] px-3 text-[12.5px]" > {label} </TabsTrigger> ))} </TabsList> </Tabs> <div className="relative"> <Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-[var(--text-3)]" /> <Input placeholder="Search quote, customer, or subject..." value={search} onChange={(event) => setSearch(event.target.value)} className="h-8 w-72 pl-8 text-[12.5px]" /> </div> </FilterBar> </div>
<DataTable> <DataTableRoot> <DataTableHead> <DataTableRow> <DataTableHeaderCell>Quote</DataTableHeaderCell> <DataTableHeaderCell>Customer</DataTableHeaderCell> <DataTableHeaderCell>Subject</DataTableHeaderCell> <DataTableHeaderCell>Status</DataTableHeaderCell> <DataTableHeaderCell>Valid Until</DataTableHeaderCell> <DataTableHeaderCell className="text-right"> Standard Value </DataTableHeaderCell> <DataTableHeaderCell className="w-10" /> </DataTableRow> </DataTableHead> <DataTableBody> {filtered.length === 0 ? ( <DataTableRow> <DataTableCell colSpan={7} className="py-10 text-center text-[12.5px] text-[var(--text-3)]" > No quotations match your filter. </DataTableCell> </DataTableRow> ) : ( filtered.map((quotation) => ( <DataTableRow key={quotation.id}> <DataTableCellPrimary className="font-mono text-[12.5px]"> <span className="flex items-center gap-1.5"> <FileText className="size-3.5 shrink-0 text-[var(--text-3)]" /> {quotation.quotationNumber} </span> </DataTableCellPrimary> <DataTableCell> <div className="flex flex-col gap-0.5"> <span className="text-[12.5px] text-foreground"> {quotation.customerName} </span> <span className="text-[11px] text-[var(--text-3)]"> {quotation.customerRefId} </span> </div> </DataTableCell> <DataTableCell className="max-w-[260px]"> <div className="truncate text-[12.5px] text-foreground"> {quotation.proposalSubject} </div> <div className="truncate text-[11px] text-[var(--text-3)]"> {quotation.items.length} categories - OT reference{" "} {formatMoney(overtimeValue(quotation))} </div> </DataTableCell> <DataTableCell> <StatusBadge status={quotation.status} /> </DataTableCell> <DataTableCell> <ValidityCell status={quotation.status} validUntil={quotation.validUntil} /> </DataTableCell> <DataTableCell className="text-right font-medium text-foreground"> {formatMoney(quotationValue(quotation))} </DataTableCell> <DataTableCell className="text-right"> <ActionsMenu quotation={quotation} onView={onViewQuotation} onEdit={onEditQuotation} onDownload={onDownloadQuotation} onSubmitForApproval={ onSubmitForApproval ? (q) => setDialogState({ type: "sendForApproval", quotation: q, }) : undefined } onStatusChange={onStatusChangeQuotation} onDelete={(quotation) => setDialogState({ type: "delete", quotation }) } /> </DataTableCell> </DataTableRow> )) )} </DataTableBody> </DataTableRoot> </DataTable> </div>
<DeleteConfirmDialog open={dialogState.type === "delete"} quotation={dialogState.type === "delete" ? dialogState.quotation : null} onClose={closeDialog} onConfirm={onDeleteQuotation} />
{onSubmitForApproval && ( <SendQuotationModal open={dialogState.type === "sendForApproval"} onOpenChange={(open) => !open && closeDialog()} quotationNumber={ dialogState.type === "sendForApproval" ? dialogState.quotation.quotationNumber : undefined } initialSubject={ dialogState.type === "sendForApproval" ? `Quotation ${dialogState.quotation.quotationNumber} — ${dialogState.quotation.proposalSubject}` : undefined } onSend={(values) => { if (dialogState.type !== "sendForApproval") return; return onSubmitForApproval( String(dialogState.quotation.id), values, ); }} /> )} </div> );}
export { QuotationPage };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.jsonThis installs the list block at components/blocks/manpowerhub-quotation.tsx along with its ManpowerHub component dependencies. Drop <QuotationPage /> inside an AppShellScrollBody for the full shell layout.
Wiring handlers
Section titled “Wiring handlers”QuotationPage is a controlled list component. It renders quotation rows that you provide and calls handlers when users navigate to create, edit, view, download, delete, or change status. Create/edit forms should live on your app routes, not inside this block.
import { QuotationPage } from "@/components/blocks/manpowerhub-quotation";
export default function QuotationsRoute() { const { data: quotations, mutate } = useQuotations(); // your data fetching
return ( <QuotationPage quotations={quotations} kpiTrend={kpiTrend} onDeleteQuotation={async (id) => { await api.quotations.delete(id); mutate(); }} onStatusChangeQuotation={async (id, status) => { await api.quotations.update(id, { status }); mutate(); }} onCreateQuotation={() => router.push("/quotations/new")} onEditQuotation={(quotation) => router.push(`/quotations/${quotation.id}/edit`)} onViewQuotation={(quotation) => router.push(`/quotations/${quotation.id}`)} onDownloadQuotation={(quotation) => window.open(`/api/quotations/${quotation.id}/pdf`) } /> );}Route layout
Section titled “Route layout”Use the quotation list block for the index route and the quotation view block for the read-only detail route.
/quotations -> manpowerhub-quotation/quotations/new -> your create form route/quotations/:id -> manpowerhub-quotation-view/quotations/:id/edit -> your edit form routeThe delete confirmation dialog is built into the block and calls onDeleteQuotation after the user confirms.
quotations: required list of quotation records to render.kpiTrend: optional sparkline data for the four KPI cards. Defaults to flat lines.onDeleteQuotation: required delete handler. Receives quotationid.onStatusChangeQuotation: required status handler. Receives(id, status).onCreateQuotation: optional handler for the New quote button. If omitted, the button is hidden.onEditQuotation: optional handler for the row Edit action. If omitted, Edit is hidden.onViewQuotation: optional handler for the row View action.onDownloadQuotation: optional handler for the Download PDF action.
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[];}
interface KpiTrend { total: number[]; pipeline: number[]; accepted: number[]; expiring: number[];}