feat(wallets): implement wallet management features including create, update, delete, deposit, transfer, and list movements
- Added createWallet, updateWallet, deleteWallet, depositWallet, transferWallet, and listMovements handlers. - Created corresponding routes for wallet operations. - Developed frontend components for wallet management including dialogs for creating, editing, depositing, adjusting balance, transferring, and viewing movements. - Integrated wallet management into the authenticated routes.
This commit is contained in:
@@ -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;
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" }));
|
||||||
|
|||||||
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal 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);
|
||||||
|
}
|
||||||
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal file
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal 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);
|
||||||
|
}
|
||||||
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal 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);
|
||||||
|
}
|
||||||
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal 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);
|
||||||
|
}
|
||||||
22
apps/backend/src/modules/wallets/wallets.routes.ts
Normal file
22
apps/backend/src/modules/wallets/wallets.routes.ts
Normal 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;
|
||||||
164
apps/backend/src/modules/wallets/wallets.service.ts
Normal file
164
apps/backend/src/modules/wallets/wallets.service.ts
Normal 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) }));
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
42
apps/frontend/src/features/wallets/WalletsPage.tsx
Normal file
42
apps/frontend/src/features/wallets/WalletsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/frontend/src/features/wallets/components/DepositDialog.tsx
Normal file
128
apps/frontend/src/features/wallets/components/DepositDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
apps/frontend/src/features/wallets/components/TransferDialog.tsx
Normal file
211
apps/frontend/src/features/wallets/components/TransferDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/frontend/src/features/wallets/components/WalletCard.tsx
Normal file
154
apps/frontend/src/features/wallets/components/WalletCard.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
6
apps/frontend/src/routes/_authenticated/wallets.tsx
Normal file
6
apps/frontend/src/routes/_authenticated/wallets.tsx
Normal 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,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user