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

@@ -13,7 +13,7 @@ export function Layout() {
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<Header onMenuClick={() => setSidebarOpen(true)} /> <Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 p-6"> <main className="flex-1 px-3 py-4 sm:p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -15,9 +15,9 @@ function ExpensesPageInner() {
return ( return (
<div className="space-y-4"> <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) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -25,8 +25,8 @@ function ExpensesPageInner() {
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={ className={
activeTab === tab.id activeTab === tab.id
? "-mb-px border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground" ? "-mb-px shrink-0 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" : "shrink-0 px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
} }
> >
{tab.label} {tab.label}

View File

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

View File

@@ -26,6 +26,38 @@ interface ExpensesTableProps {
onPageChange: (page: number) => void; 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) { export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }: ExpensesTableProps) {
const columns = useMemo<ColumnDef<Expense>[]>( const columns = useMemo<ColumnDef<Expense>[]>(
() => [ () => [
@@ -41,11 +73,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
accessorKey: "amount", accessorKey: "amount",
cell: ({ row }) => { cell: ({ row }) => {
const expense = row.original; const expense = row.original;
const isPayed = expense.status === "PAYED";
const value = isPayed && expense.amountPayed ? Number(expense.amountPayed) : Number(expense.amount);
return ( return (
<span className="tabular-nums"> <span className="tabular-nums">
${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })} {formatExpenseAmount(expense)}
</span> </span>
); );
}, },
@@ -54,15 +84,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
header: "Vencimiento", header: "Vencimiento",
accessorKey: "dueDate", accessorKey: "dueDate",
cell: ({ row }) => { cell: ({ row }) => {
const dueDate = new Date(row.getValue("dueDate"));
return ( return (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{dueDate.toLocaleDateString("es-AR", { {formatExpenseDate(row.getValue("dueDate") as string)}
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span> </span>
); );
}, },
@@ -71,19 +95,7 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
header: "Estado", header: "Estado",
accessorKey: "status", accessorKey: "status",
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue("status"); return <ExpenseStatusBadge status={row.getValue("status") as Expense["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>
);
}, },
}, },
{ {
@@ -94,15 +106,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
if (!paymentDate) { if (!paymentDate) {
return <span className="text-muted-foreground">--</span>; return <span className="text-muted-foreground">--</span>;
} }
const date = new Date(paymentDate as string);
return ( return (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{date.toLocaleDateString("es-AR", { {formatExpenseDate(paymentDate as string)}
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span> </span>
); );
}, },
@@ -141,10 +147,81 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
const currentGroup = Math.floor((page - 1) / GROUP_SIZE); const currentGroup = Math.floor((page - 1) / GROUP_SIZE);
const startPage = currentGroup * GROUP_SIZE + 1; const startPage = currentGroup * GROUP_SIZE + 1;
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages); 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 ( return (
<div className="space-y-4"> <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"> <table className="w-full text-sm">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -181,8 +258,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</table> </table>
</div> </div>
{mobilePagination}
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination> <Pagination className="hidden sm:flex">
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -29,6 +29,7 @@ interface PayExpenseDialogProps {
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) { export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext(); const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
const { const {
register, register,
@@ -43,6 +44,9 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
paymentDate: undefined, paymentDate: undefined,
}, },
}); });
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
setValueAs: (value) => Number(value),
});
useEffect(() => { useEffect(() => {
if (expense) { if (expense) {
@@ -53,6 +57,17 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
} }
}, [expense, reset]); }, [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) { function handleClose(open: boolean) {
onOpenChange(open); onOpenChange(open);
if (!open) reset(); if (!open) reset();
@@ -90,10 +105,14 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
</label> </label>
<input <input
id="amountPayed" id="amountPayed"
type="number" type="text"
step="0.01" inputMode="numeric"
min="0" pattern="[0-9]*"
{...register("amountPayed", { valueAsNumber: true })} {...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" 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 && ( {errors.amountPayed && (

View File

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

View File

@@ -114,41 +114,92 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
}); });
return ( return (
<div className="overflow-x-auto rounded-lg border"> <>
<table className="w-full text-sm"> <div className="space-y-2 sm:hidden">
<thead> {data.map((item) => (
{table.getHeaderGroups().map((headerGroup) => ( <div key={item.id} className="rounded-lg border bg-card p-3 text-sm">
<tr key={headerGroup.id} className="border-b bg-muted/50"> <div className="flex items-start justify-between gap-3">
{headerGroup.headers.map((header) => ( <div className="min-w-0">
<th <p className="truncate font-medium">{item.description}</p>
key={header.id} <p className="mt-1 text-lg font-semibold tabular-nums">
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground" ${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())} <Pencil className="size-3.5" />
</th> </Button>
))} <Button
</tr> variant="ghost"
))} size="icon"
</thead> aria-label={`Eliminar ${item.description}`}
<tbody> onClick={() => onDelete(item.id)}
{table.getRowModel().rows.map((row) => ( >
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30"> <Trash2 className="size-3.5" />
{row.getVisibleCells().map((cell) => ( </Button>
<td key={cell.id} className="px-3 py-2.5"> </div>
{flexRender(cell.column.columnDef.cell, cell.getContext())} </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> </td>
))} </tr>
</tr> )}
))} </tbody>
{table.getRowModel().rows.length === 0 && ( </table>
<tr> </div>
<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>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes, getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes,
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi, getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi,
@@ -135,6 +135,7 @@ export function useGenerateMonthlyExpense() {
export function useExpenses(status: string, page: number = 1, pageSize: number = 10, periodicExpenseId?: number, search?: string) { export function useExpenses(status: string, page: number = 1, pageSize: number = 10, periodicExpenseId?: number, search?: string) {
return useQuery({ return useQuery({
queryKey: expenseKeys.byFilters(status, page, pageSize, periodicExpenseId, search), queryKey: expenseKeys.byFilters(status, page, pageSize, periodicExpenseId, search),
placeholderData: keepPreviousData,
queryFn: () => getExpenses( queryFn: () => getExpenses(
status === "all" ? undefined : status, status === "all" ? undefined : status,
page, page,