448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import {
|
|
type ColumnDef,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import {
|
|
ChevronsLeftIcon,
|
|
ChevronsRightIcon,
|
|
CircleDollarSign,
|
|
Pencil,
|
|
SkipBackIcon,
|
|
SkipForwardIcon,
|
|
} from "lucide-react";
|
|
import { useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from "@/components/ui/pagination";
|
|
import type { Expense } from "@/lib/api";
|
|
|
|
interface ExpensesTableProps {
|
|
data: Expense[];
|
|
onPay: (expense: Expense) => void;
|
|
onEdit: (expense: Expense) => void;
|
|
page: number;
|
|
total: number;
|
|
pageSize: number;
|
|
onPageChange: (page: number) => void;
|
|
}
|
|
|
|
function formatExpenseDate(value: string) {
|
|
return new Date(value).toLocaleDateString("es-AR", {
|
|
timeZone: "UTC",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function formatExpenseAmount(expense: Expense) {
|
|
const isPayed = expense.status === "PAYED";
|
|
const value =
|
|
isPayed && expense.amountPayed
|
|
? Number(expense.amountPayed)
|
|
: Number(expense.amount);
|
|
|
|
return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function ExpenseStatusBadge({ status }: { status: Expense["status"] }) {
|
|
const isPayed = status === "PAYED";
|
|
|
|
return (
|
|
<span
|
|
className={
|
|
isPayed
|
|
? "inline-flex h-5 shrink-0 items-center rounded-md bg-green-100 px-1.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
: "inline-flex h-5 shrink-0 items-center rounded-md bg-yellow-100 px-1.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
|
}
|
|
>
|
|
{isPayed ? "Pagado" : "Pendiente"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ExpensesTable({
|
|
data,
|
|
onPay,
|
|
onEdit,
|
|
page,
|
|
total,
|
|
pageSize,
|
|
onPageChange,
|
|
}: ExpensesTableProps) {
|
|
const columns = useMemo<ColumnDef<Expense>[]>(
|
|
() => [
|
|
{
|
|
header: "Descripción",
|
|
accessorKey: "description",
|
|
cell: ({ row }) => (
|
|
<span className="font-medium">{row.getValue("description")}</span>
|
|
),
|
|
},
|
|
{
|
|
header: "Monto",
|
|
accessorKey: "amount",
|
|
cell: ({ row }) => {
|
|
const expense = row.original;
|
|
return (
|
|
<span className="tabular-nums">{formatExpenseAmount(expense)}</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: "USDC",
|
|
accessorKey: "usdcEquivalent",
|
|
cell: ({ row }) => {
|
|
const value = row.getValue("usdcEquivalent");
|
|
if (!value) {
|
|
return <span className="text-muted-foreground">--</span>;
|
|
}
|
|
return (
|
|
<span className="tabular-nums">
|
|
{Number(value).toLocaleString("es-AR", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}{" "}
|
|
USDC
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: "Vencimiento",
|
|
accessorKey: "dueDate",
|
|
cell: ({ row }) => {
|
|
return (
|
|
<span className="text-muted-foreground">
|
|
{formatExpenseDate(row.getValue("dueDate") as string)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: "Estado",
|
|
accessorKey: "status",
|
|
cell: ({ row }) => {
|
|
return (
|
|
<ExpenseStatusBadge
|
|
status={row.getValue("status") as Expense["status"]}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: "Fecha de pago",
|
|
accessorKey: "paymentDate",
|
|
cell: ({ row }) => {
|
|
const paymentDate = row.getValue("paymentDate");
|
|
if (!paymentDate) {
|
|
return <span className="text-muted-foreground">--</span>;
|
|
}
|
|
return (
|
|
<span className="text-muted-foreground">
|
|
{formatExpenseDate(paymentDate as string)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
cell: ({ row }) => {
|
|
const expense = row.original;
|
|
return (
|
|
<div className="flex justify-end gap-1">
|
|
<Button variant="ghost" size="xs" onClick={() => onEdit(expense)}>
|
|
<Pencil className="size-3.5" />
|
|
Editar
|
|
</Button>
|
|
{expense.status !== "PAYED" && (
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => onPay(expense)}
|
|
>
|
|
<CircleDollarSign className="size-3.5" />
|
|
Pagar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[onPay, onEdit],
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
});
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
const GROUP_SIZE = 3;
|
|
const currentGroup = Math.floor((page - 1) / GROUP_SIZE);
|
|
const startPage = currentGroup * GROUP_SIZE + 1;
|
|
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages);
|
|
const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
|
const lastItem = Math.min(page * pageSize, total);
|
|
const mobilePagination =
|
|
total > 0 ? (
|
|
<div className="rounded-lg border bg-card p-2 sm:hidden">
|
|
<div className="mb-2 text-center text-xs text-muted-foreground tabular-nums">
|
|
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => onPageChange(page - 1)}
|
|
>
|
|
Anterior
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= totalPages}
|
|
onClick={() => onPageChange(page + 1)}
|
|
>
|
|
Siguiente
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{mobilePagination}
|
|
|
|
<div className="space-y-2 sm:hidden">
|
|
{data.map((expense) => (
|
|
<div
|
|
key={expense.id}
|
|
className="rounded-lg border bg-card p-3 text-sm"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium">{expense.description}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Vence {formatExpenseDate(expense.dueDate)}
|
|
</p>
|
|
</div>
|
|
<ExpenseStatusBadge status={expense.status} />
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-end justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-lg font-semibold tabular-nums">
|
|
{formatExpenseAmount(expense)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{expense.paymentDate
|
|
? `Pagado ${formatExpenseDate(expense.paymentDate)}`
|
|
: "Sin fecha de pago"}
|
|
</p>
|
|
{expense.usdcEquivalent && (
|
|
<p className="text-xs text-muted-foreground tabular-nums">
|
|
{Number(expense.usdcEquivalent).toLocaleString("es-AR", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}{" "}
|
|
USDC
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => onEdit(expense)}
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</Button>
|
|
{expense.status !== "PAYED" && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => onPay(expense)}
|
|
>
|
|
<CircleDollarSign className="size-3.5" />
|
|
Pagar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{data.length === 0 && (
|
|
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
|
|
No hay gastos para mostrar.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="hidden overflow-x-auto rounded-lg border sm:block">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id} className="border-b bg-muted/50">
|
|
{headerGroup.headers.map((header) => (
|
|
<th
|
|
key={header.id}
|
|
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
|
>
|
|
{flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{table.getRowModel().rows.map((row) => (
|
|
<tr
|
|
key={row.id}
|
|
className="border-b last:border-0 hover:bg-muted/30"
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<td key={cell.id} className="px-3 py-2.5">
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
{table.getRowModel().rows.length === 0 && (
|
|
<tr>
|
|
<td
|
|
colSpan={columns.length}
|
|
className="px-3 py-8 text-center text-sm text-muted-foreground"
|
|
>
|
|
No hay gastos para mostrar.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{mobilePagination}
|
|
|
|
{totalPages > 1 && (
|
|
<Pagination className="hidden sm:flex">
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<PaginationLink
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onPageChange(1);
|
|
}}
|
|
href="#"
|
|
aria-label="Ir a la primera página"
|
|
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
|
>
|
|
<SkipBackIcon className="size-4" />
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
<PaginationItem>
|
|
<PaginationPrevious
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (page > 1) onPageChange(page - 1);
|
|
}}
|
|
href="#"
|
|
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
|
/>
|
|
</PaginationItem>
|
|
{currentGroup > 0 && (
|
|
<PaginationItem>
|
|
<PaginationLink
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onPageChange(startPage - 1);
|
|
}}
|
|
href="#"
|
|
aria-label="Ir al grupo anterior"
|
|
>
|
|
<ChevronsLeftIcon className="size-4" />
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
)}
|
|
{Array.from(
|
|
{ length: endPage - startPage + 1 },
|
|
(_, i) => startPage + i,
|
|
).map((p) => (
|
|
<PaginationItem key={p}>
|
|
<PaginationLink
|
|
isActive={p === page}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onPageChange(p);
|
|
}}
|
|
href="#"
|
|
>
|
|
{p}
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
))}
|
|
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
|
|
<PaginationItem>
|
|
<PaginationLink
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onPageChange(endPage + 1);
|
|
}}
|
|
href="#"
|
|
aria-label="Ir al siguiente grupo"
|
|
>
|
|
<ChevronsRightIcon className="size-4" />
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
)}
|
|
<PaginationItem>
|
|
<PaginationNext
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (page < totalPages) onPageChange(page + 1);
|
|
}}
|
|
href="#"
|
|
className={
|
|
page >= totalPages ? "pointer-events-none opacity-50" : ""
|
|
}
|
|
/>
|
|
</PaginationItem>
|
|
<PaginationItem>
|
|
<PaginationLink
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onPageChange(totalPages);
|
|
}}
|
|
href="#"
|
|
aria-label="Ir a la última página"
|
|
className={
|
|
page >= totalPages ? "pointer-events-none opacity-50" : ""
|
|
}
|
|
>
|
|
<SkipForwardIcon className="size-4" />
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
</Pagination>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|