Invoice Page
Full Proforma Invoices page block with KPIs, activity chart, filterable table, and built-in Sheet form for CRUD.
"use client";
import * as React from "react";
import { AppShell, AppShellMain, AppShellScrollBody,} from "@/registry/new-york/items/manpowerhub-app-shell/components/app-shell";import { Topbar, TopbarLeft, TopbarRight, TopbarSeparator, TopbarSubtitle, TopbarTitle,} from "@/registry/new-york/items/manpowerhub-topbar/components/topbar";import { InvoicePage } from "@/registry/new-york/items/manpowerhub-invoice/components/invoice";import { Badge } from "@/components/ui/badge";
const SEED_INVOICES = [ { id: "1", piNumber: "KAT/2026/05/024", billTo: "Al Naboodah Contracting", date: "2026-05-20", status: "pending" as const, trn: "100234567890003", project: "Al Quoz Industrial Site", vatApplicable: true, approvals: [ { name: "Ahmad", dept: "Operation Department" as const, signed: false }, { name: "Khalid", dept: "Account Department" as const, signed: false }, ], lineItems: [ { id: "a", description: "Site supervisor", hours: 10, rate: 950 }, { id: "b", description: "Heavy equipment operator", hours: 4, rate: 1050, }, ], grantTotal: 14385, }, { id: "2", piNumber: "KAT/2026/05/023", billTo: "Emaar Properties PJSC", date: "2026-05-19", status: "sent" as const, trn: "100198765432109", project: "Downtown Burj Phase 3", vatApplicable: false, approvals: [ { name: "Mariam", dept: "Operation Department" as const, signed: true }, { name: "Faisal", dept: "Account Department" as const, signed: true }, ], lineItems: [ { id: "c", description: "General labourers", hours: 25, rate: 350 }, ], grantTotal: 8750, }, { id: "3", piNumber: "KAT/2026/05/022", billTo: "Sobha Realty Corporate Office", date: "2026-05-18", status: "draft" as const, trn: "", project: "", vatApplicable: false, approvals: [ { name: "Tariq", dept: "Operation Department" as const, signed: false }, { name: "Salma", dept: "Account Department" as const, signed: false }, ], lineItems: [], grantTotal: null, }, { id: "4", piNumber: "KAT/2026/05/021", billTo: "Al Futtaim Carillion", date: "2026-05-17", status: "approved" as const, trn: "100345678901234", project: "Festival City Expansion", vatApplicable: true, approvals: [ { name: "Nasser", dept: "Operation Department" as const, signed: true }, { name: "Hind", dept: "Account Department" as const, signed: true }, ], lineItems: [ { id: "d", description: "Skilled technicians", hours: 6, rate: 665 }, ], grantTotal: 4190, }, { id: "5", piNumber: "KAT/2026/05/020", billTo: "Shapoorji Pallonji M.E.", date: "2026-05-16", status: "expired" as const, trn: "100456789012345", project: "Yas Island Villas", vatApplicable: false, approvals: [ { name: "Ravi", dept: "Operation Department" as const, signed: false }, { name: "Priya", dept: "Account Department" as const, signed: false }, ], lineItems: [ { id: "e", description: "General labourers", hours: 10, rate: 290 }, ], grantTotal: 2900, },];
const KPI_TREND = { total: [18, 19, 19, 20, 20, 21, 22], pending: [2, 3, 4, 3, 5, 5, 6], billed: [42000, 44000, 48000, 51000, 55000, 58000, 60481], vat: [900, 980, 1050, 1100, 1180, 1280, 1380],};
export function ManpowerhubInvoiceBasic() { const [invoices, setInvoices] = React.useState<React.ComponentProps<typeof InvoicePage>["invoices"]>( SEED_INVOICES, );
return ( <AppShell className="h-[700px] rounded-lg border border-border"> <AppShellMain> <Topbar> <TopbarLeft> <TopbarTitle>Invoices</TopbarTitle> <TopbarSeparator>/</TopbarSeparator> <TopbarSubtitle>Proforma invoices</TopbarSubtitle> </TopbarLeft> <TopbarRight> <Badge variant="outline">{invoices.length} records</Badge> </TopbarRight> </Topbar> <AppShellScrollBody> <InvoicePage className="p-0" invoices={invoices} kpiTrend={KPI_TREND} onSaveInvoice={(data, id) => { const subTotal = data.lineItems.reduce( (sum, item) => sum + item.hours * item.rate, 0, ); const vatAmount = data.vatApplicable ? Math.round(subTotal * 0.05) : 0; const grantTotal = subTotal + vatAmount; if (id) { setInvoices((prev) => prev.map((inv) => inv.id === id ? { ...inv, ...data, grantTotal } : inv, ), ); } else { const newInv = { id: String(Date.now()), ...data, status: "draft" as const, grantTotal, }; setInvoices((prev) => [newInv, ...prev]); } }} onDeleteInvoice={(id) => setInvoices((prev) => prev.filter((inv) => inv.id !== id)) } onApproveInvoice={(id, dept) => setInvoices((prev) => prev.map((inv) => { if (inv.id !== id) return inv; const updatedApprovals = inv.approvals.map((a) => a.dept === dept ? { ...a, signed: true } : a, ); const allSigned = updatedApprovals.every((a) => a.signed); return { ...inv, approvals: updatedApprovals, status: allSigned ? ("approved" as const) : inv.status, }; }), ) } onRecallToDraft={(id) => setInvoices((prev) => prev.map((inv) => inv.id === id ? { ...inv, status: "draft" as const, approvals: inv.approvals.map((a) => ({ ...a, signed: false, })), } : inv, ), ) } onDownloadInvoice={(invoice) => { console.log("Download requested for", invoice.piNumber); }} /> </AppShellScrollBody> </AppShellMain> </AppShell> );}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 { Download, Eye, FileText, MoreHorizontal, Pencil, Plus, RefreshCw, Search, Trash2, ThumbsUp,} from "lucide-react";
import { InvoiceKpiCard } from "@/registry/new-york/items/manpowerhub-invoice-kpi/components/invoice-kpi";import { InvoiceChart } from "@/registry/new-york/items/manpowerhub-invoice-chart/components/invoice-chart";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 { Button } from "@/components/ui/button";import { Sheet, SheetContent, SheetHeader, SheetTitle,} from "@/components/ui/sheet";import { Switch } from "@/components/ui/switch";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 InvoiceStatus = "draft" | "pending" | "sent" | "approved" | "expired";type FilterTab = "all" | InvoiceStatus;
interface ApprovalEntry { name: string; dept: "Operation Department" | "Account Department"; signed: boolean;}
interface LineItem { id: string; description: string; hours: number; rate: number;}
interface Invoice { id: string; piNumber: string; billTo: string; date: string; status: InvoiceStatus; approvals: ApprovalEntry[]; lineItems: LineItem[]; grantTotal: number | null; trn?: string; project?: string; vatApplicable: boolean;}
interface InvoiceFormData { piNumber: string; billTo: string; date: string; approvals: ApprovalEntry[]; lineItems: LineItem[]; trn: string; project: string; vatApplicable: boolean;}
interface KpiTrend { total: number[]; pending: number[]; billed: number[]; vat: number[];}
interface CreateSheetRenderProps { open: boolean; onClose: () => void; onSave: (data: InvoiceFormData) => void;}
interface EditSheetRenderProps extends CreateSheetRenderProps { invoice: Invoice;}
type DialogState = | { type: "idle" } | { type: "create" } | { type: "edit"; invoice: Invoice } | { type: "delete"; invoice: Invoice };
const FLAT_TREND: KpiTrend = { total: [0, 0, 0, 0, 0, 0, 0], pending: [0, 0, 0, 0, 0, 0, 0], billed: [0, 0, 0, 0, 0, 0, 0], vat: [0, 0, 0, 0, 0, 0, 0],};
const statusConfig: Record< InvoiceStatus, { label: string; className: string }> = { draft: { label: "Draft", className: "border-[var(--color-border)] bg-transparent text-[var(--text-3)]", }, pending: { label: "Pending approval", className: "border-[var(--amber-border)] bg-[var(--amber-bg)] text-[var(--amber)]", }, sent: { label: "Sent to client", className: "border-[var(--green-border)] bg-[var(--green-bg)] text-[var(--green)]", }, approved: { label: "Approved", className: "border-[var(--brand-border)] bg-[var(--brand-subtle)] text-[var(--brand)]", }, expired: { label: "Expired", className: "border-[var(--red-border)] bg-[var(--red-bg)] text-[var(--red)]", },};
function StatusBadge({ status }: { status: InvoiceStatus }) { const cfg = statusConfig[status]; return ( <span className={cn( "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[11.5px] font-medium", cfg.className, )} > {status === "pending" && ( <span className="size-1.5 rounded-full bg-[var(--amber)]" /> )} {cfg.label} </span> );}
function ApprovalCell({ approvals }: { approvals: ApprovalEntry[] }) { if (approvals.every((a) => !a.signed)) { return <span className="text-[12px] text-[var(--text-3)]">—</span>; } return ( <div className="flex flex-col gap-1"> {approvals.map((a) => ( <div key={a.dept} className="flex items-center gap-2"> <span className={cn( "flex size-3.5 items-center justify-center rounded-full border", a.signed ? "border-[var(--brand)] bg-[var(--brand)]" : "border-[var(--color-border-strong)] bg-transparent", )} > {a.signed && <span className="size-1.5 rounded-full bg-white" />} </span> <span className="text-[12px] text-[var(--text-2)]"> <span className="font-medium text-foreground">{a.name}</span> {" · "} <span className="text-[var(--text-3)]">{a.dept}</span> </span> </div> ))} </div> );}
// Normalises DD/MM/YYYY or YYYY-MM-DD → YYYY-MM-DD for <input type="date">function toIsoDate(date?: string): string { if (!date) return new Date().toISOString().split("T")[0]; if (/^\d{2}\/\d{2}\/\d{4}$/.test(date)) { const [d, m, y] = date.split("/"); return `${y}-${m}-${d}`; } return date;}
function generatePiNumber(): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, "0"); const seq = String(Math.floor(Math.random() * 900) + 100); return `KAT/${year}/${month}/${seq}`;}
function newLineItem(): LineItem { return { id: crypto.randomUUID(), description: "", hours: 1, rate: 0 };}
function DeleteConfirmDialog({ open, invoice, onClose, onConfirm,}: { open: boolean; invoice: Invoice | null; onClose: () => void; onConfirm: (id: string) => void | Promise<void>;}) { const [isDeleting, setIsDeleting] = React.useState(false);
async function handleConfirm() { if (!invoice) return; setIsDeleting(true); await onConfirm(invoice.id); setIsDeleting(false); onClose(); }
return ( <AlertDialog open={open} onOpenChange={(v) => !v && onClose()}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Delete {invoice?.piNumber}?</AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. The invoice 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> );}
function InvoiceFormSheet({ open, invoice, onClose, onSave,}: { open: boolean; invoice?: Invoice; onClose: () => void; onSave: (data: InvoiceFormData) => void | Promise<void>;}) { const isEdit = !!invoice;
const [piNumber, setPiNumber] = React.useState(""); const [billTo, setBillTo] = React.useState(""); const [date, setDate] = React.useState(""); const [trn, setTrn] = React.useState(""); const [project, setProject] = React.useState(""); const [vatApplicable, setVatApplicable] = React.useState(false); const [approvals, setApprovals] = React.useState<ApprovalEntry[]>([ { name: "", dept: "Operation Department", signed: false }, { name: "", dept: "Account Department", signed: false }, ]); const [lineItems, setLineItems] = React.useState<LineItem[]>([newLineItem()]); const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => { if (!open) return; setPiNumber(invoice?.piNumber ?? generatePiNumber()); setBillTo(invoice?.billTo ?? ""); setDate(toIsoDate(invoice?.date)); setTrn(invoice?.trn ?? ""); setProject(invoice?.project ?? ""); setVatApplicable(invoice?.vatApplicable ?? false); setApprovals( invoice?.approvals ?? [ { name: "", dept: "Operation Department", signed: false }, { name: "", dept: "Account Department", signed: false }, ], ); setLineItems( invoice?.lineItems?.length ? invoice.lineItems : [newLineItem()], ); setIsSaving(false); }, [open, invoice]);
const subTotal = lineItems.reduce( (sum, item) => sum + item.hours * item.rate, 0, ); const vatAmount = vatApplicable ? Math.round(subTotal * 0.05) : 0; const grantTotal = subTotal + vatAmount;
function updateLineItem( id: string, field: keyof Omit<LineItem, "id">, value: string | number, ) { setLineItems((prev) => prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)), ); }
function updateApproval(dept: ApprovalEntry["dept"], name: string) { setApprovals((prev) => prev.map((a) => (a.dept === dept ? { ...a, name } : a)), ); }
async function handleSave() { setIsSaving(true); await onSave({ piNumber, billTo, date, trn, project, vatApplicable, approvals, lineItems, }); setIsSaving(false); }
return ( <Sheet open={open} onOpenChange={(v) => !v && onClose()}> <SheetContent side="right" className="flex w-[70%] max-w-3xl flex-col gap-0 p-0" > <SheetHeader className="border-b border-border px-6 py-4"> <SheetTitle> {isEdit ? "Edit invoice" : "New draft invoice"} </SheetTitle> </SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5"> <div className="grid gap-4"> <div className="grid grid-cols-2 gap-3"> <div className="flex flex-col gap-1.5"> <label className="text-[12px] font-medium text-[var(--text-2)]"> PI Number </label> <Input value={piNumber} onChange={(e) => setPiNumber(e.target.value)} className="h-8 font-mono text-[12.5px]" /> </div> <div className="flex flex-col gap-1.5"> <label className="text-[12px] font-medium text-[var(--text-2)]"> Date </label> <Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="h-8 text-[12.5px]" /> </div> </div>
<div className="flex flex-col gap-1.5"> <label className="text-[12px] font-medium text-[var(--text-2)]"> Bill To </label> <Input value={billTo} onChange={(e) => setBillTo(e.target.value)} placeholder="Client / company name" className="h-8 text-[12.5px]" /> </div>
<div className="grid grid-cols-2 gap-3"> <div className="flex flex-col gap-1.5"> <label className="text-[12px] font-medium text-[var(--text-2)]"> TRN </label> <Input value={trn} onChange={(e) => setTrn(e.target.value)} placeholder="Client tax reg number" className="h-8 text-[12.5px]" /> </div> <div className="flex flex-col gap-1.5"> <label className="text-[12px] font-medium text-[var(--text-2)]"> Project </label> <Input value={project} onChange={(e) => setProject(e.target.value)} placeholder="Project name" className="h-8 text-[12.5px]" /> </div> </div>
<div className="flex flex-col gap-1.5"> <label className="text-[12px] font-medium text-[var(--text-2)]"> Approvers </label> <div className="flex flex-col gap-2"> {approvals.map((a) => ( <div key={a.dept} className="flex items-center gap-2"> <span className="w-44 shrink-0 text-[11.5px] text-[var(--text-3)]"> {a.dept} </span> <Input value={a.name} onChange={(e) => updateApproval(a.dept, e.target.value)} placeholder="Approver name" className="h-8 text-[12.5px]" /> </div> ))} </div> </div>
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2.5"> <div> <p className="text-[12.5px] font-medium text-foreground"> Apply 5% VAT </p> <p className="text-[11.5px] text-[var(--text-3)]"> {vatApplicable ? `+AED ${vatAmount.toLocaleString()}.00` : "N/A — VAT not applied"} </p> </div> <Switch checked={vatApplicable} onCheckedChange={setVatApplicable} /> </div>
<div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between"> <label className="text-[12px] font-medium text-[var(--text-2)]"> Line items </label> <Button variant="ghost" size="sm" className="h-7 gap-1.5 text-[12px]" onClick={() => setLineItems((prev) => [...prev, newLineItem()]) } > <Plus className="size-3" /> Add row </Button> </div> <div className="rounded-md border border-border"> <table className="w-full text-[12px]"> <thead> <tr className="border-b border-border bg-muted/40"> <th className="px-3 py-2 text-left font-medium text-[var(--text-3)]"> Description </th> <th className="w-24 px-3 py-2 text-right font-medium text-[var(--text-3)]"> Hours </th> <th className="w-32 px-3 py-2 text-right font-medium text-[var(--text-3)]"> Rate (AED) </th> <th className="w-32 px-3 py-2 text-right font-medium text-[var(--text-3)]"> Amount </th> <th className="w-8" /> </tr> </thead> <tbody> {lineItems.map((item) => ( <tr key={item.id} className="border-b border-border last:border-0" > <td className="px-3 py-1.5"> <Input value={item.description} onChange={(e) => updateLineItem( item.id, "description", e.target.value, ) } className="h-7 border-0 bg-transparent p-0 text-[12px] shadow-none focus-visible:ring-0" placeholder="Description" /> </td> <td className="px-3 py-1.5"> <Input type="number" value={item.hours} onChange={(e) => updateLineItem( item.id, "hours", Number(e.target.value), ) } className="h-7 border-0 bg-transparent p-0 text-right text-[12px] shadow-none focus-visible:ring-0" min={0} /> </td> <td className="px-3 py-1.5"> <Input type="number" value={item.rate} onChange={(e) => updateLineItem( item.id, "rate", Number(e.target.value), ) } className="h-7 border-0 bg-transparent p-0 text-right text-[12px] shadow-none focus-visible:ring-0" min={0} /> </td> <td className="px-3 py-1.5 text-right text-foreground"> {(item.hours * item.rate).toLocaleString()} </td> <td className="pr-2 text-center"> <Button variant="ghost" size="icon" className="size-6 text-[var(--red)]" onClick={() => setLineItems((prev) => prev.filter((i) => i.id !== item.id), ) } disabled={lineItems.length === 1} > <Trash2 className="size-3" /> </Button> </td> </tr> ))} </tbody> <tfoot> <tr className="border-t border-border"> <td colSpan={3} className="px-3 py-2 text-right text-[12px] text-[var(--text-3)]" > Sub Total </td> <td className="px-3 py-2 text-right text-[12px] text-foreground"> AED {subTotal.toLocaleString()}.00 </td> <td /> </tr> {vatApplicable && ( <tr className="border-t border-border"> <td colSpan={3} className="px-3 py-2 text-right text-[12px] text-[var(--text-3)]" > 5% VAT </td> <td className="px-3 py-2 text-right text-[12px] text-foreground"> AED {vatAmount.toLocaleString()}.00 </td> <td /> </tr> )} <tr className="border-t border-border bg-muted/40"> <td colSpan={3} className="px-3 py-2 text-right text-[12px] font-semibold text-foreground" > Grant Total </td> <td className="px-3 py-2 text-right text-[12px] font-semibold text-[var(--brand)]"> AED {grantTotal.toLocaleString()}.00 </td> <td /> </tr> </tfoot> </table> </div> </div> </div> </div>
<div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4"> <Button variant="outline" size="sm" onClick={onClose} disabled={isSaving} > Cancel </Button> <Button size="sm" onClick={handleSave} disabled={isSaving}> {isSaving ? "Saving…" : isEdit ? "Save changes" : "Create draft"} </Button> </div> </SheetContent> </Sheet> );}
interface ActionsMenuProps { invoice: Invoice; onView?: (invoice: Invoice) => void; onEdit: (invoice: Invoice) => void; onDownload?: (invoice: Invoice) => void; onApprove: ( id: string, dept: "Operation Department" | "Account Department", ) => void; onRecall: (id: string) => void; onDelete: (invoice: Invoice) => void;}
function ActionsMenu({ invoice, onView, onEdit, onDownload, onApprove, onRecall, 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)]"> {invoice.piNumber} </div> <DropdownMenuSeparator /> {onView && ( <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onView(invoice)} > <Eye className="size-3.5" /> View </DropdownMenuItem> )} <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onEdit(invoice)} > <Pencil className="size-3.5" /> Edit draft </DropdownMenuItem> {onDownload && ( <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onDownload(invoice)} > <Download className="size-3.5" /> Download PDF </DropdownMenuItem> )} <DropdownMenuSeparator /> <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onApprove(invoice.id, "Operation Department")} > <ThumbsUp className="size-3.5" /> Approve step 1 (Operation) </DropdownMenuItem> <DropdownMenuItem className="gap-2 text-[13px]" onClick={() => onRecall(invoice.id)} > <RefreshCw className="size-3.5" /> Recall to draft </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem className="gap-2 text-[13px] text-[var(--red)] focus:text-[var(--red)]" onClick={() => onDelete(invoice)} > <Trash2 className="size-3.5" /> Delete draft </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> );}
export interface InvoicePageProps extends React.ComponentProps<"div"> { invoices: Invoice[]; kpiTrend?: KpiTrend; onSaveInvoice: (data: InvoiceFormData, id?: string) => void | Promise<void>; onDeleteInvoice: (id: string) => void | Promise<void>; onApproveInvoice: ( id: string, dept: "Operation Department" | "Account Department", ) => void | Promise<void>; onRecallToDraft: (id: string) => void | Promise<void>; onDownloadInvoice?: (invoice: Invoice) => void; onViewInvoice?: (invoice: Invoice) => void; renderCreateSheet?: (props: CreateSheetRenderProps) => React.ReactNode; renderEditSheet?: (props: EditSheetRenderProps) => React.ReactNode;}
function InvoicePage({ className, invoices, kpiTrend, onSaveInvoice, onDeleteInvoice, onApproveInvoice, onRecallToDraft, onDownloadInvoice, onViewInvoice, renderCreateSheet, renderEditSheet, ...props}: InvoicePageProps) { 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 pendingCount = React.useMemo( () => invoices.filter((i) => i.status === "pending").length, [invoices], ); const totalBilled = React.useMemo( () => invoices.reduce((sum, i) => sum + (i.grantTotal ?? 0), 0), [invoices], ); const vatCollected = Math.round(totalBilled * 0.05 * 0.45);
const filtered = invoices.filter((inv) => { const matchTab = tab === "all" || inv.status === tab; const q = search.toLowerCase(); const matchSearch = !q || inv.piNumber.toLowerCase().includes(q) || inv.billTo.toLowerCase().includes(q); return matchTab && matchSearch; });
function closeDialog() { setDialogState({ type: "idle" }); }
async function handleSave(data: InvoiceFormData) { const id = dialogState.type === "edit" ? dialogState.invoice.id : undefined; await onSaveInvoice(data, id); closeDialog(); }
return ( <div className={cn("p-6", className)} {...props}> <PageHeader> <PageHeaderContent> <PageHeaderTitle>Proforma invoices</PageHeaderTitle> <PageHeaderDescription>Wednesday, May 20, 2026</PageHeaderDescription> </PageHeaderContent> <PageHeaderActions> <Button size="sm" className="gap-1.5" onClick={() => setDialogState({ type: "create" })} > <Plus className="size-3.5" /> New draft </Button> </PageHeaderActions> </PageHeader>
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4"> <InvoiceKpiCard label="Total invoices" value={invoices.length} sublabel="All PI records in the system" trendData={trend.total} accent="neutral" /> <InvoiceKpiCard label="Pending approval" value={pendingCount} sublabel="Awaiting Operation & Account sign-off" trendData={trend.pending} accent="amber" /> <InvoiceKpiCard label="Total billed" value={ <span className="text-[var(--brand)]"> AED {totalBilled.toLocaleString()}.00 </span> } sublabel="Sum of grant totals (AED)" trendData={trend.billed} accent="brand" /> <InvoiceKpiCard label="VAT collected" value={`AED ${vatCollected.toLocaleString()}.00`} sublabel="Total VAT across all invoices" trendData={trend.vat} accent="neutral" /> </div>
<div className="mb-4"> <InvoiceChart /> </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 invoices </p> <p className="text-[11.5px] text-[var(--text-3)]"> {invoices.length} invoices · Draft, approval, and send workflow </p> </div>
<div className="px-4 pt-3"> <FilterBar className="mb-3 justify-between"> <Tabs value={tab} onValueChange={(v) => setTab(v 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: "pending", label: "Pending" }, { value: "approved", label: "Approved" }, { value: "sent", label: "Sent" }, ] 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 PI number or bill to…" value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 w-56 pl-8 text-[12.5px]" /> </div> </FilterBar> </div>
<DataTable> <DataTableRoot> <DataTableHead> <DataTableRow> <DataTableHeaderCell>PI Number</DataTableHeaderCell> <DataTableHeaderCell>Bill To</DataTableHeaderCell> <DataTableHeaderCell>Date</DataTableHeaderCell> <DataTableHeaderCell>Status</DataTableHeaderCell> <DataTableHeaderCell>Approval</DataTableHeaderCell> <DataTableHeaderCell className="text-right"> Grant Total </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 invoices match your filter. </DataTableCell> </DataTableRow> ) : ( filtered.map((inv) => ( <DataTableRow key={inv.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)]" /> {inv.piNumber} </span> </DataTableCellPrimary> <DataTableCell>{inv.billTo}</DataTableCell> <DataTableCell className="text-[var(--text-3)]"> {inv.date} </DataTableCell> <DataTableCell> <StatusBadge status={inv.status} /> </DataTableCell> <DataTableCell> <ApprovalCell approvals={inv.approvals} /> </DataTableCell> <DataTableCell className="text-right font-medium text-foreground"> {inv.grantTotal != null ? `AED ${inv.grantTotal.toLocaleString()}.00` : "—"} </DataTableCell> <DataTableCell className="text-right"> <ActionsMenu invoice={inv} onView={onViewInvoice} onEdit={(inv) => setDialogState({ type: "edit", invoice: inv }) } onDownload={onDownloadInvoice} onApprove={onApproveInvoice} onRecall={onRecallToDraft} onDelete={(inv) => setDialogState({ type: "delete", invoice: inv }) } /> </DataTableCell> </DataTableRow> )) )} </DataTableBody> </DataTableRoot> </DataTable> </div>
{/* Dialogs */} {dialogState.type === "create" && (renderCreateSheet ? ( renderCreateSheet({ open: true, onClose: closeDialog, onSave: handleSave, }) ) : ( <InvoiceFormSheet open onClose={closeDialog} onSave={handleSave} /> ))}
{dialogState.type === "edit" && (renderEditSheet ? ( renderEditSheet({ open: true, onClose: closeDialog, onSave: handleSave, invoice: dialogState.invoice, }) ) : ( <InvoiceFormSheet open invoice={dialogState.invoice} onClose={closeDialog} onSave={handleSave} /> ))}
<DeleteConfirmDialog open={dialogState.type === "delete"} invoice={dialogState.type === "delete" ? dialogState.invoice : null} onClose={closeDialog} onConfirm={onDeleteInvoice} /> </div> );}
export { InvoicePage };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-invoice.jsonThis installs the full block at components/blocks/manpowerhub-invoice.tsx along with its component dependencies. Drop <InvoicePage /> inside an AppShellScrollBody for the full shell layout.
Wiring CRUD handlers
Section titled “Wiring CRUD handlers”InvoicePage is a controlled component — it renders data you provide and calls handlers on user actions. Wire your own API calls or state management:
import { InvoicePage } from "@/components/blocks/manpowerhub-invoice";
export default function InvoicesRoute() { const { data: invoices, mutate } = useInvoices(); // your data fetching
return ( <InvoicePage invoices={invoices} kpiTrend={kpiTrend} onSaveInvoice={async (data, id) => { if (id) { await api.invoices.update(id, data); } else { await api.invoices.create(data); } mutate(); }} onDeleteInvoice={async (id) => { await api.invoices.delete(id); mutate(); }} onApproveInvoice={async (id, dept) => { await api.invoices.approve(id, dept); mutate(); }} onRecallToDraft={async (id) => { await api.invoices.recall(id); mutate(); }} onViewInvoice={(invoice) => router.push(`/invoices/${invoice.id}`)} onDownloadInvoice={(invoice) => window.open(`/api/invoices/${invoice.id}/pdf`)} /> );}Overriding built-in Sheet form
Section titled “Overriding built-in Sheet form”By default, InvoicePage ships with a built-in right-side Sheet form for create and edit. Override either with your own UI using the renderCreateSheet / renderEditSheet render props:
<InvoicePage invoices={invoices} onSaveInvoice={save} onDeleteInvoice={del} onApproveInvoice={approve} onRecallToDraft={recall} renderCreateSheet={({ open, onClose, onSave }) => ( <MyCustomInvoiceForm open={open} onClose={onClose} onSubmit={onSave} /> )} renderEditSheet={({ open, onClose, onSave, invoice }) => ( <MyCustomInvoiceForm open={open} onClose={onClose} onSubmit={onSave} defaultValues={invoice} /> )}/>The delete dialog cannot currently be overridden — open a GitHub issue if you need this.
| Prop | Type | Required | Description |
|---|---|---|---|
invoices | Invoice[] | Yes | List of invoices to display |
kpiTrend | KpiTrend | No | Sparkline data for KPI cards. Defaults to flat if omitted. |
onSaveInvoice | (data, id?) => void | Promise<void> | Yes | Called on create (no id) and edit (with id). |
onDeleteInvoice | (id) => void | Promise<void> | Yes | Called after user confirms delete. |
onApproveInvoice | (id, dept) => void | Promise<void> | Yes | Called when approver signs off. dept is "Operation Department" or "Account Department". |
onRecallToDraft | (id) => void | Promise<void> | Yes | Called when user recalls an invoice to draft. |
onViewInvoice | (invoice) => void | No | Called when user clicks View. If omitted, View is hidden. |
onDownloadInvoice | (invoice) => void | No | Called when user clicks Download PDF. If omitted, Download is hidden. |
renderCreateSheet | (props) => ReactNode | No | Override the built-in create Sheet form. |
renderEditSheet | (props) => ReactNode | No | Override the built-in edit Sheet form. |
interface LineItem { id: string; description: string; hours: number; rate: number;}
interface ApprovalEntry { name: string; dept: "Operation Department" | "Account Department"; signed: boolean;}
interface Invoice { id: string; piNumber: string; billTo: string; date: string; status: "draft" | "pending" | "sent" | "approved" | "expired"; approvals: ApprovalEntry[]; lineItems: LineItem[]; grantTotal: number | null; trn?: string; project?: string; vatApplicable: boolean;}
interface InvoiceFormData { piNumber: string; billTo: string; date: string; approvals: ApprovalEntry[]; lineItems: LineItem[]; trn: string; project: string; vatApplicable: boolean;}
interface KpiTrend { total: number[]; pending: number[]; billed: number[]; vat: number[];}