diff --git a/apps/backend/prisma/migrations/20260617122516_add_wallets/migration.sql b/apps/backend/prisma/migrations/20260617122516_add_wallets/migration.sql new file mode 100644 index 0000000..03b8e6e --- /dev/null +++ b/apps/backend/prisma/migrations/20260617122516_add_wallets/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index a6d1320..78feb51 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -129,6 +129,35 @@ enum PaymentStatus { 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 { id Int @id @default(autoincrement()) description String @db.VarChar(50) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 2822e17..bc7bd65 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -12,6 +12,7 @@ import expensesRouter from "./modules/expenses/expenses.routes"; import { startQuoteJob } from "./modules/quotes/quote.job"; import quotesRouter from "./modules/quotes/quotes.routes"; import telegramRouter from "./modules/telegram/telegram.router"; +import walletsRouter from "./modules/wallets/wallets.routes"; const app = new Hono(); @@ -33,6 +34,7 @@ app.use("/api/*", async (c, next) => { app.route("/api/quotes", quotesRouter); app.route("/api", expensesRouter); app.route("/api/telegram", telegramRouter); +app.route("/api/wallets", walletsRouter); app.use("/assets/*", serveStatic({ root: "./web" })); app.get("*", serveStatic({ path: "./web/index.html" })); diff --git a/apps/backend/src/modules/wallets/handlers/adjustBalance.ts b/apps/backend/src/modules/wallets/handlers/adjustBalance.ts new file mode 100644 index 0000000..519cd6b --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/adjustBalance.ts @@ -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); +} diff --git a/apps/backend/src/modules/wallets/handlers/createWallet.ts b/apps/backend/src/modules/wallets/handlers/createWallet.ts new file mode 100644 index 0000000..30dc3c7 --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/createWallet.ts @@ -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); +} diff --git a/apps/backend/src/modules/wallets/handlers/deleteWallet.ts b/apps/backend/src/modules/wallets/handlers/deleteWallet.ts new file mode 100644 index 0000000..33c3945 --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/deleteWallet.ts @@ -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 }); +} diff --git a/apps/backend/src/modules/wallets/handlers/depositWallet.ts b/apps/backend/src/modules/wallets/handlers/depositWallet.ts new file mode 100644 index 0000000..eff72eb --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/depositWallet.ts @@ -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); +} diff --git a/apps/backend/src/modules/wallets/handlers/listMovements.ts b/apps/backend/src/modules/wallets/handlers/listMovements.ts new file mode 100644 index 0000000..8f3e75e --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/listMovements.ts @@ -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); +} diff --git a/apps/backend/src/modules/wallets/handlers/listWallets.ts b/apps/backend/src/modules/wallets/handlers/listWallets.ts new file mode 100644 index 0000000..cec41f4 --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/listWallets.ts @@ -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); +} diff --git a/apps/backend/src/modules/wallets/handlers/transferWallet.ts b/apps/backend/src/modules/wallets/handlers/transferWallet.ts new file mode 100644 index 0000000..51c9acb --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/transferWallet.ts @@ -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); +} diff --git a/apps/backend/src/modules/wallets/handlers/updateWallet.ts b/apps/backend/src/modules/wallets/handlers/updateWallet.ts new file mode 100644 index 0000000..3cf9dae --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/updateWallet.ts @@ -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); +} diff --git a/apps/backend/src/modules/wallets/wallets.routes.ts b/apps/backend/src/modules/wallets/wallets.routes.ts new file mode 100644 index 0000000..0915689 --- /dev/null +++ b/apps/backend/src/modules/wallets/wallets.routes.ts @@ -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; diff --git a/apps/backend/src/modules/wallets/wallets.service.ts b/apps/backend/src/modules/wallets/wallets.service.ts new file mode 100644 index 0000000..26ef894 --- /dev/null +++ b/apps/backend/src/modules/wallets/wallets.service.ts @@ -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; +export type UpdateWalletInput = z.infer; +export type DepositInput = z.infer; +export type AdjustBalanceInput = z.infer; +export type TransferInput = z.infer; + +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) })); +} diff --git a/apps/frontend/src/components/layout/Sidebar.tsx b/apps/frontend/src/components/layout/Sidebar.tsx index 5e61130..40a4ec5 100644 --- a/apps/frontend/src/components/layout/Sidebar.tsx +++ b/apps/frontend/src/components/layout/Sidebar.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; import { FileText, + Landmark, LayoutDashboard, Settings, TrendingUp, @@ -11,6 +12,7 @@ import { cn } from "@/lib/utils"; const navItems = [ { to: "/", label: "Panel", icon: LayoutDashboard }, { to: "/expenses", label: "Gastos", icon: Wallet }, + { to: "/wallets", label: "Billeteras", icon: Landmark }, { to: "/quotes", label: "Cotizaciones", icon: FileText }, { to: "/quotes/belo", label: "BELO", icon: TrendingUp }, ]; diff --git a/apps/frontend/src/features/dashboard/DashboardPage.tsx b/apps/frontend/src/features/dashboard/DashboardPage.tsx index b0c47df..6961e98 100644 --- a/apps/frontend/src/features/dashboard/DashboardPage.tsx +++ b/apps/frontend/src/features/dashboard/DashboardPage.tsx @@ -12,6 +12,7 @@ import { useMonthlyPayedTotal, useMonthlyTotals, useQuotes, + useWallets, } from "@/lib/queries"; import { RelativeTime } from "@/lib/time"; import { DashboardExpensesTable } from "./components/DashboardExpensesTable"; @@ -141,6 +142,13 @@ export function DashboardPage() { const isFetching = isLoading || isPending; + const { data: wallets } = useWallets(); + + const walletBalanceFormatter = new Intl.NumberFormat("es-AR", { + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }); + return (
@@ -189,6 +197,25 @@ export function DashboardPage() { variant="minimal" />
+ {wallets && wallets.length > 0 && ( +
+

Billeteras

+
+ {wallets.map((w) => ( +
+

{w.name}

+

{w.currency}

+

+ {walletBalanceFormatter.format(w.balance)} +

+
+ ))} +
+
+ )}
); diff --git a/apps/frontend/src/features/wallets/WalletsPage.tsx b/apps/frontend/src/features/wallets/WalletsPage.tsx new file mode 100644 index 0000000..d1d6e88 --- /dev/null +++ b/apps/frontend/src/features/wallets/WalletsPage.tsx @@ -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 ( +
+
+

Billeteras

+ +
+ + {isLoading ? ( +

Cargando...

+ ) : wallets && wallets.length > 0 ? ( +
+ {wallets.map((wallet) => ( + + ))} +
+ ) : ( +

+ No hay billeteras. Creá una para empezar. +

+ )} + + +
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/AdjustBalanceDialog.tsx b/apps/frontend/src/features/wallets/components/AdjustBalanceDialog.tsx new file mode 100644 index 0000000..99bc250 --- /dev/null +++ b/apps/frontend/src/features/wallets/components/AdjustBalanceDialog.tsx @@ -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; + +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({ + 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 ( + + + + + Ajustar saldo de {wallet.name} + + + +

+ Saldo actual: {wallet.balance} +

+ +
+
+ + + {errors.newBalance && ( + + {errors.newBalance.message} + + )} +
+ +
+ + + {errors.description && ( + + {errors.description.message} + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/CreateWalletDialog.tsx b/apps/frontend/src/features/wallets/components/CreateWalletDialog.tsx new file mode 100644 index 0000000..6545682 --- /dev/null +++ b/apps/frontend/src/features/wallets/components/CreateWalletDialog.tsx @@ -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; + +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({ + 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 ( + + + + Nueva billetera + + +
+
+ + + {errors.name && ( + + {errors.name.message} + + )} +
+ +
+ + + {errors.currency && ( + + {errors.currency.message} + + )} +
+ +
+ + + {errors.initialBalance && ( + + {errors.initialBalance.message} + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/DepositDialog.tsx b/apps/frontend/src/features/wallets/components/DepositDialog.tsx new file mode 100644 index 0000000..bfc1d99 --- /dev/null +++ b/apps/frontend/src/features/wallets/components/DepositDialog.tsx @@ -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; + +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({ + 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 ( + + + + + Depositar en {wallet.name} + + + +
+
+ + + {errors.amount && ( + + {errors.amount.message} + + )} +
+ +
+ + + {errors.description && ( + + {errors.description.message} + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/EditWalletDialog.tsx b/apps/frontend/src/features/wallets/components/EditWalletDialog.tsx new file mode 100644 index 0000000..8b722cd --- /dev/null +++ b/apps/frontend/src/features/wallets/components/EditWalletDialog.tsx @@ -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; + +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({ + 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 ( + + + + Editar billetera + + +
+
+ + + {errors.name && ( + + {errors.name.message} + + )} +
+ +
+ + + {errors.currency && ( + + {errors.currency.message} + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/TransferDialog.tsx b/apps/frontend/src/features/wallets/components/TransferDialog.tsx new file mode 100644 index 0000000..4663f36 --- /dev/null +++ b/apps/frontend/src/features/wallets/components/TransferDialog.tsx @@ -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; + +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({ + 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 ( + + + + + Transferir desde {sourceWallet.name} + + + +

+ Saldo disponible: {sourceWallet.balance} +

+ +
+
+ + ( + + )} + /> + {errors.toWalletId && ( + + {errors.toWalletId.message} + + )} +
+ +
+ + + {errors.outgoingAmount && ( + + {errors.outgoingAmount.message} + + )} +
+ +
+ + + {errors.incomingAmount && ( + + {errors.incomingAmount.message} + + )} + + Si hay comisiones o diferencia de cotización, el monto entrante + puede ser menor al saliente. + +
+ +
+ + + {errors.description && ( + + {errors.description.message} + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/WalletCard.tsx b/apps/frontend/src/features/wallets/components/WalletCard.tsx new file mode 100644 index 0000000..04cea92 --- /dev/null +++ b/apps/frontend/src/features/wallets/components/WalletCard.tsx @@ -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 ( + <> +
+
+
+

{wallet.name}

+

{wallet.currency}

+
+ +
+ +

+ {balanceFormatter.format(wallet.balance)} +

+ +
+ + + + + + Depositar + + + + + + + + + Ajustar saldo + + + + + + + + + Transferir + + + + + + + + + Movimientos + + +
+
+ + + + + + + + ); +} diff --git a/apps/frontend/src/features/wallets/components/WalletMovementsDialog.tsx b/apps/frontend/src/features/wallets/components/WalletMovementsDialog.tsx new file mode 100644 index 0000000..d1e532a --- /dev/null +++ b/apps/frontend/src/features/wallets/components/WalletMovementsDialog.tsx @@ -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 = { + 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 ( + + + + + Movimientos de {wallet.name} + + + + {isLoading ? ( +

Cargando...

+ ) : movements && movements.length > 0 ? ( +
+ {movements.map((m) => ( +
+
+ {typeLabels[m.type] ?? m.type} + {m.description && ( + + {m.description} + + )} + {m.transferGroupId && ( + + ID: {m.transferGroupId.slice(0, 8)}... + + )} + + {dateFormatter.format(new Date(m.createdAt))} + +
+ = 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-red-600 dark:text-red-400" + }`} + > + {m.amount >= 0 ? "+" : ""} + {amountFormatter.format(m.amount)} + +
+ ))} +
+ ) : ( +

+ Sin movimientos registrados. +

+ )} +
+
+ ); +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index ad725ab..b2ef702 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -320,6 +320,101 @@ export function importPeriodicExpenses(file: File): Promise { }); } +// 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 { + return fetcher("/api/wallets"); +} + +export function createWallet(data: CreateWalletInput): Promise { + return mutator("/api/wallets", "POST", data); +} + +export function updateWallet( + id: number, + data: { name: string; currency: string }, +): Promise { + return mutator(`/api/wallets/${id}`, "PUT", data); +} + +export function deleteWallet(id: number): Promise { + return mutator(`/api/wallets/${id}`, "DELETE"); +} + +export function depositWallet( + id: number, + data: DepositInput, +): Promise { + return mutator(`/api/wallets/${id}/deposit`, "POST", data); +} + +export function adjustBalance( + id: number, + data: AdjustBalanceInput, +): Promise { + return mutator(`/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 { + return fetcher(`/api/wallets/${id}/movements`); +} + // Telegram export type TelegramStatus = { diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 0d7747c..0a6ba25 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -7,9 +7,15 @@ import { import { type CreateNonPeriodicExpenseInput, type CreatePeriodicExpenseInput, + type AdjustBalanceInput, + type CreateWalletInput, + type DepositInput, + type TransferInput, createNonPeriodicExpense as createNonPeriodicExpenseApi, createPeriodicExpense as createPeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi, + depositWallet as depositWalletApi, + adjustBalance as adjustBalanceApi, fetchQuotes, generateMonthlyExpense as generateMonthlyExpenseApi, getDailyMinMax, @@ -25,6 +31,8 @@ import { getQuotes, getTelegramStatus, getTotalPending, + getWalletMovements, + getWallets, importExpenses, importPeriodicExpenses, type PayExpenseInput, @@ -32,6 +40,10 @@ import { type UpdateExpenseInput, updateExpense as updateExpenseApi, updatePeriodicExpense as updatePeriodicExpenseApi, + createWallet as createWalletApi, + updateWallet as updateWalletApi, + deleteWallet as deleteWalletApi, + transferWallet as transferWalletApi, } from "./api"; 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 export function useTelegramStatus() { diff --git a/apps/frontend/src/routes/_authenticated/wallets.tsx b/apps/frontend/src/routes/_authenticated/wallets.tsx new file mode 100644 index 0000000..fe59da6 --- /dev/null +++ b/apps/frontend/src/routes/_authenticated/wallets.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { WalletsPage } from "@/features/wallets/WalletsPage"; + +export const Route = createFileRoute("/_authenticated/wallets")({ + component: WalletsPage, +});