Improve expenses mobile layout and payment flow

This commit is contained in:
Jose Selesan
2026-06-01 08:03:48 -03:00
parent 988d58d761
commit 2fd23cedc6
8 changed files with 249 additions and 96 deletions

View File

@@ -15,9 +15,9 @@ function ExpensesPageInner() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Gastos</h1>
<h1 className="text-2xl font-semibold text-balance">Gastos</h1>
<div className="flex gap-1 border-b">
<div className="flex gap-1 overflow-x-auto border-b">
{tabs.map((tab) => (
<button
key={tab.id}
@@ -25,8 +25,8 @@ function ExpensesPageInner() {
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.id
? "-mb-px border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground"
: "px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
? "-mb-px shrink-0 border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground"
: "shrink-0 px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
}
>
{tab.label}

View File

@@ -64,21 +64,22 @@ export function ExpensesTabContent() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex gap-1">
<div className="space-y-3 sm:flex sm:flex-wrap sm:items-center sm:justify-between sm:gap-2 sm:space-y-0">
<div className="flex gap-1 overflow-x-auto pb-1 sm:pb-0">
{filters.map((f) => (
<Button
key={f.value}
variant={statusFilter === f.value ? "default" : "outline"}
size="xs"
className="shrink-0"
onClick={() => { setStatusFilter(f.value); setPage(1); }}
>
{f.label}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<div className="relative w-48">
<div className="grid gap-2 sm:flex sm:items-center">
<div className="relative min-w-0 sm:w-48">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar por descripción..."
@@ -103,7 +104,7 @@ export function ExpensesTabContent() {
setPage(1);
}}
>
<SelectTrigger className="w-52">
<SelectTrigger className="w-full sm:w-52">
<SelectValue placeholder="Todos los gastos" />
</SelectTrigger>
<SelectContent>
@@ -115,14 +116,16 @@ export function ExpensesTabContent() {
))}
</SelectContent>
</Select>
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm">
<Upload className="size-4" />
Importar
</Button>
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
<Plus className="size-4" />
Nuevo gasto
</Button>
<div className="grid grid-cols-2 gap-2 sm:flex">
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm" className="min-w-0">
<Upload className="size-4" />
<span className="truncate">Importar</span>
</Button>
<Button onClick={() => setNewExpenseOpen(true)} size="sm" className="min-w-0">
<Plus className="size-4" />
<span className="truncate">Nuevo gasto</span>
</Button>
</div>
</div>
</div>

View File

@@ -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

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -29,6 +29,7 @@ interface PayExpenseDialogProps {
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
const {
register,
@@ -43,6 +44,9 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
paymentDate: undefined,
},
});
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
setValueAs: (value) => Number(value),
});
useEffect(() => {
if (expense) {
@@ -53,6 +57,17 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
}
}, [expense, reset]);
useEffect(() => {
if (!open || !expense) return;
const frame = requestAnimationFrame(() => {
amountInputRef.current?.focus();
amountInputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}, [open, expense]);
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
@@ -90,10 +105,14 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
</label>
<input
id="amountPayed"
type="number"
step="0.01"
min="0"
{...register("amountPayed", { valueAsNumber: true })}
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...amountPayedField}
ref={(node) => {
amountPayedRef(node);
amountInputRef.current = node;
}}
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.amountPayed && (

View File

@@ -92,18 +92,18 @@ export function PeriodicExpensesTabContent() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-3 sm:flex sm:items-center sm:justify-between sm:space-y-0">
<p className="text-sm text-muted-foreground">
Administrá tus gastos recurrentes.
</p>
<div className="flex gap-2">
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm">
<div className="grid grid-cols-2 gap-2 sm:flex">
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm" className="min-w-0">
<Upload className="size-4" />
Importar
<span className="truncate">Importar</span>
</Button>
<Button onClick={openCreate} size="sm">
<Button onClick={openCreate} size="sm" className="min-w-0">
<Plus className="size-4" />
Nuevo
<span className="truncate">Nuevo</span>
</Button>
</div>
</div>

View File

@@ -114,41 +114,92 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
});
return (
<div className="overflow-x-auto rounded-lg border">
<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"
<>
<div className="space-y-2 sm:hidden">
{data.map((item) => (
<div key={item.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">{item.description}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
${Number(item.defaultAmount).toLocaleString("es-AR", { minimumFractionDigits: 2 })}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button
variant="ghost"
size="icon"
aria-label={`Editar ${item.description}`}
onClick={() => onEdit(item)}
>
{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())}
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Eliminar ${item.description}`}
onClick={() => onDelete(item.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<span className="block">Vence</span>
<span className="font-medium text-foreground">Día {item.defaultDueDay}</span>
</div>
<div className="min-w-0 rounded-md bg-muted/40 px-2 py-1.5">
<span className="block">Meses</span>
<span className="block truncate font-medium text-foreground">{formatPeriods(item.periods)}</span>
</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 periódicos todavía.
</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 periódicos todavía.
</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 periódicos todavía.
</td>
</tr>
)}
</tbody>
</table>
</div>
</tr>
)}
</tbody>
</table>
</div>
</>
);
}