feat(wallets): implement wallet movements feature with filtering and pagination

This commit is contained in:
Jose Selesan
2026-06-17 11:10:20 -03:00
parent 33120deed6
commit 04b3f87aca
10 changed files with 651 additions and 12 deletions

View File

@@ -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);
}

View File

@@ -3,6 +3,7 @@ import { adjustBalanceHandler } from "./handlers/adjustBalance";
import { createWalletHandler } from "./handlers/createWallet"; import { createWalletHandler } from "./handlers/createWallet";
import { deleteWalletHandler } from "./handlers/deleteWallet"; import { deleteWalletHandler } from "./handlers/deleteWallet";
import { depositWalletHandler } from "./handlers/depositWallet"; import { depositWalletHandler } from "./handlers/depositWallet";
import { listAllMovementsHandler } from "./handlers/listAllMovements";
import { listMovementsHandler } from "./handlers/listMovements"; import { listMovementsHandler } from "./handlers/listMovements";
import { listWalletsHandler } from "./handlers/listWallets"; import { listWalletsHandler } from "./handlers/listWallets";
import { transferWalletHandler } from "./handlers/transferWallet"; import { transferWalletHandler } from "./handlers/transferWallet";
@@ -11,6 +12,7 @@ import { setDefaultWalletHandler } from "./handlers/setDefaultWallet";
const app = new Hono(); const app = new Hono();
app.get("/movements", listAllMovementsHandler);
app.get("/", listWalletsHandler); app.get("/", listWalletsHandler);
app.post("/", createWalletHandler); app.post("/", createWalletHandler);
app.put("/:id", updateWalletHandler); app.put("/:id", updateWalletHandler);

View File

@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { Prisma } from "../../generated/prisma/client"; import { Prisma } from "../../generated/prisma/client";
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { cache } from "../../lib/cache";
export const createWalletSchema = z.object({ export const createWalletSchema = z.object({
name: z.string().min(1).max(100), 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) })); 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<string, unknown> = {};
if (walletId !== undefined) {
where.walletId = walletId;
}
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
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;
}

View File

@@ -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<string, string> = {
DEPOSIT: "Depósito",
WITHDRAWAL: "Extracción",
TRANSFER_IN: "Transferencia recibida",
TRANSFER_OUT: "Transferencia enviada",
ADJUSTMENT: "Ajuste",
};
function TypeBadge({ type }: { type: string }) {
const styles: Record<string, string> = {
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 (
<span
className={
`inline-flex h-5 shrink-0 items-center rounded-md px-1.5 text-xs font-medium ` +
(styles[type] ?? "")
}
>
{typeLabels[type] ?? type}
</span>
);
}
export function WalletMovementsPage() {
const { walletId: initialWalletId } = useSearch({
from: "/_authenticated/wallets/movements",
});
const { data: wallets } = useWallets();
const [walletId, setWalletId] = useState<number | undefined>(initialWalletId);
const today = new Date();
const [startDate, setStartDate] = useState<Date | undefined>(
startOfMonth(subMonths(today, 1)),
);
const [endDate, setEndDate] = useState<Date | undefined>(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<ColumnDef<WalletMovement>[]>(
() => [
{
header: "Tipo",
accessorKey: "type",
cell: ({ row }) => <TypeBadge type={row.getValue("type") as string} />,
},
{
header: "Descripción",
accessorKey: "description",
cell: ({ row }) => {
const desc = row.getValue("description") as string | null;
return (
<span className="text-muted-foreground">
{desc ?? (
<span className="italic text-muted-foreground/50">
Sin descripción
</span>
)}
</span>
);
},
},
{
header: "Billetera",
id: "walletName",
cell: ({ row }) => {
const w = wallets?.find(
(w) => w.id === row.original.walletId,
);
return w ? (
<span className="text-xs text-muted-foreground">{w.name}</span>
) : null;
},
},
{
header: "Monto",
accessorKey: "amount",
cell: ({ row }) => {
const amount = row.getValue("amount") as number;
return (
<span
className={`tabular-nums font-medium ${
amount >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}`}
>
{amount >= 0 ? "+" : ""}
{amountFormatter.format(amount)}
</span>
);
},
},
{
header: "Fecha",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<span className="text-xs text-muted-foreground tabular-nums">
{dateFormatter.format(
new Date(row.getValue("createdAt") as string),
)}
</span>
);
},
},
],
[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 ? (
<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={() => setPage(page - 1)}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Siguiente
</Button>
</div>
</div>
) : null;
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Movimientos</h1>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Billetera
</label>
<Select
value={walletId !== undefined ? String(walletId) : "all"}
onValueChange={(v) => {
setWalletId(v === "all" ? undefined : Number(v));
setPage(1);
}}
>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Todas las billeteras" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas las billeteras
</SelectItem>
{wallets?.map((w: Wallet) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Desde
</label>
<DatePicker
value={startDate}
onChange={(d) => {
setStartDate(d);
setPage(1);
}}
placeholder="Fecha inicial"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Hasta
</label>
<DatePicker
value={endDate}
onChange={(d) => {
setEndDate(d);
setPage(1);
}}
placeholder="Fecha final"
/>
</div>
{(walletId || startDate || endDate) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setWalletId(undefined);
setStartDate(undefined);
setEndDate(undefined);
setPage(1);
}}
>
Limpiar filtros
</Button>
)}
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<>
{mobilePagination}
<div className="space-y-2 sm:hidden">
{movements.map((m) => {
const wallet = wallets?.find((w) => w.id === m.walletId);
return (
<div
key={m.id}
className="rounded-lg border bg-card p-3 text-sm"
>
<div className="flex items-start justify-between gap-3">
<TypeBadge type={m.type} />
<span
className={`tabular-nums font-medium ${
m.amount >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}`}
>
{m.amount >= 0 ? "+" : ""}
{amountFormatter.format(m.amount)}
</span>
</div>
<div className="mt-2 space-y-1">
{m.description && (
<p className="text-muted-foreground">{m.description}</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{wallet && <span>{wallet.name}</span>}
<span>&middot;</span>
<span>{dateFormatter.format(new Date(m.createdAt))}</span>
</div>
</div>
</div>
);
})}
{movements.length === 0 && (
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
No hay movimientos 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 movimientos para mostrar.
</td>
</tr>
)}
</tbody>
</table>
</div>
{mobilePagination}
{totalPages > 1 && (
<Pagination className="hidden sm:flex">
<PaginationContent>
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(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) setPage(page - 1);
}}
href="#"
className={
page <= 1 ? "pointer-events-none opacity-50" : ""
}
text="Anterior"
/>
</PaginationItem>
{currentGroup > 0 && (
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(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();
setPage(p);
}}
href="#"
>
{p}
</PaginationLink>
</PaginationItem>
))}
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(endPage + 1);
}}
href="#"
aria-label="Ir al siguiente grupo"
>
<ChevronsRightIcon className="size-4" />
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
onClick={(e) => {
e.preventDefault();
if (page < totalPages) setPage(page + 1);
}}
href="#"
className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
text="Siguiente"
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(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>
);
}

View File

@@ -7,6 +7,7 @@ import {
PencilLine, PencilLine,
Star, Star,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
@@ -19,7 +20,6 @@ import { useSetDefaultWallet } from "@/lib/queries";
import { DepositDialog } from "./DepositDialog"; import { DepositDialog } from "./DepositDialog";
import { AdjustBalanceDialog } from "./AdjustBalanceDialog"; import { AdjustBalanceDialog } from "./AdjustBalanceDialog";
import { TransferDialog } from "./TransferDialog"; import { TransferDialog } from "./TransferDialog";
import { WalletMovementsDialog } from "./WalletMovementsDialog";
import { EditWalletDialog } from "./EditWalletDialog"; import { EditWalletDialog } from "./EditWalletDialog";
const balanceFormatter = new Intl.NumberFormat("es-AR", { const balanceFormatter = new Intl.NumberFormat("es-AR", {
@@ -32,11 +32,11 @@ interface WalletCardProps {
} }
export function WalletCard({ wallet }: WalletCardProps) { export function WalletCard({ wallet }: WalletCardProps) {
const navigate = useNavigate();
const setDefaultWallet = useSetDefaultWallet(); const setDefaultWallet = useSetDefaultWallet();
const [depositOpen, setDepositOpen] = useState(false); const [depositOpen, setDepositOpen] = useState(false);
const [adjustOpen, setAdjustOpen] = useState(false); const [adjustOpen, setAdjustOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false);
const [movementsOpen, setMovementsOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
return ( return (
@@ -147,7 +147,9 @@ export function WalletCard({ wallet }: WalletCardProps) {
variant="outline" variant="outline"
size="icon" size="icon"
className="size-8" className="size-8"
onClick={() => setMovementsOpen(true)} onClick={() =>
navigate({ to: "/wallets/movements", search: { walletId: wallet.id } })
}
> >
<History className="size-4" /> <History className="size-4" />
</Button> </Button>
@@ -173,11 +175,6 @@ export function WalletCard({ wallet }: WalletCardProps) {
open={transferOpen} open={transferOpen}
onOpenChange={setTransferOpen} onOpenChange={setTransferOpen}
/> />
<WalletMovementsDialog
wallet={wallet}
open={movementsOpen}
onOpenChange={setMovementsOpen}
/>
<EditWalletDialog <EditWalletDialog
wallet={wallet} wallet={wallet}
open={editOpen} open={editOpen}

View File

@@ -424,6 +424,29 @@ export function getWalletMovements(
return fetcher<WalletMovement[]>(`/api/wallets/${id}/movements`); return fetcher<WalletMovement[]>(`/api/wallets/${id}/movements`);
} }
export type WalletMovementsFilter = {
walletId?: number;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
};
export function getWalletMovementsFiltered(
filter: WalletMovementsFilter,
): Promise<PaginatedResponse<WalletMovement>> {
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<PaginatedResponse<WalletMovement>>(
`/api/wallets/movements${qs ? `?${qs}` : ""}`,
);
}
// Telegram // Telegram
export type TelegramStatus = { export type TelegramStatus = {

View File

@@ -5,12 +5,13 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
type AdjustBalanceInput,
type CreateNonPeriodicExpenseInput, type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput, type CreatePeriodicExpenseInput,
type AdjustBalanceInput,
type CreateWalletInput, type CreateWalletInput,
type DepositInput, type DepositInput,
type TransferInput, type TransferInput,
type WalletMovementsFilter,
createNonPeriodicExpense as createNonPeriodicExpenseApi, createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi, createPeriodicExpense as createPeriodicExpenseApi,
deletePeriodicExpense as deletePeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi,
@@ -32,6 +33,7 @@ import {
getTelegramStatus, getTelegramStatus,
getTotalPending, getTotalPending,
getWalletMovements, getWalletMovements,
getWalletMovementsFiltered,
getWallets, getWallets,
setDefaultWallet as setDefaultWalletApi, setDefaultWallet as setDefaultWalletApi,
importExpenses, importExpenses,
@@ -315,6 +317,8 @@ export function useUpdateExpense() {
export const walletKeys = { export const walletKeys = {
all: ["wallets"] as const, all: ["wallets"] as const,
movements: (id: number) => ["wallets", id, "movements"] as const, movements: (id: number) => ["wallets", id, "movements"] as const,
movementsFiltered: (filter: WalletMovementsFilter) =>
["wallets", "movements", filter] as const,
}; };
export function useWallets() { 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 // Telegram
export function useTelegramStatus() { export function useTelegramStatus() {

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { WalletsPage } from "@/features/wallets/WalletsPage";
export const Route = createFileRoute("/_authenticated/wallets/")({
component: WalletsPage,
});

View File

@@ -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,
});

View File

@@ -1,6 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Outlet } from "@tanstack/react-router";
import { WalletsPage } from "@/features/wallets/WalletsPage";
export const Route = createFileRoute("/_authenticated/wallets")({ export const Route = createFileRoute("/_authenticated/wallets")({
component: WalletsPage, component: WalletsLayout,
}); });
function WalletsLayout() {
return <Outlet />;
}