Improve expenses mobile layout and payment flow
This commit is contained in:
@@ -26,6 +26,38 @@ interface ExpensesTableProps {
|
||||
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, page, total, pageSize, onPageChange }: ExpensesTableProps) {
|
||||
const columns = useMemo<ColumnDef<Expense>[]>(
|
||||
() => [
|
||||
@@ -41,11 +73,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
||||
accessorKey: "amount",
|
||||
cell: ({ row }) => {
|
||||
const expense = row.original;
|
||||
const isPayed = expense.status === "PAYED";
|
||||
const value = isPayed && expense.amountPayed ? Number(expense.amountPayed) : Number(expense.amount);
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
|
||||
{formatExpenseAmount(expense)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -54,15 +84,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
||||
header: "Vencimiento",
|
||||
accessorKey: "dueDate",
|
||||
cell: ({ row }) => {
|
||||
const dueDate = new Date(row.getValue("dueDate"));
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{dueDate.toLocaleDateString("es-AR", {
|
||||
timeZone: "UTC",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}
|
||||
{formatExpenseDate(row.getValue("dueDate") as string)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -71,19 +95,7 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
||||
header: "Estado",
|
||||
accessorKey: "status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
const isPayed = status === "PAYED";
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
isPayed
|
||||
? "inline-flex h-5 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 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>
|
||||
);
|
||||
return <ExpenseStatusBadge status={row.getValue("status") as Expense["status"]} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -94,15 +106,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
||||
if (!paymentDate) {
|
||||
return <span className="text-muted-foreground">--</span>;
|
||||
}
|
||||
const date = new Date(paymentDate as string);
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{date.toLocaleDateString("es-AR", {
|
||||
timeZone: "UTC",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}
|
||||
{formatExpenseDate(paymentDate as string)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -141,10 +147,81 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
||||
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">
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{expense.status !== "PAYED" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onPay(expense)}
|
||||
>
|
||||
<CircleDollarSign className="size-3.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
)}
|
||||
</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) => (
|
||||
@@ -181,8 +258,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{mobilePagination}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<Pagination className="hidden sm:flex">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
|
||||
Reference in New Issue
Block a user