Merge pull request 'feat(wallets): implement wallet management features including create, update, delete, deposit, transfer, and list movements' (#4) from feat/wallets into main

Reviewed-on: #4
This commit is contained in:
2026-06-17 12:37:20 +00:00
26 changed files with 1643 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "wallets" (
"id" SERIAL NOT NULL,
"name" VARCHAR(100) NOT NULL,
"currency" VARCHAR(10) NOT NULL,
"balance" DECIMAL(14,4) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallet_movements" (
"id" SERIAL NOT NULL,
"walletId" INTEGER NOT NULL,
"type" VARCHAR(20) NOT NULL,
"amount" DECIMAL(14,4) NOT NULL,
"description" VARCHAR(255),
"referenceWalletId" INTEGER,
"transferGroupId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wallet_movements_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "wallet_movements_walletId_idx" ON "wallet_movements"("walletId");
-- CreateIndex
CREATE INDEX "wallet_movements_transferGroupId_idx" ON "wallet_movements"("transferGroupId");
-- AddForeignKey
ALTER TABLE "wallet_movements" ADD CONSTRAINT "wallet_movements_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "wallets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -129,6 +129,35 @@ enum PaymentStatus {
PAYED PAYED
} }
model Wallet {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
currency String @db.VarChar(10)
balance Decimal @db.Decimal(14, 4)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements WalletMovement[]
@@map("wallets")
}
model WalletMovement {
id Int @id @default(autoincrement())
walletId Int
wallet Wallet @relation(fields: [walletId], references: [id])
type String @db.VarChar(20)
amount Decimal @db.Decimal(14, 4)
description String? @db.VarChar(255)
referenceWalletId Int?
transferGroupId String? @db.Uuid
createdAt DateTime @default(now())
@@index([walletId])
@@index([transferGroupId])
@@map("wallet_movements")
}
model PeriodicExpense { model PeriodicExpense {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
description String @db.VarChar(50) description String @db.VarChar(50)

View File

@@ -12,6 +12,7 @@ import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job"; import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes"; import quotesRouter from "./modules/quotes/quotes.routes";
import telegramRouter from "./modules/telegram/telegram.router"; import telegramRouter from "./modules/telegram/telegram.router";
import walletsRouter from "./modules/wallets/wallets.routes";
const app = new Hono(); const app = new Hono();
@@ -33,6 +34,7 @@ app.use("/api/*", async (c, next) => {
app.route("/api/quotes", quotesRouter); app.route("/api/quotes", quotesRouter);
app.route("/api", expensesRouter); app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter); app.route("/api/telegram", telegramRouter);
app.route("/api/wallets", walletsRouter);
app.use("/assets/*", serveStatic({ root: "./web" })); app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" })); app.get("*", serveStatic({ path: "./web/index.html" }));

View File

@@ -0,0 +1,13 @@
import type { Context } from "hono";
import {
adjustBalance,
adjustBalanceSchema,
} from "../wallets.service";
export async function adjustBalanceHandler(c: Context) {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const parsed = adjustBalanceSchema.parse(body);
const result = await adjustBalance(id, parsed);
return c.json(result);
}

View File

@@ -0,0 +1,12 @@
import type { Context } from "hono";
import {
createWallet,
createWalletSchema,
} from "../wallets.service";
export async function createWalletHandler(c: Context) {
const body = await c.req.json();
const parsed = createWalletSchema.parse(body);
const result = await createWallet(parsed);
return c.json(result, 201);
}

View File

@@ -0,0 +1,8 @@
import type { Context } from "hono";
import { deleteWallet } from "../wallets.service";
export async function deleteWalletHandler(c: Context) {
const id = Number(c.req.param("id"));
await deleteWallet(id);
return c.json({ success: true });
}

View File

@@ -0,0 +1,13 @@
import type { Context } from "hono";
import {
depositWallet,
depositSchema,
} from "../wallets.service";
export async function depositWalletHandler(c: Context) {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const parsed = depositSchema.parse(body);
const result = await depositWallet(id, parsed);
return c.json(result);
}

View File

@@ -0,0 +1,8 @@
import type { Context } from "hono";
import { listMovements } from "../wallets.service";
export async function listMovementsHandler(c: Context) {
const id = Number(c.req.param("id"));
const result = await listMovements(id);
return c.json(result);
}

View File

@@ -0,0 +1,7 @@
import type { Context } from "hono";
import { listWallets } from "../wallets.service";
export async function listWalletsHandler(c: Context) {
const result = await listWallets();
return c.json(result);
}

View File

@@ -0,0 +1,12 @@
import type { Context } from "hono";
import {
transferSchema,
transferWallet,
} from "../wallets.service";
export async function transferWalletHandler(c: Context) {
const body = await c.req.json();
const parsed = transferSchema.parse(body);
const result = await transferWallet(parsed);
return c.json(result);
}

View File

@@ -0,0 +1,13 @@
import type { Context } from "hono";
import {
updateWallet,
updateWalletSchema,
} from "../wallets.service";
export async function updateWalletHandler(c: Context) {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const parsed = updateWalletSchema.parse(body);
const result = await updateWallet(id, parsed);
return c.json(result);
}

View File

@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { adjustBalanceHandler } from "./handlers/adjustBalance";
import { createWalletHandler } from "./handlers/createWallet";
import { deleteWalletHandler } from "./handlers/deleteWallet";
import { depositWalletHandler } from "./handlers/depositWallet";
import { listMovementsHandler } from "./handlers/listMovements";
import { listWalletsHandler } from "./handlers/listWallets";
import { transferWalletHandler } from "./handlers/transferWallet";
import { updateWalletHandler } from "./handlers/updateWallet";
const app = new Hono();
app.get("/", listWalletsHandler);
app.post("/", createWalletHandler);
app.put("/:id", updateWalletHandler);
app.delete("/:id", deleteWalletHandler);
app.post("/:id/deposit", depositWalletHandler);
app.post("/:id/adjust", adjustBalanceHandler);
app.post("/transfer", transferWalletHandler);
app.get("/:id/movements", listMovementsHandler);
export default app;

View File

@@ -0,0 +1,164 @@
import { z } from "zod";
import { Prisma } from "../../generated/prisma/client";
import { prisma } from "../../lib/prisma";
export const createWalletSchema = z.object({
name: z.string().min(1).max(100),
currency: z.string().min(1).max(10),
initialBalance: z.number().optional().default(0),
});
export const updateWalletSchema = z.object({
name: z.string().min(1).max(100),
currency: z.string().min(1).max(10),
});
export const depositSchema = z.object({
amount: z.number().positive(),
description: z.string().max(255).optional(),
});
export const adjustBalanceSchema = z.object({
newBalance: z.number().min(0),
description: z.string().max(255).optional(),
});
export const transferSchema = z.object({
fromWalletId: z.number().int().positive(),
toWalletId: z.number().int().positive(),
outgoingAmount: z.number().positive(),
incomingAmount: z.number().positive(),
description: z.string().max(255).optional(),
});
export type CreateWalletInput = z.infer<typeof createWalletSchema>;
export type UpdateWalletInput = z.infer<typeof updateWalletSchema>;
export type DepositInput = z.infer<typeof depositSchema>;
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
export type TransferInput = z.infer<typeof transferSchema>;
export async function listWallets() {
const wallets = await prisma.wallet.findMany({ orderBy: { createdAt: "desc" } });
return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
}
export async function createWallet(input: CreateWalletInput) {
const wallet = await prisma.wallet.create({
data: {
name: input.name,
currency: input.currency,
balance: input.initialBalance ?? 0,
},
});
return { ...wallet, balance: Number(wallet.balance) };
}
export async function updateWallet(id: number, input: UpdateWalletInput) {
const wallet = await prisma.wallet.update({
where: { id },
data: { name: input.name, currency: input.currency },
});
return { ...wallet, balance: Number(wallet.balance) };
}
export async function deleteWallet(id: number) {
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
if (Number(wallet.balance) !== 0) {
throw new Error("No se puede eliminar una billetera con saldo distinto de cero");
}
await prisma.walletMovement.deleteMany({ where: { walletId: id } });
await prisma.wallet.delete({ where: { id } });
}
export async function depositWallet(id: number, input: DepositInput) {
const [wallet] = await prisma.$transaction([
prisma.wallet.update({
where: { id },
data: { balance: { increment: input.amount } },
}),
prisma.walletMovement.create({
data: {
walletId: id,
type: "DEPOSIT",
amount: input.amount,
description: input.description ?? null,
},
}),
]);
return { ...wallet, balance: Number(wallet.balance) };
}
export async function adjustBalance(id: number, input: AdjustBalanceInput) {
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
const currentBalance = Number(wallet.balance);
const difference = input.newBalance - currentBalance;
const [updated] = await prisma.$transaction([
prisma.wallet.update({
where: { id },
data: { balance: input.newBalance },
}),
prisma.walletMovement.create({
data: {
walletId: id,
type: "ADJUSTMENT",
amount: difference,
description: input.description ?? null,
},
}),
]);
return { ...updated, balance: Number(updated.balance) };
}
export async function transferWallet(input: TransferInput) {
const groupId = crypto.randomUUID();
const [fromWallet, toWallet] = await prisma.$transaction(async (tx) => {
const from = await tx.wallet.update({
where: { id: input.fromWalletId },
data: { balance: { decrement: input.outgoingAmount } },
});
const to = await tx.wallet.update({
where: { id: input.toWalletId },
data: { balance: { increment: input.incomingAmount } },
});
await tx.walletMovement.create({
data: {
walletId: input.fromWalletId,
type: "TRANSFER_OUT",
amount: -input.outgoingAmount,
description: input.description ?? null,
referenceWalletId: input.toWalletId,
transferGroupId: groupId,
},
});
await tx.walletMovement.create({
data: {
walletId: input.toWalletId,
type: "TRANSFER_IN",
amount: input.incomingAmount,
description: input.description ?? null,
referenceWalletId: input.fromWalletId,
transferGroupId: groupId,
},
});
return [from, to];
});
return {
from: { ...fromWallet, balance: Number(fromWallet.balance) },
to: { ...toWallet, balance: Number(toWallet.balance) },
};
}
export async function listMovements(walletId: number) {
const movements = await prisma.walletMovement.findMany({
where: { walletId },
orderBy: { createdAt: "desc" },
});
return movements.map((m) => ({ ...m, amount: Number(m.amount) }));
}

View File

@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { import {
FileText, FileText,
Landmark,
LayoutDashboard, LayoutDashboard,
Settings, Settings,
TrendingUp, TrendingUp,
@@ -11,6 +12,7 @@ import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
{ to: "/", label: "Panel", icon: LayoutDashboard }, { to: "/", label: "Panel", icon: LayoutDashboard },
{ to: "/expenses", label: "Gastos", icon: Wallet }, { to: "/expenses", label: "Gastos", icon: Wallet },
{ to: "/wallets", label: "Billeteras", icon: Landmark },
{ to: "/quotes", label: "Cotizaciones", icon: FileText }, { to: "/quotes", label: "Cotizaciones", icon: FileText },
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp }, { to: "/quotes/belo", label: "BELO", icon: TrendingUp },
]; ];

View File

@@ -12,6 +12,7 @@ import {
useMonthlyPayedTotal, useMonthlyPayedTotal,
useMonthlyTotals, useMonthlyTotals,
useQuotes, useQuotes,
useWallets,
} from "@/lib/queries"; } from "@/lib/queries";
import { RelativeTime } from "@/lib/time"; import { RelativeTime } from "@/lib/time";
import { DashboardExpensesTable } from "./components/DashboardExpensesTable"; import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
@@ -141,6 +142,13 @@ export function DashboardPage() {
const isFetching = isLoading || isPending; const isFetching = isLoading || isPending;
const { data: wallets } = useWallets();
const walletBalanceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -189,6 +197,25 @@ export function DashboardPage() {
variant="minimal" variant="minimal"
/> />
</div> </div>
{wallets && wallets.length > 0 && (
<div className="space-y-2">
<h2 className="text-lg font-semibold">Billeteras</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{wallets.map((w) => (
<div
key={w.id}
className="rounded-lg border p-4"
>
<p className="text-sm text-muted-foreground">{w.name}</p>
<p className="text-sm text-muted-foreground/60 text-xs">{w.currency}</p>
<p className="text-2xl font-bold tabular-nums mt-1">
{walletBalanceFormatter.format(w.balance)}
</p>
</div>
))}
</div>
</div>
)}
<DashboardExpensesTable /> <DashboardExpensesTable />
</div> </div>
); );

View File

@@ -0,0 +1,42 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { useWallets } from "@/lib/queries";
import { WalletCard } from "./components/WalletCard";
import { CreateWalletDialog } from "./components/CreateWalletDialog";
export function WalletsPage() {
const { data: wallets, isLoading } = useWallets();
const [createOpen, setCreateOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Billeteras</h1>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
>
<Plus className="size-4" />
Nueva billetera
</button>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : wallets && wallets.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{wallets.map((wallet) => (
<WalletCard key={wallet.id} wallet={wallet} />
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No hay billeteras. Creá una para empezar.
</p>
)}
<CreateWalletDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import { useAdjustBalance } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const adjustSchema = z.object({
newBalance: z.number().min(0, "El saldo no puede ser negativo"),
description: z.string().max(255).optional(),
});
type AdjustFormValues = z.infer<typeof adjustSchema>;
interface AdjustBalanceDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AdjustBalanceDialog({
wallet,
open,
onOpenChange,
}: AdjustBalanceDialogProps) {
const adjust = useAdjustBalance();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<AdjustFormValues>({
resolver: zodResolver(adjustSchema),
defaultValues: { newBalance: wallet.balance, description: "" },
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: AdjustFormValues) {
await adjust.mutateAsync({
id: wallet.id,
data: {
newBalance: data.newBalance,
description: data.description || undefined,
},
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Ajustar saldo de {wallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<p className="text-sm text-muted-foreground">
Saldo actual: {wallet.balance}
</p>
<form
id="adjust-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="adjust-balance" className="text-sm font-medium">
Nuevo saldo
</label>
<input
id="adjust-balance"
type="number"
step="0.01"
min="0"
{...register("newBalance", { valueAsNumber: true })}
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.newBalance && (
<span className="text-xs text-destructive">
{errors.newBalance.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="adjust-desc" className="text-sm font-medium">
Motivo (opcional)
</label>
<input
id="adjust-desc"
{...register("description")}
placeholder="Ej: Corrección"
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.description && (
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="adjust-form" disabled={isSubmitting}>
Ajustar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,165 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCreateWallet } from "@/lib/queries";
const createWalletSchema = z.object({
name: z.string().min(1, "El nombre es obligatorio").max(100),
currency: z.string().min(1, "La moneda es obligatoria"),
initialBalance: z.number().nonnegative(),
});
type CreateWalletFormValues = z.infer<typeof createWalletSchema>;
interface CreateWalletDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const commonCurrencies = ["ARS", "USD", "USDC", "EUR", "BRL", "USDT"];
export function CreateWalletDialog({
open,
onOpenChange,
}: CreateWalletDialogProps) {
const createWallet = useCreateWallet();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm<CreateWalletFormValues>({
resolver: zodResolver(createWalletSchema),
defaultValues: { name: "", currency: "", initialBalance: 0 },
});
const currency = watch("currency");
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: CreateWalletFormValues) {
await createWallet.mutateAsync({
name: data.name,
currency: data.currency,
initialBalance: data.initialBalance,
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Nueva billetera</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form
id="create-wallet-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="name" className="text-sm font-medium">
Nombre
</label>
<input
id="name"
{...register("name")}
placeholder="Ej: Mercado Pago"
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.name && (
<span className="text-xs text-destructive">
{errors.name.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="currency" className="text-sm font-medium">
Moneda
</label>
<Select
value={currency}
onValueChange={(v) => setValue("currency", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Seleccionar moneda" />
</SelectTrigger>
<SelectContent>
{commonCurrencies.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency && (
<span className="text-xs text-destructive">
{errors.currency.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="initialBalance" className="text-sm font-medium">
Saldo inicial
</label>
<input
id="initialBalance"
type="number"
step="0.01"
min="0"
{...register("initialBalance", { valueAsNumber: true })}
placeholder="0"
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.initialBalance && (
<span className="text-xs text-destructive">
{errors.initialBalance.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="submit"
form="create-wallet-form"
disabled={isSubmitting}
>
Crear
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,128 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import { useDepositWallet } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const depositSchema = z.object({
amount: z.number().positive("El monto debe ser mayor a 0"),
description: z.string().max(255).optional(),
});
type DepositFormValues = z.infer<typeof depositSchema>;
interface DepositDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DepositDialog({
wallet,
open,
onOpenChange,
}: DepositDialogProps) {
const deposit = useDepositWallet();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<DepositFormValues>({
resolver: zodResolver(depositSchema),
defaultValues: { amount: 0, description: "" },
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: DepositFormValues) {
await deposit.mutateAsync({
id: wallet.id,
data: {
amount: data.amount,
description: data.description || undefined,
},
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Depositar en {wallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form
id="deposit-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="deposit-amount" className="text-sm font-medium">
Monto
</label>
<input
id="deposit-amount"
type="number"
step="0.01"
min="0"
{...register("amount", { valueAsNumber: true })}
placeholder="1000"
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.amount && (
<span className="text-xs text-destructive">
{errors.amount.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="deposit-desc" className="text-sm font-medium">
Descripción (opcional)
</label>
<input
id="deposit-desc"
{...register("description")}
placeholder="Ej: Sueldo"
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.description && (
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="deposit-form" disabled={isSubmitting}>
Depositar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,142 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUpdateWallet } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const editWalletSchema = z.object({
name: z.string().min(1, "El nombre es obligatorio").max(100),
currency: z.string().min(1, "La moneda es obligatoria"),
});
type EditWalletFormValues = z.infer<typeof editWalletSchema>;
interface EditWalletDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const commonCurrencies = ["ARS", "USD", "USDC", "EUR", "BRL", "USDT"];
export function EditWalletDialog({
wallet,
open,
onOpenChange,
}: EditWalletDialogProps) {
const updateWallet = useUpdateWallet();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm<EditWalletFormValues>({
resolver: zodResolver(editWalletSchema),
defaultValues: { name: wallet.name, currency: wallet.currency },
});
const currency = watch("currency");
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: EditWalletFormValues) {
await updateWallet.mutateAsync({ id: wallet.id, data });
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Editar billetera</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form
id="edit-wallet-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-name" className="text-sm font-medium">
Nombre
</label>
<input
id="edit-name"
{...register("name")}
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.name && (
<span className="text-xs text-destructive">
{errors.name.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-currency" className="text-sm font-medium">
Moneda
</label>
<Select
value={currency}
onValueChange={(v) => setValue("currency", v)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{commonCurrencies.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency && (
<span className="text-xs text-destructive">
{errors.currency.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="submit"
form="edit-wallet-form"
disabled={isSubmitting}
>
Guardar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,211 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTransferWallet, useWallets } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const transferSchema = z.object({
toWalletId: z.number().positive("Seleccioná una billetera destino"),
outgoingAmount: z.number().positive("El monto saliente debe ser mayor a 0"),
incomingAmount: z.number().positive("El monto entrante debe ser mayor a 0"),
description: z.string().max(255).optional(),
});
type TransferFormValues = z.infer<typeof transferSchema>;
interface TransferDialogProps {
sourceWallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TransferDialog({
sourceWallet,
open,
onOpenChange,
}: TransferDialogProps) {
const { data: wallets } = useWallets();
const transfer = useTransferWallet();
const otherWallets =
wallets?.filter((w) => w.id !== sourceWallet.id) ?? [];
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<TransferFormValues>({
resolver: zodResolver(transferSchema),
defaultValues: {
toWalletId: 0,
outgoingAmount: 0,
incomingAmount: 0,
description: "",
},
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: TransferFormValues) {
await transfer.mutateAsync({
fromWalletId: sourceWallet.id,
toWalletId: data.toWalletId,
outgoingAmount: data.outgoingAmount,
incomingAmount: data.incomingAmount,
description: data.description || undefined,
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Transferir desde {sourceWallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<p className="text-sm text-muted-foreground">
Saldo disponible: {sourceWallet.balance}
</p>
<form
id="transfer-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Billetera destino</label>
<Controller
control={control}
name="toWalletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Seleccionar destino" />
</SelectTrigger>
<SelectContent>
{otherWallets.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.toWalletId && (
<span className="text-xs text-destructive">
{errors.toWalletId.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="transfer-outgoing"
className="text-sm font-medium"
>
Monto saliente (de {sourceWallet.name})
</label>
<input
id="transfer-outgoing"
type="number"
step="0.01"
min="0"
{...register("outgoingAmount", { valueAsNumber: true })}
placeholder="1000"
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.outgoingAmount && (
<span className="text-xs text-destructive">
{errors.outgoingAmount.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="transfer-incoming"
className="text-sm font-medium"
>
Monto entrante (en destino)
</label>
<input
id="transfer-incoming"
type="number"
step="0.01"
min="0"
{...register("incomingAmount", { valueAsNumber: true })}
placeholder="950"
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.incomingAmount && (
<span className="text-xs text-destructive">
{errors.incomingAmount.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Si hay comisiones o diferencia de cotización, el monto entrante
puede ser menor al saliente.
</span>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="transfer-desc" className="text-sm font-medium">
Descripción (opcional)
</label>
<input
id="transfer-desc"
{...register("description")}
placeholder="Ej: Transferencia a Binance"
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.description && (
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="transfer-form" disabled={isSubmitting}>
Transferir
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import {
ArrowDownToLine,
ArrowLeftRight,
ArrowUpDown,
History,
PencilLine,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { Wallet } from "@/lib/api";
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", {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
interface WalletCardProps {
wallet: Wallet;
}
export function WalletCard({ wallet }: WalletCardProps) {
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 (
<>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="font-medium">{wallet.name}</p>
<p className="text-xs text-muted-foreground">{wallet.currency}</p>
</div>
<button
type="button"
onClick={() => setEditOpen(true)}
className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<PencilLine className="size-4" />
</button>
</div>
<p className="text-2xl font-bold tabular-nums">
{balanceFormatter.format(wallet.balance)}
</p>
<div className="flex gap-1.5 flex-wrap">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setDepositOpen(true)}
>
<ArrowDownToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Depositar</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setAdjustOpen(true)}
>
<ArrowUpDown className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Ajustar saldo</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setTransferOpen(true)}
>
<ArrowLeftRight className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Transferir</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setMovementsOpen(true)}
>
<History className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Movimientos</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<DepositDialog
wallet={wallet}
open={depositOpen}
onOpenChange={setDepositOpen}
/>
<AdjustBalanceDialog
wallet={wallet}
open={adjustOpen}
onOpenChange={setAdjustOpen}
/>
<TransferDialog
sourceWallet={wallet}
open={transferOpen}
onOpenChange={setTransferOpen}
/>
<WalletMovementsDialog
wallet={wallet}
open={movementsOpen}
onOpenChange={setMovementsOpen}
/>
<EditWalletDialog
wallet={wallet}
open={editOpen}
onOpenChange={setEditOpen}
/>
</>
);
}

View File

@@ -0,0 +1,101 @@
import { useWalletMovements } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
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",
};
interface WalletMovementsDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function WalletMovementsDialog({
wallet,
open,
onOpenChange,
}: WalletMovementsDialogProps) {
const { data: movements, isLoading } = useWalletMovements(
open ? wallet.id : 0,
);
return (
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Movimientos de {wallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : movements && movements.length > 0 ? (
<div className="max-h-96 space-y-1 overflow-y-auto">
{movements.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-md p-2 text-sm hover:bg-muted/50"
>
<div className="flex flex-col">
<span className="font-medium">{typeLabels[m.type] ?? m.type}</span>
{m.description && (
<span className="text-xs text-muted-foreground">
{m.description}
</span>
)}
{m.transferGroupId && (
<span className="text-xs text-muted-foreground font-mono">
ID: {m.transferGroupId.slice(0, 8)}...
</span>
)}
<span className="text-xs text-muted-foreground">
{dateFormatter.format(new Date(m.createdAt))}
</span>
</div>
<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>
) : (
<p className="text-sm text-muted-foreground">
Sin movimientos registrados.
</p>
)}
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -320,6 +320,101 @@ export function importPeriodicExpenses(file: File): Promise<ImportResult> {
}); });
} }
// Wallets
export type Wallet = {
id: number;
name: string;
currency: string;
balance: number;
createdAt: string;
updatedAt: string;
};
export type WalletMovement = {
id: number;
walletId: number;
type: "DEPOSIT" | "WITHDRAWAL" | "TRANSFER_IN" | "TRANSFER_OUT" | "ADJUSTMENT";
amount: number;
description: string | null;
referenceWalletId: number | null;
transferGroupId: string | null;
createdAt: string;
};
export type CreateWalletInput = {
name: string;
currency: string;
initialBalance?: number;
};
export type DepositInput = {
amount: number;
description?: string;
};
export type AdjustBalanceInput = {
newBalance: number;
description?: string;
};
export type TransferInput = {
fromWalletId: number;
toWalletId: number;
outgoingAmount: number;
incomingAmount: number;
description?: string;
};
export function getWallets(): Promise<Wallet[]> {
return fetcher<Wallet[]>("/api/wallets");
}
export function createWallet(data: CreateWalletInput): Promise<Wallet> {
return mutator<Wallet>("/api/wallets", "POST", data);
}
export function updateWallet(
id: number,
data: { name: string; currency: string },
): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}`, "PUT", data);
}
export function deleteWallet(id: number): Promise<void> {
return mutator<void>(`/api/wallets/${id}`, "DELETE");
}
export function depositWallet(
id: number,
data: DepositInput,
): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/deposit`, "POST", data);
}
export function adjustBalance(
id: number,
data: AdjustBalanceInput,
): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/adjust`, "POST", data);
}
export function transferWallet(
data: TransferInput,
): Promise<{ from: Wallet; to: Wallet }> {
return mutator<{ from: Wallet; to: Wallet }>(
"/api/wallets/transfer",
"POST",
data,
);
}
export function getWalletMovements(
id: number,
): Promise<WalletMovement[]> {
return fetcher<WalletMovement[]>(`/api/wallets/${id}/movements`);
}
// Telegram // Telegram
export type TelegramStatus = { export type TelegramStatus = {

View File

@@ -7,9 +7,15 @@ import {
import { import {
type CreateNonPeriodicExpenseInput, type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput, type CreatePeriodicExpenseInput,
type AdjustBalanceInput,
type CreateWalletInput,
type DepositInput,
type TransferInput,
createNonPeriodicExpense as createNonPeriodicExpenseApi, createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi, createPeriodicExpense as createPeriodicExpenseApi,
deletePeriodicExpense as deletePeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi,
depositWallet as depositWalletApi,
adjustBalance as adjustBalanceApi,
fetchQuotes, fetchQuotes,
generateMonthlyExpense as generateMonthlyExpenseApi, generateMonthlyExpense as generateMonthlyExpenseApi,
getDailyMinMax, getDailyMinMax,
@@ -25,6 +31,8 @@ import {
getQuotes, getQuotes,
getTelegramStatus, getTelegramStatus,
getTotalPending, getTotalPending,
getWalletMovements,
getWallets,
importExpenses, importExpenses,
importPeriodicExpenses, importPeriodicExpenses,
type PayExpenseInput, type PayExpenseInput,
@@ -32,6 +40,10 @@ import {
type UpdateExpenseInput, type UpdateExpenseInput,
updateExpense as updateExpenseApi, updateExpense as updateExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi, updatePeriodicExpense as updatePeriodicExpenseApi,
createWallet as createWalletApi,
updateWallet as updateWalletApi,
deleteWallet as deleteWalletApi,
transferWallet as transferWalletApi,
} from "./api"; } from "./api";
export const quoteKeys = { export const quoteKeys = {
@@ -295,6 +307,96 @@ export function useUpdateExpense() {
}); });
} }
// Wallets
export const walletKeys = {
all: ["wallets"] as const,
movements: (id: number) => ["wallets", id, "movements"] as const,
};
export function useWallets() {
return useQuery({
queryKey: walletKeys.all,
queryFn: getWallets,
staleTime: 30_000,
});
}
export function useCreateWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWalletInput) => createWalletApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useUpdateWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: { name: string; currency: string };
}) => updateWalletApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useDeleteWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteWalletApi(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useDepositWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: DepositInput }) =>
depositWalletApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useAdjustBalance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: AdjustBalanceInput }) =>
adjustBalanceApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useTransferWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TransferInput) => transferWalletApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useWalletMovements(id: number) {
return useQuery({
queryKey: walletKeys.movements(id),
queryFn: () => getWalletMovements(id),
});
}
// 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,
});