feat/payment-wallets #5
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
516
apps/frontend/src/features/wallets/WalletMovementsPage.tsx
Normal file
516
apps/frontend/src/features/wallets/WalletMovementsPage.tsx
Normal 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>·</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { WalletsPage } from "@/features/wallets/WalletsPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/wallets/")({
|
||||||
|
component: WalletsPage,
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user