Billing Invoice Details

PreviousNext

Detailed invoice view in a sheet with line items and payment information.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/billing/billing-invoice-details.tsx
"use client";

import { Download, Loader2, Printer } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import { Separator } from "@/registry/new-york/ui/separator";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from "@/registry/new-york/ui/sheet";

export interface InvoiceLineItem {
  description: string;
  quantity: number;
  unitPrice: number;
  subtotal: number;
}

export interface InvoiceDetails {
  id: string;
  invoiceNumber: string;
  date: Date;
  dueDate?: Date;
  amount: number;
  currency?: string;
  status: "paid" | "pending" | "failed" | "refunded" | "void";
  description?: string;
  lineItems: InvoiceLineItem[];
  subtotal: number;
  tax?: {
    amount: number;
    rate?: number;
    label?: string;
  };
  discount?: {
    amount: number;
    code?: string;
    label?: string;
  };
  total: number;
  paymentMethod?: {
    type: string;
    last4?: string;
    brand?: string;
  };
  billingAddress?: {
    name: string;
    line1: string;
    line2?: string;
    city: string;
    state: string;
    zip: string;
    country: string;
  };
  downloadUrl?: string;
}

export interface BillingInvoiceDetailsProps {
  invoice: InvoiceDetails | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onDownload?: (invoiceId: string) => void;
  onPrint?: (invoiceId: string) => void;
  className?: string;
  currency?: string;
}

function formatDate(date: Date): string {
  const year = date.getFullYear();
  const month = date.toLocaleString("en-US", { month: "short" });
  const day = date.getDate();
  return `${month} ${day}, ${year}`;
}

function formatPrice(amount: number, currency = "USD"): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(amount);
}

function getStatusConfig(status: InvoiceDetails["status"]) {
  switch (status) {
    case "paid":
      return {
        label: "Paid",
        variant: "default" as const,
        className: "bg-green-500/10 text-green-600 border-green-500/20",
      };
    case "pending":
      return {
        label: "Pending",
        variant: "secondary" as const,
        className: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
      };
    case "failed":
      return {
        label: "Failed",
        variant: "destructive" as const,
        className: "bg-destructive/10 text-destructive border-destructive/20",
      };
    case "refunded":
      return {
        label: "Refunded",
        variant: "secondary" as const,
        className: "bg-blue-500/10 text-blue-600 border-blue-500/20",
      };
    case "void":
      return {
        label: "Void",
        variant: "secondary" as const,
        className: "",
      };
    default:
      return {
        label: "Unknown",
        variant: "secondary" as const,
        className: "",
      };
  }
}

export default function BillingInvoiceDetails({
  invoice,
  open,
  onOpenChange,
  onDownload,
  onPrint,
  className,
  currency = "USD",
}: BillingInvoiceDetailsProps) {
  const [isDownloading, setIsDownloading] = useState(false);
  const [isPrinting, setIsPrinting] = useState(false);

  if (!invoice) return null;

  const statusConfig = getStatusConfig(invoice.status);
  const invoiceCurrency = invoice.currency || currency;

  const handleDownload = async () => {
    setIsDownloading(true);
    try {
      await onDownload?.(invoice.id);
    } finally {
      setIsDownloading(false);
    }
  };

  const handlePrint = async () => {
    setIsPrinting(true);
    try {
      await onPrint?.(invoice.id);
      window.print();
    } finally {
      setIsPrinting(false);
    }
  };

  return (
    <Sheet onOpenChange={onOpenChange} open={open}>
      <SheetContent
        className={cn(
          "flex flex-col gap-0 overflow-y-auto sm:max-w-2xl",
          className
        )}
      >
        <SheetHeader className="shrink-0">
          <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex min-w-0 flex-1 flex-col gap-2">
              <SheetTitle className="wrap-break-word">
                Invoice {invoice.invoiceNumber}
              </SheetTitle>
              <SheetDescription className="wrap-break-word">
                {invoice.description ||
                  `Invoice dated ${formatDate(invoice.date)}`}
              </SheetDescription>
            </div>
            <Badge
              className={cn("shrink-0 text-xs", statusConfig.className)}
              variant={statusConfig.variant}
            >
              {statusConfig.label}
            </Badge>
          </div>
        </SheetHeader>

        <div className="flex flex-1 flex-col gap-6 overflow-y-auto p-4">
          <div className="flex flex-col gap-6">
            <div className="flex flex-col gap-4 sm:flex-row sm:justify-between">
              <div className="flex flex-col gap-2">
                <h3 className="font-medium text-sm">Invoice Details</h3>
                <div className="flex flex-col gap-1 text-muted-foreground text-sm">
                  <div className="flex flex-wrap items-center gap-2">
                    <span>Date:</span>
                    <span className="text-foreground">
                      {formatDate(invoice.date)}
                    </span>
                  </div>
                  {invoice.dueDate && (
                    <div className="flex flex-wrap items-center gap-2">
                      <span>Due Date:</span>
                      <span className="text-foreground">
                        {formatDate(invoice.dueDate)}
                      </span>
                    </div>
                  )}
                  <div className="flex flex-wrap items-center gap-2">
                    <span>Status:</span>
                    <Badge
                      className={cn("text-xs", statusConfig.className)}
                      variant={statusConfig.variant}
                    >
                      {statusConfig.label}
                    </Badge>
                  </div>
                </div>
              </div>

              {invoice.billingAddress && (
                <div className="flex flex-col gap-2">
                  <h3 className="font-medium text-sm">Billing Address</h3>
                  <div className="flex flex-col gap-1 text-muted-foreground text-sm">
                    <div className="wrap-break-word text-foreground">
                      {invoice.billingAddress.name}
                    </div>
                    <div className="wrap-break-word">
                      {invoice.billingAddress.line1}
                    </div>
                    {invoice.billingAddress.line2 && (
                      <div className="wrap-break-word">
                        {invoice.billingAddress.line2}
                      </div>
                    )}
                    <div className="wrap-break-word">
                      {invoice.billingAddress.city},{" "}
                      {invoice.billingAddress.state}{" "}
                      {invoice.billingAddress.zip}
                    </div>
                    <div className="wrap-break-word">
                      {invoice.billingAddress.country}
                    </div>
                  </div>
                </div>
              )}
            </div>

            <Separator />

            <div className="flex flex-col gap-4">
              <h3 className="font-medium text-sm">Line Items</h3>
              <div className="flex flex-col gap-0 overflow-hidden rounded-lg border">
                <div className="hidden grid-cols-[2fr_1fr_1fr_1fr] gap-4 border-b bg-muted/50 p-3 font-medium text-muted-foreground text-xs sm:grid">
                  <div>Description</div>
                  <div className="text-right">Quantity</div>
                  <div className="text-right">Unit Price</div>
                  <div className="text-right">Subtotal</div>
                </div>
                {invoice.lineItems.map((item, idx) => (
                  <div
                    className="flex flex-col gap-2 border-b p-3 last:border-b-0 sm:grid sm:grid-cols-[2fr_1fr_1fr_1fr] sm:gap-4 sm:gap-y-0"
                    key={idx}
                  >
                    <div className="wrap-break-word font-medium text-sm">
                      {item.description}
                    </div>
                    <div className="flex items-center justify-between text-muted-foreground text-sm sm:justify-end">
                      <span className="sm:hidden">Quantity:</span>
                      <span>{item.quantity}</span>
                    </div>
                    <div className="flex items-center justify-between text-muted-foreground text-sm sm:justify-end">
                      <span className="sm:hidden">Unit Price:</span>
                      <span>
                        {formatPrice(item.unitPrice, invoiceCurrency)}
                      </span>
                    </div>
                    <div className="flex items-center justify-between font-medium text-sm sm:justify-end">
                      <span className="sm:hidden">Subtotal:</span>
                      <span>{formatPrice(item.subtotal, invoiceCurrency)}</span>
                    </div>
                  </div>
                ))}
              </div>
            </div>

            <Separator />

            <div className="flex flex-col gap-2">
              <div className="flex items-center justify-between text-sm">
                <span className="text-muted-foreground">Subtotal</span>
                <span>{formatPrice(invoice.subtotal, invoiceCurrency)}</span>
              </div>
              {invoice.discount && (
                <div className="flex items-center justify-between text-sm">
                  <span className="text-muted-foreground">
                    Discount
                    {invoice.discount.label && ` (${invoice.discount.label})`}
                    {invoice.discount.code && ` - ${invoice.discount.code}`}
                  </span>
                  <span className="text-green-600">
                    -{formatPrice(invoice.discount.amount, invoiceCurrency)}
                  </span>
                </div>
              )}
              {invoice.tax && (
                <div className="flex items-center justify-between text-sm">
                  <span className="text-muted-foreground">
                    Tax{invoice.tax.label && ` (${invoice.tax.label})`}
                    {invoice.tax.rate !== undefined &&
                      ` ${(invoice.tax.rate * 100).toFixed(1)}%`}
                  </span>
                  <span>
                    {formatPrice(invoice.tax.amount, invoiceCurrency)}
                  </span>
                </div>
              )}
              <Separator />
              <div className="flex items-center justify-between font-semibold text-base">
                <span>Total</span>
                <span>{formatPrice(invoice.total, invoiceCurrency)}</span>
              </div>
            </div>

            {invoice.paymentMethod && (
              <>
                <Separator />
                <div className="flex flex-col gap-2">
                  <h3 className="font-medium text-sm">Payment Method</h3>
                  <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm">
                    <span className="capitalize">
                      {invoice.paymentMethod.type}
                    </span>
                    {invoice.paymentMethod.brand && (
                      <>
                        <span aria-hidden="true">•</span>
                        <span className="capitalize">
                          {invoice.paymentMethod.brand}
                        </span>
                      </>
                    )}
                    {invoice.paymentMethod.last4 && (
                      <>
                        <span aria-hidden="true">•</span>
                        <span>•••• {invoice.paymentMethod.last4}</span>
                      </>
                    )}
                  </div>
                </div>
              </>
            )}
          </div>
        </div>

        <div className="flex shrink-0 flex-col gap-2 border-t p-4 sm:flex-row sm:justify-end">
          {onPrint && (
            <Button
              aria-label="Print invoice"
              className="w-full sm:w-auto"
              onClick={handlePrint}
              type="button"
              variant="outline"
            >
              {isPrinting ? (
                <>
                  <Loader2 className="size-4 animate-spin" />
                  Printing…
                </>
              ) : (
                <>
                  <Printer className="size-4" />
                  Print
                </>
              )}
            </Button>
          )}
          {onDownload && (
            <Button
              aria-label={`Download invoice ${invoice.invoiceNumber}`}
              className="w-full sm:w-auto"
              onClick={handleDownload}
              type="button"
            >
              {isDownloading ? (
                <>
                  <Loader2 className="size-4 animate-spin" />
                  Downloading…
                </>
              ) : (
                <>
                  <Download className="size-4" />
                  Download PDF
                </>
              )}
            </Button>
          )}
        </div>
      </SheetContent>
    </Sheet>
  );
}

Installation

npx shadcn@latest add @hextaui/billing-invoice-details

Usage

import { BillingInvoiceDetails } from "@/components/ui/billing-invoice-details"
<BillingInvoiceDetails />