From 04b3f87aca3613f8f15a1330db93517dc12401cd Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 17 Jun 2026 11:10:20 -0300 Subject: [PATCH] feat(wallets): implement wallet movements feature with filtering and pagination --- .../wallets/handlers/listAllMovements.ts | 21 + .../src/modules/wallets/wallets.routes.ts | 2 + .../src/modules/wallets/wallets.service.ts | 49 ++ .../features/wallets/WalletMovementsPage.tsx | 516 ++++++++++++++++++ .../wallets/components/WalletCard.tsx | 13 +- apps/frontend/src/lib/api.ts | 23 + apps/frontend/src/lib/queries.ts | 14 +- .../routes/_authenticated/wallets.index.tsx | 6 + .../_authenticated/wallets.movements.tsx | 10 + .../src/routes/_authenticated/wallets.tsx | 9 +- 10 files changed, 651 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/modules/wallets/handlers/listAllMovements.ts create mode 100644 apps/frontend/src/features/wallets/WalletMovementsPage.tsx create mode 100644 apps/frontend/src/routes/_authenticated/wallets.index.tsx create mode 100644 apps/frontend/src/routes/_authenticated/wallets.movements.tsx diff --git a/apps/backend/src/modules/wallets/handlers/listAllMovements.ts b/apps/backend/src/modules/wallets/handlers/listAllMovements.ts new file mode 100644 index 0000000..05802d0 --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/listAllMovements.ts @@ -0,0 +1,21 @@ +import type { Context } from "hono"; +import { listAllMovements } from "../wallets.service"; + +export async function listAllMovementsHandler(c: Context) { + const walletId = c.req.query("walletId") + ? Number(c.req.query("walletId")) + : undefined; + const startDate = c.req.query("startDate") || undefined; + const endDate = c.req.query("endDate") || undefined; + const page = parseInt(c.req.query("page") ?? "1", 10); + const pageSize = parseInt(c.req.query("pageSize") ?? "20", 10); + + const result = await listAllMovements({ + walletId, + startDate, + endDate, + page, + pageSize, + }); + return c.json(result); +} diff --git a/apps/backend/src/modules/wallets/wallets.routes.ts b/apps/backend/src/modules/wallets/wallets.routes.ts index 98b72b2..7acc856 100644 --- a/apps/backend/src/modules/wallets/wallets.routes.ts +++ b/apps/backend/src/modules/wallets/wallets.routes.ts @@ -3,6 +3,7 @@ import { adjustBalanceHandler } from "./handlers/adjustBalance"; import { createWalletHandler } from "./handlers/createWallet"; import { deleteWalletHandler } from "./handlers/deleteWallet"; import { depositWalletHandler } from "./handlers/depositWallet"; +import { listAllMovementsHandler } from "./handlers/listAllMovements"; import { listMovementsHandler } from "./handlers/listMovements"; import { listWalletsHandler } from "./handlers/listWallets"; import { transferWalletHandler } from "./handlers/transferWallet"; @@ -11,6 +12,7 @@ import { setDefaultWalletHandler } from "./handlers/setDefaultWallet"; const app = new Hono(); +app.get("/movements", listAllMovementsHandler); app.get("/", listWalletsHandler); app.post("/", createWalletHandler); app.put("/:id", updateWalletHandler); diff --git a/apps/backend/src/modules/wallets/wallets.service.ts b/apps/backend/src/modules/wallets/wallets.service.ts index 8fc03d4..48bc6b4 100644 --- a/apps/backend/src/modules/wallets/wallets.service.ts +++ b/apps/backend/src/modules/wallets/wallets.service.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Prisma } from "../../generated/prisma/client"; import { prisma } from "../../lib/prisma"; +import { cache } from "../../lib/cache"; export const createWalletSchema = z.object({ name: z.string().min(1).max(100), @@ -192,3 +193,51 @@ export async function listMovements(walletId: number) { }); return movements.map((m) => ({ ...m, amount: Number(m.amount) })); } + +export async function listAllMovements(params: { + walletId?: number; + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +}) { + const { walletId, startDate, endDate, page = 1, pageSize = 20 } = params; + + const cacheKey = `movements:all:${walletId ?? "all"}:${startDate ?? ""}:${endDate ?? ""}:${page}:${pageSize}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const where: Record = {}; + if (walletId !== undefined) { + where.walletId = walletId; + } + if (startDate || endDate) { + const createdAt: Record = {}; + if (startDate) createdAt.gte = new Date(startDate); + if (endDate) { + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + createdAt.lte = end; + } + where.createdAt = createdAt; + } + + const [data, total] = await Promise.all([ + prisma.walletMovement.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.walletMovement.count({ where }), + ]); + + const result = { + data: data.map((m) => ({ ...m, amount: Number(m.amount) })), + total, + page, + pageSize, + }; + cache.set(cacheKey, result); + return result; +} diff --git a/apps/frontend/src/features/wallets/WalletMovementsPage.tsx b/apps/frontend/src/features/wallets/WalletMovementsPage.tsx new file mode 100644 index 0000000..d52bc18 --- /dev/null +++ b/apps/frontend/src/features/wallets/WalletMovementsPage.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { subMonths, startOfMonth } from "date-fns"; +import { + ChevronsLeftIcon, + ChevronsRightIcon, + SkipBackIcon, + SkipForwardIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useSearch } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; +import { DatePicker } from "@/components/ui/date-picker"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { Wallet, WalletMovement, WalletMovementsFilter } from "@/lib/api"; +import { useWalletMovementsFiltered, useWallets } from "@/lib/queries"; + +const amountFormatter = new Intl.NumberFormat("es-AR", { + minimumFractionDigits: 2, + maximumFractionDigits: 4, +}); + +const dateFormatter = new Intl.DateTimeFormat("es-AR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +const typeLabels: Record = { + DEPOSIT: "Depósito", + WITHDRAWAL: "Extracción", + TRANSFER_IN: "Transferencia recibida", + TRANSFER_OUT: "Transferencia enviada", + ADJUSTMENT: "Ajuste", +}; + +function TypeBadge({ type }: { type: string }) { + const styles: Record = { + DEPOSIT: + "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", + WITHDRAWAL: + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + TRANSFER_IN: + "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", + TRANSFER_OUT: + "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400", + ADJUSTMENT: + "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", + }; + + return ( + + {typeLabels[type] ?? type} + + ); +} + +export function WalletMovementsPage() { + const { walletId: initialWalletId } = useSearch({ + from: "/_authenticated/wallets/movements", + }); + const { data: wallets } = useWallets(); + + const [walletId, setWalletId] = useState(initialWalletId); + + const today = new Date(); + const [startDate, setStartDate] = useState( + startOfMonth(subMonths(today, 1)), + ); + const [endDate, setEndDate] = useState(today); + + const [page, setPage] = useState(1); + const pageSize = 20; + + const filter: WalletMovementsFilter = useMemo( + () => ({ + walletId, + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString(), + page, + pageSize, + }), + [walletId, startDate, endDate, page, pageSize], + ); + + const { data: result, isLoading } = useWalletMovementsFiltered(filter); + + const movements = result?.data ?? []; + const total = result?.total ?? 0; + + const columns = useMemo[]>( + () => [ + { + header: "Tipo", + accessorKey: "type", + cell: ({ row }) => , + }, + { + header: "Descripción", + accessorKey: "description", + cell: ({ row }) => { + const desc = row.getValue("description") as string | null; + return ( + + {desc ?? ( + + Sin descripción + + )} + + ); + }, + }, + { + header: "Billetera", + id: "walletName", + cell: ({ row }) => { + const w = wallets?.find( + (w) => w.id === row.original.walletId, + ); + return w ? ( + {w.name} + ) : null; + }, + }, + { + header: "Monto", + accessorKey: "amount", + cell: ({ row }) => { + const amount = row.getValue("amount") as number; + return ( + = 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-red-600 dark:text-red-400" + }`} + > + {amount >= 0 ? "+" : ""} + {amountFormatter.format(amount)} + + ); + }, + }, + { + header: "Fecha", + accessorKey: "createdAt", + cell: ({ row }) => { + return ( + + {dateFormatter.format( + new Date(row.getValue("createdAt") as string), + )} + + ); + }, + }, + ], + [wallets], + ); + + const table = useReactTable({ + data: movements, + 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 ? ( +
+
+ {firstItem}-{lastItem} de {total} · Página {page} de {totalPages} +
+
+ + +
+
+ ) : null; + + return ( +
+

Movimientos

+ +
+
+ + +
+ +
+ + { + setStartDate(d); + setPage(1); + }} + placeholder="Fecha inicial" + /> +
+ +
+ + { + setEndDate(d); + setPage(1); + }} + placeholder="Fecha final" + /> +
+ + {(walletId || startDate || endDate) && ( + + )} +
+ + {isLoading ? ( +

Cargando...

+ ) : ( + <> + {mobilePagination} + +
+ {movements.map((m) => { + const wallet = wallets?.find((w) => w.id === m.walletId); + return ( +
+
+ + = 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-red-600 dark:text-red-400" + }`} + > + {m.amount >= 0 ? "+" : ""} + {amountFormatter.format(m.amount)} + +
+ +
+ {m.description && ( +

{m.description}

+ )} +
+ {wallet && {wallet.name}} + · + {dateFormatter.format(new Date(m.createdAt))} +
+
+
+ ); + })} + {movements.length === 0 && ( +
+ No hay movimientos para mostrar. +
+ )} +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + {table.getRowModel().rows.length === 0 && ( + + + + )} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+ No hay movimientos para mostrar. +
+
+ + {mobilePagination} + + {totalPages > 1 && ( + + + + { + e.preventDefault(); + setPage(1); + }} + href="#" + aria-label="Ir a la primera página" + className={ + page <= 1 ? "pointer-events-none opacity-50" : "" + } + > + + + + + { + e.preventDefault(); + if (page > 1) setPage(page - 1); + }} + href="#" + className={ + page <= 1 ? "pointer-events-none opacity-50" : "" + } + text="Anterior" + /> + + {currentGroup > 0 && ( + + { + e.preventDefault(); + setPage(startPage - 1); + }} + href="#" + aria-label="Ir al grupo anterior" + > + + + + )} + {Array.from( + { length: endPage - startPage + 1 }, + (_, i) => startPage + i, + ).map((p) => ( + + { + e.preventDefault(); + setPage(p); + }} + href="#" + > + {p} + + + ))} + {(currentGroup + 1) * GROUP_SIZE < totalPages && ( + + { + e.preventDefault(); + setPage(endPage + 1); + }} + href="#" + aria-label="Ir al siguiente grupo" + > + + + + )} + + { + e.preventDefault(); + if (page < totalPages) setPage(page + 1); + }} + href="#" + className={ + page >= totalPages ? "pointer-events-none opacity-50" : "" + } + text="Siguiente" + /> + + + { + e.preventDefault(); + setPage(totalPages); + }} + href="#" + aria-label="Ir a la última página" + className={ + page >= totalPages ? "pointer-events-none opacity-50" : "" + } + > + + + + + + )} + + )} +
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/WalletCard.tsx b/apps/frontend/src/features/wallets/components/WalletCard.tsx index 4332db4..056ede8 100644 --- a/apps/frontend/src/features/wallets/components/WalletCard.tsx +++ b/apps/frontend/src/features/wallets/components/WalletCard.tsx @@ -7,6 +7,7 @@ import { PencilLine, Star, } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -19,7 +20,6 @@ import { useSetDefaultWallet } from "@/lib/queries"; import { DepositDialog } from "./DepositDialog"; import { AdjustBalanceDialog } from "./AdjustBalanceDialog"; import { TransferDialog } from "./TransferDialog"; -import { WalletMovementsDialog } from "./WalletMovementsDialog"; import { EditWalletDialog } from "./EditWalletDialog"; const balanceFormatter = new Intl.NumberFormat("es-AR", { @@ -32,11 +32,11 @@ interface WalletCardProps { } export function WalletCard({ wallet }: WalletCardProps) { + const navigate = useNavigate(); const setDefaultWallet = useSetDefaultWallet(); const [depositOpen, setDepositOpen] = useState(false); const [adjustOpen, setAdjustOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); - const [movementsOpen, setMovementsOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); return ( @@ -147,7 +147,9 @@ export function WalletCard({ wallet }: WalletCardProps) { variant="outline" size="icon" className="size-8" - onClick={() => setMovementsOpen(true)} + onClick={() => + navigate({ to: "/wallets/movements", search: { walletId: wallet.id } }) + } > @@ -173,11 +175,6 @@ export function WalletCard({ wallet }: WalletCardProps) { open={transferOpen} onOpenChange={setTransferOpen} /> - (`/api/wallets/${id}/movements`); } +export type WalletMovementsFilter = { + walletId?: number; + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +}; + +export function getWalletMovementsFiltered( + filter: WalletMovementsFilter, +): Promise> { + const params = new URLSearchParams(); + if (filter.walletId) params.set("walletId", String(filter.walletId)); + if (filter.startDate) params.set("startDate", filter.startDate); + if (filter.endDate) params.set("endDate", filter.endDate); + if (filter.page) params.set("page", String(filter.page)); + if (filter.pageSize) params.set("pageSize", String(filter.pageSize)); + const qs = params.toString(); + return fetcher>( + `/api/wallets/movements${qs ? `?${qs}` : ""}`, + ); +} + // Telegram export type TelegramStatus = { diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 3da0e1f..478524b 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -5,12 +5,13 @@ import { useQueryClient, } from "@tanstack/react-query"; import { + type AdjustBalanceInput, type CreateNonPeriodicExpenseInput, type CreatePeriodicExpenseInput, - type AdjustBalanceInput, type CreateWalletInput, type DepositInput, type TransferInput, + type WalletMovementsFilter, createNonPeriodicExpense as createNonPeriodicExpenseApi, createPeriodicExpense as createPeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi, @@ -32,6 +33,7 @@ import { getTelegramStatus, getTotalPending, getWalletMovements, + getWalletMovementsFiltered, getWallets, setDefaultWallet as setDefaultWalletApi, importExpenses, @@ -315,6 +317,8 @@ export function useUpdateExpense() { export const walletKeys = { all: ["wallets"] as const, movements: (id: number) => ["wallets", id, "movements"] as const, + movementsFiltered: (filter: WalletMovementsFilter) => + ["wallets", "movements", filter] as const, }; export function useWallets() { @@ -410,6 +414,14 @@ export function useWalletMovements(id: number) { }); } +export function useWalletMovementsFiltered(filter: WalletMovementsFilter) { + return useQuery({ + queryKey: walletKeys.movementsFiltered(filter), + placeholderData: keepPreviousData, + queryFn: () => getWalletMovementsFiltered(filter), + }); +} + // Telegram export function useTelegramStatus() { diff --git a/apps/frontend/src/routes/_authenticated/wallets.index.tsx b/apps/frontend/src/routes/_authenticated/wallets.index.tsx new file mode 100644 index 0000000..9f6b98e --- /dev/null +++ b/apps/frontend/src/routes/_authenticated/wallets.index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { WalletsPage } from "@/features/wallets/WalletsPage"; + +export const Route = createFileRoute("/_authenticated/wallets/")({ + component: WalletsPage, +}); diff --git a/apps/frontend/src/routes/_authenticated/wallets.movements.tsx b/apps/frontend/src/routes/_authenticated/wallets.movements.tsx new file mode 100644 index 0000000..c9f8687 --- /dev/null +++ b/apps/frontend/src/routes/_authenticated/wallets.movements.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { WalletMovementsPage } from "@/features/wallets/WalletMovementsPage"; + +export const Route = createFileRoute("/_authenticated/wallets/movements")({ + validateSearch: z.object({ + walletId: z.coerce.number().optional(), + }), + component: WalletMovementsPage, +}); diff --git a/apps/frontend/src/routes/_authenticated/wallets.tsx b/apps/frontend/src/routes/_authenticated/wallets.tsx index fe59da6..31102bc 100644 --- a/apps/frontend/src/routes/_authenticated/wallets.tsx +++ b/apps/frontend/src/routes/_authenticated/wallets.tsx @@ -1,6 +1,9 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { WalletsPage } from "@/features/wallets/WalletsPage"; +import { createFileRoute, Outlet } from "@tanstack/react-router"; export const Route = createFileRoute("/_authenticated/wallets")({ - component: WalletsPage, + component: WalletsLayout, }); + +function WalletsLayout() { + return ; +}