Todos os componentes

Billing Billing History

Billing Billing History é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

HextaUI MIT other

O que é

Billing Billing History é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Não precisa de pacotes além do que o projeto já tem

Licença

Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Billing Billing History

registry/new-york/blocks/billing/billing-billing-history.tsx
"use client";

import { ArrowDown, ArrowUp, Download, FileText, Search } from "lucide-react";
import { useMemo, 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 {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/registry/new-york/ui/select";
import { Separator } from "@/registry/new-york/ui/separator";

export interface BillingTransaction {
  id: string;
  date: Date;
  type: "charge" | "refund" | "credit" | "adjustment" | "subscription";
  amount: number;
  currency?: string;
  description: string;
  status: "completed" | "pending" | "failed";
  invoiceId?: string;
  paymentMethod?: string;
  metadata?: Record<string, unknown>;
}

export interface BillingBillingHistoryProps {
  transactions: BillingTransaction[];
  onViewDetails?: (transactionId: string) => void;
  onExport?: () => void;
  className?: string;
  showFilters?: boolean;
  showSearch?: boolean;
  showSummary?: boolean;
  currency?: string;
  itemsPerPage?: number;
  groupBy?: "none" | "month" | "year";
}

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 {
  const sign = amount >= 0 ? "" : "-";
  return `${sign}${new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(Math.abs(amount))}`;
}

function getTypeConfig(type: BillingTransaction["type"]) {
  switch (type) {
    case "charge":
      return { label: "Charge", icon: ArrowDown, className: "text-green-600" };
    case "refund":
      return { label: "Refund", icon: ArrowUp, className: "text-blue-600" };
    case "credit":
      return { label: "Credit", icon: ArrowUp, className: "text-blue-600" };
    case "adjustment":
      return {
        label: "Adjustment",
        icon: ArrowUp,
        className: "text-yellow-600",
      };
    case "subscription":
      return {
        label: "Subscription",
        icon: ArrowDown,
        className: "text-primary",
      };
    default:
      return { label: "Transaction", icon: FileText, className: "" };
  }
}

function getStatusConfig(status: BillingTransaction["status"]) {
  switch (status) {
    case "completed":
      return {
        label: "Completed",
        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",
      };
    default:
      return {
        label: "Unknown",
        variant: "secondary" as const,
        className: "",
      };
  }
}

export default function BillingBillingHistory({
  transactions,
  onViewDetails,
  onExport,
  className,
  showFilters = true,
  showSearch = true,
  showSummary = true,
  currency = "USD",
  itemsPerPage = 10,
  groupBy = "none",
}: BillingBillingHistoryProps) {
  const [searchQuery, setSearchQuery] = useState("");
  const [typeFilter, setTypeFilter] = useState<string>("all");
  const [statusFilter, setStatusFilter] = useState<string>("all");
  const [sortBy, setSortBy] = useState<"date" | "amount">("date");
  const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
  const [currentPage, setCurrentPage] = useState(1);

  const filteredTransactions = useMemo(() => {
    let filtered = transactions;

    if (searchQuery) {
      const query = searchQuery.toLowerCase();
      filtered = filtered.filter(
        (tx) =>
          tx.description.toLowerCase().includes(query) ||
          tx.id.toLowerCase().includes(query) ||
          tx.invoiceId?.toLowerCase().includes(query) ||
          formatPrice(tx.amount, tx.currency || currency)
            .toLowerCase()
            .includes(query)
      );
    }

    if (typeFilter !== "all") {
      filtered = filtered.filter((tx) => tx.type === typeFilter);
    }

    if (statusFilter !== "all") {
      filtered = filtered.filter((tx) => tx.status === statusFilter);
    }

    filtered.sort((a, b) => {
      let comparison = 0;
      if (sortBy === "date") {
        comparison = a.date.getTime() - b.date.getTime();
      } else {
        comparison = a.amount - b.amount;
      }
      return sortOrder === "asc" ? comparison : -comparison;
    });

    return filtered;
  }, [
    transactions,
    searchQuery,
    typeFilter,
    statusFilter,
    sortBy,
    sortOrder,
    currency,
  ]);

  const summary = useMemo(() => {
    const charges = filteredTransactions
      .filter((tx) => tx.type === "charge" && tx.status === "completed")
      .reduce((sum, tx) => sum + tx.amount, 0);
    const refunds = filteredTransactions
      .filter((tx) => tx.type === "refund" && tx.status === "completed")
      .reduce((sum, tx) => sum + Math.abs(tx.amount), 0);
    const credits = filteredTransactions
      .filter((tx) => tx.type === "credit" && tx.status === "completed")
      .reduce((sum, tx) => sum + Math.abs(tx.amount), 0);
    return { charges, refunds, credits, net: charges - refunds - credits };
  }, [filteredTransactions]);

  const paginatedTransactions = useMemo(() => {
    const start = (currentPage - 1) * itemsPerPage;
    const end = start + itemsPerPage;
    return filteredTransactions.slice(start, end);
  }, [filteredTransactions, currentPage, itemsPerPage]);

  const totalPages = Math.ceil(filteredTransactions.length / itemsPerPage);

  if (transactions.length === 0) {
    return (
      <Card className={cn("w-full shadow-xs", className)}>
        <CardHeader>
          <CardTitle>Billing history</CardTitle>
          <CardDescription>Your transaction history</CardDescription>
        </CardHeader>
        <CardContent>
          <div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
            <div className="flex size-12 items-center justify-center rounded-full bg-muted">
              <FileText className="size-6 text-muted-foreground" />
            </div>
            <p className="text-muted-foreground text-sm">
              No transactions found
            </p>
          </div>
        </CardContent>
      </Card>
    );
  }

  return (
    <Card className={cn("w-full shadow-xs", className)}>
      <CardHeader>
        <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
          <div className="flex flex-col gap-1">
            <CardTitle>Billing history</CardTitle>
            <CardDescription>View all your transactions</CardDescription>
          </div>
          {onExport && (
            <Button
              className="w-full sm:w-auto"
              onClick={onExport}
              type="button"
              variant="outline"
            >
              <Download className="size-4" />
              Export CSV
            </Button>
          )}
        </div>
      </CardHeader>
      <CardContent>
        <div className="flex flex-col gap-6">
          {showSummary && (
            <div className="grid grid-cols-2 gap-4 rounded-lg border bg-muted/50 p-4 sm:grid-cols-4">
              <div className="flex flex-col gap-1">
                <span className="text-muted-foreground text-xs">
                  Total charges
                </span>
                <span className="font-semibold text-sm">
                  {formatPrice(summary.charges, currency)}
                </span>
              </div>
              <div className="flex flex-col gap-1">
                <span className="text-muted-foreground text-xs">Refunds</span>
                <span className="font-semibold text-sm">
                  {formatPrice(summary.refunds, currency)}
                </span>
              </div>
              <div className="flex flex-col gap-1">
                <span className="text-muted-foreground text-xs">Credits</span>
                <span className="font-semibold text-sm">
                  {formatPrice(summary.credits, currency)}
                </span>
              </div>
              <div className="flex flex-col gap-1">
                <span className="text-muted-foreground text-xs">Net total</span>
                <span className="font-semibold text-sm">
                  {formatPrice(summary.net, currency)}
                </span>
              </div>
            </div>
          )}

          {(showSearch || showFilters) && (
            <div className="flex flex-col gap-4 sm:flex-row">
              {showSearch && (
                <div className="flex-1">
                  <InputGroup>
                    <InputGroupAddon>
                      <Search className="size-4" />
                    </InputGroupAddon>
                    <InputGroupInput
                      onChange={(e) => {
                        setSearchQuery(e.target.value);
                        setCurrentPage(1);
                      }}
                      placeholder="Search transactions…"
                      type="search"
                      value={searchQuery}
                    />
                  </InputGroup>
                </div>
              )}
              {showFilters && (
                <div className="flex flex-col gap-2 sm:flex-row">
                  <Select
                    onValueChange={(value) => {
                      setTypeFilter(value);
                      setCurrentPage(1);
                    }}
                    value={typeFilter}
                  >
                    <SelectTrigger className="w-full sm:w-[140px]">
                      <SelectValue placeholder="Type" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="all">All types</SelectItem>
                      <SelectItem value="charge">Charges</SelectItem>
                      <SelectItem value="refund">Refunds</SelectItem>
                      <SelectItem value="credit">Credits</SelectItem>
                      <SelectItem value="adjustment">Adjustments</SelectItem>
                      <SelectItem value="subscription">
                        Subscriptions
                      </SelectItem>
                    </SelectContent>
                  </Select>
                  <Select
                    onValueChange={(value) => {
                      setStatusFilter(value);
                      setCurrentPage(1);
                    }}
                    value={statusFilter}
                  >
                    <SelectTrigger className="w-full sm:w-[140px]">
                      <SelectValue placeholder="Status" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="all">All statuses</SelectItem>
                      <SelectItem value="completed">Completed</SelectItem>
                      <SelectItem value="pending">Pending</SelectItem>
                      <SelectItem value="failed">Failed</SelectItem>
                    </SelectContent>
                  </Select>
                  <Select
                    onValueChange={(value) => {
                      const [by, order] = value.split("-") as [
                        "date" | "amount",
                        "asc" | "desc",
                      ];
                      setSortBy(by);
                      setSortOrder(order);
                      setCurrentPage(1);
                    }}
                    value={`${sortBy}-${sortOrder}`}
                  >
                    <SelectTrigger className="w-full sm:w-[140px]">
                      <SelectValue placeholder="Sort" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="date-desc">Newest first</SelectItem>
                      <SelectItem value="date-asc">Oldest first</SelectItem>
                      <SelectItem value="amount-desc">
                        Amount: High to Low
                      </SelectItem>
                      <SelectItem value="amount-asc">
                        Amount: Low to High
                      </SelectItem>
                    </SelectContent>
                  </Select>
                </div>
              )}
            </div>
          )}

          {paginatedTransactions.length === 0 ? (
            <div className="flex flex-col items-center justify-center py-12 text-center">
              <p className="text-muted-foreground text-sm">
                No transactions match your filters
              </p>
            </div>
          ) : (
            <>
              <div className="flex flex-col gap-2">
                {paginatedTransactions.map((transaction) => {
                  const typeConfig = getTypeConfig(transaction.type);
                  const statusConfig = getStatusConfig(transaction.status);
                  const TypeIcon = typeConfig.icon;
                  return (
                    <div
                      className="flex flex-col gap-3 rounded-lg border bg-card p-4 sm:flex-row sm:items-center sm:justify-between"
                      key={transaction.id}
                    >
                      <div className="flex min-w-0 flex-1 items-center gap-3">
                        <div
                          className={cn(
                            "flex size-8 shrink-0 items-center justify-center rounded-lg border",
                            typeConfig.className
                          )}
                        >
                          <TypeIcon className="size-4" />
                        </div>
                        <div className="flex min-w-0 flex-1 flex-col gap-2">
                          <div className="flex flex-wrap items-center gap-2">
                            <span className="wrap-break-word font-medium text-sm">
                              {transaction.description}
                            </span>
                            <Badge
                              className={cn("text-xs", statusConfig.className)}
                              variant={statusConfig.variant}
                            >
                              {statusConfig.label}
                            </Badge>
                          </div>
                          <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
                            <span>{formatDate(transaction.date)}</span>
                            {transaction.invoiceId && (
                              <>
                                <span aria-hidden="true">•</span>
                                <span>{transaction.invoiceId}</span>
                              </>
                            )}
                            {transaction.paymentMethod && (
                              <>
                                <span aria-hidden="true">•</span>
                                <span>{transaction.paymentMethod}</span>
                              </>
                            )}
                          </div>
                        </div>
                      </div>
                      <div className="flex shrink-0 items-center gap-3">
                        <span
                          className={cn(
                            "font-medium text-sm",
                            transaction.amount < 0 && "text-green-600",
                            transaction.amount > 0 && "text-foreground"
                          )}
                        >
                          {formatPrice(
                            transaction.amount,
                            transaction.currency || currency
                          )}
                        </span>
                        {onViewDetails && (
                          <Button
                            onClick={() => onViewDetails(transaction.id)}
                            type="button"
                            variant="ghost"
                          >
                            View
                          </Button>
                        )}
                      </div>
                    </div>
                  );
                })}
              </div>

              {totalPages > 1 && (
                <>
                  <Separator />
                  <div className="flex items-center justify-between">
                    <p className="text-muted-foreground text-sm">
                      Page {currentPage} of {totalPages}
                    </p>
                    <div className="flex gap-2">
                      <Button
                        disabled={currentPage === 1}
                        onClick={() => setCurrentPage((p) => p - 1)}
                        type="button"
                        variant="outline"
                      >
                        Previous
                      </Button>
                      <Button
                        disabled={currentPage === totalPages}
                        onClick={() => setCurrentPage((p) => p + 1)}
                        type="button"
                        variant="outline"
                      >
                        Next
                      </Button>
                    </div>
                  </div>
                </>
              )}
            </>
          )}
        </div>
      </CardContent>
    </Card>
  );
}

Componentes parecidos